Re:CACHE——Next.js的请求头反射、类型混淆与零点击SXSS

admin 2026-06-12 04:55:26 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文披露Next.js框架中因请求头反射配置引发的安全漏洞链:攻击者通过Rsc头获取RSC负载并覆盖Content-Type为text/html,结合Cloudflare缓存机制实现两阶段投毒,首次注入XSSpayload至动态页面,二次污染首页添加Refresh头实现零点击攻击。漏洞利用类型混淆与缓存策略缺陷,突破Next.js安全模型,证明即使最新版本仍存在风险。 综合评分: 85 文章分类: WEB安全,漏洞分析,实战经验,安全建设,解决方案


cover_image

Re:CACHE——Next.js的请求头反射、类型混淆与零点击SXSS

幻泉之洲

2026年6月9日 09:44 北京

在小说阅读器读本章

去阅读

一个看上去无害的配置错误——把请求头原样反射到响应头——在Next.js里引发了一场连锁反应。配合Cloudflare缓存和React Server Component的机制,利用两阶段缓存投毒,最终绕过了所有用户交互,实现了对最新版Next.js的零点击跨站脚本攻击。这篇文章会详细拆解漏洞链条,展示缓存、头部改写和类型混淆如何被组合成一击致命的利用链。

开篇:一次不太一样的研究

以往的公开研究成果通常来自纯粹的研究项目,这回不太一样。篇幅不长,也没那么多底层原理,更多是讲一个真实的利用案例。漏洞本身出在Next.js,但根源倒不是框架本身多脆弱——而是项目方做了一个奇怪的操作:把请求头直接镜像到响应头里。

因为报告迟迟得不到确认,我们甚至不确定该不该写这篇文章。最终决定匿名化那家大型企业后还是发出来。毕竟上次写缓存投毒已经是很久以前的事了,而这个案例刚好能展示,一旦犯了那个看似无关紧要的错误,在最新版Next.js上就能稳定打出零点击SXSS。

头部覆盖、篡改和脚本注入的入口

最初引起我们注意的是,目标站点的响应头里原封不动地出现了我们发过去的请求头。乍一看问题不大——响应头拆分没法用,中间代理也不会泄露什么敏感信息,除非像这次一样前面挡着Cloudflare缓存,有可能把用户真实IP之类的字段露出来。目标跑的是Next.js,用了App Router,现在大多数Next.js应用都默认走这个路由。

我脑子里闪过一个之前只在理论层面讨论过的技术,在之前的文章里也提过,但一直没在实战里碰到过。

既然目标用了App Router,那只要加上 Rsc 请求头,就能让服务器返回React Server Component(RSC)的负载数据。

RSC请求默认的Content-Type是 text/x-component,光看这个类型本身没什么攻击性。但动态页面的URL参数会被原样拼进RSC负载里——参数紧跟在 __PAGE__ 标记后面。注意这是动态页面,如果是构建时预先生成的静态页面,Next.js会直接从磁盘吐RSC数据,响应头里会有 x-nextjs-prerender,这种页面没法利用。

还有一点,text/x-component 这个Content-Type下,浏览器不会解析HTML,所以那些没被转义的特殊字符原本不该造成威胁——它们本就不应该离开这个上下文。

这时候,前面提到的请求头反射就派上大用场了。如果我们在请求里塞一个 Content-Type: text/html,它就会把服务器原本的 text/x-component 给覆盖掉。再加上URL参数直接写进了响应体,可能性一下就打开了:

不过,不是所有响应头都能被覆盖,这取决于执行流程和头部的处理逻辑。请求头被转发进响应时,会先走一个循环,逐个设置响应头:

for (const key of Object.keys(resHeaders)){     res.setHeader(key, resHeaders[key]); }

这段代码出自 server/lib/router-server.ts[1]

之后,如果Next.js在后续执行里重新定义了某些响应头,它们自然会把我们的值给顶掉。但Content-Type是个例外——它只会在响应里还没有Content-Type的时候才去设置,这对我们来说是天大的好消息:

if (!res.getHeader(‘Content-Type’) && result.contentType) {     res.setHeader(‘Content-Type’, result.contentType); }

出自 server/send-payload.ts[2]

我们注入的Content-Type不会被覆盖,也就意味着可以把RSC负载的上下文从安全的 text/x-component 切换成更危险的 text/html

剩下的就是绕一下挡在前面的WAF,构造一个小的payload。搞定之后,包含着payload和覆盖后Content-Type的响应会被Cloudflare缓存下来。由于这个Cloudflare配置把URL参数纳入了缓存键,包含我们XSS payload的那个被污染的响应,就会通过一个包含payload的查询字符串URL被外界请求到:

尽管受害者需要点击一个带着payload参数的链接,看上去像传统反射型XSS,但实际上这是通过缓存投毒实现的存储型XSS,因为响应已经存在了缓存里,而访问这个缓存的钥匙恰恰就是包含payload的URL参数。

Next.js默认会把 Rsc 加到Vary响应头里,理论上缓存系统在内容协商时要考虑这个字段,但这次Cloudflare没理它。这事不算罕见,我们在之前的研究里就发现很多缓存系统并不会老老实实遵守Vary。退一步说,就算它遵守了,也不见得能防住这个漏洞,不过这可能是另一篇论文的内容了。

做到这一步已经很不错了,但还不够——这次攻击还需要用户点一下链接,这个不纯粹的用户交互得想办法去掉。

走向零点击:用两次缓存投毒抹掉用户交互

我们想起了CVE-2025-57822[3],它影响14.2.32和15.4.7之前的版本。当请求头被反射到响应头时,Next.js中间件会错误地处理Location头,导致服务器端请求伪造。这个利用方法在Intigriti 0825挑战里已经展示过[4]。没错,我们的目标版本刚好落在受影响的范围,并且也观察到头部反射,完全能打出完整的SSRF读操作。

花了一段时间没从SSRF里弄出什么有价值的东西后,我们还是回到SXSS这条线。其实完全可以借助SSRF让服务器去请求一个我们控制的、承载XSS payload的主机,并强迫它被缓存,这样连用户点击都省了。但我们想坚持最初的思路——不依赖SSRF。

如果攻击不捆绑SSRF,那即便目标升级到了不受CVE影响的版本,整个利用链依然可行。这也给我们后来打别的目标留了后手。

理论上,即使是在打了补丁的版本上,仍然可以试着用Location头做重定向,而不是触发服务端请求,这样也能去除用户交互。但问题在于:浏览器只在响应状态码是3xx跳转范围时才理会Location头。有些路由默认就会自动重定向,比如国际化路由把 / 转到 /en,但碰到这些情形,Location的值就没法被覆盖,整个向量就废了。即便能覆盖,还得要求缓存在3xx状态码下也能工作——虽然不罕见,但远非百分百可靠,任何一个靠谱的攻击者都不太愿意给exploit加这么多前置条件。

还好,有个不依赖Location的解法:两次缓存投毒搞个小链条就行。

第一步,像之前展示的那样,用 Rsc 头拉取目标路径的RSC负载,把payload当作URL参数值嵌入,并覆盖Content-Type,然后把这条响应污染进缓存:

GET /targeted-path?pwn= HTTP/1.1 Host: targeted-site.com User-Agent: Mozilla/5.0 … Rsc: 1 Content-Type: text/html

第二步,对首页也做一次缓存投毒,往响应里注入 Refresh 头,让它指向刚才那条带payload的URL。注意这条URL必须和第一步里用来污染缓存的那个完全一致,因为缓存键里包含完整的查询参数:

GET / HTTP/1.1 Host: targeted-site.com User-Agent: Mozilla/5.0 … Refresh: 0; https://targeted-site.com/targeted-path?pwn=

Refresh响应头[5]所有浏览器都支持,能指定延迟后刷新页面或跳转到给定URL(这里延迟是0)。在状态码为200的情况下,它是Location头的替代品,因为之前说了Location只在3xx下管用,Refresh没有这个限制,还不用改状态码,缓存起来更省事。

这样一来,用户访问 https://targeted-site.com 时,会被命中那条被污染的首页缓存,拿到带着Refresh头的响应。浏览器马上执行刷新,把用户推到那个藏着XSS的页面——整个过程不需要用户做任何操作。

浏览器会乖乖刷到含有XSS的页面:

这足以证明:只要请求头被反射进响应头,就算Next.js已经是最新版本并且不再受SSRF影响,一样可以稳定打出零点击SXSS。

有人可能会问,静态文件(_next/static)的响应头是不是也会反射请求头?这个案例里并没有。原先我们想,如果静态JS文件的响应头也能被控制,那直接让Refresh头指向一个攻击者控制的文件,payload写里头,利用起来会更简单。可惜经测试,当资源通过 <script>src 属性加载时,浏览器并不理会Refresh头。

另外,负责转发请求头的逻辑通常实现在中间件层(近期被重命名为proxy),一般会用一个负向排除来匹配除了特定路径之外的所有请求[6],从而排除了静态路由。虽然不是绝对规则,但非常常见:

export const config = {   matcher: [“/((?!_next/static|_next/image|favicon.ico).*)”], };

这事我们没有向Next.js团队报告,因为漏洞的利用不仅依赖请求头反射——这种行为本身就不常见——还要求外部缓存层存储RSC负载。关于RSC负载被缓存这个问题,我们之前就跟他们聊过,他们认为框架层面没法修复,因为不同CDN对Vary头的处理并不一致。

最后这一点,连同其他一些事情,会成为我们下一篇很有意思的论文的部分内容。

结语:奖金与代价

我们成功在一家提供关键服务的全球知名企业网站上打出了零点击SXSS,可惜不能指名道姓。攻击用的是两次缓存投毒链,把一个看起来人畜无害的实现失误转化成了对框架安全模型的彻底突破。

这次的SXSS和之前那个名为“stale elixir”的利用[7]如出一辙——都涉及Content-Type混淆,以及滥用响应里本应该只存在于“安全类型”上下文中的属性(上次是 pageProps,这次是RSC负载)。

漏洞换来了一笔漂亮的五位数赏金。

感谢阅读。


参考资料

[1] https://github.com/vercel/next.js/blob/35b5582647014b47e62fba83393623f5f7ede944/packages/next/src/server/lib/router-server.ts#L475

[2] https://github.com/vercel/next.js/blob/35b5582647014b47e62fba83393623f5f7ede944/packages/next/src/server/send-payload.ts#L73

[3] https://github.com/vercel/next.js/security/advisories/GHSA-4342-x723-ch2f

[4] https://zhero-web-sec.github.io/ctf-intigriti-0825/

[5] https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh

[6] https://nextjs.org/docs/app/api-reference/file-conventions/proxy#proxy-function

[7] https://zhero-web-sec.github.io/research-and-things/nextjs-cache-and-chains-the-stale-elixir

[8] https://zhero-web-sec.github.io/research-and-things/re-cache-excessive-reflection-type-confusion-and-0-click-sxss-on-nextjs


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:幻泉之洲 《Re:CACHE——Next.js的请求头反射、类型混淆与零点击SXSS》

评论:0   参与:  0