Hacker Holidays — Day 8: Towel on the Sunbed
A crypto rewards app rate-limits its daily claim to once per 24h, but the check-then-act isn't atomic — firing 30 parallel requests with Burp's last-byte sync lets every one of them win the race.

Event: Hacker Holidays — The Byte Lotus Hotel · Day 8 · Web (Business Logic / Race Condition) Target:
http://MACHINE_IP:3000— Ponzi Wellness Rewards
The Setup
A crypto rewards app. You earn 50 PONZI per daily staking claim, and reaching 150 PONZI unlocks the Whale Vault (the flag). 150 ÷ 50 = 3 claims — but the claim is rate-limited to once every 24h. The briefing spells out the bug: “claimed three times over while he wasn’t looking… a gap wide enough to walk a whale through” between the request and the server’s clock, and @0xMia’s “bro really thinks the clock is the only thing checking him.”
Bug class: TOCTOU race condition on the claim endpoint. The 24h timer is the only guard, and the check (“claimed today?”) and the act (“credit +50, set lastClaim”) aren’t atomic.
Recon
The dashboard JS (/js/dashboard.js) exposes the whole API:
// claim: POST /claim -> { reward, priceSnapshot } (guarded by 24h canClaim)
// vault: GET /vault -> { flag } (requires balance >= 150)
// state: GET /dashboard/api/me -> { balance, canClaim, secondsUntilClaim, ... }
const WHALE_THRESHOLD = 150;
So the win condition is balance >= 150, gated only by the per-claim cooldown.
Why the Naive Burst Failed
First attempt — 20 backgrounded curls:
for i in $(seq 1 20); do curl -s -b "$C" -X POST http://TARGET:3000/claim & done; wait
Result: only one claim landed (balance: 50), the other 19 returned “Reward
already claimed.” Process-spawn jitter meant the first request completed its full
read-modify-write before the rest ran their check — no overlap, no race. And the
account was now on 24h cooldown, so it was burned.
Lesson: a race needs requests arriving in the same tick. Backgrounded curl isn’t simultaneous enough — you need last-byte synchronization (Burp) or a gated engine (Turbo Intruder), and a fresh account with an unspent claim window.
The Exploit — Parallel Claim (Single-Packet / Last-Byte Sync)
- Register a new account (fresh
canClaim:true). - Capture
POST /claimin Burp → send to Repeater. - Duplicate the tab ~30× → select all → Add to group.
- Group send → Send group in parallel (Burp uses HTTP/1 last-byte synchronization, releasing all requests together).

All 30 requests hit the server before any of them commits lastClaim, so they all
pass the canClaim check and each credits +50 PONZI. Balance rocketed to 1,550
(≈31 wins) — far past the 150 threshold.
Why ~30 and not just 3? You only need 3 winning claims (3 × 50 = 150) — and in practice a group of just 3 parallel requests landed cleanly too, no serialization or rejections. But 30 is the safer default to reach for: overshooting is free (the vault only checks
balance >= 150), while under-provisioning risks the race window not lining up perfectly and landing short of 150 — which burns the account’s 24h cooldown on a wasted attempt. 3 is the minimum that works, 30 is just cheap insurance.
Flag
Open the vault (GET /vault) now that balance ≥ 150:

THM{redacted}
Takeaways
- A rate limit is not a mutex. “Once per 24h” enforced as
if (canClaim) { credit(); setClaimed(); }is a classic check-then-act race — concurrent requests all seecanClaim=truebefore the first write lands. The flag name says it: a double-spend. - Fix: make the claim atomic — a conditional/compare-and-set DB update (
UPDATE ... SET balance=balance+50, lastClaim=now WHERE lastClaim < now-24hand check rows-affected), a per-user lock, or a unique constraint on(user, claim_day). Never read-then-write across anawait. - Race technique: true simultaneity matters. Backgrounded curl loses; Burp’s Send group in parallel (last-byte sync) or Turbo Intruder’s gate wins. Always race on a fresh account since a lost attempt burns the window.
- Recon-from-client-JS again handed us every endpoint — always read the front-end bundle before poking blindly.