MFAは「銀の弾丸」ではない:SMS認証を無力化する攻撃者と、FIDO2で築く真の鉄壁
「MFAを入れているから大丈夫」。もし社内や開発現場でそう安心しきっているメンバーがいるなら、今すぐその認識を叩き直す必要がある。
私はこれまで数多くの侵入テストやインシデント対応を行ってきたが、攻撃者にとって、SMS認証やレガシーなTOTP(認証アプリ)は、もはや「少し手間のかかる門番」に過ぎない。今回は、なぜこれらが簡単に破られるのか、そしてエンジニアとして我々がどう「フィッシング耐性」のある認証へ舵を切るべきか、現場のリアルな視点から解説する。
—
1. SMS認証・TOTPが「ザル」である理由
攻撃者はわざわざ暗号を解読したりはしない。彼らが好むのは「AiTM(Adversary-in-the-Middle)」という手法だ。
攻撃の仕組み(PoCの概念)
1. プロキシの設置: 攻撃者は正規のサイトそっくりのフィッシングサイトを構築する。
2. リアルタイム転送: ユーザーがそのサイトにログイン情報を入力すると、攻撃者のサーバーは即座に正規サイトへそれを転送する。
3. トークンの奪取: 正規サイトから送られてきたSMS認証コードやTOTPコードを、ユーザーがフィッシングサイトに入力した瞬間に攻撃者が横取りし、正規のセッションを確立する。
この攻撃の恐ろしいところは、ユーザーが「本物のサービスを使っている」と信じ込んでいる点だ。SMSのコードを盗むためのツール(Evilginxのようなプロキシフレームワーク)は、今やGitHubで誰でも手に入る。もはや、MFAのコードを画面に入力させるUI自体が、攻撃者に「どうぞ、これを使ってログインしてください」と鍵を渡しているようなものなのだ。
—
2. フィッシング耐性の切り札:FIDO2/WebAuthn
この状況を打破する唯一の解が、FIDO2(WebAuthn)だ。これは「オリジン(ドメイン名)の検証」をプロトコルレベルで行うため、攻撃者が用意した偽ドメインでは認証が成立しない。物理的なセキュリティキー(YubiKey等)や、デバイスの生体認証(TouchID/FaceID)は、秘密鍵を外部に一切出さない。
実務での実装:WebAuthnの導入
WebAuthnの実装は複雑に見えるが、ライブラリを使えば実はシンプルだ。以下は、ユーザー登録時にブラウザ側で認証器を登録する際のフロントエンドのコード例だ。
// WebAuthn 登録用クライアントサイド処理
async function registerCredential() {
// サーバーから取得したチャレンジデータ
const options = await fetch('/get-registration-options').then(res => res.json());
// ブラウザの WebAuthn API を実行
const credential = await navigator.credentials.create({
publicKey: {
...options,
challenge: Uint8Array.from(atob(options.challenge), c => c.charCodeAt(0)),
user: {
id: Uint8Array.from(atob(options.user.id), c => c.charCodeAt(0)),
name: "user@example.com",
displayName: "Security Chief"
}
}
});
// サーバーへ認証器の公開鍵を送信
await fetch('/verify-registration', {
method: 'POST',
body: JSON.stringify({ credential })
});
}
この実装において重要なのは、navigator.credentials.create を呼び出す際、ブラウザが自動的に「現在表示しているドメイン(origin)」を認証器に伝えることだ。もし攻撃者が example-login.com のような偽ドメインでサイトを構築しても、認証器は「このドメインは正当な example.com ではない」と判断し、秘密鍵の使用を拒否する。これがフィッシング耐性の正体である。
—
3. インフラレベルでの防衛:WAFとヘッダー設定
コードレベルだけでなく、インフラ側でも攻撃者がプロキシを立てることを困難にする設定が必要だ。
Nginxでのセキュリティヘッダー設定
攻撃者がiframeなどで正規サイトを埋め込んでフィッシングを行うケースを防ぐため、X-Frame-Options や Content-Security-Policy を厳格に設定する。
# /etc/nginx/conf.d/security.conf
# クリックジャッキング対策: 自ドメイン以外からの埋め込みを禁止
add_header X-Frame-Options "SAMEORIGIN" always;
# フィッシング対策の基本: 信頼できるソースのみ許可
add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'; object-src 'none';" always;
# HSTS: 強制的にHTTPSへリダイレクト
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
—
セキュリティチーフからの提言:今日から何をすべきか
1. SMS認証の廃止計画を立てる: SMS認証はもはや「認証」としてカウントせず、単なる「本人確認の補助」とみなすべきだ。
2. WebAuthnの実装を検討する: SimpleWebAuthn(Node.js向けライブラリなど)を使えば、学習コストは大幅に下げられる。
3. ユーザーに「ドメインを見る」癖を付けさせる: 技術的な防衛は限界がある。UIで「このサイトは現在 bank-auth.com です」といった警告を大きく表示するようなデザイン上の工夫も検討してほしい。
セキュリティは「ツールを入れたら終わり」ではない。攻撃者がどのレイヤーで情報を盗もうとしているのか、その動機と手口を理解すること。それが、君が守るシステムを「攻略不可能」にするための第一歩だ。
コードを書くとき、サーバーを構築するとき、常に自問してほしい。「もし、この通信の間に攻撃者が入り込んでいたら、何が奪われるのか?」と。その疑念こそが、最強のエンジニアの証明だ。
コメント