Datei-Uploads und Pfad-Manipulation sind klassische High-Impact-Klassen. Ein erfolgreicher Upload mit ausführbarer Datei in einem Web-Root führt zu Remote Code Execution. Pfad-Traversal-Tricks (../../../etc/passwd) erlauben arbitrary File Read außerhalb des erlaubten Verzeichnisses. Plus die spezielle Klasse ZIP-Slip, bei der Archiv-Einträge in beliebige Pfade entpacken. Dieser Artikel zeigt die Vektoren und die strukturellen Verteidigungen.
Pfad-Traversal — Grundklasse
Die einfachste Form: ein User-kontrollierter Wert fließt als Teil eines Dateipfads, ohne dass das Backend die ..-Sequenzen prüft.
// Schadhaft
app.get('/files/:name', (req, res) => {
const filePath = path.join('/var/data/uploads', req.params.name);
res.sendFile(filePath);
});Mit name = "../../../../etc/passwd" wird der Pfad zu /var/data/uploads/../../../../etc/passwd — was /etc/passwd auflöst. Der Server liefert die Datei aus.
Bypass-Varianten:
../../../etc/passwd
..%2f..%2f..%2fetc%2fpasswd (URL-encoded)
..%252f..%252f..%252fetc%252fpasswd (double URL-encoded)
..%c0%af..%c0%af..%c0%afetc%c0%afpasswd (UTF-8 overlong, alte Server)
..\..\..\etc\passwd (Windows mit Backslash)
%00../../etc/passwd (NULL-Byte, alte PHP/C-Bindings)Filter-Listen sind unzuverlässig — neue Encoding-Varianten tauchen regelmäßig auf.
Strukturelle Lösung: Path-Resolution + Prefix-Check
Statt zu filtern, resolve den Pfad und prüfe, ob er im erlaubten Verzeichnis liegt.
Node.js:
const path = require('path');
const fs = require('fs');
const UPLOAD_DIR = path.resolve('/var/data/uploads');
app.get('/files/:name', (req, res) => {
const candidate = path.resolve(UPLOAD_DIR, req.params.name);
// Strikter Prefix-Check
if (!candidate.startsWith(UPLOAD_DIR + path.sep)) {
return res.status(400).send('Invalid path');
}
res.sendFile(candidate);
});Python:
from pathlib import Path
UPLOAD_DIR = Path('/var/data/uploads').resolve()
def safe_file_path(name: str) -> Path:
candidate = (UPLOAD_DIR / name).resolve()
if not candidate.is_relative_to(UPLOAD_DIR):
raise ValueError('Path outside upload dir')
return candidateGo:
uploadDir, _ := filepath.Abs("/var/data/uploads")
candidate, err := filepath.Abs(filepath.Join(uploadDir, name))
if err != nil || !strings.HasPrefix(candidate, uploadDir+string(filepath.Separator)) {
return errors.New("invalid path")
}Allgemeines Pattern: Pfad resolven (alle .. und Symlinks auflösen), dann prüfen, dass das Ergebnis im erlaubten Prefix liegt. Niemals nur String-Replace .. zu "".
Noch besser — IDs statt Pfade:
Die robusteste Lösung: User bekommt keine Dateinamen, sondern IDs. Der Server löst ID auf zu Pfad serverseitig.
// URL: /files/abc123 (ID, kein Pfad)
app.get('/files/:id', async (req, res) => {
const fileRecord = await db.files.findOne({ id: req.params.id });
if (!fileRecord) return res.status(404).send();
// Serverseitig kontrollierter Pfad — User hat keine Pfad-Kontrolle
res.sendFile(path.join(UPLOAD_DIR, fileRecord.storedFilename));
});Damit ist Pfad-Traversal strukturell unmöglich — User-Input fließt nie in den Pfad.
File-Upload — Was kann schief gehen
Datei-Uploads haben mehrere unabhängige Klassen, die alle gleichzeitig adressiert werden müssen:
| Klasse | Wirkung | Schutz |
|---|---|---|
| RCE durch ausführbare Datei in Web-Root | Hochgeladenes shell.php läuft als Web-Request | Niemals in ausführbares Verzeichnis speichern |
| Stored XSS via SVG/HTML | Upload mit <script> wird beim Abruf gerendert | Content-Type-Header korrekt setzen, separater Hostname |
| Path-Traversal im Dateinamen | Datei wird in fremdes Verzeichnis gespeichert | Filename komplett neu generieren |
| DoS durch große Datei | Server-Disk voll, OOM | Size-Limit auf HTTP-Layer und nach Decompress |
| Polyglotte Datei | Datei ist gleichzeitig valid JPG + HTML mit Script | Magic-Bytes prüfen, Re-Encoding |
| Tool-Vulnerability (ImageMagick etc.) | Upload triggert RCE in Verarbeitungs-Tool | Tool-Sandbox, Policy-File, Updates |
| Inhaltlicher Missbrauch | CSAM, Malware-Verteilung | Hash-Check, AV-Scan, Moderations-Queue |
Sicheres Upload-Pattern
Vollständige Verteidigung als Pseudocode:
// 1. Größen-Limit auf Reverse-Proxy (nginx: client_max_body_size 10M)
// und im App-Framework (Express: limit in multer)
// 2. Erlaubte Content-Types per Allowlist
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
async function handleUpload(req, res) {
const file = req.file;
// 3. Magic-Byte-Check — Content-Type-Header ist User-kontrolliert
const detected = await fileType.fromBuffer(file.buffer);
if (!detected || !ALLOWED_MIME.has(detected.mime)) {
return res.status(400).send('File type not allowed');
}
// 4. Filename komplett neu generieren — niemals user-supplied
const id = crypto.randomUUID();
const ext = detected.ext; // aus Magic-Byte-Check, nicht aus Filename
const storedName = `${id}.${ext}`;
// 5. In nicht-ausführbares Verzeichnis speichern (außerhalb Web-Root)
const storagePath = path.join('/var/data/uploads', storedName);
await fs.promises.writeFile(storagePath, file.buffer);
// 6. Optional: Re-Encoding für Bilder (entfernt versteckte Payloads)
// await sharp(file.buffer).rotate().toFile(storagePath);
// 7. DB-Eintrag mit Mapping ID → storedName
await db.files.insert({ id, originalName: file.originalname,
storedName, mime: detected.mime });
res.json({ id });
}
// 8. Beim Abruf: ID, nicht Pfad. Content-Disposition: attachment für riskante TypenDie wichtigsten Punkte:
- Extension-Check ist nicht genug — Browser ignoriert oft die Extension und nutzt Magic-Byte zur Inhaltserkennung. Eine
.jpg-Datei mit HTML-Inhalt kann als HTML gerendert werden. - Content-Type-Header vom Client ist User-kontrolliert — niemals darauf verlassen.
- Filename neu generieren — User-Filenames können
../, NULL-Bytes, Unicode-Tricks, Reservierte Namen (CON,NULin Windows), exec-Extensions (.php,.jsp,.cgi) enthalten. - Niemals in Web-Root speichern — wenn Apache/nginx den Pfad direkt liest und PHP/CGI-Handler greifen, ist RCE da.
Magic-Byte-Validierung
Magic Bytes sind die ersten Bytes einer Datei, die ihren tatsächlichen Inhaltstyp signalisieren:
FF D8 FF JPEG
89 50 4E 47 PNG (".PNG")
47 49 46 38 (37|39) 61 GIF87a / GIF89a
25 50 44 46 PDF ("%PDF")
50 4B 03 04 ZIP / DOCX / XLSX (Office-Container)
7F 45 4C 46 ELF (Linux executable)
4D 5A PE / EXE (Windows executable)
23 21 Shebang ("#!") — Script
3C 73 76 67 SVG ("<svg" am Anfang)Tools:
- Node.js: file-type — erkennt 100+ Formate.
- Python:
python-magic(libmagic-Wrapper). - Go:
net/http.DetectContentType(eingeschränkt) oderh2non/filetype. - CLI:
file <pfad>auf Linux/macOS — gibt erkannten Typ.
Wichtig: Magic-Byte-Check schützt nicht vor polyglotten Dateien (siehe nächster Abschnitt) und nicht vor schadhaftem Inhalt innerhalb eines validen Formats (z. B. EXIF-Injection in JPEG).
Polyglotte Dateien
Eine polyglotte Datei ist gleichzeitig valides Format A und valides Format B. Typische Kombinationen:
- JPEG + JavaScript — Magic-Byte ist
FFD8FF, JS interpretiert das als Zahl-Literal-Stub und überspringt. Restlicher Inhalt ist JS-Code. - GIF + HTML —
GIF89aist gleichzeitig CSS-Comment-Anfang oder kann in HTML eingebettet werden. - PDF + ZIP — beide Container-Formate, ZIP-Magic kann an verschiedenen Offsets sein.
- PNG + PHP —
<?php-Code in PNG-Metadata-Chunk (tEXt). PHP-Interpreter scannt Datei nach<?php-Tag, ignoriert vorherigen Inhalt.
Realer Vektor: Eine SVG-Datei mit eingebettetem <script> ist gleichzeitig valides Bild — wenn sie als Content-Type: image/svg+xml und im selben Hostname wie die Web-App ausgeliefert wird, läuft das Skript mit voller Same-Origin-Berechtigung.
Schutz:
- Re-Encoding — Bilder durch eine Bibliothek (sharp, Pillow, ImageMagick mit Policy) re-kodieren. Eingebettete Payloads werden bei der Re-Kodierung entfernt.
const sharp = require('sharp');
// Re-encode strippt alle Metadaten und macht Polyglot-Tricks unmöglich
await sharp(uploadBuffer)
.rotate() // auto-orient, dann strip exif
.withMetadata({ exif: {} }) // metadata entfernen
.toFormat('jpeg', { quality: 85 })
.toFile(storagePath);-
SVG niemals als hochgeladenes Bild akzeptieren — SVG ist XML mit Script-Möglichkeit. Wenn SVG zwingend nötig, dann sanitizen mit DOMPurify und vor allem nicht auf dem App-Hostname ausliefern.
-
Separater Hostname für User-Content (
usercontent.example.com) — selbst wenn jemand HTML/JS hochlädt, läuft es nicht im Origin der App. Cookies und Same-Origin-Daten der App sind geschützt.
ZIP-Slip — Archiv-Entpackung
ZIP-Slip (Snyk-Disclosure 2018) ist eine Klasse, die alle Archiv-Formate (ZIP, TAR, RAR, 7z) betrifft. Archiv-Einträge können relative Pfade enthalten — ein Eintrag mit Namen ../../etc/cron.d/evil entpackt außerhalb des Ziel-Verzeichnisses.
Schadhaftes Pattern:
const unzipper = require('unzipper');
// Schadhaft — Archiv-Pfad nicht validiert
fs.createReadStream(uploadedZip)
.pipe(unzipper.Extract({ path: '/var/data/uploads' }));Wenn das ZIP einen Eintrag ../../etc/cron.d/evil enthält, landet er als /etc/cron.d/evil auf dem Server. RCE.
Schutz:
const path = require('path');
const unzipper = require('unzipper');
const TARGET = path.resolve('/var/data/uploads');
const zip = await unzipper.Open.file(uploadedZip);
for (const entry of zip.files) {
const dest = path.resolve(TARGET, entry.path);
// Prefix-Check für jeden Eintrag
if (!dest.startsWith(TARGET + path.sep)) {
throw new Error(`Unsafe entry: ${entry.path}`);
}
// Plus: Size-Check (Zip Bomb)
if (entry.uncompressedSize > 100 * 1024 * 1024) {
throw new Error('Entry too large');
}
await entry.stream().pipe(fs.createWriteStream(dest));
}Python (tarfile / zipfile):
# Python 3.12+ hat filter='data' Parameter, der path-traversal blockiert
with tarfile.open(uploaded_path) as tar:
tar.extractall(path=target_dir, filter='data')
# Für ältere Versionen: manuell prüfen
with zipfile.ZipFile(uploaded_path) as zf:
for member in zf.infolist():
dest = (Path(target_dir) / member.filename).resolve()
if not dest.is_relative_to(Path(target_dir).resolve()):
raise ValueError(f"Unsafe entry: {member.filename}")
zf.extract(member, target_dir)Zusätzlich: Zip-Bomb-Schutz — ein 42KB-ZIP kann zu Terabytes entpacken. Limits auf entpackte Gesamtgröße und Anzahl Entries setzen.
Reale Vorfälle
ImageTragick (2016) — siehe command-injection. Hochgeladene SVG-/MVG-Datei trickste ImageMagick-Delegates zu RCE.
WordPress Plugin-CVEs (laufend) — File-Upload-Bugs in Plugins sind eine der häufigsten WordPress-Lücken. Klassischer Vektor: Upload-Endpoint ohne Auth, Filename mit .php-Extension wird in Uploads-Ordner gelegt, der Web-Root-Zugriff erlaubt.
Apache Struts2 RCE-CVEs — File-Upload in Struts hatte mehrere Phasen schwerwiegender CVEs, eine davon (CVE-2017-5638) führte zum Equifax-Datenleck (147 Mio. Kreditdaten).
ZIP-Slip (2018) — von Snyk koordiniert offenbart, betraf hunderte Java-, Ruby-, .NET-, Go-, JavaScript- und Python-Libraries. Massen-Patch über Wochen.
Discord, Slack, Teams — alle Plattformen haben polyglotten-Datei-Probes regelmäßig in Bug-Bounty-Reports. Auszahlungen oft sechsstellig.
Häufige Stolperfallen
Extension-Allowlist reicht nicht ohne Magic-Byte-Check
Eine Allowlist ['.jpg', '.png', '.pdf'] ist Pflicht, aber nicht ausreichend. User-Filename ist trivial fälschbar. Magic-Byte-Check ist die zweite Schicht; idealerweise Re-Encoding für Bilder als dritte.
Separater Hostname für User-Content ist die strukturellste Verteidigung
Wenn User-Uploads unter usercontent.example.com liegen statt www.example.com, läuft Stored-XSS via SVG/HTML in einem fremden Origin — Cookies, LocalStorage, IndexedDB der App sind unzugänglich. GitHub macht das mit githubusercontent.com, Google mit googleusercontent.com. Für jede ernsthafte App empfohlen.
Windows reservierte Namen sind ein eigenes Problem
CON, PRN, AUX, NUL, COM1..COM9, LPT1..LPT9 sind reservierte Dateinamen in Windows — selbst mit Extension (CON.txt). Cross-Plattform-Apps müssen das filtern. Plus: führende/trailing Whitespace, Punkte, Sonderzeichen wie : sind in Windows verboten.
Content-Disposition: attachment für riskante Typen
Wenn HTML/SVG/PDF-Inhalt zurückgegeben wird, Header Content-Disposition: attachment; filename="..." setzen. Browser zeigt dann Download-Dialog statt zu rendern. Verhindert Stored-XSS-Auslösung bei Direct-Link-Klicks.
EXIF-Metadaten können sensitive Info enthalten
Hochgeladene Fotos haben oft GPS-Koordinaten, Geräte-Modell, Aufnahme-Zeitpunkt in EXIF. Wenn die App das Bild unverändert zurückgibt, leaken diese Daten. Re-Encoding (siehe oben) strippt EXIF — Default-Verhalten in modernen Image-Libraries.
Antivirus-Scan ist Defense-in-Depth, kein Primärschutz
ClamAV oder Cloud-AV-APIs (VirusTotal, Microsoft Defender) als Pre-Storage-Filter sind hilfreich, aber bekannte Lücken: 0-Day-Malware wird oft nicht erkannt, polyglotte Dateien können Detection umgehen. AV-Scan ja, aber nicht statt sauberer Pipeline-Architektur.
Zip-Bomb-Limits auf mehreren Ebenen
Ein Zip-Bomb kann 4.5 PB aus 46 KB packen. Limits brauchst du auf: (1) HTTP-Upload-Größe (Reverse-Proxy), (2) entpackte Gesamtgröße, (3) entpackte Datei-Anzahl, (4) maximale Verschachtelungs-Tiefe für rekursive Archive. Pro Limit eine separate Schutz-Schicht.
Weiterführende Ressourcen
Externe Quellen
- OWASP File Upload Cheat Sheet
- OWASP — Path Traversal
- Snyk — Zip Slip Research
- PortSwigger Web Security Academy — File Upload Vulnerabilities
- file-type (Node.js) — Magic-Byte-Detection
- python-magic — libmagic-Wrapper
- Python tarfile extraction filter (Python 3.12+)
- Zip-Bomb-Sammlung von David Fifield
Verwandte Artikel
- Injection-Grundlagen
- Command-Injection (ImageMagick-Vektor)
- SSRF und Cloud-Metadata (Kap 18 — URL-Fetch-Klasse)
- XXE und unsichere Deserialisierung (Kap 10 — verwandte Archiv-Klasse)
- Content-Security-Policy (Kap 11 — SVG-Schutz)