Ponzi — Race Condition in Daily Reward Claim
Briefing
Target app: Ponzi, a fictional crypto-rewards app ("wellness portal, poolside edition") with a daily claim mechanism. Goal: prove the 24-hour cooldown on the daily reward can be bypassed, collect enough reward points for "Whale Vault" status, and pull the flag from the vault.
The category tags on the room already hinted at where the problem was: Business Logic and API Abuse, not classic injection. That turned out to be correct.
Reconnaissance
Created a guest account and claimed the daily-reward button once normally via the UI. Then used
Burp Proxy History to find the actual API call behind that button (the dashboard page itself is just
a server-rendered GET, the claim action is separate):
POST /claim HTTP/1.1
Host: <MACHINE_IP>:3000
Cookie: connect.sid=<SESSION_TOKEN>
Content-Length: 0
Important detail: no body. All state (who you are, how much you already have, when you last claimed) hangs entirely off the session cookie. That means there's no field to manipulate — the only lever is timing.
A repeated claim attempt confirmed the server-side cooldown check:
HTTP/1.1 429 Too Many Requests
{"error":"Reward already claimed. Please wait before claiming again.","secondsRemaining":86374}
An explicit cooldown, so no client-side trick possible — the server enforces this itself on every request.
Vulnerability analysis: TOCTOU
The server logic is presumably built as:
1. lastClaim = database.get(userId)
2. if (now - lastClaim < 24h): return 429
3. database.set(userId, now)
4. give reward
That's a classic Time-Of-Check to Time-Of-Use (TOCTOU) pattern: between the check (steps 1-2) and the write (step 3) there's a gap. As long as the server can handle multiple requests on parallel threads/workers, several requests can all see the "not yet claimed" state before the first write is committed. That gap is exactly what the room briefing referred to with "a gap wide enough to walk a whale through".
Exploitation
My first assumption was that a tool like Turbo Intruder (raw sockets, single-packet timing) would be needed to make requests truly arrive simultaneously. Turned out not to be necessary: recent Burp versions support grouping multiple tabs with the same request in Repeater and sending them via "Send group (parallel)" — this sends the requests almost simultaneously, enough to win the race without extra extensions.
Steps:
- Created a new guest account for a clean cooldown cycle.
- Sent the
/claimrequest (with a fresh session cookie) to Repeater, duplicated three times as separate tabs within the same group. - Sent the group in parallel.
Result: all three requests arrived close enough together that the server still saw the old ("not yet claimed") state for each of them during the check. All three granted a reward, instead of just one — three times the normal claim value was enough to cross the Whale Vault threshold.
Result
- The reward balance rose from 0 to above the whale threshold after three parallel claims instead of one.
- The Whale Vault became accessible.
- Flag retrieved from the vault.
Why this is relevant beyond CTF
This wasn't an exploit against memory corruption or an injection point — purely business logic / concurrency abuse. That makes it extra relevant in a banking context: the same pattern (check, then write, with a gap in between) is exactly the risk in:
- Double-spend on internal transfer or payment endpoints.
- Loyalty/reward-point systems that can be granted multiple times on concurrent requests.
- Any flow where a "once per period" rule is enforced with a simple read-then-write instead of an atomic operation (a database-level lock, unique constraint, or an atomic increment/compare-and-swap).
Falls under OWASP API6:2023 — Unrestricted Access to Sensitive Business Flows. Mitigation is
typically: merge the check-and-write into a single atomic database operation (e.g. an
UPDATE ... WHERE last_claim < NOW() - INTERVAL '24 hours' with an affected-rows check), or a
distributed lock/mutex per user id around the whole claim flow.
Tools used
- Burp Suite Community Edition — Proxy (HTTP History) to find the hidden
/claimcall, Repeater with "Send group (parallel)" for the race.
Note on the approach
The first instinct was to use Turbo Intruder (single-packet attack) for maximum concurrency — not available/installable in this environment. The simpler Repeater parallel-group option turned out to be enough, which is a good lesson: for race conditions the required degree of concurrency depends on how tight the server's concurrency window actually is. Not every race requires raw-socket precision; sometimes "a few hundred milliseconds" is already enough slack.