
React Flight 프로토콜의 악용 및 방어: RSC에서의 역직렬화 싱크
요약
본 기사는 React Server Components(RSC)가 사용하는 내부 스트리밍 프로토콜인 Flight의 구조적 취약점을 심층 분석합니다. 이 프로토콜은 단순한 데이터 전송을 넘어 실행 가능한 참조를 재구성하는 역직렬화 시스템이며, 이를 악용한 'React2Shell'이라는 CVSS 10.0점의 원격 코드 실행(RCE) 취약점이 발견되었습니다. 따라서 개발자들은 모든 Server Action에 대한 스키마 유효성 검사 및 Taint API 활용 등 다층적인 방어책을 마련해야 합니다.
핵심 포인트
- React RSC는 Flight이라는 사용자 지정 스트리밍 프로토콜을 사용합니다.
- Flight은 단순 데이터 전송이 아닌, 실행 가능한 참조를 재구성하는 역직렬화 시스템입니다.
- CVSS 10.0점의 'React2Shell' 취약점이 발견되었으며, 인증 없이 RCE가 가능했습니다.
- 방어책으로는 Server Action에 대한 스키마 유효성 검사 및 Taint API 사용이 필수적입니다.
React Server Components는 HTML을 브라우저로 전송하지 않습니다. JSON도 아닙니다. 서버 컴포넌트가 렌더링될 때, 실제로 와이어를 타고 이동하는 것은 Flight이라는 사용자 지정 스트리밍 프로토콜입니다. 이는 자체 타입 시스템, 자체 참조 해결(reference resolution), 그리고 클라이언트에서 실행 가능한 동작을 재구성하기 위한 고유 규칙을 가진 라인 구분 형식입니다.
대부분의 React 개발자들은 Network 탭을 열어 Flight 페이로드를 실제로 살펴본 적이 없을 것입니다. 그것은 JSON 조각, 달러 기호가 붙은 참조(dollar-sign-prefixed references), 그리고 모듈 포인터들이 뒤섞여 있는 것처럼 보이며, 이들은 React 런타임에 의해 조용히 라이브 컴포넌트 트리로 재조립됩니다. 프레임워크가 처리하기 때문에 아무도 의문을 제기하지 않습니다.
저는 대부분의 팀이 그 신뢰(trust)가 실제로 무엇을 의미하는지 신중하게 생각해 보았는지 확신할 수 없습니다.
2025년 12월에 CVE-2025-55182가 공개된 후, 저는 Flight 프로토콜을 파헤치기 시작했습니다. 보안 커뮤니티는 이를 React2Shell이라고 불렀고, 그럴 만한 이유가 있었습니다. 이는 CVSS 10.0점의 인증되지 않은 원격 코드 실행(unauthenticated remote code execution) 취약점으로, Flight 역직렬화 계층에 존재했습니다. 서버 함수 엔드포인트로 조작된 HTTP 요청 하나만으로 공격자는 셸 접근 권한을 얻을 수 있었습니다. 자격 증명은 필요하지 않았습니다.
소스 코드(주로 getOutlinedModel과 실제로 중요한 해결 로직이 존재하는 getChunk)를 살펴보는 시간을 가진 후, 저는 React2Shell이 일회성 파싱 버그가 아니라는 것을 깨달았습니다. 그것은 증상일 뿐이었습니다. Flight은 텍스트 스트림으로부터 실행 가능한 참조, 지연 로드된 컴포넌트, 서버 RPC 엔드포인트, 그리고 비동기 상태를 재구성합니다. 이것은 **역직렬화 시스템(deserialization system)**입니다.
공격 표면은 단일 누락된 hasOwnProperty 검사보다 훨씬 광범위합니다. 이 글에서는 Flight이 와이어상에서 어떻게 작동하는지, 역직렬화 싱크(deserialization sinks)가 어디에 있는지, 공격자들이 이미 무엇을 악용했는지, 그리고 여전히 노출된 부분이 무엇인지 다룹니다.
이는 사용자의 Server Components를 위한 순위가 매겨진 실용적인 방어책으로 이어집니다. 모든 Server Action에 대한 스키마 유효성 검사, server-only 패키지 사용, 프레임워크 기본 설정을 넘어선 교차 사이트 요청 위조(CSRF) 강화, 그리고 Taint API와 웹 애플리케이션 방화벽(WAFs)이 제공하는 기능에 대한 평가가 포함됩니다.
목차
- Flight On The Wire
- 왜 Flight이 역직렬화 싱크인가
- React2Shell의 작동 방식
- 해결책
- 영향력 순으로 나열된 방어책
- React2Shell 이후에 일어난 일
- 여전히 노출된 부분
- 이전에 발생했던 사례
- 다음 단계는 어디인가
Flight On The Wire
Next.js App Router의 모든 페이지에서 브라우저의 Network 탭을 열고 Content-Type: text/x-component를 반환하는 요청을 찾아보세요. 이것이 바로 Flight입니다. 이는 단일 JSON 블롭(blob)이 아닙니다. 연결을 통해 도착함에 따라 클라이언트 측 React 런타임이 처리하는, 자체 포함된
1행은 임포트 지시문입니다. 클라이언트에게 번들러의 청크 맵에서 ClientComponent.js를 로드하라고 알려줍니다. 2행은 <article> HTML 요소를 구성하는 JSON 트리이며, children 내부의 "$1"은 1번 청크(임포트된 컴포넌트)로 다시 참조됩니다. 0행은 서버 실행 컨텍스트를 정의하며, 이것이 Server 환경에서 실행되는 RootLayout임을 표시합니다. 이 작은 예제에서도 구조적 데이터, 모듈 참조, 그리고 크로스-청크 포인터가 혼합되어 있어 Flight이 일반 JSON과 어떻게 다른지 알 수 있습니다.
행(Row) 형식
모든 행은 동일한 구문을 따릅니다: <ROW_ID>:<ROW_TAG><PAYLOAD>\n. 행 ID는 다른 행들이 참조할 수 있는 숫자 식별자입니다. 태그는 파서에게 뒤따르는 데이터가 어떤 종류인지 알려주는 단일 문자(또는 짧은 문자열)입니다. 페이로드는 실제 내용물입니다.
소스 코드를 읽으면서 발견한 행 태그들은 다음과 같습니다:
| 태그 | 이름 | 기능 |
|---|---|---|
| J | JSON Tree | 직렬화된 가상 DOM 노드, 컴포넌트 props, 및 HTML 요소. |
| ... | ||
| 지금까지는 사용자 정의 태그가 있는 무해한 구조적 데이터 형식처럼 보일 수 있지만, 실제 복잡성과 공격 표면은 **접두사 시스템(prefix system)**에 있습니다. |
$ 접두사 시스템
제가 더 주의를 기울이기 시작한 부분이 바로 여기입니다.
클라이언트 측 파서가 $로 시작하는 문자열 값을 만날 때, 이를 리터럴 텍스트로 취급하지 않습니다. 이 문자열을 가로채어 접두사를 확인하고, 타입별 해결 경로(resolution path)를 통해 라우팅합니다. ReactFlightClient.js의 parseModelString 함수가 바로 이곳에서 발생합니다. 이는 본질적으로 $ 뒤의 문자를 기준으로 하는 거대한 스위치문입니다.
| 접두사 (Prefix) | 타입 (Type) | 파서가 처리하는 방식 (What the parser does with it) |
|---|---|---|
$ | 모델 참조 (Model Reference) | 스트림 내의 다른 청크로 해석됩니다 (예: $2는 2번째 행을 가리킵니다). |
| ... | ||
모든 다른 접두사는 하나의 청크를 해석하고 파싱된 결과를 제공합니다. $@은 대신 원시 내부 Chunk 객체를 전달합니다. 이 객체는 React가 해결 상태, 보류 중인 콜백 및 내부 메타데이터를 추적하는 데 사용하는 래퍼입니다 (이것이 Promise에 사용되는 이유이자 공격자들이 가변 핸들(mutable handle)을 얻기 위해 사용하는 이유입니다). 프로토콜을 통해 프레임워크의 내부 구조(plumbing)가 노출된 것은 저에게는 설계상의 실수처럼 보이지만, 만약 그럴 만한 근거가 있다면 듣고 싶습니다. |
그리고 $: (속성 접근)은 또 다른 중요한 접두사입니다. 이것은 프로토콜이 $1:user:name과 같은 경로를 지정할 수 있게 해주는데, 이는 파서에게 청크 1을 해석하고, 그 결과에서 .user에 접근한 다음, 다시 .name에 접근하라고 지시합니다. 이것은 스트림 내 데이터에 의해 구동되는 임의 속성 순회(arbitrary property traversal)입니다. 만약 프로토타입 오염(prototype pollution)에 대해 JavaScript를 감사해 본 경험이 있다면, 이 패턴이 익숙하게 느껴져야 합니다.
이것은 단순한 데이터 형식이 아닙니다
Flight은 추가 단계를 거친 JSON이 아닙니다. JSON은 데이터를 제공합니다. Flight은 **행동(behavior)**을 제공합니다. 이는 클라이언트 측 코드 로딩을 유발하는 모듈 참조를 재구성하고, 클라이언트가 RPC 호출로 호출할 수 있는 서버 액션 엔드포인트를 생성하며, React 런타임이 await 할 Promise 체인을 설정하고, 필요에 따라 실행되는 지연 로드 컴포넌트 경계(lazy-loaded component boundaries)를 구축합니다.
React 개발자들이 그렇게 생각하든 아니든, 그 메커니즘은 역사적으로 문제를 일으켜 온 역직렬화 시스템과 매우 유사해 보입니다. 이 스트림은 단순히 UI가 어떻게 생겼는지를 설명하는 것에 그치지 않습니다. 어떤 코드를 로드할지, 어떤 함수를 호출할지, 그리고 무엇을 신뢰해야 하는지에 대해 클라이언트 런타임에 지침을 내립니다.
직접 구현을 읽어보고 싶다면, 미리 경고합니다: 청크 해상도 경로를 따라가기는 매우 어렵습니다. 상태 전환은 헬퍼 함수들 사이를 오가며 발생하고, 이름 지정 방식이 코드가 실제로 무엇을 하는지 모호하게 만듭니다. 저는 정적인 방식으로 읽는 것을 포기하고 그냥 중단점(breakpoint)만 설정했습니다. 핵심 파일들은 클라이언트 측 파서의 경우 react-client/src/ReactFlightClient.js를 참고하세요 (여기서 parseModelString, getChunk, reviveModel, 그리고 getOutlinedModel을 찾아보세요). 직렬화(serialization) 측면의 파일은 react-server/src/ReactFlightServer.js에 있습니다. Server Actions를 위한 응답 핸들러는 react-server/src/ReactFlightReplyServer.js에 위치합니다.
Flight이 역직렬화 싱크(Deserialization Sink)인 이유
역직렬화 패턴은 익숙합니다: Java의 ObjectInputStream은 ysoserial을 낳았고, Python의 pickle는 load()에서 코드를 실행하며, PHP의 unserialize는 __wakeup과 __destruct 메서드를 연결하고, .NET의 BinaryFormatter는 아예 사용이 중단되었습니다.
패턴: 공격자가 제어하는 입력(attacker-controlled input)을 역직렬화 → 재구축 과정에서 동작 호출 → 실행 제어권을 상실합니다.
그렇다면 JavaScript는 이것에 면역이어야 하죠? JSON.parse()는 순수한 데이터 객체만 생성할 뿐입니다. 생성자(constructor)가 호출되지 않습니다. 마법 메서드(magic methods)도 실행되지 않습니다. JSON 문자열이 설명하는 것 그대로, 그 이상은 아무것도 돌려받지 못합니다.
이는 원시적인 JSON.parse()에 대해서는 사실입니다. 하지만 프레임워크가 사용자 정의 역직렬화 로직을 감싸기만 하면 이 진실성은 깨집니다. 그리고 Flight이 바로 그렇게 작동합니다.
프로토타입 오염(Prototype Pollution)
JavaScript는 프로토타입 기반 상속(prototype-based inheritance)을 사용합니다. 모든 객체는 자신의 프로토타입으로 연결되는 __proto__ 링크를 가지며, 속성 조회(property lookups)는 이 체인을 따라 올라갑니다. 만약 공격자가 재구성 과정에서 키로 __proto__나 constructor.prototype을 주입하면, 모든 객체가 상속받는 공유 기본 프로토타입(shared base prototypes)을 수정하게 됩니다. 하위 코드들은 이를 인지하지 못한 채 공격자에 의해 제어된 값들을 읽게 됩니다.
Flight의 $: 접두사는 역직렬화된 객체에 대해 속성 순회(property traversal)를 수행합니다. getOutlinedModel 함수는 $1:user:name과 같은 콜론으로 구분된 경로를 각 세그먼트를 반복하며 부모 객체에서 접근함으로써 걸어갑니다. 만약 이 경로 세그먼트들이 __proto__나 constructor를 포함한다면, 순회는 프로토타입 체인을 따라 곧장 올라가게 됩니다. 이는 이론적인 위험이 아닙니다. React2Shell이 작동했던 방식과 정확히 같습니다.
덕 타이핑(Duck Typing) 및 Thenables
V8 엔진(및 JavaScript 사양)은 .then 속성을 가진 모든 객체를 Thenable로 취급합니다. 무언가를 await할 때, 런타임은 .then을 확인하고 존재하면 호출합니다. 클래스 검사도 없고 내부 슬롯 검증도 없습니다. 만약 .then이 호출 가능하다면, 그것이 호출됩니다.
Flight은 청크를 비동기적으로 해결(resolve)합니다. 만약 공격자가 조작된 .then 속성을 가진 객체를 구성하여 청크 해결 파이프라인에 넣는다면, 런타임은 정상적인 await 동작 중에 공격자의 함수를 호출하게 됩니다. 언어의 의미론(language semantics) 자체가 이 작업을 수행하는 것입니다.
처음에는 서버 액션 참조를 위조하는 것이 명백한 공격 표면처럼 보여서 $F에 초점을 맞췄습니다. 하지만 해결 경로를 추적해 본 결과, $: 속성 순회가 훨씬 더 흥미로워 보였습니다. 또한 청크 상태 전환(pending, blocked, resolved, errored)을 검토하는 데 몇 시간을 보냈지만, 그 접근 방식으로는 아무런 결과를 얻지 못했습니다.
핵심 문제
핵심 문제
이 두 가지 위험은 Flight에서 수렴되는데, 그 이유는 이 프로토콜이 단순히 데이터를 역직렬화(deserialize)하지 않기 때문입니다. 그것은 _행동(behavior)_을 역직렬화합니다. $ 접두사 시스템은 파서가 어떤 실행 경로를 따를지 결정합니다: $F는 호출 가능한 서버 엔드포인트를 생성하고, $L은 지연 코드 로딩(lazy code loading)을 설정하며, $B는 블롭 핸들러(blob handler)를 트리거하고, $@는 내부 프레임워크 상태를 노출합니다. 파서의 제어 흐름은 스트림에 포함된 내용에 전적으로 의해 구동됩니다.
만약 공격자가 스트림의 내용을 조작할 수 있다면, 그들은 파서가 어떤 함수를 호출할지, 어떤 객체를 구성할지, 그리고 어떤 내부 상태를 노출할지를 통제하게 됩니다.
React2Shell의 메커니즘
이것이 이론을 증명한 CVE입니다. CVE-2025-55182는 React2Shell이라는 별명을 가진, Flight 역직렬화 계층(deserialization layer)의 CVSS 10.0 인증 불필요 원격 코드 실행(unauthenticated remote code execution) 취약점입니다. 로그인할 필요 없이 HTTP 요청 하나만으로 전체 셸 접근이 가능합니다.
저는 이 전체 가젯 체인(gadget chain)을 설명하고 싶습니다. 왜냐하면 이를 이해하는 것이 Flight 프로토콜이 스트림을 제어할 수 있는 공격자에게 얼마나 많은 권한을 넘겨주는지를 보여주기 때문입니다.
근본 원인
취약점은 $: 참조 시스템에서 깊은 속성 경로를 해결하는 역할을 하는 getOutlinedModel 함수에 있습니다. 익스플로잇 체인(exploit chain)에서 사용되는 인스턴스는 서버 측 응답 처리 코드(ReactFlightReplyServer.js)에 존재합니다. 파서가 $1:user:name과 같은 참조를 만나면, 콜론을 기준으로 분할하고 경로를 세그먼트별로 탐색합니다. 취약한 루프는 다음과 같습니다:
for (key = 1; key < reference.length; key++)
parentObject = parentObject[reference[key]];
단 두 줄의 코드입니다. hasOwnProperty 검사가 없습니다. 또한 속성이 프로토타입 체인(prototype chain) 상의 어딘가에 존재하는지, 객체 자체에 존재하는지 여부를 검증하는 과정도 없습니다. 그저 parentObject[reference[key]]를 실행하고 넘어갈 뿐입니다.
따라서 공격자가 $1:__proto__:constructor:constructor를 공급하면, 루프는 일반 JSON 객체에서 시작하여 Object.prototype을 거쳐 Object 생성자, 그리고 Function 생성자로 순회합니다. JavaScript에서 Function은 eval()처럼 동작합니다. 즉, Function("임의 코드")()가 실행됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Smashing Magazine의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기