首页
社区
课程
招聘
[原创][AI分析Web漏洞] 掰开揉碎讲解 CyberGame 难度Hard 477 分 — Web Cache Poisoning 完整 Writeup
发表于: 2026-5-5 15:12 1404

[原创][AI分析Web漏洞] 掰开揉碎讲解 CyberGame 难度Hard 477 分 — Web Cache Poisoning 完整 Writeup

2026-5-5 15:12
1404

做出来了之后,我发现不需要人类有比较高的专业知识,他可能只需要会打字,所以我突然就拔剑四顾心茫然了。

AI 在嘲笑:整个武林在我面前都毫无意义,热武器面前人人平等。

CTF: CyberGame 2026 (SK-CERT) | 难度: Hard | 477 分(一道题 477 分还挺高的)

题目来源: ctf-world-platform-2.cybergame.sk/challengesfuture.js

Flag: 需要你根据文档内容自行破解

题目分类:Web 安全。本题属于 Web 类型 CTF,不是 PWN(二进制利用)。Web CTF 的攻击发生在 HTTP 协议层——伪造请求头、投毒缓存、利用应用逻辑漏洞;PWN CTF 的攻击发生在内存层——缓冲区溢出、ROP 链、堆利用。本题的攻击手段(HTTP 头注入 + 缓存投毒 + XSS)全部在 Web 应用层面完成,没有涉及二进制逆向或内存破坏。具体来说,本题考察的是 Web Cache Poisoning(Web 缓存投毒)+ XSS(跨站脚本攻击)的组合利用。

这道题的完整分析过程是由 AI(OpenCode + 大语言模型)主导完成的,人类只提供了初始提示词和少量方向性回复。

就这么多。没有告诉 AI 用什么攻击技术、没有暗示漏洞类型、没有给任何解题方向。

这确实是一道很难的题。整个分析过程断断续续持续了一天半,AI 很多次陷入僵局——试了十几种方法全部失败后,会说"我没有灵感了,你能给我一些提示或方向吗?"。人类的回复始终是:

我没啥想法,你自己探索。

然后 AI 就换一个角度继续尝试。比如投毒 / 路径发现没缓存,换成 /_next/ 路径;RSC: 1 被 Vary 挡住,试了 HTTP 走私、路径注入等各种绕过方式全部失败后,最终去啃 node_modules 里的压缩代码,才找到 base-server.jsapp-render.js 对 RSC 头判断不一致的关键突破。后面 Host 不匹配、AE 不匹配、Docker 无外网——每个坑都是 AI 自己撞墙、自己排查、自己解决的。

AI 在完全自主的情况下完成了以下全部工作:

最终,人类让 AI 把整个分析过程写成了你正在阅读的这份 writeup 文档,期间经过多轮人工调优,将知识掰开揉碎的讲解。

破解证明: 图片描述

在讲这道题之前,先理解几个概念。如果你已经懂了可以跳过。

当你用浏览器访问一个网站时,浏览器会发送一个 HTTP 请求。比如访问 4c4K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3g2^5j5h3#2H3L8r3g2Q4x3X3g2U0L8$3#2Q4x3V1k6H3j5h3N6W2

XSS(Cross-Site Scripting,跨站脚本攻击)是让网页执行攻击者注入的 JavaScript 代码。

关于"跨站"这个名称的误解:XSS 的英文原名 Cross-Site Scripting 容易造成歧义,让人以为攻击是"从一个网站攻击另一个网站"。但实际上,XSS 攻击发生在同一个网站内部——攻击者把恶意 JavaScript 注入到目标网站的页面中。当受害者用浏览器访问这个页面时,恶意代码在受害者的浏览器里执行,而且浏览器认为这段代码来自目标网站(因为它确实是目标网站返回的页面内容)。也就是说,XSS 的本质是同源攻击:恶意脚本和目标网站是同一个"源"(same-origin),所以浏览器赋予它完整的权限——能读取该网站的 Cookie、能操作该页面的 DOM、能以该网站的身份发送请求。"跨站"这个名字,指的是攻击者想要达到的效果(把数据从目标网站"跨"到攻击者手里),而不是说脚本运行在不同的网站上。

正常情况下,网页内容是服务器控制的,用户无法往里插入代码。但如果服务器把关不严,攻击者可以构造恶意输入,让页面包含 <script>alert(1)</script> 这样的标签,浏览器会执行它。

一旦 XSS 在受害者的浏览器里执行,这段 JavaScript 就拥有该网站的全部权限,可以做以下事情:

你可能会有疑问:浏览器不是有跨域限制吗?从一个域名(比如 http://proxy:4000)发请求到另一个域名(比如 621K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6W2N6X3W2D9i4K6u0W2j5$3!0E0),不是会被浏览器拦截吗?

这里有一个常见的误解需要澄清。浏览器的同源策略(Same-Origin Policy)确实有跨域限制,但这个限制是单向的:它禁止的是 JavaScript 读取跨域请求的响应内容,但不禁止发送跨域请求本身。换句话说:

更简单的窃取方式甚至不需要 fetch,只需要一行代码:

浏览器加载图片天然就是跨域的,没有任何限制。攻击者的服务器只要记录下请求中的参数就拿到 Cookie 了。所以跨域限制无法阻止 XSS 窃取数据

Cookie 是浏览器在本地存储的键值对数据。你可以把它理解为浏览器为每个域名维护的一个"小字典"。

Cookie 是怎么写入的:服务器在 HTTP 响应中通过 Set-Cookie 响应头告诉浏览器"请存储这个 Cookie":

浏览器收到后,把这些键值对保存到本地存储中。注意一个响应可以设置多个 Cookie,每个 Set-Cookie 头设置一个。

Cookie 是怎么发送的:之后浏览器每次请求同一域名下的任何 URL,都会自动在请求头中带上该域名下所有匹配的 Cookie(不需要 JavaScript 介入,浏览器自动完成):

这里的机制是:浏览器内部维护了一个"Cookie 存储",按域名和路径组织。每次发请求时,浏览器自动查表,把匹配的 Cookie 全部塞进 Cookie 请求头。JavaScript 不需要(也不应该)手动添加 Cookie 到请求中——浏览器全自动处理。

Cookie 的属性:每个 Cookie 除了 name=value,还有一些属性控制其行为。这些属性在 Set-Cookie 头中指定,格式为分号分隔的键值对:

常见的属性包括:

Path 属性详解Path 决定了 Cookie 在哪些 URL 路径下会被浏览器自动附带。比如设置了 Path=/admin 的 Cookie,只有在浏览器访问 /admin/admin/users/admin/settings 等路径时才会被发送,访问 //about 时不会发送。这道题中 Path=/(根路径),意味着访问该域名下的所有 URL 都会带上这个 Cookie。

如果同一个域名下有两个同名的 Cookie 但 Path 不同(比如 flag=x; Path=/flag=y; Path=/admin),浏览器会都存储。访问 /admin 时两个 Cookie 都会被发送(因为 /admin 同时匹配 //admin),访问 / 时只发送 Path=/ 的那个。浏览器按 Path 的具体程度排序——更具体的 Path(如 /admin)优先级更高,但两个 Cookie 都存在,不会互相覆盖。实际开发中应避免同名 Cookie + 不同 Path,这容易造成混乱。

Cookie 发送和接收的数据结构:浏览器发送 Cookie 时,是通过 HTTP 请求头 Cookie纯文本方式发送的,格式是用分号空格分隔的键值对。多个同名 Cookie 不会被合并,而是全部列出。来看一个完整的例子:

假设服务器通过两个 Set-Cookie 响应头设置了同名 Cookie:

浏览器存储后,当用户访问 /admin 时,浏览器发出的请求:

注意:浏览器把两个 flag Cookie 都放在一个 Cookie 头中,用分号分隔。HTTP 协议中 Cookie 请求头的格式就是纯文本键值对列表,没有数组、没有嵌套结构

服务器端接收时,如何解析这个 Cookie 头取决于后端框架。以 Node.js/Express 为例:

可以看到,Cookie 请求头是纯文本,同名 Cookie 会出现多次;但服务器端框架通常把 Cookie 解析成对象(字典),同名的后者会覆盖前者。这就造成了混乱:浏览器发了两个 flag,但服务器只"看到"最后一个值。这也是为什么实际开发中应避免同名 Cookie + 不同 Path——虽然浏览器能正确存储和发送,但服务器端解析会丢失数据。

SameSite 属性详解:这里的"跨站"(cross-site)和 XSS 中的"跨站"含义不同。SameSite 中的"站"(site)指的是注册域名(如 example.com),而"跨站"指的是从一个注册域名发请求到另一个注册域名。比如 evil.com 的页面中嵌入了一个指向 target.com 的请求,这就是跨站请求。SameSite 属性控制浏览器在这种跨站请求中是否附带 Cookie:

关于安全性的疑问:Strict 模式下跨站请求不带 Cookie,那 Lax 和 None 让跨站请求带 Cookie,会不会泄露鉴权信息?

首先要理解 Cookie 的归属:每个 Cookie 都属于某个特定的域名。比如 target.com 服务器通过 Set-Cookie: session=abc123 设置的 Cookie,它的"主人"是 target.com。浏览器会记住"这个 Cookie 属于 target.com"。之后浏览器只会在发请求给 target.com 时带上这个 Cookie——发请求给 evil.com 时不会带 target.com 的 Cookie。

所以当 evil.com 的页面中有一张图片 <img src="https://target.com/avatar.jpg"> 时:

简单说:Cookie 永远是跟着目标网站走的,不是跟着来源网站走的。"跨站请求带 Cookie"的意思是"发给目标网站的请求中带了目标网站的 Cookie",而不是"把目标网站的 Cookie 发给了来源网站"。

那真正的危险是什么? CSRF(跨站请求伪造)。举个例子:

注意:evil.com 看不到转账结果(跨域限制),但它成功让浏览器替用户执行了操作。SameSite=Lax 正是为了防御这种攻击:

而 XSS 中的"跨站"指的是攻击效果——攻击者想把数据从目标网站"跨"出去,但攻击本身发生在目标网站内部(同源)。两者不要混淆。

多个 Cookie 的 httpOnly 是独立的:每个 Cookie 的属性只影响自己。假设服务器在响应中设置了两个 Cookie:

浏览器会存储两个 Cookie:session 有 httpOnly 保护,theme 没有。之后浏览器发请求时,两个 Cookie 都会被自动发送:

但 JavaScript 执行 document.cookie 只能看到 theme=dark(没有 httpOnly 的),看不到 session=abc123(有 httpOnly 保护的)。

httpOnly 属性详解:httpOnly 是按每个 Cookie 单独设置的,不是按域名整体的。也就是说,同一个域名下,Cookie A 可以是 httpOnly,Cookie B 可以不是。它的效果是:

在 Chrome 开发者工具中查看 Cookie 属性:打开 Chrome 开发者工具(F12)→ Application(应用)标签 → 左侧 Cookies → 选择域名,可以看到该域名下所有 Cookie 的详细信息,包括 Name、Value、httpOnly、Secure 等列。httpOnly 列用 ✓ 标记表示该 Cookie 设置了 httpOnly。

用 Python 脚本能否看到 Cookie 的 httpOnly 属性:当你用 Python 的 http.clientrequests 发请求时,响应中的 Set-Cookie 头会包含 httpOnly 标记,所以你可以从首次设置的响应中判断。但 Python 脚本不像浏览器那样维护 Cookie 存储,它只是收到原始 HTTP 响应。要扫描 Cookie 安全性,可以检查 Set-Cookie 响应头中是否包含 httpOnly 字样——如果没有,说明该 Cookie 没有设置 httpOnly,存在被 XSS 读取的风险。

这道题中,Bot 的 flag Cookie 被设置为 httpOnly: false(即不设 httpOnly)。具体代码在 bot/server.js 第 59-65 行(这里的"Bot"是一个 Puppeteer 脚本,它扮演受害者的角色——Bot 的代码负责"设置受害者的浏览器环境",包括给受害者的浏览器设置一个含有 flag 的 Cookie):

你可能会问:为什么是 Bot(受害者)在设置 Cookie,而不是 Next.js 应用(7acK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0b7$3i4K6u0W2y4U0u0Q4x3X3f1I4y4e0y4Q4x3X3f1I4y4K6q4Q4x3@1p5@1x3o6l9H3i4K6u0r3对应的服务)在设置?这涉及到 CTF 题和真实场景的区别:

CTF 题这样设计是为了简化——不需要实现登录系统,Bot 直接预设了含有 flag 的 Cookie。无论哪种方式,最终效果一样:Bot 的浏览器在访问 proxy:4000 时会自动带上 flag=SK-CERT{...} 这个 Cookie。

FLAG 的值来自环境变量,在 docker-compose.yml 第 31 行定义为 SK-CERT{fake_flag}(比赛时是真实的 flag)。这里故意不设 httpOnly,是为了让这道题可以通过 XSS 读取 Cookie 来获取 flag——否则 XSS 就没意义了。

在实际生产环境中,鉴权类的 Cookie(如 session token)应该设置 httpOnly,因为浏览器发送 Cookie 不受 httpOnly 影响(前面说过,浏览器每次请求都会自动附带),设置 httpOnly 只是让 JavaScript 无法读取它,从而防止 XSS 窃取 session。

为了加速网页加载,可以在用户和服务器之间加一个"缓存代理"。

这道题中,nginx 就是那个缓存代理。它收到请求后:

CDN 也有缓存:CDN(Content Delivery Network,内容分发网络)比如 Cloudflare、Akamai,本质上是分布在全球各地的缓存代理。CDN 和这道题中的 nginx 缓存原理完全相同——收到请求时先查缓存,命中就返回缓存的副本。因此,如果 CDN 的缓存被投毒(污染),攻击效果和这道题是一样的:所有访问同一 URL 的用户都会收到被投毒的响应。实际上,CDN 缓存投毒是真实世界中已经被发现和利用过的攻击方式,不是只有 CTF 才会遇到的。

缓存投毒是否成功,主要取决于服务端代码是否提供了可被利用的"不安全输入点"(比如这道题的 x-nonce 头被直接注入到页面中)。CDN 本身只是忠实地执行缓存逻辑——按照 URL 和 Vary 头来存取缓存——它不会主动去判断缓存的内容是否"有毒"。换句话说,如果服务端代码写得安全(不会把用户输入直接反射到响应中),那无论有没有 CDN 或 nginx 缓存,都不会被投毒。

Vary 是 HTTP 协议的标准响应头,定义在 HTTP/1.1 规范(RFC 7231)中。它不是某个框架的私有特性,而是所有 HTTP 缓存(包括浏览器缓存、nginx 缓存、CDN 缓存、Varnish 等)都遵循的通用标准。

缓存需要知道:两个不同的请求,是否应该被视为"同一个"。

比如,同一个 URL,用浏览器访问返回 HTML,用 API 调用返回 JSON。如果缓存不区分,就会把 JSON 返回给浏览器,出大问题。

Vary 头就是告诉缓存:"根据这些请求头的值来区分缓存"。 它出现在 HTTP 响应头中(不是请求头),是服务器告诉缓存代理的指令。

例如:

意思是:Accept-Encoding 值不同的请求,要用不同的缓存副本。

这道题中 Vary 头的来源:Vary 头的值来自两部分拼接:

静态资源(JS、CSS、图片等)的请求为什么不经过 App Router? Next.js 的路由处理分为两类:

静态资源本身确实不太需要 nginx 缓存——它们变化很少,浏览器自己有本地缓存。但在这道题的配置中,location ^~ /_next/ 覆盖了所有 /_next/ 开头的路径(包括 /_next/static/),所以静态资源也会被 nginx 缓存。

静态资源缓存投毒的例子:假设攻击者发送如下请求:

nginx 收到后,先查缓存——如果 MISS,就转发给 Next.js,Next.js 返回正常的 CSS 文件,nginx 把这个响应缓存起来(主键 = GET|aafK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8Y4m8J5L8%4S2&6i4K6y4m8y4o6l9H3x3q4)9J5c8W2)9#2k6X3&6W2P5s2c8Q4x3V1k6K6N6r3q4@1K9h3y4Q4x3V1k6U0M7%4y4Q4x3V1k6K6N6s2W2D9k6g2)9J5k6h3y4K6M7H3`.`.)。之后 Bot 访问同一 URL,命中缓存,拿到正常的 CSS——这里没有安全问题。

但假设存在一个可以影响静态资源响应内容的"不安全输入点"——比如某个请求头的值会被服务器原样写入响应体(这叫"反射",即服务器把用户发送的数据不加修改地"反射"回响应中。这道题中 x-nonce 头的值被反射到了 &lt;body nonce="..."&gt; 中,就是一个反射的例子),攻击者就可以构造一个包含恶意内容的请求,让静态资源返回被投毒的内容,缓存后影响 Bot。在这道题中静态资源不存在这样的反射点——静态资源是构建时生成的固定文件,不经过 middleware,不受 x-nonceContent-Type 影响。所以虽然缓存覆盖了静态资源路径,但实际无法利用。这也说明了缓存投毒的核心前提:必须有服务端代码提供的"不安全输入点"(即用户数据被反射到响应中的地方),光有缓存是不够的。

但因为 nginx 对这些静态资源可能进行 gzip 压缩,所以响应中可能出现 Vary: Accept-Encoding(这是 nginx 自动添加的,不是 Next.js 添加的)。

上面第 1 点提到的 setVaryHeader 函数在每次 App Router 请求处理时都会被调用,给响应加上 Vary 头。

这些 Vary 参数各自的含义(前 4 个都是 Next.js 自定义的请求头,只有 Next.js 框架内部会使用):

具体示例——当用户在 Next.js 页面上点击链接进行客户端导航时,浏览器发出的请求会带上这些头:

为什么客户端导航时会带上这些头? 因为 Next.js 的前端 JavaScript 代码拦截了页面上的内部页面链接点击。正常情况下,点击一个指向 /about 的链接,浏览器会发起一个普通的页面请求(不带这些自定义头),导致整个页面刷新(白屏 → 重新加载所有资源)。但 Next.js 为了实现"不刷新页面"的流畅体验(这正是单页应用 SPA 的核心特征),在页面加载时注入了一段 JavaScript,它做了以下事情:

所以 RSC: 1只在 Next.js 内部页面导航时出现,API 请求、静态资源请求、外部链接跳转都不会带这个头。

什么操作算"客户端导航"? 客户端导航特指:用户在已加载的 Next.js 页面上点击内部链接(<a href="/other-page">),Next.js 前端 JS 拦截这个点击,用 fetch() 获取新页面的数据,然后局部更新页面 DOM(不刷新整个页面)。以下操作不算客户端导航:

简单说:客户端导航 = Next.js 前端 JS 拦截内部 <a> 链接点击,用 fetch 获取数据,局部更新页面。只有这个操作会带 RSC: 1 头。

而普通浏览器直接在地址栏输入 URL 访问时,只会发:

对这道题的攻击来说,最关键的两个参数是 rscAccept-Encoding。普通浏览器用户访问网页时不会发送 RSC,也不会发送 Next-Router-* 这些头——这些头只有 Next.js 的前端框架在内部导航时才会添加。因此,Bot 的浏览器发出的请求中,这些头的值全部是"空"(不存在)。

Next.js 是一个 React 框架。这道题用的是 Next.js 的 App Router 模式。

App Router vs Pages Router:Next.js 有两种路由模式:

RSC(React Server Components) 是 App Router 模式下的一种渲染方式。Next.js 根据请求中是否包含 RSC 这个请求头来决定使用哪种渲染方式:

这里说的 "RSC: 1 请求头",是指在 HTTP 请求中添加一个名为 RSC、值为 1 的请求头:

这个请求头什么时候会出现:你在浏览器地址栏直接输入 URL 或刷新页面时,浏览器只会发送标准的 HTTP 头(HostUser-AgentAccept-Encoding 等),不会发送 RSC 头。RSC: 1 只在 Next.js 的客户端导航时才会出现——当你在 Next.js 页面上点击内部链接时,Next.js 的前端 JavaScript 代码会拦截这个点击,不发普通的页面请求,而是发一个带 RSC: 1 头的 fetch 请求,获取 flight data,然后用它来局部更新页面(不刷新整个页面)。这就是单页应用(SPA)的典型行为。在这道题中,页面上的 "Pick Me An Episode" 按钮是客户端交互,点击后只更新页面内容,不触发新的 HTTP 请求——数据已经在页面加载时获取了。

flight data(也称为 React Flight Protocol)是 React 团队为 RSC 设计的一种数据序列化格式。它的名字来源于 React 的内部项目代号 "Flight"。它不是 JSON,也不是 HTML,而是 React 自定义的一种流式序列化协议。

flight data 的格式看起来像一系列带数字前缀的行:

每一行以数字 ID 开头,后面跟着类似 JSON 的结构,描述一个 React 组件或数据。这个格式是给 Next.js 的前端 JavaScript 代码解析的,不是给浏览器的 HTML 解析器用的。

为什么 flight data 里的 < 不转义:React 渲染器根据"渲染模式"选择不同的输出格式。渲染模式由请求中是否存在 RSC 头来决定——具体是 app-render.js 第 102 行的 headers['rsc'] !== undefined 做的判断(详见 §6.1)。RSC 头存在 → RSC 渲染模式 → 输出 flight data;RSC 头不存在 → HTML 渲染模式 → 输出完整 HTML 页面。text/x-component 不是渲染模式,它是响应的 Content-Type(响应头),是渲染结果的"标签",告诉浏览器"这个响应体的内容是什么格式"——就像"HTML"不是渲染模式,而是 HTML 渲染模式的输出格式。所以:渲染模式由请求中的 RSC 头决定,text/x-component 是 RSC 渲染模式输出结果对应的 CT,text/html 是 HTML 渲染模式输出结果对应的 CT。

当 React 渲染器在 HTML 模式下工作时,它会对属性值中的特殊字符做转义(<&lt;>&gt;)——这是 React 渲染器主动做的,不是浏览器的行为。当 React 渲染器在 RSC 模式下工作时,它输出的是 flight data(组件树的序列化格式,不是 HTML),React 渲染器不做 HTML 转义——字符串值中的 < 保持原样。转义与否完全由 React 渲染器根据渲染模式(即输出格式)决定,和浏览器无关(浏览器收到响应时,React 渲染器的工作已经结束了)。在正常使用中 RSC 模式没有问题(输出的 CT 是 text/x-component,浏览器不会当 HTML 解析),但如果攻击者能让浏览器把 flight data 当 HTML 解析(通过修改 CT 为 text/html),未转义的 &lt;script&gt; 就会被执行。

如果 flight data 被浏览器当 HTML 解析会怎样:正常情况下这不会发生,因为 flight data 的 Content-Type 是 text/x-component(这是 React/Next.js 团队自定义的 MIME 类型,不是 IANA 注册的标准类型,但浏览器遵循通用的 MIME 类型处理规则——不认识的类型不会当 HTML 解析)。但如果攻击者能把 Content-Type 改成 text/html,浏览器就会尝试把 flight data 的内容当 HTML 解析。此时 flight data 中类似 "nonce":"&lt;script&gt;alert(1)&lt;/script&gt;" 的内容,浏览器会在文本中遇到 &lt;script&gt; 标签并执行它——因为浏览器只看 Content-Type 来决定如何解析,不关心内容本来是什么格式。

注意:flight data 中的 "nonce":"..." 是 React 组件树序列化后的格式,和 layout.tsx 源码中的 nonce={nonce} 看起来不同,但表达的是同一个东西——&lt;body&gt; 标签的 nonce 属性。为了理解为什么同一个值在不同模式下表现不同,需要搞清楚 React 渲染和浏览器 HTML 解析器之间的分工:

关键点:转义是 React 服务端代码做的(Step 2),不是浏览器 HTML 解析器做的(Step 3)。React 在 HTML 模式下做了转义,在 flight data 模式下不做。浏览器 HTML 解析器只负责解析它收到的文本——如果文本中有 &lt;script&gt; 且 CT 是 text/html,它就执行。它不关心文本是谁生成的、本来是什么格式。

Web 安全 CTF 中,通常有一个"Bot"(机器人)程序,模拟真实用户的行为。

在这道题里,Bot 的角色是受害者。攻击者(你)的目标是:让 Bot 的浏览器执行你注入的 JavaScript 代码,从而窃取 Bot 的 Cookie。

具体流程是这样的:

Bot 为什么访问 proxy:4000 而不是 46.62.153.171:4000 因为 Bot 运行在 Docker 内部网络中,46.62.153.171 是公网 IP,Docker 内部的容器无法通过公网 IP 访问(就像你不能用自己的公网 IP 访问自己家里的路由器管理页面一样)。Bot 只能通过 Docker 内部的服务名 proxy 来访问 nginx 容器。看 bot/server.js 第 6 行:const CHALLENGE_URL = process.env.CHALLENGE_URL || 'http://proxy:4000',这就是 Bot 用来访问网站的 URL。

050K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0b7$3i4K6u0W2y4U0u0Q4x3X3f1I4y4e0y4Q4x3X3f1I4y4K6q4Q4x3@1p5@1x3o6l9H3i4K6u0r3j5X3!0@1i4K6u0r3 页面上的 Target URL 输入框:这个输入框是 Bot 服务的 Web 界面,攻击者可以在输入框中填写 URL,然后点击提交。Bot 的服务器收到这个 URL 后,会让 Bot 的 Chromium 浏览器访问这个 URL(通过 page.goto(url))。虽然输入框默认值可能是 http://proxy:4000,但攻击者可以改成任意 URL——比如 http://proxy:4000/_next/pwn。这个 URL 中的 proxy 在 Docker 内部网络中可以解析到 nginx 容器,所以 Bot 的浏览器能访问到。但攻击者不能把 URL 改成 d89K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0b7$3i4K6u0W2y4U0u0Q4x3X3f1I4y4e0y4Q4x3X3f1I4y4K6q4Q4x3@1p5@1x3o6l9H3i4K6u0r3i4K6g2X3L8X3g2^5N6q4)9J5c8Y4m8%4L8R3`.`.——因为 Bot 在 Docker 内部无法通过公网 IP 访问。

三个 Docker 容器(定义在 docker-compose.yml 中):

Next.js app 和 Bot 都暴露 3000 端口,不冲突吗? 不冲突,因为它们运行在不同的 Docker 容器中。每个容器有自己独立的网络栈(相当于独立的虚拟机),所以各自可以绑定 3000 端口而互不影响。在 Docker 内部网络中,容器之间通过服务名(app:3000bot:3000)访问,Docker 的 DNS 会自动解析到对应容器的 IP。

关于 Docker 内部网络:Docker Compose 会自动创建一个内部网络,容器之间可以用服务名互相访问。比如 nginx 容器可以用 http://app:3000 访问 Next.js 应用,Bot 容器可以用 http://proxy:4000 访问 nginx。这些服务名(appproxybot)是 Docker 内部的 DNS 名称,只有在 Docker 内部网络中才能解析。

所以:

这两个地址指向的是同一个 nginx 容器,但 Host 头不同——这一点后面会成为关键问题。

注意:只有 /_next/ 开头的路径会被缓存。//about 等普通路径不会缓存。

nginx 的缓存匹配是一个两层查找机制。

第一层:主键(primary key),由 proxy_cache_key 指令定义:

展开后就是 GET|http://proxy:4000/_next/pwn 这样的字符串。nginx 对每个请求算出这个字符串,去缓存里查找。

第二层:二级键(secondary key),由响应中的 Vary 头决定。当 nginx 从 Next.js 拿到响应时,会读取 Vary 头中列出的参数名(如 rsc, Accept-Encoding),然后去查看当前请求中这些参数对应的请求头的值,把这些值记录下来作为二级键。

可以把 Vary 参数理解为"索引"——nginx 用主键找到一组缓存副本,然后用 Vary 索引中的值精确匹配到具体的那个副本:

注意:Vary 是响应头(服务器在响应中告诉缓存的指令),但 Vary 中列出的参数名(如 rscAccept-Encoding)对应的是请求头的名字。nginx 缓存响应时会记录"当初是哪些请求头的值导致了这个响应",后续请求只有这些请求头的值完全匹配才能命中缓存。

完整匹配规则是:

举个例子,如果 nginx 缓存了一个响应,该响应带了 Vary: rsc, Accept-Encoding,并且当初产生这个缓存的请求的 RSC 头为空、Accept-Encodinggzip, deflate,那么:

后续另一个请求要命中这个缓存,必须主键匹配(同一个方法和 URL),并且它的 RSC 头的值和 Accept-Encoding 头的值都和缓存时记录的一样。任何一个不匹配就是 MISS。

这是 nginx 的缓存功能(HTTP 规范定义的行为,所有符合规范的缓存代理都会这样做)。Next.js 只是在响应中设置了 Vary 头,它不知道也不关心缓存代理怎么处理。nginx 作为缓存代理,按照 HTTP 规范读取 Vary 头并据此区分缓存副本。

middleware.ts(中间件——每个请求都会经过):

注意:这个代码和实际文件略有简化(省略了 getContentTypeFromHeader 函数的实现和 config 导出),但逻辑流程完全一致。getContentTypeFromHeader 的作用是验证 Content-Type 头的值是否合法(不为空、不超过 120 字符、不含换行符、符合 MIME 格式),合法就返回 text/html; charset=...,否则返回 null(不覆盖 CT)。

关于 307 重定向:307 是 HTTP 重定向状态码,意思是"你请求的资源临时搬到了另一个 URL,请重新请求那个新 URL"。浏览器的行为是:收到 307 响应后,自动向重定向的目标 URL 发起一个全新的 HTTP 请求。既然是全新的请求,它就会再次经过 middleware 函数——也就是说,重定向后的请求会重新执行上面代码的第 1 步(检查查询参数)、第 2 步(CT 覆盖)和第 3 步。重定向后的 URL 没有查询参数(第 1 步不触发),也不带 Content-Type 头(第 2 步不触发),所以最终走到第 3 步直接放行。重定向响应本身不包含我们的 XSS payload,所以重定向不是攻击向量。

那我们的攻击效果体现在哪里? 我们的攻击不通过重定向触发。我们直接请求 /_next/pwn(不带查询参数,所以第 1 步不触发重定向),同时在请求中带上 Content-Type: text/html(触发第 2 步的 CT 覆盖)和 x-nonce: XSS代码。这个请求不经过重定向,直接走到第 2 步覆盖 CT,然后到达 Next.js 渲染页面。重定向只是 middleware 的一个功能,和我们的攻击路径无关。

关于 CT(Content-Type)覆盖:CT 是 Content-Type 的缩写,是 HTTP 响应头中的一个字段,告诉浏览器响应体的内容是什么格式。比如 text/html 表示 HTML 页面,text/x-component 表示 Next.js 的 flight data。浏览器的行为取决于 CT:如果 CT 是 text/html,浏览器就把响应体当 HTML 解析(会执行 &lt;script&gt; 标签);如果 CT 是 text/x-component,浏览器不会当 HTML 解析。中间件的第 3 步做的事情是:如果请求带了合法的 Content-Type 头,就把响应的 CT 强制改成 text/html——这就是把 flight data 变成"会被浏览器当 HTML 解析"的关键。

app/layout.tsx(页面布局——每个页面都会用到):

关键nonce 的值来自 HTTP 请求头 x-nonce。攻击者可以控制这个值——这意味着攻击者可以往页面中注入任意内容(详见 §3.2 注入点分析)。

让 Bot 的浏览器执行我们控制的 JavaScript,读取 Bot 的 flag Cookie,然后发送给我们。

x-nonce 请求头!

layout.tsx 把 x-nonce 请求头的值放到了 &lt;body nonce="..."&gt; 属性里。

如果我发送 x-nonce: &lt;script&gt;alert(1)&lt;/script&gt;,会怎样?

这取决于响应类型:

HTML 响应(普通请求,不带 RSC 请求头)

HTML 里文本内容中出现的 < 会被转义成 &lt;,所以 XSS 不生效。这是因为 HTML 渲染器知道属性值中可能出现特殊字符,会自动转义用户数据,防止它们被浏览器当作 HTML 标签来解析。

RSC flight data 响应(请求中带了 RSC: 1 请求头的请求)

注意这里的格式:"nonce":"&lt;script&gt;alert(1)&lt;/script&gt;" 是 flight data 的序列化格式(冒号分隔键值对),和 layout.tsx 源码中的 nonce={nonce}(JSX 等号赋值)看起来不同。但它们是同一个值(x-nonce 请求头的值)经过不同渲染管道后的输出:JSX → React 服务端渲染 → HTML 输出或 flight data 输出。

flight data 里 < 不会转义——因为 flight data 的输出格式不是 HTML,React 渲染器不做 HTML 转义。在正常使用中这完全没问题,因为 flight data 的 Content-Type 是 text/x-component,浏览器不会把它当 HTML 解析。

但如果我们同时做了两件事:(1)在请求中带 RSC: ""(空字符串)触发 RSC 渲染(得到未转义的 nonce),(2)利用中间件的 CT 覆盖功能把响应的 Content-Type 改成 text/html,浏览器就会把 flight data 的内容当 HTML 解析。

等等,不是说 RSC 是"局部渲染"吗?浏览器怎么根据响应类型决定渲染方式? 这里需要澄清整个机制:

关键点:正常使用中,flight data 是通过 fetch() 获取的,fetch() 的响应永远交给 JavaScript 代码处理,浏览器的 HTML 解析器完全不参与。不管 CT 是什么,浏览器都不会把 fetch() 的响应当 HTML 渲染。

我们的攻击为什么能生效? 因为 Bot 的浏览器是通过 Puppeteer 的 page.goto(url) 访问 URL 的——这等同于用户在浏览器地址栏输入 URL 然后按回车,浏览器会直接把响应当作页面内容来处理。这和 Next.js 前端 JS 通过 fetch() 发请求完全不同:fetch() 返回的数据只交给 JavaScript 代码,浏览器的 HTML 解析器完全不参与;而地址栏访问时,浏览器根据响应的 Content-Type 决定如何处理响应体:

所以攻击的核心是:让 Bot 的浏览器访问一个 URL(地址栏方式),这个 URL 返回的响应的 CT 被覆盖为 text/html,而响应内容(flight data)中包含未转义的 &lt;script&gt;。浏览器把响应当 HTML 解析,&lt;script&gt; 被执行。中间没有任何 Next.js 前端 JS 参与拦截——因为 Bot 是通过 page.goto(url) 直接打开页面(浏览器地址栏方式),而不是通过点击页面内的链接触发 Next.js 的客户端导航("客户端导航"是指用户在已加载的 Next.js 页面上点击内部链接时,Next.js 前端 JS 拦截这个点击,用 fetch() 获取新页面数据并局部更新 DOM,不刷新整个页面——整个过程在客户端/浏览器中完成,不需要浏览器发起新的页面请求)。

HTML 解析器实际收到的内容——当 CT 被覆盖为 text/html 后,浏览器 HTML 解析器逐字符扫描的原始文本是这样的(有截断,实际更长):

HTML 解析器看到这段文本后的行为:

flight data 中的其他内容(如 1:["$","body",...)虽然不是合法 HTML,但浏览器会忽略不认识的文本,不影响 &lt;script&gt; 的执行。

如果能做到以下三步,就能构成完整攻击:

中间件的 Content-Type 覆盖正好可以完成第 2 步!只要请求带 Content-Type: text/html,响应的 CT 就会被覆盖。

一开始,我们把攻击目标定在 /(首页),发送:

Next.js 返回了 RSC flight data,Content-Type 被覆盖成了 text/html,XSS payload 未转义。看起来很完美。

仔细看 nginx 配置:

/ 走的是 location /,根本没有缓存指令!

所以我们的投毒响应根本没有被缓存,Bot 访问 / 时会直接拿到 Next.js 的新鲜响应(正常的 HTML,没有 XSS)。

必须用 /_next/ 开头的路径。比如 /_next/anything

这种路径 nginx 会缓存。而且 Next.js 会返回 404 页面(因为没有这个路由),但 404 页面仍然经过 App Router 的 layout.tsx,所以 nonce 仍然会出现。

Next.js 在所有 App Router 响应中都加了 Vary 头(代码位置见 §1.5 节)。但需要注意:只有缓存 MISS 时,nginx 才会从 Next.js 的响应中获取 Vary 头。如果缓存 HIT,nginx 直接返回之前缓存的响应(包括之前缓存的 Vary 头),不会再去请求 Next.js。所以 Vary 头是在首次缓存时就被记录下来的。

最终响应中的完整 Vary 值是:

这意味着 nginx 缓存会根据这些请求头的值来创建二级键(详见 §2.3),区分不同的缓存副本。

当我们投毒时(带 RSC: 1):

Bot 的浏览器正常访问网页时不会发送 RSC,所以 Vary 匹配失败,Bot 拿不到我们的投毒响应。

这些尝试花了我们大量时间,每一个都需要实际发送 HTTP 请求去验证。

Next.js 有两个核心模块参与了 RSC 请求的处理,它们的职责不同,执行顺序是 base-server.js 先,app-render.js 后

为什么两个模块各自都判断 RSC? 因为它们的关注点不同:base-server.js 需要知道请求类型来做路由和调度(比如选择哪个处理器),app-render.js 需要知道是否按 RSC 模式渲染输出。这两个判断本应保持一致,但 Next.js 的代码中出现了不一致——base-server 用严格匹配(=== '1'),app-render 用宽松匹配(!== undefined)。这个不一致就是我们的攻击入口。

我们在这两个模块中发现了两个不同的 RSC 检查逻辑

base-server.js(第 179 行)——严格检查:

app-render.js(第 102 行)——宽松检查:

也就是说:如果发送 RSC: ""(空字符串):

如果不按 RSC 模式渲染(即 RSC 头不存在时),app-render.js 会输出完整的 HTML 页面,React 渲染器会对 nonce 值做 HTML 转义(<&lt;),XSS 无法生效。而 RSC 模式下输出的是 flight data(React 组件树的序列化格式),不是 HTML——因为输出格式不是 HTML,React 渲染器不做 HTML 转义。这个设计在正常情况下没问题(flight data 的 CT 是 text/x-component,浏览器不会当 HTML 解析),但如果攻击者能修改 CT 让浏览器把 flight data 当 HTML 解析,未转义的 &lt;script&gt; 就会被执行。

两者互不影响:base-server.js 的判断只影响它选择哪个路由处理器(最终都会调用 app-render.js),而 app-render.js 的判断决定渲染输出格式。由于它们不一致,base-server.js 以为这是普通请求,但 app-render.js 实际按 RSC 渲染了——导致输出 flight data(不转义 nonce),而不是 HTML(会转义 nonce)。

总结:从攻击者请求到最终输出,整个链路是什么? 让我们把所有角色的判断串起来看:

注意:攻击者投毒的 URL 是 /_next/pwn(带 Host: proxy:4000),Bot 访问的 URL 也是 http://proxy:4000/_next/pwn——两个请求的 URL(含 Host)完全相同,缓存主键才匹配,Bot 才能命中投毒的缓存。这就是为什么 §7.1 中 Host 必须对齐——如果 Host 不一样,主键不匹配,Bot 就拿不到投毒的响应。

另外需要澄清:攻击者访问的确实是一个"普通页面 URL"(/_next/pwn),从 base-server.js 的角度看也是一个"普通请求"(它选择了标准路由处理器)。但 app-render.js 因为宽松的 RSC 检查,实际输出了 flight data(不是完整 HTML)。flight data 本身确实不是 HTML 格式——如果 CT 正确(text/x-component),浏览器不会当 HTML 解析,页面会显示空白或乱码。但因为 middleware 把 CT 改成了 text/html,浏览器把这段 flight data 当作 HTML 来解析——虽然格式不是标准 HTML,但 HTML 解析器会尽力从中找出它认识的标签(如 &lt;script&gt;),找到就执行。

nginx 在处理 Vary 二级键时,需要获取请求中各个头的值。它使用内建变量 $http_<header_name> 来获取——$http_rsc 就是请求头 RSC 的值。这是 nginx 的命名规则:$http_ 前缀加上小写的请求头名称。类似地,$http_accept_encoding 就是 Accept-Encoding 头的值,$http_content_type 就是 Content-Type 头的值。你不需要在 nginx.conf 中显式使用这些变量——nginx 在内部处理 Vary 匹配时自动使用它们。

关键在于 nginx 如何处理"请求头不存在"的情况:

nginx 把"缺失的 header"和"值为空的 header"等同对待,都会被当作空字符串 ""

这和 §6.1 说的"不发送 RSC 和发送空串有区别"不矛盾:§6.1 讲的是 Next.js(Node.js 代码)中的判断,在 JavaScript 中 headers['rsc'] === undefined(不发送)和 headers['rsc'] === ''(发送空串)是不同的;而本节讲的是 nginx(C 代码)中的判断,在 nginx 中 $http_rsc 在两种情况下都是空字符串,不做区分。正是这个"Next.js 区分了,nginx 没区分"的差异,才是我们能绕过的根本原因——具体来说:

因此,Bot 不发 RSC 头,nginx 把它当作空字符串;我们投毒时发 RSC: ""(空字符串),nginx 也当作空字符串;两者匹配,Bot 就能命中我们投毒的缓存。这一点对攻击至关重要。

Vary 绕过成功! 这是我们花了最长时间才找到的突破点。

nginx 的缓存主键包含 Host:

其中 $host 是 nginx 从请求的 Host 头中提取的值。

投毒时手动设置 Host: proxy:4000

你可能会问:我们攻击的是 1c1K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0b7$3i4K6u0W2y4U0u0Q4x3X3f1I4y4e0y4Q4x3X3f1I4y4K6q4Q4x3@1p5@1x3o6l9H3i4K6u0r3,为什么把 Host 设成 proxy:4000 还能拿到正确的响应?原因如下:

简单说:我们通过外网 IP 连接 nginx,但让 nginx 以为请求是给 proxy:4000。这样缓存主键就和 Bot 匹配了。

nginx 为什么用 $host 而不是实际连接的 IP? 这不是 nginx "选择不拿"——而是 $host 和实际连接 IP 代表的是完全不同的信息

所以 nginx 在缓存键中使用 $host正确的设计——它按"用户要访问哪个网站"来区分缓存,而不是按"用户通过哪个 IP 连接"。问题在于这道题只有一个网站,Host 头本应是固定的,但因为 Bot 和攻击者用不同的 Host 访问同一个网站,缓存键就不一致了。而攻击者可以伪造 Host 头来匹配 Bot 的 Host——这是因为 HTTP 协议允许客户端自由设置 Host 头,没有机制验证它是否"合法"。

关于浏览器中 Host 头的观察:在实际使用 Chrome DevTools 时,不同类型的请求显示情况不同:

注意:浏览器的 Fetch API 禁止修改 Host 头,所以这一步必须用 Python/curl 等工具,浏览器做不到。

AE(Accept-Encoding,接受编码)是 HTTP 请求头之一,浏览器用它告诉服务器"我支持哪些压缩格式"。服务器收到后,可以选择用其中一种格式压缩响应,减少传输数据量。常见的压缩格式有:

比如现代版 Chrome 发送的 AE 头是:

意思是"我支持 gzip、deflate 和 brotli 三种压缩"。

而 Vary 响应头里包含 Accept-Encoding(这是服务器在响应中告诉缓存的指令),所以 nginx 会根据请求中 AE 头的值创建不同的缓存副本。具体来说,nginx 缓存响应时会记录"这个响应是在请求头 AE=gzip,deflate 时产生的",后续只有 AE 值精确匹配的请求才能命中这个缓存副本。注意是精确匹配:如果缓存时 AE 是 gzip, deflate(两个值),后续请求的 AE 也必须是 gzip, deflate——如果后续请求的 AE 是 gzip(只有 gzip 一个值),或者 gzip, deflate, br(多一个 br),都不会命中。如果投毒时发的 AE 和 Bot 浏览器发的 AE 不一样,缓存就不匹配。

问题是:我们不知道 Bot 的 Chromium 发的 AE 是什么。而且浏览器的 AE 值可能是多种组合(gzip, deflategzip, deflate, brgzipgzip, br 等),必须精确匹配才能命中缓存。

探测方法:不是靠猜——而是利用 nginx 的 X-Proxy-Cache 响应头来反推 Bot 的 AE。nginx 配置中有 add_header X-Proxy-Cache $upstream_cache_status always;(nginx.conf 第 45 行),每个响应都会带一个 X-Proxy-Cache 头,值为 HIT(命中缓存)或 MISS(未命中)。

思路是:先让 Bot 单独访问一个干净路径,创建一个以 Bot 的 AE 为二级键的缓存副本,然后我们用各种 AE 值去读,看哪个命中。具体步骤:

命中 HIT 的那个 AE 值,就是 Bot 浏览器的 Accept-Encoding。

对应的探测脚本:

注意:没有 br(Brotli)。这是因为 Bot 的 Docker 镜像使用的是 Debian 系统包中的 Chromium(不是 Google 官方的 Chrome)。系统包版本的 Chromium 没有编译 Brotli 支持,所以它的 AE 头里不包含 br

找到 Bot 的 AE 后,投毒时就用这个精确的 AE 值(见 §10 完整攻击脚本中的 Step 1)。

通常 XSS 窃取 Cookie 的方式是:在受害者的浏览器中执行 JavaScript,让这个 JavaScript 把 Cookie 发送到攻击者控制的外部服务器。代码类似:

这里的执行流程是:攻击者事先把这段 JavaScript 注入到目标网站的页面中(通过缓存投毒)→ 受害者(Bot)访问这个页面 → 受害者的浏览器执行这段 JavaScript → 受害者的浏览器向 evil.com 发送请求,请求参数中包含 Cookie 值 → 攻击者在自己的服务器上收到这个请求,从中提取 Cookie。

但这需要受害者的浏览器能访问外网(即能访问 evil.com)。在这道题中,Docker 容器没有配置外网访问,Bot 的浏览器只能访问 Docker 内部网络中的服务(proxy:4000app:3000)。所以 fetch('585K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6W2N6X3W2D9i4K6u0W2j5$3!0E0i4K6u0r3i4K6u0W2i4K6u0W2i4K6u0W2i4K6t1%4i4K6t1&6 会失败——请求根本发不出去。

既然所有通信都必须在 Docker 内网完成,我们就用 nginx 缓存本身来传递数据。核心思路是:XSS 代码不把 Cookie 发到外部服务器,而是发到目标网站自身的另一个 /_next/ 路径,让那个响应被缓存,然后攻击者再去读取缓存。

完整的"缓存中缓存"流程:

关于这个流程的几个细节:

在现实世界中,大多数受害者都能访问外网,XSS 通常可以直接把数据发送到攻击者的服务器,不需要"缓存中缓存"这个技巧。

但在以下场景中,"缓存中缓存"就有用了:

在这道题中,Docker 容器完全隔离了外网,所以必须用"缓存中缓存"。

关于 nonce 中的特殊字符:XSS payload 作为 x-nonce 请求头的值传输。HTTP 头的值中不能包含真实的换行符(\r\n),但我们的 XSS 代码是单行的(用分号分隔语句,不用换行),所以没有问题。到达 Next.js 后,x-nonce 的值被原样放入 flight data 的字符串中,不做任何转义或过滤。

黑盒测试(不读源码,只通过发送 HTTP 请求观察响应)可以系统性地发现以下内容:

发现 x-nonce 注入点——不完全靠运气,有系统性技巧:

发现 middleware CT 覆盖:用 curl 或 Python(不是浏览器——浏览器的地址栏访问无法控制请求头)发送两次请求:

浏览器不能用来做这个测试——浏览器的地址栏访问只会发送标准的 HTTP 头(HostUser-Agent 等),无法添加自定义的 Content-Type 头。Chrome DevTools 也不能修改地址栏请求的头——它只能查看和修改 fetch() / XMLHttpRequest 请求的头,不能修改页面导航请求的头。

发现 nginx 缓存范围:对不同路径(//_next/test)各发两次请求,检查第二次的 X-Proxy-Cache 是否为 HIT

发现 Host/AE 不匹配:投毒后 Bot 没触发 XSS,通过 X-Proxy-Cache 头逐步排查

黑盒无法发现的关键突破:空 RSC 头绕过——RSC: "" 触发 RSC 渲染但 Vary 匹配 Bot,这个只有读 node_modules 源码才能找到。

即使没有题目提供的源码,攻击者可以通过黑盒探测获取框架信息,然后下载对应源码分析。以下是针对靶机 27eK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0b7$3i4K6u0W2y4U0u0Q4x3X3f1I4y4e0y4Q4x3X3f1I4y4K6q4Q4x3@1p5@1x3o6l9H3i4K6u0r3 的实际测试结果:

_buildManifest.js 是怎么找到的? 在首页 HTML 的 flight data 中,可以看到 "b":"tgOuYcSyBJAiYC2npQ91q"——这就是 Next.js 的 buildId。Next.js App Router 模式下,_buildManifest.js 的路径固定是 /_next/static/{buildId}/_buildManifest.js(Pages Router 的路径也类似,但 _buildManifest 中包含的页面列表不同)。这个路径格式是 Next.js 的公开约定——知道 buildId 就能拼接出 _buildManifest.js 的 URL。访问这个 URL 如果返回了 JS 内容(而不是 404),就确认了这是 Next.js 应用,并且可以从内容判断是 App Router 还是 Pages Router。

黑盒能确认"这是 Next.js App Router"(X-Powered-By + main-app chunk + _buildManifest 存在),但拿不到精确版本号 15.5.14。不过这已经足够——攻击者可以下载 Next.js 最近几个版本的源码(Next.js 是开源的,源码在 GitHub 和 npm 上公开),对比 base-server.jsapp-render.js 中 RSC 判断逻辑的变化,找到存在不一致的版本范围。这就是"灰盒"分析——黑盒探测技术栈 + 白盒分析开源代码。

攻击者无法直接"看到"Bot 的浏览器在做什么。但可以通过以下方式验证:

用脚本验证(Python 脚本中的 Step 2 和 Step 4):

手动在网站上验证(通过浏览器)148K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0b7$3i4K6u0W2y4U0u0Q4x3X3f1I4y4e0y4Q4x3X3f1I4y4K6q4Q4x3@1p5@1x3o6l9H3i4K6u0r3j5X3!0@1i4K6u0r3 页面有一个 Web 界面,可以直接在浏览器中操作完成整个攻击流程:

Step 1(投毒缓存):必须用 curl/Python 等工具完成,因为浏览器不允许修改 Host 头(浏览器的 Fetch API 禁止修改 Host)。在命令行中执行:

Step 2(让 Bot 访问投毒 URL):打开浏览器,访问 a5eK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0b7$3i4K6u0W2y4U0u0Q4x3X3f1I4y4e0y4Q4x3X3f1I4y4K6q4Q4x3@1p5@1x3o6l9H3i4K6u0r3j5X3!0@1i4K6u0r3,在 Target URL 输入框中填写 http://proxy:4000/_next/pwn,然后点击提交按钮。Bot 的 Chromium 浏览器会访问这个 URL,命中我们投毒的缓存,执行 XSS 代码。等待几秒钟让 XSS 执行完毕并把数据写入 /_next/exfil 的缓存。

Step 3(读取窃取的数据):这一步可以回到命令行用 curl 读取(因为需要设置 Host: proxy:4000):

如果响应中包含 STOLEN:flag=SK-CERT{...},说明 XSS 成功执行了。

为什么 Step 1 和 Step 3 必须用 curl 而不能在浏览器中操作?因为浏览器会自动设置 Host 头为当前页面的域名/IP(如 46.62.153.171:4000),而缓存主键中用的是 Host 头的值。如果 Host 不匹配 proxy:4000,缓存主键就对不上 Bot 的请求,投毒就失败了。浏览器的 Fetch API 和 XMLHttpRequest 都禁止修改 Host 头(这是浏览器的安全限制),所以必须用 curl 或 Python 等可以自由设置 HTTP 头的工具。

这道题的攻击能成功,是因为多个组件各自的小问题组合在一起形成了漏洞链。以下逐个说明每个环节的防御方法:

1. Middleware 不应该让请求头覆盖响应的 Content-Type(根本原因)

这道题的 middleware 允许请求中的 Content-Type 头覆盖响应的 CT,这是最核心的漏洞。没有这个功能,攻击者无法把 text/x-component 改成 text/html,flight data 永远不会被浏览器当 HTML 解析。

你可能会想:即使不覆盖 CT,flight data 里的 &lt;script&gt; 也不转义,Next.js 前端 JS 拿到 flight data 后会不会执行里面的攻击脚本?不会。Next.js 前端 JS 处理 flight data 的方式是解析组件树结构,然后用 DOM API(如 document.createElementelement.textContent 等)更新页面——textContent 设置的文本不会被浏览器当 HTML 解析。所以即使 flight data 中包含 &lt;script&gt;alert(1)&lt;/script&gt;,Next.js 前端 JS 只会把它当作普通文本设置到 DOM 节点上,不会执行。flight data 中不转义 < 的风险,仅存在于"flight data 被浏览器 HTML 解析器直接处理"的场景——而正常使用中 CT 是 text/x-component,浏览器不会启动 HTML 解析器。

防御方法:删除 middleware 中 CT 覆盖的代码,或者限制覆盖的目标值只能是安全的 MIME 类型(排除 text/html):

2. x-nonce 不应该直接反射用户输入(注入点)

layout.tsx 中 x-nonce 请求头的值被直接放入了页面,攻击者可以控制这个值。即使 RSC 模式不转义 <,如果攻击者无法注入 &lt;script&gt; 到页面中,攻击也无法成立。

防御方法:在 layout.tsx 中对 nonce 值做白名单校验(比如只允许字母数字),而不是直接使用:

3. Next.js 的 RSC 检查逻辑不一致(空值绕过)

base-server.js 用 === '1'(严格),app-render.js 用 !== undefined(宽松),导致空字符串 "" 能触发 RSC 渲染但不被标记为 RSC 请求。

这个问题出在 Next.js 框架内部代码中,应用开发者无法直接修改 base-server.jsapp-render.js(这些文件在 node_modules/next/dist/ 下,是框架自带的)。应用开发者的选项是:升级 Next.js 到修复了此问题的版本。但这需要 Next.js 团队先发布修复,而升级节奏确实不好把握——生产环境升级框架版本需要经过充分测试,不能随时升级。因此,这条防御措施更适合作为框架层面的修复,应用开发者应该关注 Next.js 的安全公告,在合适的时机升级。在升级之前,可以通过其他防御措施(如第 1、2、4 条)来弥补。

4. nginx 缓存键不包含完整的主机信息(缓存投毒的前提)

nginx 的 proxy_cache_key 使用 $host(来自请求的 Host 头),攻击者可以伪造 Host 头来匹配 Bot 的缓存主键。

防御方法:在缓存键中使用服务器自身的地址而不是客户端发送的 Host:

如果缓存键只用 $server_addr,不同域名会不会碰撞? 假设一个 nginx 同时为 a.comb.com 服务,两者都连到同一个 IP。如果缓存键用 $server_addr,那 a.com/_next/xb.com/_next/x 的缓存主键完全相同——请求 a.com 的用户可能拿到 b.com 的缓存内容,这就是碰撞。所以上面这个防御方法只适用于只服务一个域名的 nginx(如这道题)。如果 nginx 需要为多个域名服务,更安全的做法是保留 $host 在缓存键中,但同时通过白名单限制允许的 Host 值:

5. 适当设置 Cookie 的 SameSite 属性

虽然这道题的攻击不依赖跨站 Cookie(攻击者和 Bot 访问的是同一个站点 proxy:4000),但作为通用安全实践,鉴权类 Cookie 应该设置 SameSite=StrictSameSite=Lax,减少 CSRF 风险。

如果这道题的 Cookie 设置为 SameSite=Strict 会影响正常业务吗? 不会。Strict 禁止的是跨站请求带 Cookie——即从其他域名发来的请求不带 Cookie。但这道题中,Bot 访问 http://proxy:4000/_next/pwn 时,请求是直接在浏览器地址栏发起的(page.goto()),不是从其他域名跳转过来的——这属于同站请求,Strict 模式下 Cookie 仍然会被发送。所以 Strict 不会影响 Bot 的正常行为。但 Strict 会影响真实场景中的一些用户体验——比如用户从搜索引擎点击链接到你的网站时,Strict 模式下不带 Cookie,用户需要重新登录。这也是为什么大多数网站选择 Lax 而不是 Strict。

6. 防御总结:纵深防御

以上任何一项防御措施单独生效,都能阻断攻击链:

最好的做法是同时实施所有防御措施——这就是"纵深防御"(Defense in Depth)的思想:不依赖单一防线,而是让每一层都独立阻止攻击。

这道题的解法不是通过枚举或猜测找到的,而是通过逐行阅读源码发现的。整个分析过程中,以下源码阅读起到了决定性作用:

应用代码(题目提供的文件):

基础设施配置

框架源码node_modules 里的压缩代码,最难读的部分):

最关键的突破来自对比 base-server.js 和 app-render.js 对同一个 RSC 头的判断逻辑——一个用 === '1'(严格),一个用 !== undefined(宽松)。这个不一致意味着发送 RSC: ""(空字符串)时,base-server.js 认为不是 RSC 请求("" !== "1"),但 app-render.js 认为是 RSC 模式("" !== undefined)。这个发现不是猜测出来的,是逐行读 node_modules/next/dist/ 里的压缩代码找到的。

这说明了一个重要的方法论:解决复杂的安全问题,往往需要深入阅读中间件和基础软件的源码,而不只是看应用层的业务代码。本题的漏洞不在业务逻辑中,而在 Next.js 框架内部两个模块对同一请求头的判断不一致。

这道题是一次典型的 Web Cache Poisoning 攻击。理解它为什么能成功,关键在于搞清楚缓存的作用

如果没有缓存会怎样?

假设 nginx 没有配置缓存(或者 /_next/ 路径没有缓存指令),那么每次请求都会直接到达 Next.js 服务器,服务器返回一个新鲜的响应。这时:

每个用户收到的响应都是独立的,攻击者注入的 XSS 代码只会出现在攻击者自己的响应中。这叫做"Self-XSS"——只能攻击自己,毫无意义。

有了缓存之后呢?

缓存把"只能影响自己的攻击"变成了"能影响其他用户的攻击"。 攻击者投毒一次,后续所有访问同一 URL 的用户(只要缓存未过期且键匹配)都会收到被投毒的响应。这就是 Web Cache Poisoning 的威力。


[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

最后于 6天前 被不歪编辑 ,原因:
收藏
免费 4
支持
分享
最新回复 (2)
雪    币: 155
活跃值: (4651)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
谢谢分享
6天前
0
雪    币: 2234
活跃值: (7124)
能力值: ( LV7,RANK:100 )
在线值:
发帖
回帖
粉丝
3
欲买桂花同载酒 终不似 少年游
3天前
0
游客
登录 | 注册 方可回帖
返回