Open8

JSフレームワークのシリアライズ機構 - Reach2Shellの発覚に伴って調査

AkiraAkira

JSフレームワークのシリアライズ機構 - Reach2Shellの発覚に伴って調査

2025-12-03、React及びそのメタフレームワークであるNext.jsで CVE-2025-55182 、通称React2Shellと呼ばれる脆弱性が確認された。これはReact Server Componentsの実装に起因するもので、Flightプロトコルのデシリアライズロジックにおける検証不備を悪用した認証不要のリモートコード実行(Unauthenticated RCE)である。

その後、12月11日に初期パッチに対する追加の脆弱性が発見された。CVE-2025-55184(無限ループによるDoS)とCVE-2025-55183(サーバー関数のソースコード漏洩)で、これにより初期パッチ(React 19.2.0等)では不十分であることが判明している。現時点(2025-12-15)での安全なバージョンは、React 19.0.2 / 19.1.3 / 19.2.2 以降、Next.jsは 15.1.11 / 16.0.10 / 14.2.35 以降とされている。

これを受けて思ったのが、この脆弱性はReactだけに留まらないのではないかということ。モダンなJSフレームワークはRSCと同様のRPC機構を備えているものが多いため、他のフレームワークでも実装に不備があればCode Injectionは成立し得るのではないか?

ということで、その辺りの事情調査を行っていく。具体的には、RPC機構を実現するためのSerialize/DeSerializeの詳細や、既存のCVEなどを網羅的に調査していきたい。

AkiraAkira

React Router / Remix

Remixはv2からReact Router v7と合流したのでまとめて語る。Remix v3は知らん。

Sigle Fetchとturbo-stream

React Router v7ではLoader/Actionとして提供されるデータロードのメカニズムを、従来のJSON形式から "Single Fetch" と呼ばれるモデルへ移行し、シリアライズに turbo-stream ライブラリを採用している。

turbo-streamは、JSONでは表現できないリッチなデータ型(Date, Map, Set, Promise, Error, Symbolなど)をサポートし、それらをHTTPレスポンスボディとしてストリーミング転送する技術らしい。これにより、サーバーサイドで発生した非同期処理(Promise)の結果を、リクエストをブロックすることなくクライアントへプッシュ送信できるとのこと。これが所謂Streaming SSRか。

Single FetchにおけるAPI設計の改善

これはシリアライズに関する変更の一つ。

https://app.unpkg.com/@remix-run/router@1.23.0/files/CHANGELOG.md

これの概要としては、Remix内部で使用されていた ErrorResponse クラスのようなプライベートな実装詳細が、シリアライズを通じてクライアントに露出するケースが確認された、と言う話。Remixチームはこれに対し、ErrorResponse クラスを UNSAFE_ErrorResponseImpl としてリネームし、ユーザーランドでの直接生成を抑止する変更を行った、とされている(2023年9月頃)。

これは、シリアライズ可能なオブジェクトの範囲が広がったことで、従来はサーバー内部の隠蔽情報と見なされていたクラス構造やプライベートフィールドが、クライアント側に透けて見えるようになったと捉えられる。ただし、これはReact2ShellのようなRCE脆弱性とは性質が異なり、内部APIと公開APIの境界を明確化するための設計改善に近い。

作成日:2025-12-14

AkiraAkira

Nuxt / SvelteKit

Nuxt v3.4以降とSvelteKitでは renderJsonPayloads におけるサーバーからクライアントへのデータ転送に、Rich Harris氏が開発した devalue ライブラリを使用している。devalue は JSON.stringify よりも高速で、かつ循環参照や特殊な型(BigInt, RegExp, Date等)を扱えることが強みらしい。

devalueに起因するPrototype Pollution

2025-08-26に CVE-2025-57820 が報告された。

この脆弱性は、 devalue.parse 関数が入力文字列を解析してオブジェクトを復元する際、キー(プロパティ名)に対する検証が不十分であったことが原因だったとされている。

例えば、攻撃者がシリアライズされた文字列内に __proto__ プロパティを含めるだけで、オブジェクトのプロトタイプを意図的に操作できたようだ。

https://github.com/sveltejs/devalue/security/advisories/GHSA-vj54-72f3-p5jv

アプリへの影響としては、サーバーサイドのバリデーションロジックが Array.prototype のメソッドに依存している場合、そのロジックをバイパスされたり、アプリケーション全体をクラッシュさせたり(DoS)することが可能になっていたようだ。

この脆弱性はdevalue v5.3.2で修正されており、最新のNuxt(v3.20.2, v4.2.2)およびSvelteKit(v2.49.2以降)では影響を受けない。ただし、プロジェクトの依存関係(package-lock.json等)が古く、脆弱なバージョンのdevalue(< 5.3.2)に固定されている場合は依然としてリスクがあるため、npm auditnpx nuxi upgrade による更新が必要となる。

Nuxt Custom Payload Reviver

Nuxtでは標準でサポートされていない型をシリアライズするために、definePayloadReducer(シリアライズ用)と definePayloadReviver(デシリアライズ用)というプラグインフックがある。ここはReact2Shellと同じ匂いがする部分ではあり、以下のIssueではパフォーマンスを優先してバリデーション等を省略するなどの方法を取るかどうか、みたいな話も出ている。

https://github.com/nuxt/nuxt/issues/28766

作成日:2025-12-14

AkiraAkira

SolidStart

SolidStartは、データシリアライズに seroval ライブラリを採用している。Serovalは "Stringify JS Values" を謳い、Promise、Web Streams、そしてクロージャを含むJavaScriptのほぼ全ての値をシリアライズ可能にする強力なライブラリとなっている。

ただ、強力な機能の裏にはやはりトレードオフがあって、serovalは eval による動的評価を行っている。

eval 使用の是非

SolidStartの開発チームの議論によると、JSONベースのアプローチと比較して、JavaScriptコードとしてシリアライズし、それを eval で実行して復元する方が、パフォーマンスとペイロードサイズの面で圧倒的に有利であるとされている。特に、Fine-grained Reactivityを持つSolidJSのシグナルやステートを効率的に転送するためには、このアプローチが不可欠であるとの判断がなされている。

https://github.com/solidjs/solid-start/issues/1825

一方で、この設計はセキュリティのベストプラクティスである Content Security Policy (CSP) と真っ向から衝突している。厳格なCSPでは unsafe-eval の使用を禁止するが、SolidStartアプリケーションを動作させるためには、多くの場合 script-src 'unsafe-eval' を許可せざるを得ないとの指摘もされている。

XSSが発生した場合、攻撃者は eval が許可された環境を利用して任意のコードを実行できる可能性がある。これはパフォーマンスと柔軟性を重視するSolidStartの設計思想によるトレードオフと言える。

Next.jsなど他のフレームワークがクロージャのシリアライズに対してより保守的であるのに対し、SolidStartは開発者に強力な機能を提供する代わりに、慎重な運用を求める方針を採っているようだ。

use server によるServer Functions

SolidStartの use server 関数は、クライアントコード内に記述されていてもサーバー上でのみ実行される。これはNext.jsのものと同じような機能と捉えて良さそう。

https://docs.solidjs.com/solid-start/reference/server/use-server

ここで注意が必要なのが「変数のキャプチャ(Closure Capture)」の扱いについて。

// 注意が必要な実装パターンの例
export default function MyComponent() {
  const apiKey = process.env.SECRET_API_KEY; // サーバー環境変数

  const serverAction = async () => {
    "use server";
    console.log(apiKey); // この変数はどう扱われるか?
  };
  return <button onClick={serverAction}>Log Key</button>;
}

上記のコードは例として書いたもの。 serverAction はサーバー関数として定義されているが、外側のスコープにある apiKey を参照している。シリアライザがこの関数をクライアントから呼び出し可能なRPCとして処理する際、キャプチャされた変数 apiKey をどのように扱うかが問題となる。

serobvalはクロージャ環境を含めてシリアライズできる強力な機能を持つため、関数の実行コンテキストを維持するために apiKey の値をシリアライズしてしまう可能性がある。これはフレームワークのバグではなく、強力なシリアライズ機能に伴う仕様上の特性であり、開発者が慎重に扱う必要がある領域となる。

SolidStartはコンパイル時にこれを除去しようとするが、開発者が意図せずクライアントコンポーネントとサーバー関数の境界を曖昧にした場合、情報漏洩が発生する事例が報告されている。

https://www.answeroverflow.com/m/1262631377694756947

作成日:2025-12-15

AkiraAkira

Qwik City

Qwikは「Resumability(再開可能性)」を掲げ、JSの実行を極限まで遅延させるフレームワーク。これを実現するために、QRL (Qwik URL) という独自のURLスキームを使用し、関数やステートをHTML内の属性(q:obj, q:func)にシリアライズして埋め込む。

Qwikは色々調べたものがscrapにあるので、詳細は https://zenn.dev/connect0459/scraps/24bda40cc36afe を参照。

シリアライズの対象

Qwik Cityは フォームアクション専用の routeAction$ と汎用RPCとしての server$() を提供し、サーバー上で実行される関数を定義できる。これはクライアントとサーバー間の型安全なRPC(Remote Procedure Call)メカニズムとして機能する。

server$() のRPC呼び出しでは、プリミティブ、オブジェクト、Promise、JSX Nodeなどのデータ型がシリアライズ・デシリアライズされる。

標準的な JSON.stringify() / JSON.parse() では構造上の制限から、FunctionやPromiseなどの実行可能型をシリアライズできないため、Qwikは独自の拡張シリアライザを実装している。

拡張シリアライズに起因するRCE脆弱性(修正済み)

Qwikはバージョン0.21.0より前に、この拡張シリアライズメカニズムに起因するRCE脆弱性(CVE-2023-1283)が存在した。

これはReact2Shellとかなり類似した事例で、二年も前に通っていたのか、と言う感じ。

脆弱性の詳細

根本原因は PureFunctionSerializer 機能にあった。この機能はDateやRegexといった型に加えて、Functionオブジェクトのシリアライズおよびデシリアライズを許可していた。

以下の手順を踏むことで、RCEを引き起こせたとされている。

  1. 任意のJavaScriptコードを含むペイロードをクライアントから送信
  2. サーバー側(Node.js環境)でデシリアライズプロセスが実行される
  3. 悪意のあるペイロードがコードとして評価される
  4. 認証不要のリモートコード実行が発生

Qwikチームは問題の根源である PureFunctionSerializer をシリアライザから削除することで対処した(0.21.0で修正)。これにより、Functionオブジェクトをネットワーク経由で転送・復元するリスクは排除されている。現在のQwik(v1.x系)ではこの機能は存在しないため、この脆弱性の影響を受けることはない。

無効なQRL送信によるサーバークラッシュ(修正済み)

2025年12月でより重要なのは、CVE-2025-53620 に報告された脆弱性。これは、無効なQRL(存在しないシンボルや不正なフォーマット)をサーバーに送信することで、未処理の例外によりNode.jsプロセスをクラッシュさせることができるDoS脆弱性とされている。

https://github.com/QwikDev/qwik/security/advisories/GHSA-qr9h-j6xg-2j72

React2ShellのようなRCEほどのインパクトはないものの、悪意のあるリクエストを繰り返し送信することでサーバーを永続的にダウンさせることが可能であったため、可用性を損なう重大な欠陥。この問題は qwik-city 1.13.0で修正されているため、Qwikユーザーはv1.13.0以降への即時アップデートが推奨されている。

作成日:2025-12-15

AkiraAkira

Astro

Astroは、v4.15で導入された Astro Actions において、組み込みのZodによるバリデーションを強制するアプローチをとっている。

https://docs.astro.build/ja/guides/actions/

Astro ActionsのAPI体系

Astro Actionsでは、アクションを定義する際に input プロパティとしてZodスキーマを指定する。

export const server = {
  getGreeting: defineAction({
    input: z.object({ name: z.string() }), // 入力を厳密に定義
    handler: async (input, context) => {...}
  })
}

この制約を課すことで、React2Shellのような「構造化されていないオブジェクトのデシリアライズ攻撃」に対しては非常に強力な防御となっているように見える。

Zodによる入力検証

Zodは定義されていないプロパティを削除(Strip)するか、エラーとして弾く。これにより、__proto__constructor といった攻撃用のプロパティがハンドラに到達する前に遮断される。また、入力が期待通りの型(String, Number等)であることをランタイムで保証するため、Duck Typingを悪用した攻撃が成立しにくい。

https://github.com/withastro/roadmap/issues/898

ただし、Astroも戻り値(サーバーからクライアントへのレスポンス)のシリアライズには devalue を使用していることが確認できた。NuxtやSvelteKitと同様に、devalue自体の脆弱性の影響は受ける可能性がある点には注意が必要かもしれない。

https://docs.astro.build/en/reference/modules/astro-actions/

ちなみに、Qwikの routeAction$ も同様に組み込みのZodによるバリデーションをサポートしている。

https://qwik.dev/docs/action/#validation-and-type-safety

作成日:2025-12-15

AkiraAkira

Hono / HonoX

HonoでJSXを書く場合、 HonoX になるのか。見ていく。

シリアライズ機構

Honoは標準の JSON.stringify()fetch() APIに依存することで、React2Shellのような「独自デシリアライズ機構に起因するRCEリスク」は回避できていると思われる。

HonoX Islands

HonoXは、Islandsアーキテクチャにおいてサーバーからクライアントへpropsを転送する際、カスタムエレメント(<honox-island>)の data-serialized-props 属性にJSON文字列を直接埋め込む方式を採用している。

<honox-island data-serialized-props={JSON.stringify(restProps)}>

Date/Map/Set/Functionなどの実行可能なオブジェクトは標準の JSON.stringify() では転送されない。ReactやQwikのような複雑なシリアライズ層を持たないため、攻撃リスクは小さい。

hono/client

HonoのRPC実装として提供されている hono/client は、JavaScriptの Proxy オブジェクトを活用した動的HTTPリクエストビルダーとして実装されている。サーバー側の型定義(AppType)をクライアント側でインポートすることで、TypeScriptレベルでの型安全性を提供する。

ただ、型定義はあくまで型なので、Zodによるバリデーションを挟まないと意図しない値が入っても気付けないのでは?ということも思った。

app.post('/update-role', async (c) => {
  const body = await c.req.json()
  // 型定義上は body.role は 'user' | 'admin' かもしれないが、
  // ランタイムでは任意の文字列が入りうる
  await db.updateUser(body.id, body.role) // ← バリデーションなし
})

Astroのようにバリデーションを強制する仕組みがなさそうなので、シリアライズされたデータの検証は開発者の責任となる。サーバー側で @hono/zod-validator などのミドルウェアを使用してランタイムバリデーションを行って検証した方が安全か。

作成日:2025-12-15

AkiraAkira

Angular

Angularあんまり知らないんだよなと思いつつ、学習を兼ねて調査。

TransferState APIの仕組み

Angular v16以降では @angular/ssr として統合されたSSR機能において、サーバーからクライアントへのデータ転送に TransferState API を使用している。

TransferState は Map<StateKey<T>, T> 形式のKVStoreで、サーバーサイドでAPIから取得したデータを保存し、 renderApplication の完了時に <script id="ng-state" type="application/json"> タグ内にシリアライズして出力するらしい。クライアント側ではブートストラップ時にこのスクリプトタグを読み取り、TransferStateストアを初期化することでサーバーとクライアント間のデータ受け渡しを実現している。

Angularに詳しくないので二次情報になるが、以下のようにJSON形式でデータが埋め込まれるようだ。

<script id="ng-state" type="application/json">
{
  "__nghData__": [
    { "c": { "0": {} } }
  ],
  "YOUR_DATA_KEY": {
    "id": 123,
    "title": "Angular SSR Analysis",
    "createdAt": "2025-12-16T10:00:00.000Z"
  }
}
</script>

https://stackoverflow.com/questions/77641999/prerendering-with-angular-17-and-viewing-prerendered-results

Angularのドキュメントを見ると、TransferStateのシリアライズ・デシリアライズには JSON.stringify / JSON.parse を使用していることが明記されている(TransferState • Angular)。

"The values in the store are serialized/deserialized using JSON.stringify/JSON.parse. So only boolean, number, string, null and non-class objects will be serialized and deserialized in a non-lossy manner."

この設計により、クラスインスタンスのメソッドは失われてプレーンなオブジェクトになる。開発者にとっては若干不便に感じる部分だが、この制約によってReact2ShellのようなRCE脆弱性からの防御壁になっているように思える。

メタフレームワークとしてのAnalogJS

AnalogJS はAngularのメタフレームワークで、Next.jsやNuxtのようなファイルベースルーティングやサーバーサイドのデータフェッチ機能を提供している。

サーバーサイドのデータフェッチ

AnalogJSも内部的にはAngularの標準的なメカニズム(JSONシリアライズ)に準拠しているようで、Nuxtのように devalue を使用したリッチな型復元機能はデフォルトでは提供していない。 .server.ts から Date オブジェクトを返すと、クライアント側では文字列として受け取ることになる。

// index.server.ts  
import { PageServerLoad } from '@analogjs/router';

export const load = async ({ fetch }: PageServerLoad) => {  
  const data = await fetch('/api/posts');  
  return { posts: await data.json() };  
};

この load 関数はサーバーでのみ実行され、その戻り値はクライアント側のコンポーネントに injectLoad() を通じて渡されるようだ。

// src/app/pages/index.page.ts
import { Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { injectLoad } from '@analogjs/router';

import { load } from './index.server'; // not included in client build

@Component({
  standalone: true,
  template: `
    <h2>Home</h2>

    Loaded: {{ data().loaded }}
  `,
})
export default class BlogComponent {
  data = toSignal(injectLoad<typeof load>(), { requireSync: true });
}

https://analogjs.org/docs/features/data-fetching/server-side-data-fetching

load関数の仕組み

メモ書きとして。

  1. .server.ts のload関数がサーバーサイドで実行される

    https://github.com/analogjs/analog/blob/5d1475a7d7c8de3c0aed6fe76386d1456bb67331/packages/vite-plugin-nitro/src/lib/plugins/page-endpoints.ts#L53-L66

  2. 戻り値はNitro/H3のHTTPレスポンスとして返される

  3. クライアント側では HttpClient.get() でこのエンドポイントを呼び出す

    https://github.com/analogjs/analog/blob/5d1475a7d7c8de3c0aed6fe76386d1456bb67331/packages/router/src/lib/route-config.ts#L51

  4. H3はデフォルトで JSON.stringify() を使用してレスポンスをシリアライズ

Server ActionsとRPC

Server Actionsにおいても、クライアントからサーバーへのデータ送信は標準的な FormData または JSONリクエスト として行われる。

React Server Actionsのような「引数として渡されたJavaScriptオブジェクトを自動的にシリアライズして送信する」RPCスタイルではなく、開発者が明示的に readFormData 等を使ってパースする必要がある。

Actionsの結果をサーバーからクライアントに返すときも JSON.stringify で返していそう。

https://github.com/analogjs/analog/blob/5d1475a7d7c8de3c0aed6fe76386d1456bb67331/packages/router/server/actions/src/actions.ts#L12-L28

FormDataを扱う以上、攻撃者が「プロトタイプ汚染を引き起こすJSON」を送りつける余地はあるが、自動的なオブジェクト結合(Merge)を行わない限り単なる文字列として処理されるため、比較的安全と思われる。

作成日:2025-12-17