パスキーを入れるかどうかは、多要素認証を入れているかどうかとは別の問題です。SMSやTOTPの6桁を求めていても、偽サイトが本物の画面を中継すれば、利用者が入れたコードはそのまま本物のサイトで使われます。パスキー(WebAuthn)は署名にサイトの名前が入るため、転送しても通りません。
連載「事故から逆算するWebセキュリティ」の第11回は、多要素認証を素通りしたフィッシングを入口に、コードを入れる方式とパスキーの差を実測で確かめます。
RELAYED OR BOUND中継できるか
手で入れるコードは偽サイトから転送できる。パスキーは通らない
SMS・TOTP・プッシュ
人が数字を読んで入れる
- 偽サイトに入れた6桁攻撃者が本物へ転送
- 同じ時間窓なら受理実測で34秒後まで通った
- ログイン後のCookieも奪われるAiTM型の手口
パスキー(WebAuthn)
認証器が署名する
- 署名に origin と RP ID が入る偽サイトでは本物の鍵が使えない
- サーバーが照合して拒否実測で origin 違いを拒否
- 秘密鍵は送られない盗んでも再利用できない
30秒でわかる(2026年10月17日時点)
- ・コードは中継できる:SMS・TOTP・プッシュの番号入力は、偽サイトが受け取って本物へ送れば通る
- ・実測:TOTPはRFCのテストベクタ6件と一致。25秒目に出たコードは34秒後まで受理された
- ・パスキーが塞ぐ理由:署名にoriginとRP IDが入る。実測でorigin違い・RP ID違い・古いチャレンジをすべて拒否
- ・対策:管理者からパスキー必須にし、一般利用者には選択肢として出す。復旧経路をパスキーより弱くしない
セキュリティ分野の記事の全体像はサプライチェーン攻撃とは|手口・防御ツール比較・npm 12の新機構まで実践解説から辿れます。ログイン後のセッションを盗まれたときの守り方はトークン窃取を多層で止める——セッション・AI推論・課金まで含めた防御パターンで整理しました。
もう一つ押さえておきたいのは、多要素認証が守るのはそれを掛けた経路だけだという点です。10月16日に公表された、岩井コスモ証券のインターネット取引システムの委託先・トレードワークスの事案では、顧客のログインには多要素認証やパスキーがあり不正ログインは防がれていました。一方で、システムの一部の管理機能に設定の不備があり、そこを悪用されて顧客4,611人分の情報が漏えいしました。管理機能の種類や手口は公表されていません。
多要素認証を素通りしたフィッシングのタイムライン
| 時期 | 出来事 |
|---|---|
| 2011年5月 | RFC 6238(TOTP)公開。検証側が許す通信遅延は「最大1ステップ」を推奨 |
| 2021年9月 | Microsoft が追跡した AiTM フィッシング作戦の開始時期。1万を超える組織が標的になったとされる |
| 2022年7月12日 | Microsoft Threat Intelligence が公表。Evilginx2 で本物のサインイン画面を中継し、パスワードとセッション Cookie を奪った |
| 2025年 | NIST SP 800-63B-4 の確定版。OTP と帯域外認証はフィッシング耐性なし、同期できる認証器は AAL2 まで |
| 2026年10月17日 | 当サイトで TOTP の中継と WebAuthn の照合を実測 |
AiTM(adversary-in-the-middle)は、利用者と本物のサイトの間に攻撃者のプロキシを置く手口です。Microsoft の報告は「これは MFA の脆弱性ではない」と書いています。利用者は本物と同じ手順で多要素認証を通しており、攻撃者は認証が済んだセッションの Cookie を受け取るだけだからです。奪ったメールボックスは、ほかの標的へのビジネスメール詐欺に使われました。同じ報告は、対策として FIDO2 と証明書ベースの認証で「フィッシングに強い」MFA にすることを挙げています。
・Microsoft の報告は同社サイトの本文で確認した(2026-10-17)
・NIST SP 800-63B-4 は usnistgov/800-63-4 リポジトリの HTML 版で確認した。README は確定版の写しだと説明している。確定版の公開月は未確認
・RFC 6238 は GitHub 上の写し(pingidentity/ldapsdk の docs/specs/rfc6238.txt)で確認した
メール経由の詐欺の手口はAmazon SES悪用フィッシング・BEC攻撃急増2026|SPF/DKIM突破の手口とIAM最小権限防御にまとめています。
SMS・TOTP・プッシュ通知が中継される理由
ADVERSARY IN THE MIDDLE中継の流れ
偽サイトは本物の画面を中継するだけ。利用者は本物と同じ手順を踏む
-
誘導
メールやSMSで偽URLへ
-
偽サイト
本物の画面を裏で表示
-
ID・PW・コード
入力を本物へ転送
-
本物のサイト
正しい入力で成功
-
Cookieを奪う
攻撃者がセッションを使う
認証の要素は、知っているもの(パスワード)、持っているもの(端末・鍵)、本人の特徴(生体)に分かれます。SMS と TOTP は「持っているもの」を、画面に出た数字を人が読み写す形で証明します。NIST SP 800-63B-4 §3.2.5 は、手で入力する方式は、出力をそのログインのセッションに結び付けないため、フィッシング耐性があるとはみなさないと定め、偽の検証者が出力を本物へ中継できる例を挙げています。
プッシュ通知は2つに分けて考えます。画面の値を見比べて「承認」を押すだけの型について NIST は、要求を何度も送りつけて根負けさせる「認証疲労」攻撃が観測されたとして、もう認めない方式だとしています。画面の番号をスマートフォンに入れさせる型は疲労攻撃には効きますが、番号も偽サイトが本物から取ってきて見せられるので、中継そのものは止まりません。SMS は NIST が唯一の「制限付き」認証器としていて、OWASP も SIM スワップに弱いと書いています。
TOTPのコードが中継で通るかを実測する
RFC 6238 の TOTP を Python の標準ライブラリだけで書き、付録Bのテストベクタと照合しました。そのうえで、偽サイトが受け取ったコードを何秒後まで本物が受け付けるかを、時刻を固定値で動かして確かめています。
import hmac, hashlib, struct
def totp(key, t, digits=8, step=30, alg=hashlib.sha1):
counter = struct.pack(">Q", t // step) # RFC 6238: T = floor(unixtime / X)
mac = hmac.new(key, counter, alg).digest()
off = mac[-1] & 0x0F # RFC 4226 の動的切り詰め
code = (struct.unpack(">I", mac[off:off + 4])[0] & 0x7FFFFFFF) % 10**digits
return f"{code:0{digits}d}"
def verify(key, code, now, window=1): # 1ステップ前まで許す(RFC 6238 §5.2 の推奨上限)
return any(hmac.compare_digest(totp(key, now - i * 30, len(code)), code) for i in range(window + 1))
# 1) RFC 6238 付録Bのテストベクタ(SHA-1・鍵 "12345678901234567890")
key = b"12345678901234567890"
vectors = {59: "94287082", 1111111109: "07081804", 1111111111: "14050471",
1234567890: "89005924", 2000000000: "69279037", 20000000000: "65353130"}
print("RFC vectors:", all(totp(key, t) == c for t, c in vectors.items()))
print("wrong code :", verify(key, "12345678", 59)) # 陰性対照
# 2) 中継:利用者が偽サイトに入れた6桁を、攻撃者が本物のサイトへ転送する
secret = b"demo-secret-for-relay-check"
t_user = 1_800_000_000 + 25 # 利用者がコードを読んだ時刻
code = totp(secret, t_user, digits=6)
for delay in (0, 5, 34, 35, 70):
print(f"relay +{delay:>2}s -> accepted={verify(secret, code, t_user + delay)}")
| 確かめたこと | 結果 |
|---|---|
| RFC 6238 付録B(SHA-1)の6件 | すべて一致(陽性対照) |
誤ったコード 12345678 |
拒否(陰性対照) |
| 中継までの遅れ 0秒・5秒・34秒 | 受理 |
| 中継までの遅れ 35秒・70秒 | 拒否 |
Python 3.13.16、2026年10月17日に実行しました。受け付ける長さはコードを読んだ位置で変わり、ステップの先頭で読めば最長59秒です。プロキシは利用者の入力を即座に転送するので、この窓に収まります。RFC は同じコードの2回目を拒否するよう求めていますが、中継では攻撃者の送信が最初の1回になるため防げません。
パスキーがフィッシングを塞ぐ仕組み:originとRP IDの照合
パスキーは W3C WebAuthn の「discoverable credential」の通称で、仕様は複数端末で同期されるものを「synced passkeys」と呼んでいます。登録時に認証器がサイトごとの鍵ペアを作り、サーバーには公開鍵だけを渡します。ログインでは認証器がチャレンジに署名し、その署名対象には次の2つが入ります。
・RP ID のハッシュ:鍵を使えるドメイン。ブラウザは、ページのドメインと無関係な RP ID の鍵を呼び出させず SecurityError で止める(関連 origin の宣言がない場合)
・clientDataJSON の origin:ブラウザが書き込む、実際に開いているページの origin
NIST はこれを「検証者名への結び付け(verifier name binding)」と呼び、WebAuthn をフィッシング耐性の例に挙げています。WebAuthn の仕様は、サーバーが origin を照合しないと悪意あるサイトが有効な応答を得られるおそれがあるとし、認証器の不具合に備える2層目として origin の照合を必須にしています。
WebAuthnの照合を実測する
本物のブラウザと認証器は使わず、cryptography で ES256 の鍵を作るソフトウェアの認証器を自作し、py_webauthn 3.0.1 の verify_authentication_response に4通りの応答を渡しました。
import hashlib, json, os
import cbor2
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from webauthn import verify_authentication_response
from webauthn.helpers import bytes_to_base64url as b64
# ソフトウェアの認証器を自作する(秘密鍵はこの関数の外に出さない)
priv = ec.generate_private_key(ec.SECP256R1())
pub = priv.public_key().public_numbers()
cose_pub = cbor2.dumps({1: 2, 3: -7, -1: 1, -2: pub.x.to_bytes(32, "big"), -3: pub.y.to_bytes(32, "big")})
cred_id = os.urandom(16)
def assertion(rp_id, origin, challenge):
auth_data = hashlib.sha256(rp_id.encode()).digest() + bytes([0x05]) + (1).to_bytes(4, "big") # UP+UV
cdj = json.dumps({"type": "webauthn.get", "challenge": b64(challenge), "origin": origin}).encode()
sig = priv.sign(auth_data + hashlib.sha256(cdj).digest(), ec.ECDSA(hashes.SHA256()))
return {"id": b64(cred_id), "rawId": b64(cred_id), "type": "public-key",
"response": {"authenticatorData": b64(auth_data), "clientDataJSON": b64(cdj), "signature": b64(sig)}}
def server(cred, challenge): # 本物のサイト(RP)側の検証
try:
verify_authentication_response(credential=cred, expected_challenge=challenge,
expected_rp_id="example.com", expected_origin="https://example.com",
credential_public_key=cose_pub, credential_current_sign_count=0, require_user_verification=True)
return "accepted"
except Exception as e:
return f"rejected: {e}"
ch = os.urandom(32)
print("1 legit :", server(assertion("example.com", "https://example.com", ch), ch))
print("2 relayed origin :", server(assertion("example.com", "https://examp1e-login.com", ch), ch))
print("3 other rp_id :", server(assertion("examp1e-login.com", "https://example.com", ch), ch))
print("4 old challenge :", server(assertion("example.com", "https://example.com", ch), os.urandom(32)))
| ケース | 結果 |
|---|---|
| 1. 正規のページで署名 | 受理(陽性対照) |
2. 偽サイト https://examp1e-login.com の origin で署名 |
拒否:Unexpected client data origin |
| 3. RP ID だけ別ドメイン | 拒否:Unexpected RP ID hash |
| 4. 前回のチャレンジへの応答を使い回す | 拒否:チャレンジ不一致 |
署名が正しくても、どのページで作られた署名かが合わなければ通りません。TOTP の6桁と違い、偽サイトが受け取ったものを本物へ送っても使い道がありません。
・ケース2はブラウザが偽サイトに本物の鍵を呼ばせない前提を外した、サーバー側の2層目だけの確認
・実際のブラウザでの SecurityError と、登録(verify_registration_response)は未検証
多要素認証の方式比較と、同期パスキーの位置づけ
WHICH FACTOR HOLDS中継で止まるか
数字を手で入れる方式はすべて中継を通す
偽サイトを経由したログイン
- SMS電話番号に届く 転送で通るNISTは制限付きの方式
- TOTPアプリの6桁 転送で通る30秒の窓の中なら有効
- プッシュ番号を入力 転送で通る承認だけ型は連打も効く
- パスキーWebAuthn 偽サイトでは使えないorigin と RP ID で照合
| 方式 | 中継への耐性 | 復旧の負担 | 使い勝手 | 一次情報の扱い |
|---|---|---|---|---|
| SMS・音声 | なし | 番号変更で再登録。SIMスワップで奪われる | 受信を待つ。電波が要る | NIST は制限付き |
| TOTP | なし | 端末を失うとシードの再登録 | アプリの6桁を入力 | OWASP は「短時間だがフィッシングに弱い」 |
| プッシュ(承認だけ) | なし。連打もされる | アプリの再登録 | 1タップ | NIST は不可(値の受け渡しが必要) |
| プッシュ(番号入力) | なし | アプリの再登録 | 番号を入力 | 疲労攻撃の対策として OWASP が推奨 |
| 同期パスキー | あり | 同期先のアカウントの復旧が新たな弱点 | 指紋・顔・PINだけ | NIST は AAL2 まで |
| セキュリティキー | あり | 紛失に備え予備を2本目に登録 | 挿してタッチ | 取り出せない鍵。AAL3 の候補 |
同期パスキーは、秘密鍵をクラウド(NIST の言う sync fabric)へ暗号化して預け、別の端末でも使えるようにします。NIST SP 800-63B-4 の付録Bは、同期は鍵を取り出せることを意味するので AAL3 の非エクスポート要件に反するとしつつ、AAL2 では要件を満たせば使えるとしています。サーバーは UV(ユーザー検証)フラグを確かめ、立っていなければ1要素として扱います。
同じ付録は、同期先アカウントの復旧手続きを鍵への弱い入口として挙げています。同期済みかを示す BS フラグは、一般向けサービスで受け入れの条件にすべきでないとしています。認証器の証明(attestation)を必須にすると、利用者を SMS のような弱い方式へ追いやるとも書いています。
対策:パスキーの導入順とアカウント復旧の守り方
導入の順番
・1. 管理者・特権アカウントを先にパスキー必須にする。OWASP は管理者に MFA を必須にするよう勧めています。奪われたときの被害が大きい順に固めます
・2. 一般利用者には選択肢として出す。NIST は AAL2 で、フィッシングに強い方式を少なくとも1つ提供することを必須にしています。パスワードと TOTP は当面残します
・3. 重要操作でパスキーを求め直す。パスワード・メールアドレスの変更、MFA の無効化、管理者権限への昇格は、OWASP が MFA を求める場面に挙げています
・4. サーバーの検証を締める。origin は許可リストで完全一致、RP ID は登録とログインで同じ値、UV フラグを確認します。CBOR や署名の検証は自作せず、保守されているライブラリを使います
・5. プッシュは番号入力にし、回数に上限を置く。NIST も、前回の成功から送る通知の数に上限を置くべきだとしています
アカウント復旧をいちばん弱い入口にしない
OWASP の Passkey Security Cheat Sheet は、パスキーのアカウントの強さは最も弱い復旧経路で決まると書いています。メールや SMS の復旧で、パスキーを黙って迂回させないことが要点です。NIST も、認証器を失ったときの手続きを多くの方式の弱点とし、人が対応する復旧ではソーシャルエンジニアリングの危険があるとしています。
・登録時に2つ目のパスキーやセキュリティキーを勧め、失う前に備えてもらう
・保存型の復旧コードは64ビット以上の乱数で作り、ハッシュで保存し、試行回数を絞る(NIST)
・AAL2 の復旧は「別の手段で得た復旧コード2つ」か「復旧コード1つと、残っている1要素」。身元確認済みなら確認のやり直しも可(NIST)
・認証器の追加・復旧のたびに、登録済みの連絡先へ通知する(NIST は必須)
・サポート窓口に、本人確認を飛ばして MFA を外す権限を持たせない。外すなら遅延と通知を挟む
・復旧のあとはセッションを作り直し、登録済みの認証器を見直してもらう(OWASP)
影響範囲と自システムの確認
影響を受けるのは、多要素認証を SMS・TOTP・プッシュだけで提供しているサービスです。これらを入れていても、中継型のフィッシングでは乗っ取りを防げません。逆に、パスキーを入れても防げないものがあります。OWASP は、乗っ取られたセッションからの攻撃者のパスキー追加、弱い復旧経路による迂回、端末や同期先アカウント自体の侵害を挙げています。
・ログインの API やモバイルアプリなど、別の入口にも同じ MFA がかかっているか
・パスキーで入れる利用者が、SMS の復旧に落とされる経路が残っていないか
・認証器の追加・削除に、既存の要素での再認証と通知があるか
・WebAuthn の検証で origin と RP ID を固定値と照合し、UV フラグを見ているか
SCIM や OAuth の連携からアカウントを奪われた事例はbetter-auth 脆弱性|SCIM/OAuthでアカウント乗っ取り、CVSS9.9のGHSA4件で扱っています。
参照ソース
・岩井コスモ証券「外部委託先への不正アクセスに伴うお客様情報の漏洩について」(2026-10-16)・トレードワークスの適時開示(2026-10-16)
・NIST SP 800-63B-4 Authentication and Authenticator Management(公式の HTML 版リポジトリ usnistgov/800-63-4 の §3.2.5・付録B・Account Recovery で確認)
・W3C Web Authentication Level 3(仕様の原稿 w3c/webauthn の index.bs で確認)
・RFC 6238 TOTP: Time-Based One-Time Password Algorithm(GitHub 上の写しで §4・§5.2・付録Bを確認)
・OWASP Multifactor Authentication Cheat Sheet(OWASP/CheatSheetSeries の原稿で確認)
・OWASP Passkey Security Cheat Sheet(同上)
・Microsoft Threat Intelligence「From cookie theft to BEC: Attackers use AiTM phishing sites as entry point to further financial fraud」(2022-07-12)
・py_webauthn(PyPI の webauthn 3.0.1・BSD-3-Clause を実行に使用)





