pull down to refresh

This writeup is part of a series going through TryHackMe's 2026 14-day Hacker Holidays daily challenge event

Background & Info from the Challenge Page

This section was copied from the Day 8 challenge page on THM.

This challenge was rated as Medium.

The category was Web and the associated tags were:

  • Web Exploitation
  • Business Logic
  • Burp Suite
  • API Abuse

Concierge Briefing

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.

Room Access

Lab Machine with [MACHINE_IP:3000]
App: Ponzi — Wellness Rewards

Today's Itinerary - Goals

  • Create a guest account and explore Ponzi's daily reward mechanism.
  • Work out exactly what's standing between you and Whale Vault status.
  • Find your way past it and retrieve the flag from the vault.

Mia's Story

@0xMia
"ponzi guy has been refreshing his dashboard for an HOUR waiting on this timer 💀 bro really thinks the clock is the only thing checking him #HackerHolidays"

Recon - Exploring the Rewards App

First up, let's boot up the browser and head to the MACHINE_IP:3000 address. The first thing we see is a typical login page. Now it'd be easy to jump the gun and start thinking about injections again because of the last few challenges. However, there is also a working "Register" button, so we might as well explore fully as a typical user before we try breaking things.

After registering an account, we are automatically logged in and brought to the /dashboard of the rewards app. We can see our current balance of 0 ponzi, which makes us a "shrimp". Lower on the dashboard is a button to earn 50 ponzis as staking reward, available once every 24 hours. At the bottom is the "Whale Vault" we need to get access to, which requires 150 ponzis. That's equivalent to hitting the button 3 days in a row. However, we're not waiting 3 days to become a whale. The virtual machine would also timeout before then.

Now, before I click the button or interact in any way, I note that "Burp Suite" is one of the challenge tags. I might as well use it to see what happens step by step when I click it.

I launch Burp Suite and start a temporary project in memory. Then I open Burp's internal browser.

Note: if I had clicked the button already on a user account, the 24-hour cooldown would've been set off and that account's button becomes greyed out, so it's best to just register a fresh account for the Burp Suite test in that case.

Once back on the dashboard, with the button ready to be clicked, I go to the Proxy tab in Burp Suite, turn on interception (Burp's Intercept feature), launch Burp's internal browser and log into our user account, or simply register a new one there. As long as the user still has a clickable Claim button.

With that set up, it's time to hit the Claim Reward button and analyze the request-response step by step. What happens on click is that Burp will likely pop to the foreground and show what the button click action triggered as a request. Then we can analyze, edit and manipulate a few things as needed, and approve each request one by one. I think of it a bit like stepping through code in a debugger, where each request is a breakpoint I choose to continue past.

We can see the button calls (POST) the /claim endpoint. It also sends a session cookie that was created upon login. At this point this request isn't sent to the webapp yet, Burp is showing us what will be sent when we are ready.

We hit the orange "Forward" button to tell Burp to send that request. A new request shows up. A GET call to /dashboard/api/me.

We hit Forward again. Now the pane goes empty. That's the whole interaction. Each request also had a response. We head to the "HTTP History" tab to check the (short) history of this whole button click interaction.

The list contains all the requests and responses since we started Burp's browser, even those from before we turned interception on. For the sake of completeness, here is what each line represents.

URLMethodPathPurpose
https://www.google.com/warmup.htmlGET/warmup.htmlBurp's internal browser connectivity warm-up check on startup
http://10.64.159.112/GET/First navigation to the lab machine's root
http://10.64.159.112:3000/GET/Initial load of the app on port 3000
http://10.64.159.112:3000/auth/loginGET/auth/loginLoading the login page
http://10.64.159.112:3000/auth/loginPOST/auth/loginSubmitting credentials to log in
http://10.64.159.112:3000/auth/registerGET/auth/registerLoading the registration page
http://10.64.159.112:3000/auth/registerPOST/auth/registerSubmitting the form to create the guest account
http://10.64.159.112:3000/dashboardGET/dashboardLoading the dashboard after login/registration
http://10.64.159.112:3000/dashboard/api/meGET/dashboard/api/meFetching the current user's balance/tier to populate the dashboard

Then, right before we click the button, we turn on interception. The button click triggers two requests:

URLMethodPathPurpose
http://10.64.159.112:3000/claimPOST/claimThe Claim Reward button click, the request we intercepted
http://10.64.159.112:3000/dashboard/api/meGET/dashboard/api/meRefetching user data after the claim to update the displayed balance

Now let's click the line for the button click's call to /claim. We already saw our request as it was sent, but now we can hit the Response tab and see what the server sent back.

We see a message that the reward was claimed successfully, along with the value of the reward (50), the newBalance, the new tier (still Shrimp), and a priceSnapshot (seems irrelevant).

After the button click request-response, there is one last line: a GET call to /dashboard/api/me, which refetches our user information so that the dashboard updates the displayed numbers.

{
  "id":4,
  "username":"futurewhale4",
  "balance":50,
  "tier":"Shrimp",
  "whaleThreshold":150,
  "canClaim":false,
  "secondsUntilClaim":86324,
  "prices":[ ... ]
}

It appears the dashboard uses these values to populate the UI. We can even see the secondsUntilClaim, which is used to show the countdown until the reward becomes claimable again.

So what now? Let's start with something simple: even if the button is disabled, we can use Burp Suite to resend the /claim request and see what happens.

HTTP/1.1 429 Too Many Requests
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 95
ETag: W/"5f-hoVOHn/lRj3K/o/dhqwklyST2sM"
Date: Fri, 28 Aug 2026 18:07:41 GMT
Connection: keep-alive
Keep-Alive: timeout=5


{"error":"Reward already claimed. Please wait before claiming again.","secondsRemaining":86316}

Resending the request with Burp gives us a 429 Too Many Requests, along with a message telling us how long to wait before trying to claim again.

Let's try, for the heck of it, to register a new user (so the claim cooldown hasn't triggered yet) and send a claim request with a body telling the server our reward is 500 instead of 50 and that our new balance is 500 too. This is unlikely to work, but it's cheap to try.

I create a new user and sign in, then turn on Burp's interception before clicking the button. Before forwarding the request, I send it to Burp's Repeater, add Content-Type: application/json and ensure "automatically update Content-Length" is turned on by checking the gears menu. Then I add a body with the values "reward": 500, "newBalance": 500, along with "tier": "Whale" for good measure.

Upon sending this, there's immediately the request queued up: the GET to /dashboard/api/me. We could edit this one too but it's unlikely to matter. Even if I were to edit those values, they're utilized only as variables to populate the UI values on the dashboard. So maybe we can make it tell us we're whales and show us a value of 500, but that'd just be on the client side (our own browser). The server itself would not care. The whale vault would still be locked. It'd be the equivalent of doing Inspect Element when looking at our bank's chequing account balance and changing the number to 1000000000. It wouldnt make us a billionaire or change the value on the bank's databases for what we can withdraw.

Let's check the response to our edited request.

Looks like it gave us back the standard response, and it only increased our ponzi balance by 50.

What if we try to attack at an earlier point? Instead of on button click, let's try to append a tier and balance field when registering a new user to see if we can create an account that's already a whale with 150+ ponzi.

Sending this gives a 500 Internal Server Error. No matter how I play with the format, nothing works until I delete the extra fields and only give it a username and password.

The next thing to turn our attention to is the fact that the secondsUntilClaim countdown is the main thing the app uses to stop us from claiming rewards more than once per 24-hour period. There were also some hints in the challenge backstory flavor text that point us in the right direction:

  • "he came back to find the sunbed had been claimed three times over while he wasn't looking"
  • "Somewhere between his request and the server's clock, there's a gap wide enough to walk a whale through"

A gap between the request and the server's clock. This implies the possibility of a race condition. This is the next thing we'll try: rapid-fire a bunch of /claim requests to see if we can get the server to accept more than one (50 × 3 = 150, so at least three) before it writes the new cooldown timestamp on the backend.

To begin this attempt, let's register yet another fresh account so that canClaim is True. We are blocked if the claim cooldown is already enforced.

Once we have the fresh account, we turn on Burp interception to pause the request before firing it off, then send it to Intruder. We choose "Sniper" as the attack type. We're firing the same request many times without varying any value, and Sniper is the attack type that takes a request and replays it once per payload. With null payloads there's nothing to substitute: Burp sends the exact request, unmodified, N times.

We also choose to send 30 null payloads. The number is largely arbitrary, it just needs to be comfortably above the three claims we must land (50 × 3 = 150). Whether 30 or 50, the point is to have enough copies in the burst that at least three slip through the tiny check-then-write window before any write commits.

Unfortunately, this attack wasn't successful, but not for reasons that immediately disqualify the attack itself. It turns out that the free Community Edition of Burp Suite contains some limitations for Intruder, and "some attacks are time-limited". It's possible some of these limits throttled the sniper attack, so the burst never fired at full speed. The result was a single successful 200 response and then all 429s.

We need to try the same attack in some other way. Let's try using Burp Suite's Repeater instead and see if we can shoot the requests fast enough to race the server. Again with a fresh account, we turn on interception, and send the captured request to the Repeater. Then I duplicate the tab with the same payload 20 times. It's an arbitrary number, we just want to have a comfortable margin above 3 (since we want at least 3 to go through to get 3 × 50 coins = 150).

Then I add the tabs to a tab group so that I can "Send group" and make sure to send in parallel. A dialog pops up to confirm the host IP address and port and whether to use HTTPS. We must turn OFF HTTPS because the app is using plain HTTP. Note that the host field takes the bare IP: putting IP:3000 in there gave me an "invalid host" error. The port goes in its own field, set to 3000. We must also make sure all tabs/payloads are identical, and that they all use the same target and protocol. If any tab differs (say, HTTP/2 while the rest are HTTP/1), Burp refuses the group send with a "protocol/target not the same" error. The easiest way to avoid this is to set up the first payload correctly (target MACHINE_IP, port 3000, HTTPS and SNI off), then duplicate that tab 10-20+ times so every copy inherits the same settings.

Once ready we click Send Group (Parallel). It should take a few seconds, and then we can tab through our payloads and check the response side for each. With that we can confirm that at least 3 of them returned a 200 with the balance bump. In my case, they all went through, and the server sent back a newBalance of 1050 (21 copies × 50 = 1050, so every single request landed)!

Let's go back to the browser and confirm our profile reflects our new Whale status.

Let's open the whale vault and check out the loot.

Got em!

The Kill Chain (at a glance)

StepActionResult
1Register a guest; read the client JS and /dashboard/api/meReward 50, threshold 150, so 3 claims needed
2Baseline claim, then resend429 + secondsRemaining: the gate is a server-side cooldown timestamp
3Dead probes: claim-body tampering and register-field tamperingBody ignored (+50 regardless); extra register fields cause a 500. Race is the path
4Intruder Sniper, 30 null payloadsThrottled by the Community Edition, never fired fast enough
5Repeater: 21 identical tabs, Send Group (Parallel)All 21 landed, newBalance 1050, tier Whale
6Open the whale vaultFlag

Questions Raised Along the Way

  1. Send Group (Parallel) vs single-packet attack, what's the difference? Parallel opens a separate connection per request; single-packet (Turbo Intruder) puts all requests on one connection, back to back. For a single-threaded server both work when the window is wide; single-packet is the tightest possible delivery.
  2. What are the Intruder attack types, and when do I use each? Sniper, one position and one payload set, N requests. Battering ram, one payload into every marked position. Pitchfork, multiple sets aligned by index. Cluster bomb, every combination. Battering ram, Pitchfork and Cluster bomb all exist to combine payload positions and payload sets, which is pointless when you have zero positions and nothing to vary, so for a duplicate/race request Sniper with null payloads is the only sensible pick. For a CTF challenger the ones to know: Sniper, Cluster bomb, Pitchfork, and null payloads for duplicates and races.
  3. What does the resource pool setting do? It caps how fast an attack runs: maximum concurrent requests and delay between requests. In Community Edition the cap is fixed no matter what you set, which is why a Sniper race couldn't fire fast enough.

Some Lessons Learned

  • Burp Community throttles Intruder; Repeater group send doesn't. That's the free path to a race condition.
  • Group-send gotchas: the host field takes the bare IP (port goes in its own field), all tabs must share the same target and protocol (all HTTP/1), and HTTPS and SNI stay off for a plain-HTTP app.
  • Read the client JS as the rulebook. Threshold, endpoints and reward size are all in there, and the reward/threshold ratio tells you how many claims your race must land.
  • Do the cheap deterministic probes before racing. Body tampering and register-field tampering are low risk and high signal; the race is the last resort, not the first.
  • Fresh account per attempt. A committed 24-hour lock kills the user; register a new one for each race and re-check balance via /dashboard/api/me.
  • Judge races by the count of successes, not their printed position. Whether in a script or Burp, an ordered log can lie about completion order.
  • The single-packet attack is the right tool for a single-threaded server. Python http.client pipelining is the same thing Turbo Intruder does: all requests queued before any write commits.

Alternate Method - The Python Single-Packet Attack

I had solved this challenge once before the Burp route above, with a small Python script. Unfortunately lost the draft of that writeup in a OS reinstallation accident. The two approaches are worth knowing side by side. The idea is the same, to race /claim, but the delivery differs.

Attempt 1, naive parallel threads: fire 10 bare requests.post() calls at once, each with its own connection. Result: 2 landed, balance 100. That already proved the TOCTOU was real, but not all threads beat the window.

Attempt 2, a barrier with a shared requests.Session(), was worse: only 1 landed. The trap is connection pooling. A Session pools ~10 sockets per host, so 30 threads mostly waited for a socket and the burst arrived in serialized waves, each wave fully committed before the next started.

Attempt 3, HTTP pipelining, won. One connection, 15 requests written back-to-back, then all responses read. All the requests ride one TCP stream, the single-threaded server queues them as a burst before the first handler's write resolves, so they all read a "not claimed yet" state. 4 landed, balance 200, tier Whale.

import http.client, requests


BASE, PORT = "10.10.10.10", 3000
user = "racenew1"  # FRESH, never-claimed account each attempt
r = requests.post(f"http://{BASE}:{PORT}/auth/register",
                  json={"username": user, "password": "pass"}, allow_redirects=False)
cookie = "; ".join(f"{c.name}={c.value}" for c in r.cookies)


conn = http.client.HTTPConnection(BASE, PORT)
for _ in range(15):  # pipeline 15 claims, no waiting between them
    conn.putrequest("POST", "/claim")
    conn.putheader("Host", f"{BASE}:{PORT}")
    conn.putheader("Cookie", cookie)
    conn.putheader("Content-Length", "0")
    conn.endheaders()
for _ in range(15):  # now read all responses in order
    resp = conn.getresponse()
    print(resp.status, resp.read(160))
print("ME:", requests.get(f"http://{BASE}:{PORT}/dashboard/api/me",
                          cookies={c.name: c.value for c in r.cookies}).text)

That's the single-packet attack, the same technique Turbo Intruder automates in Burp. The Repeater group-send route is the click-based way to the same place.