Re:CACHE - Excessive reflection, type confusion, and 0-click SXSS on Next.js - zhero_web_security You are using an outdated browser. Please upgrade your browser to improve your experience.
Rachid Allam - zhero;<br>vulnerability researcher ⚔️
Follow /<br>Email<br>LinkedIn<br>X (formerly Twitter)
Introduction<br>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.<br>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.<br>Override, mutation and (S)XSS<br>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.<br>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.<br>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.<br>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:<br>for (const key of Object.keys(resHeaders)){<br>res.setHeader(key, resHeaders[key]);
server/lib/router-server.ts<br>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:<br>if (!res.getHeader('Content-Type') && result.contentType) {<br>res.setHeader('Content-Type', result.contentType);
server/send-payload.ts<br>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.<br>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.<br>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...