File Upload Vulnerabilities: From Bypassed Filters to RCE
File upload vulnerabilities explained end to end: what makes an upload dangerous, how each filter gets bypassed, a benign lab proof of a bypass, the non-RCE impact, and how to lock uploads down.
File upload vulnerabilities turn a convenient feature, letting a user hand you a file, into one of the shortest paths from the public internet to code running on your server. A profile picture, a CV, an attachment on a support ticket: any of these is an attacker writing a file of their choosing onto a machine you own, and the only thing standing between that and a shell is the set of checks you wrote around the upload.
This writeup walks the whole path. What makes an upload dangerous, when it crosses over into code execution, every common filter and the idea behind its bypass, a worked example on a lab box using a harmless proof file, the damage that does not need code execution at all, how to spot it, and how to close it for good. Everything here is for a box you own or are authorized to test.
Test file uploads only against an application you own or have written permission to assess, or a lab such as the one at lab.0xpriv.dev. Uploading files to a site you do not control, even a benign one, is unauthorized access.
01What makes a file upload dangerous
File upload vulnerabilities are dangerous because an upload is one of the few features that lets an outsider place a file of their own choosing onto your server, with a name and contents they control. Most input you receive is text that you store and later print back. An upload is different: the bytes land on disk, and if the server can be convinced to treat those bytes as a program instead of as data, the attacker has moved from sending requests to running code.
The feature itself is fine. The risk comes from three things the application decides: what it accepts, what it names the file, and where it puts it. Get any one of those wrong and an upload form becomes a write primitive into your filesystem. The rest of this post is about how each of those three decisions goes wrong and what the attacker does with it.
02When an upload turns into code execution
An upload becomes remote code execution when the saved file lands somewhere the web server will run it, and its name tells the server which interpreter to hand it to. That is the whole condition. A PHP file dropped into the web root runs the moment someone requests it, because the server sees the .php suffix and routes the file to the PHP engine. The same file sitting in a database row, or on a storage bucket served as plain bytes, does nothing.
The diagram below shows the fork. Same uploaded file, two destinations, two completely different outcomes.
Keep that fork in mind for the whole post. Every filter bypass in the next section is only useful if the file reaches the top path. If your uploads live on the bottom path, a bypass is far less interesting, which is exactly why storage location is the strongest defense you have.
03The checks apps rely on, and how each is bypassed
The checks apps rely on fall into a handful of families, and each family has a well understood weakness. The common mistake is to stack several weak checks and assume they add up to a strong one. They do not. An attacker only needs one path through, so the table below pairs each check with the idea behind its bypass.
| Check the app relies on | Why it fails |
|---|---|
| Client-side validation only (JavaScript) | Runs in the browser, so it is advice, not enforcement. An attacker sends the request straight to the server with a proxy and skips it entirely. |
Content-Type header allowlist | The header is supplied by the client and can say anything. Setting Content-Type: image/png on a PHP file costs one edit in Burp. |
Extension denylist (block .php, .asp) | Denylists forget cases. Alternate handler suffixes like .phtml, .php5 or .phar often still execute and are not on the list. |
Extension allowlist (only .jpg) | Stronger, but breaks if the parser only checks part of the name, or if the upload directory maps a second suffix to an interpreter. |
| Magic bytes / content sniffing | A file can start with a valid image header and still contain script after it. GIF89a followed by code passes the signature check and runs when executed. |
| Double extensions | A name like proof.php.jpg or proof.jpg.php plays the check that reads one end against the server config that reads the other. |
| Null byte, legacy stacks | On old PHP (before 5.3.4) and similar C-backed code, proof.php%00.jpg truncated at the null byte, so the check saw .jpg and the filesystem saw .php. Modern runtimes fixed this. |
| Case and trailing characters | A lowercase denylist misses .pHp. A trailing dot, space or ::$DATA stream (proof.php., proof.php ) can be stripped by the filesystem after the check passed. |
The names an attacker tries are mechanical. Here is the kind of list you walk through against a denylist that only inspects the final suffix.
proof.php.jpg double extension, a last-segment check sees .jpg proof.jpg.php reversed, for a first-segment or naive split proof.pHp case variation against a lowercase denylist proof.phtml alternate handler the denylist forgot proof.php. trailing dot the filesystem strips afterwards proof.php%00.jpg null-byte truncation on legacy C-backed stacks
Content sniffing feels safer than it is. The trick against it is to keep a real image header so the signature matches, then append the payload. The file is a valid GIF to a parser and a benign marker script to the interpreter at the same time.
GIF89a <?php echo "UPLOAD-PROOF-0xpriv"; ?>
Do not count weak checks like a security budget where four small ones equal one good one. The attacker walks the whole list and needs a single success. Design as if every check except the content and the storage location will be bypassed.
04Where the file has to land to run
Where the file lands is the difference between a finding and a footnote. For the upload to execute, two things have to be true at once: the file sits under a directory the web server maps to a URL, and the server is configured to pass that file's suffix to an interpreter. Break either and the same malicious file becomes a harmless download.
Three storage patterns are common, and only one of them is dangerous by default:
Inside the web root, handler enabled
The classic mistake. Files save to /var/www/html/uploads and that folder runs PHP. An uploaded script is one request away from executing. This is where RCE lives.
Inside the web root, execution disabled
Files are reachable by URL but the server refuses to run scripts in that folder. Better, though it depends on getting and keeping the config right, and a parser quirk can still reintroduce execution.
Outside the web root or on a separate domain
Files live somewhere the web server never maps to an interpreter, or on object storage and a cookieless download host. The app streams them back deliberately. An uploaded script is just bytes no one will ever run.
This is why a good test always answers two questions before anything else: can I reach my uploaded file by URL, and does requesting it produce dynamic output. If the answer to either is no, you pivot to the non-RCE impact further down this post rather than chasing a shell that cannot run.
05Finding and mapping the upload point
Finding the upload point starts with the request, not the page. Open the form in a proxy so you can see exactly what the browser sends, then strip away the client-side theatre. Burp Suite sits in the middle and lets you change the filename, the Content-Type, and the bytes before they ever reach the server.
POST /profile/avatar HTTP/1.1 Host: lab.0xpriv.dev Content-Type: multipart/form-data; boundary=x --x Content-Disposition: form-data; name="file"; filename="me.jpg" Content-Type: image/jpeg (image bytes) --x--
Two fields are yours to control on every request: the filename and the part's Content-Type. Those are the first two levers from the bypass table. The third question is where the file ends up, and for that you need the upload directory. If the response does not hand you the saved path, fuzz for it with ffuf.
# 0xpriv.dev/web # find the directory uploads land in, then confirm a known filename is reachable ffuf -u https://lab.0xpriv.dev/FUZZ -w common-dirs.txt -mc 200,301,302 ffuf -u https://lab.0xpriv.dev/uploads/FUZZ -w filenames.txt -mc 200
$ ffuf -u https://lab.0xpriv.dev/FUZZ -w common-dirs.txt -mc 200,301,302 uploads [Status: 301, Size: 169] assets [Status: 301, Size: 169] files [Status: 200, Size: 0]
Once you know the directory is reachable and you can rename the file at will, you have everything the next step needs. The goal now is to prove, with the most harmless file possible, that a bypass puts an executable file where the server will run it.
06A worked bypass on the lab, with a benign proof file
The worked bypass uses a benign proof file on purpose: a one line script that prints a fixed marker and nothing else. There is no command runner, no shell, no parameter it reads. If the marker comes back in the response, the server executed the file, which proves the vulnerability without ever shipping a usable weapon. This is the right way to demonstrate the bug in a report.
The lab endpoint keeps a denylist of .php and sniffs for an image header, but it only inspects the last extension segment. Both the content check and the suffix check fall to a single file: an image header to satisfy the sniff, an alternate handler suffix the denylist forgot, and a benign marker as the body.
GIF89a # 0xpriv.dev/web benign proof: prints a marker, runs no commands <?php echo "UPLOAD-PROOF-0xpriv-" . PHP_VERSION; ?>
Save that as proof.phtml. The GIF89a header makes a content sniffer read it as a GIF. The .phtml suffix is still mapped to the PHP engine on many default stacks but is a classic gap in a denylist written only for .php. Send it through the form with the part's Content-Type set to image/gif, then request the saved file.
$ curl -s https://lab.0xpriv.dev/profile/avatar \ -F "[email protected];type=image/gif" -b session.txt {"ok":true,"path":"/uploads/2026/10/proof.phtml"} $ curl -s https://lab.0xpriv.dev/uploads/2026/10/proof.phtml UPLOAD-PROOF-0xpriv-8.2.12
The marker and the server's PHP version come back in the response body. That single line is the proof: the file was accepted past both filters, it landed in a directory with a live handler, and the handler ran it. In a real assessment you stop here, record the request and response, and write the finding. The step from a marker to running operating-system commands is well understood and does not belong in a lab writeup, because the proof above already establishes the full impact.
A marker that echoes a string is all you need to demonstrate code execution. Do not leave a file on the target that accepts commands or input. Clean up the proof file when you are done, and note its path in the report so the owner can confirm removal.
07Impact beyond code execution
Impact beyond code execution is where upload bugs still bite even when the file can never run. A read of the upload feature that stops at "no RCE, no finding" misses most of the real risk, because several high-impact issues need nothing more than the file being served back to a browser or parsed by a library.
| Non-RCE impact | How the upload causes it |
|---|---|
| Stored XSS via SVG or HTML | An .svg or .html file can carry script. Served inline on your origin, it runs in the victim's session, so an "image" upload becomes persistent cross-site scripting. |
| SSRF and XXE via parsers | Server-side image, document and XML parsers can be steered to fetch URLs or read local files. A crafted SVG or an Office document with an external entity makes the server send requests on the attacker's behalf. |
| Path traversal in the filename | A filename like ../../config/app.ini that the app trusts can write or overwrite files outside the intended folder, corrupting or replacing application files. |
| Denial of service by decompression | A small image with enormous declared dimensions, a pixel flood, forces the server to allocate huge amounts of memory when it decodes the file. A few uploads can exhaust the box. |
The SVG case is worth calling out because it surprises people. An SVG is XML, not a raster image, and it can contain a <script> element. If your gallery serves user SVGs from your own domain with an image content type the browser overrides, you have shipped stored XSS through what looked like a picture upload. The fix is the same as for the RCE path: never serve user files inline from your trusted origin.
08Detecting malicious uploads
Detecting malicious uploads is realistic because the attack leaves clear traces in two places: on disk and in the access log. On disk, a script file appearing in a directory that should only ever hold images is a strong signal on its own. File integrity monitoring on upload folders, alerting on any file whose suffix or content is executable, catches most drops.
In the log, watch the sequence rather than a single request. An upload followed within seconds by a GET to a freshly created file under the upload path, especially one that returns a non-empty body for what should be a static asset, is the exploitation pattern. A spray of upload attempts with odd filenames (.phtml, double extensions, trailing dots) from one client is reconnaissance against your filter. Alert on the behaviour, because the filenames and payloads change but the shape of the attack does not.
Log the stored path of every upload, then alert whenever a file under the upload directory is requested and the response body is not byte-for-byte the stored file. A static image folder serving dynamic output is almost always an executed upload.
09Shutting file uploads down
Shutting file uploads down is a short list of controls that work together, and the first two matter most because they hold even when every other check is bypassed. Validate what the bytes actually are, and make sure the place they land can never run them. The rest raises the cost of the attacks that remain.
Put together, the first and third rows alone defeat the entire worked example above: a file validated by content cannot smuggle a script behind a GIF header, and a file stored where nothing runs it cannot execute no matter what it is named.
10FAQ
Does blocking the .php extension stop file upload attacks?FAQ
No. A denylist of one or two extensions misses alternate handler suffixes like .phtml, .php5 and .phar, case variations, double extensions and trailing characters. Use a content-based allowlist and store files where no interpreter runs them instead of trying to list every dangerous name.
Is checking the Content-Type header enough to validate an upload?FAQ
No. The Content-Type is sent by the client and can be set to any value, so it tells you what the uploader claims, not what the file is. Decode the bytes and confirm the real type, then accept only an allowlist of types you actually support.
Can a file upload be a vulnerability if the file never executes?FAQ
Yes. An uploaded SVG or HTML file served from your origin can run script as stored XSS, server-side parsers can be pushed into SSRF or XXE, a crafted filename can traverse paths, and a pixel-flood image can exhaust memory. None of these need the file to run as code on the server.
Why prove the bypass with a marker file instead of a web shell?FAQ
A file that only echoes a fixed marker proves the server executed your upload, which is the full severity of the finding, while leaving nothing usable on the target. A command-accepting shell adds no evidence and creates real risk if it is forgotten, so a marker is the responsible proof.
What single change prevents most upload RCE?FAQ
Store uploads outside the web root, or on a separate domain with no interpreter mapped, and stream them back through application code. With nowhere for a script to run, a bypassed filter cannot turn into code execution even if the attacker gets a malicious file accepted.
11Wrapping up
File upload vulnerabilities come down to three decisions: what you accept, what you name the file, and where you put it. Attackers walk every filter in order because they only need one to give way, so client-side checks, header checks and extension lists are speed bumps, not walls. The two controls that actually hold are validating the real content and storing the file where no interpreter will ever touch it. Prove the bug with a harmless marker, report it, and fix it at those two points, and the whole class stops being a path to your server.
references
Founder of 0xpriv, covering offensive security, web application vulnerabilities, and practical security research through PHP, Python, and hands-on technical analysis.