タイミング攻撃と定数時間比較:== と hash_equals を実測する

セキュリティ基礎講座6。== と hash_equals・compare_digest を一致長ごとに計測し、プロセス内とループバック HTTP で検出できるかを統計付きで比較。会員の有無が127倍の時間差で漏れるログインも実測。

シリーズ:セキュリティ基礎講座 6/6(上級)。 if ($token === $expected) と書いた後で「hash_equals を使え」とどこかで読んだ人向けの最終回です。比較が一致した長さに応じてどれだけ時間を漏らすか、その漏れが HTTP の往復を越えて残るか、典型的な Web アプリでどのタイミングの漏れが本当に大きいかを、自分のマシンで測ります。最後にシリーズ全体のチェックリストをまとめます。

タイミング攻撃は研究室の話に聞こえますし、コードによっては実際そうです。しかし別のコードでは「リクエスト1回で成立するバグ」になります。日本の現場で特に関係が深いのは後者で、ログイン画面やパスワード再設定画面が「そのメールアドレスが会員かどうか」を漏らす問題です。サービスによっては会員であること自体が知られたくない情報です。この回の目的は、どちらのケースかを言い伝えではなく数字で見分けること、そしてノイズに埋もれて見えなかった場合もそのまま報告することです。

環境:Apple Silicon Mac(M1 Pro)、Python 3.9.6(time.perf_counter_ns)、PHP 8.5.6(hrtime)、127.0.0.1:18150 で動かした php -S 8.5.6。測定はすべて、この講座のためにこのマシンで作ったコードとデータに対して行いました。シードは固定(random.seed(2026)、mt_srand(2026))し、ドリフトがすべてのケースに均等にかかるよう測定順はシャッフルしています。パスは ~/demo/ に置き換えています。

タイミング攻撃に必要なもの

a == b のような比較は、たいてい最初に違うバイトで止まります。攻撃者が b を自由に決められ、時間を測れるなら、先頭から何バイト合っていたかがわかります。64文字の16進トークンなら、不可能な1回の推測(16^64 通り)が、16候補ずつの小さな推測64回に変わります。

実際に成立するかは3つで決まります。

  1. 信号:一致するバイトが1つ増えるごとに増える時間。16バイト単位で比べる memcmp ならほぼゼロ、インタプリタ言語の自作ループならずっと大きくなります。
  2. ノイズ:それ以外のすべてのばらつき(スケジューラ、ネットワーク、サーバーの他の処理)。攻撃者は繰り返しと統計で対抗します。
  3. サンプル数:ノイズはおおよそ測定回数の平方根に比例して小さくなります。信号が10分の1なら、必要なリクエストは約100倍です。

この3つをすべて測ります。各ケースについて 中央値(外れ値に強い)と IQR(四分位範囲:測定値の真ん中半分の幅)を示します。

プロセス内:Python の == と hmac.compare_digest

最初のスクリプトは、先頭 k バイトが正しく k バイト目が違う推測値を使い、2つの比較を計測します。

# ~/demo/t_inproc.py — 一致した先頭の長さごとに == と compare_digest を計測
import hmac, random, statistics, sys, time
random.seed(2026)
L = int(sys.argv[1]) if len(sys.argv) > 1 else 64
secret = bytes(random.choice(b"0123456789abcdef") for _ in range(L))
def guess(k):                                    # 先頭 k バイトは正しく、k バイト目が違う
    if k >= L: return bytes(bytearray(secret))   # 値は同じ、オブジェクトは別
    return secret[:k] + b"x" + secret[k+1:]
prefixes = sorted({0, L//4, L//2, 3*L//4, L-1, L})
cands = {k: guess(k) for k in prefixes}
INNER, REPS = 2000, 301
fns = {"==": lambda a, b: a == b, "compare_digest": hmac.compare_digest}
res = {(f, k): [] for f in fns for k in prefixes}
order = list(res)
for r in range(REPS):
    random.shuffle(order)                        # ドリフトが全ケースに均等にかかるよう混ぜる
    for f, k in order:
        fn, g = fns[f], cands[k]
        t0 = time.perf_counter_ns()
        for _ in range(INNER):
            fn(secret, g)
        res[(f, k)].append((time.perf_counter_ns() - t0) / INNER)
for (f, k), v in res.items():
    q = statistics.quantiles(v, n=4)
    print(f"{f:>15} prefix={k:>5}: median {q[1]:7.2f} ns  IQR {q[2]-q[0]:5.2f} ns")

最初の実行には見せる価値のあるバグがありました。「全部正しい」推測値を bytes(secret) で作ったところ、その == が0バイト目で外れたときと同じくらい速かったのです。原因は、bytes オブジェクトに bytes() を呼ぶと同じオブジェクトが返り、CPython の == は両辺が同じオブジェクトならすぐ結果を返すことでした。上のように bytearray を経由してコピーするのが対策です。自分自身と比べるベンチマークは何も測っていません。

64バイトの秘密値(2,000回 × 301反復)での結果です。

推測値 == 中央値(IQR) compare_digest 中央値(IQR)
0バイト一致 84.42 ns (2.57) 89.50 ns (1.72)
16バイト一致 86.12 ns (1.54) 89.46 ns (1.99)
32バイト一致 87.08 ns (1.56) 89.48 ns (1.71)
63バイト一致 87.17 ns (1.74) 89.48 ns (1.59)
64バイト全一致 87.04 ns (1.82) 89.48 ns (1.84)

== の変化は全体で約2.8ナノ秒で、自分の IQR と同程度です。0〜32バイトの間で段差があり、その後は平らです。大きな塊で比べる memcmp と合う形です。compare_digest はどの一致長でも0.1ナノ秒以内でした。秘密値を4,096バイトにすると漏れははっきり見えます。== は0バイト一致の 82.98 ns から4,095バイト一致の 205.33 ns まで伸び、compare_digest は 1,861〜1,868 ns のままでした。定数時間のコストも見ておきます。4 KiB では == の約9倍遅いですが、普通の64バイトのトークンなら2〜5ナノ秒の差です。

プロセス内:PHP の ===、hash_equals、自作ループ

PHP には3つ目の候補を加えます。「ちゃんと自分で実装しよう」として書かれがちな1文字ずつのループです。

<?php
// ~/demo/t_inproc.php(抜粋)— 計測対象の比較
function loop_eq(string $a, string $b): bool {             // 素朴な早期 return の比較
    if (strlen($a) !== strlen($b)) return false;
    for ($i = 0; $i < strlen($a); $i++) if ($a[$i] !== $b[$i]) return false;
    return true;
}
// 計測方法は Python 版と同じ:シャッフルした301反復 × 2,000回、hrtime(true)
ナノ秒の棒グラフ。早期 return の自作ループは、一致0バイトで61.6、16バイトで258.8、32バイトで441.5、48バイトで623.8、63バイトで795.2。hash_equals はどの一致長でも67.9。=== 演算子は25.9〜27.3。
PHP 8.5.6、64バイトの秘密値。先頭から一致したバイト数ごとの比較1回の時間(2,000回 × 301反復の中央値)。自作ループは一致した長さが漏れ、hash_equals は漏れない。
推測値(64バイトの秘密値) === hash_equals 自作ループ
0バイト一致 25.94 ns 67.85 ns 61.56 ns
16バイト一致 26.29 ns 67.88 ns 258.81 ns
32バイト一致 26.85 ns 67.85 ns 441.54 ns
48バイト一致 27.21 ns 67.85 ns 623.75 ns
63バイト一致 27.23 ns 67.85 ns 795.17 ns

IQR は0.15〜6ナノ秒でした。振る舞いは3通りです。

  • === は漏れますが、ごくわずかです。0〜63バイト一致で1.35ナノ秒。
  • hash_equals は0.03ナノ秒以内で平らです。
  • 自作ループは 一致1バイトあたり約11.6ナノ秒 漏れます((795.17 − 61.56) / 63)。善意で書かれた、教科書どおりのタイミング攻撃の標的です。

ループバック HTTP 越しでも信号は残るか

プロセス内の数字は攻撃者にとって最良の条件です。実際の攻撃者に見えるのは HTTP の往復全体です。そこで同じ3つの比較を 127.0.0.1 の php -S の裏に置き、別プロセスの Python クライアントから測りました。自分のマシンの自分のサーバーだけを使っています。

<?php
// ~/demo/router.php — php -S 127.0.0.1:18150 router.php(ローカルのデモ専用サーバー)
const SECRET = '3f9c2a7e5b1d4c8f0a6e2b9d7c1f5a3e8b0d4f6a2c9e7b1d5f3a0c8e6b4d2f19'; // デモ用の値
function loop_eq(string $a, string $b): bool {
    if (strlen($a) !== strlen($b)) return false;
    for ($i = 0; $i < strlen($a); $i++) if ($a[$i] !== $b[$i]) return false;
    return true;
}
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($path === '/check') {
    $t = (string)($_GET['t'] ?? '');
    $ok = match ($_GET['mode'] ?? '') {
        'eq'    => $t === SECRET,
        'hash'  => hash_equals(SECRET, $t),
        'loop'  => loop_eq(SECRET, $t),
        default => false,
    };
    http_response_code($ok ? 200 : 403);
    echo $ok ? "ok\n" : "no\n";
}
# ~/demo/t_http.py — 1リクエストごとに新しい接続、ケースは混ぜて測る
# 使い方: python3 t_http.py 3000 eq,hash,loop 0,32,63
import http.client, random, statistics, sys, time
random.seed(2026)
SECRET = "3f9c2a7e5b1d4c8f0a6e2b9d7c1f5a3e8b0d4f6a2c9e7b1d5f3a0c8e6b4d2f19"
N = int(sys.argv[1])
modes = sys.argv[2].split(",")
ks = [int(k) for k in sys.argv[3].split(",")]
cases = [(m, k) for m in modes for k in ks]
res = {c: [] for c in cases}
order = cases * N
random.shuffle(order)
for mode, k in order:
    conn = http.client.HTTPConnection("127.0.0.1", 18150)
    t0 = time.perf_counter_ns()
    conn.request("GET", f"/check?mode={mode}&t={SECRET[:k]}x{SECRET[k+1:]}")
    conn.getresponse().read()
    res[(mode, k)].append((time.perf_counter_ns() - t0) / 1000)   # マイクロ秒
    conn.close()

def boot_ci(a, b, B=2000):           # median(b) - median(a) の95%信頼区間
    rnd = random.Random(7)
    d = sorted(statistics.median(rnd.choices(b, k=len(b))) - statistics.median(rnd.choices(a, k=len(a)))
               for _ in range(B))
    return d[int(.025 * B)], d[int(.975 * B)]

for m in modes:
    for k in ks:
        q = statistics.quantiles(res[(m, k)], n=4)
        print(f"{m:>5} prefix={k:>2}: median {q[1]:6.1f} us  IQR {q[2]-q[0]:5.1f} us")
    a, b = res[(m, ks[0])], res[(m, ks[-1])]
    lo, hi = boot_ci(a, b)
    d = statistics.median(b) - statistics.median(a)
    print(f"      difference {d*1000:+.0f} ns, 95% CI [{lo*1000:+.0f}, {hi*1000:+.0f}] ns")

python3 t_http.py 3000 eq,hash,loop 0,32,63(27,000リクエスト、11.2秒)の結果です。

方式 0一致 32一致 63一致 中央値の差(63 − 0)、95%信頼区間
=== 192.6 µs 192.3 µs 191.5 µs −1,146 ns [−1,875, −250]
hash_equals 191.7 µs 192.8 µs 192.5 µs +770 ns [−104, +1,459]
自作ループ 192.1 µs 192.7 µs 192.7 µs +541 ns [−314, +1,355]

どのケースも IQR は約20マイクロ秒で、自作ループの信号全体(730ナノ秒)の20倍以上です。=== の行を見てください。信頼区間が0を含みませんが、向きが 逆 です。一致が増えて比較が速くなることはないので、これはノイズとドリフトがたまたまそろったものです。3つを同時に検定すれば、ときどきこういうことが起きるのは当然です。スクリプトを整理する前の同じ実験では IQR が60マイクロ秒で、3つの区間はすべて0を含んでいました。「1マイクロ秒遅かった」という1回の観測は、どちらの向きでも何も証明しません。

次に、自作ループと hash_equals だけを0一致と63一致で、1ケース2万リクエスト 測りました。python3 t_http.py 20000 hash,loop 0,63(8万リクエスト、44.3秒)です。

方式 0一致 63一致 差、95%信頼区間
hash_equals 190.3 µs(IQR 14.0) 190.5 µs(IQR 14.2) +126 ns [−84, +416]
自作ループ 190.3 µs(IQR 14.3) 191.2 µs(IQR 14.1) +875 ns [+624, +1,084]

ここで自作ループの漏れが見えました。プロセス内で測った730ナノ秒に近く、区間は0を含みません。整理前の実行も同じ傾向で、自作ループ +688 ns [+208, +1,186]、hash_equals −166 ns [−625, +292] でした。正直にまとめるとこうなります。

  • ループバックでは、遅いループの63バイト 全体 の差は数万リクエストで検出できました。
  • トークンを復元するには 1バイトあたり の差、約11.6ナノ秒が必要です。2万リクエストでの95%区間の幅は約±230〜±490ナノ秒でした。これを約±6ナノ秒まで狭めるには (230/6)² ≈ 1,500 〜 (490/6)² ≈ 6,700 倍のサンプル、つまり候補1バイトあたり数千万〜約10^8リクエストが要る計算です。これは外挿であり、測定ではありません。
  • PHP の ===(トークン全体で1.35ナノ秒)は、まったく見えませんでした。

だからといって === のままでよいわけではありません。同じネットワークや同じクラウドホストにいる攻撃者は、Python で動くループバッククライアントよりノイズが少なく、中央値より強い統計を使い、私たちの44秒よりずっと辛抱強いからです。良い条件では小さな差に対するリモートのタイミング攻撃が成立した研究も公表されています。対策のコストは数ナノ秒なので、判断は簡単です。

測りやすい漏れ:誰がアカウントを持っているか

Web アプリの大きなタイミングの漏れは、文字列比較にあることはまれです。一部の入力にだけ重い処理をすることから生まれます。典型は、ユーザーが存在するときだけ password_verify を実行するログインです。同じルーターに /login を追加しました。

// ~/demo/router.php の続き:/check のブロックの下に追記する
// users.php は ['mina' => '$2y$10$…'] を返す(bcrypt コスト10)
// 誰も知らないランダム文字列の bcrypt ハッシュ。本物と同じコスト
const DUMMY_HASH = '$2y$10$KDMs6ijzOIwe1yxm5R15tOxeShXOlOTt33nqyt3WzZ5xQOkehLuLG';
if ($path === '/login') {
    $users = require __DIR__ . '/users.php';
    $u = (string)($_POST['user'] ?? '');
    $p = (string)($_POST['pass'] ?? '');
    if (($_GET['fix'] ?? '') === '1') {
        $hash = $users[$u] ?? DUMMY_HASH;                // 対策:bcrypt の検証を必ず1回行う
        $ok = password_verify($p, $hash) && isset($users[$u]);
    } else {
        $ok = isset($users[$u]) && password_verify($p, $users[$u]);   // バグ:早期 return
    }
    http_response_code($ok ? 200 : 401);
    echo $ok ? "welcome\n" : "invalid\n";
}

間違ったパスワードでのログインを各ケース300回、混ぜた順に、毎回新しい接続で送りました。

ミリ秒の棒グラフ。バグのあるログイン:存在するユーザー59.62、存在しないユーザー0.47。ダミーハッシュで直した版:存在するユーザー59.63、存在しないユーザー59.64。応答はすべて401。
ループバック HTTP で間違ったパスワードを送ったときの応答時間の中央値(各300回、bcrypt コスト10)。バグのある版は存在しないユーザーに127倍速く答える。
buggy user=mina  : status [401], median  59.62 ms  IQR  0.60 ms
buggy user=nobody: status [401], median   0.47 ms  IQR  0.16 ms
fixed user=mina  : status [401], median  59.63 ms  IQR  0.58 ms
fixed user=nobody: status [401], median  59.64 ms  IQR  0.58 ms

ステータスも本文も同じなのに、時間は127倍違います。ユーザー名ごとに1回リクエストすれば、どのアカウントが存在するかわかります。統計は要りません。ダミーハッシュを使った版では、2人のユーザーへの応答差が0.01ミリ秒で、IQR の中に完全に収まります。2回目の実行も同じ結果でした(バグあり 59.61 ms 対 0.45 ms、修正後 59.62 ms 対 59.58 ms)。同じパターンは、パスワード再設定(「ユーザーがいるときだけメールを送る」)、会員登録時の重複チェック、検索してから検証する API キーにも現れます。直し方の考え方はいつも同じで、どちらの経路でも同じ重い処理をし、同じ応答を返すことです。回数制限(第2回 参照)を加えると、残った漏れを悪用するコストも上がります。

よくある間違いと実際に起きること

間違い 実際に見たこと 直し方
自作の「安全な」比較ループ 一致1バイトあたり11.6ナノ秒。ループバック越しでも検出 hash_equals / hmac.compare_digest
非 ASCII の str に hmac.compare_digest 'トークン' で TypeError: comparing strings with non-ASCII characters is not supported bytes 同士、または16進ダイジェスト同士で比べる
欠けた項目の null を hash_equals に渡す TypeError: hash_equals(): Argument #2 ($user_string) must be of type string, null given 先に型をそろえる:(string)($_GET['t'] ?? '')
hash_equals の引数が逆 結果は正しいが、ユーザー側の文字列の長さが時間に影響しうる 既知の値が先、ユーザー入力が後
正しいユーザーにだけ重い処理 0.47 ms 対 59.62 ms ダミーハッシュ、同じ応答
x == x をベンチマーク 同一オブジェクトの近道で「全一致」が0バイト一致並みに速い 別オブジェクト同士で比べる
トークンの値そのもので DB を検索 インデックスの比較は定数時間ではない(説明のみ、未測定) トークンの SHA-256 を保存し、それで検索

最後の行はコードで示します。保存するのはトークンのハッシュだけです。ユーザーはトークンを提示し、こちらはそれをハッシュしてハッシュ同士を比べます。攻撃者は SHA-256 の出力の先頭を操れないので先頭一致の漏れは役に立たず、16進ダイジェストは ASCII なので compare_digest が例外を出すこともありません。

import hashlib, hmac, secrets
token = secrets.token_urlsafe(32)                         # ユーザーに一度だけ渡す
stored = hashlib.sha256(token.encode()).hexdigest()       # DB に入れるのはこれだけ
def check(presented: str) -> bool:
    h = hashlib.sha256(presented.encode()).hexdigest()
    return hmac.compare_digest(h, stored)
print(len(token), check(token), check(token[:-1] + "x"), check("トークン"))
# 43 True False False

hash_equals も compare_digest も、場合によっては秘密値の 長さ を漏らします(ドキュメントにもそう書かれています)。長さが固定のトークンやダイジェストなら問題になりません。これもハッシュ同士を比べる理由の1つです。

その他のサイドチャネル(説明のみ、未実行)

時間は数あるチャネルの1つにすぎません。以下は測っていませんが、見分けられるようにしておきます。

  • エラーメッセージやステータスコードの違い:「ユーザーが存在しません」と「パスワードが違います」、存在するオブジェクトへの 404 と 403。統計なしで漏れます。メッセージを同じにします。
  • 応答サイズ:秘密値と攻撃者が操れる文字列を同じページに入れて圧縮すると、サイズから秘密値がわかることがあります(HTTPS の圧縮に対する CRIME・BREACH 攻撃の考え方)。通信側の話は HTTPS と TLS を参照してください。
  • CPU キャッシュと分岐予測:テーブルを使う AES 実装や、秘密値に依存する分岐は、共有ハードウェアを通じて漏れます(Spectre 系やキャッシュタイミング攻撃)。自前の AES ではなく検証済みの暗号ライブラリを使う理由の1つです。
  • 秘密データに依存する処理量:パーサーの早期終了、バックトラックの量が変わる正規表現、早く返るデータベースのクエリ。
  • 回数制限とロックアウトの挙動:存在するアカウントにだけかかるロックアウトも、存在確認の手がかりになります。

どの比較を使うか

判断の図。出発点は秘密値とユーザー入力の比較。パスワードなら password_verify を使い、存在しないユーザーにはダミーハッシュで同じ処理を走らせる。そうでなくトークン・MAC・API キーなら、既知の値を先にして hash_equals、または hmac.compare_digest。トークンでレコードを探す場合は SHA-256 ハッシュだけを保存し、そのハッシュで検索する。
秘密値とユーザー入力を比べるときに呼ぶもの。どの道も、推測がどこまで合っていたかで時間が変わらない比較で終わる。
比べるもの 使うもの 注意
パスワード password_verify(PHP)、ライブラリの verify 関数 存在しないユーザーにはダミーハッシュで実行
HMAC・署名(Webhook、JWT、CSRF) hash_equals($expected, $given)、hmac.compare_digest 期待値は自分で再計算し、送られてきた値を信用しない
DB にある API キーや再設定トークン トークンの SHA-256 を保存キーに ハッシュで検索し、ハッシュ同士を比較
短い数字のコード(OTP) 定数時間比較 + 試行回数の制限 比較より回数制限が重要
秘密でないデータ == / === で問題なし すべての比較を定数時間にする必要はない

シリーズのまとめ:チェックリスト

6回分を1ページにまとめます。新しく入ったメンバーに「リリース前に何を確認すればいいですか」と聞かれたら、このリストです。日本で繰り返し報じられてきた「パスワードを平文や復号できる形で保存していた」タイプの事故は、2番と5番を守っていれば起きません。

  1. エンコードは暗号化ではない。 Base64 や16進は誰でも元に戻せます(第1回)。
  2. パスワード:password_hash/password_verify で bcrypt か Argon2id。高速なハッシュは使わない。コストを変えたら再ハッシュ。bcrypt の72バイト制限に注意(第2回)。
  3. 乱数:トークンには secrets/random_bytes。random/mt_rand は使わず、時刻でシードしない。128ビット以上(第3回)。
  4. 署名付きトークン:アルゴリズムを固定し、alg: none を拒否し、32バイトのランダムな HMAC 鍵を使い、exp と aud を確認(第4回)。
  5. 暗号化:AEAD だけ(AES-256-GCM か XChaCha20-Poly1305)。メッセージごとに新しいランダム nonce、16バイトのタグを確認、鍵は DB の外(第5回)。
  6. 比較:秘密値には hash_equals/compare_digest。正しいユーザーにも間違ったユーザーにも同じ処理と同じ応答(今回)。
  7. Web セキュリティの残り:SQL インジェクションにはプリペアドステートメント(PDO と SQLite)、出力のエスケープと CSRF トークン(XSS・CSRF・セッション)、Cookie の属性(ヘッダー・Cookie・セッション)、全ページ HTTPS(TLS)。古い PHP コードを出力を変えずに監査する方法は レガシー PHP の SQLi/XSS 監査 にあります。

正直な限界・バージョンについて

  • 測定は M1 Pro 1台、Python 3.9.6、PHP 8.5.6、php -S 8.5.6 です。php -S はシングルプロセスで、毎回接続を閉じました。keep-alive・TLS・他のトラフィックがある本番サーバーではノイズの性質が違い、攻撃が易しくなることも難しくなることもあります。
  • ループバックのクライアントは Python の http.client で、それ自体のゆらぎがあります。C のクライアントや生のソケットならノイズは減るはずですが、試していません。
  • 「1バイトあたり10^7〜10^8リクエスト」は、2万リクエストの信頼区間から、ノイズがサンプル数の平方根に比例して減ると仮定した外挿です。
  • ループバックのノイズは実行ごとに変わりました(ある回は IQR 60マイクロ秒、次の回は20マイクロ秒)。自分の測定も、信じる前に複数回実行してください。
  • hash_equals は PHP 5.6 から、hmac.compare_digest は Python 3.3 からあります。実行したのは上の版だけです。
  • 前の節のサイドチャネルは説明のみで、実行していません。

演習

1. Webhook の受信処理が if ($_SERVER['HTTP_X_SIGNATURE'] == hash_hmac('sha256', $body, $secret)) で署名を確認しています。この1行の問題をすべて挙げ、書き直してください。

2. パスワード再設定フォームは「登録済みのアドレスであればメールを送りました」と表示しますが、アドレスが存在すると 300 ms(画面描画とメール送信)、存在しないと 5 ms かかります。攻撃者は何を知り、メッセージを変えずにどう直しますか。

解答

1. (a) == は定数時間ではなく、しかも緩い比較です。PHP 8.5.6 でも数値に見える文字列は数値として比べられ、"0e1234" == "0e5678" は true でした。16進の HMAC がたまたま 0e と数字だけになると、同じ形の別の文字列と一致してしまいます。(b) ヘッダーがないと null になります。(c) 送られてきた値が先にあり、比較が攻撃者の文字列に沿って進みます。書き直し:$given = (string)($_SERVER['HTTP_X_SIGNATURE'] ?? ''); $expected = hash_hmac('sha256', $body, $secret); if (!hash_equals($expected, $given)) { http_response_code(401); exit; }。HMAC は受け取った生の本文に対してそのまま計算します。

2. 60倍の時間差から、どのアドレスにアカウントがあるかがわかります。ログインのデモとまったく同じです。直し方:どちらの場合もすぐに応答し、実際の処理はバックグラウンド(キューや、応答送信後に動くジョブ)で行います。こうすればリクエストの時間がアカウントの有無に左右されません。IP ごと・アドレスごとの回数制限も加えます。

次へ

よくある質問

API トークンを == で比較するのは本当に危険ですか?

漏れはしますが、量は比較方法しだいです。PHP の === は64バイトのトークンで、一致なしと63バイト一致の差が約1.4ナノ秒しかなく、ループバック HTTP では見えませんでした。自作の1文字ずつのループは一致1バイトあたり約11.6ナノ秒漏れ、その63バイト分の差は1ケース2万リクエストでループバック HTTP 越しでも検出できました。それでも hash_equals か hmac.compare_digest を使います。コストは数十ナノ秒です。

Web アプリで一番測りやすいタイミングの漏れは何ですか?

一部の入力にだけ重い処理をすることです。今回のログインは存在しないユーザー名で早く返していて、bcrypt コスト10で 59.62 ms に対し 0.47 ms、127倍の差が1回のリクエストで見えました。存在しないユーザーにもダミーハッシュで password_verify を実行すると、4ケースすべてが約 59.6 ms になりました。

hmac.compare_digest がトークンで TypeError になるのはなぜですか?

str 同士の比較では ASCII しか受け付けないからです。非 ASCII 文字が1つでもあると TypeError: comparing strings with non-ASCII characters is not supported になります。両方を UTF-8 の bytes にして比べるか、常に ASCII になる SHA-256 の16進ダイジェスト同士を比べます。

  • #セキュリティ
  • #上級
  • #タイミング攻撃
  • #hash_equals
  • #PHP
  • #Python

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑