MIME-Spoofing erkennen, Magic Bytes prüfen, Ausführung im Upload-Verzeichnis verhindern
Upload-Formulare zählen zu den gefährlichsten Eintrittspunkten in Webanwendungen, weil Angreifer Dateien gezielt so präparieren, dass sie jede oberflächliche Prüfung bestehen. Wer sich allein auf Dateiendung und Content-Type verlässt, öffnet die Tür für Webshells und ausführbaren Code. Dieser Artikel zeigt, wie Magic-Byte-Validierung, sichere Speicherung außerhalb des Webroots, Dateinamen-Sanitization und Bild-Reencoding echten Schutz aufbauen.
Inhaltsverzeichnis
- 1. Warum naive Upload-Validierung scheitert
- 2. MIME-Type-Spoofing: Warum Endung und Content-Type allein nicht reichen
- 3. Magic-Byte-Validierung richtig umgesetzt
- 4. Uploads außerhalb des Webroots und in nicht ausführbaren Verzeichnissen speichern
- 5. Dateinamen-Sanitization gegen Path Traversal
- 6. Bild-Reencoding gegen eingebettete Skripte und Polyglot-Payloads
- 7. Webserver-Hardening für Upload-Verzeichnisse
- 8. Magento-spezifische Absicherung von Medien-Uploads
- 9. Validierungsansätze im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum naive Upload-Validierung scheitert
Ein Upload-Formular ist aus Sicht eines Angreifers keine Ausnahme, sondern eine der direktesten Möglichkeiten, eigenen Code auf einen Server zu bringen. Die naive Annahme vieler Entwickler lautet: Wenn die Dateiendung stimmt und der Content-Type passt, ist die Datei sicher. Genau diese Annahme wird gezielt ausgenutzt. Ein Angreifer testet systematisch alternative PHP-Endungen wie .phtml, .php5, .pht oder .phar, manipuliert den Content-Type-Header im Multipart-Request und packt ausführbaren Code in Dateien, die auf den ersten Blick wie harmlose Bilder aussehen.
Wirksame Absicherung von Datei-Uploads funktioniert nur als mehrschichtiges System, nicht als einzelne Prüfung. Jede Schicht deckt eine andere Angriffsvariante ab: Whitelisting der erlaubten Endungen verhindert offensichtliche Fälle, Magic-Byte-Prüfung erkennt gefälschte Dateitypen, Speicherung außerhalb des Webroots verhindert die Ausführung selbst bei erfolgreichem Bypass, und Reencoding zerstört eingebetteten Code in Bilddateien vollständig. Wer nur eine dieser Schichten implementiert, baut eine Sicherheitsmaßnahme, die mit dem ersten gezielten Angriff fällt. Die folgenden Abschnitte gehen jede Schicht einzeln durch.
2. MIME-Type-Spoofing: Warum Endung und Content-Type allein nicht reichen
Der Content-Type-Header eines Multipart-Uploads wird vollständig vom Client gesetzt und ist damit ein reiner Vorschlag, kein verlässliches Signal. Ein Angreifer kann mit curl oder einem manipulierten Formular jeden beliebigen Wert senden, etwa image/jpeg für eine Datei, die tatsächlich PHP-Code enthält. Serverseitige Anwendungen, die $_FILES['datei']['type'] in PHP oder ähnliche Client-Metadaten ungeprüft übernehmen, validieren in Wirklichkeit gar nichts, sondern vertrauen blind der Angabe des Angreifers.
Auch die Dateiendung ist kein verlässliches Kriterium. Doppelte Endungen wie shell.php.jpg werden von manchen Serverkonfigurationen fälschlich als PHP ausgeführt, wenn Apache über AddHandler jede Endung im Dateinamen prüft statt nur die letzte. Null-Byte-Injection in älteren PHP-Versionen erlaubte es, Prüfungen mit shell.php%00.jpg zu umgehen. Case-Variationen wie .PhP oder alternative ausführbare Endungen wie .phtml werden von unvollständigen Blacklists oft übersehen. Deshalb gilt: Endung und Content-Type sind Hinweise für die Benutzerführung, niemals eine Sicherheitsgrenze.
3. Magic-Byte-Validierung richtig umgesetzt
Magic Bytes sind die ersten Bytes einer Datei, die das tatsächliche Dateiformat unabhängig vom Dateinamen identifizieren. Ein echtes PNG beginnt immer mit der Byte-Sequenz 89 50 4E 47, ein JPEG mit FF D8 FF. PHP stellt mit der fileinfo-Extension und der Funktion finfo_file() eine zuverlässige Methode bereit, den echten MIME-Type anhand dieser Signatur zu bestimmen, statt sich auf Client-Angaben zu verlassen. Diese Prüfung muss serverseitig direkt nach dem Empfang der temporären Upload-Datei erfolgen, bevor irgendeine weitere Verarbeitung stattfindet.
Magic-Byte-Prüfung ist notwendig, aber nicht ausreichend, denn sogenannte Polyglot-Dateien sind gleichzeitig gültige Bilder und gültige Skripte. Eine GIF-Datei kann mit einem gültigen GIF-Header beginnen und dennoch PHP-Code enthalten, der ausgeführt wird, sobald die Datei mit einer .php-Endung auf einem PHP-fähigen Server landet. Deshalb muss die Magic-Byte-Prüfung immer mit einer strikten Endungs-Whitelist und einer Storage-Strategie kombiniert werden, die Ausführung grundsätzlich verhindert, unabhängig vom Dateiinhalt.
<?php
declare(strict_types=1);
namespace Mironsoft\Security\Model\Upload;
/**
* Validates uploaded files by inspecting their real file signature (magic bytes)
* instead of trusting the client supplied extension or Content-Type header.
*/
final class MagicByteValidator
{
/**
* Maps allowed MIME types to their expected binary signatures (magic bytes).
*
* @var array<string, string>
*/
private const ALLOWED_SIGNATURES = [
'image/jpeg' => "\xFF\xD8\xFF",
'image/png' => "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A",
'image/gif' => "\x47\x49\x46\x38",
'application/pdf' => "\x25\x50\x44\x46",
];
/**
* Determines the real MIME type of a file using its binary signature,
* never trusting $_FILES['type'] which is fully client controlled.
*
* @param string $tmpFilePath Path to the temporary uploaded file.
* @return string Detected MIME type or 'application/octet-stream' if unknown.
* @throws \RuntimeException If the fileinfo extension is unavailable.
*/
public function detectRealMimeType(string $tmpFilePath): string
{
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo === false) {
throw new \RuntimeException('fileinfo extension is not available');
}
$mimeType = finfo_file($finfo, $tmpFilePath);
finfo_close($finfo);
return $mimeType !== false ? $mimeType : 'application/octet-stream';
}
/**
* Verifies that a file's real MIME type is on the allowed list.
*
* @param string $tmpFilePath Path to the temporary uploaded file.
* @param array<int, string> $allowedMimeTypes Whitelist of accepted MIME types.
* @return bool True if the file's real signature matches an allowed type.
*/
public function isAllowed(string $tmpFilePath, array $allowedMimeTypes): bool
{
$realMimeType = $this->detectRealMimeType($tmpFilePath);
return in_array($realMimeType, $allowedMimeTypes, true);
}
}
4. Uploads außerhalb des Webroots und in nicht ausführbaren Verzeichnissen speichern
Die wirksamste einzelne Maßnahme gegen File-Upload-Angriffe ist nicht die Validierung, sondern die Speicherung: Wenn eine hochgeladene Datei niemals vom Webserver interpretiert werden kann, ist es irrelevant, ob ein Angreifer eine Validierung umgangen hat. Uploads sollten grundsätzlich außerhalb des Document-Roots gespeichert werden, etwa in einem Verzeichnis wie /var/app-storage/uploads/ statt in public/uploads/. Der Zugriff erfolgt dann ausschließlich über ein PHP-Skript, das Berechtigungen prüft, die Datei aus dem geschützten Verzeichnis liest und mit korrekten Headern ausliefert.
Ist eine Ablage außerhalb des Webroots aus architektonischen Gründen nicht möglich, muss das Upload-Verzeichnis explizit als nicht ausführbar konfiguriert werden. Bei Apache verhindert eine .htaccess-Datei mit deaktivierten Handlern die Interpretation von PHP-Dateien im Verzeichnis. Bei nginx erreicht man dasselbe, indem der location-Block für Upload-Pfade PHP-Verarbeitung explizit ausschließt, statt sich auf einen globalen PHP-Handler zu verlassen. Zusätzlich sollten Dateiberechtigungen auf 644 ohne Ausführungsbit gesetzt werden, sodass selbst ein direkter Aufruf über das Betriebssystem keine Skriptausführung ermöglicht.
5. Dateinamen-Sanitization gegen Path Traversal
Der vom Nutzer übermittelte Dateiname darf niemals ungeprüft für Dateisystem-Operationen verwendet werden. Ein Angreifer kann Namen wie ../../etc/cron.d/malicious oder ..\..\config\settings.php einreichen, um mit Path-Traversal-Sequenzen aus dem vorgesehenen Upload-Verzeichnis auszubrechen und beliebige Dateien im System zu überschreiben. Ebenso gefährlich sind Sonderzeichen, Null-Bytes und übermäßig lange Dateinamen, die je nach Dateisystem und Framework zu unerwartetem Verhalten führen können.
Die robuste Lösung generiert serverseitig einen neuen, zufälligen Dateinamen, etwa auf Basis einer UUID oder eines kryptografisch sicheren Zufallswerts, und verwirft den ursprünglichen Namen vollständig bis auf die validierte Dateiendung. Der ursprüngliche Dateiname wird, falls er für die Anzeige benötigt wird, separat in der Datenbank gespeichert, niemals als tatsächlicher Dateisystempfad verwendet. basename() allein reicht nicht aus, da es Path-Traversal-Sequenzen nicht zuverlässig entfernt; die Kombination aus Whitelisting erlaubter Zeichen und vollständiger Neugenerierung des Namens ist der einzig robuste Ansatz.
<?php
declare(strict_types=1);
namespace Mironsoft\Security\Model\Upload;
/**
* Generates safe, unpredictable filenames for uploaded files and rejects
* any path traversal attempt in the originally submitted filename.
*/
final class FilenameSanitizer
{
/**
* Whitelist of file extensions that may be persisted after validation.
*
* @var array<int, string>
*/
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];
/**
* Builds a completely new, random filename and keeps only the validated
* extension of the original file. The original name is never reused
* as part of the filesystem path.
*
* @param string $originalFilename Filename as submitted by the client.
* @return string Safe filename, e.g. "3f2a9c1e8b7d4a10.jpg".
* @throws \InvalidArgumentException If the extension is not on the whitelist.
*/
public function generateSafeFilename(string $originalFilename): string
{
$extension = strtolower(pathinfo($originalFilename, PATHINFO_EXTENSION));
if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
throw new \InvalidArgumentException(sprintf('Extension "%s" is not allowed', $extension));
}
// Cryptographically secure random name, no relation to user input
$randomName = bin2hex(random_bytes(16));
return sprintf('%s.%s', $randomName, $extension);
}
/**
* Rejects filenames containing path traversal sequences or null bytes.
* Use this only as a defensive check before generateSafeFilename();
* it must never be the sole protection against path traversal.
*
* @param string $filename Filename to inspect.
* @return bool True if the filename contains no traversal indicators.
*/
public function isFreeOfTraversalSequences(string $filename): bool
{
if (str_contains($filename, "\0")) {
return false;
}
if (str_contains($filename, '..') || str_contains($filename, '/') || str_contains($filename, '\\')) {
return false;
}
return true;
}
}
6. Bild-Reencoding gegen eingebettete Skripte und Polyglot-Payloads
Selbst nach erfolgreicher Magic-Byte-Prüfung kann eine Bilddatei zusätzliche, für die Bildanzeige irrelevante Daten enthalten, die von einem anfälligen Bestandteil der Infrastruktur ausgeführt werden. EXIF-Metadaten können Skript-Fragmente enthalten, und Polyglot-Dateien sind so konstruiert, dass sie gleichzeitig als gültiges Bild und als gültiges Skript interpretierbar sind. Die zuverlässigste Gegenmaßnahme ist Reencoding: Das hochgeladene Bild wird mit einer Bildbibliothek wie GD oder Imagick vollständig neu dekodiert und in ein neues Bild geschrieben, wobei alle Bytes, die nicht zur eigentlichen Pixelinformation gehören, verworfen werden.
Reencoding entfernt dabei automatisch EXIF-Daten, eingebettete Kommentarfelder und jeglichen Code, der außerhalb der reinen Bilddaten liegt, weil die Zielbibliothek beim Schreiben ausschließlich die dekodierten Pixel neu kodiert. Für SVG-Dateien reicht Reencoding nicht aus, da SVG als XML-Format aktive Inhalte wie <script>-Tags oder externe Referenzen enthalten kann; hier ist stattdessen eine strikte XML-Sanitization mit einer Bibliothek, die aktive Inhalte gezielt entfernt, oder ein grundsätzliches Verbot von SVG-Uploads die sicherere Wahl.
<?php
declare(strict_types=1);
namespace Mironsoft\Security\Model\Upload;
/**
* Re-encodes an uploaded image through GD to strip any embedded scripts,
* EXIF payloads, or polyglot content that is not part of the actual pixels.
*/
final class ImageReencoder
{
/**
* Decodes the source image and writes a brand new JPEG file,
* discarding every byte that is not part of the decoded pixel data.
*
* @param string $sourcePath Path to the validated temporary upload.
* @param string $destinationPath Path the clean, re-encoded file is written to.
* @param int $quality JPEG quality between 0 and 100.
* @return bool True on success.
* @throws \RuntimeException If the source image cannot be decoded.
*/
public function reencodeAsJpeg(string $sourcePath, string $destinationPath, int $quality = 85): bool
{
$imageInfo = getimagesize($sourcePath);
if ($imageInfo === false) {
throw new \RuntimeException('Source file is not a decodable image');
}
$image = match ($imageInfo['mime']) {
'image/jpeg' => imagecreatefromjpeg($sourcePath),
'image/png' => imagecreatefrompng($sourcePath),
'image/gif' => imagecreatefromgif($sourcePath),
default => throw new \RuntimeException('Unsupported source image type'),
};
if ($image === false) {
throw new \RuntimeException('Failed to decode source image');
}
// Flatten transparency onto a white background before writing JPEG
$width = imagesx($image);
$height = imagesy($image);
$flattened = imagecreatetruecolor($width, $height);
imagefill($flattened, 0, 0, imagecolorallocate($flattened, 255, 255, 255));
imagecopy($flattened, $image, 0, 0, 0, 0, $width, $height);
$result = imagejpeg($flattened, $destinationPath, $quality);
imagedestroy($image);
imagedestroy($flattened);
return $result;
}
}
7. Webserver-Hardening für Upload-Verzeichnisse
Neben der Anwendungslogik muss die Webserver-Konfiguration selbst absichern, dass Dateien im Upload-Verzeichnis niemals als Skript interpretiert werden, auch dann nicht, wenn alle anderen Schutzmaßnahmen versagen. Bei nginx erreicht man das über einen dedizierten location-Block, der für den Upload-Pfad jede Weiterleitung an den PHP-FPM-Handler explizit deaktiviert und stattdessen nur statische Auslieferung erlaubt. Bei Apache übernimmt eine .htaccess-Datei im Upload-Verzeichnis mit RemoveHandler und RemoveType für PHP-Endungen dieselbe Aufgabe, vorausgesetzt AllowOverride ist für dieses Verzeichnis aktiviert.
Diese Konfiguration sollte als eigenständige, unabhängige Sicherheitsschicht verstanden werden, die nicht von der Anwendungslogik abhängt und deshalb auch dann greift, wenn ein Deployment-Fehler die Validierung im Code deaktiviert. Zusätzlich empfiehlt sich, den X-Content-Type-Options: nosniff-Header für ausgelieferte Uploads zu setzen, damit Browser den deklarierten Content-Type nicht eigenständig erraten und dadurch unbeabsichtigt HTML oder JavaScript aus einer vermeintlichen Bilddatei ausführen. Regelmäßige automatisierte Tests, die versuchen, eine Testdatei mit PHP-Endung im Upload-Verzeichnis abzurufen, decken Konfigurationsfehler frühzeitig auf.
# nginx: deny PHP execution inside the upload directory
location /media/uploads/ {
location ~ \.php$ {
deny all;
return 403;
}
# Serve only static files, no fastcgi_pass to php-fpm here
try_files $uri =404;
}
# Apache: .htaccess placed inside the upload directory
<FilesMatch "\.(php|phtml|php3|php4|php5|php7|pht|phar)$">
# Disable execution regardless of AddHandler in a parent context
SetHandler none
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .pht .phar
RemoveType .php .phtml .php3 .php4 .php5 .php7 .pht .phar
Require all denied
</FilesMatch>
Options -ExecCGI
AddType text/plain .php .phtml .php3 .php4 .php5 .php7 .pht .phar
8. Magento-spezifische Absicherung von Medien-Uploads
Magento bringt mit Magento\Framework\File\Uploader bereits eine Basis-Validierung mit, die Entwickler jedoch korrekt konfigurieren müssen. Die Methode setAllowedExtensions() definiert eine Whitelist erlaubter Dateiendungen und sollte niemals leer bleiben oder um weitreichende Endungen wie .svg ohne zusätzliche Prüfung erweitert werden. Uploader::checkMimeType() ergänzt eine MIME-Type-Prüfung, ersetzt aber, wie in Abschnitt zwei beschrieben, keine Magic-Byte-Validierung. Der Media-Storage-Pfad pub/media/ liegt im Webroot und ist standardmäßig für Katalogbilder vorgesehen, weshalb nutzergenerierte Uploads aus Kundenbereichen oder individuellen Modulen bewusst in einem separaten, nicht direkt browserzugänglichen Pfad landen sollten.
Admin-Uploads und Storefront-Uploads erfordern unterschiedliche Vertrauensstufen: Ein Administrator mit ACL-Berechtigung für den Produktkatalog gilt als vertrauenswürdiger als ein anonymer Storefront-Besucher, der etwa Anhänge in einem Kontaktformular oder individuelle Produktbilder hochlädt. Für Storefront-Uploads sollte grundsätzlich eine engere Whitelist gelten, verpflichtendes Reencoding für Bilder und eine Speicherung außerhalb von pub/media/. Magento-Module, die eigene Upload-Funktionalität implementieren, sollten den Uploader konsequent mit setAllowRenameFiles(true) und setFilesDispersion(true) konfigurieren, um vorhersehbare Dateinamen und Verzeichnisstrukturen zu vermeiden.
<?php
declare(strict_types=1);
namespace Mironsoft\Security\Model\Upload;
use Magento\Framework\File\Uploader;
use Magento\Framework\File\UploaderFactory;
use Magento\Framework\Filesystem;
use Magento\Framework\Filesystem\DirectoryList;
/**
* Wraps Magento's native Uploader with a strict extension whitelist,
* randomized filenames, and dispersed directory storage.
*/
final class SecureMediaUploader
{
/**
* Constructor with promoted dependencies.
*
* @param UploaderFactory $uploaderFactory Factory for Magento\Framework\File\Uploader.
* @param Filesystem $filesystem Filesystem abstraction to resolve the target directory.
*/
public function __construct(
private readonly UploaderFactory $uploaderFactory,
private readonly Filesystem $filesystem
) {
}
/**
* Uploads a file from a form field into a non public storage path,
* enforcing a strict extension whitelist and randomized naming.
*
* @param string $formFieldName Name of the multipart form field.
* @return array<string, mixed> Result data returned by Magento\Framework\File\Uploader::save().
* @throws \Exception If validation or the upload itself fails.
*/
public function upload(string $formFieldName): array
{
/** @var Uploader $uploader */
$uploader = $this->uploaderFactory->create(['fileId' => $formFieldName]);
// Strict whitelist, never leave this empty or add .svg without extra checks
$uploader->setAllowedExtensions(['jpg', 'jpeg', 'png', 'gif']);
$uploader->setAllowRenameFiles(true);
$uploader->setFilesDispersion(true);
$uploader->checkMimeType(['image/jpeg', 'image/png', 'image/gif']);
// var/storage is outside pub/, never directly reachable via HTTP
$targetDir = $this->filesystem->getDirectoryWrite(DirectoryList::VAR_DIR)
->getAbsolutePath('storage/uploads');
return $uploader->save($targetDir);
}
}
9. Validierungsansätze im Vergleich
Die vorangegangenen Abschnitte zeigen, dass einzelne Validierungsschritte unterschiedlich wirksam sind und teils nur eine Illusion von Sicherheit erzeugen. Die folgende Übersicht stellt unsichere, weit verbreitete Ansätze den empfohlenen, robusten Alternativen gegenüber und macht deutlich, warum mehrschichtige Absicherung in der Praxis unverzichtbar ist.
| Prüfschritt | Unsicherer Ansatz | Empfohlener Ansatz |
|---|---|---|
| Dateiendung | Blacklist einzelner Endungen | Whitelist + Magic-Byte-Prüfung |
| Content-Type | $_FILES['type'] vertrauen | finfo_file() Signaturprüfung |
| Speicherort | Upload in public/uploads/ | Speicherung außerhalb des Webroots |
| Dateiname | Original-Dateiname übernehmen | Zufälliger Dateiname, UUID-basiert |
| Bildverarbeitung | Datei unverändert speichern | Reencoding mit GD/Imagick |
| Webserver-Konfiguration | PHP-Ausführung im Upload-Verzeichnis erlaubt | PHP-Ausführung explizit deaktiviert |
Mironsoft
Sicherheitsaudits, Upload-Härtung und Magento-Absicherung für produktive Shops
Datei-Uploads professionell absichern?
Wir prüfen eure Upload-Formulare auf MIME-Spoofing, fehlende Magic-Byte-Validierung und unsichere Speicherung, und setzen die notwendigen Schutzschichten in PHP- und Magento-Anwendungen gezielt um.
Upload-Audit
Analyse aller Upload-Endpunkte auf MIME-Spoofing, Path Traversal und Ausführungsrisiken
Härtung
Magic-Byte-Validierung, Storage-Isolation und Reencoding nachrüsten
Magento-Absicherung
Uploader-Konfiguration, ACLs und Storefront-Uploads sicher trennen
10. Zusammenfassung
Sichere File-Uploads entstehen nicht durch eine einzelne clevere Prüfung, sondern durch mehrere unabhängige Sicherheitsschichten, die jede für sich einen anderen Angriffsvektor abdecken. MIME-Type und Dateiendung sind reine Hinweise, keine Sicherheitsgrenze, weil beide vollständig vom Client kontrolliert werden. Magic-Byte-Validierung mit finfo_file() erkennt gefälschte Dateitypen zuverlässig, verhindert aber keine Polyglot-Dateien, die gleichzeitig gültiges Bild und gültiges Skript sind.
Die wirksamste Einzelmaßnahme bleibt die Speicherung außerhalb des Webroots oder in einem Verzeichnis, das der Webserver explizit von der PHP-Ausführung ausschließt, kombiniert mit zufällig generierten Dateinamen gegen Path Traversal und Reencoding gegen eingebetteten Code in Bildern. In Magento ergänzt Magento\Framework\File\Uploader mit korrekt konfigurierter Extension-Whitelist diese Schichten, ersetzt sie aber nicht. Wer alle Schichten kombiniert, macht einen erfolgreichen Bypass einzelner Prüfungen praktisch wirkungslos.
Sichere File-Uploads: Das Wichtigste auf einen Blick
MIME-Spoofing
Content-Type und Dateiendung werden vom Client kontrolliert und sind keine Sicherheitsgrenze. Immer serverseitig mit Magic Bytes verifizieren.
Magic Bytes
finfo_file() erkennt echte Dateitypen zuverlässig, schützt aber nicht vor Polyglot-Dateien. Immer mit Storage-Isolation kombinieren.
Speicherung & Dateinamen
Uploads außerhalb des Webroots ablegen, PHP-Ausführung im Upload-Verzeichnis deaktivieren, Dateinamen zufällig neu generieren.
Magento-Uploads
setAllowedExtensions() strikt konfigurieren, setFilesDispersion(true) nutzen, Storefront-Uploads separat von pub/media/ absichern.