6桁OTPは、どこまで推測できるのでしょうか。
表示上はどの方式も 000000 から 999999 までの100万通りです。しかし、同じ6桁でも生成方法によって、攻撃者が実際に作れる候補数は大きく変わります。
本記事では、次の4方式を比較します。
| 方式 | 生成方法 | 発行時刻を±3秒まで絞った場合 |
|---|---|---|
秒seed → random
|
秒単位のUNIX timestampをseedにする | 最大7候補 |
ms seed → random
|
ミリ秒単位のUNIX timestampをseedにする | 最大6,001候補 |
| ms timestamp直値 |
timestamp_ms % 1_000_000 をOTPにする |
最大6,001候補 |
secrets |
OSのCSPRNGからOTPを直接生成する | 時刻から再現できない |
ここで、4方式がすべて同じ意味で「seedを使っている」わけではありません。秒seedとms seedは時刻を random のseedへ入れます。timestamp直値はPRNGを使わず、時刻を加工してOTPにします。secrets はアプリケーションからseedを与えず、OSの暗号学的乱数源を使う安全側の対照です。
そこで、Mac miniをOTPサーバー、別Wi-Fi上のMacBook Airを実験クライアントにして、4方式のOTPを実際に発行しました。候補を /verify へ送信し、1回、3回、5回、10回、30回の試行でどこまで推測できたのかを比較します。本稿の数値は、2026年9月17日に完了した実測に基づきます。
本稿は、自分が管理する隔離ラボで行った教育・防御目的の実験です。第三者のサービスやアカウントへ推測を試すものではありません。
3行まとめ
- 秒単位の時刻をseedにした方式は、100標的すべてが3回以内で成功した
- ミリ秒単位でも、30回以内では
random(timestamp_ms)が22/100、timestamp直値が18/100成功した。secrets対照は0/100だった - 対策は時刻を細かくすることではなく、CSPRNG、短い有効期限、single-use、binding、rate limitを組み合わせること
数値は測定した2台、時刻、経路に限定されます。単一runの成功率を、すべての本番環境へ一般化してはいけません。
これはTOTPの話ではない
本稿で問題にするのは「現在時刻そのものをseedやOTP値に使う独自実装」です。
標準のTOTPは、時刻だけで値を作る方式ではありません。RFC 6238では、共有秘密鍵Kと時間カウンターTを使い、TOTP = HOTP(K, T)として計算します。攻撃者が知らない秘密鍵が入る点が本質的に異なります。
今回比較したのは次の4方式です。
import random
import secrets
import time
# 脆弱な比較対象1: 秒単位の時刻をseedにする
random.Random(int(time.time())).randrange(1_000_000)
# 脆弱な比較対象2: ミリ秒単位の時刻をseedにする
random.Random(int(time.time() * 1000)).randrange(1_000_000)
# 脆弱な比較対象3: ミリ秒timestampの下6桁を使う
int(time.time() * 1000) % 1_000_000
# 安全側の対照: OSの暗号学的乱数源を使う
secrets.randbelow(1_000_000)
Python公式ドキュメントも、randomはシミュレーション向けの決定的な疑似乱数生成器(PRNG)であり、セキュリティ用途には使わずsecretsを使うよう明記しています。予測可能な時刻をseedにする問題は、CWE-337: Predictable Seed in PRNGにも対応します。
100万通りが7候補まで減る理由
これは机上の分類だけではありません。CVE-2020-28597は、password reset用のone-time tokenに予測可能なseedを使った脆弱性です。同じNVDページには、評価主体とCVSS版が異なる2つの値が掲載されています。
- NVD評価: 7.5 HIGH(CVSS v3.1)
- CNA Talos評価: 9.8 CRITICAL(CVSS v3.0)
点数だけを混ぜず、評価主体とCVSS版を添えて併記するのが適切です。
攻撃者がOTP発行時刻をクライアント側で観測し、真のサーバー時刻がその前後3秒にあると仮定します。
random(timestamp)を通すと、出力は時計らしく見えなくなります。しかし、同じ整数seedを同じ処理へ入れれば同じ値が返るため、攻撃者が知らない秘密は増えていません。
なお、この実験は複数出力からMersenne Twisterの内部状態を復元する攻撃ではありません。OTPごとに「あり得る時刻seed」を作り、同じ決定的変換を再実行しています。
実験環境
サーバーとクライアントを別のWi-Fiへ置き、Tailscaleのdirect接続で通信しました。Tailscale ServeやFunnelは使わず、OTPサーバーはtailnet上の実験クライアントだけを許可しました。
Tailscaleは安全な実験経路を作るための手段であり、今回の脆弱性の原因ではありません。private networkに置いても、OTP生成ロジック自体の予測可能性は解消しません。
正解を見ずにどう測ったか
攻撃側へ見せる情報と、評価まで隠す情報を分けました。
| 攻撃側が利用できる | 推測完了まで利用できない |
|---|---|
| 自分向けに発行された30件のOTP | targetの正解OTP |
| request開始・response完了時刻 | サーバー上の生成時刻とseed |
| 公開された脆弱な生成規則 | SQLite内の検証用監査値 |
| verifyのaccept / reject | 全試行後の評価結果 |
ここでいう時刻差(offset)は、クライアントが観測した時刻と、サーバーがOTPを生成した時刻の差です。
- 自分向けOTPを30件受け取り、offset候補を順位付けする
- 30件の学習後に順位を固定し、target開始後は再学習しない
- 正解を知らない100標的へ、固定済みの候補を最大30回送る
- 全推測が終わってから、サーバー側の隠し正解と照合する
4方式は各ラウンドで順番を入れ替え、時間経過による一方向の偏りも抑えました。
遠隔pilotの結果
まず30回以内の最終結果です。
-
秒seed →
random: 100/100
前後3秒には最大7候補しかなく、1候補目で92件、3候補までで100件すべてに成功しました。 -
ms seed →
random: 22/100
前後3秒で約6,001候補あります。事前に取得した30件で通信・時計offsetを順位付けし、各targetへ最大30回試した結果です。 -
ms timestamp直値: 18/100
同じoffset推定を使い、最大30回で18件に成功しました。時刻値を加工しただけなので、暗号学的な乱数にはなっていません。 -
secrets対照: 0/100
時刻を生成入力に使わないため、同じoffset推定では1件も再現できませんでした。
累積成功率は次のとおりです。表を縦向きにし、スマートフォンでも比較しやすくしています。
| 最大試行数 | 秒seed → random
|
ms seed → random
|
ms timestamp直値 |
secrets対照 |
|---|---|---|---|---|
| 1回 | 92% | 0% | 3% | 0% |
| 3回 | 100% | 0% | 4% | 0% |
| 5回 | 100% | 5% | 5% | 0% |
| 10回 | 100% | 8% | 8% | 0% |
| 30回 | 100% | 22% | 18% | 0% |
発行RTT中央値は、秒seed 68.38ms、ms seed 67.64ms、ms直値85.99ms、secrets 60.65msでした。各方式の分母は100標的で、全体の所要時間は約22分38秒です。
秒seedはネットワーク越しでも候補が少なすぎる
秒seedは前後3秒に最大7候補しかありません。1候補目で92/100、3候補までで100/100成功しました。RTTが約68msあっても、秒bucket自体が粗いため、候補を絞り込めました。
ミリ秒化は難易度を上げるが、安全な乱数にはならない
ms方式は、前後3秒で約6,001個のseed候補になります。30件の校正期間からtarget期間へ真のoffset中央値が13.5msまたは26ms移動し、5回以内の成功は両方式とも5%に留まりました。
それでも30回まで許すと、ms seedは22/100、ms直値は18/100成功しました。独立な6桁乱数を異なる30候補で当てる理論確率は30 / 1,000,000 = 0.003%です。単一runの点推定とはいえ、18〜22%は「ミリ秒なら十分」という見方を支持しません。
30回以内成功率のWilson 95%区間は、ms seedが15.0〜31.1%、ms直値が11.7〜26.7%でした。secretsの0/100も「絶対に当たらない」という意味ではなく、この実験規模での95%区間の上限は3.70%です。
長時間観測では約45%まで上がった
追加の予備実験では、ms seed → random、ms timestamp直値、secretsをそれぞれ1,000件取得しました。最初の850件を約17分42秒かけて学習し、モデルを固定して、残り150件の未見データ(blind holdout)を評価しました。
これはオンラインverifyを送る本攻撃ではありません。候補を先に固定し、その後に自分向けOTPを読み、正解順位だけを判定した予備実験です。
| 候補数 | ms seed → random
|
ms timestamp直値 |
secrets対照 |
|---|---|---|---|
| 1候補 | 1/150 | 3/150 | 0/150 |
| 3候補 | 6/150 | 7/150 | 0/150 |
| 5候補 | 9/150 | 11/150 | 0/150 |
| 10候補 | 27/150 | 30/150 | 0/150 |
| 30候補 | 68/150(45.3%) | 67/150(44.7%) | 0/150 |
ms timestamp直値では、1,000,000ms(1,000秒)の真の周期候補を回収できました。一方、random(ms)とsecretsで見つかった見かけの周期候補は、残差が大きく棄却されました。
random(ms)が45.3%になった理由は周期回収ではありません。850件の既知OTPを時刻候補へ照合し、繰り返し現れる通信・時計offsetの分布を学習した効果です。
ただし、この予備実験は単一runで、ms直値のwrapも1回だけです。「850件あれば常に45%」とは言えません。複数の時刻、経路、時計同期状態での反復が必要です。
同一Mac上のsub-ms RTT環境では、各方式1,500標的の測定で、最大5回以内に秒seedとms seedが1,500/1,500、ms直値が1,497/1,500成功しました。
しかし、これは攻撃手順が動くことを示す局所能力実証です。別Wi-Fi間の遠隔pilotでは、ms方式の5回成功率は5%でした。localhostの99.8〜100%を、インターネット、SMS、メール配送、reverse proxy、queue、複数workerを含む本番へ外挿してはいけません。
実装が同じでも、成功率は次で変わります。
- 発行時刻をどれだけ正確に観測できるか
- clientとserverの時計差、片道遅延、jitterがどれだけ安定するか
- 実装とPython runtimeを攻撃側が再現できるか
- OTPの有効期限内に何回試せるか
- アカウント、IP、端末、セッション単位の制限があるか
- 再発行や成功後に古いOTPが失効するか
安全な乱数を使う場合の設計上の注意点
生成にはOSの暗号学的乱数源を使います。
import secrets
def generate_secure_otp() -> str:
return f"{secrets.randbelow(1_000_000):06d}"
ただし、6桁10進数の空間は約19.9bitしかありません。CSPRNGへ変えても、オンライン総当たりを許せば別の問題が残ります。サーバー側で次を組み合わせます。
- 短い有効期限
- 1回使用後と再発行後の即時失効
- 利用者、セッション、用途、操作内容へのbinding
- アカウント単位を中心にした少ない試行上限と段階的なrate limit
- 失敗回数の集約、監査ログ、異常検知、利用者への通知
- OTPを平文でログへ残さない
- 保存が必要なら、サーバー側秘密鍵を使うHMACなどを検討し、比較には
hmac.compare_digest()を使う
6桁OTPは全候補を列挙できるため、salt付きhashだけを保存時の安全性の根拠にしてはいけません。短いTTL、秘密鍵の管理、保存先へのアクセス制御を含めて設計します。
NIST SP 800-63Bも、64bit未満のOTP出力に対してverifier側のrate limitingを要求しています。network ACLやprivate overlayは到達面を狭めますが、OTP生成と検証の安全性を置き換えるものではありません。
再現性を守るためのチェックリスト
再現実験は、自分が管理する隔離環境だけで行います。最低限、次を固定・記録します。
- serverをpublic IPや
0.0.0.0へ公開せず、実験clientだけを許可する - trainingとtargetを分離し、target開始前に候補順位を固定する
- targetのOTP、seed、生成時刻を推測処理へ渡さない
- 評価用の正解は全推測後にだけ参照する
- Python version、時計同期、RTT、接続経路、試行上限を記録する
- 1/3/5/10/30回以内の累積成功率と分母を残す
- OTP値、token、Authorization headerを公開用ログや結果へ保存しない
- 実験終了後も、結果と監査データを誤って削除しない
この実験から言えること・言えないこと
言えること
- 時刻をseedにすると、6桁OTPの候補空間は観測した時刻窓まで縮む
- 秒seedは、別Wi-Fi間の実測でも少数候補で再現できた
- ms方式も、時刻差を学習できる条件では独立乱数より大幅に高い成功率になった
-
secrets対照には同じ時刻依存関係が現れなかった
この実験だけでは言えないこと
- あらゆる本番サービスで同じ成功率になる
- 30個の数値だけからPRNGの内部状態を復元できた
- 不安定なネットワークでも固定offsetを正確に予測できる
- 認証、配送、rate limit、監視を含む本番システム全体を突破できる
まとめ
今回の遠隔実測で確認できたのは、「6桁」という表示幅よりも、生成元の予測可能性とオンライン試行制御が支配的だということです。
- 秒単位の時刻seedは、前後3秒なら候補が最大7個しかない
- ミリ秒へ細粒度化しても、安全な乱数源にはならない
-
random(timestamp)は見た目を複雑にするが、攻撃者が知らない秘密を追加しない - 標準TOTPは共有秘密を使うため、今回の独自方式とは別物
- 実装は
secretsなどのCSPRNGへ置き換え、TTL、single-use、binding、rate limit、監視を重ねる
一言でまとめるなら、時刻を細かくするのではなく、時刻を秘密の代わりに使わないことです。