はじめに
僕は自宅のUbuntuマシンでRAGの勉強をしていた。RAGというのは、文書を検索してその内容をもとにAIに答えさせる仕組みだ。題材に選んだのは国税庁のサイトで、サイトをクロールしてPDFを集めて解析し、LLMに必要な文書と不要な文書を仕分けさせ、ベクトル検索用のデータベースを作る、というものを作っていた。コードはほとんどAIに書かせていた。世間でバイブコーディングと呼ばれているやつである。
この記事は、その開発用のPostgreSQLがインターネットに公開されたままになっていて、ランサムウェアのボットにデータベースを消されてビットコインを請求され、その追跡調査でOllamaも公開されていて知らない人たちがモデルを追加していたことが分かり、おまけに自分で直したつもりの裏口を5週間見落としていた、という話である。
読み終わったら、自分のマシンでss -ltnを一度叩いてみてほしい。それが何のコマンドかは、最後まで読めば分かる。
第一章: 僕の開発環境、と少し変わった事情
マシンは自宅の机の下にあるUbuntu 24.04のデスクトップ機で、GPUはRTX 5090、メモリは32GBある。買うときはかなり悩んだが、ローカルLLMを動かすなら必要だと自分を説得した。この上で、国税庁のサイトをクロールしてPDFを集めて解析し、LLMに必要な文書と不要な文書を仕分けさせ、ベクトル検索用のDBに流し込むパイプラインが動いている。
ミドルウェアはDockerで動かしている。知らない人向けに説明すると、Dockerはアプリを小さな箱(コンテナ)に閉じ込めて、インストールも設定もせずに起動できる仕組みだ。PostgreSQLもベクトルDBも、コマンド一発で立ち上がる。Docker Composeは、その箱を複数まとめて、どの箱をどんな設定で起動するかを1枚のYAMLファイルに書いておくもの。
| サービス | 役割 | 動かし方 | ポート |
|---|---|---|---|
| PostgreSQL 17 | クロールした文書の正本をしまう | Docker Compose | 5432 |
| Qdrant | ベクトル検索 | Docker Compose | 6333 |
| Ollama | ローカルLLM。文書が要るか要らないか判定する | ホストに直接インストール | 11434 |
| MinerU | PDFをテキストにする | Docker | 18000 |
ポートというのは、一台のマシンの中にある窓口の番号だ。役所に窓口がいくつもあって、番号ごとに別の担当が座っているのと同じで、5432番に話しかけるとPostgreSQLが応対する。
ここで、僕の環境の少し変わった事情を説明しておかないといけない。普通、家庭のPCはルータの内側にいる。インターネットから見えるのはルータのアドレスだけで、内側のPCには192.168.○.○のような家の中でしか通用しないアドレスが振られている。頼んでもいない通信はルータが内側に入れないので、家のPCは何もしなくてもそこそこ守られている。
ところが僕のマシンは、契約している回線の都合で、インターネット上でそのまま通用するグローバルIPアドレスを直接持っている。ルータの内側ではなく、インターネットの道端に直に立っている。
$ ip -4 addr show enp7s0
inet 221.115.xxx.xxx/24 brd 221.115.xxx.255 scope global dynamic noprefixroute enp7s0
scope globalと書いてある。この行は何度も見ていた。グローバルIPがあると外からSSHできて便利だな、くらいにしか思っていなかった。便利なのは僕にとってだけではなかった、というのがこの記事の要約である。
そのマシンで、僕はこういうcomposeファイルを使っていた。正確には、AIが書いたものをちらっと見て承認した。レビューはしたつもりだった。
services:
postgres:
image: postgres:17
container_name: postgres
ports:
- "${POSTGRES_HOST_PORT:-5432}:5432"
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-taxforge}
volumes:
- postgres_data:/var/lib/postgresql/data
Dockerの公式イメージの説明に載っている例とほぼ同じで、ネットを検索すれば同じものが何千件と出てくる。この中に、あとで僕を泣かせる行が2つある。今の時点で分かった人は、この先を読まなくても大丈夫だ。当時の僕には分からなかった。
第二章: データベースから請求書が届いた
2026年9月5日、金曜の夜。6月に立ち上げたあと、しばらく触っていなかった開発用のPostgreSQLに久しぶりに接続した。しばらく、というのがあとで効いてくる。
$ psql postgresql://postgres:postgres@127.0.0.1:5432/taxforge
psql: error: connection to server at "127.0.0.1", port 5432 failed: FATAL: password authentication failed for user "postgres"
パスワードが通らない。最初の感想は、あれ、.envをいじったかな、だった。自分の記憶より設定ファイルを疑ったわけだが、今回は記憶の方が正しかった。
コンテナの中からならパスワードなしで入れる。そこで箱の中に入って、データベースの一覧を出した。
$ docker exec -it postgres psql -U postgres -c '\l'
Name | Owner | Encoding | ...
-------------------+----------+----------+-----
postgres | postgres | UTF8 | ...
readme_to_recover | postgres | UTF8 | ...
template0 | postgres | UTF8 | ...
template1 | postgres | UTF8 | ...
作っていたデータベースがない。代わりにreadme_to_recoverという、作った覚えのないデータベースがある。復旧したければ読め、という名前だ。とりあえず中身を見る。
readme_to_recover=# \dt
Schema | Name | Type | Owner
--------+--------+-------+----------
public | readme | table | postgres
readme_to_recover=# select * from readme;
All your data was backed up by us. You must pay 0.0076 bitcoin to bc1qk9kvwhzt60u3eqcjllqlj44h0tj7w7n72apz99 or in 48 hours, your data will be publicly disclosed and deleted.
(for more information visit https://2no.co/2psg) After payment send mail to ak+35sm4@onionmail.org and we will provide a link for you to download your data. Your DATAID is: 35SM4
(2 rows)
あなたのデータは我々がバックアップした。48時間以内に0.0076ビットコインを払わなければ公開して削除する。支払ったらメールをくれ。お前のDATAIDは35SM4だ。
ここでようやく背筋が凍った。自分のデータベースから英語で金の話をされるという経験は、あまりしたことがない。
落ち着いて読むと、分かることがいくつかある。メールアドレスの+35sm4と末尾のDATAID: 35SM4が同じだ。被害者ごとに振られた管理番号である。つまり相手は僕を狙ったのではなく、大量の被害者を番号でさばいている。僕は35SM4番だった。
もう一つ。脅迫文がvarchar(255)のテーブルに2行に分けて入っているのは、文字数制限に引っかかったからだろう。犯人もvarcharの長さを気にしている。
この時点で分かっていた被害は次の通り。
- 作っていたデータベースが丸ごとDROPされている
- postgresユーザーのパスワードが書き換えられている(だから入れなかった)
- 脅迫文入りのDBが置かれている
救いは、消されたのがクロールし直せば戻るデータだったことだ。国税庁のサイトは明日もそこにある。払う理由はない。そもそもこの手のボットは、各テーブルをLIMIT 20で少し読んだだけでDROPすることが別の人の検証で分かっている。バックアップしたというのは嘘で、払っても何も帰ってこない。
いつやられたのか
Postgresのコンテナログはとっくに流れて消えていた。そこでデータディレクトリを直接見た。PostgreSQLはデータベースを作るとbase/番号/というディレクトリを作るので、そのタイムスタンプが脅迫文を置かれた時刻になる。
| 日時(UTC) | 出来事 | 根拠 |
|---|---|---|
| 2026-06-13 01:44 | 僕がこのPostgresを初期化 |
PG_VERSIONの更新時刻 |
| 2026-06-15 10:37 | readme_to_recoverが作られる |
base/16802/の更新時刻 |
| 2026-09-05 | 僕が気づく | この日に接続した |
立ち上げてから2日でやられ、その後82日間、誰も気づかなかった。開発用のDBは使っていない時間の方が長い。壊されても、次に使おうとするまで分からない。
第三章: 犯人を探したら、自分がいた
犯人探しを始めよう。と言っても脅迫文を書いた人間を探すのではなく、なぜそいつが僕のDBに届いたのかを探す。こちらの方が建設的だし、こちらの方が辛い。
こういうときに見るべきなのは設定ファイルではなく、マシンが実際にどの窓口を開けているかだ。それを見せてくれるのがssというコマンドで、-ltnを付けると待ち受け中のTCPポートが一覧で出る。当時の僕のマシンはこうなっていた。
$ sudo ss -ltnp | grep -E '5432|6333|11434|18000'
LISTEN 0 4096 0.0.0.0:5432 0.0.0.0:* users:(("docker-proxy",...))
LISTEN 0 4096 0.0.0.0:6333 0.0.0.0:* users:(("docker-proxy",...))
LISTEN 0 4096 0.0.0.0:18000 0.0.0.0:* users:(("docker-proxy",...))
LISTEN 0 4096 0.0.0.0:11434 0.0.0.0:* users:(("ollama",...))
左の0.0.0.0:5432が全部を説明している。マシンには複数のアドレスがある。自分自身とだけ話すための127.0.0.1(ループバックと呼ぶ。家の中だけで通じる内線だ)と、外と話すためのグローバルIPだ。0.0.0.0はその全部で待ち受けるという意味で、内線でも外線でもかかってきたら出る。グローバルIPを持つマシンでこれをやると、インターネットの向こう側から誰でも5432番に話しかけられる。
しかも待ち受けているのはdocker-proxyだ。PostgreSQL本人ではなく、Dockerが用意した取次ぎ役である。つまりあのcomposeのports: - "5432:5432"が、この取次ぎ役をグローバルIPに立たせていた。
Dockerのポート公開はファイアウォールを通らない
ここまで調べて、ファイアウォールで防げなかったのか、と思った。正直に書くと、当時の僕は自分のマシンにファイアウォールが入っているのかどうかも知らなかった。調べると、Ubuntuにはufwというファイアウォールが最初から入っているらしい。どの窓口への来客を通して、どれを追い返すかを決める門番だ。自分のマシンで確かめてみた。
$ systemctl is-active ufw
active
$ grep ENABLED /etc/ufw/ufw.conf
ENABLED=no
入ってはいるが、有効になっていない。ufw enableを一度も打っていないのだから当然である。では有効にしていれば防げたのか。調べていくと、話はそう単純ではなかった。
Linuxのファイアウォールは、パケットが通る道をいくつかの検問所(チェーンと呼ぶ)に分けている。このマシン宛てのパケットはINPUTという検問所を通る。このマシンを経由して別のところへ行くパケットはFORWARDという別の検問所を通る。ufwがこのポートを閉じる、と書くルールは、基本的にINPUTの検問所に置かれる。
ではDockerは何をするか。コンテナはマシンの中にある小さな別のマシンで、172.18.0.2のような自分専用のアドレスを持っている。ports:を書くと、Dockerはホストの5432に届いたパケットの宛先を、検問所に着くより前に172.18.0.2に書き換える。この書き換えをNAT(正確にはDNAT)と呼ぶ。宛先が書き換えられたパケットは、もはやこのマシン宛てではなく、別のマシン(コンテナ)宛てだ。だからINPUTではなくFORWARDを通る。ufwの門番が立っているのはINPUTで、そのパケットを見ることすらない。
インターネット → NIC → [宛先書き換え: 5432 → 172.18.0.2:5432]
│
├─ 宛先がこのマシン → INPUT ← ufwの門番はここ。Ollamaはこちらを通る
└─ 宛先がコンテナ → FORWARD → DOCKER-USER → DOCKER → コンテナ
↑ 自分でルールを足せるのはここだけ
これはバグではなく仕様で、Dockerの公式ドキュメントPacket filtering and firewallsにそのまま書いてある。
Docker and ufw use firewall rules in ways that make them incompatible with each other. When you publish a container's ports using Docker, traffic to and from that container gets diverted before it goes through the ufw firewall settings.
読めば分かる。僕は読んでいなかったし、そもそもufwを知らなかった。つまり、仮に当時ufwを知っていて有効にしていたとしても、Postgresには届いていた。ufw statusに5432は出てこないので、閉じていると思い込んだまま、今より長く気づかなかったかもしれない。
侵入というより、普通にログイン
届いたあと、どうやって中に入ったのか。脆弱性を突かれたとか暗号を破られたとか、そういう話であってほしかった。実際はユーザー名postgres、パスワードpostgres。composeの既定値のままだった。ローカルでしか使わないつもりだったから、という言い訳は、ついさっきローカルではなかったと分かったばかりだ。
あとから残骸を調べて分かったボットの行動は、こうだ。
- postgres / postgresで接続
- 作っていたデータベースをDROP DATABASE
- readme_to_recoverを作って脅迫文をINSERT
- postgresのパスワードを変更して持ち主を締め出す
- もう一つ、僕が5週間気づかなかったこと
5番目は、最初の対応を終えてすっかり解決した気になっていた10月13日、念のためと再調査して見つけた。PostgreSQLにはロール(ユーザーのことだ)の一覧がある。そこにpostgresが2人いた。
postgres=# select oid, rolname, rolsuper from pg_roles where rolname like 'postgres%';
oid | rolname | rolsuper
-------+------------+----------
10 | postgres | t
17215 | postgres | t ← 末尾に半角スペースがある
名前は"postgres "。末尾に半角スペースが一つ入っている。スーパーユーザー、つまり何でもできる権限付きで、パスワードも設定済み。番号(oid)がreadme_to_recoverより後なので、脅迫文を置いたあとに作られている。僕がパスワードを戻して一件落着と思っていた間も、この裏口はずっと開いていた。ロール一覧を流し見しただけでは、末尾のスペースは見えない。その流し見を、9月の僕はやっていた。ちゃんと確認した、という感覚だけはあった。
それ以外の仕掛けは見つからなかった。設定ファイル(postgresql.auto.conf、pg_hba.conf)は初期化した日のまま、変な拡張や関数もなし。コンテナは管理者権限なしで動いていたので、ホスト側も一通り(cron、SSHの鍵、自動起動のサービス)確認して異常なし。ボットはデータベースの中でできることだけをして帰っていった。それで十分だった、ということである。
第四章: 僕のGPUを世界が使っていた
9月5日の夜、僕はcomposeファイルを全部直した。ポートの前に127.0.0.1:を付けて回り、パスワードを変え、DBを作り直した。これで終わったと思っていた。そのまま5週間過ごした。
10月13日、念のためにもう一度ss -ltnを叩いた。
$ ss -ltn | grep 11434
LISTEN 0 4096 0.0.0.0:11434 0.0.0.0:*
まだいる。Ollamaだ。
種明かしは情けない。Ollamaのcomposeファイルは確かに直したが、そのcomposeはそもそも使っていなかった。実際に動いていたOllamaは、もっと前に公式のインストールスクリプトでホストに直接入れたものだった。自分で入れたものを自分で忘れていた。
Linuxではsystemdという仕組みが、起動時に自動で立ち上げるサービスを管理している。Ollamaのインストーラはそこにサービスを登録する。そして僕は、コンテナの中からもOllamaに届くようにと、ある日こういう追加設定を自分の手で書いていた。
$ systemctl cat ollama
# /etc/systemd/system/ollama.service
[Service]
ExecStart=/usr/local/bin/ollama serve
...
# /etc/systemd/system/ollama.service.d/override.conf
[Service]
Environment="OLLAMA_HOST=0.0.0.0:11434"
Environment="OLLAMA_KEEP_ALIVE=-1"
OllamaのFAQにある通り、既定は127.0.0.1、つまり内線専用だ。それを僕が自ら、必要に迫られて、ちゃんと調べた上で0.0.0.0に変えた。ちゃんと調べたのは、設定の書き方だけだった。OLLAMA_KEEP_ALIVE=-1は一度ロードしたモデルをずっとGPUに置いておく設定で、応答が速くなって親切だ。誰に対して親切だったのかは、このあと分かる。
ここで大事なことを書いておく。Ollamaには認証がない。パスワードもAPIキーもない。0.0.0.0で待ち受けるということは、インターネット上の誰でもこのGPUで好きなだけ推論でき、モデルを足したり消したりできるという意味になる。
ログを見る
systemdのサービスのログはjournalctlで読める。Ollamaはリクエストを1件1行で記録するので、外部からのものだけ数えた。ログの保管期間の都合で、残っていたのは9月22日以降の3週間分だけだ。
$ journalctl -u ollama -o short-iso | grep '\[GIN\]' | grep -vE '\| +(127\.0\.0\.1|::1) +\|' | wc -l
242
$ journalctl -u ollama | grep '\[GIN\]' | grep -cE '\| +(127\.0\.0\.1|::1) +\|'
14
| 項目 | 件数 |
|---|---|
| 外部からのリクエスト(2026-09-22〜10-13) | 242 |
| 送信元IPアドレスの数 | 43 |
| そのうち推論が成功したもの | 73 |
| 同じ期間に僕が投げたリクエスト | 14 |
僕が14回使う間に、知らない人たちが242回。持ち主より客の方が多い。電気代を払っているのは僕である。
| パス | 件数 | つまり |
|---|---|---|
POST /v1/chat/completions |
94 | OpenAI互換のAPIで推論 |
POST /api/chat |
38 | OllamaのAPIで推論 |
GET /api/tags |
30 | 入っているモデルの一覧を見る |
GET / |
26 | Ollama is runningと返ってくる。生存確認 |
GET /api/version |
21 | バージョン確認 |
POST /api/generate |
5 | 推論 |
POST /api/pull |
1 | モデルを追加する |
/solr/admin/...、/cgi-bin/authLogin.cgi、/nice ports,/Trinity.txt.bakなど |
7 | Ollamaと無関係。他の製品の脆弱性を片っ端から試すスキャナ |
常連さんの行動記録
一番の常連は136.0.10.220というIPで、121回来ている。RDAP(IPアドレスの登録情報を調べる公開サービス)で引くと、Evoxtというホスティング事業者のアドレスだ。誰かが借りたサーバであって、その誰かは分からない。9月22日の初訪問を並べると、こうなる。モデル名は同じ時刻にOllamaがロードしたモデルのログから引いた。
19:25:57 GET /api/version 200
19:25:57 GET /api/tags 200 ← モデル一覧を取得
19:25:58 POST /v1/chat/completions 401 ← 一覧にあった:cloudモデルを指定したらしく失敗(推測)
(9分後、一覧のモデルを端から順に試す)
19:35:20 POST /v1/chat/completions 200 2.9s gemma3:1b プロンプト23トークン
19:35:25 POST /v1/chat/completions 200 7.1s gemma3:4b プロンプト23トークン
19:35:31 POST /v1/chat/completions 200 12.6s gemma3:12b プロンプト23トークン
19:35:44 POST /v1/chat/completions 200 23.2s gpt-oss:20b プロンプト78トークン
19:35:59 POST /v1/chat/completions 200 35.5s gemma3:27b プロンプト23トークン
19:36:09 POST /v1/chat/completions 499 45.9s deepseek-r1:32b ← 向こうが45秒で諶めた
19:36:10 POST /v1/chat/completions 499 45.9s nemotron-3-nano:30b
19:36:11 POST /v1/chat/completions 499 45.9s qwen3.8:27b
応答時間がモデルの大きさに比例して伸びていく。リクエストのたびに数GBから30GBのモデルをGPUに読み込んでいるからだ。僕のRTX 5090が、誰かのスクリプトのためにモデルを8個順番にロードし、向こうは大きいやつを45秒で見限って帰っていく。このIPはその後もほぼ毎日来て、同じことを繰り返した。使えるモデルと応答速度を定期的に調べて回るクローラ、というのが僕の解釈だ。
そして10月4日、同じIPが一歩踏み込んだ。
2026-10-04 20:10:26 GET /api/tags 200 136.0.10.220
2026-10-04 20:10:28 msg="downloading a010b879d3ae in 1 293 B part(s)"
2026-10-04 20:10:28 POST /api/pull 200 136.0.10.220 ← glm-5.3:cloudを追加
2026-10-04 20:10:28 POST /api/chat 401 136.0.10.220 ← cloudモデルはサインインが要るので失敗
僕のマシンに、僕の知らないモデルが増えていた。今回はクラウド経由で動く軽いモデルの定義(293バイト)だったので実害はない。ただ、数十GBのモデルをディスクが溢れるまで落とさせることも、/api/deleteで僕のモデルを全部消すことも、同じ手順でできた。やられなかったのは、たまたまそういう目的の人が来なかったからであって、僕が何かしたからではない。
他にも、性格の違う客がいた。
| 送信元 | 行動 | 僕の解釈 |
|---|---|---|
| 129.146.97.8(Oracle Cloud) | 9/25の午後から翌朝まで、約40分おきに/api/chatを17回。応答は0.4秒〜24秒 |
スキャナではない。誰かのアプリが僕のGPUをバックエンドとして使っていたように見える |
| 103.218.237.115(インド) |
/api/chatを10秒で諶めながら連打 |
応答速度を測っている |
| 65.49.1.x / 64.62.156.x / 74.82.47.x |
GET /と/favicon.icoだけ |
Shadowserver Foundation。公開されたサービスを調査して通報する非営利団体。善意のスキャナ |
| 185.247.137.x(Driftnet) |
GET /だけ |
インターネット全域のスキャン結果を売る会社 |
では、彼らは僕のGPUに何を聞いたのか。ここは正直に書く。分からない。Ollamaはプロンプトの本文をログに残さない。残るのは内部の推論エンジンが出すトークン数だけだ。常連さんのプロンプトは23トークンが圧倒的に多く、おそらく返事をして、程度の定型文だろう。一方Oracle Cloudからの17回には20秒以上かかった応答がある。何かを生成させていたのは確かだ。それが詩なのか、スパムメールの文面なのか、僕には分からない。
第五章: 彼らはどうやって僕を見つけたのか
ここまで読んで、こう思った人がいるはずだ。自宅のマシンなんて、どうやって見つけるのか。僕もそう思っていた。誰も僕のことなど知らない、と。それは正しかった。誰も僕を探してはいない。全員を探しているだけだ。
インターネットは思ったより狭い
IPv4のアドレスは約43億個しかない。多いように聞こえるが、コンピュータにとっては大した数ではない。全部のアドレスの5432番の窓口をノックして回るだけなら、masscanのようなツールとそこそこの回線があれば数十分で終わる。返事があったアドレスをリストにする。それだけだ。
このノックをしているのは悪人だけではない。僕のログにも、その世界の住人が写っていた。Shadowserver Foundationは公開されてしまったサービスを調べて回線事業者に通報する非営利団体で、Driftnetはスキャン結果を商品にしている会社だ。どちらもGET /を一回だけ投げて、Ollama is runningという返事を記録して去っていった。彼らが作っているのと同じリストを、当然、善意のない人たちも作っている。
さらに言えば、自分でスキャンする必要すらない。ShodanやCensysという検索エンジンがあって、GoogleがWebページを集めるように、全世界の開いているポートを集めて検索できるようにしている。Cisco Talosというセキュリティ研究チームが2025年9月にShodanで調べたところ、公開されたOllamaが1,100台以上見つかり、その約2割がモデルを載せて応答する状態だったという。僕のマシンは、その2割の1台だった。
リストができたら、あとは機械作業
PostgreSQLの場合、リストのアドレスに対して、よくあるユーザー名とパスワードの組を上から順に試す。postgres / postgresはそのリストの一番上にある。僕のDBは、総当たりと呼ぶのも申し訳ないくらい最初の一手で開いたはずだ。
その後の手順は完全にスクリプトで、人間は関与していない。Border0という会社のエンジニアがわざと弱いパスワードのPostgresを公開してボットを誘い、流されたSQLを全部記録する実験を2024年にやっている。公開から数時間でやられ、ボットの行動は次の通りだった。
- データベースとテーブルを列挙する
- 各テーブルを
SELECT * ... LIMIT 20で少しだけ読む。これがバックアップと称するものの正体だ -
DROP TABLE ... CASCADEで消し、pg_terminate_backendで他の接続を切り、DROP DATABASEする - readme_to_recoverを作って脅迫文を入れる
当時の要求額は0.007 BTCで、被害者の番号はDBCODEと呼ばれていた。僕のところに来たのは0.0076 BTCでDATAID。同じ系統のボットが2年経っても稼働していて、値上げまでしている。ビットコインのアドレスも当時の記事とは別のものだった。
Ollamaの場合はもっと簡単で、認証がないのだから試す工程すらない。GET /api/tagsでモデル一覧を取り、その場で使える。常連さんの動き(version→tags→全モデルを順に推論→毎日再訪問)は、今日も使えるかを確かめてリストを更新するクローラの動きそのものだ。そしてそのリストを見た誰かが、Oracle Cloudから40分おきに僕のGPUを使う。リストを作る人と使う人が分業している。僕はその分業の、電気代を払う担当だった。
要するに、グローバルIPでポートを開けるというのは、見つかるかもしれないではなく、数時間から数日で必ず見つかる。僕の場合は2日だった。運が悪かったのではなく、それが普通だ。
第六章: やった対策
対策は、言ってしまえば一つしかない。ローカルでしか使わないものは、ローカルでしか待ち受けない。門番で後から追い返すのではなく、外向きの窓口を最初から作らない。具体的にやったことを書く。
1. Dockerのports:には必ず127.0.0.1:を付ける
ports:
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
これでdocker-proxyと宛先書き換えのルールは内線宛てだけになり、外からは届かない。Qdrant、MinerU、pgAdminも同じように直した。ファイルの先頭には理由を書いておく。理由が書いてないと、あとで平気で外してしまう。
# ホストが公開IPを持つため、ポートはすべて127.0.0.1にのみ束縛する(Dockerはufwを迂回する)。
マシン全体で既定値を変えたいなら、Dockerの設定ファイル/etc/docker/daemon.jsonに"ip": "127.0.0.1"と書けば、ports:でアドレスを省略したときの既定が内線になる。composeの書き忘れが怖い人にはこちらも薦める。僕は両方やった。
2. 自分で書いていないcomposeも疑う
これは別プロジェクトで発覧した。SupabaseというサービスのCLIは、supabase startでローカル開発用のPostgresを立ち上げてくれる。そのPostgresは0.0.0.0:54322で待ち受け、パスワードは既定でpostgres、しかも待ち受けアドレスを変える設定がない(supabase/cli#1397)。今回と完全に同じ状態を、僕はもう一つのプロジェクトでも作っていた。一度やられたあとで。
こちらはDockerネットワークのオプションで対処した。ネットワークを作るときにhost_binding_ipv4を指定すると、そのネットワークにつながるコンテナの公開ポートは全部そのアドレスに束縛される。
db-local-start:
docker network inspect supabase-local >/dev/null 2>&1 || \
docker network create -o com.docker.network.bridge.host_binding_ipv4=127.0.0.1 supabase-local
SUPABASE_NETWORK_ID=supabase-local npx supabase start
3. Ollamaは既定に戻す
override.confからOLLAMA_HOST=0.0.0.0:11434の行を消して、systemctl daemon-reload && systemctl restart ollama。これだけで内線専用に戻る。コンテナからホストのOllamaを使いたい場合は、0.0.0.0ではなくDockerがホストに作るブリッジのアドレス(172.17.0.1)に束縛するか、コンテナをnetwork_mode: hostで動かしてホストと内線を共有する。
勝手に追加されたモデルはcurl -X DELETE 127.0.0.1:11434/api/delete -d '{"name":"glm-5.3:cloud"}'で消した。消すのにも認証は要らない。そういう製品である。
4. 設定ではなく実態を見る
今回の教訓で一番大きいのはこれだ。composeを直したあとの5週間、Ollamaは公開されたままだった。設定ファイルを直すことと、待ち受けが閉じることは別だ。最後は必ずこれを見る。
$ ss -ltn | grep -vE '127\.0\.0\.|\[::1\]'
State Recv-Q Send-Q Local Address:Port Peer Address:Port
見出し行以外が出なければ、内線以外で待ち受けているポートはない。僕のマシンは今、この状態だ。さらに外から見た姿を確かめたければ、スマホのテザリングなど別の回線からnc -vz 自分の公開IP 5432を打つか、Shodanで自分のIPを検索する。何も出ないのが正解だ。
5. 直したあとで、裏口を探す
パスワードを戻してDBを作り直しただけでは、末尾にスペースの入ったロールが残った。侵入されたPostgresでは、最低限これを見る。
-- 知らないロール、名前が似ているロール(末尾スペース、大文字小文字)。[]で囲むとスペースが見える
select oid, '[' || rolname || ']', rolsuper from pg_roles order by oid;
-- 設定の上書き
select name, setting, source from pg_settings where source not in ('default', 'override');
-- 拡張、イベントトリガ、見覚えのない関数
select * from pg_extension; select * from pg_event_trigger;
select proname, prolang from pg_proc where pronamespace = 'public'::regnamespace;
もっとも、スーパーユーザーを取られたDBを確認して使い続けるのは、合鍵を誰かが持っていると分かった部屋に住み続けるようなものだ。正解はボリュームごと捨てて初期化し直すことである。僕は証拠を残したかったのでreadme_to_recoverだけ残して、あとは作り直した。脅迫文はこうして記事のネタになったので、残して正解だったと思う。
やらなかったこと
ビットコインは払っていない。脅迫文の短縮URLも開いていない。メールも送っていない。返事をすると、反応がある被害者としてリストの別の列に移されるだけだ。
おわりに
最後に、一連の出来事を時系列で並べておく。
| 日付 | 出来事 |
|---|---|
| 2026-06-13 | 開発用Postgresを0.0.0.0:5432、パスワードpostgresで起動 |
| 2026-06-15 | ランサムウェアボットが侵入。DBを削除、脅迫文を設置、パスワード変更、裏口ロール作成 |
| 2026-09-05 | 発見。composeを127.0.0.1束縛に変更、DBを作り直し。ここで解決したつもりだった |
| 2026-09-22〜10-12 | Ollamaが外部43IPから242回叩かれる(ログが残っている範囲だけで) |
| 2026-10-04 | 外部から/api/pullでモデルを追加される |
| 2026-10-13 | 追跡調査。Ollamaの公開と裏口ロールに気づき、全部内線に。この記事を書くことにした |
教訓は3つ。
-
Dockerの
ports:はファイアウォールの外側にある。"5432:5432"は"0.0.0.0:5432:5432"の略で、ufwで拒否しても通る。常に"127.0.0.1:5432:5432"と書くか、daemon.jsonで既定を内線にする -
Ollamaには認証がない。
OLLAMA_HOST=0.0.0.0は、誰でもこのGPUを使っていいしモデルも入れ替えていいという意味で、実際にそう使われる -
設定ファイルではなく
ss -ltnを信じる。 直したつもりの5週間、Ollamaは公開されたままだった
Ubuntuを使っていることと、Ubuntuを守れていることは別だった。AIにコードを書かせられることと、書かれたコードの意味が分かることも別だった。どちらも、やられてから分かった。
読み終わったら、自分のマシンでこれを打ってほしい。見出し行以外に何か出たら、それが今、外に向けて開いている窓口だ。
ss -ltn | grep -vE '127\.0\.0\.|\[::1\]'
- 0.0.0.0や*や[::]で待ち受けているポートがあるか
- それは本当に外から使うものか
- パスワードは既定値のままではないか
-
ルータの内側にいるつもりで、実はグローバルIPを直持ちしていないか(
ip -4 addrで10.xや192.168.x以外のscope globalが見えたら要注意) - Postgresのpg_rolesに、見覚えのないスーパーユーザーがいないか
開発環境だから安全、ということはない。ボットは開発環境と本番環境を区別しないし、持ち主が初心者かどうかも気にしない。
参考資料
- Docker: Packet filtering and firewalls — Dockerとufwが両立しないことの公式説明
- Border0: A detailed look at an active PostgreSQL and MySQL Ransomware bot — 同系統のボットの動作をSQLログ付きで検証
- The Register: Cisco Talosによる公開Ollamaの調査報道 — 1,100台以上が公開、約2割が稼働中
- Ollama FAQ — OLLAMA_HOSTと既定の待ち受け先
- supabase/cli#1397 — Supabase CLIが待ち受け先を指定できない件