Towel on the Sunbed — TryHackMe Hacker Holidays Day 8
A race condition in the claim-reward endpoint lets us mint way more PONZI than we should ever be allowed to hold.
Intro
Day 8 of the Hacker Holidays series drops us into "PONZI Portfolio" — a slick little crypto dashboard that tracks a portfolio balance, shows live market prices for BTC, ETH, SOL, and (of course) PONZI, and offers a daily staking reward.
The hook for today's room sits in two widgets on the dashboard:
- Staking Rewards — claim 50 PONZI every 24 hours.
- Whale Vault — locked until your balance hits 150 PONZI, at which point it unlocks an "exclusive reward" (read: the flag).
At 50 PONZI per claim, and one claim per day, getting to 150 PONZI the "intended" way would take three days of waiting around. Nobody's got time for that, so let's see if the app's claim logic holds up.
Step 1 — Account setup and first look
Registered a fresh account and landed on the dashboard:
- Portfolio Balance: 0 PONZI
- Rank badge: SHRIMP
- Market Prices table showing BTC, ETH, PONZI, SOL — cosmetic, not relevant to the bug
- Staking Rewards card: "Reward is available to claim now" with a Claim Reward button
- Whale Vault card: progress bar at 0 / 150 PONZI, Open Vault button greyed out
Clicked Claim Reward once through the UI. Balance ticked up to 50 PONZI, and the card flipped to a cooldown state — confirming the "once every 24 hours" rule is at least enforced somewhere. The question is whether it's enforced server-side, and whether it's enforced atomically.
Step 2 — Finding the claim endpoint in Burp
Fired up Burp Suite, routed traffic through the proxy, and hit Claim Reward again on a fresh session with intercept on. Found the request going out to something like:
POST /api/staking/claim HTTP/1.1
Host: <target>
Cookie: session=<redacted>
Content-Type: application/json
{}
Sent it to Repeater and replayed it manually. Response came back clean the first time (+50 PONZI), but replaying the exact same request a second time returned an error along the lines of:
{ "success": false, "message": "Reward already claimed. Try again later." }
So there's a server-side check. Good — that rules out a trivial "just call it in a loop" replay attack. But a check like this almost always follows the same lazy pattern under the hood:
- Read the user's "last claimed at" timestamp / flag.
- If it's been ≥24h (or hasn't been set), credit the balance.
- Then update the "last claimed at" timestamp.
That gap between steps 1 and 3 is exactly the kind of window a race condition lives in. If ten requests all pass step 1 before any of them finish step 3, they could all get credited before the "already claimed" flag ever gets set.
Step 3 — Setting up the race
Registered another fresh account (clean slate, 0 PONZI, reward available) and repeated the capture, this time sending the claim request straight to Repeater without letting it fire from the browser first.
In Repeater:
- Right-clicked the request → Add tab (or duplicated it) to create multiple identical copies of the claim request — did this 5–6 times since we only need 150 PONZI (3× the 50-PONZI reward covers it, but a few extra copies give margin for any that get dropped or lose the race).
- Selected all the duplicated tabs, right-clicked → Group requests to bundle them together.
- Set the group's send mode to Send group in parallel (this is the key setting — Burp opens the connections and fires all requests in the same tick instead of sequentially, which is what actually creates the race window).
Step 4 — Firing it
Hit send on the group. All the requests landed on the server at effectively the same instant, before the "reward already claimed" flag had a chance to persist from any single one of them.
Result: instead of one request succeeding and the rest bouncing off the cooldown check, multiple requests raced past the check simultaneously and each credited +50 PONZI to the balance.
Refreshed the dashboard:
- Portfolio Balance jumped straight past 150 PONZI in one shot.
- Whale Vault progress bar filled and the Open Vault button lit up.
- Clicking it revealed the flag.
Why this happened
This is a textbook TOCTOU (time-of-check to time-of-use) race condition:
- The "have they claimed today?" check and the "credit the balance / set the claimed flag" write were not wrapped in a single atomic operation (no row locking, no atomic increment, no idempotency key, no mutex).
- Sending requests in parallel rather than sequentially is what exposes the bug — sequential replay just hits the check every time and fails, because by the time request #2 arrives, request #1 has already finished updating the flag. Parallel requests all read the "not yet claimed" state before any of them writes back.
- Burp Repeater's "Send group in parallel" feature is purpose-built for this: it opens all the connections first and releases the requests together (using HTTP/2 single-packet-attack behavior where supported), minimizing the timing gap and maximizing how many requests land inside the vulnerable window.
Fix, for the curious
The realistic server-side fixes are the usual suspects for this class of bug:
- Use an atomic database operation (
UPDATE ... SET claimed=true WHERE claimed=false, checking rows-affected) instead of separate read-then-write steps. - Wrap the check-and-credit in a transaction with row-level locking.
- Add an idempotency key per claim window so duplicate requests in flight all resolve to the same outcome.
- Rate-limit / debounce at the application layer as a secondary defense, not the primary one.
Flag Recovered 🚩
🎉 FLAG CAPTURED!
THM{redacted_for_writeup}
Takeaways
"It rejects a replayed request" is not the same as "it's safe from a race condition." Always test parallel delivery on state-mutating endpoints.
Burp's Repeater groups + parallel send makes concurrent testing extremely straightforward once endpoints are identified.
A tiny delay between checking and writing state is enough to turn a 3-day restriction into a 1-second exploit.
🎄 See You on Day 9
This challenge was a textbook demonstration of TOCTOU. Looking forward to the next challenge!