計算してみよう:私たちが ChatGPT アカウントを乗っ取った方法
公開された ChatGPT の共有リンクは、開いた人のログイン済みセッションで攻撃者の JavaScript を動かし、非公開の会話を読み出して外部へ送れてしまうものだった。
日本語
コピー

2026年10月、私たちは、公開されている通常のChatGPT共有リンクを、開いた人のログイン済みセッション内で攻撃者が仕込んだJavaScriptが動く仕組みに変える方法を発見した。
攻撃者は悪意ある会話を作り、ChatGPTの通常の共有機能で公開し、そのリンクを別のユーザーに送るだけでよい。被害者が共有を開くと、ペイロードはchatgpt.comとして実行され、被害者の認証済みWebセッションにアクセスできた。
このコードが被害者の会話一覧を列挙し、攻撃者に一度も共有されていない非公開の会話から内容を取得し、その会話のデータを外部サーバーへ送信できることを確認した。ペイロードはページを書き換えたりアラートを出すだけにとどまらず、被害者としてChatGPTの認証済みAPIを直接呼び出せた。要するに、被害者のChatGPTアカウントを乗っ取れたということだ。
被害者はAppをインストールする必要も、Projectに参加する必要も、コードを貼り付ける必要も、開発者ツールを開く必要もなかった。共有リンクを開くこと、それだけが条件だった。
OpenAIは10月26日にこの問題を修正し、報告をP1に分類した。
発端は、単体では無害に見えた2つの機能だった。グラフとHTML Previewである。

技術的な解説
すべてはグラフとPreviewから始まった
この調査の出発点になった疑問は単純だった。Previewのコードを別のウェブサイトに閉じ込めたとき、そのコードは何と通信できるのか。
ChatGPTはPreview機能を使うと、HTMLのコードブロックを小さな動作するウェブページに変換できる。そのページには任意のコードが含まれ得るため、ChatGPTは当然の判断として、別のウェブアドレス上の制限付きフレーム内で実行する。PreviewはJavaScriptを実行できるが、ブラウザはこれを別のウェブサイトとして扱うため、周囲のサインイン済みChatGPTページを直接調べることはできない。
当初、この分離は意図どおりに機能しているように見えた。Preview内のコードは、ChatGPTのメインページであるparent.documentを読めなかった。話はそこで終わるはずだった。
この会話には通常のインタラクティブなグラフも含まれていた。裏側では、そのグラフがMathLiveという数学ライブラリを読み込んでいた。読み込みはグラフの描画だけにとどまらず、MathLiveのオンスクリーンキーボードに関するメッセージを受け取るリスナーをChatGPTのメインページに設置していた。
ブラウザはこの用途のAPIをpostMessageと呼ぶ。ブラウザが互いのページの読み取りを禁じているフレーム同士でも、情報をやり取りする正規の手段だ。郵便受けの差し込み口を思い浮かべればいい。制限されたPreviewはメインページの中に入り込めないが、差し込み口からメッセージを落とすことはできる。
これが安全なのは、受け取り側が送信者を確認した場合に限る。
MathLiveは、今回注目した設定についてこの確認を行っていなかった。リスナーは仮想キーボードのレイアウトを変更するメッセージを受け入れ、そのレイアウトにはHTMLを含められた。受信側を設置したのはグラフで、信頼できない送信者を用意したのはPreviewだった。無関係な2つの機能が、偶然にもサンドボックスを越える経路を作ってしまった。
通るはずのなかったメッセージ
Previewは2つのメッセージを送った。1つ目は仮想キーボードのレイアウトをこちらのHTMLに置き換える。2つ目はMathLiveにキーボードの表示を指示し、新しいレイアウトをレンダリングさせる:
top.postMessage({
type: "mathlive#virtual-keyboard-message",
action: "update-setting",
layouts: [{
label: "Compatibility",
layers: [{ id: "compatibility", markup }]
}]
}, "*");
top.postMessage({
type: "mathlive#virtual-keyboard-message",
action: "show",
animate: false
}, "*");
MathLiveは公開されたメッセージ種別を認識したが、最上位のコントローラはオリジンポリシーnoneを使っており、任意のウェブアドレスからのメッセージを受け入れていた。また、update-settingメッセージが事前に承認されたウィンドウから来たものであることも要求していなかった。リスナーに到達でき、メッセージの形式を知っている者なら誰でも、レイアウトを置き換えられた。
キーボードが開くと、MathLive は渡されたレイアウトを HTML 文字列に結合し、実際の ChatGPT ページの innerHTML に代入した。createHTML というヘルパーは、一見すると値をサニタイズしてくれそうに見えた。しかし実際には何もせず、文字列を信頼済みとしてマークし、こちらの HTML をそのまま通していた。
これは心躍る話だが、まだ JavaScript の実行には至っていない。innerHTML 経由で挿入された script タグは自動では実行されない。
次の手は srcdoc を使った iframe だ。埋め込みフレームが HTML を属性として直接持てるブラウザの機能で、次のように書く。
<iframe srcdoc="..."></iframe>
MathLive はこのフレームを sandbox 属性なしで ChatGPT のメインドキュメントに挿入した。ここから 2 つの都合のよい性質が得られる。1 つ目は、srcdoc によって外部スクリプトを実行できる新しいドキュメントが作られること。2 つ目は、フレームを ChatGPT のメインページが生成したため、ブラウザがこれに https://chatgpt.com の Web アイデンティティを与えることだ。つまり新しいフレーム内のコードは親ページにアクセスできる。
YouTube の JSONP で CSP を回避する
ここで ChatGPT の Content Security Policy(CSP)に再び阻まれた。CSP はページが読み込めるスクリプトを制御するもので、ポリシーはインライン JavaScript をきちんとブロックした。ただし YouTube からのスクリプトは許可していた。
YouTube の oEmbed エンドポイントは JSONP 形式のコールバックパラメータに対応していた。JSONP のレスポンスは通常の JSON ではなく JavaScript だ。このエンドポイントは不正なコールバックを拒否しようとしたが、検証エラーを返す前にコールバックをレスポンスへ埋め込んでいた。
https://www.youtube.com/oembed?callback=globalThis.__marker=31337;void
レスポンスの冒頭はこうなる。
// API callback
globalThis.__marker=31337;void({
"error": {
"code": 400,
"message": "Invalid JSONP callback name: ..."
}
});
コールバックの末尾を ;void にすると、YouTube が後ろに付けるオブジェクトは無害になる。YouTube はすでに ChatGPT の CSP で許可されていたので、ブラウザはこのレスポンスをスクリプトとして読み込み、新しいフレーム内でこちらのコールバックを実行した。
これで chatgpt.com として任意の JavaScript が動く状態になった。元の Preview は今も別の Web アドレスに隔離されたままだ。メインページ内の信頼されたコンポーネントを説得して、サンドボックスなしの新しいドキュメントを作らせることで、境界を越えたわけだ。
実験室のバグを 1 つの公開リンクに変える
ここまでは有用なサンドボックス脱出だが、最初に動いた版は現実的な攻撃としては説得力に欠けていた。送信元が研究者の管理する ChatGPT App だったからだ。被害者はまずその App を接続するか呼び出す必要があった。
次に、ネイティブのHTML添付ファイル経由でペイロードを届ける方法を試した。ChatGPTは添付ファイルを同じサンドボックス化されたPreview内で開くため、悪意あるMathLiveメッセージを送信できた。
しかし、会話を公開共有としてエクスポートすると、添付ファイルは残ったものの、先ほどのグラフは残らなかった。グラフは不可欠だった。MathLiveを読み込み、脆弱なメッセージリスナーをメインページに仕込むのはグラフだからだ。グラフがなければ、添付ファイルはメッセージを送信できても、それを受信して処理するものが何もない。
突破口は、両方の要素を1つの普通の公開会話に直接入れることだった。
- 本物のグラフがメインページでMathLiveを初期化する。
- その後にアシスタントが生成したHTML Previewが2つのメッセージを送信する。
出来上がった/share/リンクは、別のユーザーに対して完全な攻撃チェーンを保持していた。攻撃者は、通常のChatGPT共有会話を送るのと同じ方法、つまりメール、チャット、サポートチケット、ソーシャル投稿でこのリンクを届けられる。被害者は何もインストールする必要がなく、攻撃者のワークスペースに参加する必要もなく、攻撃者が制御するフローで認証する必要もない。攻撃者が共有したChatGPTのURLを訪問するだけでよい。
このインタラクションには2つのバージョンが確認できた。最も安定して再現できたのは、被害者が共有を開き、ChatGPTの通常のPreviewボタンを1回クリックするパターンだ。後にHTMLブロックをプレビュー可能としてマークし、ChatGPTが自動で開くようにした。このバージョンでは、リンクを開くことが被害者の唯一のクリックだった。自動で開く経路は別アカウントで3回再現し、ハードリフレッシュ後も含めて、約4.5秒後にアラートが表示された。
グラフとPreviewは独立して読み込まれるため、MathLiveの準備が整う前にペイロードがメッセージを送ってしまうことがあった。最終的なキャリアでは、同じ2つのメッセージを一定時間再試行することでこの競合に対処した。メインページ側のガードにより、プライベート会話での検証と外部へのリクエストは1回だけ実行される。

この時点で、公開共有はブラウザ上でchatgpt.comからのものと識別されるアラートを表示でき、メインページにバナーを追加することもできた。これでコードがどこで動いているかは証明できた。だが、なぜこのバグが重要なのかはまだ示せていない。
アラートだけでは足りなかった
ここまで来ると、アラートの表示はこのエクスプロイトでできる最も退屈なことだった。JavaScriptはchatgpt.comとして実行されており、通常のWebアプリケーションと同じ認証済みコンテキストでChatGPTとやり取りできる状態だった。
ペイロードはページがすでに持っているセッション情報を読み取り、それを使ってChatGPTの会話APIを呼び出した。まず被害者の最近の会話を列挙した。続いて、選んだ非公開の会話のメッセージデータを要求した:
GET /backend-api/conversations?...&limit=20&order=updated&offset=0
GET /backend-api/conversations/<conversation-id>?num_turns=2
これは画面にたまたま表示されていたテキストをスクレイピングしたのではなく、認証済みAPIへの直接アクセスだ。対象の会話は悪意ある共有とは無関係で、攻撃者と共有されたことは一度もない。
レスポンスが注入されたJavaScriptから参照できるようになれば、外部への送信は簡単だ。ペイロードは、こちらが管理するサーバーへの普通の画像リクエストを生成した。
外部へ送ったのはレスポンスのごく一部だけに意図的にとどめた。注入されたコードは認証済みの会話レスポンスをすでに受け取っており、その内容を読めていた。つまり、この脆弱性はその値だけに限られたものではなかった。

結果として得られたのは、ユーザーをまたいだ実用的なデータ窃取のプリミティブだった。悪意ある公開会話が、別ユーザーがサインイン中のChatGPTセッション内で実行され、非公開の会話を列挙し、非公開メッセージの内容を取得し、選んだデータを攻撃者の管理するサーバーへ送信できた。
コードはWebアプリケーションの認証済みアクセス権で動いていたため、その到達範囲は私たちの実証で使った会話エンドポイントに限られない。ChatGPTの認証済みフロントエンドAPIを通じて露出している他のデータや操作も、同じセキュリティ境界の内側にあった。要するに、被害者のChatGPTアカウントになりすまして任意の操作を実行できた。
何が起きたか、OpenAIは何を直したか
Previewのサンドボックス自体は機能していた。Preview内のコードはChatGPTのページを直接読めなかった。問題は、メインページの別のコンポーネントがその指示を素直に受け取ったことにある。
三つの設計判断が、この誤りを完全なエクスプロイトに変えた。
- MathLiveが、どのフレームから送られたかを検証せずに状態を変えるメッセージを受け入れた。
- メッセージ形式が、キーボードレイアウトのデータとして生のHTMLを渡せるようになっていた。
- そのHTMLが、
chatgpt.comの同一性を継承したサンドボックス外のドキュメントを生成できた。
YouTubeへのリクエストはCSPを回避するための最後の抜け道であって、根本原因ではない。あのエンドポイントだけを塞いでも、JavaScriptへの経路が一つ消えるだけで、信頼できないPreviewがChatGPTにHTMLを挿入できる状態は残っていた。
OpenAIはJavaScriptの実行と非公開会話の取得の両方を再現した。修正はChatGPTのWebフロントエンドの2バージョンにわたって展開された。展開中、CSPの変更で元の攻撃チェーンの一部が塞がる一方、片方のバージョンはまだ更新が必要だった。10月26日、OpenAIは両バージョンが報告で使われたクロスフレームのMathLiveメッセージを拒否するようになったことを確認し、問題を解決済みとした。
防御側の教訓はごく単純だ。
- メッセージの受信側は、送信元のWebアドレスと、許可を与えたウィンドウそのものの両方を検証すべきだ。
- ウィンドウをまたぐ設定に生のHTMLを含めるべきではない。
- 内容が信頼できないフレームから影響を受けうるなら、動的に生成するドキュメントはサンドボックス内に留めるべきだ。
- CSPは最後の保険であって、攻撃者が制御するHTMLがメインサイトに届いた後の唯一の障壁にしてはいけない。
このバグが興味深いのは、個々の機能を眺める限りどれも妥当に見える点だ。プレビューは分離されていた。グラフは数式キーボードを読み込めた。フレームはメッセージをやり取りできた。CSPは大手メディアプロバイダーを許可していた。公開会話は共有できた。脆弱性は、それらすべてが1つのページに揃ったときにだけ現れた。
タイムライン
| 日付 | 出来事 |
|---|---|
| 2026年10月13日 | MathLiveのメッセージからHTMLへの経路を、ChatGPTの本番コードに対して確認。 |
| 10月14日 | 通常のUIを通して一連の攻撃を再現し、修正済みのアラートを任意のJavaScriptに格上げ。 |
| 10月16日 | 別アカウント間および別ワークスペース間での公開共有の配信を、Previewの1クリックで確認。 |
| 10月17日 | 共有ナビゲーションからの自動Previewを確認し、ハードリフレッシュ後にも再現。 |
| 10月18日 | OpenAIのバグバウンティプログラムに報告書を提出。 |
| 10月19日 | 非公開チャットの情報漏えいを制御下で実証した証拠を追加。 |
| 10月21日 | OpenAIが問題を再現。深刻度は当初のP2トリアージからP1に引き上げられた。 |
| 10月22日 | OpenAIが一方のWebフロントエンドを修正したと報告。もう一方はまだ更新中だった。 |
| 10月23日 | CSPの変更で攻撃チェーンの一部が遮断されたが、フロントエンドの更新が1件残っていた。 |
| 10月26日 | OpenAIが両方のフロントエンドで報告されたメッセージを拒否することを確認し、問題を解決。報奨金は合計$5,000。 |
技術的な補足
上の本文に攻撃チェーンの全体を書いた。以下は、本番コードとペイロードの変換をより詳しく追いたい読者向けの詳細だ。
難読化を解除した関連する制御フローは、次と等価だった。
window.addEventListener("message", (event) => {
if (event.data?.type !== "mathlive#virtual-keyboard-message") return;
if (!validateOrigin(event.origin, this.originValidator)) return;
this.handleMessage(event.data, event.source);
});
if (message.action === "update-setting" && message.layouts) {
this.layouts = message.layouts;
}
レイアウトの正規化ではlayouts[].layers[].markupが保持された。キーボードを表示すると、最終的には次と等価な処理が実行された。
keyboardContainer.innerHTML = MathfieldElement.createHTML(keyboardHTML);
createHTMLはサニタイザーではなく、恒等変換またはTrusted Typesのラッパーとして機能した。Trusted TypesはHTMLシンクへの代入者を制限できるが、恒等ポリシーでは攻撃者が制御する文字列が安全になるわけではない。
パーサーとCSPの遷移。 innerHTMLの操作でiframeを作成できたが、その方法で直接挿入したスクリプトは不活性のままだった。サンドボックス化されていないsrcdocが新しいHTMLドキュメントのパースを引き起こし、埋め込み元のChatGPTオリジンを継承した。そのドキュメントはテスト中に取得した有効なCSPも継承しており、インラインコードはブロックしたが、script-src-elem内のwww.youtube.comは許可していた。
oEmbed レスポンスは Content-Type: text/javascript; charset=UTF-8 と X-Content-Type-Options: nosniff を伴って HTTP 200 を返し、Cross-Origin-Resource-Policy ヘッダーは確認されなかった。Google はコールバック内の山括弧を書き換えたため、ペイロードはアロー関数ではなく従来の function 構文を使っていた。プログラムを ;void で終了させると、エンドポイントが付加したエラーオブジェクトが消費された。
非公開会話の実証では、関数を toString() でソースに変換し、二重引用符を単一引用符に置き換え、空白を詰めた。次に URLSearchParams がそれをコールバックへエンコードし、スクリプト URL は <script src> 内で HTML エスケープされ、その要素全体がさらに iframe の srcdoc 属性へエスケープされた。最終的なコールバックは 5,533 文字、YouTube URL は 7,309 文字、script 要素は 7,337 文字、最終的な iframe のマークアップは 7,390 文字だった。
認証済み読み取り。 同一オリジンのフレームは親ドキュメントから #client-bootstrap をパースし、セッションのアクセストークンとアカウント ID を取り出した。両方が存在する場合、トークン内のアカウント ID がセッションと一致することを要求した。メモリ上のヘッダーは次のとおり:
Authorization: Bearer <victim token>
ChatGPT-Account-ID: <victim account>
Accept: application/json
主要なリクエストは次のとおり:
GET /backend-api/conversations?exclude_conversation_origin=tpp&expand=false&is_archived=false&is_starred=false&limit=20&order=updated&offset=0
GET /backend-api/conversations/<id>?num_turns=2&include_has_versions=true
詳細リクエストが 403、404、405 を返したときに限り、コードはレガシーの /backend-api/conversation/<id>?include_full_conversation=true ルートを試した。検証が送信したのはレスポンスから選んだ小さな値だけだった。外部画像をリクエストする前に、トークン、アカウント、bootstrap、セッション、リクエストヘッダーへの参照をクリアした。
要点
- 分離の強さは、それをまたぐすべての橋の強さで決まる。サンドボックス化されたフレームでも、特権を持つリスナーが誤った送信元からのメッセージを信頼すれば、親を侵害できる。
postMessageのレシーバーは、状態を変える指示を受け入れる前にevent.originとevent.sourceの両方を認証しなければならない。- HTML インジェクションは、新しく生成されたサンドボックスなしのドキュメントと、許可されたサードパーティのスクリプトエンドポイントを経由して JavaScript 実行になり得る。CSP は多層防御として有用だが、安全でないレンダリング経路を安全にはできない。
- 同一オリジンの JavaScript の影響は、被害者の認証済みコンテキストで評価する必要がある。コードが
chatgpt.comとして動き出せば、アラートを表示するだけでなく、通常のフロントエンドが扱えるのと同じ非公開データやアカウント操作に到達できた。 - 機能をまたいだテストが重要だ。この連鎖は、公開共有、Preview、MathLive、フレーム生成、CSP の間の継ぎ目に存在していたのであって、どれか一つの機能を単独で見ても分からない。
ここから得られるより広い教訓は、セキュリティ境界は個々のコンポーネントの内側だけでなく、製品全体にわたって保たれなければならないということだ。公開された面であれ共有可能な面であれ、攻撃者が制御する入力として扱うべきであり、フレーム間のメッセージはすべて認証し、信頼できないマークアップはレンダリング経路の全体にわたってサンドボックス化したままにすべきである。