Zum Inhalt springen

Unbeschränkter Datei-Upload

CWE-434CWE-400OWASP A04:2021Aktualisiert 4. September 20265 Min. Lesezeit

Eine Uploadfunktion, die jeden Dateityp annimmt und das Ergebnis innerhalb des Webroots ablegt, kann einem Angreifer die Möglichkeit geben, Code auf Ihrem Server auszuführen. Eine Prüfung des Dateinamens oder des angegebenen Content-Type genügt nicht, da der Nutzer beides bestimmt. Es zählt, wo die Datei landet und wie sie zurückgegeben wird.

Eine Uploadfunktion ist eine der wenigen Stellen, an denen Sie einem Nutzer erlauben, eine Datei auf Ihrem Server abzulegen. Alles hängt danach an zwei Fragen: Was genau wurde gespeichert, und was macht der Server damit, wenn jemand es abruft? Im Folgenden lesen Sie, warum die üblichen Prüfungen von Name und Typ nicht genügen und welche Maßnahmen den Unterschied machen.

Was ist ein unbeschränkter Datei-Upload?

Von einem unbeschränkten Datei-Upload sprechen wir, wenn eine Anwendung Dateien annimmt, ohne hinreichend zu prüfen, was geliefert wird, und diese Dateien anschließend so ablegt, dass der Webserver sie verarbeiten kann. Der Schweregrad liegt selten im Hochladen selbst; er liegt in der Verbindung mit dem Ablageort und der Art, wie die Datei später zurückgegeben wird.

Diese Unterscheidung ist wichtig, denn sie bestimmt, wo die Lösung ansetzt. Eine PHP-Datei auf einer Festplatte, mit der der Webserver nichts zu tun hat, ist eine harmlose Textdatei. Dieselbe Datei in einem Verzeichnis, das der Webserver ausliefert, ist eine Fernbedienung für Ihren Server.

Denken Sie an eine Poststelle, die Pakete annimmt. Das Problem ist nicht, dass ein Paket eintrifft; das Problem ist, dass es ungeöffnet in einen Raum weitergereicht wird, in dem der Inhalt automatisch in Kraft tritt. Die Prüfung gehört vor diesen Ort, und der Ort selbst sollte so gewählt sein, dass dort überhaupt nichts in Kraft treten kann.

Wie geht ein Datei-Upload schief?

Verwundbar:

const multer = require('multer');

// Speichert unter dem vom Nutzer angegebenen Namen, im Webroot
const upload = multer({ dest: './public/uploads' });

app.post('/profil/foto', upload.single('datei'), (req, res) => {
  const ziel = path.join('./public/uploads', req.file.originalname);

  if (req.file.mimetype.startsWith('image/')) {     // vom Client bestimmt
    fs.renameSync(req.file.path, ziel);
    return res.send('Hochgeladen nach /uploads/' + req.file.originalname);
  }
  res.status(400).send('Nur Bilder');
});

Drei Fehler verstärken einander. Die Prüfung nutzt mimetype, einen Wert, den der Client mitsendet und damit frei wählen kann. Der Dateiname stammt unverändert vom Nutzer. Und das Ergebnis landet in public/, wo der Webserver es ausliefert. Ein Angreifer sendet:

POST /profil/foto HTTP/1.1
Content-Type: multipart/form-data; boundary=x

--x
Content-Disposition: form-data; name="datei"; filename="shell.php"
Content-Type: image/png

<?php system($_GET['c']); ?>
--x--

Der angegebene Typ ist image/png, die Prüfung lässt es also durch. Die Datei landet unter /uploads/shell.php, und eine Anfrage an https://portal.example/uploads/shell.php?c=whoami führt Befehle auf Ihrem Server aus. Vom Profilfoto zur vollständigen Kontrolle in einer einzigen Anfrage.

Sicher:

const ERLAUBT = new Map([
  ['image/jpeg', 'jpg'],
  ['image/png', 'png'],
  ['application/pdf', 'pdf'],
]);

const upload = multer({
  dest: '/var/ablage/temporaer',              // außerhalb des Webroots
  limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});

app.post('/profil/foto', upload.single('datei'), async (req, res) => {
  // Typ aus dem tatsächlichen Inhalt bestimmen, nicht aus der Behauptung des Clients
  const typ = await bestimmeTypAusInhalt(req.file.path);
  const endung = ERLAUBT.get(typ?.mime);

  if (!endung) {
    await fs.promises.unlink(req.file.path);
    return res.status(400).send('Dateityp nicht zulässig');
  }

  // Der Name des Nutzers wird vollständig verworfen
  const name = `${randomUUID()}.${endung}`;
  await fs.promises.rename(req.file.path, `/var/ablage/uploads/${name}`);
  await dateien.registriere({ name, eigentuemer: req.nutzer.id, typ: typ.mime });

  res.json({ id: name });
});

// Ausliefern über eine Route, die autorisiert und nichts ausführen lässt
app.get('/datei/:id', async (req, res) => {
  const datei = await dateien.finde(req.params.id);
  if (!datei || !darfSehen(req.nutzer, datei)) return res.sendStatus(404);

  res.set('Content-Type', datei.typ);
  res.set('X-Content-Type-Options', 'nosniff');
  res.set('Content-Disposition', 'attachment');
  res.set('Content-Security-Policy', "default-src 'none'; sandbox");
  res.sendFile(`/var/ablage/uploads/${datei.name}`);
});

Die Verteidigung besteht hier aus Schichten, die den Angriff jeweils für sich bereits brechen. Der Typ wird aus dem tatsächlichen Inhalt der Datei bestimmt. Der Name des Nutzers wird vollständig durch einen Zufallswert ersetzt, was zugleich Path Traversal und Überschreiben ausschließt. Die Ablage liegt außerhalb des Webroots, der Webserver kann die Datei also nicht ausführen. Und beim Ausliefern wird autorisiert, das Sniffing abgeschaltet und die Datei als Download angeboten.

Prüfen Sie nie allein das, was der Nutzer behauptet. Sowohl der Dateiname als auch der Content-Type in einem Upload sind vollständig vom Angreifer bestimmbar. Die einzigen verlässlichen Quellen sind der tatsächliche Inhalt der Datei und der Ort, an dem Sie sie ablegen.

Welche Auswirkungen hat ein unbeschränkter Datei-Upload?

Der Schweregrad ist hoch bis kritisch, was dies zu einem der gewichtigeren Befunde in einer Webanwendung macht. Der Grund ist der unmittelbare Weg zur Codeausführung auf dem Server.

Gelingt es einem Angreifer, eine ausführbare Datei an einer Stelle abzulegen, die der Webserver verarbeitet, ist das Ergebnis vollständige Kontrolle über den Anwendungsserver: das Auslesen der Datenbank, der Zugriff auf Konfigurationsdateien mit Zugangsdaten und ein Ausgangspunkt für die weitere Erkundung des internen Netzes. Ab diesem Punkt lautet die Frage nicht mehr, was mit der Anwendung geschehen kann, sondern was mit der Infrastruktur geschehen kann.

Auch ohne Codeausführung bleiben reale Folgen. Eine Datei, die als HTML zurückgegeben wird, ergibt Cross-Site Scripting auf Ihrer eigenen Domain. Eine unbegrenzte Dateigröße erlaubt es, Ihren Speicher vollzuschreiben. Ein Dateiname mit Pfadzeichen kann bestehende Dateien überschreiben. Und Ihre Plattform kann zum Verbreiten von Schadsoftware missbraucht werden, wobei Ihr Domainname die Glaubwürdigkeit liefert.

Wie spürt man einen unbeschränkten Datei-Upload auf?

Ein Tester stellt zunächst fest, welche Prüfungen bestehen: Wird eine Datei anhand der Endung, des angegebenen Content-Type oder des Inhalts abgewiesen? Das wird ausprobiert, indem ein harmlos wirkendes Bild mit geändertem Namen angeboten wird und umgekehrt eine ausführbare Datei mit einem Bildtyp.

Anschließend geht es um die Frage, wo die Datei landet und ob sie erreichbar ist. Lässt sich die URL ermitteln oder erraten? Bleibt der ursprüngliche Name erhalten? Lässt sich mit Pfadzeichen im Namen außerhalb des Zielverzeichnisses schreiben? Wird die Datei mit einem aus dem Namen abgeleiteten Content-Type zurückgegeben? Ebenso werden die klassischen Umwege geprüft: doppelte Endungen, Varianten in der Groß- und Kleinschreibung, Endungen, die der Server sehr wohl kennt, die Prüfliste jedoch nicht, sowie Dateien, die zugleich ein gültiges Bild und gültiger Code sind. AssistSec beurteilt ausdrücklich die Auslieferungsroute, denn eine strenge Uploadprüfung ist wenig wert, wenn die Dateien von derselben Domain und ohne Autorisierung ausgeliefert werden.

Wie verhindert man einen unbeschränkten Datei-Upload?

  • Arbeiten Sie mit einer Positivliste von Dateitypen und bestimmen Sie den Typ aus dem tatsächlichen Inhalt, nicht aus Name oder Header.
  • Ersetzen Sie den vom Nutzer angegebenen Dateinamen vollständig durch einen selbst erzeugten Wert.
  • Legen Sie Uploads außerhalb des Webroots ab oder in einem Objektspeicher ohne Ausführungsmöglichkeit.
  • Liefern Sie Dateien über eine Route aus, die autorisiert, statt das Verzeichnis unmittelbar freizugeben.
  • Senden Sie beim Ausliefern X-Content-Type-Options: nosniff und Content-Disposition: attachment mit.
  • Liefern Sie von Nutzern bereitgestellte Dateien nach Möglichkeit von einer eigenen Domain aus.
  • Begrenzen Sie Dateigröße, Anzahl der Dateien und das Gesamtvolumen je Nutzer.
  • Verarbeiten Sie Bilder neu (etwa durch Skalieren), wodurch eingebetteter Code verschwindet.
  • Schalten Sie die Skriptausführung im Uploadverzeichnis ab, als zusätzliche Schicht über der Ablage außerhalb des Webroots.
  • Prüfen Sie Uploads mit einem Virenscanner als Ergänzung, nicht als Grundlage.

Quellen

Häufige Fragen

Genügt eine Prüfung der Dateiendung nicht?

Nein, aus zwei Gründen. Der Nutzer bestimmt den Dateinamen, dieser sagt also nichts über den Inhalt aus. Und je nach Serverkonfiguration können Namen wie datei.php.jpg oder datei.php%00.jpg dennoch als Code ausgeführt werden. Arbeiten Sie mit einer Positivliste und prüfen Sie den tatsächlichen Inhalt.

Kann ich dem Content-Type des Browsers vertrauen?

Nein. Der Header Content-Type in einem Upload wird vom Client mitgesendet und ist vollständig vom Angreifer bestimmbar. Er kann eine ausführbare Datei mit dem Typ image/png anbieten. Bestimmen Sie den Typ serverseitig anhand des tatsächlichen Inhalts.

Warum ist die Ablage außerhalb des Webroots so wichtig?

Weil eine Datei, die nicht über eine URL erreichbar ist, vom Webserver auch nicht ausgeführt werden kann. Selbst wenn es einem Angreifer gelingt, eine Skriptdatei hochzuladen, kann er sie nicht aufrufen. Liefern Sie Dateien über eine Route aus, die sie einliest und weitergibt, statt das Verzeichnis freizugeben.

Hilft ein Virenscanner?

Als zusätzliche Schicht ja, als Grundlage nicht. Ein Scanner erkennt bekannte Schaddateien und übersieht gezielte oder neue Varianten. Zudem löst er das Kernproblem nicht: Eine völlig saubere PHP-Datei ist kein Virus, wohl aber verhängnisvoll, wenn der Server sie ausführt.

Verwandte Artikel

Zum Suchen / drücken · Esc