🤔

RSCの脆弱性から学ぶ、JavaScriptの「プロトタイプ」の仕組み

に公開

ヤプリ&フラー 合同アドベントカレンダー Advent Calendar 2025 10日目の記事です。

先日、Next.js (App Router) などで使用される RSC (React Server Components) で発生した脆弱性が話題になりました。
この脆弱性が突いたのは、JavaScriptという言語が持つ 「プロトタイプ(Prototype)」 という仕組みでした。

フロントエンドの開発をしていてJavaScriptのプロトタイプを意識することは多くなく、私自身正しく理解できていないと感じました。
そこで今回は、なぜこの脆弱性が起きてしまったのかを深掘りしながら、JavaScriptの「プロトタイプ」と、それを悪用した攻撃手法「プロトタイプ汚染」について順を追って見ていきます。

1. 今回の脆弱性の正体

まず簡単に事象を整理します。

  • 脆弱性ID: CVE-2025-66478 (Next.js) / CVE-2025-55182 (React)
  • 深刻度: Critical (CVSS 10.0)
  • 概要: Next.js (App Router) が使用している React Server Components (RSC) の通信プロトコルにおいて、外部から送られてきたデータをサーバーが復元(デシリアライズ)する処理に欠陥があった。

攻撃者は、特殊なデータ構造を送りつけることで、サーバー内のオブジェクト操作を不正に操り、最終的に任意のコードを実行 (RCE) させることができました。
この脆弱性は、その深刻さと性質から 通称 『React2Shell』 と呼ばれているようです。

この攻撃を成立させた鍵が 「プロトタイプ汚染 (Prototype Pollution)」 です。

2. そもそも「プロトタイプ」とは何か?

JavaScriptは「プロトタイプベース」の言語です[1]。クラスベースの言語とは異なり、オブジェクトが別のオブジェクトを「親」として持ち、その性質を受け継ぐ(継承する)仕組みを持っています。

「親に借りる」仕組み (Prototype Chain)

JavaScriptのオブジェクトは、自分の持っていないプロパティやメソッドを呼び出されたとき、「自分の親(プロトタイプ)」 に探しに行きます。親も持っていなければ、さらにその親へ……と遡ります。これを プロトタイプチェーン と呼びます。

// 親オブジェクト(プロトタイプ)
const parent = {
  greet: () => console.log("親のメソッドです")
};

// 子オブジェクト
const child = {};

// 親子の縁を結ぶ(継承)
// ※モダンな書き方では __proto__ への直接代入は非推奨ですが、仕組みの理解のために使用します
child.__proto__ = parent;

// childは greet を持っていないが、親から借りて実行できる
child.greet(); // "親のメソッドです"

全ての始祖 Object.prototype

このチェーンを遡っていくと、最終的にたどり着くのが Object.prototype です。
私たちが普段のコーディングで扱う、以下のような JSONオブジェクト も、実はすべて生まれながらにして、この Object.prototype を親として持っています。

// ユーザー情報などの一般的なオブジェクト
const user = {
  id: 101,
  name: "Tanaka",
  role: "member"
};

だからこそ、自分で定義していないのに .toString().hasOwnProperty() などのメソッドが使えるようになっています。

もし、このObject.prototypeが書き換わったら?

しかし、この仕組みには危険な側面もあります。
プロトタイプチェーンの仕組み上、Object.prototypeに加えられた変更は、システム内の「すべてのオブジェクト」に即座に反映されてしまうのです。

実際にコードで見てみましょう。

// 1. 普通の空のオブジェクトを作る
const userA = {};
const userB = {};

// 2. 現時点では role(役割)は設定されていない
console.log("UserAの役割は?", userA.role); // undefined

// 3. 【攻撃】Object.prototypeを汚染する
// 攻撃者が脆弱性を突き、「全てのオブジェクトのデフォルトの役割は管理者(admin)である」
// というルールを注入したと仮定
Object.prototype.role = "admin";

// 4. 何もしていないはずの userA, userB が
console.log("UserAの役割は?", userA.role); // "admin"
console.log("UserBの役割は?", userB.role); // "admin"

// 5. これにより、権限チェックをすり抜けてしまう
if (userA.role === "admin") {
  console.log("UserAは管理者エリアへのアクセスに成功しました"); // 実行されてしまう
}

// 6. このあとに作られた別の userCもroleがadminになっている
const userC = {};
console.log("UserCの役割は?", userC.role); // "admin"

このように、たった1行 Object.prototype に変更を加えるだけで、 システム内に存在するありとあらゆるオブジェクトが「自分は管理者(admin)だ」と勘違いする状態になってしまいました。

これが 「プロトタイプ汚染」 の正体です。

3. なぜこれが脆弱性になるのか?

ここで問題になるのが、「もし、このObject.prototypeを外部から勝手に書き換えられたら?」 という点です。

もちろん、攻撃者はサーバーのソースコードを直接編集することはできません。
しかし、Webサーバーは常に外部(クライアント)からJSONなどのデータを受け取り、それを処理しています。

攻撃の仕組み:データの「取り込み」を悪用する

多くのアプリケーションには、外部から送られてきたJSONデータを、内部のオブジェクトに結合(マージ)したり、復元(デシリアライズ)したりする処理が存在します。

もし、この取り込み処理が「送られてきたキーと値を、何も疑わずにそのまま代入する」ように作られていたらどうなるでしょうか?

  1. 攻撃者の狙い: 「データの中に、普通のキーに見せかけて __proto__ というキーを混ぜて送ろう」
  2. サーバーの挙動: 「お、__proto__ というキーに値が入ってるな。じゃあ、オブジェクトの __proto__ プロパティにこの値を代入しておこう」
  3. 結果: 代入した瞬間に「プロトタイプ(親)」への書き込みが発生する。

こうしてプロトタイプ汚染が発生します。
Object.prototypeに加えられた変更は、即座にシステム内の「すべてのオブジェクト」に伝染します。

脆弱性の再現コード(イメージ)

今回の脆弱性では、サーバーがReactの通信データ(Flightペイロード)を解析する際、これと似たようなことが起き得る状態でした。

// 1. サーバー内の普通のユーザーデータ
const adminUser = { role: "admin" };
// 本来は role プロパティを持っていない(一般ユーザー)
const normalUser = {}; 

// 2. 攻撃者が送ってきた悪意あるデータ
// "role": "admin" を "__proto__" の中に隠して送る
const payload = JSON.parse('{ "__proto__": { "role": "admin" } }');
// ※注: 標準のJSON.parseはこの攻撃を防ぎますが、
// 独自の解析ロジック(Deep Mergeや今回のFlightプロトコルなど)では素通ししてしまうことがあります。

// 3. 脆弱な関数でマージしてしまう(ここで汚染発生)
// 関数が再帰的に処理する中で、obj['__proto__'] = {role: "admin"} を実行してしまう
vulnerableMerge({}, payload);

// 4. 結果:本来権限がないはずのユーザーが
// normalUser自身のプロパティではなく、汚染された親のプロパティを参照してしまう
console.log(normalUser.role); // "admin"

4. 実際の修正コード(React PR #35277より)

Reactで行われた実際の修正は、GitHubのプルリクエスト Fix security vulnerability in React Server Components (#35277) で確認できます。

その中から修正の核心部分である requireModule 関数(モジュール読み込み処理)の変更を見てみます。

修正内容:hasOwnProperty によるホワイトリスト検証

修正前は、外部から指定されたプロパティ名(metadata[NAME])を使って、そのままオブジェクトのプロパティを返していました。

修正後は、hasOwnProperty.call を使用したガード処理が追加されました。

実際の修正コード(抜粋):
packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js

export function requireModule<T>(metadata: ClientReference<T>): T {
  const moduleExports = parcelRequire(metadata[ID]);
  
  // プロトタイプチェーン上のプロパティではなく、
  // オブジェクト自身が直接持つプロパティのみを許可することで、
  // constructorなどの危険なプロパティへのアクセスを防ぐ
  if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
    return moduleExports[metadata[NAME]];
  }
  
  // 持っていない(または継承プロパティである)場合は undefined を返す
  return (undefined: any);
}

5. 対策と注意点

この脆弱性はNext.jsやReactだけの問題ではありません。JavaScriptを扱うエンジニア全員が意識すべきことがあります。

  1. スキーマバリデーションを徹底する(Zodなどの活用)
    • 外部から来た信頼できないデータはZodなどのスキーマライブラリを使ってバリデーション処理をすると良さそうです。
      「許可するキー」を定義(ホワイトリスト化)して、__proto__ のような予期しないキーが含まれていても、バリデーションの段階で除去することができます。
// zodを使ったスキーマ定義
const userScheme = z.object({
  id: z.number(),
  name: z.string(),
  role: z.enum(["member", "admin"]),
})
  1. 依存ライブラリの更新を習慣化する
    • 今回のように、アプリケーションコードに問題がなくても、利用しているフレームワーク(Next.js/React)に脆弱性があるケースは避けられません。
    • GitHub Dependabot、Renovate などのツールを活用し、依存関係の脆弱性を早期に検知・更新できる体制を整えておくことが、最終的な守りとなります。

まとめ

RSCの脆弱性からJavaScriptのプロトタイプについて調べてみました。
この脆弱性を通して、改めてプロトタイプの仕組みと危険性について理解を深めることができました。
この記事を読んでくださった方にも、その一助となれば幸いです。

参考リンク

脚注
  1. JavaScript | MDN ↩︎

Discussion