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