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.

JavaScript path-traversal-vulnerable.js
// 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:

Plain traversal-payloads.txt
../../../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:

JavaScript path-traversal-secure-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:

Python path-traversal-secure-python.py
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 candidate

Go:

Go path-traversal-secure-go.go
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.

JavaScript path-id-mapping.js
// 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:

KlasseWirkungSchutz
RCE durch ausführbare Datei in Web-RootHochgeladenes shell.php läuft als Web-RequestNiemals in ausführbares Verzeichnis speichern
Stored XSS via SVG/HTMLUpload mit <script> wird beim Abruf gerendertContent-Type-Header korrekt setzen, separater Hostname
Path-Traversal im DateinamenDatei wird in fremdes Verzeichnis gespeichertFilename komplett neu generieren
DoS durch große DateiServer-Disk voll, OOMSize-Limit auf HTTP-Layer und nach Decompress
Polyglotte DateiDatei ist gleichzeitig valid JPG + HTML mit ScriptMagic-Bytes prüfen, Re-Encoding
Tool-Vulnerability (ImageMagick etc.)Upload triggert RCE in Verarbeitungs-ToolTool-Sandbox, Policy-File, Updates
Inhaltlicher MissbrauchCSAM, Malware-VerteilungHash-Check, AV-Scan, Moderations-Queue

Sicheres Upload-Pattern

Vollständige Verteidigung als Pseudocode:

JavaScript secure-upload-pattern.js
// 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 Typen

Die 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, NUL in 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:

Plain magic-bytes-table.txt
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) oder h2non/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 + HTMLGIF89a ist 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:

  1. Re-Encoding — Bilder durch eine Bibliothek (sharp, Pillow, ImageMagick mit Policy) re-kodieren. Eingebettete Payloads werden bei der Re-Kodierung entfernt.
JavaScript image-reencoding.js
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);
  1. 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.

  2. 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:

JavaScript zip-slip-vulnerable.js
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:

JavaScript zip-slip-secure.js
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 zip-slip-secure-python.py
# 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

/ Weiter

Zurück zu Injection (SQL/NoSQL/Cmd)

Zur Übersicht