Re:CACHE - Excessive reflection, type confusion, and 0-click SXSS on Next.js
Published in zhero_web_security, 2026

Introduction
Unlike our usual publications, this one is shorter and focuses on a real-world exploitation case rather than pure research. Although the vulnerability was exploited in Next.js, which, as we will see, met all the conditions for reliable exploitation, it stems from an unusual mistake: mirroring request headers into response headers.
As the acceptance of disclosure was not likely to happen in our lifetime, we weren’t sure it was worth blogging about as we would therefore have to anonymize the name of the large company involved. Not being able to be specific about the target isn’t ideal, but here we are, It’s been a while since we last published an article on cache poisoning, and this case shows how this particular mistake leads to a systematic zero-click SXSS on the latest versions of Next.js.
Override, mutation and (S)XSS
To begin with, one of the first things that caught our attention about this target is that the request headers are reflected in the response headers. This might seem innocuous if HTTP response splitting is not possible and nothing sensitive is leaked from chatty intermediaries, although the presence of a Cloudflare cache could reveal certain information, such as users’ IP addresses. The target runs on Next.js and uses the App Router, as is the case with most Next.js applications today, the latter being the default choice in more recent versions.
We quickly tested a technique I had previously considered in theory and discussed in earlier articles, but had not yet encountered on a real target.
Since the application uses the App router, it is possible to alter the response and obtain the React Server Component payload by adding the Rsc header:

And although the default content-type of RSC requests is text/x-component, which makes this behavior inoffensive on its own, URL parameters are systematically reflected in the RSC payload after the __PAGE__ marker. This naturally applies to dynamic pages, and not to static pages whose RSC payload has been pre-generated at build time and is therefore served from disk by Next.js, these being identifiable via the x-nextjs-prerender response header.
The same constraint imposed by the RSC content-type is also likely what explains why < and > characters are not escaped, as they are not originally intended to exist outside a text/x-component context.

This is where the previously mentioned reflection of request headers in the response headers becomes particularly useful. By including a Content-Type of text/html in our request, we can overwrite the default text/x-component. Combined with the fact that URL parameters are reflected in the response body, this opens the door to interesting possibilities:

Not all response headers can be overwritten, it depends on the execution flow and the header consumption logic. Once the headers are forwarded, they first go through a loop that sets them as response headers:
for (const key of Object.keys(resHeaders)){
res.setHeader(key, resHeaders[key]);
}
After that, if any response headers from our incoming request are redefined later by Next.js in the execution flow, they will naturally overwrite our value. However, this is not the case for Content-Type, whose value is only set if no Content-Type is already present in the response, which is good news for us:
if (!res.getHeader('Content-Type') && result.contentType) {
res.setHeader('Content-Type', result.contentType);
}
Our injected value is therefore not overwritten and allows us to switch the context of the RSC payload to a much more interesting type: text/html.
All that’s left is to craft a small payload to bypass the wild young WAF standing in our way. After that, the response containing both the payload and the overridden Content-Type will be cached. Since the URL parameters are part of the cache key in this Cloudflare configuration, the poisoned response containing our XSS will be accessible via a URL that includes our payload in the query string:

Although the fact that the victim needs to click on a link containing the payload as a parameter may suggest a classic reflected XSS, it is in reality a stored XSS via cache poisoning, with the response being accessible through a URL parameter used as part of the cache key, which in this case is the payload itself.
While Rsc is added by Next.js to the Vary header and is therefore supposed to be considered during the cache’s content-negotiation phase, this is not the case here. This is far from an isolated situation, cache systems do not consistently honor Vary values as we have observed in some of our previous research. And even if it had been properly taken into account, it would not necessarily have prevented the vulnerability, but this will likely be the subject of a future paper.
This is good, but not enough. This impure user interaction spoils our exploit and must be addressed.
On the road to 0-click
We recalled CVE-2025-57822 affecting versions prior to 14.2.32 and 15.4.7, in which the Location header is processed by Next.js middleware only if headers from the incoming request are reflected back in the response. This exploit was notably demonstrated in the Intigriti challenge 0825. As you might have guessed, the version of our target falls within the vulnerable range and reflects headers as observed, resulting in a full read SSRF.
After a while without success in seeing what we could get from it, we returned to our SXSS. Although we could have simply achieved zero user interaction by pointing the server (via Location) to a host we control serving an XSS payload and forcing it to be cached, we preferred to stick to our initial approach.
Succeeding in building an exploit without relying on SSRF would make the attack viable even if the target were not running a version vulnerable to the aforementioned CVE and were fully up to date.
We therefore decided to find a way to exploit it while imposing an additional constraint: assuming the version was no longer vulnerable to SSRF, which also proved useful on other targets later on.
Although the Location header could theoretically still be leveraged on patched versions, not to trigger a server-side request this time but purely as a redirection mechanism, with the same initial goal of removing user interaction from our SXSS, a problem remained: browsers ignore the Location header when the response status code is not in the redirection range (3xx). While we could have relied on endpoints that perform automatic redirects by default, such as internationalization routes (e.g. / to /en), in those cases it is no longer possible to override the Location value, making the vector ineffective. And even if this had worked, it would also have required the redirect status codes to be cached, something that is not uncommon, but far from guaranteed, and no respectable person likes to over-condition their exploits.
Fortunately, there is a solution: a small chain involving two cache poisonings:
1 - Poison the cache for the target path by altering the response via the Rsc header to retrieve the React Server Component payload, embed the payload as a URL parameter value and override the Content-Type, as shown previously:
GET /targeted-path?pwn=<img+src=x+onerror=b='0)';a='javascript'%2b':'%2b'alert'%2b'(';frames['loca'%2b'tion']=a%2bb> HTTP/1.1
Host: targeted-site.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0
Rsc: 1
Content-Type: text/html
2 - Poison the desired page, let’s say the home page, by adding the Refresh header pointing to the target site and path, including the full XSS payload as a URL parameter (exactly the same as the one used when the page was previously poisoned, as it serves as a cache-key to access the poisoned response)
GET / HTTP/1.1
Host: targeted-site.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0
Refresh: 0; https://targeted-site.com/targeted-path?pwn=<img+src=x+onerror=b='0)';a='javascript'%2b':'%2b'alert'%2b'(';frames['loca'%2b'tion']=a%2bb>
The Refresh response header is supported by all browsers and allows you to refresh the page or redirect it to a specified URL after a defined delay (0 in our case). It can be used as an alternative to the Location header when dealing with 200 responses, as the Location header is not effective on non-redirection status codes, as explained earlier, while also simplifying caching since the status code remains the same.
As a result, when a user visits https://targeted-site.com, they are served the previously poisoned cached response containing the Refresh header pointing to our poisoned page. The page then redirects to it (in a non-technical sense, as this is handled via a refresh), sending the user to the poisoned path containing the stored XSS, thereby eliminating any need for user interaction.
The browser will kindly refresh the page until it eventually lands on the poisoned page containing the XSS:

This demonstrates that it is possible, when request headers are reflected in the response, to achieve a reliable zero-click SXSS on the latest versions of Next.js, even if they are no longer vulnerable to SSRF.
For those wondering whether request headers are also reflected in response headers when dealing with static files (_next/static), they are not in this case. While one might expect this to significantly simplify exploitation by simply pointing the Refresh header (particularly for JS assets) to an attacker-controlled file containing the desired payload, this is not the case. The header does not appear to be honored by the browser when the resource is loaded via the src attribute of a script tag.
However, since the logic responsible for forwarding headers is likely implemented at the middleware level (recently renamed proxy), it typically relies on a negative lookahead to match all paths except specific ones, thereby excluding static routes (not a strict rule, but it’s fairly common):
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
We did not report this to the Next.js team, as exploitation depends not only on header reflection, which is not, as you will have understood, a particularly common behavior, but also on the presence of an external caching layer storing the RSC payload. This latter point had already been discussed with them previously, regarding the caching of RSC payloads, and they considered that there was no feasible fix for this issue at the framework level, given that various CDNs do not consistently respect the Vary header.
And as was said previously, this last point will, among other things, be the subject of a particularly interesting future paper.
Conclusion
We were able to achieve a zero-click SXSS on, among others, the website of a globally recognized company providing critical services, whose name we unfortunately cannot disclose. The attack relied on a two-stage cache poisoning chain that exploited a seemingly innocuous implementation mistake, ultimately breaking the framework’s security model.
This SXSS is strikingly reminiscent of the stale elixir exploit, which also involved a content-type confusion and the abuse of a response element being reflected into properties - pageProps vs RSC payload - originally intended to exist within a “safe type” context.
The vulnerability was rewarded with a nice five-figure bounty.
Thank you for reading.
Al hamduliLlah;
Research conducted by zhero; & inzo_
Published in June 2026
Announcement : Now open to sponsorships, partnerships, and selective intellectual property transfers related to ongoing and future research.
Interested parties can reach out via DM or via the email listed on the blog.
