什么是竞争条件

在处理并发请求时(例如上传文件与将文件存入数据库;支付订单与付款过程),当他们这些行为并未严格进行原子性保护,每个请求之间会存在极短的时间间隔,可通过这个极短的时间间隔实现一些恶意行为(在支付与付款期间将更多订单加入,但支付金额不变;在使用优惠卷时与优惠券核验的状态转变过程加入多步相同的优惠卷使用行为,优惠卷状态来不及更新就已经使用,使得优惠卷多次使用)。而在这个极短时间内行为的竞争使用即为竞争条件。可能发生碰撞的时间段被称为“竞争窗口”。

争夺竞争窗口前提

竞争条件其实就是为了竞争修改状态,而这个状态必须是服务端持久化的共享状态,客户端的状态(如JWT里带个token)没法被并发篡改。

  • 可利用:数据库记录、服务器端Session、缓存(Redis等)、文件系统中的文件。
  • 可忽略:完全依赖客户端Cookie/JWT传递的状态

竞争条件本质是两个线程同时读写同一块内存/存储。如果每个请求都自带状态,互不影响,就没有冲突。

竞争条件攻击方式

在竞争条件中,主要有两种攻击方式,一种是字节同步技术,一种是单包技术。但字节同步技术是当时HTTP/1.1时代比较适配的产物,随着HTTP/2多路复用技术的出现,允许我们在一个TCP连接并发所有请求,我们不必再构建多个TCP连接,繁琐且效率低的字节同步技术也就慢慢退环境了。

方式 单包攻击 (Single-Packet Attack) 最后字节同步 (Last-Byte Sync)
适用协议 HTTP/2 (主流现代网站) HTTP/1.1 (老旧或特定配置网站)
核心原理 利用HTTP/2的多路复用,在一开始就保留每个请求的最后一小片数据,待所有请求都准备好后,一次性发出这些“最终片段”,由操作系统将其打包进单个TCP数据包中发出。 同时建立多个HTTP/1.1连接,在每个连接上预先发送请求的绝大部分内容(仅保留最后一个字节)。最后,再同时发出这个“最后字节”,利用TCP机制使它们在极短时间内到达
并行请求数 20-30个,足以应对绝大多数场景。 20-30个,但管理复杂,难以精确控制。

方法论

竞争条件相对一般的漏洞来说比较抽象和没有这么明显,除了常见的情况外,还会存在一些我们难以直接想到的利用点。portswigger给我们提供一种比较通用的方法论,可以帮助我们更好发现竞争条件的利用点:

预测潜在碰撞

预测的关键在于效率。由于所有操作都是多步骤的,理想情况下,我们应该测试整个网站上所有可能的端点组合。但这并不现实——因此,我们需要预测漏洞可能出现的位置。一个看似诱人的方法是稍后尝试找到本文所述漏洞的复现版本——这固然简单易行,但却会错过一些令人兴奋的、尚未被发现的变种。

首先,确定哪些对象具有您想要绕过的安全控制。这通常包括用户和会话,以及一些特定于业务的概念,例如订单。

对于每个对象,我们需要识别所有写入或读取其数据的端点,并将这些数据用于重要用途。例如,用户数据可能存储在数据库表中,该表会因注册、个人资料编辑、密码重置发起和密码重置完成而发生更改。此外,网站的登录功能在创建会话时也可能会从用户表中读取关键数据。

竞态条件漏洞需要发生“冲突”——即对共享资源进行两个并发操作。我们可以使用三个关键问题来排除不太可能导致冲突的端点。对于每个对象及其关联的端点,请问:

1)状态是如何存储的?

存储在持久化服务器端数据结构中的数据非常适合被利用。有些端点完全在客户端存储其状态,例如通过电子邮件发送 JWT 的密码重置功能——这些端点可以安全地忽略。

应用程序通常会在用户会话中存储一些状态。这些状态通常会受到一定程度的保护,以防止被子状态篡改——稍后会详细介绍。

2)我们是在编辑还是在添加内容?

编辑现有数据的操作(例如更改帐户的主要电子邮件地址)存在很大的冲突风险,而简单地向现有数据追加操作(例如添加额外的电子邮件地址)则不太可能受到限制溢出攻击以外的任何攻击。

3)该操作的关键是什么?

大多数端点都基于特定记录进行操作,这些记录通过“密钥”进行查找,例如用户名、密码重置令牌或文件名。要成功发起攻击,我们需要两次使用相同密钥的操作。例如,设想两种可能的密码重置实现方式:

在第一种实现方式中,用户的密码重置令牌存储在数据库的users表中,提供的用户 ID充当密钥。如果攻击者同时使用两个请求触发两个不同用户 ID 的密码重置,则会修改两条不同的数据库记录,因此不会发生密钥冲突。通过识别密钥,您已经确定这种攻击可能不值得尝试。

在第二种实现方式中,状态存储在用户的会话中,令牌存储操作以用户的会话 ID 为键。如果攻击者同时使用两个请求触发两个不同邮箱的重置,则这两个线程都会尝试修改同一会话的令牌和用户 ID属性,最终该会话可能包含一个用户的用户ID和发送给另一个用户的令牌。

探寻线索

既然我们已经选定了一些高价值的端点,现在是时候探寻线索了——寻找隐藏子状态存在的蛛丝马迹。我们目前无需造成实质性的攻击——我们的目标仅仅是引出一些线索。因此,你需要发送大量的请求,以最大程度地提高出现可见副作用的概率,并减少服务器端的抖动。你可以把这看作是一种基于混沌的策略——如果我们发现了什么有趣的东西,之后再去弄清楚到底发生了什么。

准备好一系列请求,包括目标端点和参数,以触发所有相关的代码路径。尽可能使用多个请求,每次使用不同的输入值来多次触发每个代码路径。

接下来,通过在每次请求之间间隔几秒钟发送请求混合包,来测试端点在正常情况下的行为。

最后,使用单包攻击(如果不支持 HTTP/2,则使用最后字节同步)一次性发出所有请求。您可以在 Turbo Intruder 中使用单包攻击模板来实现,也可以在 Repeater 中使用“并行发送组”选项来实现。

分析结果,寻找与基准行为偏差的线索。这可能表现为一个或多个响应的变化,也可能是次级效应,例如不同的电子邮件内容或会话的明显变化。线索可能很微妙且违反直觉,因此如果您跳过基准测试步骤,就会错过漏洞。

几乎任何信息都可能构成线索,但请密切关注请求处理时间。如果处理时间比预期短,则可能表明数据正在被传递到单独的线程,从而大大增加漏洞出现的可能性。如果处理时间比预期长,则可能表明存在资源限制,或者应用程序正在使用锁来避免并发问题。请注意,PHP 默认对 sessionid 进行锁,因此您需要为批处理中的每个请求使用单独的 session,否则它们将按顺序处理。

竞争条件情况

1.限制溢出竞争条件

限制溢出:当某个功能具有使用次数限制(如验证码次数、验证码发送、优惠券使用、密码重置等),攻击者通过并发请求的方式,在极短时间内绕过次数限制或配额限制,从而过度使用本该受限的资源。

例子:

假设有一家在线商店,允许你在结账时输入促销代码,即可获得一次性订单折扣。要应用此折扣,应用程序可能会执行以下简要步骤:

  1. 检查你是否尚未使用过此代码。
  2. 将折扣应用于订单总额。
  3. 更新数据库中的记录以反映你现在已使用此代码的事实。

如果你稍后尝试重复使用此代码,则在流程开始时执行的初始检查应该会阻止你执行此操作:

现在考虑一下,如果一个从未使用过此折扣码的用户几乎同时尝试使用两次,会发生什么情况:

如你所见,应用程序会转换到一个临时子状态;也就是说,在请求处理完成之前,它会进入一个子状态,然后再次退出。在这种情况下,子状态从服务器开始处理第一个请求时开始,到服务器更新数据库以指示你已使用过此代码时结束。这引入了一个短暂的竞争窗口,在此期间,你可以随意多次重复领取折扣。

这种攻击有很多种变体,包括:

  • 多次兑换礼品卡
  • 对产品进行多次评级
  • 提取或转移超过账户余额的现金
  • 重复使用单个 CAPTCHA 解决方案
  • 绕过反暴力破解速率限制

限制超限是所谓的“检查时间到使用时间”(TOCTOU) 缺陷的一个子类型。

竞争条件利用:

  1. 确定具有某种安全影响或其他有用目的的一次性或速率受限的端点。
  2. 快速连续地向此端点发出多个请求,看看是否可以超出此限制。

主要挑战:要确保请求到达服务端的时间间隔不可太大,否则无法实现目标。即使同时发送请求也可能因为各种原因导致请求延误,无法碰撞

(单包攻击是我们能够使请求碰撞的利用方法:其核心特征是攻击者只需发送一个特定构造的数据包(单个网络包),即可触发目标系统的异常行为、漏洞利用或服务中断。这类攻击具有高效率、低成本和较强的隐蔽性。可利用bp将多个请求形成一个数据包发送给服务端)

2.绕过速率限制

速率限制:在登录密码等情况中,服务端会限制我们试错密码的速度,这种情况下单纯的暴力破解几乎无法起到有效作用。这种情况可利用竞争条件单包攻击。可以多个密码同时测试,虽然在三个左右密码的请求后就会被锁,但这段间隔时间其他密码都会尝试且返回。

3.单端点竞争

单端点:是指多个并发请求被发送到同一个接口(endpoint),由于服务器端缺乏正确的并发控制机制,导致产生不一致的数据状态、越权行为或逻辑绕过

3.2.例子:

示例 1:余额重复转账

接口:POST /transfer

假设该端点的处理逻辑如下伪代码:

1
2
3
if balance >= amount:
balance -= amount
send_money_to(target)

攻击者并发发送两个转账请求,在判断余额和扣款之间插入第二个请求:

  1. 请求 A:判断余额充足;
  2. 请求 B:也判断余额充足;
  3. 两个请求都成功发起转账,结果账户被多次扣款,或余额为负。

结果:资金损失或非法重复操作。

示例 2:重置密码令牌绕过

接口:POST /reset-password

逻辑是这样的:

1
2
3
if token in database:
reset_password()
delete_token(token)

攻击者并发发起两个带有相同 token 的请求:

  1. 请求 A 使用 token 并重置密码;
  2. 请求 B 在 token 删除前也使用成功;
  3. 攻击者能继续使用该 token。

结果:令牌多次使用,存在会话劫持、越权风险。

示例 3:订单未支付却发货

接口:POST /checkout

处理逻辑如下:

1
2
3
if payment_verified:
create_order()
ship_goods()

攻击者:

  1. 并发发起两个 checkout 请求;
  2. 其中一个请求付款完成,另一个请求未付款但也进入发货逻辑;
  3. 发货前的状态检查存在竞态窗口

结果:用户未支付商品却发货成功。

4.多端口竞争

4.1.多端口竞争:是指攻击者并不是向同一个接口发送多个并发请求,而是利用不同接口(端点)之间共享数据或状态不一致的逻辑漏洞,在多个接口之间制造竞态条件,从而绕过业务逻辑、权限校验或触发非法操作。

4.2.单端点与多端点对比

类型 请求来源 举例 特点
单端点竞争条件 同一个接口(如 /update 并发两次余额转账 接口本身处理不当
多端点竞争条件 不同接口(如 /apply-coupon + /checkout 一个端点生成资源,另一个利用 接口间状态不同步

4.3.例子:

示例 1:优惠券滥用

背景:

  • /apply-coupon 用于将优惠券应用到当前 session;
  • /checkout 提交订单,读取 session 中的优惠信息;
  • 后端没有在下单时验证优惠券的有效性,只看 session。

攻击过程:

  1. 用户将一张一次性优惠券加入购物车(调用 /apply-coupon);
  2. 攻击者在多个 session 中同时发起 /checkout 请求,每次都复用那张优惠券;
  3. 后端只检查 session,有可能允许多个 session 使用同一张优惠券。

结果:一次性优惠券被多人使用

示例 2:权限提升

背景:

  • /start-verification 接口发起邮箱验证流程(标记为“未验证”);
  • /update-role 接口允许权限升级,但要求邮箱已验证;
  • 用户 ID 和状态信息存在 race window。

攻击过程:

  1. 发起邮箱验证请求;
  2. 在邮箱验证状态更新前,同时发起 /update-role
  3. 系统仍认为邮箱已验证,成功将用户权限提升。

示例 3:重置密码逻辑不一致

  • /forgot-password 生成 token 写入数据库;
  • /reset-password?token=... 接受重置请求;
  • /change-email 能覆盖该 token 或修改绑定状态。

攻击者在 /reset-password 和其他端点之间制造竞争窗口,导致错误的邮箱收到密码重置。

4.4.在测试多端点竞争条件时,你可能会遇到尝试为每个请求排列竞争窗口的问题,即使你使用单包技术同时发送所有请求。

这一常见问题主要由以下两个因素造成:

  • **网络架构导致的延迟 -**例如,前端服务器与后端建立新连接时可能会出现延迟。所使用的协议也会产生重大影响。
  • **端点特定处理引入的延迟 -**不同端点的处理时间本质上存在差异,有时差异很大,这取决于它们触发的操作。

幸运的是,这两个问题都有潜在的解决方法。

连接警告

后端连接延迟通常不会干扰竞争条件攻击,因为它们通常会平等地延迟并行请求,因此请求保持同步。

区分这些延迟与特定于端点的因素造成的延迟至关重要。一种方法是使用一个或多个无关紧要的请求来**“预热”**连接,看看这是否能平滑剩余的处理时间。在 Burp Repeater 中,你可以尝试将GET主页的请求添加到选项卡组的开头,然后使用“**按顺序发送组(单个连接)”**选项。

如果第一个请求仍然需要较长的处理时间,但其余请求现在可以在短时间内处理,那么你可以忽略明显的延迟并继续正常测试。

滥用速率或资源限制

在某些环境中,比如后端响应时间不稳定、负载均衡、缓存波动等,并发请求的时间差异会变大,导致竞态攻击失效。

例如,你发送两个请求希望它们“同时”修改某个状态,但其中一个请求晚到了几十毫秒,就错过了竞态窗口。

方法一:客户端延迟(不推荐)

你可以通过脚本引入 sleep() 等方式,让请求之间有意延迟以避开不稳定时间段,但这样做的问题是:

  • 会导致一个请求拆成多个 TCP 包
  • 破坏了所谓“单包攻击”的精确性(即在一个包中发送全部数据);
  • 对于某些服务端,多包请求会被识别、拒绝或者导致行为不同
  • 所以不适用于必须精确对齐的竞争条件。

方法二:滥用服务器端“速率限制”机制制造延迟

原理:

许多服务器都有“速率限制”或“资源控制”功能,例如:

  • 每秒只处理 N 个请求;
  • 超出速率后排队处理或返回 429 错误;
  • 单 IP 或单会话限制带宽、连接数。

利用方式:

  1. 在攻击请求发出前,先发送大量虚假请求(虚耗资源)
  2. 这些请求会逼迫服务器进入处理瓶颈或延迟处理状态
  3. 接下来的攻击请求将更容易在几乎相同的时机被处理
  4. 有利于创造“同时命中”条件,从而制造竞态成功的窗口

单终点竞争条件

向同一个端点发送具有不同值的并行请求有时会引发严重的竞争条件。

考虑一种密码重置机制,该机制将用户 ID 和重置令牌存储在用户的会话中。

在这种情况下,从同一会话发送两个并行的密码重置请求,但使用两个不同的用户名,可能会导致以下冲突:

注意所有操作完成后的最终状态:

  • session['reset-user'] = victim
  • session['reset-token'] = 1234

会话中现在包含受害者的用户 ID,但有效的重置令牌已发送给攻击者。

电子邮件地址确认或任何基于电子邮件的操作通常都容易出现单端点竞态条件。电子邮件通常在服务器向客户端发出 HTTP 响应后,在后台线程中发送,这使得竞态条件更容易出现。

绕过php锁机制

PHP 默认把 Session 数据存在服务器文件系统里,比如 /tmp/sess_<PHPSESSID>

当你调用 session_start() 时,PHP 会对这个文件执行 flock() 排他锁。这个锁会一直保持,直到脚本结束,或者你显式调用 session_write_close()

我们可以尝试下面的方式绕过PHP的锁机制:

  • 使用不同的 PHPSESSID:为每个攻击请求分配一个全新的 Cookie。对于不要求登录的端点(如注册、密码重置),直接不带 Cookie,PHP 就会为每个请求自动创建新 Session,锁就失效了。
  • 利用 session_write_close() 的漏洞:有些应用为了性能,会提前调用 session_write_close() 释放锁,然后继续执行耗时操作(如发邮件)。这个锁外的逻辑就完全暴露在并发攻击之下了。

如何防止竞争条件漏洞

当单个请求即可使应用程序经历多个不可见的子状态时,理解和预测其行为将极其困难,这使得防御措施难以实施。为了有效保护应用程序,可以通过应用以下策略消除所有敏感端点的子状态:

  • 避免混用来自不同存储位置的数据。
  • 确保敏感端点的状态变更具有原子性,方法是利用数据存储的并发特性。例如,使用单个数据库事务来检查付款是否与购物车金额匹配并确认订单。
  • 作为纵深防御措施,应充分利用数据存储的完整性和一致性功能,例如列唯一性约束。
  • 不要试图使用一种数据存储层来保护另一种数据存储层。例如,会话机制并不适合用来防止数据库遭受限制溢出攻击。
  • 确保会话处理框架能够保持会话内部的一致性。单独更新会话变量而不是批量更新或许是一种诱人的优化方式,但这极其危险。ORM 也同样如此;它们通过隐藏事务等概念,实际上承担了所有相关的责任。
  • 在某些架构中,完全避免服务器端状态可能更为合适。例如,可以使用加密技术将状态推送到客户端,例如使用 JWT。