File Upload Vulnerabilities
Upload bugs aren’t one vulnerability — they’re a family:
- Extension validation bypass → uploaded file executes as PHP
- Content-Type spoofing → server-side MIME filter waved through
- Storage in web root → uploaded files served by the webserver as code
- Path traversal in filename → overwrite arbitrary files
- SVG / HTML / polyglot payloads → stored XSS on render
- Metadata-based deserialization (PHAR)
A safe upload pipeline gets all six right. Most don’t.
Why
The minimum-viable bug is treating the client-supplied filename and MIME type as truthful:
$name = $_FILES['avatar']['name']; // attacker controls
$type = $_FILES['avatar']['type']; // attacker controls
$ext = pathinfo($name, PATHINFO_EXTENSION); // attacker controls
if (!in_array($ext, ['jpg', 'png', 'gif'])) abort(); // extension check
if (!str_starts_with($type, 'image/')) abort(); // MIME check (worthless)
move_uploaded_file($_FILES['avatar']['tmp_name'], "/var/www/uploads/$name");The MIME check is decoration — $_FILES[…]['type'] is the client’s claim.
The extension check is bypassable by case (shell.Php), variants
(.phtml, .phar, .pht), Apache mod_mime rules
(shell.php.jpg → executed as PHP), and the filename can contain ../.
Search patterns
rg -n 'move_uploaded_file\(|\$_FILES' --type=php
# Specific danger shapes
rg -n '\$_FILES\[[^]]+\]\[[''"]name[''"]\]' --type=php # trusting client filename
rg -n '\$_FILES\[[^]]+\]\[[''"]type[''"]\]' --type=php # trusting client MIME
# Indirect upload code (frameworks)
rg -n '->store\(|->storeAs\(|->move\(|UploadedFile' --type=phpTest inputs
Filename / extension:
shell.phpshell.php.jpg(Apachemod_mime)shell.phtml/shell.phar/shell.pht/shell.phpsshell.Php/shell.PHP(case-only difference)shell.php%00.jpg(null byte, legacy)shell.php;.jpg(IIS)shell.php/(trailing slash on some setups)shell.php.(trailing dot)shell .php(trailing space).htaccess(overrides server config in upload dir)web.config(IIS equivalent)
Path traversal in filename:
../../../var/www/html/shell.php..\..\..\Windows\System32\drivers\etc\hosts
Content payloads (image-disguised PHP):
- JPG comment / EXIF holding PHP:
exiftool -Comment='<?php system($_GET["c"]); ?>' avatar.jpg - GIF magic header + PHP: file starts with
GIF89a<?php … - PNG IDAT chunk with PHP (less common, more elaborate)
Polyglot / scripted:
- SVG with
<script>alert(1)</script>(XSS on render in browser) - HTML uploaded as
.htmlor.svg(stored XSS) - PDF with embedded JavaScript
PHAR polyglot (see PHAR Deserialization):
avatar.jpgthat is also a valid PHAR with serialized RCE gadget
MIME spoofing:
- Send
Content-Type: image/jpegwhile body is<?php … Content-Type: text/htmlwith a PHP payload (server-side filter that only blocksapplication/x-php)
Audit focus
For each upload flow:
- Extension validation — allowlist (good) or denylist (bad)? Allowlist
of
['jpg', 'png', 'gif', 'webp']only, lowercased afterpathinfo. - MIME detection — server-side via
finfo/mime_content_typeon the actual file content, not the client-suppliedtype. Cross-checked against the extension. - Filename handling — is the client name discarded and replaced
with a server-generated random name?
bin2hex(random_bytes(16)) . '.' . $extis the standard. - Storage location — outside the webroot, or served via a controller
that streams bytes with forced
Content-Disposition: attachment? - Execution disabled on upload directory —
.htaccess:Or, in Nginx, aphp_flag engine off AddType text/plain .php .phtml .phar .pht .phpslocationblock that strips PHP-FPM handlers. - Content re-encoding — for images, re-render through GD/Imagick.
Strips PHP from EXIF/comments, drops
<script>from SVG, kills PHAR polyglots. The single best defense in depth. - Function choice —
move_uploaded_file(), notcopy()/rename(). Only the former validates that the file came from a multipart POST. - Size limits —
$file['size']andupload_max_filesize/post_max_sizealigned. - Race conditions — does code check the file at one path then move it later (a window for TOCTOU)?
The “uploaded image stored under its original name in /uploads/” pattern
is the single most reliable RCE primitive in PHP shops. If you find it
during review, the fix is almost certainly: random filename + outside
webroot + re-render image content + serve via controller. All four.
Fix
function handle_upload(array $file, string $dest_dir): string {
if ($file['error'] !== UPLOAD_ERR_OK) abort();
if ($file['size'] > 5 * 1024 * 1024) abort();
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
if (!isset($allowed[$mime])) abort();
// Re-encode image — neutralises EXIF / comment / PHAR payloads
$img = imagecreatefromstring(file_get_contents($file['tmp_name']));
if ($img === false) abort();
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$path = $dest_dir . '/' . $name;
match ($mime) {
'image/jpeg' => imagejpeg($img, $path, 85),
'image/png' => imagepng($img, $path),
'image/webp' => imagewebp($img, $path),
};
imagedestroy($img);
return $name;
}$dest_dir is /var/uploads/ — outside the webroot. Serve via a
controller that:
- Authenticates the request
- Looks up the file by stored ID, not by user-supplied path
- Sends bytes with a hardcoded
Content-TypeandContent-Disposition: attachment