NextJS 彻底完了…… 13 个新增安全漏洞

BBetter Stack
컴퓨터/소프트웨어경제 뉴스AI/미래기술

Transcript

00:00:00又发生了。这是我今年做的关于 Server Component CVE 的第三个视频了,
00:00:05我甚至觉得还没涵盖所有的漏洞。这次涉及 React 和 Next.js 的 13 个 CVE,
00:00:11没错,整整 13 个,其中 6 个是高危漏洞,包括拒绝服务、中间件
00:00:15绕过、跨站脚本攻击等等。也许 Server Components 就是个错误。
00:00:20这是 Next.js 的安全发布公告,在这个月里他们修复了几个“稀松平常”的问题,
00:00:28显然在底部提到的解决方法是升级所有 Next.js 版本,
00:00:32这些是受影响的版本。值得注意的是,TanStack 并没有受到影响,
00:00:36虽然我可能带点偏见,但这也是我选择使用 TanStack 的另一个理由。
00:00:41我不会逐一讲解所有漏洞,否则我们可能要在这待很久,
00:00:44而且我也没能为所有漏洞都找到可行的 Exploit,但我确实想向大家展示
00:00:48每个类别中的一个漏洞。我们先从中间件和代理绕过开始,
00:00:52我成功重现的是这个 Pages Router 的漏洞。如果我们使用了 i18n,
00:00:56Pages Router 中就会存在中间件代理绕过风险,它的 CVE 严重程度评分高达 7.5。
00:01:02这是一个受漏洞影响的应用示例。我在 Next.js 配置中开启了 i18n,
00:01:06并设置了英语和法语两种语言。我还准备了一个中间件文件,
00:01:12在 Next.js 的后续版本中它被重命名为 proxy,以试图避免我即将展示的那种混淆。
00:01:16基本上,中间件应该允许我们修改传入的请求,
00:01:19无论是通过重定向、重写,还是添加某些 Header 等等。
00:01:24在我的案例中,我用它来检查访问 /secret 页面的用户是否拥有 session cookie,
00:01:28即是否已登录。如果没有登录,就应该将他们重定向到登录页面,
00:01:32理论上只有已登录用户才能看到我的秘密页面。在底部,
00:01:37我们还有一个 matcher,用于确保应用到该秘密页面的中间件
00:01:41也能匹配到它的多语言变体。因为现在有两种语言,这个 URL 实际上有三个版本。
00:01:45在秘密页面本身,我还设置了一些 server-side props,
00:01:50这些应该是在渲染时从服务器获取的。再次强调,由于设置了中间件,
00:01:54理论上只有已登录用户才能看到这些值,我随后会在页面上使用这些值,
00:01:58比如邮箱、Flag 标志以及标题。同样,只有授权用户才能查看。
00:02:03让我们来测试一下。首先,我尝试访问那个秘密页面,
00:02:07可以看到因为我没登录,被重定向到了登录页。这说明中间件起作用了。
00:02:11但如果我们变身顶级黑客呢?我们可以先通过“检查元素”——
00:02:16简直是疯狂的黑客操作。然后在下方的 next data 脚本中,
00:02:20寻找我们的 build ID。在我这儿是这个,直接把它复制下来。
00:02:24然后我们需要输入一个 URL,格式是 _next/data/,加上刚才复制的 build ID,
00:02:28然后是我们要访问的页面路径加上 .json。完成之后,
00:02:32你可以看到我们拿到了那些本该受中间件保护的 props 属性。
00:02:37包括 Flag、邮箱、标题,还有一段话提醒你订阅,
00:02:40以获取更多开发者新闻、教程和技巧。大家快去订阅吧。
00:02:44希望刚才疯狂的黑客演示让你印象深刻。但为什么会发生这种事?
00:02:48这解释起来和操作起来一样简单。因为我们有秘密页面和 server-side props,
00:02:52在 Next.js 中,server-side props 是通过这种格式的 URL 提供的,
00:02:56而我们的中间件本应保护这条路由。问题在于由于我们使用了 i18n,
00:03:00所以还有英语和法语版本的 URL。你可以看到 server-side props
00:03:05也同样拥有英语和法语版本。而 Next.js 的一段错误代码意味着,
00:03:09如果启用了 i18n,它就不会保护基础路径,基础路径没有被包含在 matcher 中,
00:03:13而另外两个却被包含在内了。所以英语和法语版本受保护,但 /secret 的基础路径却没有。
00:03:18如果我把 URL 改成英语版本,确实会被重定向到登录页。
00:03:22这真是个非常简单的漏洞。但说实话,这些中间件绕过听起来很严重,
00:03:26实则不然。虽然不太妙,但你本来就不该只靠中间件来保护重要内容,
00:03:31Next.js 甚至不推荐你这样做。如果你把敏感数据放在 server-side props 中,
00:03:35却没有任何服务器端认证逻辑,那我觉得你本身也要承担一部分责任。
00:03:40让我们来看看破坏性更强的一个:拒绝服务攻击(DoS)。
00:03:44这类漏洞有三个,但我只能稳定重现这一个,即 Server Components 拒绝服务。
00:03:48这不仅影响 Next.js,也影响任何使用 react-server-dom 包的框架,
00:03:53基本上就是 Next.js 及其追随者,比如 Vinxi 或其他的分支版本。
00:03:56TanStack Start 没用这个,所以不受影响。该漏洞的严重程度评分也是 7.5。
00:04:01要触发这个漏洞,只需要一个非常简单的 Next.js 应用,
00:04:05并且在其中使用了一个 Server Action,哪怕是非常简单的一个也行。
00:04:10这是运行中的站点,可以看到刷新页面时几乎没有加载,几乎是秒开。
00:04:14用数据来说话,发送这个请求,大约在 0.02 秒内就有响应。
00:04:18但如果我现在运行 Exploit 脚本并再次发送请求,这次花了 6 秒钟。
00:04:22这还仅仅是我运行了一次 Exploit 的结果,想象一下如果是链式攻击会怎样。
00:04:25要理解这个 Exploit,我们需要了解一点 React Flight 协议,
00:04:29这是 React 用来在服务器和客户端之间序列化组件树和数据的格式。
00:04:34大家可能见过这种格式。在这个页面上,我们有一个包含 Server Action 的表单。
00:04:39打开 Network 标签点击发送,你会发现 Payload 载荷看起来像是一堆乱码,
00:04:42响应也是如此。通过复制这个 Payload,我可以解释它发送到服务器后发生了什么。
00:04:46第一步是反序列化,从 chunk 0 开始,也就是这个 $k1。
00:04:50这个 $k1 实际上只是一个指针,表示这里将会有一些表单数据,
00:04:54以一个下划线开头。所以它会遍历我们随 Payload 发送的所有其他 key,
00:04:58并寻找以一个下划线开头的字符串,从而确定这是 key 及其对应的 value。
00:05:02完成后,它就能识别出 name、email、message,并将数据转换为底部的对象。
00:05:05非常简单。但问题在于这种方式在规模扩大后会怎样。
00:05:10假设我增加了另一个指针 $k2,用来寻找以两个下划线开头的 key。
00:05:16问题是,在处理 $k1 时,它会遍历所有 6 个 key 寻找以一个下划线开头的;
00:05:20处理 $k2 时,它又会做完全相同的事去寻找以两个下划线开头的。
00:05:24这样总共就要遍历 12 次。这还没那么糟,让我们把它推向极端。
00:05:28如果在 Payload 中加入 199,999 个随机 key,并将 chunk 0 的数组改为
00:05:32$k1、$k2 一直持续到 $k1000,这意味着它必须在总共 20 万个 key 中,
00:05:36不断寻找一个下划线、两个下划线……一直到一千个下划线开头的字符串。
00:05:41算下来,总共要进行 2 亿次字符串比较。正如你所料,
00:05:44这将导致线程被阻塞好几秒。我认为这是修复该问题的提交记录。
00:05:48你可以看到这个提交中有很多改动,说实话有点复杂,但我会尽力解释。
00:05:52基本上,现在他们对 key 使用了一种基于游标(cursor-based)的系统。
00:05:56他们将 Payload 中的 20 万个 key 加载到一个列表中,然后从寻找 $k1 开始,
00:06:03用一个不能回退的游标向下遍历列表。遇到 $j1,发现不匹配一个下划线,
00:06:07游标继续向下到 $j2,还是不匹配,于是它一直向下遍历到 $j199,999。
00:06:12两个下划线、三个下划线,一直查找到一千个下划线,而我们的有效载荷里有 20 万个
00:06:17键。这意味着总共要进行 2 亿次字符串比较。
00:06:21你可以想象,这将导致线程阻塞好几秒钟。
00:06:25我认为这个提交(commit)修复了我们遇到的问题。你可以看到,
00:06:28这个提交涉及的内容非常多,坦白说有点复杂,但我会尽力
00:06:33向大家解释清楚。基本上,他们现在对键使用了基于游标(cursor-based)的系统,
00:06:36所以他们将载荷中发送的所有这 20 万个键加载到一个列表中,然后
00:06:41我们从这里的 0 开始,寻找 $k1 引用,并开始向下遍历
00:06:45那个列表,游标不能后退。它向下移动到 $j1,发现不
00:06:50匹配我们需要的一个下划线,于是移到 $j2,发现也不匹配
00:06:54一个下划线,所以它继续遍历这个键列表,直到 $j199,999。一旦到达
00:07:01这里,它意识到没有匹配 $k1 的项,于是转向 $k2。现在 $k2 开始寻找
00:07:06两个下划线,但问题是,由于这是一个基于游标的系统,且
00:07:09游标不能后退,它会立即到达列表末尾,所以 $k2 结果
00:07:14也是未定义的,这种情况一直持续到 $k1000。所以这次我们仅仅
00:07:18遍历了 20 万个键。本质上,这次修复将操作次数从
00:07:23$k*n($k 是引用的数量,$n 是键的数量)降低到了
00:07:27$n+k。所以在我们的例子中,操作次数从 2 亿次减少到了 20.1 万次,
00:07:33我觉得 Prime 的这条推文很好地总结了我们的处境。
00:07:37自创带有序列化的协议是非常困难的,所以看到这么多问题并不奇怪。
00:07:41在我看来,他们需要让 Claude Mythos 对 React 和 Next.js 的代码库进行一次审查。
00:07:46接下来是这些漏洞中严重程度最高的一个:Next.js 应用中的服务器端请求伪造。
00:07:50你可以看到,这个漏洞的评分为 8.6(总分 10 分),但值得注意的是,
00:07:54它并不影响在 Vercel 托管的部署,只影响自托管或其他服务商。
00:07:59这个漏洞利用起来也非常简单。首先,我们需要启动 Next.js 服务器,
00:08:04再说一次,它可以是一个原生的 Next.js 应用。你不需要进行任何修改。
00:08:09接下来,我们还需要一个内部服务器。假设该服务器只能被
00:08:14Next.js 服务器访问,而不能被外界访问。假设它位于我们的云部署中。
00:08:18然后我们只需发送一个非常简单的 curl 请求,将其发送到我们的 Next.js 应用。
00:08:23它位于 3002 端口,我们将请求目标设置为想要访问的本地主机 URL。
00:08:26如果我现在按下回车键,你可以看到返回的内容。它实际上是一个
00:08:31之后这个解析出的 URL 会经过一个检查,看它是否包含协议,
00:08:36因为我们使用了 HTTP,结果为 true,于是它就帮我们执行了代理请求。
00:08:40针对这个漏洞,修复方案在 resolve routes 函数中加入了两个新的哨兵,
00:08:45现在会额外获取一个 finished 布尔值和一个状态码。
00:08:49这个 resolve routes 函数现在会根据重写规则、中间件等
00:08:53处理 URL,判断这是否为一个合法的代理请求。如果不是,finished 将设为 false。
00:08:57在下方的检查中,只有当 finished 为 true 时才会进行下一步操作,
00:09:02否则就不会执行代理请求。而在我们之前的 curl 请求案例中,
00:09:06finished 恰恰会被设为 false。退一步讲,就算 finished 为 true,
00:09:10我们还有状态码检查。当 resolve routes 运行且请求为 HTTP 时,
00:09:15会返回 200 或 404 等状态码。只要存在状态码,就说明它不是有效的
00:09:19WebSocket 代理请求,从而被忽略并停止执行。在视频中我努力深挖了这些问题,
00:09:23如果你还没走开,请留言一些随机内容,比如 bar 之类的让我知道,
00:09:28同时也请订阅,支持一下。还有最后两个类别的漏洞,
00:09:32不过会讲解得快一点。首先是缓存投毒,我重现了这个中危漏洞,
00:09:37也就是 React Server Component 缓存投毒,评分 5.4。为了重现它,
00:09:40我准备了一个 Next.js 应用,还有一个模拟真实部署环境的 CDN。
00:09:45这意味着如果我第一次访问 CDN URL,点击浏览产品并返回,
00:09:49第一次应该是 cache miss,之后就是 cache hit 了。从日志中可以看到,
00:09:55第一次带查询参数访问 /products 是 miss,之后变成了 hit。接着,
00:09:58我清除缓存来模拟缓存过期的情况,然后发送这个 curl 请求。
00:10:02这个 resolve routes 函数实际的作用是
00:10:06我认为其中的逻辑是:当我们发送带有 react-server-components: 1 header 的
00:10:11curl 请求时,该 URL 会返回 Server Component 数据而非 HTML。
00:10:15之后在缓存时,Next 会通过一个函数判断这是否为 Server Component 数据。
00:10:20如果是,就存储为对应的格式;如果不是,就存储为 HTML。理论上,
00:10:24当用户通过点击按钮获取页面的 HTML 版本时,绝不该返回 Server Component 数据。
00:10:27问题就在于,在我们的案例中,由于 curl 请求末尾带有查询字符串,
00:10:32它并没有触发“是否为 Server Component”的校验。因为校验只是简单地看
00:10:36如果是 HTTP 请求,它会返回 200、404 之类的状态码,所以如果有状态码
00:10:41并以此存入缓存。下次用户点击时,即便要的是 HTML,也会拿到缓存里的乱码。
00:10:45修复方法异常简单:在进行 .rsc 结尾检查时,直接忽略查询字符串即可。
00:10:49最后是最后一类 CVE:跨站脚本攻击(XSS)。我重现了这个评分 6.1 的漏洞,
00:10:53它发生在处理不信任输入的 before-interactive 脚本中。简单来说,
00:10:58在 Next.js 中,如果 script 标签使用了 before-interactive 策略,
00:11:01且该标签上有其他属性需要接收不信任的内容(例如来自 searchParams),
00:11:04我就能进行 XSS 攻击。我可以引诱用户点击这种嵌入了大量内容的链接,
00:11:09用户点击后可能会看到类似这样的界面,让他们误以为需要重新登录。
00:11:14当他们点击登录时,那个登录表单完全是通过查询参数注入的假表单。
00:11:18这本质上允许攻击者在受害者的机器上执行 JavaScript。更现实的例子是
00:11:23窃取 Chrome 的 session cookie,从而登录你拥有权限的所有账户。
00:11:27这是一个典型的转义不当案例。在这个简化版中可以看得更清楚:
00:11:31我所做的只是先闭合一个 script 标签,再开启一个新的标签并运行任意代码。
00:11:35按下回车,你就能看到那个显示 “pwned” 的弹窗。其运作机制是,
00:11:39我们需要一个带有 before-interactive 策略的 Next.js script 标签,
00:11:44且该标签的一个属性从查询参数等不信任来源获取数据。
00:11:47在 Next.js 中,这个 script 标签会被转化为 dangerouslySetInnerHTML 形式,
00:11:52听这名字就知道很危险。它还使用了 JSON.stringify,关键点在于
00:11:58JSON.stringify 不会转义像闭合括号这样的 HTML 字符。所以,
00:12:02Next.js 设置好 source,而剩余的 props(包含从查询参数获取的 tracking ID)
00:12:07被放入 JSON.stringify。最终在页面上渲染出的内容大概是这样的:
00:12:11这里有 tracking ID,后面跟着我们通过查询参数插入的字符串。
00:12:16如果展开看,你会发现原来的 script 标签被提前闭合了,
00:12:20从而让我们能够运行自定义脚本。最后我还在末尾加了一点内容,
00:12:24用来“吃掉”原本会被渲染为纯文本的 analytics 字符串,
00:12:29这样页面上就不会留下明显的入侵痕迹。这就是本周 React 和 Next.js 的
00:12:3313 个 CVE 及其部分原理。老实说,我对此心情复杂。几年前,
00:12:37我每个项目都用 Next.js,觉得它代表了未来。但现在感觉困难重重,
00:12:41像是他们急于推出新功能,然后再回过头来修补。现在我更倾向于
00:12:46TanStack,内容型网站则用 Astro,它们对我来说更简单纯粹。
00:12:50我也很喜欢 Cloudflare 最近的动作,正在慢慢把项目迁移过去。
00:12:55但我仍有约 20 个项目在 Vercel 上,得去更新一下。你觉得 Server Components
00:12:59未来真的有用吗,还是已经宣告失败?在评论区告诉我吧。
00:13:03需要某些不可信内容的情况,例如这个内容是来自 search params
00:13:08我就可以进行跨站脚本攻击,方法是诱导用户点击像这样的链接
00:13:12例如,我在该 search param 中嵌入了大量内容,如果有人
00:13:16点击了那个链接,他们就会看到这个界面,所以他们可能会认为
00:13:19必须再次登录该网站,而当他们点击登录时,你可以看到
00:13:22这完全是通过该 search param 注入的虚假登录表单,它基本上允许攻击者
00:13:26在受害者的机器上执行 JavaScript,所以我认为一个更现实的例子
00:13:31是窃取 Chrome 的会话 cookie 以登录你拥有访问权限的所有内容
00:13:34这其实是一个非常简单的转义错误示例,通过这个
00:13:39简化版本可以看得更清楚,我在 search param 中所做的只是先关闭一个
00:13:43script 标签,然后开启一个新的 script 标签,放入任何我想运行的内容
00:13:47当我们按回车键时,你可以看到我收到了那个显示 “pwned” 的弹窗
00:13:51正如我在应用中展示的,触发方式是需要一个策略为 before interactive 的 Next.js script 标签
00:13:55并且该 script 标签上还需要某个属性,其数据取自某些
00:13:59不可信来源,在我的案例中,数据是来自查询参数,在 Next.js 中
00:14:04这个 script 标签会被转换成类似这样的东西,其中包含 dangerouslySetInnerHTML
00:14:08名字里就写着它是危险的,而且我们还用到了 JSON.stringify
00:14:12关于 JSON.stringify 需要了解的重要一点是,它不会转义 HTML 字符
00:14:17比如闭合括号,所以这里实际发生的是,我们获取了在 Next.js 中设置的所有 script 标签
00:14:21它正在寻找将在这里设置的 source,然后
00:14:24其余的 props 将包含 data tracking ID 以及我们
00:14:29在查询参数中设置的值,这些都会被放入这个 JSON.stringify 中
00:14:33在页面上实际渲染出来的效果就像这样,我们有 data tracking ID
00:14:37也就是其余的 props,但接着还有我们通过查询
00:14:41参数插入的字符串,如果我们展开看它在页面上的实际样式,就像
00:14:45这样:我们有 script 标签,有 data tracking ID,但在那之后
00:14:49实际上有一个闭合 script 标签,所以它结束了 Next.js 试图执行的 script 标签
00:14:53在那之后我们可以运行任何想在该页面运行的脚本,接着最后还有
00:14:58这额外的一小段,因为如果没有这个,analytics 实际上会作为
00:15:01文本渲染在页面上,因为 HTML 会将其视为文本,所以这基本上
00:15:05把它吞掉了,从而没有明显的迹象表明该页面正在发生不好的事情
00:15:09本周 Next.js 和 React 爆出的 13 个 CVE 及其原理讲解完毕。
00:15:14老实说我不知该作何感想。我对此感到厌倦,要知道两三年前,
00:15:18我几乎所有的项目都是用 Next.js 做的,认为它就是未来的方向。
00:15:22但现在感觉全是阻碍。感觉他们急功近利,之后不得不反复修补。
00:15:26就我个人而言,现在我已经彻底转向 TanStack 阵营,
00:15:31如果是内容型网站则会选择 Astro。它们对我来说更简单直接。
00:15:35说实话,我也非常喜欢 Cloudflare 最近的动作,
00:15:39正在慢慢把我的项目搬过去。但我还有大约 20 个项目留在 Vercel,
00:15:43必须去升级一下。你认为 Server Components 真的会有用吗,还是失败了?
00:15:48在下方评论区告诉我吧。别忘了点赞订阅,我们下期见。
00:15:51再见。

Key Takeaway

Next.js 与 React 近期爆出的 13 个 CVE 漏洞暴露了 Server Components 序列化协议的复杂性风险,开发者需立即升级至最新版本以防御包括 8.6 分高危 SSRF 在内的多项核心安全威胁。

Highlights

  • Next.js 和 React 近期修复了 13 个 CVE 漏洞,其中包括 6 个评分高达 7.5 至 8.6 的高危漏洞。

  • Server Components 的拒绝服务漏洞(DoS)通过构造 20 万个键的恶意载荷,可将服务器响应时间从 0.02 秒延长至 6 秒。

  • Next.js 的 SSRF 漏洞(CVE 评分 8.6)允许攻击者通过构造 curl 请求绕过访问控制,直接访问服务器内部私有 URL。

  • Pages Router 在启用 i18n 时存在中间件绕过风险,攻击者通过访问 _next/data 路径下的 JSON 文件可直接获取受保护的敏感数据。

  • Next.js 的 script 标签在 before-interactive 策略下由于 JSON.stringify 转义不当,导致攻击者能通过查询参数注入恶意脚本进行 XSS 攻击。

  • React Server Component 的缓存投毒漏洞通过向带有查询参数的 URL 发送特定 Header,使 CDN 错误地将乱码数据缓存为 HTML 页面。

Timeline

Pages Router 中的中间件与代理绕过

  • i18n 配置错误导致基础路径在中间件匹配中失去保护。
  • 攻击者利用 _next/data 路径结构配合 build ID 可直接读取服务器端属性(server-side props)。
  • 中间件不应作为保护敏感数据的唯一防御手段。

在开启 i18n 的 Pages Router 应用中,Next.js 的 matcher 逻辑存在缺陷,导致基础路径可能未被包含在安全过滤范围内。通过检查元素获取 build ID 后,任何人都能构造特定的 .json 请求来绕过登录重定向。这种漏洞直接暴露了原本仅限授权用户访问的邮箱、Flag 等敏感数据信息。

Server Components 拒绝服务攻击原理

  • React Flight 协议的反序列化过程在处理大量指针时存在性能瓶颈。
  • 恶意构造的 20 万个 key 载荷会导致服务器进行 2 亿次字符串比较。
  • 修复方案将操作复杂度从引用量与键数量的乘积降低为线性相加。

该漏洞利用了 React 处理 Server Action 载荷时的递归查找机制。当攻击者发送包含大量以不同数量下划线开头的 key 时,服务器线程会因高频率的匹配操作而被长时间阻塞。通过引入基于游标(cursor-based)且不可回退的查找系统,Next.js 成功将无效比较次数减少了近一千倍,从而恢复了响应速度。

高危服务器端请求伪造(SSRF)漏洞

  • 自托管环境下的 Next.js 应用极易受到 8.6 评分的 SSRF 攻击。
  • resolve routes 函数在旧版本中会盲目执行未经校验的代理请求。
  • 新版通过引入 finished 哨兵和状态码校验来拦截非法代理。

攻击者通过简单的 curl 命令并将目标指向 localhost,即可诱导 Next.js 服务器去请求其内网环境中的私有服务。此漏洞不影响 Vercel 托管环境,但对私有云部署具有破坏性。目前的修复逻辑是在路由解析阶段强制检查请求是否符合重写或中间件规则,并识别 WebSocket 代理之外的异常 HTTP 状态码。

RSC 缓存投毒与跨站脚本攻击

  • 查询字符串的校验缺失导致 RSC 数据被错误地作为 HTML 缓存到 CDN。
  • before-interactive 脚本策略下的 JSON.stringify 未转义 HTML 闭合标签。
  • 注入虚假登录表单可窃取 Chrome 浏览器会话 cookie。

缓存投毒源于 Next.js 在判断 .rsc 后缀时忽略了查询参数,导致恶意请求生成的序列化数据覆盖了正常的页面 HTML。而在 XSS 漏洞中,dangerouslySetInnerHTML 配合未转义的 JSON 字符串化过程,使攻击者能提前闭合原有的 script 标签并嵌入任意 JS 代码。这种手段可以构造极其逼真的虚假界面,在用户无感的情况下完成权限窃取。

Community Posts

View all posts