File Upload Security
# CHAPTER 7
File Upload Security
1. The vulnerability class
Unrestricted file upload appears in essentially every serious application security list, and it earns the place. Most vulnerabilities let an attacker read data they should not see or perform an action as someone else. This one can let them place a file of their choosing on your server and then ask the server to run it.
That outcome is remote code execution, and it is usually the end of the assessment.
This chapter looks at how the flaw is exploited. The defensive build — how to construct an endpoint that resists all of it — is covered in *Secure File Upload APIs* in the API Security tutorial.
2. The web shell
The classic attack is small. An attacker uploads a file containing something like:
If that file lands somewhere the web server executes PHP, they visit it with ?cmd=whoami and now have a command prompt on your host, running as the web server user. From there the usual path is reading configuration files for database credentials, then moving outward.
Nothing exotic is required. It works whenever two conditions hold at once: the application accepted the file, and the location it was stored in executes code.
3. How filters get bypassed
Most applications do have *some* validation. These are the techniques that routinely defeat it.
Client-side validation only. JavaScript that checks the extension before submitting is a usability feature, not a security control. An attacker never runs your JavaScript — they send the request directly with curl or an intercepting proxy.
Content-Type spoofing. The Content-Type header is set by the client. Sending image/png alongside a PHP payload defeats any check that reads only that header.
Double extensions. Against a filter that inspects only the final extension, shell.php.jpg may pass. Against certain historical server configurations, shell.jpg.php was executed. Which one works depends on whether the filter or the server has the last word.
Case variation. A filter comparing against lowercase php misses .PHP or .pHp unless it normalises first.
Trailing characters. Spaces, dots or null bytes appended to a filename (shell.php., shell.php%00.jpg) have historically caused the filter and the filesystem to disagree about what the name actually is.
Magic-byte prefixing. If validation checks only the first few bytes, an attacker prepends a valid PNG header to a script. The file passes the type check and remains executable.
The pattern behind all of these is the same: the filter and the thing that ultimately interprets the file disagree. Every bypass lives in that gap.
4. It does not stop at code execution
Even where execution is impossible, uploads carry other risks.
Stored XSS via SVG. SVG is XML, and XML can contain script. An SVG served inline from your origin can run JavaScript in your users' sessions:
HTML files uploaded and served from your domain do the same thing more directly.
Path traversal. A filename such as ../../.htaccess or ../../../config.php can escape the intended directory and overwrite something important, if the application uses the supplied name.
Overwriting. Where filenames are predictable, one user can replace another user's file — or replace an application file.
Denial of service. A zip bomb — a small archive that expands enormously — exhausts disk or memory on extraction. Very large images can do the same to an image-processing library.
Antivirus and content bypass. Where uploads are shared between users, an unscanned upload turns the site into a malware distribution channel and a phishing host, with your domain lending the credibility.
5. Why the vulnerability persists
It survives in real codebases for predictable reasons: validation is written on the client because that is where the form is; a denylist is used because enumerating dangerous extensions feels more natural than enumerating safe ones; files are stored in the web root because it makes serving them trivial; and the original filename is preserved because it looks friendlier in the interface.
Each choice is locally reasonable. Together they produce the exact conditions the attack requires.
6. Testing an upload feature
Working through this list will find most instances:
- 1. Submit the request directly, bypassing the page's JavaScript entirely.
-
2.
Send a disallowed file with a forged
Content-Type.
- 3. Try double extensions in both orders.
- 4. Vary the case of the extension.
- 5. Append a trailing dot, space or null byte.
- 6. Prepend valid image magic bytes to a script.
- 7. Put traversal sequences in the filename.
- 8. Upload an SVG containing a script tag and see whether it is served inline.
- 9. Upload a file that already exists under a predictable name.
- 10. Where an upload succeeds, work out the stored URL and request it — the question is whether it is executed, rendered, or downloaded inertly.
Only ever run these against systems you are authorised to test.
7. What actually fixes it
The controls that hold up, in order of value:
- Verify the type from file contents, not from headers or the extension.
- Use an allowlist, so unknown types fail closed.
- Generate the stored filename so no attacker input reaches the filesystem.
- Store files where nothing executes — outside the web root, or in object storage.
-
Serve user content from a separate domain, with
Content-Disposition: attachmentandX-Content-Type-Options: nosniff.
- Enforce size limits at both the application and the web server, and cap decompressed output.
- Authorise every download, not just every upload.
- Scan anything that will be shared with other users.
8. Practice exercises
-
1.
On a deliberately vulnerable application you control, upload a text file renamed to
.jpgand confirm the server accepts it.
- 2. Add contents-based type detection and confirm the same file is now rejected.
-
3.
Serve an uploaded SVG inline and observe the stored XSS; then re-serve it with
Content-Disposition: attachmentand observe the difference.
- 4. Move the upload directory outside the web root and rewrite retrieval as an authorised streaming endpoint.
9. Summary
File upload is the rare vulnerability where a single mistake yields code execution rather than data exposure, which is why it is worth more attention than its frequency alone suggests. Every bypass exploits a disagreement between the code that validates the file and the system that eventually interprets it — so the durable fixes remove the interpreter from the equation entirely.
Chapter 8 moves on to access control and authorisation, which decides who is allowed to reach these features in the first place.