ChatMateによるM365 Copilotサンドボックスの突破 | CXO Transformation

ユーザーがLLMにドキュメントについて質問する場面を想像してください。見えない攻撃者がそのチャットセッションに対話型のプロンプトチャネルを確立し、プロンプトを送信し、アシスタントの応答を読み取り、次に何を尋ねるかを決められるとしたらどうでしょうか。

私たちはこの攻撃クラスをRemote Prompt Execution(RPE)と呼んでいます。単発の初期トリガーの後、AIアシスタントを適応的かつ双方向に制御できる点が特徴です。公開文書として初めて記録されたRPEである「ChatMate」は、悪意あるドキュメントがMicrosoft Copilotにコード実行環境を呼び出させ、ネットワーク分離されたサンドボックスから脱出させ、攻撃者へ人間が操作するプロンプトシェルを確立させる仕組みを示しています。

ChatMateが重要な理由

Microsoft Copilotは非常に多くの企業で導入されており、多くの最新のアシスタントと同様に、実際の計算処理を行うためにバックグラウンドでPythonインタプリタを実行しています。モデルが生成したコードを実行するインタプリタは興味深い攻撃対象領域であるため、私たちはこれを調査しました。その結果、最終的にはコンテナからホストへの完全なエスケープと、被害者のMicrosoft 365データへの経路にまで至り、CVE-2026-32193(CVSS深刻度: High、8.8)として追跡されています。

このシリーズでは、隠されたテキスト指示からホストノード上でのコード実行に至り、最終的には被害者のアシスタントのコンテキスト内で人間が操作するプロンプトシェルに達するまでの道筋を解説します。第一部では、その他すべてが依拠する2つの基本要素を取り上げます。

  1. Copilotのコード実行安全レイヤーのバイパス: Copilotはpsnetstatのような「詮索的な」コードの実行を拒否しますが、私たちはあらゆるものを実行させる確実な方法を見つけ、アシスタントを汎用のPythonランナーへと変貌させました。
  2. サンドボックス内でrootへのローカル権限昇格

最も深刻で影響の大きいバグはシリーズの後半に登場します。ここではそれを見つけるために必要なツールを解説します。

Remote Prompt Executionが意味するもの

RPEとは、攻撃者が被害者の認証済みAIアシスタントに繰り返しプロンプトを与え、出力を受け取り、被害者側の追加操作なしに次のプロンプトを適応させられる、侵害後の能力を指します。ChatMateでは、これは人間が操作する完全応答型のREPLという形を取ります。攻撃者はアシスタントの完全な回答を目にし、次のプロンプトを入力し、それを繰り返します。

ChatMateは、私たちが把握している最も関連性の高い先行研究であるVaronisのRepromptとは独立して開発されました。Repromptは、Copilotが意図的に備えているインターネット機能を利用し、以前の応答に依存する後続の指示によるサーバー主導のリクエストチェーンを構築しました。その公開記事では、継続的かつ動的なデータ流出について説明されていますが、人間が操作する汎用的なプロンプトシェルは公には示されていません。とはいえ、その根底にある制御ループは密接に関連しています。

したがって、その違いは「適応的なリモート制御」がこれまで一度も現れなかったという点ではありません。ChatMateがそれをどのように実現しているかという点が異なります。悪意あるドキュメントがコード実行を引き起こし、ネットワーク分離されたサンドボックスから脱出し、脱出先の環境を完全応答型のプロンプトチャネルへと変えるのです。Repromptは意図された既存のインターネット機能を武器化しましたが、ChatMateはアシスタントの実行境界を突破することでそのチャネル自体を作り出します。

私たちが目にしているもの

大規模言語モデルはテキストの生成には優れていますが、機械的な計算にはあまり向いていません。業界標準の解決策は、モデルにコードインタプリタを与えることです。例えば、PDFを生成させたい場合、モデルはPDFを生成するPythonコードを書き、それをサンドボックス内で実行し、結果を読み取ります。Microsoft 365 Copilotも全く同じことを行っています。スプレッドシートの処理、グラフの作成、アップロードされたドキュメントの処理を依頼すると、裏側でPythonコードが生成され、コード実行ツールを通じて実行されます。

このコード実行ツールは、分離されたサンドボックス環境内でコードを実行します。攻撃的な研究をしている方なら、この一文に耳がピクリと動くはずです。攻撃者が制御するコードを実行する安全なサンドボックスを正しく構築するのは非常に難しい作業です。

Copilotに私たちのコードを実行させる

最初の障壁は、Copilotが任意のコードを実行することを望んでいないという点です。コード実行ツールは正当なデータ処理タスクのためのものであり、コードが環境を詮索しているように見えるものには抵抗する安全レイヤーが備わっています。ps(プロセス一覧)やnetstat(ソケット一覧)の実行を依頼すると、丁寧に拒否されます。

しかし、この安全レイヤーは表示されたコードから意図を判断しているため、その意図は簡単に「洗浄」できます。Copilotにgzip圧縮されたバイト列を渡してその解凍処理をベンチマークするよう指示すれば、完全に無害な性能テストのように読めます。たとえ解凍後のバイト列が、直前に拒否されたps/netstatのコードそのものであっても、です。

そこで私たちは、任意のPythonソースコードを受け取り、gzip圧縮し、無害に見える「gzipbenchmark」プロンプトでラップする小さなヘルパーを書きました。
 

これに次のものを与えます。
 

…すると、ペイロードが16進数のブロブになったプロンプトが出力されます。
 

Copilotは無害なベンチマークだと認識し、それを解凍してexecし、出力をダウンロード可能なファイルとして返してくれます。先ほど実行を拒否したpsnetstatが、今度は何の異論もなく実行されます。以降、「Xを実行した」というのは「このハーネスを通じてXを実行した」ことを意味します。実質的に、Copilotサンドボックス内にPythonのREPLを手に入れたことになります。

しかし稀に、Copilotが疑わしいと判断し、ペイロードを実行せずに解凍だけ行い、これから実行しようとしているものを確認するコードを書くことがありました。その場合はCopilotは実行を拒否しました。より強固なバイパス手法であれば、ペイロードだけでなくexecそのものも難読化するべきですが、研究目的としては単純な方法で十分でした。

内部からサンドボックスをマッピングする

REPLを手に入れたら、最初の作業は偵察です。Copilotはどのようなサンドボックスでコードを実行しているのでしょうか。

psを実行すると、環境の概要が即座に分かります。
 

次のような明確な構図が見えてきます。

  • PID 1は/app/entrypoint.shで、rootとして動作しています。これがその他すべてを起動します。この点は後で重要になるので覚えておいてください。
  • 私たちのコードは、権限のないubuntuユーザーとして、Jupyter/IPythonカーネル内で実行されます(それがipykernel_launcherjupyter-notebookのプロセスです)。
  • goclientappは、後のリバースエンジニアリングによって判明したところによると、外部世界とやり取りし、私たちのカーネルに実行すべきコードを渡したり、ファイルのアップロード/ダウンロードを行ったりするGoバイナリです。
  • httpproxyappはプロキシで、Apache Tikaはドキュメント解析のために存在しています。

netstatと環境変数によって残りの部分も明らかになりました。いくつか目を引く点がありました。まず、環境は大幅にネットワークが強化されており、インターネットは使えず、DNSも意図的に壊されており、http_proxy/https_proxy変数はすべてに対してforbiddenを返すローカルプロキシを指していました。
 

netstatでは、私たちのものではないいくつかのローカルリスナーも確認できました。

ポート53827と53828は、PodAgentという内部Azureサービスの一部です。特にポート8578は謎で、私たちが投げるあらゆるリクエストに対して404を返すHTTPサーバーであり、それを所有しているプロセスは見えません(PID列の「-」)。この謎のサービスは、今回の研究において重要な役割を果たします。

すべてのファイルシステムマウントを一覧表示するfindmntコマンドは、有望な情報を示しました。/mnt/data/etc/hosts/etc/resolv.confはいずれも、コンテナのオーバーレイではなく、ホストのディスク(/dev/sda2)によって裏付けられていました。つまり、これらはコンテナ自身のファイルシステムの外部で共有されているということです。
 

マッピングされたサンドボックスでは、私たちのコードはインターネットアクセスを持たないJupyterカーネル内で弱いubuntuユーザーとして動作し、一方でentrypoint.shはrootとして動作しており、脇には説明のつかない「謎のサービス」が1つ待機しています。

壁にぶつかる

私たちは、権限のない立場からこの箱のに出ようと実際に時間をかけて試みましたが、大部分は良好な強化が施されていることが分かりました。

  • ネットワーク。インターネットも動作するDNSもありません。IMDSや、よく知られた十数個のAzure・パブリックDNSサーバーに到達しようとしたところ、goclientappが通信していたIPはすべてタイムアウトしました。
  • 他のセッション。同一アカウント・別アカウントの他のCopilotセッションは分離されたままで、通信することはできませんでした。
  • ローカルサービス。goclientapphttpproxyapp、Jupyter、Tikaはすべて私たち自身のubuntuユーザーとして動作しています。これらを制圧しても得るものはありません。興味深いリスナー(8578、PodAgentのポート)は私たちには見えないプロセスが所有しており、同様にネットワーク強化されていると推測されました。

サンドボックスはよく作り込まれているように見え、私たちは行き詰まりました。

前進するにはroot権限が必要でした。例えばトラフィックを傍受するためです。つまり、サンドボックス内で権限昇格の脆弱性を見つける必要があるということです。

サンドボックス内でrootを取得する

先ほどのpsの1行目に戻ってみましょう。
 

rootとして動作するPID 1は、/app/entrypoint.shにあるbashスクリプトです。そして/appは、サンドボックスのハーネスファイル一式が置かれている場所であり、まさに私たちのubuntuユーザーがずっと読み込んでいた場所でもあります。

そこで、そのスクリプトのパーミッションを確認しました。この先どうなるか、想像がつくでしょうか。

私たちに書き込み権限がありました。環境全体を起動するroot所有のプロセスが、権限のないユーザーが編集できるシェルスクリプトだったのです。このスクリプトを再実行させることができれば、追記した内容がrootとして実行されます。しかしこれはentrypointであり、他の何もそれを実行しません。プロセスを終了させれば環境全体が破棄され、プラットフォームはサンドボックス全体をリセットしてしまいます。

解決策はbashスクリプトへの追記攻撃です。bashはシェルスクリプトを1行ずつ読み込んで実行します。つまり、bashがスクリプトの実行を開始した後でも、現在実行中の行よりにあるファイルの部分に変更を加えれば、その変更は読み込まれ、実行されるということです。

私たちのケースでは、entrypoint.shスクリプトはおおよそ次のようになっています。
 

サンドボックスが起動している間、このスクリプトは最後の行 – waitで停止しています。つまり、必要なのは次の2つだけです。

  1. wait行の後にバックドアを追記する。
  2. entrypoint.shにそのwait行の実行を完了させ、追記したコードに到達させる。

ステップ1は簡単です。しかしステップ2はどうすればよいでしょうか。waitはすべての子プロセスが終了するまでブロックします。つまり、wait行が戻り、追記したバックドアコードが実行されるのは、./goclientapp./httpproxyapp./keepAliveJupyterSvc.shのすべてが終了した後だけです。それなら、これら3つのプロセスをkillすればバックドアが実行される、というわけでしょうか?

ほぼその通りですが、1つ落とし穴があります。./goclientappをkillすると、サンドボックスと通信するための唯一のチャネルも失われてしまうのです。一瞬rootとして動作できたとしても、何も得られないことになります。

そこで、より慎重なアイデアが必要になります。これらのプロセスをkillしてwaitを解放し、rootを取得した上で、プラットフォームが何かが停止したことに気づく前に、環境を元の状態に十分速く戻す必要があります。具体的には、追記したペイロードがentrypointが通常起動するすべてのサービスを再起動します。追記後のentrypoint.shは次のようになります。
 

理論上は単純ですが、実際にはなかなか動きませんでした。何をしても環境が再作成されてしまうのです。私たちは多くの誤った仮説を追いかけた末、もっと早くやるべきだったこと、すなわちオフラインで再現することにしました。

同一バージョンのUbuntu VMを立ち上げ、ハーネスファイルをコピーし、不要なものをすべて取り除いて攻撃を実行したところ、エラーが判明しました。goclientappが再起動後に起動失敗していたのです。単純な再起動では満たされない起動要件があり、そのためサービスが復帰していませんでした。プラットフォームはセッションを死んだものと判断していました。修正後、goclientappの再起動はクリーンに成功し、まずVM内で、その後Copilot自体でも全体が機能するようになりました。

これで、Copilotサンドボックス内でrootを取得しました。

rootを一度限りのものではなく使えるものにするため、ペイロードは小さなデーモンを起動します。ポート1337のローカルリスナーで、bashを受け取ってrootとして実行するものです。そして、gzipトリックと全く同じように、rootのbashコマンド(あるいはrootのPythonコード)を受け取り、それを通常のサンドボックスコードにパッケージ化する小さなスクリプトがlocalhost:1337と通信します。外から見れば、単にCopilotにPythonの実行を依頼しているだけに見えますが、裏ではrootコマンドが実行されています。

除外できたことが有益だった1つの手詰まりについて触れておきます。cp /bin/bash /mnt/data/rootbash; chmod +xsという古典的なSUIDトリックはうまくいきませんでした。このマウントにはnosuid/no-new-privilegesのセマンティクスが設定されているため、SUIDバイナリはここでは意味を持ちません。一方、rootとしてコードを実行する方法はうまく機能し、これがポート1337のデーモンが与えてくれるものです。

成果: Azure Dynamic Sessions

rootを手に入れたことで、ついにトラフィックを監視できるようになりました。tcpdumpはなかったため、Pythonでプロミスキャスモードの簡易パケットスニファを作成しました。

これが捕捉したリクエストの1つです。
 

Host: ACA-Session-Interpreter。「ACA」はAzure Container Appsです。AzureContainerApps-DynamicSessions環境変数と合わせると、突然全体像が明らかになりました。Copilotのコードインタプリタは、信頼できないコードを使い捨てのサンドボックスで実行するための、汎用の一般公開されているAzureサービスであるAzure Container Appsのダイナミックセッション上に構築されているのです。

この発見によって、私たちの研究の方向性は大きく変わりました。Copilotのサンドボックスがダイナミックセッションそのものであるなら、gzipプロンプトやチャットウィンドウ、安全レイヤーとの戦いなしに、クリーンでドキュメント化されたAPIを通じて、Azureから同じ環境を直接借りることができるのです。

個人のAzureアカウントでダイナミックセッションプールを開き、そのREST API経由でコードを実行したところ、まったく同じ環境であることが確認できました。同じ/app、同じサービス、あらゆる面が同一で、権限昇格のエクスプロイトもそのまま機能しました。

これが意味するのは次の2点です。

  • 研究が大幅に容易になったことです。チャットボットではなく通常のPythonスクリプトから、セッションを開き、権限昇格を実行し、rootコマンドを操作できるようになりました。
  • ここで見つかったものはCopilotだけのバグではなく、Azure Dynamic Sessionsにも影響することが分かりました。

まとめ

第一部を終えた時点で、私たちは以下を達成しています。

  • 安全レイヤーがあるにもかかわらず、Copilotサンドボックス内で任意のコードを確実に実行できる。
  • そのサンドボックス内でrootへ権限昇格できる。
  • サンドボックスがAzure Container Appsのダイナミックセッションであることを把握しており、これによりクリーンでスクリプト化可能な複製環境で作業できる。

それでもなお、私たちは完全に自分自身が所有するコンテナの内部にいるにすぎません。使い捨てサンドボックス内でrootを取得しても、ネットワークがなければそれ自体は大きな影響を持ちません。そこから到達可能なものはすべて私たち自身として動作するか、あの優れたネットワーク強化によって壁で隔てられています。唯一の未解決の糸口は、私たちが試したあらゆるものに対して404を返していた、ポート8578の未確認のHTTPサーバーです。

ポート8578のあの「あらゆるリクエストに対して404」を返すサーバーこそが、私が説明できず解明できなかった唯一のものでした。第二部でこれに戻ります。

M365 Copilotサンドボックスの突破 第二部: ポート8578のデーモン

私たちはCopilotのコード実行安全レイヤーを言葉で突破し、サンドボックスをマッピングし、rootへ権限昇格し、そして自分たち自身のトラフィックを傍受することで、これ全体がAzure Container Appsのダイナミックセッション、つまり直接借りて研究できる一般公開されているAzureサービスであることを知りました。この最後の事実こそが、この部を可能にするものです。

第一部では、行き詰まった方向が1つありました。偵察の際、netstatは私が説明できないリスナーを表示していました。

ポート8578上のHTTPサーバーで、見えないプロセスによって所有され、あらゆるリクエストに対して404を返していました。

しかし、サンドボックスがAzure Dynamic Sessions上で動作し、それがContainer Apps上で動作し、それがAKS上で動作していると分かった後、この謎のサービスがそれらのプラットフォームにも現れるかどうかを確認しに戻りました。実際に現れました。同じリスナーが通常のContainer AppsやAKSノード上にも存在していたのです。つまり、これはCopilotのサンドボックス特有のものではなく、3つすべてに共通するインフラに属しているということです。

これこそ私が必要としていた突破口でした。Copilotのプロンプトを通じてこのサービスを調べる代わりに、好きなツールを使った通常のContainer Appを立ち上げ、直接それを調べることができるようになったのです。

すべての扉をノックする

最初に8578を調べたとき、私は手動で明白なルートをいくつか試しました。しかし、試したすべてのルートは404 Not Foundを返しました。また、Serverヘッダーもなく、ステータス行の特徴もなく、フィンガープリントできるものが応答に何もありませんでした。それが最初にあきらめた理由です。

しかし、「手動で10のルートを試した」と「ルートが存在しない」は全く異なる主張です。今や同じサービスが、私が管理する通常のAzure Container Appからも到達可能だったため、本格的なブルートフォースを行うことができました。大きなワードリストとファザーをアップロードし、何百万ものパスを投げつけたのです。

それほど時間はかかりませんでした。

1つのルート、/configが見つかりました。空の404ではなく、応答があったのです。missing field ns。パラメータを求めています。

そこで与えてみました。

今度は「unknown config」という応答が返ってきました。どうやらhelloという名前のコンフィグ(あるいはネームスペースかもしれません)を探していて、それが見つからないようです。

では、PUTを使ってコンフィグを書き込めるのでしょうか?試してみましょう。

すると200 OKが返ってきました。コンフィグの書き込みに成功したようです。今度はそれを読み取ってみましょう。
すると今度は200 OKが返ってきます(この全く同じリクエストが以前はunknown configを返していたことを思い出してください)。ボディは空なので、実際にはコンフィグそのものを取得しているわけではありませんが、PUTで書き込んだ後にhelloという名前のコンフィグが存在することが確認できます。

つまり、このサービスは名前付きのコンフィグを保存します。PUTを名前付きで実行すると書き込まれ、GETを実行すると、存在すれば返されるということです。

なかなか面白いです。しかし、最初は全く役に立ちませんでした。読み返すことができない何かを、自分で選んだ名前で保存するサービスに何ができるのでしょうか。

私はこれを長い時間かけて試しました。セッション間でデータを永続化させようとしたり、URLを取得させようとしたり、あらゆる形の値を試したりしましたが、成果は得られませんでした。

nsがファイル名だとしたら?

ブラックボックスが、あなたが指定した名前の下に何かを保存する場合、問うべき価値のある問いはそのプログラムにとって、その名前とは何なのかということです。マップのキーなのか、行IDなのか、それともディスク上のパスなのでしょうか。

もしかすると、各コンフィグはnsにちなんだ名前のファイルに書き込まれ、もしかすると、このサービスはその名前をサニタイズしておらず、その場合nsはディレクトリトラバーサルのプリミティブになります。しかし、これをテストするのが難しい落とし穴があります。サービスが書き込むファイルシステムを見ることができないのです。それは別のプロセスであり、別のコンテナ内にあります。だから、トラバーサルが機能していたとしても、それをどうやって知るのでしょうか。

ここで一度立ち止まって、どうすれば分かるか考えてみてください。なかなか巧妙な手です。

ここに着眼点があります。nsがパスとして使われているなら、以下の2つは同じファイルになります。

  1. aa
  2. bb/../aa

ルートにあるファイルaaに行くのは、ディレクトリbbに入って..で戻り、aaに到達するのと同じです。つまり、トラバーサルを証明するためにファイルシステムを見る必要はなく、サービスがこれら2つの名前が同じものを指すと認めればよいのです。

  1. PUT /config?ns=aa – トラバースしたパスにコンフィグを書き込む。
  2. GET /config?ns=bb/../aa – 見た目の異なるパスで存在を確認する。

2番目のリクエストが200 OKを返せば、..が実際のファイルシステム上で解決されたということです。

そして…成功しました。200 OKが返ってきました。つまりaabb/../aaは同じファイルに解決される、つまりこの謎のサービスにディレクトリトラバーサルの脆弱性を発見したのです!

さらに、絶対パスも使えることが分かりました。例えば/bin/fileを指定して、任意のファイルを指すこともできます。

しかし、実際にこの名前で何をしているのでしょうか。もう一組のプローブでそれが確定しました。すでに存在する2つのパス、1つはファイル、もう1つはディレクトリを対象に指定してみました。

この2つの組み合わせをよく見てください。これが全体の仕組みを確定させます。もしサービスが指定した名前そのものにファイルを単純に書き込んでいるなら、既にディレクトリである/binは、/bin/lsと全く同じように失敗するはずです。しかし失敗しませんでした。両方の結果に整合する唯一のモデルは次のものです。このサービスは名前をディレクトリとして扱い、それを作成し、その内部にファイルを書き込んでいます。

  • /bin/lsは失敗する。既存のファイルの上にディレクトリを作成できないため。
  • /binは成功する。ディレクトリが既に存在しているため、単に新しいファイルをその中に置くだけで済むから。
  • さらに、/binはrootしか書き込めないため、このデーモンはrootとして動作していることになります。

つまり、ディレクトリトラバーサルの脆弱性を手に入れました。次はどうすればよいでしょうか。

行き詰まり、頭を抱える

私は、見ることのできない何らかのファイルシステムへのディレクトリトラバーサルを手に入れ、内容が何であるか全く分からないファイルを書き込んでおり、既存のものを上書きすることもできませんでした。さらに、このサービスは私のポッドの隣に存在する別のコンテナにすぎず、この環境の他のすべてと同様にネットワーク的に分離されていると想定していました。つまり、この脆弱性をコード実行にまで悪用できたとしても、どうせ興味深いものには到達できないだろうと考えていました。

私はこの研究全体を一旦置いておき、約1か月間、別の研究に取り組みました(ちなみにAIオーケストレーションに関する研究です — [TODO: そのシリーズへのリンク])。

しかし、この研究は私の頭の中に残り続けました。というのも、多くのことを達成していたからです。

  • サンドボックスのあらゆる部分を理解していました。
  • サンドボックス内の権限昇格を発見し、それを悪用していました。
  • 内部のAzureサービスにディレクトリトラバーサルを発見していました。

しかし、それら全てを合わせても、私が達成した実際の影響は完全にゼロでした。

興味深い発見ではあるが、価値はない、という状態でした。

突破口が開いた朝

ある朝、私はイライラしながらこの研究に戻り、「既存ファイルを上書きできない」という挙動を、障害としてではなく機能として、もう一度見直しました。

その挙動が実際に何を意味しているのか考えてみましょう。

  • まだファイルが存在しない名前にPUTする → 200 OK
  • 既にファイルが存在する名前にPUTする → 500 Internal Server Error

これは単なる不便な仕様ではありません。これは「このファイルは存在するか?」というオラクルです。このサービスに任意の絶対パスを渡せば、そのステータスコードによって、そのファイルが書き込み先のファイルシステム上に存在するかどうかが分かるのです。

例えば次のようになります。

つまり、この環境にどんなファイルが存在するかを確認することで、このファイルシステムをフィンガープリントし、それが何なのかを理解できる可能性があります。

私はブルートフォースツールを再度起動し、今度はこのファイルシステムが何であるかを示唆する特徴的なパスを探しました。

その大部分は「存在しない」という結果でした。そして、ファザーは予想外のものに当たりました。

/var/lib/cloud/instance/…が存在していました。

これらのファイルは何でしょうか。これらのファイルはcloud-initに属するもので、初回起動時にクラウド仮想マシンをブートストラップするパッケージです。/var/lib/cloud/instance/boot-finishedはアプリケーションコンテナの内部には存在しません。それが見つかるのはホストVM上です。

もう一度読んでください。私も一瞬信じられませんでした。このサービスは、私のポッドの隣にあるコンテナに書き込んでいるのではありません。私のポッドが動作しているKubernetesノードに書き込んでいるのです。私たちはサンドボックスの外側に書き込んでいるのです!

ファイル存在オラクルによって、このデーモンがホストVM、つまりサンドボックスの完全に外側に書き込んでいることが証明されました。

その瞬間、この発見にまつわるすべてが変わりました。隣接するコンテナへのディレクトリトラバーサルは単なる興味深い事象です。しかし、Azureサンドボックスの下にあるホストノードに、何らかの特権デーモンとしてファイルを書き込むディレクトリトラバーサルで、しかもプロンプトが起動できるコード内部から到達可能なものは、まったく別次元の話です。

まだ分かっていないことが2つありました。このサービスが実際にそのファイルに何を書き込んでいるのか、そして「新しいファイルを作成する」ことをどうやって「実行される何か」に変えるのか、という点です。しかし、このバグの輪郭はついに明確になり、それは思っていたよりもはるかに大きなものでした。

M365 Copilotサンドボックスの突破 第三部: コンテナからの脱出

私たちは何らかの特権デーモンとして、ホストノード上にファイルを作成できますが、その内容は制御できません。

サンドボックス内でポート8578をリスニングしている謎のサービスには、/configエンドポイントにディレクトリトラバーサルのバグがあります。それはnsという名前を受け取り、それをディレクトリとして扱い、作成し、その内部にファイルを書き込みます。これをファイル存在オラクルとして使うことで、これらの書き込みが私たちのサンドボックス内部ではなくKubernetesノードに着地しており、しかもこのデーモンがそれをrootとして行っていることを証明しました。私たちは、コンテナの外側の任意のパスに、rootとしてファイル入りのディレクトリを作成できます。

これは興味深いプリミティブですが、限定的です。どこにディレクトリを作成するかは制御できますが、そこに何を書き込むかは制御できません。内部に落とされるファイルの名前も、そのファイルの内容の1バイトも分かりません。

これを武器化するには、このサービスが正確に何を書き込んでいるかを知る必要があります。そこで最初の作業は、このサービスが生成するものを読み取ることです。そしてそのために、私たちのサンドボックスのポッドが、まさにこのデーモンが書き込んでいるノード上に存在しているという事実を利用できます。

デーモンに、私たちが読める場所へ書き込ませる

鍵となるのは、第一部でfindmntが示していたものです。私たちの/mnt/dataはコンテナ自身のオーバーレイファイルシステムの一部ではなく、ホストのディスクからのバインドマウントです。実際の行はこちらです。

よく読んでください。サンドボックスからは/mnt/dataとして見えるディレクトリは、ホストノード上では/podr/volume/ca0c744a898449029951ae58951dbebeです。同じファイルに対して2つのパスがあり、1つはポッドの内側、もう1つはノード上のものです。つまり、デーモンにそのディレクトリをそのホストパスに作成させれば、書き込まれるものは私自身のサンドボックス内の/mnt/dataに着地し、単純に読み取ることができるということです。

トラバーサルを自分自身のポッドに向ける。デーモンを自分のボリュームのホストパスに向けさせ、他の方法では見えなかったファイルの内容をサンドボックス内で読み取ります。

これこそがこのトラバーサルの真価です。何も見えないホストパスに向けるのではなく、自分自身のポッドのボリューム内に向けたのです。サービスに、/podr/volume/…/confnameにトラバースする名前を与えました。すると、ホスト上のそこに忠実にconfnameディレクトリが作成されました。それはまさに、サンドボックス内で/mnt/data/confnameとして見えるディレクトリです。デーモンが残していったファイルを開いてみました。

2つのことが同時に腑に落ちました。

まず、このデーモンが各コンフィグディレクトリ内に落とすファイルがついに見えました。名前はhosts.tomlです。第二部では、このサービスがコンフィグ名にちなんだディレクトリを作成し、その内部にファイルを書き込むと推測しましたが、今そのファイル名が判明しました。

次に、capabilities = ["resolve", "pull"]skip_verifyを含むhosts.tomlは、containerdのレジストリホスト設定ファイルであり、containerdがどこからイメージコンテンツを取得すべきかを伝えるファイルです。これを書き込んでいるデーモンは自らをacr、つまりAzure Container Runtimeの一部だと名乗っています。これはAKSのイメージ/アーティファクトストリーミングの背後にあるノードコンポーネントで、ノードがイメージのダウンロード完了前にコンテナを起動できるようにする機能です。これはまた、なぜ同じサービスがContainer Apps、Dynamic Sessions、AKSのいずれからも到達可能なのかという理由でもあります。これらはすべて、このランタイムを持つノード上で動作しているのです。

そして重要な点として、このコンフィグはノード上の/etc配下に書き込まれています。これは第二部で/binのプローブから既に推測していたroot権限を、別の角度から再確認するものです。このデーモンはrootとして動作しています。

「ファイルを作成する」を「任意のファイルに任意の内容を書き込む」に変える

では、私たちが持っているものと、必要なものを並べて見てみましょう。

これを本物のエクスプロイトに変えるには、ノード上の特定のセキュリティ上重要なファイルを、私たちが選んだ内容で上書きする必要があります。そのためには、ファイルのパス(hosts.tomlという名前のファイルしか作成できないプリミティブではRCEへの悪用は難しい)と、書き込むファイルの内容の両方を制御する必要があります。私たちが持っているのは、内容がほぼ固定されたhosts.tomlを含むディレクトリを作成するプリミティブです。私たちが制御できるのは、server = "https://<name>"の行に入る<name>だけです。

2つの問題、2つのトリックです。

問題1: 制御できるのは名前であって、書き込み先のパスではない。書き込みは常に<config-dir>/hosts.tomlに着地します。

これを任意の既存ファイルに向けてリダイレクトする方法が分かりますか?

ヒント: シンボリックリンクです。

私たちは、デーモンがこれからhosts.tomlを書き込む予定の場所、つまり自分自身のポッドのボリューム内に、シンボリックリンクを作成し、そのシンボリックリンクを実際に上書きしたいファイル、例えばノード上の/etc/somethingに向けます。デーモンがhosts.tomlを書き込むとき、シンボリックリンクをたどり、対象を通して書き込みを行います。

問題2: 内容はcontainerdの固定テンプレートであり、私たちが制御できるのは<name>だけ。containerd形式のファイルはそれ自体ではあまり役に立ちません。しかし、私たちの入力がどこに着地するかもう一度見てみましょう。

<name>に改行が含まれていたらどうなるでしょうか。このサービスはそれをサニタイズしていないことが分かりました!つまり、server行から脱出し、追加の任意の行をファイルに書き込むことができるのです。

私たちが持つのは固定のプレフィックス(server = "https://)と固定のサフィックス(containerdテンプレートの残り)ですが、その間にあるものはすべて私たちのものです。これは、サニタイズされていないnsの値を経由したTOML/コンフィグインジェクションです。

この2つのトリックを組み合わせると、このプリミティブは「固定内容のファイルを作成する」ではなくなります。それは次のものになります。

Kubernetesノード上に、rootとして、任意のファイルを、(ほぼ)任意の内容で書き込む

(プロンプトがサンドボックス内に入れたコードから駆動される。)

任意ファイル書き込みからホスト上でのコード実行へ

任意ファイル書き込みは、root権限であっても、まだコード実行ではありません。書き込まれた結果、私たちのコードを実行させるファイルが必要です。私は定番の候補を確認していきました。

  • cron – crontab形式は厳格で、私たちの固定プレフィックス/サフィックスがそれを壊してしまいました。だめでした。
  • /etc/profile.d/ – 機能しますが、対話的なログイン時にしか発火しません。誰もログインするつもりはありません。トリガーがありません。
  • Pythonの.pthファイル – 有望でした(インタプリタ起動時に実行される)が、ここでは確実に発火させることができませんでした。うまくいきませんでした。
  • /etc/ld.so.preloadこれは使えました。ここにリストされたライブラリは、マシン上で起動するほぼすべての動的リンクプロセスに強制的にロードされます。ノードは常に新しいプロセスを起動しているため、ここに落としたライブラリはほぼ即座に、rootプロセス内でロードされます。

そこで、こういう計画になります。小さな共有ライブラリを私たちのポッドのボリュームに書き込みます(これは既に既知のホストパスでノードのディスク上にあります)。次に、任意ファイル書き込みプリミティブを使って、そのライブラリのノード上のパスを/etc/ld.so.preloadに書き込みます。ノード上で次に起動するプロセスが、rootとして私たちのライブラリをロードします。

最終的なエクスプロイト

まさにこれを組み立てた実際のエクスプロイトがこちらです。ld.so.preloadファイル用のシンボリックリンク、私たちのバックドアライブラリを指すための改行インジェクション、そして自分のポッドのconfnameディレクトリに書き込みを着地させるためのトラバーサルです。

このライブラリ自体は小さなエージェントです。/mnt/dataはサンドボックスとノードの間で共有されているため、この共有ディレクトリを双方向のチャネルとして使い、ファイルからコマンドを読み込み、それを実行し、出力を書き戻します。私たちはこのライブラリと次のように通信します。

私たちは/mnt/data/cnc/cmdにコマンドを書き込みます。ノード上のプリロードされたライブラリがそれを読み込み、rootとして実行し、標準出力を/mnt/data/cnc/outに書き込みます。私たちはそれをサンドボックス内で読み返します。コンテナの境界を越える、クリーンなシェルブリッジです。

1つ小さな補足です。root実行を使ってポッドへのネットワークアクセスを有効化し、ポッドから直接攻撃者にアクセスすることもできたかもしれませんが、それに気づいたのはもっと後の段階でした。

ld.so.preloadはノード上のすべての新しいプロセスにライブラリをロードするため、堅牢性を保つために3つの安全策を組み込みました。

  1. 起動時に、自分がrootプロセスに注入されたことを確認し、そうでなければ終了します。
  2. 起動時にミューテックスを取得するため、常に1つのインスタンスだけが生き続けます。
  3. ロードされたプロセスからforkしてデーモン化するため、そのプロセスを乱しません。

以下のデモンストレーションで、実際のサンドボックス脱出をご覧いただけます。エクスプロイトがプリロードライブラリを配置する様子、そしてそれがノード上でロードされた瞬間にrootシェルを取得し、ホスト上でコマンドを実行する様子をご覧いただけます。

私たちはサンドボックスを脱出し、Kubernetesノード上でrootとして動作しています。

サンドボックス脱出完了: プリロードされたライブラリが、共有された/mnt/dataチャネルを介してサンドボックスと橋渡しされた、ノード上でのroot権限のコード実行を私たちに与えます。

ここでCopilotを超えて重要になる、より広い論点を明確に述べておく価値があります。acrのバグは「Copilotのバグ」ではありません。これは、localhostでリスンし、認証を要求せず、書き込みリクエストをホスト上のroot所有のファイル書き込みに変換するデーモンであり、それはイメージストリーミングが有効なContainer Apps、Dynamic Sessions、AKSノード上で動作しています。localhost:8578に到達できる場所はどこでも、そうしたノード上の任意のワークロードにおけるサーバーサイドリクエストフォージェリ(SSRF)のバグを経由するものも含めて、これはノード自体でのrootコード実行に変換されます。

私たちはチャットボックスから出発しました。今、私たちはAzureサービスの下にあるホストVM上でrootです。しかし、ノード上でrootを取得することはまだ目標ではありません。目標は常に被害者のデータでした。第四部では、サンドボックスが決して持ち得なかったノードの1つの重要な能力が、この印象的な脱出を攻撃へと変えます。

M365 Copilotサンドボックスの突破 第四部: AIアシスタント上の対話型シェル

私たちはacrデーモンのディレクトリトラバーサルとコンフィグインジェクションを組み合わせ、ホスト上での任意ファイル書き込みに変え、/etc/ld.so.preloadを使ってKubernetesノード上でのrootコード実行を実現しました。共有された/mnt/dataボリュームを通じて、私たちのサンドボックスコードとノード上のrootプロセスの間にクリーンなコマンドチャネルがあります。

では、ノード上でrootであることは実際に何の価値があるのでしょうか。

私はまず明白なことを確認しました。ここから他のテナントのポッドに到達できるでしょうか。答えはノーでした。Azureの分離は健全であり、私たちはノード上で単独でした。しかし、このノードはインターネットアクセスを持っていました。

ノードにはインターネットがある

私たちがサンドボックス内にいた間、ネットワークは堀でした。DNSなし、外向き通信なし、あらゆる送信接続が死んでいました。この強化は本物ですが、それはポッドレベルで適用されていました。その下にあるノードは、イメージを取得し、Azureのコントロールプレーンと通信し、インターネット上のマシンとして一般的に機能する必要があります。ホストには外向きのインターネットアクセスがあります。

この1つの能力こそが、第一部から欠けていたループを閉じるものです。ノード上のrootプロセスから、攻撃者が管理するサーバーへの接続を開くことができます。

今度は、すでに構築した2つのホップを連結してみましょう。

  • CopilotはPythonを実行することでサンドボックスと会話します。
  • サンドボックスは、私たちのエクスプロイトが仕込んだバックドアを通じて、/mnt/data経由でノードと通信します。
  • ノードはインターネットを介して攻撃者と通信します。

初めて、被害者のCopilotサンドボックス内で動作するコードが、外の世界に到達できるようになりました。

直近の用途はデータ流出です。これは最初に私たちが設定した目標です。ユーザーがCopilotにサンドボックスを必要とする作業(スプレッドシートの処理、アップロードされたドキュメントの解析)を依頼するたびに、Copilotはそのデータをサンドボックスに送り込みます。今、私たちはそれをノード経由で静かに転送できます。私はアップロードされたドキュメントが着地するディレクトリに監視を仕込み、ホストのネットワーク経由でそれらを流出させました。

有用ではありますが、正直に言えば少し物足りないものでした。これは、私たちが攻撃した特定のセッションの中で、たまたまサンドボックスを通過したものしか得られません。Copilotはメール、ファイル、カレンダー、チャットを含むMicrosoft 365テナント全体に到達できますが、そのほとんどはサンドボックスに触れることがありません。約1週間、私はサンドボックス内のデータが限界だと考え、次に進みました。

その後、このプロジェクト全体に名前を付けたひらめきが訪れました。

このチャネルは双方向に働く

私はこの連鎖を流出用のパイプ、つまり被害者から攻撃者へ流れるデータとして考えていました。

攻撃者は、同じ経路を逆方向にたどって入力を送り返すこともできます。その接続を下ってノードに入り、/mnt/dataを横断してサンドボックスに入り、そしてCopilotの会話そのものに上っていくのです。

つまり基本的に、攻撃者と被害者のCopilotの間には双方向通信が成立します。

同じ連鎖を逆から読むと: 双方向チャネルによって、攻撃者は被害者のCopilotに任意のプロンプトを送り込むことができます。

つまり、攻撃者は被害者の稼働中のCopilotに任意のプロンプトを送り込み、Copilotに被害者のID・完全なM365アクセス権で実行させ、応答を外部にストリーミングして戻すことができます。プロンプトを送り、応答を得て、次のプロンプトを送る、という繰り返しです。

これはAIアシスタント上の対話型シェルです。

従来のエクスプロイトにおける究極の勝利は、マシン上での対話型シェルでした。AIアシスタントにおけるその等価物はこれまで存在しませんでした。これがそれです。攻撃者が入力し、アシスタントが被害者のデータ、権限、到達範囲をもって応答する、生きた双方向チャネルです。

これこそが、第一部で予告したRemote Prompt Executionです。Remote Code Executionのプロンプト世界における類似物であり、私たちはこれを実現するエクスプロイトをChatMateと名付けました。

実際の配信: あらゆるプロンプトインジェクション攻撃ベクター

ここまでのすべては、私自身のCopilotから駆動していました。実際の攻撃では、この連鎖は被害者のCopilot内で発火し、攻撃者が現実的に被害者の目の前に提示できるものによって引き起こされる必要があります。

これは、これまでに聞いたことのあるあらゆるプロンプトインジェクション攻撃ベクターで実現できます。例えば、Repromptのワンクリック攻撃ベクター、EchoLeakのメール経由のプロンプトインジェクションのトリガー、あるいはその他のプロンプトインジェクションベクターです。

デモンストレーションのために、私たちは以下の攻撃ベクターを選びました。ユーザーを説得して、私たちのドキュメントをAIアシスタントに渡し、それを処理するよう依頼させるというものです。Copilotはユーザーがアップロードしたファイルを読み取り処理するため、それだけで十分な足がかりになります。

そこで、私たちは無害そうに見えるドキュメント、つまり日常的な業務のように読めるWordファイルを作成しました。

これは典型的なプロンプトインジェクション攻撃です。プロンプトインジェクションの部分は白背景の矩形の裏に隠しているため、ユーザーはそれを見ることができません。以下のデモンストレーションで、その配信部分単独をご覧いただけます。このドキュメントは、Johnが開いたときには完全に無害に見えますが、彼がそれをCopilotにアップロードした瞬間、単にそれが解析されるだけで、Copilotはサンドボックス内で私たちのコードを実行してしまいます。

それを隠すことに加えて、注入されたプロンプトにある3つの詳細が、この攻撃を機能させています。

  1. Pythonコードは第一部のgzipトリックでラップされているため、Copilotは何も疑いません。
  2. 私たちはCopilotに、このPython実行についてユーザーに何も報告しないよう依頼します。
  3. そして最も重要な点として、私たちはCopilotにプログラムの出力を指示として扱い、それに従った結果をサンドボックスに送り返すよう依頼します。これが、プロンプト→応答のループを作り出し、対話型シェルを実現するものです。

そこから先は、連鎖が自動的に進みます。

被害者はCopilotにドキュメントの詳細を記入するよう依頼します。

  1. その裏で、プロンプトインジェクションが発火します。私たちのコードが彼らのサンドボックス内で実行され、権限を昇格させ、ノードへ脱出し、攻撃者のC&Cにダイヤルします。
  2. 攻撃者側にシェルが立ち上がります。
  3. 攻撃者がプロンプトを入力します。
  4. そのプロンプトはノードのバックドアへ移動し、そこからサンドボックスへ入り、プログラムの出力としてCopilotに返されます。
  5. Copilotは(ドキュメント内のプロンプトインジェクションのため)そのプログラム出力をプロンプトとして扱います。
  6. Copilotはそのプロンプトを実行し、出力をサンドボックスに送り返します。
  7. サンドボックスのバックドアがそれをホストのバックドアに渡し、それが攻撃者に渡されます。
  8. 攻撃者は応答を見て、さらにプロンプトを送ります。
  9. ステップ4~9が、攻撃者が望む限り、あるいは最大ターン数が尽きるまで繰り返されます。

このループがChatMateです。

ChatMate: 悪意あるドキュメントが、攻撃者から被害者のCopilotに至るまでの双方向チャネルをブートストラップします。

プロンプトを表示している攻撃者の画面のイメージ

では、デモをご覧いただきましょう。私はこの脆弱性をMicrosoftに報告し、このデモを録画する前に修正されたため、攻撃者へのインターネット接続はシミュレーションです。デモ内のその他すべては実際のものです。

事後分析

「このサンドボックスは安全なのか」という糸を引いていったところ、4つのMicrosoft製品にわたる5つの異なる問題が明らかになりました。

  • Azure Dynamic Sessions
    • サンドボックス権限昇格
    • ホストネットワークの露出
  • Azure Container Runtime
    • 認証の欠落
    • パストラバーサル
    • TOMLインジェクション

合計で、4つのMicrosoft製品が影響を受けました。

  1. Azure Kubernetes Service
  2. Azure Container Apps
  3. Azure Dynamic Sessions
  4. Microsoft Copilot

そして、このプリミティブ自体が示すように、AIアシスタント上の対話型シェルは今や現実の攻撃者能力です。アシスタントがより多くの自律性とデータへのより広いアクセス範囲を得るにつれ、その自律性が私たちに対して悪用されるリスクは高まるばかりです。

もう一つ

このサンドボックス脱出の脆弱性は、実際にはAzure Container Runtimeデーモンの脆弱性です。つまり、この脆弱性の影響範囲はCopilotよりも広いということです。Container AppやAKS(イメージストリーミングが有効で、かつホストネットワークが露出したアプリを持つ場合)におけるSSRF脆弱性は、そのアプリケーションを実行しているK8sノード上でのRCEへ悪用され得ます。

開示タイムライン

  • 2026年2月10日 — サンドボックスの権限昇格とacrのディレクトリトラバーサルの両方をMicrosoft Security Response Center(MSRC)に報告。
  • 2026年3月19日 — サンドボックス権限昇格が修正される。
  • 2026年4月中旬 — acrディレクトリトラバーサル/ホスト脱出の連鎖が修正され、CVE-2026-32193(CVSS 8.8)が割り当てられ、その脆弱性の深刻度を理由に48,000ドルのバグバウンティが授与される。

MSRCの素晴らしい対応に心から感謝します。

Ori LahavはRubrik Zero Labsのセキュリティ研究者です。この研究はBlack Hat USA 2026で発表され、新興のAIインフラのセキュリティに関するRubrik Zero Labsの取り組みの一環として実施されました。

翻訳元: https://zerolabs.rubrik.com/blog/breaking-m365-copilot-sandbox-chatmate

ソース: zerolabs.rubrik.com