← すべての投稿に戻る
公開日 · 著者 Renaud Deraison

ログインリンクはコマンドだった

10月19日、Pillar Security は Windows と WSL 上の opencode にあるリモートコード実行の欠陥を公表した。リモートの MCP サーバーがごく普通の https:// ログイン URL に PowerShell の部分式を仕込むことができ、opencode のブラウザ起動処理が OAuth ハンドシェイクの途中でそれを実行していた。修正はリファクタリングとして出荷され、CVE もアドバイザリもない。Bromure Agentic Coding では MCP のサインインを Mac 自身が行い、VM から来た URL はどのシェルも読まないテキストとして Mac に届く。

ログインページを選んだのはサーバーで、そのページのアドレスにはコマンドが入っていた。エージェントがそれを開くと、ブラウザが現れる前に PowerShell がコマンドを実行した。

コーディングエージェントにリモートの MCP サーバーを追加する。MCP(Model Context Protocol)は、エージェントが外部のツール、たとえば課題管理システム、データベース、ドキュメントの索引などに手を伸ばすための仕組みだ。サーバーがサインインを求めるので、エージェントは OAuth ハンドシェイクを実行する。サーバーにログインページの場所を尋ね、返ってきたアドレスをあなたのブラウザで開く。あなたはそのアドレスに一度も触れない。そのコピー&ペーストを省くことこそが、ハンドシェイクの目的だ。

10月19日、Pillar Security は Eilon Cohen による Open and Shut: Remote Code Execution in opencode via MCP OAuth を公開した。opencode は週に約 300 万回 npm からダウンロードされるオープンソースのコーディングエージェントだ。Windows と WSL では、バージョン 1.0.137 から 1.18.32 までが、このハンドシェイクの最中に MCP サーバーの選んだコマンドを、確認もクリックもなしに実行していた。Pillar が提案する CVSS スコアは 9.8。Infosecurity Magazine も10月20日に報じている。

ディスカバリー文書から PowerShell へ

アドレスを握っているのはサーバーだ。OAuth ハンドシェイクでは、サーバーのディスカバリー文書が authorization_endpoint という自由形式の文字列を指定し、クライアントはあなたをそこへ送る。Pillar は opencode 1.18.32 の中でその経路をたどった。

server's discovery doc → MCP SDK → oauth-provider.ts → mcp/index.ts → browser.ts → open() → PowerShell

この経路のどこにも、スキームを確認したり、アドレスを設定済みのサーバーと照合したりする処理はなかった。open はブラウザを起動する人気の npm パッケージで、週に約 1 億 8,400 万回ダウンロードされている。macOS と Linux では URL を open か xdg-open に単一の引数として渡すので、シェルがそれを読むことはない。Windows、そして Windows ホストに処理を投げる WSL では、バージョン 10.1.2 が PowerShell のコマンドラインを組み立てる。アプリケーション名はエスケープするが、URL はそのまま二重引用符の中に置く。PowerShell は二重引用符の文字列の中の $(...) を展開する。Pillar のペイロードはこうだ。

https://evil.test/$([IO.File]::WriteAllText('C:\Users\Public\pwned.txt','RCE'))

PowerShell は何かを起動しようとする前に部分式を評価する。ブラウザの起動が失敗する頃にはファイルはもうディスク上にあり、opencode はそのエラーを捨てる。

URL は https:// で始まっている。mcp-remote の同一のバグ(CVE-2025-6514、2025年7月)の後、標準的な助言は http: と https: のリンクだけを受け付けることだった。MCP の TypeScript SDK は javascript:、data:、vbscript: をブロックする。opencode 自身のパッチは http/https のチェックを追加した。Pillar のペイロードはその三つをすべて通り抜ける。opencode 1.18.33 で穴を塞いだのは open 11.0.4 へのアップグレードで、こちらは URL を単一引用符で囲む。単一引用符の中では PowerShell は何も展開しない。

さらに二つの事情が、この件を管理しにくくしている。

  • サーバーは追加した後で敵に回りうる。 ペイロードは接続時の HTTP レスポンスで届く。設定した日には問題のなかったサーバーでも後から侵害されうるし、そのドメインも、レジストリの登録も、タイポスクワッティングされた複製も同じだ。
  • どのスキャナーも検出しない。 Pillar は8月29日に GitHub の非公開アドバイザリでバグを報告した。opencode の開発元 Anomaly は、悪意ある MCP サーバーは自社の脅威モデルの範囲外だと答え、9月25日にブラウザ起動コードのリファクタリングとして修正をマージし、9月28日に 1.18.33 をリリースした。CVE は申請されず、アドバイザリも公開されていないので、依存関係ボットには照合する材料がない。Pillar の助言は、opencode のインストールを手作業で棚卸しすることだ。
一つのログイン URL、ホストへの二つの受け渡しauthorization_endpoint: https://evil.test/$([IO.File]::WriteAllText(…))WSL の opencode 1.18.32Linux ゲストopencode MCP OAuth[email protected]Windows ホストpowershell -EncodedCommandStart "…" "https://…$(…)"$(…) が先に展開あなたの Windows 権限で実行ブラウザ起動は失敗、エラーは破棄URL がホスト上のコマンドラインの一部になる。Bromure マシンのエージェントLinux VMxdg-open, $BROWSER→ bromure-open送信用ファイルURL を一行のテキストでシェルは読まないあなたの Machttp/https、公開ホストのみNSWorkspace.open(url)URL は VM からブラウザまで URL のまま。$(…) はただのテキスト。
同じログイン URL を二つのマシンで。WSL では、Linux ゲスト内の起動処理が URL を Windows ホストの powershell.exe に渡し、powershell.exe が部分式 $(...) を展開して、あなたの Windows アカウントの権限で実行する。Bromure Agentic Coding では、ゲストの起動処理が URL を Mac 宛てのファイルに書き込む。Mac は公開ホストを指す http か https のアドレスとしてのみそれを受け付け、シェルを一切起動しない macOS 自身の URL 起動機構に渡す。

WSL と Bromure、違いは設計上の一つの選択

WSL はこのバグをホストの侵害に変える。WSL は Windows の上で Linux を動かし、中のプログラムがブラウザを求めると、その要求はホストへ行く。開く処理を担うのは Windows のシェルなので、URL に隠されたコマンドは Linux の外、あなたの Windows マシン上で、あなたの Windows アカウントとして動く。

Bromure Agentic Coding も構成は同じだ。Claude Code、Codex、Grok、Kimi、omp を Mac 上の Linux VM で動かし、VM の中から開いたログインページは Mac のブラウザに表示される。両者が分かれるのは受け渡しの部分だ。

どの Bromure マシンでも、xdg-open と $BROWSER は bromure-open という小さなスクリプトを指している。Linux の open パッケージは xdg-open を呼び、「ブラウザを開く」CLI のほとんども同じだ。bromure-open がすることは一つだけで、URL を一行のテキストとして、Mac と共有しているフォルダのファイルに書き込む。その中身を実行することも展開することもない。

Mac はそのファイルを拾い、削除し、その行を URL として読む。通すのは公開ホストを指す http と https のアドレスだけだ。mailto:、file:、独自スキーム、localhost、プライベートアドレスは捨てられる。残ったものは、URL を既定のブラウザに渡す macOS の呼び出し NSWorkspace.open に送られる。VM の境界のどちら側にも、シェルは登場しない。

Pillar の URL は URL のままブラウザに届き、パスの中には $([IO.File]::WriteAllText(…)) がただのテキストとして残る。ブラウザは evil.test のページを開くか開けずに終わり、コマンドは実行されない。

MCP のサインインは Mac が行う

Bromure に同梱されたエージェントでは、ログイン URL はそもそも VM に届かない。リモートの MCP サーバーは、マシンの MCP ペインで一度追加するだけだ。Bromure はそれぞれを Claude Code、Codex、Grok、Kimi、omp の設定形式に変換し、起動時に VM に渡す。

HTTP サーバーの場合、Authorize with OAuth… は Mac 上でサインインを実行する。Bromure のブローカーがサーバーのディスカバリー文書を取得し、Bromure AC として自身を登録し、Apple の URL API で認可 URL を組み立てて NSWorkspace.open で開き、Mac のループバックポートでコールバックを受け取る。トークンは Mac に残る。エージェントの設定には認証情報が一切なく、そのサーバー宛てのリクエストにはプロキシが認可ヘッダーを付ける。

プロキシは、サーバーへの OAuth ディスカバリー要求にも自分で応答する。エージェントから見れば、そのサーバーにはすでにサインイン済みだ。だから自前のハンドシェイクを始めることも、サーバーに authorization_endpoint を尋ねることも、それを起動処理に渡すこともない。Pillar が悪用したコードパスは動かない。

接続先を決めるのは VM ではない

Pillar は、サーバーが追加後に敵に回りうると警告している。Bromure では、VM がどのサーバーに届くかを Mac 側で制御する。マシンが張るすべての接続は、どちらも VM の外にある仮想ネットワークスイッチとプロキシを通る。マシンの Guardrails ペインで Unmatched traffic を Deny にし、使っている MCP サーバーを列挙しよう。

allow web mcp.linear.app
allow web mcp.notion.com
allow web registry.npmjs.org
allow web github.com
allow web api.anthropic.com
default deny

タイポスクワットや誰かがフォーラムから貼り付けたエントリーのように、リストにないサーバーはディスカバリー文書を返せないので、開くべき authorization_endpoint も存在しない。スイッチは各接続を IP と DNS で見たホスト名で確認し、プロキシは TLS のサーバー名でもう一度確認する。拒否はすべて Security Timeline(Window → Security Timeline…)に赤い deny 行として、ホスト、ポート、マシンとともに残る。ルールは Save をクリックした時点で適用され、VM の中からは何も編集できない。

VM の中のキーはすべて代役だ。ANTHROPIC_API_KEY、gh のトークン、AWS のシークレット、MCP の静的トークンには偽の値が入っていて、プロキシが本物に差し替えるのは、そのキーが発行された相手のホストへのリクエストだけだ。あるコマンドがそれらの値を集め、本来の相手ではないホストに送ろうとすると、侵害検出器が HTTP 451 で応答し、何も転送せず、This environment may have been compromised を表示してマシンを一時停止する。

Bromure Agentic Coding のリモート MCPLinux VMエージェントの MCP 設定サーバー URL、起動時に書込トークンなし、サインイン不要キーとトークン代役のみxdg-open → bromure-openURL はテキストで出るあなたの MacOAuth ブローカーディスカバリー、PKCE、トークンMac に保持Proxyディスカバリーに代理応答通信に本物のトークンを付与allow web mcp.linear.appallow web mcp.notion.comallow web api.anthropic.comdefault denymcp.linear.app許可済み、本物のトークンで到達mcp-linear[.]appリスト外:スイッチで拒否Timeline に deny 行
Bromure で追加した MCP サーバー。Mac の OAuth ブローカーがサインインし、トークンを保持する。プロキシがエージェントのディスカバリー要求に応答するので、エージェントは自前のハンドシェイクを実行せず、サーバーが渡す URL を開くこともない。Mac 上の許可リストが VM の届く MCP ホストをそもそも限定し、拒否はすべて記録される。

どのスキャナーも示さないバージョンを探す

CVE がない以上、Pillar の助言は opencode のインストールを自分で棚卸しすることだ。Bromure なら、その記録はもうある。

バージョンを探す。 Security Timeline は、サプライチェーン設定に関係なくすべてのパッケージ取得を記録し、パッケージとバージョンを Condition 列に残す。Filter events… に opencode-ai と入力すれば、npm · [email protected] 以前と書かれた行がそのマシンを示す。Export… で行を CSV に保存できる。bromure.io に登録された Mac では、取得のたびに supply_chain.fetch イベントも発生するので、管理者は各チームに尋ねて回らなくても、一つのコンソールから全開発者のマシンを opencode-ai で検索できる。

サーバーを探す。 bromure-cli trace hostnames は、マシンが接続したすべてのホストをリクエスト数とともに一覧表示する。エージェントが通信している MCP ホストはこの一覧にあり、MCP ペインに一度も登録されなかったものも含まれる。精査すべきなのは、あるいは許可リストから外しておくべきなのは、まさにそれらだ。

VM の中でアップグレードする。 マシンのターミナルで npm install -g opencode-ai@latest を実行すれば 1.18.33 以降が入り、新しい取得が証拠として Timeline に現れる。

URL はテキストとして VM を出る

xdg-open と $BROWSER は URL を Mac 宛てのファイルに書き込む。Mac は公開ホストを指す http か https だけを受け付け、シェルを使わずに開く。

MCP のサインインは Mac で

ブローカーが OAuth を実行してトークンを保持する。プロキシがエージェントのディスカバリーに応答するので、エージェントがサーバーから渡されたログイン URL を開くことはない。

MCP ホストの許可リスト

Unmatched traffic を Deny にすれば、届くのは列挙したサーバーだけになる。拒否はすべて Security Timeline の一行になる。

CVE なしの棚卸し

パッケージの取得はすべてバージョン付きで記録され、マシンが接続したホストはコマンド一つで一覧できる。

すべてのフィールドは信頼できない

Pillar の締めくくりの教訓は、MCP クライアントを作る人に向けたものだ。サーバーが送ってくるすべてのフィールドは信頼できない入力であり、その大半はモデルが何かを読むずっと前に解析される。このバグでは、モデルはペイロードを一度も目にしていない。実行したのはブラウザの起動処理だった。

インストールするすべてのエージェントの、すべての起動処理を監査することはできないし、今回の修正は誰にも知らされずに行われた。Bromure は判断を VM の外に移す。サインインを行い、URL を開き、VM が届くホストを決めるのは Mac だ。本物の認証情報を持っているのも Mac だ。MCP サーバー自体が攻撃者だった以前の事例については、モデルは彼らのサーバーだったを、誤った相手を信頼したエージェント設定については、あなたが信頼したのはワークスペースであって、サーバーではなかったを読んでほしい。