2026年6月25日、AnthropicがAlibaba(Qwen)を「蒸留攻撃」で告発したと報じられた。Claudeのモデル能力を不正に抽出するため、2880万回以上のアクセスがあった、という話だ(gigazine)。
自分はこのニュースを読んで、最初に気になったのが「2880万回」という数字の出し方だった。件数を数えて「多いから怪しい」と言うのは簡単だけど、自前でLLMをAPIで出している側からすると、件数だけで抽出を止めるのはほぼ無理なんだよね。今日はそこを手元で再現できる最小実験で確かめる。掲載するコードは全部動かして数字を取った。numpyだけで動く。
件数で止める、の何がダメか
抽出(蒸留)攻撃は、こちらのモデルに大量のクエリを投げて入出力ペアを集め、その回答で自分の小さいモデルを学習させる。素朴な防御は「1キーあたりの呼び出し回数に上限を付ける」だ。
これが効かない理由は2つある。
ひとつ、攻撃者はキーを分散できる。1キーで2880万回叩くバカはいない。数千キーに割れば、1キーあたりは普通のヘビーユーザと区別がつかない。
ふたつ、もっと本質的な話で、抽出に必要なのは「回数」ではなく「カバレッジ」だ。モデルの決定境界を写し取るには、入力空間を満遍なくサンプリングしたい。逆に普通のユーザは、自分の関心がある2〜5個の話題の周りを何度も行き来する。つまり見るべきは「どれだけ叩いたか」ではなく「入力空間のどこを、どれだけ広く舐めたか」なんだよね。
幸い、この「どこを舐めたか」はすでに手元にある場合が多い。RAGのキャッシュやログ用にクエリの埋め込みベクトルを保存しているなら、それを再利用できる。新しい計測基盤はいらない。
検知の流れ
やることはシンプルで、クライアントごとにクエリ埋め込みを集めて、占有しているグリッドの広がりを測るだけ。
最小実験
3種類のクライアントを作る。正常ユーザ、入力空間を一様にばらまく「雑bot」、そして実在の話題からだけ引いて正常に擬態しつつ、多数の話題を薄く舐める「巧妙bot」だ。巧妙botが本番の相手になる。
import numpy as np
rng = np.random.default_rng(7)
D = 16
GRID = 5
TOPICS = rng.normal(0, 1.0, size=(60, D)) # サービスが扱う有限個の話題
def _emit(ids, w, n, jitter=0.18):
pick = rng.choice(ids, size=n, p=w)
return TOPICS[pick] + rng.normal(0, jitter, size=(n, D))
def normal_client(n):
ids = rng.choice(60, size=rng.integers(2, 6), replace=False)
w = 1.0 / np.arange(1, len(ids) + 1); w /= w.sum() # 人気話題に偏る
return _emit(ids, w, n)
def naive_bot(n): # 雑: 一様にばらまく
return rng.uniform(-2.6, 2.6, size=(n, D))
def stealth_bot(n): # 巧妙: 実在話題を広く薄く
ids = rng.choice(60, size=min(45, n), replace=False)
return _emit(ids, np.ones(len(ids)) / len(ids), n)
指標は2つ。入力空間を粗いグリッドに切って、占有セルのエントロピー(広がり)と、クエリ列の後半でどれだけ新しいセルを踏むか(探索が止まらないか)を測る。
def _cells(X):
idx = np.clip(((X + 2.6) / 5.2 * GRID).astype(int), 0, GRID - 1)
keys = np.zeros(len(X), dtype=np.int64)
for d in range(D): # 高次元セルを素数で畳む
keys = (keys * GRID + idx[:, d]) % 1_000_003
return keys
def occupancy_entropy(X):
_, c = np.unique(_cells(X), return_counts=True)
p = c / c.sum()
return float(-(p * np.log(p)).sum() / np.log(len(X))) # クエリ数で正規化
def tail_novelty(X, head=0.5):
keys = _cells(X)
seen = set(); novel = np.empty(len(keys))
for i, k in enumerate(keys):
novel[i] = k not in seen # 初見セルなら 1
seen.add(int(k))
return float(novel[int(len(keys) * head):].mean()) # 後半の新規率
200人の正常ユーザと各20体のbotを生成して、件数(リクエスト数)・占有エントロピー・後半の新規率を比べる。判定は単純な閾値で、正常を取りこぼす誤検知(FP)と、botを見逃す数(FN)を数えた。順位ベースのAUCも出す。
結果
中央値で並べると差がはっきり出る。
| 指標(中央値) | 正常 | 雑bot | 巧妙bot |
|---|---|---|---|
| リクエスト数 | 315.0 | 345.5 | 351.5 |
| 占有エントロピー | 0.824 | 1.000 | 0.973 |
| 後半の新規率 | 0.415 | 1.000 | 0.840 |
リクエスト数は正常もbotも300台で、ほとんど重なっている。一方で占有エントロピーと新規率は、巧妙botでも正常からきれいに浮く。
検知性能を数字にすると、件数がいかに使えないかが分かる。
| 巧妙botを正常から分離 | AUC | FP | FN |
|---|---|---|---|
| リクエスト数 | 0.555 | 1/200 | 19/20 |
| 占有エントロピー | 0.998 | 2/200 | 0/20 |
| 後半の新規率 | 0.998 | 2/200 | 0/20 |
件数のAUCは0.555で、ほぼコイン投げだ。閾値をどこに置いても巧妙botの19/20を見逃す。これが「件数では止まらない」の正体で、巧妙botはわざと擬態しているから当然こうなる。
対して空間カバレッジ系の2指標はAUC 0.998。誤検知2件はクエリ数が極端に多くて話題も広い「ヘビーな正常ユーザ」で、見逃しはゼロだった。雑botに至っては両指標ともAUC 1.000で完全に分かれる。
つまずいたのはここからだ。「2つ効くなら両方ANDで組めば誤検知が減る」と思って試したが、
[2指標AND] 占有>=0.955 かつ 新規率>=0.749 : 巧妙bot FP=2/200 FN=0/20
FPは2件のまま減らなかった。理由は単純で、誤検知を出す2人のヘビーユーザは「広く散る」も「探索を止めない」も両方やっているから、AND条件も素通りしてしまう。2つの指標が独立じゃないと、組み合わせても誤検知は消えない。組むなら別の軸(時間帯の規則性とか、回答の再利用率とか)を足さないと意味がない、というのが手を動かして分かったことだった。
注意点
これはあくまで検知のアイデアを確かめる再現実験で、本物の攻撃トラフィックではない。実運用に持っていくなら2つ気をつけたい。
ひとつ、埋め込みの次元やグリッドの粗さで閾値は動く。GRIDを上げると正常ユーザの占有も上がって誤検知が増える。Dは生のembeddingではなくPCAなどで16〜32次元に落としてからグリッドに切るのが現実的だ。
ふたつ、検知できても即ブロックはしない方がいい。誤検知2件は実在のヘビーユーザなので、いきなり遮断すると優良顧客を切ることになる。検知はあくまで「このキー群を別レートで管理する」「監視を厚くする」のトリガーに留めて、判断は人を挟む。
まとめ
Anthropicの件で表に出た「2880万回」という数字は派手だけど、防御の設計としては件数は弱い指標だ。手元の実験でも、件数のAUCは0.555でほぼ役に立たなかった。抽出が欲しいのは回数ではなく入力空間のカバレッジだから、見るべきもそこになる。
そして都合がいいことに、その材料はRAGのために保存している埋め込みログにもう入っている。新しい計測を足す前に、すでに持っているログで「誰が空間を広く舐めているか」を測ってみる。これが一番安く始められる一手だと思う。最後に自戒として、効く指標を見つけても安易にANDで重ねないこと。相関した指標を足しても誤検知は減らないし、優良ユーザを誤って切る代償の方が高くつく。