关于 hunt-race-condition
hunt-race-condition 是用于发现和验证 Web 应用竞态条件的安全测试技能,介绍同步请求方法、目标识别、影响检查及复现评估。
主要功能
- 01相同与不同请求的竞态模式
- 02同步并行请求方法
- 03待检查的端点与代码信号
- 04响应及状态变化分析
- 05可利用性与复现性检查
适用场景
- 01测试优惠券、额度和支付流程是否重复生效
- 02检查投票与配额是否可并发绕过
- 03评估账户和权限流程中的 TOCTOU 问题
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ elementalsouls/ claude-bughunter --skill hunt-race-condition安装后在 Claude Code 中输入/hunt-race-condition确认已加载。
详细介绍
触发竞争条件 — 两种原语(与工具无关)
要赢得竞争,必须让请求在同一个狭窄的时间窗口内到达——顺序发送永远行不通。请使用单数据包/同步发送工具:Burp Repeater“Send group in parallel”(HTTP/2 单数据包攻击)、Turbo Intruder(engine=Engine.BURP2、gate 同步),或任何能同时刷新 N 个请求的客户端。两种形式:
- 相同请求副本竞争 — 同时发送某个请求的 N 个完全相同副本(超限漏洞:重复使用优惠券/礼品卡,突破每位用户仅限一次的配额)。成功标志 = N 个请求中至少有 2 个返回 2xx。
- 不同请求竞争(部分构造) — 在一个同步窗口中发送一组不同的请求,并重复多轮。适用于先注册再确认/TOCTOU 竞争,即对象在创建过程中曾处于可用状态。示例(绕过电子邮件验证——注册任意电子邮件地址,然后在构造窗口内使用空令牌确认):
先从Request A: POST /register body: csrf=<csrf>&username=hacker&[email protected]&password=pw Request B: GET /confirm params: token= (empty) Fire A and B together, repeat ~20 rounds.GET /register获取新的 CSRF,再发送请求批次。成功后,以新账户登录并执行目标操作(例如删除用户等会更改状态的管理员操作)。当用户记录已存在但验证令牌尚未设置时,空令牌确认请求会在此窗口内抢先成功。
皇冠明珠目标
竞争条件属于高严重性漏洞,因为它们会破坏金融、访问控制和完整性假设,而防御人员很少对此进行压力测试。赏金最高的目标包括:
- 货币/额度系统 — 重复消费礼品卡、优惠券、推荐奖励、促销额度、钱包余额
- 投票/声望操纵 — 对同一内容重复点赞,操纵排行榜或热门算法
- 绕过账户限制 — 超出免费套餐配额,绕过邀请、试用激活或 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();
技术栈信号
- Ruby on Rails 未使用
with_lock/lock!— ActiveRecord 默认不会加锁 - Node.js 使用 async/await 链 — 先读取数据库再写入,且操作非原子
- PHP 未使用
SELECT ... FOR UPDATE— 在旧代码库中很常见 - 微服务 — 服务间调用会产生自然的 TOCTOU 窗口
- Redis 计数器 未使用 Lua 脚本或
INCR原子性检查 - 消息队列 — 通常缺少幂等性密钥
分步狩猎方法
-
枚举一次性或有限次数的操作 — 梳理所有强制执行“每位用户仅限一次”“数量有限”或“扣减余额”限制的端点。这些是你的主要目标。
-
了解状态机 — 对每个目标操作,确定:(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 -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 -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 年扩展)——正是这项技术让如今大多数现代竞态攻击得以实现。
-
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 的促销活动;打开同一商家的两个支付链接标签页,在两处都使用优惠券,然后同时点击 Pay → 两次操作均成功
- 根本原因:兑换计数器在扣款后才递增,并未与扣款原子执行;
promotion_code.times_redeemed没有行级锁 - 年份:2022 — $250
-
Stripe — 手续费折扣被多次兑换(H1 #1849626)
- 子类:钱包/余额双重消费(Connect 手续费折扣可被反复兑换)
- 载荷:在一次性促销额度的已用尽标志更新之前,并行向兑换端点发送 POST 请求
- 根本原因:对额度余额对象的检查与扣减并非原子操作
- 年份:2023 — $5,000,每次兑换造成约 $600 的平台手续费损失
-
Reverb.com — 礼品卡多次兑换(H1 #759247)
- 子类:优惠券重复兑换(礼品卡)
- 载荷:捕获
POST /gift_cards/redeem→ 复制 N 次 → 并行发送 → 单张卡的余额被重复入账 N 次 - 根本原因:余额入账后才写入礼品卡消费标记;兑换读取操作没有使用
SELECT…FOR UPDATE - 年份:2019 — $1,500(开创性/广泛引用的案例)
-
Cosmos / Starport 水龙头 — 双重铸币竞态(H1 #1438052)
- 子类型:钱包/余额双花(加密货币水龙头代币发行)
- Payload:同时发送
/faucet/transfer请求;TransferGo 函数每个请求执行两个会修改状态的操作,且两者都不是原子操作 - 根本原因:水龙头处理程序未按接收方加锁;transfer() 的读取-修改-写入操作未串行化
- 年份:2022 — $5,000(CVSS 9.3)
-
InnoGames — 邮箱激活竞态 → 无限钻石(H1 #509629)
- 子类型:推荐滥用倍增/创建账户竞态(一个激活令牌 → 多次发放“首次激活奖励”)
- Payload:在
token_used标志提交前,使用同一个一次性令牌并发请求邮箱激活端点 → 每个成功请求都会获得奖励 - 根本原因:令牌消费标志与奖励发放在同一事务中设置,但事务隔离级别过低(READ COMMITTED)
- 年份:2019 — $2,000
-
RyotaK / Flatt Security — “First Sequence Sync” PIN 暴力破解(单数据包扩展至 10,000 个请求)(Flatt Security Research)
- 子类型:通过竞态绕过速率限制/MFA OTP 验证竞态(6 位 PIN,最多尝试 5 次)
- 单数据包 HTTP/2:是 — 通过将请求拆分到多个 IP 分片并同步 TCP 首序列号,将 Kettle 的单数据包攻击从约 30 个请求扩展到 166 毫秒内发送 10,000 个请求
- Payload:在 166 毫秒内并发发送约 10,000 个
POST /verify-pin请求,每个请求使用不同的 4–6 位猜测值,且全部落在速率限制窗口内 - 根本原因:速率限制计数器按请求异步递增;“5 次尝试”检查在整个批次中读取到的都是过时计数器
- 年份:2024 — 必须引用的现代单数据包示例
-
nopCommerce — CVE-2024-58248 礼品卡重复兑换(NVD)
- 子类型:优惠券重复兑换(电子商务结账 TOCTOU)
- 单数据包 HTTP/2:是 — 单数据包攻击可稳定复现此问题
- Payload:两个并行的
POST /checkout/PlaceOrder请求都使用同一张礼品卡 → 两笔订单均成功,礼品卡余额却只扣减一次 - 根本原因:下单代码路径未对礼品卡余额行加锁 → 检查后扣款不是原子操作
- 年份:2024(4.80.0 之前的版本)
HTTP/2 单数据包攻击 — 深入参考
单数据包攻击是自 2020 年以来发布的最重要的竞态条件技术。它将竞态窗口从“受 TCP 握手抖动影响的几十毫秒”压缩到“服务器工作线程池分发 N 个预缓冲请求所需的时间”——整个批次通常不到 1 毫秒。这使现代竞态利用能够攻击那些受速率限制、采用分布式架构和负载均衡的后端;此前,这些后端似乎无法被竞态利用。
原始研究: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 的 HTTP/2 目标,Turbo Intruder 的 engine=Engine.BURP2 实现可确保单数据包传送。对于更大的请求体,请参阅下文的“竞争窗口估算”小节。
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 个带有 END_STREAM 的 1 字节 DATA 帧。30 个帧会在一个 IP 数据包中通过网络传输(假设小于 MTU)。complete(timeout=10)会收集响应和时间数据。
检查每个响应对象:req.code、req.length、req.time。当至少有 2 个请求返回成功,且按逻辑来说这些成功不可能同时发生(例如,兑换限额为 1,但两次优惠券兑换都成功),则表示竞态“获胜”。
Flatt 的 first-sequence-sync 扩展(需要 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 个候选值)。没有 first-sequence-sync,就需要约 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 first-sequence-sync 扩展;按照文章手动实现 |
| 前端使用 h2,后端使用 h1(CDN+源站) | h2.cl 请求走私变体——与 hunt-http-smuggling 搭配使用 |
| 在单个端点上快速进行可复现测试 | curl --next --next --next --next(4 个并行 HTTP/1.1 请求)——窗口较宽,但无需设置 |
反模式
- 不要因为在 Burp Repeater 的“Send group in parallel”模式中观察到两次几乎同时成功,就声称存在“竞态条件”——该模式通过 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)
相关技能与链
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 次重复 / 超出限额 / 过期状态演示,并附上响应截图。