# 用单包攻击拿下 Web 竞态条件漏洞

> 竞态条件的可利用窗口往往在亚毫秒级，而你的多个 request 之间的网络抖动却有 1-10ms —— 于是它们被串行化，漏洞看起来像是不存在。single-packet attack 把这段抖动压缩到零。下面讲清楚它的机制、背后的数学，以及它能带来什么回报。

**Key takeaways:**
- 窗口才是全部问题所在：race 窗口往往在亚毫秒级，而分开发送的各个 request 之间的网络抖动有 1-10ms，于是服务器按顺序处理它们，race 永远不会触发。
- single-packet attack 先扣住 20-30 个 HTTP/2 request 的最后一个 frame，然后在一个约 1,500 字节的 TCP packet 里一起释放 —— 它们同时到达，抖动便不再有影响。原本『只能在 localhost 上复现』的 race 变得可远程、可稳定复现。
- 先给连接预热（完成 TLS 握手，用一些废弃 request 把 TCP 窗口撑起来），否则建立连接的延迟又会把你刚消除掉的时间散布重新引入。
- 突破次数上限只是入门教程。真正高危的漏洞是多端点碰撞和隐藏的子状态 —— 一个写到一半、被在中途观察到的对象。

**Series:** Bug Class Deep Dives  
**Published:** July 5, 2026 · 2 min read  
**Canonical:** https://crusaderproxy.com/research/zh/posts/winning-the-race-condition-bugs/

---


大多数文章告诉你，竞态条件是"检查（check）与使用（use）之间的一段时间窗口"。没错，但没用 —— 因为你的 race 攻击失败，原因并不是你没搞懂这个概念，而是**这个窗口比互联网的精度还要小。**

一个典型的 race 窗口 —— 应用读取 `balance` 与写入 `balance - amount` 之间的间隙 —— 以**微秒到几毫秒**计。而当你在同一瞬间*发出*两个 HTTP request 时，等它们*到达*服务器并被调度时，两者之间的抖动通常有 **1 到 10 毫秒**，跨公网还会更糟。于是你那些"同时"发出的 request 排成整齐的一小列到达，服务器一个一个地处理，上限保持不破，你便断定这个端点是安全的。它并不安全。你只是在服务器还没来得及表态之前，就先输给了网络抖动。

> **利用 Web race 的全部功夫，就是一场让 N 个 request 在同一瞬间到达的搏斗。赢下它，那个"无法利用"的上限就会在一击之间崩塌。**

## 为什么最后字节同步始终差那么一点

那个老式的 HTTP/1.1 技巧 —— **最后字节同步** —— 能让你接近目标：开约 20 个连接，把每个 request*除最后一个字节以外*的部分都发出去，然后一起发出所有的最后字节。它管用，多年来一直是最先进的做法。但它有一个无法修复的缺陷：这些是 **20 个独立的 TCP 连接**。每个都有自己的拥塞窗口、自己的路径时延、自己的内核调度。即便扣住最后一个字节，到达时间仍会被抹开几毫秒 —— 足以让你错过一个微秒级的窗口。最后字节同步把千分之一的 race 变成大概十分之一。是好了，但仍不稳定，仍然是"只有从同区域的 EC2 机器上才复现得出来"。

## single-packet attack：一个 packet，零抖动

改变这一切的原语在此，出自 James Kettle 的 *Smashing the State Machine* 研究。HTTP/2 在**一个**连接上多路复用多个 request。于是你不再去竞速 20 个连接，而是：

1. 在单个 HTTP/2 连接上发送 20-30 个 request，但**扣住每个 request 的最后一个 frame**（就是那个告诉服务器"这个 request 已完成"的部分）。
2. 当全部就位后，**把每个 request 的最后一个 frame 一起释放** —— 由于它们都很小，全部 20-30 个都能塞进**单个约 1,500 字节的 TCP packet**（一个网络 MTU）里。
3. 这一个 packet 被原子地送达。服务器在同一瞬间看到全部 20-30 个 request 变为"完成"，并把它们一起调度。

现在网络抖动**无关紧要**了，因为不存在"request 之间"这回事 —— 只有一个 packet。那 10ms 曾把你串行化的互联网噪声消失了。原本只在 localhost 上有效的 race，如今能从你的笔记本、通过 WiFi 稳定地、一次尝试就成功。这就是"理论上有漏洞"和"报告里一张截图"之间的区别。

有个坑会吞掉很多人的头几次尝试：**连接预热。**如果你的攻击 packet 同时也是这个连接上的第一样东西，它就会承担 TLS 握手的往返和 TCP 慢启动，那些 request 又会重新散开。先发一小把废弃的 request（几个 `GET /`）来完成握手、把拥塞窗口撑起来，*然后*再布置攻击。跳过这一步，你会赌咒发誓说这技术根本没用。

## 越过突破上限：真正好的漏洞长什么样

"把一张 coupon 兑换两次"只是入门教程。真正有回报的发现是这三种形态：

**单端点上的次数超限。**经典款，而且但凡有钱的地方都还在用它：

- **gift card / 商店余额。**在同一个 packet 里把一张 $50 的卡应用到 10 个购物车 → 得到 $500 的余额，因为这 10 个都在任何一个把它扣减之前读到了"balance: $50"。
- **提现 / 转账。**从一个 $100 的余额里提现 $100，做 5 次 —— 每个 request 都在任何一笔扣款落地*之前*读到余额。妥妥的双花。
- **2FA / OTP 频率限制。**一个允许"3 次验证码尝试，然后锁定"的端点，会在递增尝试计数器*之前*先检查它。在一个 packet 里发 30 个猜测，全部 30 个都读到"attempts: 0"并通过闸口 —— 你在每个窗口里拿到了 30 次尝试，而不是 3 次。这往往就是"暴力破解不可行"与"午饭工夫就能搞定"之间的全部区别。

**多端点碰撞。**微妙而高危的一类：两个*不同*的 request 被计时到相撞。典型例子是邮箱验证 —— 把 `confirm-email`（针对你控制的地址）和 `change-email`（改成受害者的地址）一起发出，最终落到一个*受害者的*邮箱在你的账户上被标记为已确认的状态。检查—执行横跨两个端点，所以单端点加锁救不了它们。

**隐藏的子状态。**Kettle 更深一层的观点：许多操作会经过一个本不应被观察到的中间状态 —— 一个已存在但尚未完成的用户行、一个已认证但尚未被限定作用域的 session、一个已创建但尚未设 ACL 的文件。race 让你能*在*这个子状态期间*观察或行动*。这些不会把自己标榜为"上限"，而这恰恰是它们得以存活的原因。你要靠让一个读去竞速一个写，并盯着那个读是否返回了某样本不该存在的东西，来找到它们。

## 检测：数副作用，别数那些 200

一个既会造成漏报*又*会造成误报的陷阱：**用 HTTP 状态码来判断。**两个能救你的具体点：

- **满屏的 `200` 可能是假象。**有些后端会接受全部 30 个 request，然后数据库的唯一约束悄悄地拒掉那些重复项 —— 你看到三十个 `200`，却没有漏洞。要去验证*实际的副作用*：余额真的动了两次吗？真的建出了两行吗？响应码不等于结果。

- **一次成功就是一个发现。**你不需要全部 30 个都成功。如果 29 个返回"coupon 已被使用"、而*一个*额外的兑换落地了，这个上限就已经被打破了 —— 你只需要它破一次。别因为一次运行大部分都是拒绝就把它丢掉。

对于那些不可见的子状态漏洞，**先做基准**：发一批，记录响应的正常散布，然后去找那个异常值 —— 那个长度不同、状态不同的响应，那个本不该可见的对象。这个异常就是子状态在泄漏。

## 会让你丢掉漏洞的那些坑

- **没做连接预热** → 你的第一个 packet 又慢又散。给它预热。
- **idempotency key。**如果客户端为每个操作发一个唯一的 key、而服务器认这个 key，你那 30 个重复就会坍缩成一个。剥掉或改变这个 key 再重试。
- **把约束当噪声，别当判决。**一个唯一索引能挫败*这个* race，而同一对象上的另一个端点却毫无防护。一次失败的 race 是关于某一条代码路径的信息，不是一张健康证明。
- **状态新鲜度。**一张真正只能用一次的 coupon，在第一次正当使用之后就"确认"不了任何东西了 —— 每次运行都从一个干净、未使用的对象开始。

## 它是怎么被防御的（以及如何防御）

供你报告的修复建议部分：修复从来不是"加个检查"。它是**数据层的原子性** —— `SELECT … FOR UPDATE`、一个唯一约束、一次原子扣减（`UPDATE … SET balance = balance - 1 WHERE balance >= 1`），或者带版本列的乐观锁。任何在应用代码里、跨两条语句先读后写的东西，都是在和自己竞速。idempotency key 则堵住重复提交那个变种。

## 要点

竞态条件并不罕见 —— 它们只是*很少被复现*，因为直到最近，那种让 request 同时落地的技术还不够可靠，人们测了那个显眼的 coupon 端点、失败了、就走开了。single-packet attack 消除了这份不稳定。这就把应用里的每一个上限、每一句"这个你只能做一次"、每一条多步流程，都重新框定为一个你能用一个 packet、两秒钟就能测的候选目标。漏洞一直都在；只是现在计时是免费的了。

Crusader 说 HTTP/2，所以它能直接布置 single-packet attack —— 预热连接、扣住最后的 frame、一起释放它们，然后对比结果差异。把它和 [Finding IDOR and BOLA]({{< relref path="posts/finding-idor-bola-with-shadow-replay.md" lang="en" >}}) 里的访问控制工作搭配起来 —— 两者都是业务逻辑漏洞，没有任何扫描器会替你找到它们。[免费下载 Crusader](https://crusaderproxy.com/#install)，把它对准应用信誓旦旦说已经强制执行的任何一个上限。


---

*Original research by Crusader Research. Try Crusader free: https://crusaderproxy.com/#install*
