name: race-condition description: 竞态条件 / TOCTOU 检测 — 检测限量资源操作(优惠券领取、积分兑换、余额操作、限量抢购、状态切换)在并发请求下是否绕过业务限制;适用于检查-修改非原子的业务流程。 when-to-use: 当目标存在限量资源操作(优惠券领取、积分兑换、余额操作、限量抢购),并发请求可能绕过业务限制时 allowed-tools: bash,read_file,list_files,rg user-invocable: false
竞态条件 / TOCTOU 检测
成因引用
竞态条件成因:source(并发请求)→ sink(共享资源:库存 / 余额 / 限量优惠 / 数量字段 / 状态机)。"检查→执行"两步非原子,多并发请求都通过检查后才写入,业务命名不可作筛选——无论端点叫"优惠券领取"、"余额扣减"、"积分兑换"、"限量抢购"、"状态切换"、"一次性 token 使用",sink 语义都是"对共享有限资源做检查-修改",属同一范围。详见同根目录 pentest/web-security-testing/SKILL.md 漏洞成因图谱 · 竞态条件 / TOCTOU 行(不在本 skill 重复成因)。
关键 sink 形态:服务端在"读取当前状态 → 校验是否允许 → 写入新状态"三步中,未用原子操作(事务 + 行锁 / CAS / 唯一约束 / 分布式锁)保证,导致并发请求可在同一窗口都通过检查、都执行写入——产生超卖、超发、透支、状态错乱等真实业务损失。
触发线索(基线检查项)
以下是已知的常见竞态触发线索,作为基线起点而非必检硬清单。结合目标代码与上下文动态调整:
- 适用且已完成 → 标注
[x] done - 明确不适用 → 标注
[-] n/a (原因),原因要具体到代码事实 - 基线未列出但实际发现 → 新增条目并标注
[+] added (来源)
基线触发线索按"sink 语义"分类(不按业务命名):
- 优惠券 / 红包 / 礼品码领取:一人限领一次 / 总量限制的领取入口
- 限量抢购 / 秒杀 / 库存扣减:SKU 库存为整型字段、下单先 select 再 update 形态
- 余额 / 积分扣减:钱包支付、积分兑换、虚拟资产消费
- 一次性 token / 验证码核销:邀请码 / 激活码 / OTP 一次性使用
- 状态机切换:订单
pending→paid/pending→cancel、审批submit→approve等单向状态切换 - 投票 / 点赞 / 关注:一人一票、一次性点赞、关注数计数
- 签到 / 任务领奖:每日仅一次的签到、任务完成领奖
- 跨子系统覆盖:多个子系统各自有独立限量资源逻辑——每个子系统都需独立测
- 代码模式:后端代码出现
if balance >= amount { balance -= amount }/if claimed == 0 { insert claim record }/SELECT stock FROM ... ; UPDATE stock = stock - 1非事务 / 无行锁形态
思考检查点
加载本 skill 时按这些问题思考:
- 这个端点是否对某共享有限资源做"读 → 判 → 写"?三步是否在同一事务、同一行锁内?
- 事务隔离级别是 RC 还是 RR?应用层 SELECT + UPDATE 分两步是否仍有 TOCTOU?
- 资源字段是否带唯一约束 / CHECK 约束 / 应用层 CAS?还是只靠应用层 if 判断?
- 状态机切换是否依赖"当前状态 == X 才能切到 Y",且未用
UPDATE ... WHERE state=X这类 CAS 形态? - 同一类 sink("共享资源 check-then-write")的其他端点是否也同模式?跨子系统是否独立测过?
前置条件与安全边界
- 仅在授权环境测试;并发请求数控制在 5~20 个,不造成服务过载。
- 测试后检查并清理异常数据(多领的优惠券、多扣的余额、错乱的状态等)。
- 并发操作优先在自己持有的哨兵资源上进行(自有账号 / 自建优惠券 / 自建 SKU / 自有余额)。若竞态效果会不可逆地破坏共享真实数据(如真实库存超卖、他人数据被毁)且无法用哨兵承接,按
common/closure-verification.md的《破坏性 / 不可逆动作的闭环边界》执行——改走非破坏差分,做不到就停suspected,不得靠破坏真实业务数据来闭环。 - 使用 HTTP/2 单一数据包攻击(Single Packet Attack)时注意服务器兼容性。
检测步骤
Step 1:目标操作识别
识别存在"检查 → 执行"两步模式的业务操作:
- 检查:验证前置条件(余额充足、未领取过、库存足够、状态为
pending) - 执行:实际写入(扣款、发放、减库存、切状态)
- 两步之间存在时间窗口 = 竞态条件机会
Step 2:基线采集
- 正常执行一次目标操作,记录请求和响应。
- 确认前置条件(如初始余额、库存数量、当前状态)。
- 确认操作后的状态变化(余额减少、库存减少、状态切换)。
Step 3:并发请求发送
使用以下策略发送并发请求:
- 策略 1:简单并发 — 用并发工具发送 5~10 个相同请求,检查是否有多个请求都返回成功。
- 策略 2:HTTP/2 单一数据包攻击 — 将多个请求封装在同一个 TCP 数据包中发送,消除网络延迟差异;这是最精确的竞态测试方法。
- 策略 3:条件竞争边界 — 准备刚好满足条件的场景(余额 = 1 次消费金额、库存 = 1 件、邀请码剩 1 次),并发发送 2~5 个请求,检查是否出现透支 / 超卖 / 多核销。
并发操作须在自有哨兵资源上进行。若证明 confirmed 必然不可逆破坏真实/共享数据(如真实库存超卖、他人资源被毁),按
common/closure-verification.md《破坏性 / 不可逆动作的闭环边界》改走非破坏差分,做不到则降suspected,不得靠破坏真实数据闭环。
Step 4:结果验证 + 跨端点 / 跨子系统传播
- 统计成功响应数量。
- 回读实际业务状态:
- 余额是否为负(透支)
- 优惠券是否多领
- 库存是否为负 / 超出总量
- 状态机是否到达不可达组合 / 同一记录被双重切换
- 与预期单次执行结果对比,排除幂等导致的"假成功"。
- 若该端点确认存在竞态 → 按"端点矩阵传播"横扫同子系统其他限量资源 + 跨子系统的同类业务。
示例库
正例形态(代码层根因)
- 领取优惠券:
SELECT count FROM claim WHERE user_id=? AND coupon_id=?→ if 0 →INSERT claim ...,两步无事务、claim 表无UNIQUE(user_id, coupon_id)约束,可并发多领(coupon-claim-check-then-insert-race) - 余额扣减:
SELECT balance FROM wallet WHERE uid=?→ if balance >= amount →UPDATE wallet SET balance=balance-? WHERE uid=?,应用层判断 + 后续 update,可并发透支至负数(balance-deduct-select-then-update-race) - 库存扣减:
SELECT stock FROM sku WHERE id=?→ if stock > 0 →UPDATE sku SET stock=stock-1,未使用UPDATE ... WHERE stock > 0这类原子条件,并发可超卖(stock-decrement-non-atomic-oversell-race) - 状态机切换:
SELECT status FROM order WHERE id=?→ if status='pending' →UPDATE order SET status='paid',未使用UPDATE ... WHERE status='pending',可被并发两次切换(state-transition-non-atomic-double-spend-race)
窄化反例(必须避免)
以下是竞态维度的典型窄化误判:
- "看起来有事务 → 安全" — 错。事务隔离级别(RC vs RR)下,应用层 SELECT + UPDATE 仍可能在两条独立 SQL 之间观察到旧值;只有
SELECT ... FOR UPDATE或UPDATE ... WHERE <条件>这类持锁 / 原子 CAS 才真正消除窗口。 - "已测了某接口 → 跳过其他限量资源" — 错。每种限量资源(优惠券 / 余额 / 库存 / 状态机 / 一次性 token / 投票 / 签到)都是独立 sink,必须按端点账本逐个测。
- "限量字段是 int → 不会负数" — 错。Go / Java 等有符号 int 会在边界回绕;DB 字段定义为 unsigned 也可能在应用层被先减再写入,应用层把负值当成 0 处理也会形成"看似无负数实则超发"。
- "已在子系统 A 命中竞态 → B 假定同样" — 错。跨子系统的限量资源逻辑独立实现,必须按子系统独立结账。
- "看起来并发量小 → 不会触发" — 错。HTTP/2 单连接多路复用、攻击者本地多协程、Single Packet Attack 都能轻松构造高密集并发;竞态不取决于"业务真实并发量",只取决于服务端是否原子。
反例义务(必须遵守)
为什么这里是「必须」:反例义务属于交付契约——"该子系统竞态已防护"结论是覆盖完整性的产物声明,缺失反向验证清单会让下游误信"该维度全站安全"。
写"未发现竞态 / TOCTOU"或"已防护"前,产物必须包含:
- 测过的竞态候选端点完整清单(按 sink 语义枚举:所有"对共享有限资源做检查-修改"的端点;含优惠券领取 / 余额扣减 / 库存扣减 / 状态机切换 / 一次性 token / 投票 / 签到等全部触发线索类别)
- 每个端点测过的并发策略(简单并发 / HTTP/2 Single Packet / 条件竞争边界)
- 每个端点的回读状态证据(基线状态 / 攻击后状态 / 差异点 / 是否原子)
清单不完整 → 结论降级为 partial-coverage 并显式声明未覆盖范围。
特别警示:只测了"优惠券领取"而未覆盖"余额扣减 / 库存 / 状态机切换"等其他限量资源,不能下"全站无竞态"结论。
闭环验证要求(必须遵守)
通用闭环口径见同根目录 common/closure-verification.md(技能表 path 列同一抽取根下,需要时 read_file 读取)。核心:结论须形成「输入 → 处理 → 真实危害 → 可复核证据」完整证据链;仅凭"多个请求返回 200"等中间信号最多判 suspected,证明实际业务状态发生了超出预期的变化(余额负值 / 多领 / 超卖 / 状态错乱)才判 confirmed。本漏洞特有要点:
- 仅凭"多个请求返回 200"不得判定 confirmed,必须回读业务状态确认超出预期的变化。
- 需排除"幂等操作"(即使多次请求成功,但只有一次生效)——通过差分基线状态与攻击后状态确认有效写入次数。
- 不可逆动作例外:若 confirmed 必须靠不可逆破坏真实 / 共享数据才能证明,按
common/closure-verification.md《破坏性 / 不可逆动作的闭环边界》——优先自有哨兵资源或非破坏差分,做不到则降suspected。
判定标准
| 现象 | 判定 |
|---|---|
| 并发请求导致业务状态超出预期(余额透支、多领、超卖、状态错乱),通过回读确认 | confirmed |
| 多个请求返回成功但无法确认实际业务影响(如无法回读状态 / 区分幂等) | suspected |
| 并发请求仅有一个成功或业务状态正常(正确的原子操作) | not vulnerable |
| 只测了部分子系统 / 部分限量资源 / 部分并发策略 | partial-coverage(不得宣称 safe) |
上表 confirmed 若需不可逆破坏真实/共享数据才能证明,按
common/closure-verification.md《破坏性 / 不可逆动作的闭环边界》——优先自有哨兵或非破坏差分,做不到则降suspected。
修复建议
- 使用数据库事务 + 行锁(
SELECT ... FOR UPDATE)保证检查与写入原子。 - 使用乐观锁(版本号 / CAS):
UPDATE ... SET stock=stock-1 WHERE id=? AND stock>0这类原子条件。 - 对关键操作使用分布式锁(Redis SETNX / Redlock / Zookeeper)。
- 使用数据库唯一约束(如
UNIQUE(user_id, coupon_id))防止重复领取。 - 状态机切换使用 CAS:
UPDATE ... SET status='paid' WHERE id=? AND status='pending'并检查 affected rows。 - 对计数 / 余额字段使用
UPDATE ... SET balance=balance-? WHERE balance>=?,避免应用层判断后写入。