TechEarl

File Upload Vulnerabilities: Validation, MIME and Safe Storage

Understand extension, MIME and double-extension failures. Local lab cases, a defensive PHP validation example and storage and serving checks.

Ishan Karunaratne⏱️ 10 min readUpdated
Share thisCopied
Extension blacklists, MIME bypass, polyglots, double extensions. Field notes plus a Dockerised lab that reproduces every bypass locally.

An unsafe upload can expose data, overwrite files, serve active browser content or reach a server-side interpreter. Accepting a file is only one step in that chain. To understand the impact, follow its name and bytes through validation, storage, processing and download.

This guide brings extension validation, MIME trust and double-extension handling into one review. The examples use the existing local upload-basic lab; its deliberately unsafe handlers are teaching fixtures, not deployment configuration.

The underlying bug

A dangerous flow is request filename → writable public directory → executable server handler. A different flow is uploaded image → vulnerable decoder. Both start with an upload, but fixing filename validation does not automatically fix a decoder bug.

Keep three questions separate:

  1. Did the application accept and store the file?
  2. Can another request retrieve it, and under what authorization and response headers?
  3. Does any server or browser interpret the contents as code?

A success message proves only what the application actually checked. A file served as plain text is not evidence that PHP executed. Conversely, a private file can still be dangerous if an import job later processes it unsafely.

The four validation patterns

Pattern 1: no validation

Saving the client filename directly under a public upload directory gives the client influence over both the stored name and its later interpretation. basename() can remove path components, but it does not decide whether the remaining extension should be executable or whether the contents are acceptable.

Use an application-generated storage name, a file-type policy and a location that is not routed to an interpreter. Preserve the original name only as display metadata, with output escaping when shown.

Pattern 2: extension blacklist (why blocklists are fragile)

A blocklist that rejects a few PHP suffixes can disagree with the installed server handlers. .php, .phtml and .phar are not interchangeable defaults across deployments; inspect the actual configuration. Installing PHP's PHAR support does not itself prove that Apache executes HTTP requests for .phar files.

For an image-only feature, choose a short list of required image formats. Normalize the case once, reject names outside that policy, and generate the stored extension from the validated type. Lowercasing closes a case mismatch in the validator; it does not solve every upload vulnerability.

Historical null-byte, Windows filename and IIS parsing examples require their original version and configuration context. Do not assume an old bypass applies to a current stack. The durable check is agreement between your validator, filename generation, storage mapping and the server that ultimately serves the file.

Pattern 3: MIME validation from the client-supplied Content-Type

PHP's $_FILES['file']['type'] is the multipart MIME value supplied by the client. It is not a server-side finding about the file. PHP upload documentation.

Use server-side inspection such as finfo as one validation signal. Match the detected type to the accepted extension rather than checking two unrelated allowlists. For an image feature, a maintained image decoder can additionally check whether the input decodes within your size and resource limits.

A MIME match is not a certificate that a file is harmless. A valid image may contain extra data, and an accepted document can contain features your product should not expose. Treat the original bytes as untrusted throughout processing.

Pattern 4: double-extension and the Apache AddHandler trap

For example.php.jpg, a last-extension check sees jpg. Apache mod_mime can associate more than one extension with metadata, including a handler. An AddHandler association for .php can therefore matter even when .jpg is last. The exact result depends on the effective handler configuration. Apache mod_mime.

An anchored FilesMatch rule matching only the final .php differs from that association, but permitting PHP in an upload directory is still unnecessary exposure. The stronger boundary is to keep user uploads outside executable routing altogether.

Case handling also depends on the directive: mod_mime extension arguments are case-insensitive, while FilesMatch uses its configured regular expression. Filesystem case sensitivity does not, by itself, describe the complete handler decision.

The nginx and PHP-FPM boundary

An unanchored nginx location can send an unexpected filename to FastCGI. That does not prove execution: SCRIPT_FILENAME, filesystem resolution and PHP-FPM's security.limit_extensions also matter. PHP documents that this setting restricts the extensions FPM will parse. PHP-FPM configuration.

Review the effective server and pool configuration together. Keep the upload path out of PHP routing, and do not broaden FPM's allowed extensions to make uploaded files run. Clearing a file's Unix execute bits does not stop PHP from reading and interpreting it.

Lab walkthrough: compare the four endpoints

The existing upload-basic lab contains the four related cases:

EndpointValidation boundaryConfiguration detail to inspect
/upload-naive.phpNo file-type policySaves client-named files into a served directory
/upload-blacklist.phpA short extension blocklistLab configuration explicitly adds a .phar handler
/upload-mime.phpClient MIME claimAccepts the request's image/jpeg label
/upload-double-ext.phpFinal extensionUpload directory enables an unsafe .php AddHandler association

Run it only on the loopback binding supplied in Compose:

bash
git clone https://github.com/ishankaru/techearl-labs.git
cd techearl-labs
docker compose up upload-basic

Use a disposable, non-sensitive image to observe a legitimate upload first:

bash
curl -i -F 'file=@sample.jpg;type=image/jpeg' \
  http://127.0.0.1:8083/upload-mime.php

Then compare the endpoint source and stored filename with the returned message. The type= value in this command is a client claim; it does not alter the file bytes. That is the central defect in the MIME-only endpoint.

The lab's double-extension case illustrates a validator/handler disagreement. The .phar case depends on an explicit extra handler in its Dockerfile. Neither observation establishes a default for every Apache installation. Keep the acceptance result and the execution condition as separate entries in your notes.

The lab source was reviewed for this guide. Exact runtime versions, request counts and tool output depend on your image and checkout; no fixed success transcript is promised. To remove this lab's container and disposable writes when finished:

bash
docker compose stop upload-basic
docker compose rm -f upload-basic

Modern defences

1. Validate the upload before storage

This is the file-validation and storage portion of a PHP handler, not a complete authenticated upload endpoint. Run your application's authentication, authorization, CSRF and request-rate controls first. Provision /var/uploads outside all web-server aliases, with permissions restricted to the service that needs it. Catch errors at the request boundary and return generic client messages. Configure production PHP with display_errors=Off and log_errors=On so a failed filesystem operation cannot print private paths into the response.

php
function storeImageUpload(array $file): array
{
    $allowed = [
        'jpg' => 'image/jpeg',
        'jpeg' => 'image/jpeg',
        'png' => 'image/png',
        'gif' => 'image/gif',
        'webp' => 'image/webp',
    ];
    $canonicalExt = [
        'image/jpeg' => 'jpg',
        'image/png' => 'png',
        'image/gif' => 'gif',
        'image/webp' => 'webp',
    ];

    if (($file['error'] ?? null) !== UPLOAD_ERR_OK
        || !is_string($file['name'] ?? null)
        || !is_string($file['tmp_name'] ?? null)
        || !is_uploaded_file($file['tmp_name'])) {
        throw new RuntimeException('Invalid upload.');
    }

    $size = filesize($file['tmp_name']);
    if ($size === false || $size < 1 || $size > 5 * 1024 * 1024) {
        throw new RuntimeException('Upload exceeds the size policy.');
    }

    $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
    if (!isset($allowed[$ext])) {
        throw new RuntimeException('Unsupported extension.');
    }

    $mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
    if ($mime !== $allowed[$ext]) {
        throw new RuntimeException('File type does not match the extension.');
    }

    $storedName = bin2hex(random_bytes(16)) . '.' . $canonicalExt[$mime];
    $destination = '/var/uploads/' . $storedName;
    if (file_exists($destination)
        || !move_uploaded_file($file['tmp_name'], $destination)) {
        throw new RuntimeException('Could not store the upload.');
    }

    return ['name' => $storedName, 'mime' => $mime, 'size' => $size];
}

The example checks the upload status, actual temporary-file size, type correspondence and move result. It does not claim to detect every image polyglot or decoder vulnerability. Apply image decoding/re-encoding in a resource-limited processing step if the feature requires it, and keep unprocessed files unavailable to readers until all required checks pass.

2. Randomise the stored filename

The generated name above has a single server-selected extension. This removes the original filename from filesystem routing. The original name may still be useful to the user; store it separately and escape it on output. Do not return private filesystem paths in API responses.

3. Store outside the web root

A directory called /var/uploads is only private if no server alias, application route or object-storage policy exposes it unexpectedly. Check the actual delivery path. Keep upload writers from modifying application code, configuration and handler files.

4. Serve uploads through a controller

Resolve an opaque file ID to stored metadata, authorize the requesting user, and read the file as data. Set an application-selected content type and a safely constructed Content-Disposition; use attachment for downloads. X-Content-Type-Options: nosniff adds a browser control but does not make arbitrary active content safe to display inline.

If your product needs inline SVG or HTML, treat that as an active-content feature with its own sanitization and origin isolation. An SVG used as a document has different behavior from a raster image. See the separate SVG XSS guide.

For object storage, configure the metadata and delivery layer explicitly. S3 stores metadata such as Content-Type and Content-Disposition; a bucket policy is an authorization policy, not a general response-header editor. S3 metadata documentation.

5. Process images with limits

Metadata removal can reduce privacy exposure and discard unwanted fields. Decoding and re-encoding can normalize an image. Neither operation replaces safe serving, and the decoder itself processes untrusted input. Bound dimensions, pixel count, memory and processing time; maintain the image libraries.

Keep image polyglots as a separate case: passing a MIME check is not the same as proving that another interpreter cannot read embedded data.

6. Scan without treating a clean result as proof

Malware scanning can detect known patterns. Quarantine suspicious or unprocessed uploads and design an explicit release decision; do not make a destructive recursive deletion command the default workflow. A clean scanner result does not authorize executing or embedding the original file.

7. Verify the serving boundary

The OWASP upload guidance recommends layered type checks, controlled storage and access restrictions. In your deployment, test the combination with harmless fixtures:

Regression caseExpected result
Allowed image within limitsStored under an application-generated name
Wrong MIME label or extension/type mismatchRejected according to policy
Partial, empty or oversized uploadRejected before publishing a file
Client filename with extra dots or path componentsNever controls the stored path
Failed move or failed processingNo successful-upload response or public record
Unauthorized downloadDenied even with a valid file ID
Uploaded content requested directlyNever routed to a server-side interpreter

Also verify response headers at the CDN and origin, not just in application source. The fuxploider reference explains the difference between a tool's upload classification and a confirmed execution result; a scanner finding is evidence to investigate, not a substitute for these checks.

Sources

Authoritative references this article was fact-checked against.

TagsFile UploadRCEWeb SecurityOWASP Top 10Polyglot FilesApplication Security

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Systems and Network Architect · Chief Technology Officer

Systems and network architect and Chief Technology Officer with more than two decades designing, building, and running production software, cloud and network architecture, Linux systems, and the bare metal underneath them, and lately working AI into the stack. A US Army veteran who served in Operation Iraqi Freedom. What I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts

Dalfox v3 walkthrough: scan subcommand, captured request files, stored and DOM XSS, blind callbacks, cookie theft. Reproducible against one lab.

Dalfox Tutorial: Exploiting a Vulnerable App End to End

A complete Dalfox walkthrough against a deliberately vulnerable XSS lab: reflected, stored, and DOM sinks, captured request files, blind callbacks, custom payloads, and a working cookie-theft chain. Updated for the Dalfox v3 Rust rewrite (May 2026) with the unified scan subcommand.

Step-by-step SSRFmap walkthrough: capture, detect, read files, portscan, bypass an allowlist, steal IMDS credentials, confirm blind SSRF.

SSRFmap Tutorial: Exploiting a Vulnerable App End to End

A complete SSRFmap walkthrough against a deliberately vulnerable lab: identify the sink, capture the Burp request, run detection, read local files, scan internal hosts, bypass a broken allowlist, hit the IMDS mock, and confirm blind SSRF out of band.