
CVE-2026-94545(Next.js next/og ImageResponse SVG 인젝션)를 재현하는 격리된 Docker 랩 및 비파괴 Python 스캐너로, 취약 버전과 패치 버전 대조 및 픽셀 수준 PNG 증거를 제공합니다.
next/og ImageResponse SVG 인젝션 (GHSA-vcvr-r3jv-pc5j)CVE-2026-94545, 즉 Next.js ImageResponse SVG 직렬화 인젝션(업스트림: Satori GHSA-wx4j-mvgx-mqwp)을 격리된 루프백 전용 랩에서 재현하고 검증하기 위한 보안 연구 자료입니다.
>=16.2.0 <16.3.6 (Node.js ImageResponse만 해당)26a52aff); 15.5.26은 관련 하드닝을 포함하며, 15.x는 RCE 이슈의 영향을 받지 않음본인이 소유하거나 명시적으로 테스트 권한을 받은 시스템에서만 사용하십시오. 스캐너는 비파괴적입니다: 이미지를 렌더링하고 픽셀을 카운트할 뿐입니다. 대상에 아무것도 쓰거나 저장하거나 실행하지 않습니다.
상태: 제공된 랩에서 입증됨.
권고문의 정확한 라우트 패턴(공격자 값이 <svg><title>{value}</title></svg>로 들어감)에 대해:
next 16.3.5 (취약): 주입된 500x150 빨간 rect가 PNG에 렌더링됨
(응답에서 75,000개의 #FF0000 픽셀 측정)
next 16.3.6 (패치됨): 값이 이스케이프됨, 빨간 픽셀 0개
satori 0.25.0 (취약): 직렬화된 SVG 문자열에 페이로드가 원시 상태로 존재
satori 0.33.5 (수정됨): 페이로드가 </title>로 이스케이프됨
이는 인젝션 프리미티브를 엔드투엔드(직렬화기 -> 임베드된 SVG -> 래스터화된 출력)로 입증합니다. 원격 코드 실행을 입증하지는 않습니다: 권고문은 RCE를 다운스트림 SVG 파서에 연결하며, 그 취약점 세부 사항은 작성 시점에 공개되지 않았습니다. 파일 읽기, SSRF, 코드 실행은 주장하지 않습니다.
Satori는 <svg> 노드를 translateSVGNodeToSVGString()(src/handler/preprocess.ts)으로 직렬화하는데, 이 함수는 이스케이프 없이 출력 XML에 보간했습니다:
String(node)를 통해 반환됨 - 이스케이프되지 않음 (현재는 escapeXMLText)."가 빠져나감.style 값이 원시 상태로 보간됨.&를 인코딩하지 않아, 내부 XML 엔티티가 외부 SVG 파싱을 통과해 살아남음.expand.ts가 내부 스타일 속성(접두사 _)을 직렬화된 스타일에 전달함 - 현재는 거부됨.수정(satori@26a52aff)은 모든 것을 하드닝된 buildXMLString()을 통해 라우팅하여 값을 이스케이프하고 XML 이름을 검증합니다. 이 랩은 이를 모두 동작적으로 검증합니다; 정확한 요청과 픽셀 증거는 poc/에 있습니다.
요구 사항: Docker(Compose v2 포함), 호스트의 Python 3.10+ (스캐너용), 첫 실행 시 npm에 대한 네트워크 접근.
./lab verify
취약한 앱([email protected]의 벤더링된 next/og)을 빌드하고, 마커 rect가 HTTP 응답에 렌더링되어야 함을 요구한 다음, 패치된 앱([email protected])을 빌드하고 그 부재를 요구합니다.
취약한 컨트롤:
[*] #FF0000 pixels: 75000 (threshold 10000)
[!!!] INJECTION CONFIRMED: the attacker value escaped <title> and
became a rendered SVG element in the generated image.
RESULT: VULNERABLE CONTROL CONFIRMED
패치된 컨트롤:
[*] #FF0000 pixels: 0 (threshold 10000)
[=] no injection observed
RESULT: PATCHED CONTROL CONFIRMED
./lab start vulnerable # 16.3.5 app on http://127.0.0.1:9481/og
./lab test # assert injection renders
./lab start patched # 16.3.6 app
./lab test # assert absence
./lab status
./lab reset
스캐너를 직접 실행 (기본 루프백; 권한을 받은 외부 대상은 명시적 오버라이드 필요):
python3 poc/scan.py http://127.0.0.1:9481/og
python3 poc/scan.py https://host.example/og --param value \
--allow-authorized-non-loopback [--insecure]
페이로드는 500x150 순수 빨간색 <rect>를 담은 균형 잡힌 XML입니다. 취약한 엔드포인트에서는 값이 <title>을 빠져나가고 rect가 임베드된 SVG 문서의 실제 요소가 됩니다; 스캐너는 반환된 PNG에서 정확한 #FF0000 픽셀을 카운트합니다(예상 75,000개; 임계값 10,000). 패치된 엔드포인트에서는 값이 이스케이프된 텍스트이고 빨간색이 렌더링되지 않습니다. 프로브는 대상에 아무것도 변경하지 않습니다: GET 요청 하나, 이미지 렌더링 하나.
캐리어는 의도적으로 텍스트가 아닌 <rect>입니다: 임베드된 SVG 내부의 텍스트는 렌더러에 로드 가능한 폰트가 없어 주입되더라도 보이지 않기 때문입니다.
이 취약점의 증명은 렌더링된 래스터의 픽셀 내용이며, nuclei 매처는 PNG를 디코딩할 수 없습니다. 크기 차이 템플릿을 만들어 보았으나 거부했습니다: 패치된 앱에서도 이스케이프된 페이로드가 (리터럴 비활성 텍스트로) 여전히 렌더링되므로, 정상 대 페이로드 크기가 패치된 시스템에서도 달라지며, 취약한 델타와 패치된 델타 사이의 구분은 앱별 압축 노이즈입니다. 패치된 시스템에서 발동하는 매처를 배포하는 것은 아무것도 배포하지 않는 것보다 나쁩니다. poc/scan.py를 사용하십시오; 이는 응답 이미지를 정확하게 평가합니다.
| 버전 | 평가 |
|---|---|
| Next.js 16.3.5 (벤더링된 @vercel/og) | HTTP를 통한 엔드투엔드 인젝션 재현됨 |
| Next.js 16.3.6 (벤더링된 @vercel/og) | 패치된 네거티브 컨트롤 재현됨 |
| satori 0.25.0 (npm) | 직렬화된 SVG에 원시 인젝션 (소스 수준) |
| satori 0.33.5 (npm, 2026년 9월 22일) | 이스케이프됨 (소스 수준) |
| Next.js 16.2.0-16.3.4 | 권고문에 따라 영향 받음; 개별 테스트되지 않음 |
| Next.js 15.x | 벤더 블로그에 따라 RCE 이슈의 영향을 받지 않음 |
.
├── .gitignore
├── LICENSE
├── README.md
├── SECURITY.md
├── docker-compose.yml
├── lab
├── app/
│ └── server.mjs # advisory route pattern over loopback HTTP
└── poc/
└── scan.py # non-destructive scanner (Python stdlib: urllib + zlib)
커밋되지 않음: assets/ (npm으로 패킹된 next tarball + 추출된 번들), node_modules/, .lab-state.
| 주장 | 상태 |
|---|---|
| satori < 수정에서의 SVG 직렬화 인젝션 | 입증됨 (소스 + 동작) |
| next/og 16.3.5를 통한 주입된 마크업의 엔드투엔드 렌더링 | 입증됨 |
| 패치된 네거티브 컨트롤 (16.3.6 / satori 0.33.5) | 입증됨 |
| 속성/스타일 캐리어 (동일 근본 원인) | 수정 diff에서 근본 원인 확인됨; 텍스트 캐리어 입증됨 |
| 주입된 리소스 참조를 통한 SSRF / 파일 읽기 | 테스트되지 않음 |
| 원격 코드 실행 | 주장하지 않음 (공개되지 않은 다운스트림 파서 세부 사항에 의존) |
본인이 소유하거나 명시적으로 테스트 권한을 받은 시스템에서만 이 저장소를 실행하십시오. 랩 드라이버는 vulnerable/patched 이외의 변형을 거부하고, 스캐너는 --allow-authorized-non-loopback이 제공되지 않는 한 비루프백 대상을 거부하며, Compose 포트는 127.0.0.1에 바인딩됩니다.
공개 처리에 대해서는 SECURITY.md를 참조하십시오. 이 저장소에는 프로덕션 대상, 고객 데이터, 액세스 토큰 또는 실제 시스템의 증거가 포함되지 않습니다.
MIT - LICENSE 참조.