2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

なぜSMS認証は弱いと言われるのか

2
Posted at

はじめに

海外で現地のSIMに差し替えた初日、銀行アプリにログインしようとして「SMSで確認コードを送信しました」の画面で止まりました。日本の番号のSIMは机の上。コードは日本の番号に飛んでいく。届くわけがない。結局その旅の間、銀行には一度も入れませんでした。

一方で、同じ旅の間も認証アプリの6桁は普通に出ていました。機内モードでも出る。電波が無くても出る。SMSは届かないのに、なぜこっちは動くのか。そもそもこの6桁、どこから来ているのか。

気になって仕様(RFC 6238)を読み、Pythonで書いてみたら20行足らずで、スマホのGoogle Authenticatorと同じ数字が出ました。この記事はその過程と、「SMSと認証アプリは何が違って、どちらも何に負けるのか」の整理です。

執筆時点は2026年9月です。NIST SP 800-63B は第4版に改訂されていますが、SMS等の電話網を使う認証を「制限付き」とする扱いは第3版から続いているものです。本文ではその内容に沿って書いています。コードは Python 3.10 以上を前提にしています(int | None の型注釈を使っているため)。

対象読者
2段階認証を使っているが仕組みを説明できない人。「SMSより認証アプリが安全」と聞いたことはあるが理由を知らない人。読了目安は10分ほど。Pythonは標準ライブラリしか使いません。

忙しい人向けの結論です。

問い 答え
認証アプリの6桁はどこから来る? 登録時に共有した秘密の値と、現在時刻から、スマホの中だけで計算している。通信していない
なぜSMSは弱いと言われる? 電話番号という「乗っ取れる経路」に依存するから。SIMスワップ、番号の再発行、そして中継フィッシング
認証アプリなら安全? SIMスワップには強い。でも中継フィッシングには負ける。正しい6桁を偽サイトに打てば終わり
じゃあ何が違うの? パスキーは「どのサイトに対する応答か」が署名に含まれる。6桁にはそれが無い

参考文献

仕様と一次情報を先に置きます。

「SMSで確認コード」が弱いと言われる3つの理由

6桁のコードは20ビット程度の情報量しか無いので、総当たりや再利用への対策(試行回数制限、一度使ったコードは受け付けない)が必要です。ただそれは認証アプリの6桁にも共通の話で、SMSに固有の弱点は別にあります。電話番号という届け先と、そこまでの配送経路です。理由は3つに整理できます。

1. SIMスワップ。 攻撃者があなたになりすまして携帯ショップでSIMを再発行させると、あなたの番号は攻撃者のスマホで生きます。以後、あなた宛のSMSは全部攻撃者に届く。日本でも2024年に、偽造マイナンバーカードで本人確認を通してSIMを再発行させ、SMS認証を突破して不正送金や高額購入に至った事案が複数報道されています。狙われたのは氏名・住所・生年月日が公開されている地方議員でした。

2. 電話網そのものの傍受。 SMSはエンドツーエンドで暗号化されておらず、事業者間の接続やローミングで今も使われている古い信号方式(SS7)を含む通信事業者網の弱点を突いて、特定番号宛のSMSを横取りした事例が海外で報告されています。個人が対策できる話ではなく、「SMSは暗号化されたチャネルではない」という前提だけ覚えておけばよいです。

3. 中継フィッシング。 これはSMSに限らず認証アプリにも効くので、後で1節使って説明します。

そして私の旅先の話は、この3つとは別の、もっと素朴な弱点です。番号に依存する認証は、番号が使えない状況で本人も締め出される。海外SIM、機種変更の途中、圏外。セキュリティの問題ではなく可用性の問題ですが、SMS認証しか選べないサービスに当たると本人が一番困ります。

認証アプリの6桁はどこから来ているのか

認証アプリ(Google Authenticator、Microsoft Authenticator、1Passwordなど)が表示する6桁はTOTP(Time-based One-Time Password)と呼ばれ、RFC 6238で定義されています。仕組みは思っているより素朴です。

totp-how-six-digits-are-made.png

  1. 登録時、サービスがランダムな秘密の値(共有シークレット)を作り、QRコードで見せる。スマホがそれを読んで保存する。この1回だけ、秘密が両者の間を移動する
  2. 以後、スマホは「共有シークレット」と「現在時刻を30秒で割った整数」からHMAC-SHA1を計算し、その結果を6桁に丸めて表示する
  3. サーバーも同じ材料で同じ計算をして、あなたが打った6桁と一致するかを見る

どちらの側にもネットワークが出てこない。スマホが機内モードでも6桁が出るのはこのためです。私がSIMを差し替えても動いていたのは、SMSと違って「届ける」工程がそもそも存在しないからでした。

Pythonで20行書いたら、Authenticatorと同じ6桁が出た

RFC 6238の擬似コードをそのままPythonにしました。標準ライブラリだけです。

totp.py
import hmac, hashlib, struct, time, base64

def hotp(secret: bytes, counter: int, digits: int = 6) -> str:
    msg = struct.pack(">Q", counter)                       # カウンタを8バイトのビッグエンディアンに
    mac = hmac.new(secret, msg, hashlib.sha1).digest()     # HMAC-SHA1 → 20バイト
    offset = mac[-1] & 0x0F                                # 最後のバイトの下位4ビットで切り出し位置を決める
    code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
    return str(code % 10 ** digits).zfill(digits)          # 10^6 で割った余りを6桁ゼロ埋め

def totp(secret: bytes, at: int | None = None, step: int = 30, digits: int = 6) -> str:
    t = int(time.time() if at is None else at) // step     # 現在時刻を30秒刻みの整数に
    return hotp(secret, t, digits)

if __name__ == "__main__":
    b32 = "U6KIWCCUQACEWCPVNI33LKEKQUBWVZ75"                # 登録時にQRから読む Base32 の秘密
    print(totp(base64.b32decode(b32)), "残り", 30 - int(time.time()) % 30, "")

まず、自分の実装が正しいかをRFCの付録にあるテストベクタで確かめました。共有シークレットを 12345678901234567890、桁数を8にして、RFCに載っている時刻を入れます。

k = b"12345678901234567890"
vectors = [(59, "94287082"), (1111111109, "07081804"), (1111111111, "14050471"),
           (1234567890, "89005924"), (2000000000, "69279037"), (20000000000, "65353130")]
for ts, expect in vectors:
    print(ts, totp(k, ts, digits=8), expect)
59 94287082 94287082
1111111109 07081804 07081804
1111111111 14050471 14050471
1234567890 89005924 89005924
2000000000 69279037 69279037
20000000000 65353130 65353130

RFCに載っているSHA-1のテストベクタ6件、全部一致しました。

次に、実際のアプリと突き合わせます。上のコードに書いた秘密をQRコードにして、手元のGoogle Authenticatorに読ませました。QRの中身は次のURI形式で、Google Authenticatorのwikiに仕様があります。

otpauth://totp/Qiita%20TOTP%20Demo:demo%40example.com?secret=U6KIWCCUQACEWCPVNI33LKEKQUBWVZ75&issuer=Qiita%20TOTP%20Demo&algorithm=SHA1&digits=6&period=30

スクリプトを実行した瞬間の6桁と、スマホの画面の6桁が同じでした。30秒待つと両方が同時に切り替わる。分かっていたことですが、自分で書いたHMACの出力とGoogleのアプリの表示が並んで同じ数字を出しているのは、思っていたより気持ちがいい。

このQRコードの秘密は記事に載せている公開の値なので、検証にだけ使い、実際のサービスの登録には使わないでください。読み終わったらアプリから削除してください。

なぜ30秒で変わるのか、なぜ少しズレても通るのか

30秒はRFCの既定値です。短くすると打ち終わる前に切り替わり、長くすると盗み見られたコードが有効な時間が伸びる。その妥協点が30秒で、ほとんどのサービスがこの値を使っています。

では、29秒の時点で表示された6桁を打って、サーバーに届いた時点で31秒になっていたら。サーバー側のTは1つ進んでいて一致しません。そこでRFCは、ネットワーク遅延を見込んで「1ステップ前まで」を許容するポリシーを持つことを勧めています。さらに、スマホの時計がズレている場合に備えて未来側も見る実装が多く、結果として「現在のT、その前、その後」の3つを計算して、どれかに一致すれば通す、というのがよくある形です。図の右側で「前後1個も試す」と書いたのはこれです。ただし許容幅はRFCの固定仕様ではなく実装ポリシーで、RFCが必須としているのは別のこと、一度成功したコードを同じ期間内に二度受け付けないことです。これが無いと、盗み見た6桁を30秒以内に使い回されます。

逆に言えば、スマホの時計が許容幅を超えてズレていると、正しい秘密を持っていても一致しません。よくある前後1ステップの設定なら、1分ほどズレたら通らなくなります。「認証アプリのコードが通らない」の原因の相当数は、これです。昔のGoogle Authenticatorには「コードの時刻調整」という設定がありましたが、現行版では廃止されてOSの時刻をそのまま使うので、直すならスマホの日時を自動設定にするのが先です。

それでも認証アプリはフィッシングに負ける

ここが一番言いたいところです。認証アプリの6桁は、正しい本人が正しいスマホで受け取った正しい値であっても、突破されます。

aitm-relay-phishing.png

攻撃者は本物そっくりの偽サイトを用意します。ただし昔のフィッシングと違って、偽サイトは裏で本物のサイトに接続していて、あなたが入力したものをそのまま転送します。あなたがIDとパスワードを打つと、本物のサイトが「確認コードを入力してください」と返す。偽サイトはそれをそのまま見せる。あなたは認証アプリを開き、いま有効な正しい6桁を偽サイトに打つ。偽サイトはそれが失効する前に本物へ転送する。認証成功。ログイン済みのセッションが攻撃者の手に残ります。SMSの場合も同じで、本物から届いた正しいコードを偽サイトに打てば終わりです。

この手法はAdversary-in-the-Middle(AiTM)と呼ばれ、Evilginxなどのツールでほぼ自動化されています。Microsoft 365を標的にした大規模なキャンペーンが2025年から2026年にかけて継続して報告されていて、月間数千万通の規模で送られたものもあります。

なぜ6桁は防げないのか。6桁は「あなたが秘密を持っていること」しか証明しません。その6桁をどのサイトに入力しているかを、6桁自身は知らない。正しい値を間違った相手に渡すことを、仕組み上止められないのです。SMSでも認証アプリでも、ここは同じです。

NISTがSMS認証を「制限付き」にした理由

米国NISTのデジタルアイデンティティガイドライン SP 800-63B は、電話網(SMS・音声通話)を使う認証を「制限付き(restricted)」の認証手段として位置づけています。第3版で導入された扱いで、2025年に出た第4版でも維持されました。使ってはいけない、ではなく、使うなら組織がリスクを評価して受け入れ、SIMの変更や番号の移転といった兆候を見ること、コードの有効期限を10分以内にすること、といった条件が付いています。

制限付きになっている理由は、電話網に固有のものです。番号という経路が本人以外に移りうること、番号の再発行や移転が本人確認の弱い窓口で起きうること、事業者網の上で傍受されうること。フィッシング耐性の無さは認証アプリにも共通なので、それ自体はSMSだけが制限される理由ではありません。

ではパスキーは何が違うのか

前にパスキーの記事で書いたことの、この記事からの言い直しです。

パスキーはログイン時に「サービスから送られた乱数」に秘密鍵で署名を返しますが、鍵そのものがサービスのドメイン(RP ID)に紐づいて作られていて、署名対象にはそのRP IDのハッシュと、今ブラウザが開いているオリジンが含まれます。偽サイト example-login.com ではそもそも example.com 用の鍵が呼び出されず、仮に署名が作られてもオリジンが違うのでサーバーの検証で落ちる。中継フィッシングの図で言えば、⑤で偽サイトに渡るものが「どこにでも通る6桁」ではなく「example-login.com で作られた、と自分で書いてある署名」になるので、⑥の転送が意味を持たなくなります。

6桁の弱点は「どこに入力しているかを知らない」ことでした。パスキーはそれを仕組みで解決している。SMS・認証アプリ・パスキーが「順に強くなる」と言われる理由は、この1点に集約できます。

結局、今なにを選べばいいのか

サービス側で選べるなら、この順です。

  1. パスキーが選べるなら、それを使う。中継フィッシングに唯一構造的に耐える
  2. 無ければ認証アプリ。SIMスワップと海外SIMの問題が消える。フィッシングには自分で気をつける必要がある
  3. SMSしか無いサービスは、それでもパスワードだけより確実にましなので有効にする。ただし旅行や機種変更の前に、バックアップコードを控えておく

認証アプリを使うなら、機種変更で秘密が飛ぶ問題も忘れないでください。Google Authenticatorはクラウド同期に対応しましたが、同期をオフにしている人は端末を失うと全アカウントの6桁を失います。私は旅の前に、各サービスのバックアップコードをパスワードマネージャーに入れておくのを習慣にしました。

まとめ

  • 認証アプリの6桁は、登録時に共有した秘密と現在時刻からスマホの中だけで計算している。通信していないので、機内モードでも海外SIMでも出る
  • SMS認証の弱点はコードではなく電話番号という経路。SIMスワップや番号再発行で本人以外に移り、海外SIMでは本人にも届かない
  • 認証アプリはその経路の問題を解消するが、中継フィッシングには負ける。正しい6桁を偽サイトに打てば、失効する前に本物へ転送されて終わる
  • 6桁は「どこに入力しているか」を知らない。パスキーは署名にドメインが含まれるので、そこが構造的に違う
  • 選べるならパスキー、無ければ認証アプリ、SMSしか無いなら有効にした上でバックアップコードを控える

RFCを読んでPythonで20行書く、という作業は正直やらなくても生きていけます。ただ、自分のスマホに出ている6桁が「秘密と時刻のHMAC」だと体で分かってからは、「認証アプリのコードが通らない」というトラブルの原因が時計だとすぐ疑えるようになりました。次に海外に行くときは、SMSしか無いサービスを出発前に洗い出しておこうと思います。

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?