「ログ相手の機械学習」では済まない理由
一般的な機械学習は、静的な世界を相手にすることが多い。離脱予測や需要予測のモデルは、ノイズの多いデータには苦しめられても、その裏で誰かが積極的にモデルを騙そうとしてくることはない。セキュリティ領域はそこが根本的に違う。不正な挙動を検知するために展開したモデルは、遅かれ早かれ誰かに探られる。どこに死角があるかを探され、見つかればそこを偶然ではなく意図的に突かれる。
この一点だけで、モデルの作り方は大きく変わってくる。去年の攻撃トラフィックに対して99%の精度を出せていたモデルでも、本番では役に立たないことがある。取りこぼす1%こそが、攻撃者が真似方を学習してくる部分だからだ。この記事では、そうした敵対的な環境でも実際に機能するパターンを、動くコードとともに紹介する。
パターン1: ラベルが少ないなら、分類より異常検知
教師あり分類器には「悪性」のラベル付きデータが必要だ。しかしセキュリティの現場では、それが十分に揃うことは稀である。新しい攻撃手法は、誰かがラベル付けを終えるより先に現れる。異常検知はこの問題を回避する。「正常とは何か」だけを学習し、そこからの逸脱を検知するため、攻撃がどんな形をしているかを事前に知る必要がない。
よく使われる安価なベースラインが Isolation Forest だ。ログイン挙動に適用してみる。
import numpy as np
import pandas as pd
from sklearn.ensemble import IsolationForest
# ログインイベントごとの特徴量:
# 時刻、通常の位置からの地理的距離、同一デバイスからの
# 前回ログインとの経過時間、ログイン頻度(直近1時間)、MFAの使用有無
logins = pd.DataFrame({
"hour_of_day": [9, 14, 3, 22, 10, 2, 9, 23],
"geo_distance_km": [2, 5, 1400, 8, 3, 3100, 4, 900],
"hours_since_last_login": [18, 6, 0.1, 30, 20, 0.05, 15, 0.2],
"login_velocity_1h": [1, 1, 6, 1, 1, 9, 1, 5],
"mfa_used": [1, 1, 0, 1, 1, 0, 1, 0],
})
model = IsolationForest(
n_estimators=200,
contamination=0.15, # トラフィック全体のうち異常とみなす割合の事前仮定
random_state=42,
)
model.fit(logins)
# スコアが低い(より負の値)ほど異常度が高い
logins["anomaly_score"] = model.decision_function(logins)
logins["flagged"] = model.predict(logins) == -1
print(logins.sort_values("anomaly_score")[
["hour_of_day", "geo_distance_km", "login_velocity_1h", "flagged"]
])
このモデルは、「1,400km離れた場所からMFAなしで、しかも短時間に何度もログインしている」という行を確実に異常として検知する。それも「これはクレデンシャルスタッフィングだ」と教えられていないのに、である。ここが重要な点だ。モデルは攻撃に名前をつける必要がない。ほかの挙動と比べて統計的に不自然かどうかを判断できればいい。
ただし注意点もある。contamination パラメータや採用する特徴量そのものが、こちらが持ち込んだ仮定であり、攻撃者はまさにその仮定を学習し、そのすぐ下を潜り抜けようとしてくる。
パターン2: 自分のモデルを、攻撃者として試す
マルウェア分類、フィッシング検知、不正利用スコアリングなど、攻撃者と直接対峙するようなタスクにMLを使うなら、いつか誰かが決定境界をすり抜ける入力を作ってくると想定すべきだ。それがどれくらい簡単にできてしまうのかを知る最も早い方法は、他人にやられる前に自分のモデルを自分で攻撃してみることである。
Fast Gradient Sign Method(FGSM)は、モデルの損失が最も大きくなる方向に入力をわずかに動かす、よく知られた手法だ。これは「攻撃者が悪性サンプルを良性に見せかけるために押す方向」の良い近似になる。
import torch
import torch.nn as nn
def fgsm_perturb(model, x, y, epsilon=0.05):
"""
損失勾配の方向に入力xを微小に動かし、誤分類に近づける。
ここでは分類器の決定境界がどれだけ脆いかを検証するために使う。
自分が管理していないシステムに対する攻撃ツールとして
使うことを意図したものではない。
"""
x = x.clone().detach().requires_grad_(True)
output = model(x)
loss = nn.functional.cross_entropy(output, y)
model.zero_grad()
loss.backward()
perturbation = epsilon * x.grad.data.sign()
x_adversarial = (x + perturbation).clamp(0, 1)
return x_adversarial.detach()
# 例: ごくわずかで有界な摂動によって、モデルが
# 「悪性である」と確信する度合いはどれくらい変化するか
model.eval()
x_adv = fgsm_perturb(model, x_sample, y_true_label, epsilon=0.03)
with torch.no_grad():
before = torch.softmax(model(x_sample), dim=1)
after = torch.softmax(model(x_adv), dim=1)
print(f"摂動前の正解クラスへの確信度: {before[0, y_true_label].item():.3f}")
print(f"摂動後の正解クラスへの確信度: {after[0, y_true_label].item():.3f}")
攻撃者にとって実行が容易な、あるいは見た目にほとんど気づかれないレベルの微小な摂動で、モデルの確信度が大きく崩れるなら、それは理論上の話ではなく、実際の脆弱性だ。モデルを訓練するのにかけた労力より、はるかに少ない労力でその境界を見つけられてしまうということを意味する。
パターン3: モデル単体を「唯一の関門」にしない
セキュリティ領域のML運用で最もよく見られる失敗は、モデリングの誤りではなく、アーキテクチャ上の誤りだ。よくできたモデルを作ったあと、その出力をそのままアクション(ログインの遮断、ファイルの隔離、セッションの強制終了)に直結させ、間に何のチェックも挟まないケースが非常に多い。これでは、モデルの死角や敵対的な弱点のすべてが、そのまま本番環境の死角になる。二重の確認がどこにもない。
より頑健なパターンは、モデルのスコアを複数ある信号のうちの一つとして扱い、モデルの正しさに依存しない決定論的なルールと組み合わせることだ。
def evaluate_login_risk(features: dict, model_score: float) -> dict:
"""
MLによるリスクスコアと、決定論的なガードレールを組み合わせる。
モデルは判断材料の一つであり、それ単体が判断そのものにはならない。
"""
hard_signals = []
if features["impossible_travel"]:
hard_signals.append("impossible_travel")
if features["known_malicious_ip"]:
hard_signals.append("known_malicious_ip")
if features["credential_reused_from_breach"]:
hard_signals.append("breached_credential")
if hard_signals:
# 決定論的なルールは、モデルの判断を無条件に上書きする
return {"action": "block", "reason": hard_signals, "model_score": model_score}
if model_score < -0.3:
return {"action": "require_mfa_step_up", "reason": ["ml_anomaly"], "model_score": model_score}
if model_score < -0.1:
return {"action": "log_and_monitor", "reason": ["ml_borderline"], "model_score": model_score}
return {"action": "allow", "reason": [], "model_score": model_score}
ここで得られるものに注目してほしい。仮に敵対的な入力によってモデルのスコアがほぼゼロに騙されたとしても、侵害済み認証情報であることが既知、といった決定論的なチェックはそのまま機能する。モデルには、ルールでは表現しづらい曖昧な中間領域を見つける役割が与えられる一方で、判断そのものの主導権までは渡されない。
セキュリティML向けの成熟度の目安
本番環境に触れる自動化と同じで、「もうモデルがあるから大丈夫」という一つのスイッチとして捉えるのではなく、その段階でどこまでモデルを信頼しているのかを明確にしておくと役に立つ。
| 段階 | モデルが行うこと | 何がアクションの関門になっているか |
|---|---|---|
| 助言 | イベントにスコアを付け、分析担当者のトリアージに使う | フラグの立ったイベントすべてを人間がレビューする |
| 補助 | イベントを優先度順に並べ、想定される原因を提示する | 対応アクションの実行はすべて人間が承認する |
| 副操縦士 | リスクの低い対応(追加ログ収集、緩やかな通知)は自動実行する | アクセスやデータに関わる対応はすべて人間が扱う |
| 限定自律 | 事前承認済みの狭いシナリオ内でのみ自動遮断する(既知の悪性IPレンジなど) | シナリオの境界を定めるのはモデルではなく決定論的ルール |
| 完全自律 | 検知・判断・実行を、個別の人間の介在なしに行う | 事後の集計監査と、定期的な敵対的再テストのみ |
正規ユーザーをロックアウトしたり、データを削除したりしうるタスクについて、この表の下の段階に近いところまで進むべきセキュリティチームは、ほとんどないはずだ。深夜3時に発生する誤検知のコストは、ユーザー全体に対して積み重なると急速に大きくなる。しかもこの種のコストは、システム全体への信頼を損ない、結果として現場が仕組みそのものを回避するようになるという、より厄介な副作用を生む。
実際に痛い目を見るところ
繰り返し見られる失敗は3つあり、どれも特別なものではない。
誰も見ていないコンセプトドリフト。 前四半期の攻撃トラフィックで訓練したモデルは、攻撃者の挙動が変化するにつれて静かに劣化していく。パイプラインの障害とは違い、ドリフトしたモデルはエラーを出さない。見逃し率が週ごとにわずかに悪化し続け、誰かが事後検証で気づくまで放置される。
ベースレートの問題。 実際の攻撃が100万件に1件程度しかないところに、モデルにわずかな偽陽性率があるだけで、誤警報の絶対数が真の陽性を埋め尽くしてしまう。分析担当者はやがてアラートキューそのものを無視するようになる。それはモデルを導入しなかった場合よりも悪い結果だ。
ラベル自体からの特徴量リーク。 ラベルの下流にある情報を、うっかり特徴量として使ってしまい、精度が高く「見える」だけのモデルを作ってしまうことは意外と多い。たとえば「チケットが悪性としてクローズされた」というフラグは、モデルが助けようとしている判断がすでに人間によって下された後にしか立たない。バックテストでは高精度に見えるが、本番では何の役にも立たない。その信号は、判断が下された後にしか存在しないからだ。
結局のところ言いたいこと
ここで紹介したコードは、機械学習として特別なものではない。セキュリティ特有なのは、その前提の置き方だ。入力の分布は意図的にずらされることを前提とする。境界は意図的に探られることを前提とする。そして、実害のあるアクションについて、モデルを最後の決定権者にはしない。モデルを作ること自体は、実は簡単な部分だ。攻撃者のようにモデルを試すこと、その下に決定論的なガードレールを敷いておくこと、そしてモデルがどこまでの自律性を実際に獲得したのかを正直に見極めること——本当のセキュリティの仕事は、そこにある。