修复并发场景下 Token 验证竞态漏洞,核心是将“校验”与“失效”操作合并为原子操作,或使用分布式锁确保互斥访问。适用高并发认证、验证码核验、单点登录场景,风险边界在于锁粒度不当可能引发性能下降或死锁。
先说结论:必须消除校验与状态变更之间的时间窗口,依赖数据库行锁、Redis 原子命令或分布式锁实现互斥。
- 先判断:确认业务是否允许 Token 多次使用,单次使用场景必须强制原子失效。
- 优先做:将“查询状态 + 更新状态”逻辑合并为一条 SQL 或 Lua 脚本执行。
- 再验证:通过并发请求测试确认同一 Token 无法被第二次成功验证。
快速处理思路
此类问题无法通过单一命令修复,需修改代码逻辑。优先采用数据库乐观锁或 Redis 原子操作,避免使用应用层锁。
- 数据库方案:使用 UPDATE 语句带 WHERE 条件,影响行数作为判断依据。
- 缓存方案:使用 Redis SETNX 或 Lua 脚本保证检查与删除原子性。
- 锁方案:仅在跨服务协调时使用分布式锁,注意设置超时时间防止死锁。
为什么会这样
竞态条件产生的根本原因是“检查”与“执行”两个动作不原子,中间存在时间窗口。
当多个请求同时到达时,服务端先查询 Token 状态为有效,随后多个请求均通过校验,最后才执行失效操作。在查询完成到失效执行之间的毫秒级间隔内,并发请求均可绕过限制。公开资料中没有看到可靠的量化数据说明具体时间窗口大小,但该窗口足以被自动化脚本利用。
分步处理
按业务场景选择方案,修改核心验证逻辑,确保状态变更不可逆且互斥。
1. 数据库乐观锁方案
适用于 Token 状态存储在关系型数据库的场景。利用 UPDATE 返回值判断是否竞争成功。
配置片段示例:
UPDATE token_table SET status = 'used', version = version + 1 WHERE token = 'xxx' AND status = 'unused';
检查点:代码需判断 affected_rows 是否大于 0,若为 0 则表示竞争失败或 Token 已失效。
2. Redis 原子脚本方案
适用于高频验证场景,利用 Lua 脚本在 Redis 服务端原子执行。
逻辑片段:先 GET 判断存在性,若存在则 DEL 并返回成功,否则返回失败。整个过程在 Redis 单线程中执行。
检查点:确保脚本内无延时操作,避免阻塞 Redis 主线程。
3. 分布式锁兜底方案
适用于跨多实例部署且无法修改存储结构的场景。在验证逻辑外层加锁。
操作动作:获取锁 -> 验证 Token -> 释放锁。锁 key 应绑定 Token 值。
风险边界:必须设置锁过期时间,防止服务宕机导致锁无法释放。
怎么验证是否生效
通过并发测试工具模拟多请求同时提交同一 Token,观察服务端响应和数据库状态。
- 使用压测工具(如 JMeter、ab)发送 10 个以上并发请求,携带相同 Token。
- 检查日志:仅允许一个请求返回成功,其余应返回 Token 失效或重复使用错误。
- 检查数据:数据库中该 Token 状态应仅变更一次,无脏数据。
常见坑
- 应用层锁无效:多进程或多实例部署时,内存锁无法跨进程互斥。
- 事务隔离级别:数据库事务若隔离级别过低,仍可能出现幻读导致竞态。
- 锁粒度太粗:全局锁会严重降低系统吞吐量,锁应精确到 Token 级别。
- 异常未释放锁:代码抛出异常时需确保 finally 块中释放锁,避免死锁。
常见问题
在代码里加 sleep 延时能解决吗?
不能。延时只能缩小时间窗口,无法消除竞态条件,攻击者仍可通过重试绕过。
数据库事务一定能解决吗?
不一定。需配合行锁或乐观锁机制,仅开启事务而无非冲突检测逻辑仍可能失效。
Redis 删除 Key 后怎么防止重放?
删除即失效,若需防重放需结合请求签名或时间戳,确保请求不可复用。