Skip to content

CWE-434: Unrestricted Upload of File with Dangerous Type - PHP

Overview

PHP uploads arrive through the $_FILES superglobal and are typically persisted with move_uploaded_file(). The field most often mistaken for validation is $_FILES['file']['type'], which is the Content-Type header the client sent in the multipart request - PHP copies it into $_FILES without checking it against the file's actual bytes. The extension in $_FILES['file']['name'] is equally client-supplied.

The reliable fix is to detect the real MIME type from the uploaded file's bytes with the Fileinfo extension (finfo_file()), generate the stored filename server-side, store outside the document root, and - where files must stay under the document root - disable script execution in that directory as defence-in-depth.

Common Vulnerable Patterns

Trusting $_FILES['file']['type'] and the Name Extension

<?php
// VULNERABLE - 'type' is the client-supplied Content-Type header, not verified content
$allowedTypes = ['image/png', 'image/jpeg'];
if (!in_array($_FILES['file']['type'], $allowedTypes, true)) {
    die('Invalid file type');
}

// VULNERABLE - the original filename is used directly as the storage name
$uploadDir = __DIR__ . '/uploads/';
$targetPath = $uploadDir . $_FILES['file']['name'];
move_uploaded_file($_FILES['file']['tmp_name'], $targetPath);

// Attack: multipart part sends Content-Type: image/png and
// name="shell.php" but the body bytes are a PHP web shell. The declared
// type is accepted at face value; nothing inspects the uploaded bytes.

Why this is vulnerable: $_FILES['file']['type'] is populated straight from the multipart Content-Type header the client sent - PHP performs no verification of it. $_FILES['file']['name'] is equally client-controlled, so an attacker chooses both the value checked and the exact filename the server writes to disk.

Storing Inside the Document Root Without Disabling Execution

// VULNERABLE - uploads/ is inside DOCUMENT_ROOT and the web server will
// execute .php files placed there
$uploadDir = $_SERVER['DOCUMENT_ROOT'] . '/uploads/';

Why this is vulnerable: If the upload directory is inside DOCUMENT_ROOT and the web server is configured to execute PHP anywhere under it (the common default), an attacker who gets a .php file past the extension check can request it directly and have it executed server-side.

Path Traversal and Null-Byte Tricks via the Original Name

// VULNERABLE - building the path directly from the client-supplied name
$targetPath = $uploadDir . $_FILES['file']['name'];

// Attack: name = "../../../../var/www/html/shell.php"
// Attack (older PHP builds using unsafe C string handling): name =
// "shell.php\0.jpg" can truncate the string at the null byte in some
// non-PHP contexts; modern PHP (7+) rejects embedded NUL in filenames,
// but any code that reimplements string handling in C extensions should
// not assume that protection extends to it.

Why this is vulnerable: Nothing strips ../ sequences from $_FILES['file']['name'] before it becomes part of $targetPath, so a crafted name can resolve outside $uploadDir. Relying on extension checks alone is also fragile against double-extension tricks (invoice.pdf.php) if the check only looks for an allowed substring rather than validating the final extension exactly.

Secure Patterns

Magic-Byte Validation with finfo, Generated Filename, Storage Outside the Document Root

<?php
// SECURE - outside DOCUMENT_ROOT
$uploadDir = '/var/app-data/uploads/';

// SECURE - allowlist of real MIME types the endpoint accepts
$allowedTypes = ['image/png' => 'png', 'image/jpeg' => 'jpg'];

if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
    // UPLOAD_ERR_INI_SIZE / UPLOAD_ERR_FORM_SIZE arrive here too - PHP has
    // already discarded the body, so this is the size rejection, not a crash
    throw new RuntimeException('Upload failed');
}

// SECURE - application-level cap in addition to upload_max_filesize/post_max_size
if ($_FILES['file']['size'] > 5 * 1024 * 1024) {
    throw new RuntimeException('File too large');
}

$tmpPath = $_FILES['file']['tmp_name'];

// SECURE - detect the real type from the file's content, not the
// client-supplied 'type' field
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$detectedType = finfo_file($finfo, $tmpPath);
finfo_close($finfo);

if (!isset($allowedTypes[$detectedType])) {
    throw new RuntimeException('Unsupported file type: ' . $detectedType);
}

// SECURE - server-generated storage name; $_FILES['file']['name'] is never
// used to build a filesystem path
$storedName = bin2hex(random_bytes(16)) . '.' . $allowedTypes[$detectedType];
$targetPath = $uploadDir . $storedName;

if (!move_uploaded_file($tmpPath, $targetPath)) {
    throw new RuntimeException('Failed to store upload');
}

Why this works: finfo_file() inspects the actual bytes of the uploaded file using libmagic's signature database, so a forged Content-Type in the type field has no effect on the outcome. The stored filename comes entirely from random_bytes(), so $_FILES['file']['name'] - and any traversal sequence, double extension, or unusual character it contains - never becomes part of a filesystem path. Because the directory sits outside DOCUMENT_ROOT, the web server cannot serve or execute anything written there even if a file somehow bypassed validation.

Disable Script Execution in the Upload Directory (Defence-in-Depth)

For deployments where uploads must remain reachable under the document root (for example, an existing integration that expects direct URLs), disable execution in that specific directory:

# Apache with mod_php only: uploads/.htaccess disables the interpreter here.
# php_flag is a mod_php directive - under PHP-FPM (mod_proxy_fcgi) it does
# nothing at all, and the IfModule guard makes that failure silent.
<IfModule mod_php.c>
    php_flag engine off
</IfModule>

# Apache with PHP-FPM: stop the request reaching the FastCGI handler at all
<Directory /var/www/html/uploads>
    <FilesMatch "\.(php|phar|phtml|php[0-9])$">
        Require all denied
    </FilesMatch>
    SetHandler none
</Directory>
# nginx: deny handing .php requests under /uploads/ to PHP-FPM
location ^~ /uploads/ {
    location ~ \.php$ {
        deny all;
    }
}

Two things to get right here. PHP-FPM is the usual deployment on PHP 8.x, and php_flag engine off is a mod_php directive - wrapped in <IfModule mod_php.c> on an FPM host the block is skipped entirely and the directory is left executable, with nothing in the logs to say so. Confirm which SAPI is running (php_sapi_name(), or the Server API line in phpinfo()) and use the form that matches it. And an .htaccess file only applies to directories the web server serves, so it does nothing for an upload directory outside the document root - that case is already covered by the file not being reachable, and the hardening to apply instead is to whatever directory under the root might still receive attacker-influenced files.

Serving Uploaded Files Back Safely

<?php
// SECURE - id is validated against the exact format the server generates,
// then used only as a lookup key. \A...\z, not ^...$: PCRE's $ also matches
// immediately before a trailing newline, so "<32 hex>.png\n" satisfies ^...$
if (!preg_match('/\A[0-9a-f]{32}\.(png|jpg)\z/', $_GET['id'] ?? '')) {
    http_response_code(404);
    exit;
}
if (!currentUserCanAccess($_GET['id'])) {
    http_response_code(403);
    exit;
}

$path = '/var/app-data/uploads/' . $_GET['id'];
if (!is_file($path)) {
    http_response_code(404);
    exit;
}

header('Content-Disposition: attachment; filename="' . basename($path) . '"');
header('X-Content-Type-Options: nosniff');
header('Content-Type: application/octet-stream');
readfile($path);

Why this works: Forcing Content-Disposition: attachment makes the browser download the file instead of rendering it inline, so a stored file cannot execute as HTML/SVG/script in a victim's browser even if something slipped past validation. Restricting id to the exact pattern the server generates means the value can only ever resolve to a file the application created, inside the upload directory.

Testing

  • Normal inputs: upload genuine PNG and JPEG files within the configured size limit; confirm both are accepted and retrievable.
  • Double extension: upload invoice.pdf.php with real PDF bytes and with real PHP bytes; confirm acceptance depends on the finfo-detected type, not the filename suffix.
  • Null-byte tricks: attempt a filename such as shell.php%00.jpg through the raw multipart body (not just the browser, which usually blocks this); confirm the application does not derive the storage extension from a truncated string.
  • MIME-type spoofing: submit Content-Type: image/png in the multipart part while the body is PHP or executable content; confirm rejection, since $_FILES['file']['type'] is never consulted.
  • Path traversal: set the filename to ../../../../var/www/html/shell.php; confirm the stored path always resolves inside the configured upload directory.
  • Oversized file: upload past upload_max_filesize/post_max_size and any application-level check; confirm rejection before the file is processed.
  • Rescan: re-run any scanner against the fixed endpoint to confirm the finding no longer reproduces.

Common Pitfalls

  • Checking $_FILES['file']['type'] against an allowlist and stopping there: this only confirms the client sent a value from the expected list - it says nothing about the bytes that follow, since PHP never verifies type against content.
  • Storing outside the document root but skipping the .htaccess/nginx hardening: relying on a single control is fragile - a future refactor that moves the upload path back under the webroot, or a misconfigured virtual host, silently removes the only protection in place if execution was never disabled in the directory itself.
  • Validating the extension with a substring check (str_contains($name, '.jpg')) instead of an exact match: a name like shell.php.jpg or shell.jpg.php can satisfy a loose substring or "ends with one of" check depending on how it's written; validate the extension as the exact final suffix, and still pair it with content detection.

Dependencies and Installation

finfo_file() requires the Fileinfo extension. It is built by default and enabled in most distribution packages, but "shipped" is not "loaded": on the Windows builds it is a separate php_fileinfo.dll that a stock install leaves commented out, and finfo_open() on PHP 8.5.8 with no php.ini raises Error: Call to undefined function finfo_open() rather than degrading. Confirm with extension_loaded('fileinfo') rather than assuming, and fail the deployment rather than the request if it is missing - a content check that is absent looks the same from the outside as one that passed. No Composer package is required for basic magic-byte detection.

Additional Resources