外部脳をクラウドに預けない代わりに、認証なしのポートを常時開けていた話

外部脳をどのAIにもロックインしない設計にした話を書いたとき、乗り換えられる根拠として並べたものの一つが「意味検索の埋め込みはローカルのOllamaで作っている」だった。vaultの中身をクラウドに送らない、という選択だ。

8月末、そのOllamaを狙う脆弱性の記事が流れてきた。悪意のあるWebページを一度開くだけで、ローカルで動いているOllamaに命令を恒久的に埋め込める、という内容だ。俺の環境構成をまとめたノートには一行こう書いてある。「homebrew.mxcl.ollama が常駐」。常駐しているということは、何かのポートで何かを待ち受けているということだ。それが何なのか、ノートに書いておきながら一度も見たことがなかった。

何が起きた脆弱性か

正確には、Ollama本体の脆弱性ではなかった。NVIDIAのセキュリティ情報で CVE-2026-65105 として公表されたのは、NVIDIA NemoClaw というエージェント実行基盤の話だ。NemoClawはローカル推論のバックエンドとしてOllamaを使う。Oasis Security の研究を載せたCyera Research のレポートを読むと、仕組みはこうなっていた。

Ollama の API には認証がない。代わりにブラウザからの攻撃を防ぐ層が2枚ある。一枚目は Origin ヘッダの照合で、許可リストにあるか、Host ヘッダと一致していれば通す。二枚目は Host ヘッダの検証で、localhost や自分のホスト名など、ローカルを指す名前でなければ弾く。

NemoClaw は、サンドボックスのコンテナ内からホストのOllamaに届くように、Ollamaを 0.0.0.0:11434 で起動していた。Ollamaのソースを読むと、待ち受けアドレスがループバックでない場合、Host の検証は丸ごとスキップされる。残るのは Origin の照合だけで、これはDNSリバインディングで抜ける。攻撃者は自分のドメインをまず自分のサーバに向けてページを読み込ませ、その後にDNSを 127.0.0.1 へ切り替える。ブラウザは同じホスト名なので同一オリジンと見なし、以後のリクエストは被害者のローカルのOllamaに届く。このとき OriginHost は両方とも攻撃者のドメインで一致しているので、一枚目の照合は通る。二枚目はスキップ済み。つまり素通しだ。

Ollamaの2枚の防御層。0.0.0.0で待ち受けるとHost検証がスキップされ、DNSリバインディングでOrigin照合も同一オリジン扱いになり素通し。127.0.0.1で待ち受けるとHost検証が働き、攻撃者ドメインのHostは403で弾かれる

届いた先で何ができるか。レポートで面白かったのは、モデルにシステムプロンプトを埋め込む素朴な手口は、エージェント相手には効かないと書いてあったことだ。エージェントは自分のシステムプロンプトを毎回送るので、モデル側に仕込んだものは上書きされる。だから彼らはチャットテンプレートのほうを書き換えた。こちらは全ての会話の生成経路に入るので、上書きされない。

自分の環境を見る

記事を読み終えてから、ようやく手元を見た。

$ lsof -nP -iTCP:11434 -sTCP:LISTEN
ollama  1023 nobu666  3u  IPv4  TCP 127.0.0.1:11434 (LISTEN)

127.0.0.1 だった。0.0.0.0 ではない。Homebrew の launchd 設定に OLLAMA_HOST は書かれておらず、Ollama のデフォルトのままループバックで待ち受けていた。

ということは二枚目の Host 検証が生きているはずだ。攻撃のときにブラウザが送るのと同じ形、つまり OriginHost の両方を攻撃者のドメインにしたリクエストを curl で投げてみた。

$ curl -s -o /dev/null -w "%{http_code}\n" \
    -H "Origin: http://evil.example:11434" \
    -H "Host: evil.example:11434" \
    http://127.0.0.1:11434/api/tags
403

弾かれた。一枚目の照合は OriginHost が一致しているので通るが、二枚目で evil.example はローカルの名前ではないと判定されて止まる。DNSリバインディングでブラウザから届いたとしても、ここで終わる。

この Host 検証は、Ollama が2024年に同種の穴を塞いだときに入ったものだ。俺はそれを知らずに、デフォルトのまま使っていた。守っていたのは俺ではなく、デフォルトだった。

「認証なし」で残るもの

ブラウザからの経路は閉じていた。それでも API に認証がないことは変わらない。同じマシンで動くプロセスなら、何でも素通しで叩ける。それで何ができるのか、自分のモデルに対して試した。

意味検索のCLI(vault-search)が埋め込みに使っている bge-m3 を元に、レポートの手口をなぞったテスト用モデルを /api/create で作った。システムプロンプトと、チャットテンプレートの両方に命令を仕込んである。認証なし、一発で通る。作ったモデルの /api/show を見ると、仕込んだ文がそのまま systemtemplate に入っている。

ただし、これは埋め込みには効かなかった。同じ文を元のモデルと仕込み済みモデルで埋め込みにして cosine 類似度を取ると、1.0 だった。ベクトルの要素を全部比べても一致した。埋め込みの計算は、システムプロンプトもチャットテンプレートも通らない。vault-search が Ollama に頼んでいるのは埋め込みだけなので、レポートの手口をそのまま持ち込まれても、俺の使い方には刺さらない。

刺さるとしたら別の手だ。/api/create は同じ名前のモデルを別物から作り直せる。テスト名で bge-m3 から作ったモデルを gemma3 から作り直すと、モデルの family は bert から gemma3 に変わった。同じことを本物の bge-m3 に対してやられたら、検索の土台が差し替わる。

これが静かに壊れるなら怖い。試したところ、gemma3 に埋め込みを頼むと Ollama は 501 を返した。そもそも埋め込みに対応していない。別の埋め込みモデルに差し替えられた場合も、vault-search には索引とクエリのベクトル次元が違ったら止まる検査が入っている。今回試した範囲では、静かには壊れない。うるさく壊れる。ただし、同じ次元の埋め込みモデルに差し替えられたらこの検査は通る。そこまでは見ていない。

見つかったのは、別の穴だった

ついでにバージョンを見たら、想定していなかったものが出てきた。

$ ollama --version
ollama version is 0.33.0
Warning: client version is 0.33.3

動いているサーバは 0.33.0、ディスクにあるバイナリは 0.33.3。プロセスの起動日時を見ると9月5日で、その後に brew でOllamaが更新されたのに、常駐プロセスは古いまま動き続けていた。パッチの当たったバイナリが手元にあって、動いているのは更新前のもの。今回の脆弱性に関係するバージョン差ではないが、この状態を放置していたら次はそうなる。

launchd の常駐は「勝手に起動して勝手に動き続ける」のが利点だ。裏返すと、更新しても勝手には入れ替わらない。再起動して 0.33.3 で動いていることを確認した。今回直したのは、結局これ一つだ。

守っていたのは、保管場所だけだった

ロックインの記事で棚卸ししたのは、記憶がどこに保管されているかだった。Markdown で手元にある、Anthropic のサーバにはない、埋め込みもローカルで作る。全部、置き場所の話だ。

記憶を引くときに通る経路は見ていなかった。常駐プロセスが待ち受けソケットを一つ持っていて、そこに認証がない。この条件は意味検索を作った時点からずっと同じだったのに、意味検索の記事でもロックインの記事でも書いていない。書かなかった理由は単純で、見ていなかった。

GCPの鍵を漏らしたときと形が似ていると思う。あのときは秘密が外に出る手前でスキャンしていたのに、スキャン対象に足し忘れた場所から鍵が入ってきた。今回はクラウドに預けるかどうかを点検していて、待ち受けポートは点検の対象に入っていなかった。ガードはあった。守っているつもりの範囲の外側に、見ていない場所があっただけだ。

結果だけ見れば、思ったより守られていた。ループバックで待ち受け、Host 検証が効き、埋め込みには注入が効かず、試した範囲では差し替えも止まる。ロックインの記事のときは自分の過去の判断が効いていたが、今回効いていたのは他人が決めたデフォルトだ。点検するまでの俺は、効くかどうかを知らないまま常駐させていた。ローカルLLMをプライバシーやロックイン回避のために常駐させている人は、たぶん俺と同じで、置き場所のほうは何度も確認している。lsof を一回叩いて、何がどのアドレスで待ち受けているかを見るところまでやった人は、そこまで多くないんじゃないかと思う。

comments powered by Disqus