Next.js OG 이미지 RCE — 같은 취약점이 9.5점과 5.3점인 이유
Next.js OG 이미지 RCE, 같은 CVE가 9.5점과 5.3점. 가른 것은 SVG를 받는 쪽이었습니다.
목차
2026년 9월 22일, Next.js의 OG 이미지 생성 API인 next/og의 ImageResponse에서 원격 코드 실행(RCE) 취약점이 공개됐습니다. 원격 코드 실행은 공격자가 네트워크 너머에서 서버에 자기 코드를 돌릴 수 있다는 뜻입니다. 번호는 CVE-2026-94545 하나인데, Next.js 권고는 이것을 Critical 9.5점으로, 원인이 된 SVG 생성 라이브러리 Satori의 권고는 Moderate 5.3점으로 매겼습니다. Satori 권고는 그 차이를 한 문장으로 설명합니다 — "영향은 생성된 SVG가 어떻게 처리되느냐에 달렸다." 이 글은 두 권고와 공식 블로그, 그리고 npm에 배포된 Next.js 16.3.5·16.3.6 패키지를 직접 받아 비교한 결과로 무엇이 영향을 받고 무엇이 받지 않는지를 가릅니다.
1. 핵심 요약
ImageResponse에 공격자가 고를 수 있는 값이 SVG로 들어가는 경우입니다. 배포된 코드를 보면 영향을 받는 Node.js 경로와 받지 않는 Edge 경로는 SVG를 PNG로 변환하는 단계에서 함수 인자 하나, 곧 sharp를 넘기는지 여부만 다릅니다. 그리고 공식 문서가 라우트 설정의 runtime = 'edge'를 폐기 예정으로 표시하고 있어, "Edge로 옮긴다"는 우회로는 권할 수 없습니다.붙은 번호는 본문 끝 참고자료의 번호입니다. 모든 수치는 2026-09-23에 원문에서 확인했습니다.
2. 무엇이 공개됐나
OG 이미지는 링크를 메신저나 SNS에 공유할 때 미리보기로 뜨는 1200×630 크기의 그림입니다. Next.js에서는 app/.../opengraph-image.tsx 파일에서 JSX로 그림을 그리고 next/og의 ImageResponse로 PNG를 만들어 돌려주는 방식이 공식 문서의 기본 예제입니다. ImageResponse는 내부에서 JSX를 먼저 SVG(벡터 그림을 XML로 적은 형식)로 바꾸고, 그 SVG를 다시 PNG로 변환합니다. JSX를 SVG로 바꾸는 라이브러리가 Satori입니다. [5]
9월 22일 하루에 네 개의 패키지가 차례로 배포됐습니다. 아래 시각은 npm 레지스트리에 기록된 배포 시각(UTC)입니다. [7]
| 배포 시각 (UTC) | 패키지 | 무엇인가 |
|---|---|---|
| 2026-09-22 16:01 | satori 0.33.5 | Satori 수정판. 0.0.27부터 0.33.4까지가 영향 범위 [2] |
| 2026-09-22 16:19 | next 16.3.6 | Next.js 16 수정판 (Active LTS) |
| 2026-09-22 16:19 | @vercel/og 1.0.3 | Next.js 밖에서 쓰는 OG 패키지. satori 0.33.5에 의존 |
| 2026-09-22 17:03 | next 15.5.26 | Next.js 15 판 (Maintenance LTS). 보안 강화만 포함 |
Next.js 공식 블로그는 15.5.26을 이렇게 설명합니다 — "관련 보안 강화를 포함하지만, Next.js 15.x는 원격 코드 실행 문제의 영향을 받지 않는다." 설치 명령의 주석도 npm install next@15.5.26 # for 15.5 (hardening only)입니다. 15.x 사용자에게 올릴 이유가 없다는 뜻은 아니지만, 15.x가 이 취약점으로 뚫린다는 뜻도 아닙니다. [3]
발견자는 두 권고 모두 같은 두 사람(RaghavMaheshwari124, rafabd1)으로 적혀 있습니다. 두 권고 어디에도 실제 공격이 관측됐다거나 공개 공격 코드가 있다는 언급은 없습니다. [1] [2]
3. 같은 CVE, 두 점수
CVSS는 취약점의 심각도를 0~10점으로 매기는 공통 기준이고, 두 권고 모두 최신판인 4.0을 썼습니다. 점수는 벡터라고 부르는 항목 조합으로 계산되며, 두 권고의 벡터를 나란히 놓으면 어느 항목이 달라졌는지 보입니다.
| 항목 | 뜻 | Next.js 권고 | Satori 권고 |
|---|---|---|---|
| AV · AC | 공격 경로 · 복잡도 | 네트워크 · 낮음 | 네트워크 · 낮음 |
| AT | 공격 전제 조건 | 있음 (P) | 있음 (P) |
| PR | 필요한 권한 | 없음 | 없음 |
| UI | 사용자 상호작용 | 없음 | 수동적 상호작용 필요 (P) |
| VC · VI · VA | 취약한 시스템 자체의 기밀 · 무결 · 가용 | High · High · High | None · None · None |
| SC · SI · SA | 뒤따르는 시스템의 기밀 · 무결 · 가용 | High · High · High | High · High · None |
| 점수 | 9.5 Critical | 5.3 Moderate | |
달라진 곳은 VC·VI·VA, 곧 취약한 시스템 자체가 입는 피해입니다. Satori 관점에서 이 라이브러리의 역할은 SVG 문자열을 만드는 데까지입니다. 이스케이프 처리가 빠져 공격자가 넣은 값이 SVG 마크업으로 해석되더라도, Satori 자체가 코드를 실행하지는 않습니다. 그래서 피해는 "뒤따르는 시스템"(SC·SI) 쪽에만 매겨졌고, 권고는 "영향은 생성된 SVG가 어떻게 처리되느냐에 달렸다"고 적었습니다. 이스케이프는 특수 문자를 문자 그대로 읽히도록 바꿔 적는 처리입니다. [2]
Next.js 관점에서는 그 SVG를 받아 처리하는 구성 요소까지 제품 범위에 들어갑니다. 권고는 "Satori가 만든 SVG의 부적절한 이스케이프가 다른 의존 라이브러리의 취약점 때문에 원격 코드 실행으로 이어질 수 있다"고 쓰고, 서버 자체의 피해를 모두 High로 매겼습니다. [3]
4. SVG는 어디로 가는가
그렇다면 Next.js에서 SVG를 받는 "다른 의존 라이브러리"는 무엇일까요. 두 권고와 공식 블로그 어디에도 그 이름은 없습니다. 그래서 npm에 배포된 next 16.3.5와 16.3.6의 패키지를 받아, 안에 들어 있는 next/og 코드(dist/compiled/@vercel/og/)를 직접 비교했습니다. [7]
Edge용 번들과 Node.js용 번들은 SVG를 PNG로 변환하는 같은 함수를 공유합니다. 핵심만 옮기면 이렇습니다.
async function render(satori, resvg, sharp, opts, defaultFonts, element) {
const svg = await satori(element, { ... }) // JSX → SVG 문자열
if (sharp) {
pngBuffer = await sharp(new TextEncoder().encode(svg))
.resize(options.width).png().toBuffer() // SVG → sharp
} else {
const resvgJS = new resvg.Resvg(svg, { ... }) // SVG → resvg.wasm
...
}
}
// Edge 번들의 호출
await render(satori, resvg_wasm_exports, void 0, options, fonts, element)
// Node.js 번들의 호출
await render(satori, resvg_wasm_exports, await getSharp(), options, fonts, element)
Edge는 세 번째 인자에 void 0(곧 undefined)을 넘기므로 언제나 resvg.wasm으로 변환합니다. Node.js 번들은 getSharp()의 결과를 넘기는데, 이 함수는 import("sharp")를 시도해 sharp가 설치돼 있으면 sharp를, 없으면 undefined를 돌려줍니다. 곧 Node.js 런타임에서 sharp가 설치돼 있으면 Satori가 만든 SVG가 sharp로 들어갑니다. resvg.wasm은 SVG를 PNG로 변환하는 렌더러를 WebAssembly로 빌드한 것이고, sharp는 네이티브 라이브러리 libvips를 쓰는 이미지 처리 패키지입니다.
16.3.5와 16.3.6에서 이 render() 함수는 한 글자도 바뀌지 않았습니다. 바뀐 것은 Satori 쪽입니다. 16.3.6에 새로 들어간 코드는 두 가지입니다.
- XML 요소·속성 이름 검증 — 이름이 XML 규칙에 맞지 않으면
Invalid XML ${e} name오류를 던집니다. 16.3.5에는 이 문구가 0회였습니다 - 속성 값 이스케이프 — 속성 값을
escape-html로 감쌉니다
이 수정이 들어간 커밋의 제목은 "[active-lts] Harden next/og SVG serialization"(9월 22일)입니다. 즉 수정 방식은 SVG를 받는 쪽을 바꾸는 것이 아니라, 공격자가 넣은 값이 애초에 SVG 마크업이 되지 못하게 막는 것이었습니다.
5. 영향을 받는 조건
CVSS 벡터의 AT:P는 공격에 전제 조건이 있다는 뜻입니다. Next.js 권고와 블로그가 밝힌 조건을 모으면 아래 넷이 모두 맞아야 합니다. [1] [3]
| 조건 | 무엇을 보나 | 근거 |
|---|---|---|
| 버전 | next가 16.2.0 이상 16.3.6 미만 | Next.js 권고 |
| API | next/og의 ImageResponse를 쓴다 (opengraph-image.tsx, twitter-image.tsx, 또는 직접 new ImageResponse(...)) | Next.js 권고 |
| 런타임 | Node.js 런타임에서 돈다 (Edge는 영향 없음) | Next.js 권고·블로그 |
| 입력 | 공격자가 고를 수 있는 값이 SVG의 내용·속성·스타일로 들어간다 | Next.js 권고 |
반대로 영향이 없는 경우를 권고는 두 가지로 명시합니다 — Edge ImageResponse를 쓰는 앱, 그리고 공격자가 고를 수 있는 값을 SVG에 넣지 않는 앱입니다. 공식 블로그는 여기에 Next.js 15.x를 더했습니다.
기본값이 영향을 받는 쪽입니다
라우트 세그먼트 설정의 runtime 기본값은 'nodejs'이고, 'edge'는 공식 문서에 (deprecated)로 표시돼 있습니다. export const runtime = 'edge'를 따로 적지 않은 OG 라우트는 Node.js 런타임으로 돕니다. 영향이 없는 쪽의 설정값이 폐기 예정이므로, 런타임을 Edge로 옮기는 것은 이번 건의 우회책으로 권할 수 없습니다. 올리는 것이 답입니다. [6]
"공격자가 고를 수 있는 값"은 생각보다 넓습니다
공식 문서는 생성 이미지가 기본적으로 빌드 시점에 정적으로 만들어져 캐시되고, 요청 시점 API(쿼리 문자열·헤더·쿠키 등)나 캐시되지 않은 데이터를 쓸 때만 요청마다 렌더된다고 적습니다. 빌드 때 신뢰할 수 있는 값으로 한 번 만들고 끝나는 OG 이미지에는 공격자 입력이 닿을 자리가 없습니다. [5]
다만 경로에 [slug] 같은 동적 세그먼트가 있으면 이야기가 달라집니다. dynamicParams의 기본값은 true여서, 빌드 때 만들지 않은 값도 요청이 오면 그 자리에서 렌더합니다. OG 라우트는 공식 문서상 "페이지·레이아웃과 같은 라우트 세그먼트 설정을 쓰는 특수한 라우트 핸들러"입니다. 그러니 /posts/아무-문자열/opengraph-image를 요청받아 그 문자열을 그대로 JSX에 그리는 코드라면, 처음 요청받은 순간 그 값으로 렌더하므로 공격자가 고른 문자열이 SVG로 들어갑니다. 한 번 만든 이미지는 이후 캐시되지만, 위험은 렌더하는 그 시점에 공격자가 고른 값이 JSX에 실제로 들어가는지에 달렸습니다. 공식 문서의 대표 예제도 params로 받은 값으로 글을 조회해 제목을 JSX에 넣습니다. 조회해 온 제목이 사용자가 쓴 글이면 그것도 공격자가 고를 수 있는 값입니다. [5] [6]
6. Next.js 밖에서 쓰는 Satori
Satori는 Next.js 전용이 아닙니다. 다른 프레임워크의 OG 이미지 생성기나 직접 만든 이미지 파이프라인에서도 씁니다. 이 경우 Next.js를 올려도 아무것도 바뀌지 않습니다.
| 쓰는 방식 | 올릴 버전 | 확인한 것 |
|---|---|---|
satori를 직접 설치 | 0.33.5 이상 | Satori 권고의 수정 버전 [2] |
@vercel/og를 직접 설치 | 1.0.3 이상 | npm상 1.0.3이 satori 0.33.5에 의존. 1.0.2는 satori 0.33.3 [7] |
next/og (Next.js 안) | next 16.3.6 | Next.js 권고 [1] |
보안 스캐너가 헷갈릴 수 있는 지점
배포된 패키지를 비교하다 한 가지를 더 확인했습니다. next 16.3.6 안에 들어 있는 OG 코드는 여전히 @vercel/og 0.11.1과 satori@0.25.0으로 표기돼 있습니다. 16.3.5와 같은 표기입니다. 공식 블로그는 "Satori를 포함한 의존 라이브러리를 업그레이드한다"고 썼지만, 번들 안의 표기는 0.33.5로 올라간 것이 아니라 0.25.0에 수정을 덧댄 형태였습니다. [3] [7]
실무에서는 두 가지로 이어질 수 있습니다. 첫째는 가능한 오탐 시나리오입니다 — 패키지 안의 버전 표기로 취약 여부를 판정하는 도구라면 16.3.6을 "satori 0.25.0, 0.33.5 미만"으로 읽고 여전히 취약하다고 표시할 수 있습니다. 실제 스캐너가 그렇게 표시하는 것을 저희가 관찰한 것은 아닙니다. 그런 경고를 보면 표기만으로 판정하지 말고 next 버전을 보세요. 둘째, 반대 방향으로도 틀릴 수 있습니다 — Next.js가 고쳐졌다고 해서 같은 저장소에 따로 설치한 satori나 @vercel/og까지 고쳐진 것은 아닙니다. 번들 안에 넣어 쓰는 의존성을 따로 관리해야 하는 이유는 AI 코딩 공급망 보안에서도 다뤘습니다.
rsvg-convert로 PNG를 만들고 정적 파일로 배포합니다. 요청 시점 렌더링도, 사용자 입력도 없어 이번 조건에 해당하지 않습니다.7. 내 프로젝트 점검 순서
위 네 조건을 확인 비용이 낮은 순서대로 봅니다. 앞 단계에서 해당이 없으면 거기서 멈춰도 됩니다.
① 실제로 설치된 버전
package.json의 ^16.3.4 같은 범위 표기가 아니라 락파일이 고정한 값을 봐야 합니다.
npm ls next # 또는 pnpm why next / yarn why next
node -p "require('next/package.json').version"
② next/og를 쓰는 곳
grep -rn --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' \
-E "from ['\"]next/og['\"]|ImageResponse" app src pages 2>/dev/null
find app src -name 'opengraph-image.*' -o -name 'twitter-image.*' 2>/dev/null
opengraph-image.png처럼 이미지 파일이면 ImageResponse를 쓰지 않으므로 해당이 없습니다. .tsx·.ts·.js로 그림을 그리는 파일만 봅니다.
③ 런타임과 sharp
grep -rn "export const runtime" app src 2>/dev/null # 없으면 기본값 nodejs
ls node_modules/sharp 2>/dev/null && echo "sharp 설치됨"
sharp는 Next.js가 이미지 최적화에 쓰는 패키지라, next/image를 쓰는 프로젝트에는 이미 설치돼 있을 수 있습니다. [4]
④ JSX에 무엇이 들어가는가
②에서 찾은 파일을 열어, ImageResponse에 넘기는 JSX 안에 아래 값이 들어가는지 봅니다.
params로 받은 동적 세그먼트 값 ([slug],[id]등)searchParams나 요청 헤더·쿠키에서 꺼낸 값- 데이터베이스나 외부 API에서 가져온 사용자가 쓴 제목·이름·설명
- 위 값이
style이나 SVG 속성으로 들어가는 경우 (색상, 글꼴 이름, 이미지 주소 등)
이 가운데 하나라도 해당하고 ①의 버전 조건에도 해당하면, 올리기 전까지 그 값을 JSX에 넣지 않는 것이 권고가 밝힌 유일한 임시 조치입니다. Satori 권고는 "업그레이드 말고는 완전한 우회책이 없다"고 적었습니다. [2]
8. 체크리스트
- Next.js 16.2.0 이상 16.3.6 미만이면 16.3.6으로 올린다. 조건을 따지기 전에 이것부터 합니다. 비용이 가장 작은 조치입니다
- Next.js 15.x라면 이번 RCE는 해당이 없다. 15.5.26은 보안 강화이므로 다음 정기 업데이트 때 함께 올린다
satori를 직접 설치했다면 0.33.5 이상으로,@vercel/og를 직접 설치했다면 1.0.3 이상으로 따로 올린다- 올리기 전까지는 동적 OG 라우트에서 요청 값과 사용자 작성 콘텐츠를 JSX에 넣지 않는다
- 보안 스캐너가 16.3.6 안의
satori@0.25.0을 취약하다고 표시하면, 표기만 보고 판정하지 말고next버전으로 확인한다 - 런타임을 Edge로 바꾸는 것을 대책으로 삼지 않는다. 공식 문서가 라우트 설정의
runtime = 'edge'를 폐기 예정으로 표시하고 있다 - 8월 25일의 AVIF 이미지 최적화 RCE(16.3.3에서 수정)도 함께 확인한다. 16.3.3 미만이면 두 건 모두 해당될 수 있다
9. 아직 결론이 아닌 것
- 실제로 코드를 실행하는 구성 요소는 공개되지 않았습니다. 4장의 "sharp 경로"는 배포된 코드와 8월 권고에서 나온 저희의 추론입니다. 두 권고와 공식 블로그는 "다른 의존 라이브러리"라고만 적었습니다
- sharp가 설치되지 않은 Node.js 환경이 안전한지는 권고가 말하지 않았습니다. 코드상 그 경우 Edge와 같은 resvg.wasm으로 변환하지만, 권고는 "Node.js
ImageResponse"를 영향 대상으로 적었을 뿐 sharp 유무로 나누지 않았습니다. 이 차이로 안전하다고 판단하지 말고 올리세요 - Vercel에 배포한 앱의 상태는 확인하지 못했습니다. 공식 블로그와 권고 모두 호스팅 환경별 영향을 언급하지 않았습니다
- 공격 기법의 세부는 확인하지 못했습니다. 어떤 값이 이스케이프되지 않았는지 Satori 권고는 "특정 값들"이라고만 적었습니다. 저희는 수정 코드에서 요소·속성 이름 검증과 속성 값 이스케이프가 추가된 것까지만 확인했습니다
- 실제 공격 여부는 알 수 없습니다. 권고에 공격 관측이나 공개 공격 코드에 대한 언급이 없다는 것이지, 공격이 없었다는 확인이 아닙니다
- 자체 공격 재현은 하지 않았습니다. 이 글에서 직접 한 것은 npm 레지스트리의 배포 시각 확인과 16.3.5·16.3.6 패키지 코드 비교입니다
10. 참고자료
| 출처 | 확인한 내용 | 근거의 범위 |
|---|---|---|
| [1] Next.js 보안 권고 GHSA-vcvr-r3jv-pc5j | CVE-2026-94545, Critical 9.5, CVSS 4.0 벡터, 영향 범위 16.2.0 이상 16.3.6 미만, 영향 없는 조건, 발견자 | 개발사 1차 권고 · 2026-09-23 확인 |
| [2] Satori 보안 권고 GHSA-wx4j-mvgx-mqwp | 같은 CVE의 Moderate 5.3, 벡터, 영향 범위 0.0.27 이상 0.33.5 미만, "영향은 SVG 처리 방식에 달렸다" | 개발사 1차 권고 · 2026-09-23 확인 |
| [3] Next.js Security Update for a Critical Upstream Issue | 16.3.6·15.5.26 배포, 15.x는 RCE 영향 없음, Edge 영향 없음, "다른 의존 라이브러리" | 개발사 공식 블로그 · 2026-09-23 확인 |
| [4] Next.js 보안 권고 GHSA-2xp9-vwfh-vxw4 | 8월 25일 AVIF RCE. 원인을 sharp가 쓰는 libheif로 명시, 같은 9.5 벡터, 16.3.3·15.5.24에서 수정 | 개발사 1차 권고 · 2026-09-23 확인. 이번 건과 같은 원인이라는 근거가 아니라 비교 사례 |
| [5] Next.js 문서: opengraph-image and twitter-image | 기본 정적 생성, 요청 시점 API를 쓸 때만 동적 렌더, params를 쓰는 대표 예제, 특수 라우트 핸들러 | 공식 문서 (16.3.5판) · 2026-09-23 확인 |
| [6] Next.js 문서: Route Segment Config | runtime 기본값 'nodejs', 'edge' 폐기 예정 표시, dynamicParams 기본값 true | 공식 문서 (16.3.6판) · 2026-09-23 확인 |
[7] npm 레지스트리 · next 16.3.5·16.3.6 배포 패키지 | 네 패키지의 배포 시각, @vercel/og 1.0.3의 satori 의존 버전, 번들 안 render() 코드와 satori@0.25.0 표기, 16.3.6에 추가된 코드 | 저희가 직접 받아 비교 · 2026-09-23. 코드 해석은 저희의 것 |
편집 기준: 권고·블로그·문서의 값과 저희가 배포 패키지에서 확인한 값을 구분해 적었습니다. 4장의 "sharp 경로"는 추론임을 본문과 한계 절에 함께 밝혔습니다. 요약 기사의 수치는 근거로 쓰지 않았습니다.