Exploring File Upload Vulnerabilities Without Breaking the Law
How to bypass upload validations in your own lab, map the bug classes, and harden webservers against RCE via malicious file.

In this article
A misconfigured upload endpoint is still one of the cheapest ways to turn a humble form into remote code execution. In a lab built with DVWA, OWASP Juice Shop and an Nginx 1.25 reverse proxy, we went from 'avatar upload' to a www-data shell in under 40 minutes. Before any payload, the contract matters: the target is our own VM, isolated on a 10.10.0.0/24 network with no internet route, and the goal is writing hardened rules, not collecting scalps. This walkthrough covers the full attack surface of file upload, each bug class with a vector, an oracle, and the fix that closes it for good.
The contract: authorized lab only#
Every technique here is demonstrated against infrastructure we own and are explicitly authorized to break. Replicating them against a production SaaS 'just to check' is not bug bounty, it is a felony under most computer-misuse statutes, and the difference between a researcher and a defendant is usually a written scope and a VM snapshot. Build the lab the way web pentest from scratch lays it out: Docker on an isolated bridge, no route to the host, Burp Suite as the intercepting proxy. Snapshot before each run so you can prove exactly what you touched and roll back cleanly.
Client-side and extension blacklist bypass#
The first bug class that shows up is client-side extension validation. Legacy PHP commonly ships a blacklist (.php, .phtml, .php5) with no real Content-Type check. Renaming shell.php to shell.pHp.jpg, intercepting with Burp and forcing the Content-Type to image/jpeg defeats around 80% of the amateur filters seen in corporate CTFs. Case-mixing beats naive lowercase blacklists; trailing dots and spaces (shell.php.) beat Windows-oriented ones; and alternate handlers like .phtml, .phar or .pht often execute where .php is blocked. The oracle is simple: request the uploaded path and see whether the server returns your marker string or the raw source. If it runs, the blacklist was the only control and it failed.
Content-Type and MIME confusion#
The second layer is trusting the client-declared MIME type. A backend that checks only the multipart Content-Type header is trivially bypassed, because that header is attacker-controlled; flip it to image/png and a PHP payload sails through. Slightly better backends call getimagesize() or sniff the first bytes, which raises the bar but does not clear it. The correct oracle during testing is to separate declared type from real type: send a genuine image extension with executable content, then a fake image header in front of code, and observe which combination the server both stores and serves as executable. Any path where declared type alone decides acceptance is a finding worth writing up.
Path parsing: double extensions and null bytes#
A classic surface is server path parsing. Old Nginx double-parsing (CVE-2013-4547 and its descendants) let a file named shell.jpg followed by a null byte and .php trick stacks that combine Nginx and PHP-FPM with a sloppy pathinfo. In the lab I rebuilt this with nginx:1.14-alpine and php:7.2-fpm purely for historical reference; with Nginx 1.25 and cgi.fix_pathinfo=0 the same payload dies. The lesson is that upload safety is not decided by the application alone; the web server, the FastCGI handler and the fix_pathinfo setting jointly decide whether an image directory can ever execute code. This drill pairs naturally with the input-parsing mindset in SQL injection in practice.
Magic bytes, polyglots and the image pipeline#
When the backend validates magic bytes, the game changes. GIF/PHP polyglots beat getimagesize() but fail against finfo plus Imagick with reprocessing, so the trick is to attack the transformation pipeline rather than the validator. ImageMagick with loose policies still parses MVG and SVG, and the ImageTragick family keeps resurfacing in LMS and CMS forks. At a fintech client I found a receipt upload calling convert without memory or delegate limits, which turned an SVG into an SSRF and then a file read. If the pipeline fetches remote resources, the upload becomes a request-forgery primitive, which is why this class ties directly to SSRF and cloud metadata. Reprocess every image, disable dangerous coders, and never let convert follow a URL.
Where the file lands: storage, traversal and serving#
An underrated surface is where the file ends up. Storage under /var/www/uploads served directly by Apache is the classic, but the same problem hides in S3 buckets with the wrong Content-Disposition and in CDNs that reprocess HTML. Path traversal inside the filename (dot-dot-slash sequences aimed at /tmp/cron.d/ or a webroot) still works against Node middleware that trusts Multer's originalname. The oracle is to upload a benign marker with a traversal name and check where it materializes on disk; if it escapes the intended directory, an attacker chooses the write location. For a more formal per-endpoint test plan, especially on APIs, follow the REST and GraphQL pentest checklist.
Fuzzing the endpoint methodically#
Manual bypasses find the first bug; methodical fuzzing finds the rest. Point Burp Intruder or a small script at the upload field and iterate three dimensions at once: the extension (a wordlist of .php, .phtml, .phar, .pht, .php7, .inc, mixed case, trailing dot and space), the declared Content-Type, and the magic-byte prefix. For each combination, the oracle is a two-step probe: does the server accept the file, and does requesting it back execute the marker. Do not forget the upload race condition, where a webshell is briefly reachable between write and the antivirus or move step; a tight loop of upload-then-request can win that window on a real target and must be tested explicitly so the fix (validate before the file is ever reachable) is justified.
Chaining an upload into full compromise#
A single executed marker is a finding, but the report should show impact. In the lab, the GIF/PHP polyglot gave a minimal command webshell; from there a standard reverse shell (a bash TCP callback to the attacker VM on the isolated network) upgraded the foothold to an interactive www-data session in seconds. The realistic escalation from there is not a Hollywood exploit but boring persistence: a writable directory served by the web server, a world-writable cron path reachable via the earlier traversal, or a leaked database credential in a config file the shell can now read. Document the chain to the point of proven impact and stop; the objective is to justify the severity rating, not to ransack a machine, and the snapshot lets you replay the whole chain for the client without leaving damage behind.
Defensive rules that actually work#
On the defensive side, the rules that earn their place in a report are boring and effective. Generate a random filename (UUIDv7) and discard the client name entirely; store outside the document root; serve through a handler that forces Content-Disposition: attachment and Content-Type: application/octet-stream; validate the MIME type with libmagic server-side, not the declared header; reprocess images and strip metadata; and block double extensions at the web server itself, for example denying any request whose path contains .php, .phtml or .phar before a further dot. Add an antivirus pass and a size cap, and put the whole thing behind the shift-left gates in AppSec shift-left so a regression is caught in CI, not in production.
Checklist#
Before signing off an upload feature: filename is server-generated and never reflects user input; extension is allowlisted, not blacklisted; MIME is verified by content, not by header; storage is outside the webroot and served with attachment disposition; images are reprocessed and metadata stripped; the web server refuses to execute anything in the upload path; path traversal in the filename is neutralized; size and rate limits exist; and an antivirus or content scanner runs asynchronously. Every 'no' on that list is a finding, and every finding gets a concrete remediation line, not a vague 'sanitize input'.
FAQ#
Is blocking .php enough to be safe? No; a blacklist is the weakest possible control, defeated by case-mixing, alternate handlers (.phtml, .phar, .pht), trailing dots, and server path-parsing quirks. The durable control is an allowlist of extensions combined with content-based MIME validation and a web server that never executes code in the upload directory. Can I safely allow SVG uploads? Only with heavy caution: SVG is XML and can carry script and external references, so serve it with a restrictive Content-Security-Policy and attachment disposition, or rasterize it to PNG on receipt and discard the original. Treating SVG as a harmless image is how ImageTragick and stored XSS keep landing.
Should uploads live on the same host that serves the app? Prefer a separate storage domain with no code execution and a locked-down serving handler, so even a successful write cannot become RCE on the application host. And how do I test without risking damage? Snapshot the VM, work only inside authorized scope, use benign marker payloads that prove the bug (a unique string, the VM's own id) rather than exfiltrating real data, and roll back after each run; proving the write is enough, reading /etc/passwd on someone else's box is both unnecessary and illegal.
Conclusion#
File upload stays dangerous because it sits at the seam between three systems, the application, the web server and the storage layer, and a gap in any one of them is enough. Attack it the way this lab does, class by class, with a clear oracle for each, and the defensive rules write themselves: server-generated names, allowlisted extensions, content-based MIME checks, reprocessed images, no execution in the upload path, and traversal neutralized. Keep every experiment inside authorized scope with a snapshot to prove it, ship the hardened rules through CI, and the humble avatar form stops being a 40-minute path to a shell.


