Next.jsCVE-2026-94545分析:next/og里没转义的引号,和一个没讲完的RCE

admin 2026-10-02 05:18:56 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文分析Next.jsCVE-2026-94545漏洞,根因是上游satori引擎的buildXMLString函数属性值未转义导致SVG注入,可能升级为RCE。影响Next.js16.2.0至16.3.6版本,修复版本为16.3.6和satori0.33.5。建议立即升级,并检查用户输入是否流入SVG内容、属性或样式。 综合评分: 90 文章分类: 漏洞分析,WEB安全,代码审计,应急响应


Next.js CVE-2026-94545 分析:next/og 里没转义的引号,和一个没讲完的 RCE

原创

Kratos Kratos

Kratos Sec

2026年9月30日 22:06 河南

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

Next.js CVE-2026-94545 分析:next/og 里没转义的引号,和一个没讲完的 RCE

9 月 22 日 Vercel 发了一次带外(out-of-band)安全更新,Next.js 16.3.6 修复 CVE-2026-94545:next/og 的 ImageResponse 在 Node.js 运行时下可被未授权用户利用,CVSS 4.0 给到 9.5。有意思的是,这个洞的根因不在 Next.js 自己的代码里,而在上游的 JSX 转 SVG 引擎 satori——一处从项目早期就存在的字符串裸拼接。这篇从代码层面把注入点、补丁、复现和利用边界讲清楚,重点回答两个被预警稿模糊掉的问题:SVG 注入到底怎么升级成 RCE,以及你的业务到底中不中招。

0x0 漏洞概述

| 项目 | 内容 | | — | — | | CVE 编号 | CVE-2026-94545 / GHSA-vcvr-r3jv-pc5j | | 上游 advisory | GHSA-wx4j-mvgx-mqwp(satori) | | 漏洞类型 | SVG 注入 → 远程代码执行 | | CVSS 评分 | 9.5(CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H) | | 影响组件 | next/og 的 Node.js 版 ImageResponse(底层 satori) | | 影响版本 | Next.js >=16.2.0 <16.3.6;satori >=0.0.27 <0.33.5 | | 修复版本 | Next.js 16.3.6(15.5.26 仅加固,15.x 不受 RCE 影响);satori 0.33.5 | | 报告者 | RaghavMaheshwari124、rafabd1(Vercel HackerOne) | | 在野利用 | 截至 9 月 23 日无公开利用报告 |

时间线:9 月 22 日 Next.js 与 satori 的 advisory 同步公开,16.3.6、15.5.26、satori 0.33.5 同日发布;9 月 23 日社区出现 Docker 复现实验室,注入原语端到端跑通。修复 commit 是 satori 仓库的 26a52af,标题叫”Harden SVG serialization”,8 个文件,+262/-86。

0x1 next/og 是怎么画图的

很多业务用 ImageResponse 动态生成分享卡片:/og?title=xxx 返回一张带标题的 PNG,用于社交平台预览。这条渲染链在 Node.js 运行时下是这样的:

路由 handler(Node.js runtime)
&nbsp; &nbsp;└─ ImageResponse(next/og,只是个壳)
&nbsp; &nbsp; &nbsp; &nbsp; └─ @vercel/og index.node.js(预编译进 next 包)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;├─ satori:JSX + CSS ──字符串拼接──▶ SVG 字符串
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;└─ Resvg:SVG ──光栅化──▶ PNG

next/og 本体几乎不含逻辑,image-response.ts 里按 NEXT_RUNTIME 选择加载 index.node.js 还是 index.edge.js,真正的渲染在预编译的 @vercel/og 里。这就是为什么这次修复不体现在 Next.js 的某一行代码上,而是整体升级打包进去的 satori 及相关依赖。

satori 负责 HTML/CSS 布局(排版交给 yoga),最后把布局结果序列化成 SVG 字符串。注意这个”序列化”:不是走 XML 库,而是手工模板字符串拼接。漏洞就长在这里。

0x2 代码层面:四个注入点

核心缺口在 buildXMLString。 这个函数是 satori 生成所有 SVG 元素的出口,修复前的实现:

// satori/src/utils.ts(修复前)
export&nbsp;function&nbsp;buildXMLString(type, attrs, children)&nbsp;{
&nbsp;&nbsp;let&nbsp;attrString =&nbsp;''
&nbsp;&nbsp;for&nbsp;(const&nbsp;[k, _v]&nbsp;of&nbsp;Object.entries(attrs)) {
&nbsp; &nbsp;&nbsp;if&nbsp;(typeof&nbsp;_v !==&nbsp;'undefined') {
&nbsp; &nbsp; &nbsp; attrString +=&nbsp;`&nbsp;${k}="${_v}"`
&nbsp; &nbsp; }
&nbsp; }
&nbsp; ...
}

属性值 _v 原样塞进双引号,没有任何转义。只要值里带一个 ",就能闭合当前属性,后面的内容全部变成攻击者定义的新属性乃至新元素。

正常文本反而逃不出去,style 能。 文本内容走的是另一条路,builder/text.ts 里对 content 老老实实做了 escapeHTML:

// satori/src/builder/text.ts(修复前,节选)
buildXMLString(
&nbsp;&nbsp;'text',
&nbsp; {
&nbsp; &nbsp; ...shapeProps,
&nbsp; &nbsp;&nbsp;fill: style.color, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// ← 不转义
&nbsp; &nbsp;&nbsp;'font-family': style.fontFamily, &nbsp;&nbsp;// ← 不转义
&nbsp; &nbsp;&nbsp;'letter-spacing': style.letterSpacing,
&nbsp; &nbsp;&nbsp;stroke: style.WebkitTextStrokeColor,
&nbsp; &nbsp;&nbsp;style: style.filter ?&nbsp;`filter:${style.filter}`&nbsp;:&nbsp;undefined,
&nbsp; },
&nbsp; escapeHTML(content) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// ← 文本内容有转义
)

所以 <div>{userText}</div> 这种写法注入不了,但 style 派生出来的属性(color→fill、fontFamily、letterSpacing、WebkitTextStrokeColor、filter 等)全部走裸拼接。color: 'red"/><path data-pwned="true"/>' 一类值可以 直接打穿——补丁新增的测试用例里就是这个 payload。

内联 <svg> 路径是重灾区。 satori 支持在 JSX 里直接写 <svg> 子元素,这条路径在 preprocess.ts 的 translateSVGNodeToSVGString,三处裸拼:

// satori/src/handler/preprocess.ts(修复前,节选)
if&nbsp;(typeof&nbsp;node !==&nbsp;'object')&nbsp;return&nbsp;String(node) &nbsp; &nbsp; &nbsp;// 文本子节点,原样返回
...
return&nbsp;`&nbsp;${ATTRIBUTE_MAPPING[k] || k}="${_v}"`&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 属性名和值都原样
...
` style="${Object.entries(style).map(([k, _v]) =>&nbsp;`${midline(k)}:${_v}`).join(';')}"`

官方 advisory 给的受影响示例正是打在这里——query 参数直接进 <title>:

import&nbsp;{ ImageResponse }&nbsp;from&nbsp;'next/og'

export&nbsp;function&nbsp;GET(request)&nbsp;{
&nbsp;&nbsp;const&nbsp;value =&nbsp;new&nbsp;URL(request.url).searchParams.get('value') ??&nbsp;''
&nbsp;&nbsp;return&nbsp;new&nbsp;ImageResponse(
&nbsp; &nbsp;&nbsp;<svg&nbsp;width="1200"&nbsp;height="630">
&nbsp; &nbsp; &nbsp;&nbsp;<title>{value}</title>
&nbsp; &nbsp;&nbsp;</svg>
&nbsp; )
}

还有两个助攻缺口。 一是内联 SVG 转成 data URL 时用正则做百分号编码,字符集 [\r\n%#()<>?[\\\]^“‘]漏了&,嵌套 SVG 里的 HTML 实体会穿过外层解析复活成标记;二是expand.ts对下划线前缀的"内部样式属性"(如_inheritedClipPathId`)直接透传,调用方可伪造内部字段混进序列化。

补丁是收口式修复。26a52af 把散落在各 builder 里的手工拼接全部收敛到 buildXMLString,属性值经 escapeXMLAttribute(内部用 escape-html),文本经 escapeXMLText;名字没法转义,就照 React 的做法用 XML NameChar 正则白名单校验,非法直接抛错;_ 前缀属性从透传改成 throw;& 补进编码字符集。修的是一整类问题,不是被报告的那一个实例。

0x3 复现

社区的复现实验室(Hassham1/CVE-2026-94545-nextjs-og-poc)用官方 advisory 的路由模式起两个容器:16.3.5 和 16.3.6,payload 走 query:

GET /og?value=</title><rect width="500"&nbsp;height="150"&nbsp;fill="#FF0000"/>

payload 要点:先闭合 <title>,再注入一个 500×150 的纯红矩形。注意整个 payload 必须是配平的 XML,标签不闭合会把内层文档的解析搞挂。

验证方式很讲究。返回的是 PNG,肉眼看”图上多了个红块”不够客观,实验室的扫描脚本把 PNG 的 IDAT 解开后逐像素统计,结果:

| 目标 | 结果 | | — | — | | next 16.3.5 | 75,000 个 #FF0000 像素,注入成立 | | next 16.3.6 | payload 被转义成文本,0 个红像素 | | satori 0.25.0 | payload 原样出现在序列化 SVG 里 |

三个实践坑值得记下:光栅化器里没有可加载字体,注入 <text> 渲染出来不可见,验证标记要用 <rect> 这类纯形状;响应大小启发式不靠谱,修复版把 payload 当可见文本渲染出来,响应同样变大;想写 nuclei 模板的话,matcher 解不了 PNG,官方特征没法直接用。

0x4 冷静一下:RCE 到底证没证实

把公开信息排开看,这件事要分两层说。

注入原语是板上钉钉的。 未授权、单请求、攻击者完整控制 SVG 文档内容,16.3.5 上 75,000 个红像素就是证据。satori 的转义缺陷从 0.0.27 就在,PoC 仓库在 0.25.0 上也复现了裸注入。

RCE 完整链条没有公开。 Vercel 的原文是”不当转义……由于其他上游依赖中的漏洞,可能导致远程代码执行”——升级到 RCE 的那一环发生在消费 SVG 的下游组件(Node 实现里的 resvg 光栅化链路)里,但具体是哪个依赖、什么缺陷,advisory 没有点名,细节未公开。ikzero 和 Checkmarx 的分析文都明确说自己验证到注入为止,RCE 没走通也没证伪。修复方式侧面印证了这一点:16.3.6 是把 satori 和这些依赖一并升级,而不是改掉某处逻辑。

两个评分的落差也在这里。Next.js 给 9.5,靠的是 SC:H/SI:H/SA:H(对后续系统的高影响)加上 AT:P(有利用前提);satori 自己只评 5.3 Moderate,因为对库来说这就是个转义缺陷,后果取决于下游怎么消费 SVG。AT:P 换成人话:业务必须把用户输入拼进 SVG 的内容、属性或样式。分享卡片接口把标题、用户名、评论片段塞进 OG 图,是再常见不过的写法,这个前提在实际业务里并不苛刻。

Edge 不受影响是官方口径:next/og 在 Edge 运行时加载的是另一套 index.edge.js 构建,官方明确 Edge 实现不在 RCE 影响范围内。另外直接使用 satori 的项目(不止 Next.js)都该对号入座,影响要看你的 SVG 落到谁手里。

即便 RCE 一时不成立,注入本身已经意味着攻击者可以在服务器上驱动一个 SVG 解析器去解析任意构造的文档,社区也点名了外部资源引用带来 SSRF/文件读取的可能性,同样属于未验证但值得警惕的方向。

0x5 自查:判断自己有没有暴露

第一步看版本。npm ls next,落在 >=16.2.0 <16.3.6 就在 RCE 影响范围内。15.x 不受 RCE 影响,但建议跟上 15.5.26 的加固。项目直接依赖 satori 的,查是否 <0.33.5。

第二步比版本更重要:查数据流。 全局搜这几个关键词:

  • next/og、ImageResponse、@vercel/og
  • 文件约定路由 opengraph-image、twitter-image
  • 直接使用 satori( 的代码

命中之后看渲染入参有没有用户可控值流入 JSX 文本、内联 <svg> 的属性、style 对象。重点盯三类写法:searchParams.get(...) 进 JSX、请求体里的字段进 JSX、动态路由参数进 JSX。export const runtime = 'edge' 的路由不在 RCE 范围内,但注入风险同样存在。

第三步翻日志。 对 OG 类路由回溯请求参数,找这些特征:</title>、"/><、<rect、<path、<image、<style 出现在 query 或请求体里。这类路由平时流量不会带 SVG 片段,特征很干净。

0x6 修复与应急

升级优先。npm install [email protected](16.3 线),15.x 升 15.5.26,直接用 satori 的升 0.33.5。补丁在预编译产物里,重启生效,没有迁移成本。

暂时不能升级的,用官方 workaround: 不把攻击者可控的值传进 SVG 的内容、属性和样式。落到操作上:OG 路由的动态参数走白名单;必须渲染用户文本的,进 JSX 前先做 HTML 转义;style 对象里的颜色、字体等字段不要拼接用户输入。

怀疑被打过的,升级之外做两件事。 主机侧排查异常进程、计划任务、对外连接;应用侧核对 OG 路由的历史访问日志,把带注入特征的请求 IP 和时间线拉出来。截至目前没有公开在野利用的报告,但 PoC 和检测脚本已经流通一周,自建 Next.js 服务在公网上的不该赌这个时间差。


参考

  • https://github.com/vercel/next.js/security/advisories/GHSA-vcvr-r3jv-pc5j
  • https://github.com/vercel/satori/security/advisories/GHSA-wx4j-mvgx-mqwp

免责声明:本文仅用于安全研究与学习,未经授权不得用于任何非法渗透测试活动。


免责声明:

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

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

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

本文转载自:Kratos Sec Kratos Kratos《Next.js CVE-2026-94545 分析:next/og 里没转义的引号,和一个没讲完的 RCE》

评论:0   参与:  0