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