lsofの点検を道具にしたら、Ollamaより先にAirPlayが赤くなった

前の記事は、lsof を一回叩いて何がどのアドレスで待ち受けているかを見た人は多くない、と書いて終わった。あの点検で実際に見つかったのは、目当ての脆弱性ではなく、更新されずに動き続けていた古い Ollama だった。そして書いた本人が、点検を Ollama 以外に対してやっていない。LM Studio や llama.cpp を常駐させている人が同じ点検をするには、それぞれのポート番号を調べ、lsof を叩き、curl で偽装ヘッダを投げる手順を、製品ごとに繰り返すことになる。俺は一回やっただけで満足していた。

ちょうど次に何を作るか考えていたところだったので、この手順をそのまま道具にした。localai-audit という Go の CLI で、標準ライブラリだけ、依存なし、lsof も使わない。引数なしで実行すると、ローカルAI系サービスの既定ポートを順に見て、こういう表を出す。

$ localai-audit
PORT   SERVICE                      LISTEN     REBIND   AUTH      VERDICT
5000   text-generation-webui        ALL        n/a      required  RED
11434  Ollama                       loopback   blocked  none      ok

目当ての Ollama は問題なし。先に赤くなった 5000 番の話は後でする。

何を見て、何を赤にするか

前の記事の点検は、実質2つの質問だった。待ち受けアドレスはループバックか。攻撃者のドメインを HostOrigin に入れたリクエストは弾かれるか。道具にするにあたって、これに認証の有無を足して3列にした。

判定の流れ。既定ポートに127.0.0.1・::1・全インターフェースのアドレスで接続を試み、開いていたら素のGETと偽装HostのGETを投げて、LISTEN・REBIND・AUTHの3列に落とす。ALLか素通しなら赤

LISTEN は、127.0.0.1::1 に加えて、マシンが持つ全インターフェースのアドレスで同じポートに接続を試み、ループバック以外でも繋がったら ALL と表示する。0.0.0.0 で待ち受けているソケットは、自分の LAN 側のアドレスからも繋がるので、lsof でプロセスを覗かなくても、繋いでみれば分かる。

REBIND(DNSリバインディング経由で届くリクエストを弾けるか)は、素の GET と、Host: evil.exampleOrigin: http://evil.example を付けた GET を同じパスに投げて、差分で見る。素で 200、偽装で 4xx なら blocked。偽装も 2xx か 3xx で通れば open。素の GET が 200 でなければ差分が取れないので n/a。リダイレクトは追わない。前回 curl に手で書いたのと同じリクエストを、機械に書かせているだけだ。

AUTH は、素の GET が 200 なら none、401 か 403 なら required。ローカルLLMの API に認証がないのは普通なので、これ単体では色を変えない。

赤の条件は、最初の設計では「ALL と open が両方揃ったら」だった。これを、設計を書いた段階でレビュー用のエージェントに読ませたら潰された。前の記事で説明した攻撃経路は、ブラウザから 127.0.0.1 に届くものだ。LAN に露出していなくても、偽装 Host が素通しなら、Web ページを一枚開いただけで届く。逆に、Host 検証が効いていても、0.0.0.0 で待ち受けていれば LAN の隣人から届く。二つは別の経路で、どちらか片方で赤にしないと、片方だけ開いている構成が ok で通る。指摘を受けて、ALL 単体でも open 単体でも赤にした。赤が一行でもあれば終了コードは 1 になる。

もう一つ、道具に持たせなかったものがある。前回は認証なしの /api/create でモデルを作って、注入が埋め込みに効くかを確かめた。道具はそれをやらない。投げるのは GET だけで、何も作らず、何も書き換えない。点検の道具が攻撃の手順を持っている必要はない。

手元で回す

最初に出た表がさっきのものだ。Ollama の行は、前の記事で手で確かめた通り、ループバックで待ち受け、偽装 Host は弾かれ、認証はない。

問題は 5000 番だった。text-generation-webui の API の既定ポートなので候補に入れてあるのだが、俺は text-generation-webui を入れていない。道具はプロセス名までは出さないので、ここは自分で lsof を叩いた。待ち受けていたのは ControlCenter だった。macOS の AirPlay 受信機能で、*:5000、つまり全インターフェースで待ち受けている。素の GET には 403 を返すので認証ありの扱いになり、Host 検証は判定不能。それでも LISTEN が ALL なので赤になる。

これは誤検知と言えば誤検知で、README の既知の問題に書いた。ただ、判定そのものは間違っていない。全インターフェースで待ち受けて、素の GET を 403 で返すサービスが、AIとは無関係に一つ動いていた。俺が探していたのはローカルLLMの穴で、道具が最初に見つけたのはそれではなかった。手で lsof -iTCP:11434 と叩いていたときは、11434 番以外は視界に入らなかった。ポートの一覧を機械に回させると、目当て以外のものが先に引っかかる。

次に、前回説明した危険な構成を再現した。常駐している Ollama は触らず、別のポートで OLLAMA_HOST=0.0.0.0:11435 としてもう一つ起動する。

11435  (unknown)                    ALL        open     none      RED

ALL で open。前の記事で書いた「ループバック以外で待ち受けると Host 検証がスキップされる」が、そのまま表に出た。サービス名が unknown なのは、道具がサービスを既定ポート番号でしか識別していないからで、11435 番は誰の既定でもない。

もう一つ試したのは、LAN 側のアドレスだけで待ち受ける構成だ。OLLAMA_HOST=192.168.2.199:11436 で起動すると、127.0.0.1 では開いていない。最初の設計は 127.0.0.1 で開いているポートだけを起点にしていたので、この構成は表に出ず、終了コードは 0 で「問題なし」になるところだった。一番外に露出している構成が、表にすら出ない。これも同じレビューで指摘され、接続を試みる起点をループバックからではなく全アドレスからにした。

既定が製品ごとに正反対だった

道具に既定ポートの表を持たせるために、15製品の公式ドキュメントとソースを当たった。ポート番号を確認するだけのつもりだったが、ソースに既定の待ち受けアドレスが明記されている製品は、ついでにそれも見た。全部を確認しきれたわけではないが、見えた分だけでも正反対に割れていた。

既定でループバックなのは、Ollama、Jan、ComfyUI、Jupyter。Jan のドキュメントは 127.0.0.1 を「自分のコンピュータからしか届かない、個人利用で一番安全な選択」と説明して既定にしている。ComfyUI は --listen を付けない限り 127.0.0.1 で、--listen を値なしで付けると 0.0.0.0:: の両方になる。

既定で全インターフェースなのは、KoboldCpp、LocalAI、Qdrant。KoboldCpp の --host のヘルプには「このフラグを付けなければ、経路のある全インターフェースを受け付ける」と書いてある。LocalAI の既定アドレスは :8080 で、これはホスト部分が空なので全インターフェースだ。ただし LocalAI には、認証なしで公開アドレスに待ち受けようとすると起動を拒否する設定が入っていて、その説明に、ループバックと LAN のアドレス帯は「認証なしでも受け付ける」と明記されている。LAN は公開ではない、という線の引き方だ。この線引きは道具の判定と食い違う。道具は LAN に開いていれば赤にする。俺は LAN を信用しない側に倒した。

前の記事で「守っていたのは俺ではなく、デフォルトだった」と書いた。それは Ollama の話で、隣の製品では既定が逆を向いている。ローカルLLMを一括りに「手元で動くから安全」と思っていると、入れた製品によって初日から LAN に開いている。ドキュメントには書いてあるのだが、入れる前に待ち受けアドレスの既定値を読む順番には、普通ならない。ソケットに聞くほうが早い。

見ていないもの

ホストのファイアウォールは見ていない。自分の LAN アドレスへの接続はループバック経路を通るので、macOS のアプリケーションファイアウォールや Linux の ufw は評価されない。LISTEN が ALL でも、ファイアウォールが外からの接続を落としていれば、隣人には届かない。表の ALL は「ソケットが全インターフェースに束縛されている」までしか言っていない。

サービスの識別は、既定ポート番号だけでやっている。8080 番は llama.cpp と LocalAI と Open WebUI と Weaviate が既定で使うので、候補を全部並べる。応答の中身で確定させることはしていない。識別が外れても3列の判定は変わらないので、初版では割り切った。

それから、俺の手元で実際に動いているローカルAI系サービスは Ollama しかない。他の製品の行は、既定ポートと応答パスをソースで確認して表に入れただけで、実物に対して回してはいない。5000 番のように、実物を入れている人の環境で初めて分かることがまだあると思う。

一回叩く代わりに

前の記事の結びで、lsof を一回叩いた人は多くない、と書いた。一回叩くのは簡単だが、製品ごとにポートを調べて curl の偽装ヘッダを書く作業は、二回目からやらなくなる。俺は Ollama で一回やって、そこで止まっていた。

道具にしてはっきりしたのは、ブラウザ経路と LAN 経路が別の穴で、片方だけ塞がっていても赤は赤だということだ。それと、既定の向きが製品ごとに逆なので、ある製品のデフォルトに守られた経験は隣の製品に持ち越せない。あのとき効いていたデフォルトは、Ollama を選んでいたから効いていた。

赤が最初に出たのは、ローカルLLMではなく AirPlay だった。点検の対象を Ollama に絞っていた間は、5000 番は視界の外にあった。前回出てきたのは古い Ollama で、今回はローカルAIですらないものが出てきた。範囲を広げるたびに、探していたものとは別のものが先に出てくる。

go install github.com/nobu666/localai-audit@latest

入れたら、まず一回叩いてみてほしい。目当ての行が ok でも、その上か下に、入れた覚えのないものが並んでいるかもしれない。俺の場合、それが AirPlay だった。

comments powered by Disqus