OktaのMFA疲労攻撃をSystem Logから拾う — Claudeに集計スクリプトを書かせる
OktaのSystem Log APIで、プッシュ通知の連続拒否を時間窓で数える手順です。Classic向けのdeny_pushがIdentity Engineでは出ない点と、集計をClaude Codeに書かせる進め方を示します。
MFA疲労攻撃は、盗んだパスワードでサインインを試し、本人のスマートフォンにプッシュ通知を何度も送りつける手口です。根負けして1回でも承認されたら突破されます。ログ側から見ると、同じユーザーに対する認証要素の失敗が短時間に積み上がる形で現れます。
この記事では、そのパターンをOktaのSystem Log APIから取り出し、時間窓で数えるところまでを扱います。集計スクリプトの下書きはClaude Codeに任せられますが、どのイベントを数えるかを間違えると、スクリプトが正しくても何も検出できません。そこで最初にイベントの選び方を固め、そのあとでスクリプトの作り方と検証に進みます。
定番の「deny_push」は、Identity Engineでは記録されない
検索すると、拒否されたプッシュを拾うイベントとして user.mfa.okta_verify.deny_push が挙がります。Event Typesカタログの説明は「User rejected Okta push verify」です。ただし同じ説明に、このイベントはClassicのV1 API呼び出しで発生するもので、Identity Engine(OIE)では代わりに user.authentication.auth_via_mfa が使われ、理由は INVALID_CREDENTIALS になる、と書かれています。
つまり、組織がOIEなら deny_push で絞った検索は空振りします。Classicの組織なら逆に auth_via_mfa は第2要素の検証だけを記録します。カタログによれば、OIEでは第1要素と第2要素の両方で発生するためです。
| 組織の種類 | 拒否の拾い方 | 注意点 |
|---|---|---|
| Classic | 拒否の拾い方user.mfa.okta_verify.deny_push | 注意点auth_via_mfa は第2要素のみ |
| Identity Engine | 拒否の拾い方user.authentication.auth_via_mfa かつ outcome.result が FAILURE | 注意点第1要素の失敗も混ざる |
どちらの組織か分からないときは、自分のログで両方を引いて確かめるのが早い道です。eventType sw "user.mfa.okta_verify" と eventType eq "user.authentication.auth_via_mfa" を別々に流し、どちらに件数が出るかを見ます。sw は「で始まる」を表す演算子で、System Logのクエリ解説にも例があります。
プッシュを送った側のイベントもあります。system.push.send_factor_verify_push は、デバイスにプッシュが送られるたびに発生します。拒否の件数だけでなく、送られた通知の総数も数えたいときの分母になります。
Event Typesカタログには、応答されないまま放置されたプッシュがどのイベントとして残るかの記載がありません。「拒否はされなかったが何度も鳴った」ケースは拒否イベントに乗らない可能性があるため、自分のテストユーザーで通知を無視して、ログにどう出るかを一度見ておくと安心です。
System Log APIでイベントを取り出す
取得先は GET /api/v1/logs です。疲労攻撃の調査は期間が決まっているので、since と until を両方指定する「期間指定型」のリクエストにします。この型では結果が published の順に並び、ページ送りの最後には次ページのリンクが付きません。
| パラメータ | 意味 | メモ |
|---|---|---|
since / until | 意味published の下限と上限 | メモISO 8601形式。両方指定で期間指定型 |
filter | 意味SCIM形式の条件式 | メモeventType eq、actor.id eq、target.id eq などが使える |
limit | 意味1回の取得件数 | メモ0〜1000、既定は100 |
sortOrder | 意味並び順 | メモASCENDING が既定 |
q | 意味キーワード検索 | メモ1語40字まで、最大10語 |
認証にはAPIトークン(SSWS)かOAuth 2.0のアクセストークンを使います。OAuthなら必要なスコープは okta.logs.read で、ログの読み取りだけに絞れます。
curl -s -G "https://$OKTA_DOMAIN/api/v1/logs" \
-H "Authorization: SSWS $OKTA_API_TOKEN" \
--data-urlencode "since=2026-10-19T00:00:00Z" \
--data-urlencode "until=2026-10-20T00:00:00Z" \
--data-urlencode 'filter=eventType eq "user.authentication.auth_via_mfa"' \
--data-urlencode "limit=1000" \
-D headers.txt -o mfa.json次ページがあるときは、レスポンスヘッダーの link に rel="next" のURLが入ります。after の値は自分で組み立てず、このリンクをそのまま辿ります。第2のクエリとして、eventType eq "system.push.send_factor_verify_push" でも同じ期間を取り、2つのファイルを1つの配列にまとめておきます。イベント種別ごとに別々のクエリで取得し、取得後に1つの配列へまとめます。
取得時の制約は3つあります。
- 1回のクエリのタイムアウトは30秒です。期間が長いと途中で切れるので、日単位などに分けます。
- 90日より古いログは返りません。範囲を超えたクエリは成功しますが、期間内の分だけが返ります。
- レート制限を超えると、エラーコード
E0000047が返ります。
落とし穴
published の順序は、取得の型で保証が変わる
ストリーム型(until なしで昇順)の取得は、イベントが記録された順に返すため、published の順と食い違うことがあります。時間窓の集計では順序が前提になるので、期間指定型で取るか、取得後に published で並べ直してください。また、期間内のイベントが稀に遅れて届くことがある点も、期間指定型の注意書きに書かれています。直近の数分は、あとで再取得する前提で見るのが無難です。
何を数えれば「疲労」の兆候と言えるのか
ログに現れる手がかりは、単独では決め手になりません。パスワードの打ち間違いや、通知に気づかず拒否した正当なユーザーも、同じ失敗として残るからです。複数の手がかりを重ねて読みます。
重ねて読む4つの手がかり
時間窓内の失敗数
同じ
actor.idのauth_via_mfa失敗が、10分など短い窓にいくつ入るか。窓の長さとしきい値は組織の実態に合わせて決めます。送られた通知の総数
system.push.send_factor_verify_pushの件数。拒否が少なくても、通知が鳴り続けていれば同じ圧力がかかっています。失敗元のIPアドレス
client.ipAddressが単一か複数か。通知を受ける本人の端末と、サインインを試す側のIPは別になります。失敗の後の成功
窓の直後に、同じユーザーの
auth_via_mfaがSUCCESSで記録されていないか。しかも失敗側と異なるIPからなら、最優先で見る対象です。
しきい値の数字は、Oktaが定めた基準ではありません。サンプルのスクリプトでは10分に5回としていますが、自組織の通常の失敗頻度を数日分見てから決めるのが現実的です。
失敗したサインインのログ同士を、セッションIDで束ねる方法は使えません。System Logのクエリ解説は、サインインに失敗したイベントでは authenticationContext.externalSessionId が null になる例を示しています。セッションが発行されないためです。束ねる単位は、actor.id と時間窓にします。
Claude Codeに集計スクリプトを書かせる
ここからがClaudeの出番です。やることは単純で、取得済みのJSONファイルを読んで上の4つの手がかりを数えるスクリプトを書かせます。ネットワークに出る必要も、APIトークンも要りません。
先に、Claude Codeの権限を絞っておきます。APIトークンはシェルの環境変数に置き、Claudeには取得済みのファイルだけを渡す構成です。
{
"permissions": {
"deny": [
"Read(./.env)",
"Bash(curl *)",
"WebFetch"
]
}
}Read(./.env) はカレントディレクトリの .env の読み取りを止める書き方で、WebFetch のように値を付けない名前は、そのツール自体をClaudeから外します。ただし Bash(curl *) は、/usr/bin/curl のようにパスで呼ばれた場合や sh -c の中では一致しません。権限ルールはClaudeが普段出すコマンド形式を止めるもので、セキュリティの境界ではない、と権限のドキュメントに書かれています。APIトークンは、環境変数での管理と読み取り専用スコープで守ります。
指示文には、イベント名と条件を具体的に書きます。
mfa.json(System Log のイベント配列)を読み、ユーザー(actor.id)ごとに集計する
Python 3 標準ライブラリだけのスクリプト fatigue.py を書いてください。
- 対象は eventType が user.authentication.auth_via_mfa で outcome.result が FAILURE のイベント
- published 順に並べ直したうえで、10分の窓に入る失敗数の最大値を出す。5回以上のユーザーだけ出力
- system.push.send_factor_verify_push の件数を、同じユーザーで数える
- 失敗元のIP一覧と、最後の失敗以降に SUCCESS があるか、その成功IPが失敗側に無いIPかを出す
- 出力は1ユーザー1行のJSON出てきたスクリプトは、読んで意図どおりか確かめてから使います。以下は同じ仕様で書いた一例です(窓の端を詰めていく単純な2ポインタ方式)。
#!/usr/bin/env python3
import json, sys
from collections import defaultdict
from datetime import datetime, timedelta
WINDOW = timedelta(minutes=10)
MIN_FAIL = 5
def ts(s):
return datetime.fromisoformat(s.replace("Z", "+00:00"))
def main(path):
events = json.load(open(path))
by_user = defaultdict(list)
for e in events:
by_user[e["actor"]["id"]].append(e)
for uid, evs in by_user.items():
evs.sort(key=lambda e: e["published"])
mfa = [e for e in evs if e["eventType"] == "user.authentication.auth_via_mfa"]
fails = [e for e in mfa if e["outcome"]["result"] == "FAILURE"]
pushes = [e for e in evs
if e["eventType"] == "system.push.send_factor_verify_push"]
best, lo = 0, 0
for hi in range(len(fails)):
while ts(fails[hi]["published"]) - ts(fails[lo]["published"]) > WINDOW:
lo += 1
best = max(best, hi - lo + 1)
if best < MIN_FAIL:
continue
last_fail = ts(fails[-1]["published"])
later_ok = [e for e in mfa if e["outcome"]["result"] == "SUCCESS"
and ts(e["published"]) >= last_fail]
fail_ips = sorted({e["client"]["ipAddress"] for e in fails})
ok_ips = sorted({e["client"]["ipAddress"] for e in later_ok})
print(json.dumps({
"user": evs[0]["actor"]["alternateId"],
"max_failures_in_window": best,
"pushes_sent": len(pushes),
"failure_ips": fail_ips,
"success_after_failures": bool(later_ok),
"success_ips": ok_ips,
"success_from_new_ip": bool(set(ok_ips) - set(fail_ips)),
}, ensure_ascii=False))
main(sys.argv[1])本番のログに当てる前に、仕込んだデータで検証する
書かれたスクリプトが期待どおり拾うかは、実ログを待たずに確かめられます。架空のユーザー2人分のイベントを自分で作って流します。1人目(alice)は7分ほどの間に失敗とプッシュ送信を7回重ね、その後に別のIPから成功します。2人目(bob)は50分空けて2回失敗しただけです。
python3 -I fatigue.py sample.json手元のPythonで動かした出力は、次の1行です。ドキュメント用の予約IP(RFC 5737のアドレス)と架空のアドレスを使った合成データなので、実際の攻撃ログではありません。
{"user": "alice@example.com", "max_failures_in_window": 7, "pushes_sent": 7, "failure_ips": ["198.51.100.7"], "success_after_failures": true, "success_ips": ["203.0.113.9"], "success_from_new_ip": true}bobは窓に入る失敗が1回止まりなので出力されません。確認したいのは、拾うべき人が拾われ、拾うべきでない人が拾われない、という2点です。しきい値を境にした動きも、5回の失敗を持つデータと4回のデータで見ておくと、窓の端の数え間違い(1つずれ)を早く見つけられます。
検出結果から対応手順の案を出させる
集計結果が出たら、2段目としてClaudeに「このユーザーにまず何をするか」の叩き台を書かせられます。ここで渡すのは生ログではなく、上の1行JSONのような集計済みの結果にします。生ログには全従業員のメールアドレスとIPアドレスが入っているので、必要なユーザーの分だけ、場合によっては仮名に置き換えてから渡します。
依頼の書き方は、判断材料を限定するのがコツです。
次の集計結果から、確認の優先順位と、本人への連絡文の下書きを作ってください。
「侵害された」とは断定せず、ログから言えることと言えないことを分けて書くこと。案をそのまま実行するのは避けます。セッションの終了やパスワードのリセットは、ユーザーの業務に直接影響する操作です。提案は人が読み、本人に別の経路(電話など、通知を送っているチャネル以外)で確認してから、管理コンソールで実施します。Claudeには、判断材料の整理と文面の下書きまでを任せる分担が、事故が少なくなります。
つまずきやすい点
落とし穴
deny_pushで検索して「攻撃は無かった」と結論する
OIEの組織で user.mfa.okta_verify.deny_push を引くと、攻撃があっても0件になります。冒頭の表のとおり、OIEでは拒否が auth_via_mfa の失敗として残ります。件数が0のときは、イベント名が組織の種類に合っているかを先に疑ってください。
- 第1要素の失敗が混ざる: OIEでは
auth_via_mfaが第1要素にも発生するため、第1要素の失敗も数えに入ります。直前直後のuser.session.startと並べて見ると、手がかりになることがあります。 - 正当な拒否の誤検知: 通知を誤って拒否する本人は日常的にいます。窓の長さとしきい値は、通常時の分布を見てから調整します。
- 定期実行は別の設計: 継続的な監視には、ストリーム型の取得か、SIEMへの転送のほうが向いています。SIEM側の設計はCompliance APIのSIEM連携に、同じ「ログを引いて不審な条件を探す」作業をEntra IDで行う手順はEntra IDのサインインログをKQLで調べるにあります。
Oktaには設定変更のイベントもあります。security.attack_protection.settings.update は、パスワード攻撃対策の設定が更新されたときに発生し、MFA時に「パスワードより先に所持要素を要求する」設定もその対象に含まれます。意図しない変更を拾う用途として、疲労攻撃のログと並べて見ておく価値があります。
まとめ
Oktaの拒否イベントは、Classicなら deny_push、OIEなら auth_via_mfa の失敗と、組織の種類で名前が変わります。最初に自分の組織のログでどちらが出るかを確かめれば、検索が空振りする失敗を避けられます。そのうえで、時間窓の失敗数、プッシュ送信数、IP、失敗後の成功の4点を重ねると、疲労攻撃の兆候を筋よく読めます。
Claude Codeに任せるのは、取得済みJSONの集計スクリプトと、集計結果を元にした確認手順の下書きまでにとどめる構成が扱いやすいはずです。スクリプトの検証は、仕込んだデータで拾う・拾わないの両方を確かめます。
Claude側の利用状況をIDの観点から点検する話は、Claude利用をOkta ISPMで洗うで扱っています。