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

大多数文章告诉你,竞态条件是"检查(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 里的访问控制工作搭配起来 —— 两者都是业务逻辑漏洞,没有任何扫描器会替你找到它们。免费下载 Crusader,把它对准应用信誓旦旦说已经强制执行的任何一个上限。

常见问题

什么是 single-packet attack?
一种把 20-30 个完整的 HTTP/2 request 放进同一个 TCP packet 的技术。你把每个 request 都发出去,但扣住它的最后一个 frame,然后把所有最后的 frame 一起释放,于是这些 request 会同时到达服务器。这样就消除了本会把 request 串行化、并关闭 race 窗口的网络抖动。它由 James Kettle 在 PortSwigger 的研究中提出,可在 HTTP/2 和 HTTP/3 上使用。
为什么稳定的 race condition 需要 HTTP/2?
在 HTTP/1.1 上,你只能用跨多个独立 TCP 连接的最后字节同步来近似模拟同时性,而这些连接之间的抖动仍会把到达时间抹开好几毫秒。HTTP/2 在单个连接上多路复用多个 request,因此你可以把它们打包进一个 packet,到达才是真正同时的。HTTP/1.1 攻击只能退回到更旧、更不可靠的方法。
什么是连接预热,它为什么重要?
在攻击之前先发几个废弃的 request,好让 TLS 握手完成、服务器端连接就绪。没有它,握手往返和 TCP 慢启动会不均匀地拖慢你的攻击 packet,重新引入 single-packet attack 本要消除的时间散布。
免费试用 Crusader →