July 12, 2026
Why move_uploaded_file() Doesn't Validate Anything You Think It Does
$_FILES[‘type’] is the HTTP header the attacker sent. finfo can be fooled by polyglots. Why your PHP upload handler is broken.

By Ann R.
19 min read
A developer is reviewing a PR adding user-uploaded avatar functionality to a Laravel application. The code is short:
public function uploadAvatar(Request $request) {
$file = $request->file('avatar');
move_uploaded_file($file->getRealPath(), public_path('avatars/' . $file->getClientOriginalName()));
return response()->json(['success' => true]);
}public function uploadAvatar(Request $request) {
$file = $request->file('avatar');
move_uploaded_file($file->getRealPath(), public_path('avatars/' . $file->getClientOriginalName()));
return response()->json(['success' => true]);
}The developer thinks move_uploaded_file() validates the upload. The function name implies validation — moving an "uploaded file" sounds like it knows the file is legitimate. The reviewer approves the PR. Two weeks later, the security team finds a backdoor shell in the avatars directory at /public/avatars/shell.php. The attacker uploaded a file named shell.php containing PHP code. move_uploaded_file() happily accepted it and moved it to a web-accessible path. The next request to /avatars/shell.php executes arbitrary code on the server.
move_uploaded_file() does almost nothing of what developers assume. The function checks one thing: that the source file was registered as an HTTP upload by PHP (preventing path-traversal attacks via the source argument). It does not check the file's extension. It does not check the file's MIME type. It does not check the file's actual content. It does not sanitize the destination path. It does not verify the destination is safe. The function's name suggests validation; the function's behavior is just rename() with a tampering check on the source. Every other safety property is the developer's responsibility, and most developers don't realize this.
This article walks through what move_uploaded_file() actually does versus what developers assume, with verified demonstrations of upload attacks that pass naive validation. The attack vectors that bypass each layer of common validation, the layered defenses that produce a genuinely safe upload handler, and the architectural patterns that eliminate the bug class entirely. Verified throughout against PHP 8.3.6 with GD extension.
TL;DR Speedrun
move_uploaded_file()checks exactly one thing: that the source path was registered as an HTTP POST upload by PHP (preventing local file inclusion attacks where the attacker tries to "move"/etc/passwd). It does not validate content, MIME type, extension, filename safety, or destination safety.- Three of five
$_FILESarray fields are attacker-controlled.$_FILES[field]['name']is the user's filename.$_FILES[field]['type']is the HTTP Content-Type header from the upload — the attacker sends it. Onlytmp_name,error, andsizeare server-set and trustworthy. Code validating$_FILES[field]['type']is checking what the attacker said, not what's true. finfo_file()MIME sniffing can be fooled by polyglots. Verified: a file starting with JPEG magic bytes followed by PHP code shows asimage/jpegtofinfo, andgetimagesize()returns valid image metadata. The MIME check passes; the file is still a PHP-executable polyglot.- GD/Imagick re-saving is the real defense for image uploads. Verified: a JPEG with PHP appended (1007 bytes) re-saves as a clean JPEG (987 bytes) with the PHP code stripped. A JPEG with PHP injected as a metadata marker (1013 bytes) re-saves to 988 bytes, also clean. GD re-decodes the image data and emits a fresh JPEG, discarding anything that wasn't real image content.
- File extension determines execution, not content. Verified: a file named
test.phpcontaining only the text "this is not actually PHP code, just text" getstext/plainfromfinfobut would still be processed as PHP by Apache/nginx because the extension matches. Validating MIME type while keeping user-supplied extensions is broken. - The complete safe upload handler has 5+ layers: validate file size, validate extension against whitelist, validate MIME via finfo, validate by re-saving through GD/Imagick for images, generate random filename (don't preserve user filename), store outside the document root, serve through a controlled PHP endpoint, set restrictive permissions. Each layer compensates for others' potential failures.
What You'll Learn
- What
move_uploaded_file()actually checks (it's less than you think) - Three of five
$_FILESfields that are attacker-controlled - Verified attacks that bypass naive MIME validation
- Why GD/Imagick re-saving works where finfo alone doesn't
- The complete safe upload handler with all layers
What move_uploaded_file() Actually Does
The PHP manual's documentation is concise:
"This function checks to ensure that the file designated by filename is a valid upload file (meaning that it was uploaded via PHP's HTTP POST upload mechanism). If the file is valid, it will be moved to the filename given by to."
That's the complete description. The function does two things:
- Verify the source path is a registered HTTP POST upload (i.e., PHP placed it there as part of processing the request)
- Move the file to the destination via
rename()or equivalent
The "is a registered upload" check exists to prevent a specific attack: code that passes a user-controlled source path to move_uploaded_file(). Without the check, an attacker who could control the source argument might supply /etc/passwd and have PHP "move" it to a web-accessible location. The check prevents this by tracking which paths PHP itself created for incoming uploads. Only those paths pass the validation.
The check provides genuine security against that specific attack. It provides no security against any other concern. The file's content, its extension, its MIME type, the destination path's safety — none of these are inspected. The function's job is "move this file safely, given that we created it"; the rest is the developer's responsibility.
The $_FILES Superglobal Is Mostly Attacker-Controlled
When a user uploads a file, PHP populates $_FILES with information about the upload. The array structure:
$_FILES['avatar'] = [
'name' => 'profile.jpg', // The original filename the user uploaded
'type' => 'image/jpeg', // The Content-Type header from the upload
'tmp_name' => '/tmp/phpAa1b2c', // PHP-generated temp path
'error' => 0, // PHP error code (0 = success)
'size' => 12345, // PHP-measured byte size
];$_FILES['avatar'] = [
'name' => 'profile.jpg', // The original filename the user uploaded
'type' => 'image/jpeg', // The Content-Type header from the upload
'tmp_name' => '/tmp/phpAa1b2c', // PHP-generated temp path
'error' => 0, // PHP error code (0 = success)
'size' => 12345, // PHP-measured byte size
];The fields with their actual sources:
name: the original filename. User-supplied via the HTTP multipart upload'sfilenameparameter. Can be any string the attacker chooses, including names with ../ traversal, special characters, or arbitrary extensions.type: the MIME type. User-supplied via the HTTPContent-Typeheader of the file part. The attacker sendsContent-Type: image/jpegregardless of what the file actually contains. PHP does not sniff the content.tmp_name: the temporary path. PHP-generated. The only field that's trustworthy as a server-side value.error: an error code (0 = success, others indicate various upload errors). PHP-set. Trustworthy.size: the byte size. PHP-measured during the upload. Trustworthy.
Three of five fields can be anything the attacker wants. Code that "validates" using $_FILES[$field]['type'] or $_FILES[$field]['name'] is validating what the attacker chose to send.
The most common broken validation pattern:
// BROKEN — checks what the attacker said
function isImage(array $file): bool {
$allowed = ['image/jpeg', 'image/png', 'image/gif'];
return in_array($file['type'], $allowed);
}// BROKEN — checks what the attacker said
function isImage(array $file): bool {
$allowed = ['image/jpeg', 'image/png', 'image/gif'];
return in_array($file['type'], $allowed);
}An attacker uploads a PHP shell named shell.php with Content-Type: image/jpeg in the multipart request. The validation passes. The file gets saved with its .php extension to the destination. The attack succeeds.
Verified: finfo Can Be Fooled
The improvement over $_FILES['type'] is finfo_file(), which actually reads the file's first few bytes and matches them against known magic byte sequences (the "magic database"):
function isImageContent(string $tmpName): bool {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $tmpName);
finfo_close($finfo);
return in_array($mime, ['image/jpeg', 'image/png', 'image/gif']);
}function isImageContent(string $tmpName): bool {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $tmpName);
finfo_close($finfo);
return in_array($mime, ['image/jpeg', 'image/png', 'image/gif']);
}This is better than checking $_FILES['type']. The finfo library reads the file's actual content and reports the MIME type based on magic bytes. A pure PHP file (<?php echo "shell"; ?>) gets reported as text/x-php, not image/jpeg.
But: finfo can be fooled by polyglot files that start with image magic bytes and continue with PHP code. Verified:
// Create a polyglot: JPEG magic + PHP code
$polyglot = "\xFF\xD8\xFF\xE0\x00\x10JFIF" . "<?php system(\$_GET['cmd']); ?>";
file_put_contents('/tmp/disguised.jpg', $polyglot);// What finfo says
$finfo = finfo_open(FILEINFO_MIME_TYPE);
finfo_file($finfo, '/tmp/disguised.jpg');
// Returns: "image/jpeg"
// Create a polyglot: JPEG magic + PHP code
$polyglot = "\xFF\xD8\xFF\xE0\x00\x10JFIF" . "<?php system(\$_GET['cmd']); ?>";
file_put_contents('/tmp/disguised.jpg', $polyglot);// What finfo says
$finfo = finfo_open(FILEINFO_MIME_TYPE);
finfo_file($finfo, '/tmp/disguised.jpg');
// Returns: "image/jpeg"
finfo looks at the first bytes (FF D8 FF is the JPEG magic), recognizes the JPEG header, and reports image/jpeg. It doesn't read past the header to verify the rest is valid JPEG data. The file is a polyglot — both a valid (partial) JPEG header and contains PHP code. If saved with a .php extension and executed, the PHP code runs. If saved with .jpg extension in a directory where PHP isn't supposed to execute, the file is just a "broken JPEG" — until someone misconfigures the server.
More realistic polyglots use actual valid JPEG data with PHP code injected into JPEG metadata markers (COM segments, EXIF data) or appended after the JPEG end marker. Verified:
// Create real JPEG with PHP appended
$im = imagecreatetruecolor(100, 100);
imagefill($im, 0, 0, imagecolorallocate($im, 255, 0, 0));
imagejpeg($im, '/tmp/with_php.jpg', 90);
imagedestroy($im);
file_put_contents('/tmp/with_php.jpg', "\n<?php phpinfo(); ?>", FILE_APPEND);// Status checks
filesize('/tmp/with_php.jpg'); // 1007 bytes
finfo_file($finfo, '/tmp/with_php.jpg'); // "image/jpeg"
getimagesize('/tmp/with_php.jpg'); // [100, 100, 2, 'width="100" height="100"', ...]
// Create real JPEG with PHP appended
$im = imagecreatetruecolor(100, 100);
imagefill($im, 0, 0, imagecolorallocate($im, 255, 0, 0));
imagejpeg($im, '/tmp/with_php.jpg', 90);
imagedestroy($im);
file_put_contents('/tmp/with_php.jpg', "\n<?php phpinfo(); ?>", FILE_APPEND);// Status checks
filesize('/tmp/with_php.jpg'); // 1007 bytes
finfo_file($finfo, '/tmp/with_php.jpg'); // "image/jpeg"
getimagesize('/tmp/with_php.jpg'); // [100, 100, 2, 'width="100" height="100"', ...]
The file is a real, valid JPEG (rendered correctly in browsers) with PHP code appended. finfo says image/jpeg. getimagesize returns valid image dimensions. Every common validation passes. The file still contains PHP code that runs if processed by PHP-FPM.
GD/Imagick Re-Saving Strips the Bad Content
The defense that actually works for image uploads is to re-save the image through GD or Imagick. The library decodes the image data into a pixel buffer and re-encodes it as a fresh image — anything that wasn't part of the actual image data gets discarded. Verified:
// Load the malicious JPEG
$im = imagecreatefromjpeg('/tmp/with_php.jpg');
// Re-save as a new file
imagejpeg($im, '/tmp/clean.jpg', 90);
imagedestroy($im);
// Status checks
filesize('/tmp/clean.jpg'); // 987 bytes (smaller - PHP code stripped)
str_contains(file_get_contents('/tmp/clean.jpg'), '<?php'); // false - gone// Load the malicious JPEG
$im = imagecreatefromjpeg('/tmp/with_php.jpg');
// Re-save as a new file
imagejpeg($im, '/tmp/clean.jpg', 90);
imagedestroy($im);
// Status checks
filesize('/tmp/clean.jpg'); // 987 bytes (smaller - PHP code stripped)
str_contains(file_get_contents('/tmp/clean.jpg'), '<?php'); // false - goneThe re-saved JPEG is smaller because the appended PHP code is gone. GD doesn't know about "PHP appended after JPEG" — it just decodes the image bytes into pixels, then encodes pixels back into a JPEG. The PHP bytes never made it into the pixel buffer (they were past the JPEG end marker), so they don't appear in the re-encoded output.
The same principle applies to PHP code injected into JPEG metadata markers. Verified:
// Inject PHP into a JPEG comment marker
$comment = "<?php /* hidden */ ?>";
$comMarker = "\xFF\xFE" . pack('n', strlen($comment) + 2) . $comment;
// ... inject after JFIF marker, save as with_php_in_metadata.jpg
filesize('/tmp/with_php_in_metadata.jpg'); // 1013 bytes
str_contains(file_get_contents('/tmp/with_php_in_metadata.jpg'), '<?php'); // true
// Re-save through GD
$im = imagecreatefromjpeg('/tmp/with_php_in_metadata.jpg');
imagejpeg($im, '/tmp/stripped.jpg', 90);
imagedestroy($im);
filesize('/tmp/stripped.jpg'); // 988 bytes
str_contains(file_get_contents('/tmp/stripped.jpg'), '<?php'); // false// Inject PHP into a JPEG comment marker
$comment = "<?php /* hidden */ ?>";
$comMarker = "\xFF\xFE" . pack('n', strlen($comment) + 2) . $comment;
// ... inject after JFIF marker, save as with_php_in_metadata.jpg
filesize('/tmp/with_php_in_metadata.jpg'); // 1013 bytes
str_contains(file_get_contents('/tmp/with_php_in_metadata.jpg'), '<?php'); // true
// Re-save through GD
$im = imagecreatefromjpeg('/tmp/with_php_in_metadata.jpg');
imagejpeg($im, '/tmp/stripped.jpg', 90);
imagedestroy($im);
filesize('/tmp/stripped.jpg'); // 988 bytes
str_contains(file_get_contents('/tmp/stripped.jpg'), '<?php'); // falseGD re-encodes the image and emits standard JPEG markers — the malicious COM marker doesn't get reproduced because GD generates its own marker structure. The PHP code is gone.
The same principle applies to PNG (using imagecreatefrompng + imagepng), GIF (imagecreatefromgif + imagegif), and WebP (PHP 7.1+). Imagick provides similar functionality with more format support. For non-image uploads (PDFs, documents), equivalent libraries exist (smalot/pdfparser for PDFs, etc.) but the validation pattern is similar — parse the file with a real library, then validate the parsed result is what you expected.
File Extension Determines Execution
A critical point developers often miss: file extension determines what server software does with the file, not the content. Verified:
// Create a file with .php extension containing plain text
file_put_contents('/tmp/test.php', "this is not actually PHP code, just text");
finfo_file(finfo_open(FILEINFO_MIME_TYPE), '/tmp/test.php');
// Returns: "text/plain"// Create a file with .php extension containing plain text
file_put_contents('/tmp/test.php', "this is not actually PHP code, just text");
finfo_file(finfo_open(FILEINFO_MIME_TYPE), '/tmp/test.php');
// Returns: "text/plain"finfo reports text/plain because the content is plain text. But if this file is served via a web server configured to execute .php files through PHP-FPM, the server will try to execute it as PHP. PHP will tokenize the content, find no PHP tags, and output the file's contents as-is. The "execution" produces nothing harmful for this specific content, but the security model is broken — the server's decision to execute is based on the extension, not on what the file actually is.
The implication: even with perfect content validation, a file saved with a .php extension (or any other executable extension) in a server-accessible location is potentially executable. Validation that "the content is harmless" doesn't help if the server treats the file's extension as authoritative.
The defense: never preserve user-supplied filenames or extensions for uploaded files in web-accessible locations. Generate a random filename with a known-safe extension, regardless of what the user uploaded. A user uploading evil.php.jpg doesn't get to influence the saved name; the file becomes 7f8a9c.jpg (or whatever random name with the safe extension).
The Complete Safe Upload Handler
Combining all defenses produces a multi-layered handler:
class SafeUploadHandler {
private const MAX_SIZE = 5 * 1024 * 1024; // 5MB
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif'];
private const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/gif'];
private const STORAGE_PATH = '/var/www/storage/uploads/';
public function handle(array $upload): array {
// Layer 1: Check upload completed successfully
if ($upload['error'] !== UPLOAD_ERR_OK) {
throw new \RuntimeException('Upload failed: ' . $upload['error']);
}
// Layer 2: Validate file size
if ($upload['size'] > self::MAX_SIZE) {
throw new \RuntimeException('File too large');
}
// Layer 3: Validate extension via the (user-supplied!) name
// We don't trust this for security, just for early rejection
$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
if (!in_array($ext, self::ALLOWED_EXTENSIONS)) {
throw new \RuntimeException('Disallowed extension');
}
// Layer 4: Validate MIME via content sniffing
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $upload['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, self::ALLOWED_MIMES)) {
throw new \RuntimeException('Disallowed MIME type');
}
// Layer 5: Validate it's actually an image we can parse
$imageInfo = @getimagesize($upload['tmp_name']);
if ($imageInfo === false) {
throw new \RuntimeException('Not a valid image');
}
// Layer 6: Re-encode through GD (strips embedded code)
$cleanPath = $this->reEncodeImage($upload['tmp_name'], $mime);
// Layer 7: Generate random filename with safe extension
// Don't use user's filename or its extension
$extension = $this->extensionForMime($mime);
$randomName = bin2hex(random_bytes(16)) . '.' . $extension;
// Layer 8: Move to storage outside webroot
$destination = self::STORAGE_PATH . $randomName;
if (!move_uploaded_file($cleanPath, $destination)) {
// (technically, the clean path is no longer a registered upload
// — use rename() for that case)
rename($cleanPath, $destination);
}
// Layer 9: Restrictive permissions
chmod($destination, 0644);
// Layer 10: Return info for the application
return [
'filename' => $randomName,
'url' => '/uploads/' . $randomName, // served via controlled endpoint
'mime' => $mime,
'size' => $upload['size'],
'dimensions' => [$imageInfo[0], $imageInfo[1]],
];
}
private function reEncodeImage(string $sourcePath, string $mime): string {
$tempPath = tempnam(sys_get_temp_dir(), 'cleaned_');
switch ($mime) {
case 'image/jpeg':
$im = imagecreatefromjpeg($sourcePath);
if ($im === false) throw new \RuntimeException('Invalid JPEG');
imagejpeg($im, $tempPath, 90);
imagedestroy($im);
break;
case 'image/png':
$im = imagecreatefrompng($sourcePath);
if ($im === false) throw new \RuntimeException('Invalid PNG');
imagepng($im, $tempPath);
imagedestroy($im);
break;
case 'image/gif':
$im = imagecreatefromgif($sourcePath);
if ($im === false) throw new \RuntimeException('Invalid GIF');
imagegif($im, $tempPath);
imagedestroy($im);
break;
default:
throw new \RuntimeException('Unsupported MIME type');
}
return $tempPath;
}
private function extensionForMime(string $mime): string {
return [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
][$mime];
}
}class SafeUploadHandler {
private const MAX_SIZE = 5 * 1024 * 1024; // 5MB
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif'];
private const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/gif'];
private const STORAGE_PATH = '/var/www/storage/uploads/';
public function handle(array $upload): array {
// Layer 1: Check upload completed successfully
if ($upload['error'] !== UPLOAD_ERR_OK) {
throw new \RuntimeException('Upload failed: ' . $upload['error']);
}
// Layer 2: Validate file size
if ($upload['size'] > self::MAX_SIZE) {
throw new \RuntimeException('File too large');
}
// Layer 3: Validate extension via the (user-supplied!) name
// We don't trust this for security, just for early rejection
$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
if (!in_array($ext, self::ALLOWED_EXTENSIONS)) {
throw new \RuntimeException('Disallowed extension');
}
// Layer 4: Validate MIME via content sniffing
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $upload['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, self::ALLOWED_MIMES)) {
throw new \RuntimeException('Disallowed MIME type');
}
// Layer 5: Validate it's actually an image we can parse
$imageInfo = @getimagesize($upload['tmp_name']);
if ($imageInfo === false) {
throw new \RuntimeException('Not a valid image');
}
// Layer 6: Re-encode through GD (strips embedded code)
$cleanPath = $this->reEncodeImage($upload['tmp_name'], $mime);
// Layer 7: Generate random filename with safe extension
// Don't use user's filename or its extension
$extension = $this->extensionForMime($mime);
$randomName = bin2hex(random_bytes(16)) . '.' . $extension;
// Layer 8: Move to storage outside webroot
$destination = self::STORAGE_PATH . $randomName;
if (!move_uploaded_file($cleanPath, $destination)) {
// (technically, the clean path is no longer a registered upload
// — use rename() for that case)
rename($cleanPath, $destination);
}
// Layer 9: Restrictive permissions
chmod($destination, 0644);
// Layer 10: Return info for the application
return [
'filename' => $randomName,
'url' => '/uploads/' . $randomName, // served via controlled endpoint
'mime' => $mime,
'size' => $upload['size'],
'dimensions' => [$imageInfo[0], $imageInfo[1]],
];
}
private function reEncodeImage(string $sourcePath, string $mime): string {
$tempPath = tempnam(sys_get_temp_dir(), 'cleaned_');
switch ($mime) {
case 'image/jpeg':
$im = imagecreatefromjpeg($sourcePath);
if ($im === false) throw new \RuntimeException('Invalid JPEG');
imagejpeg($im, $tempPath, 90);
imagedestroy($im);
break;
case 'image/png':
$im = imagecreatefrompng($sourcePath);
if ($im === false) throw new \RuntimeException('Invalid PNG');
imagepng($im, $tempPath);
imagedestroy($im);
break;
case 'image/gif':
$im = imagecreatefromgif($sourcePath);
if ($im === false) throw new \RuntimeException('Invalid GIF');
imagegif($im, $tempPath);
imagedestroy($im);
break;
default:
throw new \RuntimeException('Unsupported MIME type');
}
return $tempPath;
}
private function extensionForMime(string $mime): string {
return [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
][$mime];
}
}The layers and what they prevent:
Layer Defense What it stops 1: error check Sanity check Partial uploads, server errors 2: size check DoS prevention Disk exhaustion attacks 3: extension whitelist Early rejection Obvious bad uploads (.php, .exe) 4: MIME via finfo Content sniffing Files with wrong Content-Type header 5: getimagesize Image-specific check Non-image files masquerading as images 6: GD re-encode Content sanitization Polyglots, appended PHP, EXIF injection 7: random filename Path/name safety Path traversal, executable extension 8: outside webroot Architecture Direct HTTP-fetch of stored files 9: restrictive permissions File-system hardening Other system users reading uploads 10: controlled endpoint Access control Lets app verify auth before serving
Removing any single layer weakens the overall defense, but the combination is robust. The handler is roughly 70 lines of code; the production version would add logging, metrics, and integration with the application's existing error handling, but the core security pattern is what's shown.
The Serving Side
Once a file is safely stored, serving it back requires its own controls. The naive pattern:
// BAD: directly link to storage
echo '<img src="/uploads/' . $randomName . '">';// BAD: directly link to storage
echo '<img src="/uploads/' . $randomName . '">';If the uploads directory is publicly served, this works but provides no access control — anyone with the URL can fetch the file. For images that are meant to be public, this is fine. For files that should be access-controlled, the URL leaks.
The controlled serving pattern:
// GOOD: serve through PHP with auth checks
public function serveUpload(string $filename) {
// Verify the current user has permission to access this file
if (!$this->canAccess($filename, currentUser())) {
abort(403);
}
$path = '/var/www/storage/uploads/' . $filename;
if (!file_exists($path)) {
abort(404);
}
// Get the stored MIME type (don't use finfo here)
$metadata = $this->getMetadata($filename);
return response()->file($path, [
'Content-Type' => $metadata['mime'],
'Content-Disposition' => 'inline; filename="' . $metadata['display_name'] . '"',
'X-Content-Type-Options' => 'nosniff', // prevent browser sniffing
]);
}// GOOD: serve through PHP with auth checks
public function serveUpload(string $filename) {
// Verify the current user has permission to access this file
if (!$this->canAccess($filename, currentUser())) {
abort(403);
}
$path = '/var/www/storage/uploads/' . $filename;
if (!file_exists($path)) {
abort(404);
}
// Get the stored MIME type (don't use finfo here)
$metadata = $this->getMetadata($filename);
return response()->file($path, [
'Content-Type' => $metadata['mime'],
'Content-Disposition' => 'inline; filename="' . $metadata['display_name'] . '"',
'X-Content-Type-Options' => 'nosniff', // prevent browser sniffing
]);
}The PHP endpoint controls access, sets correct headers, and serves the file via PHP rather than via the web server directly. This adds latency (PHP has to process each request) but provides genuine access control.
The X-Content-Type-Options: nosniff header is important — it tells browsers not to second-guess the MIME type the server provided. Without it, browsers may sniff content and decide a file is actually a different type, potentially executing it as something dangerous.
Pitfalls to Avoid
Trusting $_FILES['type']. Verified: it's the HTTP Content-Type header the attacker sent. Use finfo_file() for content sniffing, not the $_FILES value.
Treating finfo as the complete defense. Verified: polyglot files with image magic bytes followed by PHP code pass finfo checks. Re-encoding through GD/Imagick is the actual sanitization step.
Preserving user-supplied filenames in the saved location. A user uploading shell.php.jpg can sometimes get the file saved with that name. Combined with server misconfiguration, this leads to RCE. Always generate random filenames; never use any part of $_FILES['name'] for the saved path.
Storing uploads inside the document root. Even with proper extensions, files in webroot can be requested directly via HTTP. The combination of upload bug + direct-fetch is sometimes the missing link in an attack chain. Store uploads outside webroot; serve via controlled PHP endpoints.
Using Content-Type from $_FILES for serving the file later. The attacker controls this value. If you save it and later use it to serve the file back, the attacker has poisoned your application's response. Determine MIME type at storage time via finfo or by associating with file extension, not from $_FILES.
Not handling all the UPLOAD_ERR_* cases. PHP defines specific error codes for various upload failures (file too large, partial upload, no tmp directory, write error, etc.). Code that only checks error !== 0 doesn't differentiate; production code should log specific errors for debugging.
Forgetting that some file types embed code natively. SVG can contain <script> tags. HTML can be anything. Office documents have macros. PDFs can have JavaScript. "It's an image" isn't always safe — an SVG image can attack the user's browser. For browser-rendered formats, additional sanitization (SVG sanitizers, HTML purifiers) is needed.
Skipping GD re-encoding because "the user might lose quality." The 90 quality setting in imagejpeg($im, $path, 90) preserves most image quality while still producing a clean output. The quality loss is usually imperceptible; the security gain is substantial. Users won't notice; attackers will.
Trusting filename extensions blindly even for non-security checks. A file named image.jpg.exe has extension .exe per pathinfo. PHP's behavior with multiple dots is straightforward, but some applications treat "the first part after the first dot" as the extension. Use pathinfo($name, PATHINFO_EXTENSION) for consistency.
Mini Q&A
Does move_uploaded_file() provide any security at all?
Yes, one specific protection: it verifies the source path was a registered HTTP upload by PHP. This prevents an attack where code passes a user-controlled source path to move_uploaded_file() and the attacker supplies something like /etc/passwd. Without this check, the function would happily "move" arbitrary files. With it, only paths that PHP itself created during upload processing are accepted. This is genuine protection; it's just narrow.
Is getimagesize() enough to validate an image?
No. Verified: a JPEG with PHP appended passes getimagesize() (returns valid dimensions and metadata) because the JPEG portion of the file is real. getimagesize just reads the image headers; it doesn't sanitize the file. Combine with finfo for MIME validation and GD/Imagick re-encoding for content sanitization.
Why is re-encoding through GD/Imagick the defense?
Because GD/Imagick parses the image data into a pixel buffer, then re-encodes to a fresh file. Anything that wasn't part of the actual image (PHP code in metadata, appended PHP after the image data, polyglot constructions) doesn't make it into the pixel buffer and doesn't appear in the output. The re-encoded file is "pure" image data with no embedded code.
What about uploads other than images (PDFs, documents)?
The same principle applies but with different tooling. For PDFs, re-render through a library (or convert to images then back). For documents, parse with a real library and re-emit. For arbitrary file types, consider whether the application actually needs to accept them — restricting to known-safe types (images, plain text) is often appropriate. For truly arbitrary uploads, the only defense is strict storage isolation (outside webroot, no execution permissions, served via controlled endpoint with nosniff header).
Can I just use a library like intervention/image for uploads?
Libraries that wrap GD/Imagick (Intervention Image, GD, Imagine, etc.) provide the re-encoding for free as part of image manipulation. If you're using one of these for resizing or thumbnail generation, the security benefit comes along automatically. Using the library only for thumbnails while leaving the original file untouched misses the protection — the original file is still the user-uploaded one, possibly with embedded code.
What if I need to preserve the original filename for display?
Store the user-supplied name in a database column or metadata file, associated with the random filename you actually use for storage. Display the user-supplied name in the UI (after sanitization for the display context — HTML escaping, etc.). Store the file under the random name. The two are decoupled.
How do I serve uploaded files with the correct filename when users download them?
Use Content-Disposition: attachment; filename="user_supplied_name.jpg". Make sure to sanitize the filename for header safety (no newlines, no special characters that break HTTP parsing). The browser uses the filename in the Content-Disposition header for the download, regardless of the URL path.
Does Laravel's file validation help?
Laravel's mimes, mimetypes, and image validators use finfo (or equivalent) for content-based MIME detection. Better than checking $_FILES['type']. But Laravel's validators don't do GD re-encoding — that's still your responsibility. The validators reject obvious bad uploads; the re-encoding handles polyglots.
Wrap-Up
move_uploaded_file() is one of those PHP functions whose name suggests more validation than it provides. The function exists to safely move files; it doesn't validate them. Every other safety property — content, MIME, extension, filename, destination — is the application's responsibility. The PHP function does its narrow job correctly; the bug class comes from applications assuming it does more.
For PHP applications handling user uploads, the practical posture is multi-layered validation. Check the size first (cheap, catches DoS). Check the extension second (cheap, catches obvious bad uploads). Check the MIME via finfo (medium cost, catches Content-Type lies). Validate with format-specific parsing. Re-encode through GD/Imagick to strip embedded code. Generate random filenames with safe extensions. Store outside webroot. Serve through controlled endpoints. Each layer compensates for failures in others.
For new applications, designing the upload pipeline correctly from the start is straightforward — the patterns are well-known, the libraries exist, and the resulting code is robust. For existing applications, auditing the upload handlers is the work. The bug class is widespread enough that almost every PHP application has at least one upload endpoint with at least one missing layer. The audit usually finds quick wins (add finfo validation, add GD re-encoding, move uploads outside webroot) that significantly reduce the attack surface.
The deeper takeaway: function names in any language can be misleading about scope. "Move uploaded file" sounds like it includes "validate the uploaded file" — natural language conflation. The PHP function does what its narrow definition says; the broader security posture comes from the application code around it. This pattern applies beyond PHP — function names often promise less than developers assume, and the gap between assumption and reality is where bugs live.
Closing Loop
The team whose avatar feature had the backdoor shell eventually rewrites the upload handler with all the defenses described. The new handler validates size, extension, MIME via finfo, image structure via getimagesize, re-encodes through GD, generates random filenames, and stores outside webroot. The previously-uploaded shell files (from the attacker's exploitation) are found and deleted. Every database row referencing user-uploaded files is audited to verify the actual files match expectations.
The team adds an automated security check to their CI pipeline: a test that attempts to upload a JPEG with embedded PHP code and verifies the resulting stored file doesn't contain the PHP tag. The test runs on every PR; any regression in the upload validation gets caught before merging. Three months after deployment, the test catches a refactoring PR that accidentally removed the GD re-encoding step. The reviewer notices the failing test and adds the re-encoding back before merging.
Six months later, the team's general security posture has improved noticeably. The pattern of "find a bug class, write defenses, automate detection" has applied to type juggling, SQL injection patterns, ReDoS, the PHP-FPM misconfig, and uploads. Each instance turned one incident into a permanent defense. The team's documentation captures the principles for new joiners: "Multiple layers of defense, each one assuming the others might fail."
The team also migrated their uploaded images to S3 with restricted IAM policies. The S3 bucket has no public read; access goes through signed URLs generated by the application. Even if a malicious file somehow gets stored, S3's storage class prevents code execution from the storage layer. The compute-environment defense and the storage-layer defense are independent — bypassing one doesn't bypass the other.
"People Also Ask"
-
What does
move_uploaded_file()actually validate? It validates exactly one thing: that the source path was registered as an HTTP POST upload by PHP during the current request. This prevents an attack where code passes a user-controlled source path (like/etc/passwd) to the function. Beyond this single tampering check, the function does no validation of content, extension, MIME type, filename safety, or destination safety. Those are the developer's responsibility through additional code. -
Is
$_FILES['type']trustworthy for validating uploads? No. Thetypefield in$_FILESis the HTTP Content-Type header from the multipart upload — set by the user's browser (or by an attacker). An attacker can send any value:Content-Type: image/jpegfor a file containing PHP code. PHP does not sniff the actual content for this field. Usefinfo_file($tmpName)for content-based MIME detection instead. -
Can
finfo_file()detect malicious uploads? Partially.finforeads the file's first few bytes and matches against known magic byte sequences. It correctly identifies pure PHP files astext/x-php. But it can be fooled by polyglot files that start with image magic bytes followed by PHP code. Verified: a file with JPEG magic bytes followed by<?php ... ?>shows asimage/jpegtofinfo. The defense requires re-encoding through GD/Imagick, not just MIME checking.
4. Why does re-encoding through GD/Imagick prevent code execution attacks? GD/Imagick decodes the image data into a pixel buffer and re-encodes it as a fresh file. The decoder reads only the actual image content; anything that wasn't part of the encoded image (appended PHP code, code injected into metadata markers, polyglot constructions) doesn't make it into the pixel buffer. The encoder emits a clean image with only standard markers. Verified: a JPEG with PHP appended (1007 bytes) re-saves to 987 bytes with no PHP content; a JPEG with PHP in metadata (1013 bytes) re-saves to 988 bytes, also clean.
5. Should I keep the user's filename for uploaded files? No, not for the stored filename. Generate a random filename with a known-safe extension. Store the user's original name separately (database column, metadata file) for display purposes if needed. The user-supplied filename can contain path traversal characters (../), executable extensions (.php), shell metacharacters, and other dangerous content. The random filename eliminates this entire class of attack regardless of what the user named the upload.
6. Where should uploaded files be stored? Outside the web document root, served through a controlled PHP endpoint with access controls. Files in webroot can be requested directly via HTTP, bypassing application-level access controls and potentially exposing files that shouldn't be public. The PHP-served pattern allows authentication checks, audit logging, and proper Content-Type headers. For high-traffic uploads, cloud storage (S3, GCS) with signed URLs provides similar isolation with better scalability.
7. What's the minimum safe upload handler? The absolute minimum: (1) validate file size, (2) validate extension against whitelist, (3) validate MIME via finfo, (4) re-encode through GD/Imagick for images, (5) generate random filename with safe extension, (6) store outside webroot. Removing any of these layers introduces specific bypass possibilities; the combination is robust. For production code, additional layers (CSRF protection, rate limiting, virus scanning for non-image uploads) add further defense.
8. Does this issue affect Laravel's file upload handling? Laravel's Request::file() and validation rules (image, mimes, mimetypes) use proper content sniffing rather than $_FILES['type']. This is better than naive validation. But Laravel doesn't automatically re-encode images — that's the developer's responsibility. The validation catches obvious bad uploads (PHP files, wrong MIME types); the re-encoding catches polyglots and embedded code. For complete safety, use Laravel's validation plus explicit re-encoding via the Intervention Image library or direct GD calls.
Note: All attack scenarios and defense effectiveness in this article were verified on PHP 8.3.6 with GD extension installed. The polyglot file constructions reflect documented attack patterns; specific magic byte values, JPEG marker structures, and EXIF injection techniques are publicly documented in security research. The re-encoding effectiveness was verified by direct measurement: original file size and content versus re-encoded file size and content showed the embedded PHP code consistently stripped. Specific behavior may vary across GD versions and image format edge cases; for production systems, testing with representative attack payloads is the only reliable verification. Laravel-specific behavior reflects documented Laravel validation behavior; specific framework versions may have minor differences. The patterns described apply to any PHP upload handling regardless of framework.