Post

TryHackMe - Towel on the Sunbed

RoomTowel on the Sunbed
Authortryhackme
DifficultyMedium

Ponzi found the resort’s wellness portal running a little side project called Ponzi — a crypto rewards app, poolside edition. He set his towel down, claimed his daily reward, and went to reapply sunscreen. He came back to find the sunbed had been “claimed” three times over while he wasn’t looking.

He’s convinced the app owes him a spot in the Whale Vault. The app disagrees, politely, once every 24 hours. Somewhere between his request and the server’s clock, there’s a gap wide enough to walk a whale through.

Let’s get started

Enumeration

I started by enumerating the website as a normal user would — registering an account, poking around the dashboard, and getting a feel for the intended flow before looking for anything broken.

image

After registering, the initial PONZI balance is 0, with each claim worth 50 PONZI.

Trying to claim immediately confirmed the intended rate-limit: one claim every 24 hours, and a stated goal of 150 PONZI to unlock access to the Whale Vault.

image image

Clicking Claim Reward bumped the balance to 50, as expected:

image image

Following the intended path, I’d need three separate 24-hour cooldowns to legitimately reach 150 PONZI (50 → 100 → 150). Once a claim succeeds, the button disables client-side and a countdown timer starts:

image

That cooldown UI is the tell. Any time a web app enforces a “wait X before doing this again” rule, the natural question for a pentester is: where is that rule actually enforced — client or server? If it’s only client-side, it’s not a rule, it’s a suggestion.

The Vulnerability

Race Condition (TOCTOU)

Here’s the relevant client-side JS for the claim button:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
document.getElementById('claim-btn').addEventListener('click', async () => {
    const btn = document.getElementById('claim-btn');
    btn.disabled = true;
    const status = document.getElementById('claim-status');
    try {
        const resp = await fetch('/claim', { method: 'POST' });
        const json = await resp.json();
        if (resp.ok) {
            status.textContent = `Claimed! +${json.reward} PONZI. PONZI price: $${json.priceSnapshot}`;
            await loadDashboard();
        } else {
            status.textContent = json.error || 'Claim failed.';
            btn.disabled = false;
        }
    } catch (e) {
        status.textContent = 'Network error.';
        btn.disabled = false;
    }
});

btn.disabled = true is purely a UI affordance. It stops this browser tab from firing a second click, but it does nothing to the server, and nothing stops a second tab, a curl loop, or (in this case) Burp Repeater from issuing POST /claim directly, bypassing the DOM entirely. The real control has to live server-side — and this app’s server-side control has a gap.

This is a textbook TOCTOU (Time-Of-Check to Time-Of-Use) race condition. A naive claim handler looks something like this:

1
2
3
4
1. Read user's "last claimed" timestamp / balance from the DB
2. Check whether 24h have elapsed since last claim
3. Calculate the reward
4. Write the new balance and update the last-claimed timestamp

The bug is that steps 1–4 are not atomic. Each step is a separate read or write against the database, and there’s a window between the “check” (step 2) and the “use” (step 4) where the record hasn’t been updated yet. If the application handles requests concurrently — which every modern web server does by default — multiple /claim requests arriving within that window will each read the same stale “last claimed” value in step 1, because none of them have committed their update yet. Every single request then passes the eligibility check in step 2, and every single one gets paid out in step 4.

The fix for the attacker is simple: don’t send requests one at a time, send them at the same instant, so they all land inside that window before any of them can close it.

Why This Matters Beyond “Free Currency”

It’s tempting to read this as a low-stakes “in-game currency” bug, but the same class of flaw shows up in production systems with real consequences: coupon/promo redemption, account balance top-ups, inventory decrements (“only 1 left in stock”), vote/like counters, password-reset token consumption, and financial transfer endpoints. Anywhere a check-then-act sequence isn’t wrapped in a single atomic operation, concurrency is a lever an attacker can pull.

Exploitation with Burp Suite

To win the race, the requests need to be fired in parallel, not sequentially — a normal script with a for loop and await per request won’t reliably trigger this, since each request would complete (and update state) before the next begins. Burp Suite’s Repeater has a built-in feature for exactly this: parallel group sending.

I registered a second account, turned on intercept, and captured the claim request:

image

Forwarded it to Repeater:

image

In the Repeater tab, I created a tab group (right-click the tab → Add tab to group → New tab group) named ponzi:

image

Group sending fires every tab in the group as one batch, so I duplicated the request tab several times within the ponzi group (right-click → Duplicate tab) to build up enough concurrent requests to guarantee a win against the server’s timing window:

image

Then, from the group’s send dropdown, I selected Send group in parallel (last-byte sync):

image

This mode is the key technical detail: Burp opens all the TCP connections and sends every byte of every request except the very last byte up front, then releases the final byte of each request simultaneously. That guarantees the server receives all requests within the same, extremely tight window — far tighter than what you’d get from firing requests in quick succession normally — maximizing the odds that several land before any single one finishes writing its state update.

Clicking send fired the whole batch at once. Since the server doesn’t queue or lock claims per-user, multiple requests were processed as if each was the “first” eligible claim:

image

Refreshing the dashboard confirmed the attack worked — the balance jumped well past the 150 PONZI threshold in a single burst, despite the 24-hour cooldown supposedly allowing only one claim:

image

With enough PONZI banked, the Whale Vault unlocked and the flag was retrievable:

image image

Conclusion

Towel on the Sunbed is a clean, practical demonstration of a business-logic race condition — a bug class that’s easy to miss because nothing about it looks broken in normal, single-request testing. The claim endpoint behaved exactly as intended every time I tested it manually; the flaw only became visible once requests were sent concurrently and deliberately timed to land inside the same processing window.

Root cause: the /claim handler performed a non-atomic check-then-act sequence (read last-claim time → validate cooldown → write new balance) without any locking, transaction isolation, or idempotency control to serialize concurrent requests from the same user.

Exploitation technique: Burp Suite Repeater’s “send group in parallel (last-byte sync)” feature turned an ordinary rate-limit bypass into a reliable, repeatable multi-claim exploit by synchronizing the final byte of each duplicated request, forcing the server to process them within the same race window.

How this class of bug is typically fixed:

  • Replace the read-then-write pattern with a single atomic operation, e.g. UPDATE users SET balance = balance + 50, last_claim = NOW() WHERE user_id = ? AND last_claim < NOW() - INTERVAL '24 hours', and verify rows_affected == 1 before returning success.
  • Or wrap the check and update in a database transaction using row-level locking (SELECT ... FOR UPDATE) so concurrent requests for the same user are serialized rather than interleaved.
  • Or acquire a short-lived distributed lock (e.g., a Redis SET NX keyed on user ID) around the claim logic if the balance and cooldown state live across multiple services.
  • Layer on defense-in-depth: per-user rate limiting at the API gateway/WAF, and idempotency keys on state-mutating endpoints, though neither of these alone fixes the underlying atomicity gap.

The broader lesson: client-side disabled buttons, countdown timers, and “please wait” messages are UX, not security controls. Any endpoint that enforces a limit, cooldown, or one-time action needs that enforcement to be atomic at the data layer — because if a request can be duplicated, an attacker will duplicate it, and Burp’s parallel-send tooling makes winning that race trivially easy.

GG.

This post is licensed under CC BY 4.0 by the author.