hash_equalsでタイミング攻撃を防ぐ
hash_equalsとは、二つの文字列を常に一定の時間で比較して一致を返すPHPの関数です。処理時間の差から情報が漏れるタイミング攻撃を防ぐため、トークンや署名の照合に使います。
==比較には時間差が漏れる
トークンや署名を照合するとき、つい === で比べてしまいますが、秘密の値の比較にこれを使うのは危ういですね。文字列比較は先頭から一文字ずつ照合し、違いが見つかった時点で処理を打ち切ります。つまり「何文字目まで一致していたか」で処理時間がわずかに変わる。この差を大量の試行で測ることで、正しい値を一文字ずつ推測していけてしまう、というのがタイミング攻撃です。
// 危険。一致した文字数で処理時間が変わる
if ($knownToken === $userToken) {
// ...
}
// 先頭が合っているほど比較に時間がかかる、という差が漏れる
hash_equalsは定数時間で比較する
hash_equals は、文字列全体を常に同じ時間で比較するように作られています。途中で違いが見つかっても最後まで走るので、処理時間から一致の度合いが漏れません。秘密の値を比べるときは、迷わずこれを使うのが正解だと思います。第一引数に既知の正しい値、第二引数に利用者から来た値、という順で渡すのが慣例ですね。
// 安全。定数時間で比較する
if (hash_equals($knownToken, $userToken)) {
// 認証成功
}
// CSRFトークンの照合でも同じ
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
exit('検証失敗');
}
どこで使うべきか
使いどころは、APIキーやトークン、HMAC署名、CSRFトークンなど「秘密の固定文字列を照合する」場面ですね。逆にパスワードの照合は password_verify が内部で安全な比較をしてくれるので、こちらを hash_equals で自作する必要はありません。またウェブフックの署名検証は典型例で、送られてきた署名を自分で計算し直し、それを hash_equals で比べる、という流れになります。
// Webフック署名の検証
$expected = hash_hmac('sha256', $payload, $secret);
$received = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit('署名が不正です');
}
まとめ
秘密の値の照合で === を使うと、比較にかかる時間の差から情報が漏れる余地があります。hash_equals は定数時間で比べてくれるので、トークンや署名の検証ではこれを使うのが基本だと思いますね。パスワードは password_verify に任せる、その他の秘密は hash_equals、と役割を分けておけば迷わない気がします。地味ですが、外せない一手だと思います。
よくある質問
Q. パスワードの照合にも hash_equals を使うべきですか?
A. パスワードは password_verify が内部で安全な比較をしてくれるので、そちらを使えば十分です。hash_equals はトークンや署名など固定の秘密文字列に使います。
Q. 引数の順番に決まりはありますか?
A. 第一引数に既知の正しい値、第二引数に利用者から来た値を渡すのが慣例です。逆でも比較は成立しますが、慣例に合わせておくと読み手に優しいですね。