Skip to content
File Upload

File Upload Vulnerabilities, Explained: From an Avatar Field to a Web Shell

An upload form that trusts the filename, the declared Content-Type or a file's first bytes can place a server-side script or a scripted SVG on your own origin. Here is how upload validation fails, how to test it safely, and how to build a handler that holds.

Part 1 of 1From the File Upload series

At a glance

CWE-434
Interpreter
Web server handler mapping and the victim's browser
Required condition
The uploader's filename, declared type or content decides how the server or a browser treats the stored file.
Potential impact
Remote code execution through a web shell, stored script on your origin, or overwritten files.
Primary defense
Allowlisted and verified types, server-generated names, and storage nothing executes, served with a fixed type.

Safe practice: use benign proof only, in a disposable local lab or on a system you are explicitly authorized to test.

On this page

File upload vulnerabilities appear when an application accepts a file and lets the uploader decide what that file becomes. The name, the declared type and the bytes all come from the client. If the server stores the file where a script handler or a browser will interpret it, a profile picture can become a web shell, an HTML page on your domain, or a path that overwrites someone else's data.

MITRE tracks the flaw as CWE-434, Unrestricted Upload of File with Dangerous Type, and the OWASP Top 10:2025 maps it to A06 Insecure Design: most upload bugs are not one bad line but a pipeline that never decided who owns a file's identity.

The mental model: the client writes the label

Everything a multipart upload says about the file is written by the sender.

Part of the upload Who sets it What trusting it decides
filename in Content-Disposition Client The extension, so which handler runs the file, and sometimes the directory
The part's Content-Type Client Nothing reliable: it is a claim, not a measurement
Leading bytes (magic number) Client Only that the file starts like a format, not what follows
Storage directory and serving path Server Whether anything interprets the file at all

Only the last row belongs to the server, and a safe design puts the weight there.

How it happens

A typical avatar handler trusts the client's label and stores the file where it can run:

$name = $_FILES['avatar']['name'];
$type = $_FILES['avatar']['type'];
if (!in_array($type, ['image/png', 'image/jpeg'], true)) {
    http_response_code(400);
    exit;
}
move_uploaded_file($_FILES['avatar']['tmp_name'], __DIR__ . '/uploads/' . $name);
echo 'Uploaded to /uploads/' . htmlspecialchars($name);

type is the header the sender chose, name keeps the sender's extension, and uploads/ sits under the document root, where the PHP handler runs any .php file it is asked for.

A concrete test: prove storage, then execution

An authorized tester uses a test account. First upload a real PNG and note where it lands and whether the name survives. Then send a probe with a server-side extension, a declared image type and a split marker:

POST /profile/avatar HTTP/1.1
Host: app.example
Cookie: session=4c1d9a0e
Content-Type: multipart/form-data; boundary=----lab

------lab
Content-Disposition: form-data; name="avatar"; filename="probe-7f3a.php"
Content-Type: image/jpeg

<?php echo 'upload-' . 'check-7f3a'; ?>
------lab--

The joined string upload-check-7f3a never appears in the file, so only execution can return it. Request /uploads/probe-7f3a.php and read the result:

  • Rejected: move on to the bypasses below.
  • The PHP source comes back: stored and served as text. Still a finding, one handler change from execution.
  • Only upload-check-7f3a comes back: the server executed it. Stop, delete the file and report. Never upload a functional shell to prove more.

When the plain probe is rejected

  • Double extensions: probe.php.jpg passes a check on the last extension, and Apache setups that map PHP with AddHandler run any file with .php among its extensions.
  • Case and alternative extensions: .PhP beats a case-sensitive denylist, and common PHP packages for Apache also execute .phtml and .phar.
  • Name truncation: probe.php%00.jpg ended at the null byte in legacy runtimes, and Windows drops trailing dots, so probe.aspx. is stored as probe.aspx.
  • Signature prefixes: a polyglot that starts with GIF89a and continues with script passes a check that reads only the first bytes.
  • Configuration files: a denylist that forgets .htaccess or web.config lets one upload change how the whole directory is handled.
  • Traversal in the name: filename="../public/probe.php" escapes the upload folder when the name is joined to a path; see path traversal.

Uploads that attack the browser

Where nothing executes on the server, a file can still execute in a browser. SVG may carry script:

<svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.domain)"/>

Embedded with an <img> tag, it never runs. Opened directly from https://app.example/uploads/logo.svg, it runs with your application's origin, exactly like stored XSS. An uploaded .html file does the same. Server-side parsing of SVG or Office files adds XXE.

What an attacker gains

  • Remote code execution through a web shell running as the web server's account.
  • Stored cross-site scripting on your origin from SVG and HTML files.
  • File overwrite when the name chooses the path: another user's avatar, a template or a configuration file.
  • Parser attacks on image, document and archive processing, such as archive entries that write outside the extraction directory.
  • Abuse of your resources: oversized files, decompression bombs, and malware or phishing pages hosted on your domain.

Reading a SelfSec finding

SelfSec tests the forms its crawler finds with a file input or multipart/form-data encoding. It posts a plain-text random marker, never a script, under .php, .jsp, .asp, .aspx, .py, .sh and .exe names, double extensions, case variants, a %00 name and server-side extensions declared as image/jpeg. A 201, or a 200 whose body contains a word such as upload, success or saved, counts as accepted. When the response's Location header points at the stored file, SelfSec fetches it, and a 200 carrying the marker raises the finding to critical and marks the stored file verified. That proves storage and retrieval, not execution; the split-marker test settles execution.

Other findings rest on the upload response alone, and a page that merely mentions "upload" can look like success, so read the attached response before triaging. For a file input, SelfSec also sends one harmless sample that fits the field, such as a one-pixel PNG named selfsec_canary.png; a finding for that file shows only that the form accepts an ordinary file, not a bypass. Each finding lists the file it left behind, with its URL when known; delete those files after the scan.

The fix: let the server decide what a file is

Allowlist the types the feature needs, derive the stored extension from verified content, generate the name, and keep the file outside the web root. The PHP handler above, fixed:

$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg'];
$upload = $_FILES['avatar'] ?? null;
if ($upload === null || $upload['error'] !== UPLOAD_ERR_OK || $upload['size'] > 2 * 1024 * 1024) {
    http_response_code(400);
    exit;
}
$detected = (new finfo(FILEINFO_MIME_TYPE))->file($upload['tmp_name']);
if (!isset($allowed[$detected])) {
    http_response_code(400);
    exit;
}
$stored = bin2hex(random_bytes(16)) . '.' . $allowed[$detected];
if (!move_uploaded_file($upload['tmp_name'], '/srv/app-data/media/' . $stored)) {
    http_response_code(500);
    exit;
}

The client's name and declared type no longer influence anything. In ASP.NET Core, which documents IFormFile.FileName as untrusted, the same rules look like this:

static readonly Dictionary<string, byte[]> Signatures = new()
{
    [".png"] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
    [".jpg"] = [0xFF, 0xD8, 0xFF],
    [".pdf"] = "%PDF-"u8.ToArray(),
};

static async Task<string?> StoreUploadAsync(IFormFile file, string storageRoot)
{
    string extension = Path.GetExtension(file.FileName).ToLowerInvariant();
    if (!Signatures.TryGetValue(extension, out byte[]? signature)
        || file.Length < signature.Length || file.Length > 5 * 1024 * 1024)
    {
        return null;
    }

    byte[] header = new byte[signature.Length];
    await using (Stream source = file.OpenReadStream())
    {
        await source.ReadExactlyAsync(header);
    }
    if (!header.AsSpan().SequenceEqual(signature))
    {
        return null;
    }

    string storedName = Guid.NewGuid().ToString("N") + extension;
    await using var target = new FileStream(Path.Combine(storageRoot, storedName), FileMode.CreateNew);
    await file.CopyToAsync(target);
    return storedName;
}

A signature check confirms the start of a file, not the rest. For images, re-encoding with a maintained library discards appended script, and its pixel limit stops decompression bombs. Then serve files from a location that never executes them:

location ^~ /media/ {
    alias /srv/app-data/media/;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Content-Security-Policy "default-src 'none'; sandbox" always;
}

The ^~ modifier matters: without it, a regular-expression location such as location ~ \.php$ wins for /media/x.php and passes the file to PHP-FPM. An add_header in a location block also replaces every header inherited from server, so repeat site-wide headers such as HSTS here. The sandboxing policy keeps a stray SVG or HTML file from running script, and nosniff stops browsers from second-guessing the type; see security headers. Add Content-Disposition: attachment for downloads, and serve user content from a separate domain where you can.

Fixes that do not hold

  • Checking the declared Content-Type: the sender writes it.
  • Denylisting extensions: executable extensions, case variants and configuration files outnumber any list.
  • Checking magic bytes alone: a polyglot starts with a valid signature.
  • Sanitizing the original name: removing ../ once turns ....// into ../; generate the name instead.
  • Client-side validation: the accept attribute only shapes the file picker.
  • Antivirus as the only control: it catches known malware, not a new script or an SVG event handler.

A developer review checklist

  1. Inventory every upload path: forms, APIs and imports.
  2. Allowlist types per feature and verify content; re-encode images.
  3. Generate stored names and never put the client's filename in a path.
  4. Store outside the web root or in object storage, without execute permission.
  5. Serve with a server-chosen type, nosniff, a sandboxing policy and, for downloads, an attachment disposition.
  6. Enforce size limits in the proxy, the framework and the handler, and cap decompressed archive size.
  7. Require authentication and CSRF protection on upload endpoints.
  8. Retest with a harmless split-marker file after each change, then delete it.

References

Module: File Upload Testing. How it probes: Finds forms with a file input or multipart/form-data encoding during the crawl and posts a plain-text random marker under dangerous names: .php, .jsp, .asp, .aspx, .py, .sh and .exe extensions, double extensions such as .php.jpg, case variants such as .PhP, a %00 null-byte name, and server-side extensions declared as image/jpeg. A file input also gets one harmless sample of the type the field suggests, such as a one-pixel PNG for an avatar. When a WAF is detected and evasion is on, a case that is not accepted is retried in reshaped multipart encodings. How it confirms: A case counts as accepted on a 201, or a 200 whose body contains a success word such as upload, success or saved. If the response's Location header names the stored file, SelfSec fetches it, and a 200 carrying the marker raises the finding to critical and marks the stored file verified; otherwise a dangerous extension is high and every other case medium. There is no separate confirmation pass, and each finding records the file it left on the target, since an upload cannot be removed over HTTP. A finding moves from Possible to Confirmed only after this check. Reported as CWE-434 · ATT&CK T1505.003, T1190 · SARIF 2.1.0.
How SelfSec proves this one
Module
File Upload Testing
How it probes
Finds forms with a file input or multipart/form-data encoding during the crawl and posts a plain-text random marker under dangerous names: .php, .jsp, .asp, .aspx, .py, .sh and .exe extensions, double extensions such as .php.jpg, case variants such as .PhP, a %00 null-byte name, and server-side extensions declared as image/jpeg. A file input also gets one harmless sample of the type the field suggests, such as a one-pixel PNG for an avatar. When a WAF is detected and evasion is on, a case that is not accepted is retried in reshaped multipart encodings.
How it confirms
A case counts as accepted on a 201, or a 200 whose body contains a success word such as upload, success or saved. If the response's Location header names the stored file, SelfSec fetches it, and a 200 carrying the marker raises the finding to critical and marks the stored file verified; otherwise a dangerous extension is high and every other case medium. There is no separate confirmation pass, and each finding records the file it left on the target, since an upload cannot be removed over HTTP.
Reported as CWE-434 · ATT&CK T1505.003, T1190 · SARIF 2.1.0

A reflected response alone is not enough to promote a File Upload finding. The classical engine has to confirm the behavior before it reaches the report. See the detection engine

SelfSec scanner

Find File Upload in your own app

Reading about the class is one thing. SelfSec tests for it against an application you are authorized to assess, and only calls it real when independent evidence agrees.

Find it Confirmed, not guessed 21 vulnerability modules, with the classical engine owning the verdict. Prove it Evidence you can re-read Every finding keeps the raw request, the response and the baseline it was measured against.
Join the launch list