TryHackMeMedium

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.

Dark blue hooded mascot wearing a cap, sunglasses, and a gold Bitcoin chain

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)

  1. Register a new account (fresh canClaim:true).
  2. Capture POST /claim in Burp → send to Repeater.
  3. Duplicate the tab ~30× → select all → Add to group.
  4. Group send → Send group in parallel (Burp uses HTTP/1 last-byte synchronization, releasing all requests together).

Burp Repeater with ~30 duplicated tabs grouped for a parallel send

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:

Whale Vault unlocked at 1,550/150 PONZI, flag redacted

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 see canClaim=true before 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-24h and check rows-affected), a per-user lock, or a unique constraint on (user, claim_day). Never read-then-write across an await.
  • 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.