Uploads are one of the riskiest features. A mistake can give an attacker control of your server. Here is a deliberately unsafe script, why it is dangerous, and the fix.
The vulnerable script
<?php
// DANGEROUS: do not write this
$target = "uploads/" . $_FILES["file"]["name"];
if ($_FILES["file"]["type"] === "image/png") {
move_uploaded_file($_FILES["file"]["tmp_name"], $target);
echo "Uploaded!";
}
What is wrong
- It trusts
type, which is just text sent by the visitor. An attacker sends a PHP script with the headerContent-Type: image/png, and the check passes. - It uses the visitor’s file name. The name
shell.phpis saved asuploads/shell.php. The name../index.phpmay overwrite your own files. A name likex.php.pngcan trick some server setups. - It stores the file inside the web folder, where the server can run it.
The attacker then opens https://yoursite.example/uploads/shell.php. The server runs their code. This is a web shell: the attacker can now read your database password, steal data and attack other sites from your server.
The fixes
<?php
function checkUpload(string $tmpFile): ?string
{
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmpFile);
$allowed = ["image/png" => "png", "image/jpeg" => "jpg", "image/webp" => "webp"];
if (!isset($allowed[$mime]) || getimagesize($tmpFile) === false) {
return null; // not an allowed image
}
return $allowed[$mime]; // the extension WE choose
}
$png = base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
file_put_contents("real.tmp", $png);
file_put_contents("shell.tmp", "<?php system(\$_GET['cmd']);");
var_dump(checkUpload("real.tmp"));
var_dump(checkUpload("shell.tmp"));
string(3) "png"
NULL
The script disguised as an image is rejected, because the content is checked, not the name or the browser’s claim.
The safe save
<?php
$extension = checkUpload($_FILES["file"]["tmp_name"] ?? "");
if ($extension === null) {
exit("Only PNG, JPEG and WebP images are allowed.");
}
$name = bin2hex(random_bytes(16)) . "." . $extension; // our name, our extension
$target = dirname(__DIR__) . "/storage/uploads/" . $name; // outside the public folder
if (!move_uploaded_file($_FILES["file"]["tmp_name"], $target)) {
exit("Could not save the file.");
}
The full checklist
| Rule | Why |
|---|---|
Check UPLOAD_ERR_OK | The upload may have failed |
| Limit the size (PHP and server settings) | Stops disk and memory abuse |
Check the content type with finfo | The type and name are attacker-controlled |
| Allow-list types and extensions | Never “block bad types” |
| Random file name, extension chosen by you | No overwrite, no .php tricks |
| Store outside the web root | PHP files cannot be run from a URL |
| If it must be public, disable script execution in that folder | Defence in depth |
| For images, re-encode with GD | Removes hidden payloads |
Serve with the right Content-Type and X-Content-Type-Options: nosniff | Stops the browser from guessing |
| Limit who can upload, and how many | Abuse control |
| Scan with antivirus when files are shared with others | Malware |
Never allow uploads of .php, .phtml, .phar, .html, .svg (SVG can contain scripts) or .htaccess unless you really know what you are doing.