Time-of-check Time-of-use (TOCTOU) 竞争条件导致身份验证漏洞
作者:Irsyad Muhammad Fawwaz
我是如何开始的
我感到无聊,开始随机测试公共漏洞赏金项目。像往常一样,我从子域名收集开始,以缩小攻击面。我的快速漏洞赏金狩猎流程是:
subfinder -d example.com --all >> subdomain.txt
然后通过aquatone进行处理:
cat subdomain.txt | aquatone
之后我找到了一个有趣的子域名,它立即重定向到了登录/注册页面。
发现过程
我花了四天时间希望找到漏洞,使用常规测试(如XSS、SQL注入、扫描、原型污染等)和暴力破解实验对网站进行测试。
在四天的测试中,我观察到异常行为:网站在某些时候极其缓慢且无响应,而在其他时候则完全正常。
看到这两种行为,我在网站缓慢期间和快速期间分别运行了登录暴力破解测试。
在正常时间,一切行为正常:错误密码返回401,正确密码返回200和令牌。
但在一天中的某些特定时间,网站会变得极其缓慢。在进行登录或注册时不是"有点慢",而是完全卡顿。
然后我意识到是服务器过载了。
所以我尝试在快速窗口和缓慢窗口期间再次进行暴力破解。
在快速窗口期间,结果很无聊且符合预期:除了正确密码外,所有请求都被拒绝。
在缓慢窗口期间,发生了奇怪的事情。我找到了五个不同的密码,它们都返回200并给了我有效令牌,但这些密码都不是真正的密码。
起初我以为我看错了,但我验证了这些令牌对受保护端点的访问,它们确实有效。
发现原因
发现问题后,我发现这不是魔法。这是一个TOCTOU竞争条件。简单来说,检查登录是否允许的函数和实际发放令牌的部分没有同步。如果认证函数比传入请求慢,多个请求可以在系统更新状态之前通过检查,因此几个不同的错误密码看起来都"有效",因为请求超过了函数的内部计时。
实际发生了什么
将登录流程想象成两条街道:
- “检查"街道
- “使用"街道
代码首先沿着检查街道走,说"好的,这个账户现在看起来被允许”,然后沿着使用街道走,说"好的,我将创建会话/令牌”。
如果这两个步骤连续发生,一切都能完美工作。
但如果检查步骤很慢(CPU负载重、数据库慢、I/O阻塞、服务器过载),多个传入请求会在它后面排队。它们都在令牌创建步骤完成之前通过了初始检查。结果是几个请求到达了令牌生成步骤,尽管密码验证本应失败。
简而言之:当认证函数比传入请求速率慢时,请求可以超越内部状态并产生不一致的结果。
代码层面的原因
核心问题是一个非原子序列:
- 代码检查某些条件(密码、速率限制、账户状态)
- 之后基于该条件发放令牌
但在步骤1和步骤2之间,条件可能发生变化。
由于没有每个账户的锁或原子操作,多个并发请求都可以在状态更新之前通过检查步骤。其他常见根本原因包括:
- 在缓存中使用GET→SET模式(非原子)
- 缺乏Redis原子操作/Lua脚本
- 在没有同步的情况下将验证卸载到异步工作器
- 在数据库事务中不使用SELECT … FOR UPDATE
- 高并发下的陈旧读取
TL;DR: 数据库延迟在登录流程中创建了一个竞争窗口:密码检查比传入请求运行得慢,让多个错误密码到达令牌创建步骤。结果是为无效登录发放了真实令牌。