01개요
React 서버 컴포넌트(RSC)의 Flight 역직렬화 경로에서 발생하는 무인증 원격 코드 실행입니다. Next.js App Router 조합은 기본 설정만으로 노출됩니다.
데이터를 복원하는 코드가 복원 대상의 정체를 세 번 잘못 믿어, "복원"이 "코드 실행"으로 바뀝니다.
CVSS: 10.0
관련 식별자: CVE-2025-55182 / CVE-2025-66478(Rejected).
02영향받는 버전
| 대상 | 취약 버전 | 안전 버전 |
|---|---|---|
| React | 19.0.0 / 19.1.x / 19.2.0 | 19.0.1 · 19.1.2 · 19.2.1 (라인별) |
| Next.js | 15.x(~15.5.6) · 16.x(~16.0.6) · 14.3.0-canary.77+ | 15.0.8 · 15.1.12 · 15.2.9 · 15.3.9 · 15.4.11 · 15.5.10 · 16.0.11 · 16.1.5 (캐너리: 15.6.0-canary.60 · 16.1.0-canary.19) — 2026-08-29 react.dev 기준값. 후속 CVE 대응으로 갱신될 수 있으니 설치 전 react.dev 원문 확인 |
03배경 — 역직렬화는 작은 인터프리터다
React 서버 컴포넌트(RSC)는 서버와 브라우저가 복잡한 데이터를 주고받습니다. 일반 JSON으로는 Promise나 Blob을 표현할 수 없어, React는 Flight라는 자체 포맷을 씁니다.
값을 chunk 조각으로 쪼개고, 조각 사이 참조를 $@·$B 같은 문자열로 적어 둡니다. 서버는 이 참조를 따라가며 원래 객체로 되살립니다 — 이게 역직렬화입니다.
여기서 하나만 미리 짚습니다. 입력을 받아 객체로 복원하는 코드는 사실상 작은 인터프리터입니다. 자바 가젯 체인, 파이썬 pickle이 반복해 뚫린 이유가 이것입니다.
복원 과정이 "무엇을 만들지"를 입력이 조종할 수 있으면, 역직렬화는 그 순간 코드 생성 도구가 됩니다.
04취약점 발생 원인
취약점은 서버가 요청을 처리하는 흐름을 따라 세 지점에서 차례로 열립니다. 아래 그림이 전체 사슬이고, 이어서 각 지점을 하나씩 봅니다.
1"status가 resolved이면, 완성된 내 조각이다"
조각의 정체를 상태 값 하나로 판단합니다 — 그런데 그 상태 값은 공격자가 붙일 수 있는 필드입니다.
Flight의 조각(Chunk)은 Promise를 상속한 thenable, 즉 then을 가진 객체입니다. 자바스크립트는 then이 있는 객체를 Promise로 취급해 await합니다 — 정체가 아니라 모양으로 판단합니다.
그래서 조각 해석 코드는 then이 불릴 때 status가 "resolved_model"이면 '완성된 정상 조각'으로 보고 곧장 파싱에 넘깁니다. 공격자가 {"status":"resolved_model"}를 붙여 보내면 그대로 통과합니다.
왜 이렇게 됐나이 역직렬화기는 "내가 처리하는 객체는 전부 내가 만든 것"이라는 전제 위에서 쓰였습니다. 외부에서 "나는 완성된 조각이야"라고 스스로 주장하는 객체가 들어올 상황을 고려하지 않았습니다.
2"참조 경로의 각 조각은 데이터 접근일 뿐이다"
경로의 키를 소유 검사 없이 따라가기 때문에, __proto__ 한 조각으로 데이터가 아니라 프로토타입에 닿습니다.
$1:foo:bar는 '1번 조각의 foo의 bar'라는 참조입니다. 복원 코드는 이를 :로 쪼갠 뒤 속성을 하나씩 따라 들어갑니다. 문제는 각 조각을 키로 쓰면서 그 키가 객체 자신의 속성인지(hasOwnProperty)를 확인하지 않는다는 것입니다. 아래 615행이 바로 그 무방비한 접근입니다.
React 실제 소스 — ReactFlightReplyServer.js · getOutlinedModel (패치 전, 커밋 36df5e8b — 패치 커밋 7dc903c의 부모). 붉은 615행이 소유 검사 없이 임의 키로 속성에 접근하는 곳입니다.
자바스크립트에서 obj["__proto__"]나 obj["constructor"]는 데이터가 아니라 객체 내부로 통하는 통로입니다. 게다가 Flight의 $@ 참조는 조각의 값이 아니라 조각 객체 자체를 돌려줍니다.
그래서 $1:__proto__는 조각들이 공유하는 Chunk.prototype이 되고, $1:constructor:constructor는 Function 생성자가 됩니다. Function("문자열")은 문자열을 코드로 컴파일하는 가장 강력한 도구입니다.
검증 없는 경로 접근 하나가 '임의의 문자열을 코드로 만드는 능력'을 통째로 넘긴 셈입니다.
왜 이렇게 됐나작성자는 참조 경로를 JSON 포인터 같은 "순수한 데이터 탐색"으로 취급했습니다. 하지만 JS에서 임의 문자열 키로 속성에 접근하는 순간, 그 키가 __proto__라면 값 읽기가 아니라 정체를 만지는 일이 됩니다.
3"데이터를 꺼내는 이 함수는 내가 아는 그 함수다"
평범한 조회 함수가 이미 Function 생성자로 바뀌어 있어, 데이터를 꺼내려는 호출이 곧 코드 컴파일이 됩니다.
복원 중 Blob 참조($B)를 만나면, 코드는 원본 데이터를 꺼내려 내부 조회 함수(_formData.get)를 호출합니다. 그런데 두 번째 결함으로 이 함수는 이미 Function 생성자로 바뀌어 있습니다 — 데이터를 꺼내려는 호출이 곧 공격자 문자열을 코드로 컴파일하는 일이 됩니다.
같은 오염으로 이 객체의 then은 정상 조각의 then을 가리켜, 객체 전체가 thenable이 됩니다. React가 이를 해석하며 promise를 resolve하는 순간 방금 컴파일된 코드가 실행됩니다 — 인증도, 클릭도 없이.
세 단계 사슬처럼 보이지만, 무게 중심은 두 번째 결함(경로 소유 검사 부재) 하나입니다.
첫 번째(가짜 status)와 세 번째(함수 치환)는 단독으로는 무력합니다. 두 번째 결함이 Chunk.prototype과 Function 생성자로 가는 길을 열어야 비로소 무기가 됩니다. 그래서 보안상 핵심은 "이 키가 객체 자신의 속성인가"를 묻는 한 줄입니다 — 실제로 배포된 패치는 이 한 줄을 포함한 더 큰 리팩터의 일부였지만, 사슬을 끊는 지점은 여전히 이 한 줄입니다.
한 문장으로 묶으면 이렇습니다. 역직렬화기는 자기가 다루는 것의 정체를 세 번 잘못 믿었습니다 — 스스로 선언한 상태를, 경로에 섞인 프로토타입 통로를, 자기 내부 함수의 정체를. 그 잘못된 믿음이 하나씩 공격 재료가 됐습니다.
05공격 과정
5.1 공격 과정 요약
5.3 미흡점 ↔ 공격 흐름 매핑
| 미흡점(§4) | 발화 지점(§5.2) | 여는 프리미티브 | → 다음으로 잇는 것 |
|---|---|---|---|
| W1 — 가짜 status | 조각 .then() 호출 시점 | 가짜 상태로 정상 조각 위장 | 검증 없이 파싱(initializeModelChunk) 단계로 통과 |
| W2 — 경로 소유 검사 부재 | 참조 경로 순회(getOutlinedModel) | Chunk.prototype / Function 생성자 획득 | 내부 조회 함수(_formData.get) 치환 가능 |
| W3 — 함수 정체 오신뢰 | Blob 참조 처리 중 _formData.get 호출 | 임의 문자열 → 실행 가능 함수로 컴파일 | promise resolve 대기 |
| (귀결) | then 콜백 resolve | 컴파일된 함수 실행 | 원격 코드 실행(RCE) 확정 |
06취약점 패치
왜경로 순회가 __proto__·constructor 같은 프로토타입 통로로 새어 나가는 것을 막기 위해서입니다. 615행이 바로 결함 ②가 성립하던 자리입니다.
어떻게취약하던 직접 접근(615행 삭제)을 지우고, 그 키가 객체 자신의 속성일 때만 값을 읽도록 hasOwnProperty 검사를 앞에 세웠습니다. __proto__처럼 프로토타입으로 올라가는 키는 소유 속성이 아니므로 이 검사에서 걸러집니다.
비고실제 배포된 커밋 7dc903c는 이 소유 검사 한 줄만 담은 게 아니라, ReactFlightReplyServer.js 한 파일만 822줄이 바뀐 더 큰 리팩터(ReactPromise 언랩·waitForReference 도입 등, ReactFlightClient와의 동기화)의 일부입니다. 위 diff는 그 중 보안상 핵심인 소유 검사 추가 구간만 발췌한 것입니다.
07대응방안
패치가 사실상 유일한 근본 해결입니다. 사용하는 라인에 맞춰 §영향받는 버전 표의 안전 버전으로 올립니다.
- WAF 키워드 차단에 기대지 마십시오. 역직렬화가
JSON.parse를 거치므로, 일반 원리상 페이로드를\uXXXX유니코드로 인코딩하면__proto__·constructor키워드 규칙을 우회할 여지가 있습니다(이 CVE에 특정된 실관측 사례는 1차 출처에서 확인하지 못했습니다 —JSON.parse의 유니코드 이스케이프 디코딩이라는 일반 동작에서 나온 추론입니다). 차단 규칙은 알려진 대량 스캔을 줄이는 완충 정도로만 봅니다. 추론 - 업데이트 전까지는, 필수가 아닌 서버 액션을 일시 비활성하고 서버 액션 엔드포인트에 인증·인가를 얹어 익명 접근을 막습니다(무인증이 이 취약점의 전제입니다).
- 탐지 관점. 키워드가 아니라 구조로 — 요청 본문을 유니코드 정규화한 뒤 참조 경로에 프로토타입 매직 속성이 등장하는지 검사합니다.
08왜 이게 반복되는가
문자열 경로를 따라 입력으로부터 객체를 복원하는 코드라면, 자바스크립트에서 프로토타입 체인은 늘 숨은 sink입니다. 자바 가젯 체인, 파이썬 pickle과 뿌리가 같습니다.
방어 원칙은 둘입니다. 첫째, 신뢰할 수 없는 키로 속성에 접근할 때는 소유 여부를 확인하거나 Object.create(null)·Map을 씁니다.
둘째, thenable·iterable처럼 '생김새로 판별되는 계약'은 그 자체가 신뢰 경계입니다. 'X처럼 생겼다'를 검증 없이 받아들이면 X의 능력까지 함께 받아들이는 셈이니까요.
이 한 틈은 패치됐지만, "외부 데이터를 검증 없이 자기 것으로 되살린다"는 습관은 다음 포맷에서 또 나타난다 — 그래서 우리는 CVE 번호가 아니라 이 사고방식을 기록으로 남깁니다.
참고 자료
공개된 취약점을 우리 관점에서 직접 분석·재구성한 연구 글입니다. 코드 발췌는 React 오픈소스(MIT)의 실제 소스입니다.
- Checkmarx — React2Shell (CVE-2025-55182): Deserialization to RCE in React and Next.js · 또 다른 악용 경로(모듈 로딩 가젯
vm#runInThisContext) 분석 - Datadog Security Labs — CVE-2025-55182 (React2Shell): Remote code execution in React Server Components and Next.js · 본문이 다루는
__proto__→Function→_formData.get체인의 실전 payload 관측 - NVD — CVE-2025-55182 상세 (CVSS 10.0) · CVE-2025-66478(Rejected) 포함
- React 오픈소스 패치 — ReactFlightReplyServer.js에 소유(own-property) 검사 추가 · 커밋 7dc903c
- Wiz — Critical Vulnerability in React (CVE-2025-55182)
- Cloudflare — React2Shell RSC vulnerabilities: exploitation threat brief · 실전 악용 관측