【追記 2026/2/26】 本記事の内容をもとに、第184回PHP勉強会@東京で発表しました。Q&Aや補足情報も含めたスライドはこちらからご覧いただけます。
こんにちは。プロダクト開発部の塚原です。
業務でセキュリティに関する実装をする機会があり、その参考としてLaravelの内部実装を読むことがあります。今回は、そのコードリーディングの中で見つけた気づきをきっかけに調べたことをまとめてみました。
きっかけ:===ではダメなのか?
LaravelのCSRFトークン検証の実装を読んでいたとき、こんなコードが目に留まりました。
// Laravel の VerifyCsrfToken ミドルウェア return hash_equals($request->session()->token(), $token);
「なぜ===ではなくhash_equalsを使っているのだろう?」
文字列の一致を確認するだけなら===で十分なはずです。しかし調べてみると、これはタイミング攻撃(Timing Attack)への対策でした。
この記事では、タイミング攻撃の原理を理解し、===とhash_equalsの内部実装の違いを確認した上で、実践的な使い分けの基準を整理していきます。
タイミング攻撃とは
タイミング攻撃は、処理時間の微小な差を観測して秘密情報を推測するサイドチャネル攻撃の一種です。
名前だけ聞くと大掛かりな手法のように思えますが、原理自体はシンプルです。まずは===の内部動作から確認していきましょう。
PHPの===演算子の内部動作
PHPの===演算子による文字列比較は、内部的にzend_string_equals関数を呼び出します(簡略化して記載)。
// PHP内部実装(簡略化) static zend_always_inline bool zend_string_equal_val(const zend_string *s1, const zend_string *s2) { return !memcmp(ZSTR_VAL(s1), ZSTR_VAL(s2), ZSTR_LEN(s1)); } static zend_always_inline bool zend_string_equal_content(const zend_string *s1, const zend_string *s2) { return ZSTR_LEN(s1) == ZSTR_LEN(s2) && zend_string_equal_val(s1, s2); } static zend_always_inline bool zend_string_equals(const zend_string *s1, const zend_string *s2) { return s1 == s2 || zend_string_equal_content(s1, s2); }
ポイントは2つあります。
- 長さが異なる場合:即座に
falseを返す - 長さが同じ場合:
zend_string_equal_val()で内容を比較(内部でmemcmp()を使用)
そしてmemcmp()の実装を見ると、問題が見えてきます。memcmpはC標準ライブラリの関数で、PHPはシステムのlibcを使用します。
一般的なLinux環境ではglibcが使用されますが、実装が300行以上と複雑なため、ここではシンプルなmusl libcの実装で原理を説明します。
int memcmp(const void *vl, const void *vr, size_t n) { const unsigned char *l=vl, *r=vr; for (; n && *l == *r; n--, l++, r++); return n ? *l-*r : 0; }
ループの継続条件*l == *rが偽になった時点、つまり最初の不一致を見つけた時点で処理を終了します。glibcでも同様に、不一致を検出した時点で即座にreturnする実装になっています(該当箇所)。
実際、Linux man pageのCAVEATSセクションでも明確に警告されています。
Do not use memcmp() to compare confidential data, such as cryptographic secrets, because the CPU time required for the comparison depends on the contents of the addresses compared, this function is subject to timing-based side-channel attacks.
(機密データ(暗号シークレットなど)の比較にmemcmp()を使用しないでください。比較にかかるCPU時間は比較されるアドレスの内容に依存するため、この関数はタイミングベースのサイドチャネル攻撃を受けやすいです。)
攻撃の原理
タイミング攻撃の攻撃者はこの処理時間の差を統計的に分析することで、秘密のトークンを1文字ずつ特定できます。
ステップ1:文字列長の特定
長さが一致した場合のみmemcmp()が実行されるため、わずかに処理時間が長くなります。この差を観測することで、秘密の文字列長を特定できます。
ステップ2:1文字目の特定
長さが分かったら、1文字目を総当たりします。正しい文字の場合は2文字目の比較に進むため、処理時間がわずかに長くなります。
ステップ3:2文字目以降も同様に繰り返す
この手法により、約49,000回のサンプルで15ナノ秒の差を検出できることが研究で示されています。
実験:本当にタイミング差は観測できるのか
理論を確認したところで、実際にどの程度のタイミング差が生じるのかが気になりました。ローカル環境でベンチマークを取ってみます。
ベンチマークコード
256文字の秘密文字列に対して、不一致の位置を変えながら===とhash_equalsの処理時間を比較します。1,000万回の反復を7ラウンド実行し、中央値を採用することでノイズの影響を軽減しています。
<?php declare(strict_types=1); $iterations = 10000000; $rounds = 7; $warmupIter = 2000000; $secret = str_repeat('a1b2c3d4e5f6a7b8', 16); // 256文字 function median(array $values): float { sort($values); return $values[intdiv(count($values), 2)]; } $cases = [ '長さ不一致' => 'short', '1文字目不一致' => 'X' . substr($secret, 1), '中間不一致' => substr($secret, 0, 64) . 'X' . substr($secret, 65), '最後不一致' => substr($secret, 0, -1) . 'X', '完全一致' => substr($secret, 0, -1) . substr($secret, -1), // ポインタが一致しないように文字列生成 ]; foreach ($cases as $label => $input) { // === の計測 for ($i = 0; $i < $warmupIter; $i++) { $secret === $input; } $strictTimes = []; for ($r = 0; $r < $rounds; $r++) { $start = hrtime(true); for ($i = 0; $i < $iterations; $i++) { $secret === $input; } $strictTimes[] = (hrtime(true) - $start) / $iterations; } // hash_equals の計測 for ($i = 0; $i < $warmupIter; $i++) { hash_equals($secret, $input); } $hashEqTimes = []; for ($r = 0; $r < $rounds; $r++) { $start = hrtime(true); for ($i = 0; $i < $iterations; $i++) { hash_equals($secret, $input); } $hashEqTimes[] = (hrtime(true) - $start) / $iterations; } $strictNs = median($strictTimes); $hashEqNs = median($hashEqTimes); printf("%-20s | %9.2f ns | %13.2f ns | %7.2f ns\n", $label, $strictNs, $hashEqNs, $hashEqNs - $strictNs); }
ベンチマーク結果
PHP 8.4.17(JIT有効)、Docker環境で実行した結果です。
| ケース | === (ns) |
hash_equals (ns) |
差分 (ns) |
|---|---|---|---|
| 長さ不一致 | 3.15 | 14.13 | +10.98 |
| 1文字目不一致 | 2.74 | 93.69 | +90.95 |
| 中間不一致 | 3.63 | 94.16 | +90.53 |
| 最後不一致 | 5.17 | 94.28 | +89.11 |
| 完全一致 | 5.17 | 94.52 | +89.35 |
注目していただきたいのは===の列です。
- 1文字目不一致(2.74ns)と最後の文字不一致(5.17ns)で約2.4nsの差がある
- つまり「どこまで一致しているか」が処理時間に表れている
一方、hash_equalsは長さが一致するケースではどれもほぼ93ns前後で安定しています。これが定数時間比較の効果です。
なお、長さ不一致のケースではhash_equalsも14nsと大幅に短くなっています。これは後述するように、長さが異なる場合は即座にfalseを返す仕様のためです。
ランダムな遅延を入れれば防げるのでは?
「レスポンスにランダムな遅延を追加すれば、タイミング差をごまかせるのでは?」と考えたくなるところですが、実はこれは効果がありません。
ランダムな値は試行回数を増やせば平均化されてしまい、本来のタイミング差(信号)は依然として残ります。ircmaxell氏がこの点を詳しく解説しています。
hash_equalsの内部実装
では、hash_equalsはどのようにして定数時間を実現しているのでしょうか。PHP内部の実装を見てみます。
// PHP内部実装(簡略化) // ext/hash/hash.c PHP_FUNCTION(hash_equals) { zval *known_zval, *user_zval; // 引数パース zend_parse_parameters(ZEND_NUM_ARGS(), "zz", &known_zval, &user_zval); // 型チェック(stringのみ許可) if (Z_TYPE_P(known_zval) != IS_STRING) { RETURN_THROWS(); } if (Z_TYPE_P(user_zval) != IS_STRING) { RETURN_THROWS(); } // 定数時間比較を委譲 RETURN_BOOL(0 == php_safe_bcmp(Z_STR_P(known_zval), Z_STR_P(user_zval))); } // main/safe_bcmp.c int php_safe_bcmp(const zend_string *a, const zend_string *b) { // volatileはポインタに付与(コンパイラの最適化を防ぐ) const volatile unsigned char *ua = ZSTR_VAL(a); const volatile unsigned char *ub = ZSTR_VAL(b); int r = 0; // 長さチェック(ここで長さが漏れる) if (ZSTR_LEN(a) != ZSTR_LEN(b)) { return -1; } /* This is security sensitive code. Do not optimize this for speed. */ while (i < ZSTR_LEN(a)) { r |= ua[i] ^ ub[i]; // 全バイト必ず比較 ++i; } return r; // 0なら一致 }
===とmemcmp()との決定的な違いは、不一致があっても処理を止めないことです。XOR演算(^)で差分を検出し、OR演算(|=)で結果を累積していきます。最後にresult == 0であれば全ての文字が一致した、という判定になります。
これにより、入力内容に関わらず常に同じ処理時間が保証されます。
なぜ長さのリークは許容されるのか?
ここで一つ気になるのが、「hash_equalsも長さが異なる場合は即座にfalseを返しているが、それは問題ないのか?」という点です。
ircmaxell氏は以下のように説明しています。
"In general, it's not possible to prevent length leaks. So it's OK to leak the length. The important part is that it doesn't leak information about the difference of the two strings." (一般的に、長さのリークを防ぐことは不可能です。だから長さが漏れても問題ありません。重要なのは、『どの位置の文字が不一致だったか』という内容に関する部分的な情報を漏らさないことです。)
Paragon Initiative Enterprisesはより具体的に述べています。
"The output of HMAC is dependent on hash functions, whose output lengths are public information. No sane cryptography protocol that employs constant time string comparison for comparing the output of two hash functions depends on the length of those hash function outputs remaining a secret." (HMACの出力はハッシュ関数に依存しており、その出力長は公開情報です。ハッシュ関数の出力を比較するために定数時間比較を使用するまともな暗号プロトコルは、その出力長が秘密であることに依存していません。)
つまり、CSRFトークンやHMACの長さは通常固定長であり、長さ自体は秘密ではありません。攻撃者が知りたいのは内容であり、長さ情報だけでは内容を推測できないということです。
ベンチマーク結果でも、長さ不一致のケース(14.13ns)と長さが一致するケース(約93ns)で明確な差が見られましたが、これは仕様通りの動作です。
実践的な使い分け基準
ここまでで原理と内部実装を確認しました。では実際の開発において、いつhash_equalsを使い、いつ===で問題ないのか。整理してみます。
判断フロー
使い分けは「比較する値に秘密情報が含まれ、それをユーザー入力と照合しているか」で決まります。
比較する値にユーザーが知らない秘密が含まれている? ├─ Yes → その秘密をユーザーの入力と照合している? │ ├─ Yes → hash_equals を使う │ └─ No → === で問題ない └─ No → === で問題ない
ここでいう「秘密情報」とは、ユーザーが知らないが、正しい値を提示することで何かにアクセスできる文字列のことです。トークン、APIキー、署名、ハッシュ値などが該当します。
具体的なシナリオ
hash_equalsを使うべき場面
// ✅ CSRFトークンの検証
hash_equals($session->token(), $requestToken);
// ✅ APIキーの検証
hash_equals($storedApiKey, $requestApiKey);
// ✅ Webhookの署名検証
$expected = hash_hmac('sha256', $payload, $secret);
hash_equals($expected, $receivedSignature);
// ✅ メール確認トークンの検証
hash_equals($storedToken, $urlToken);
// ✅ パスワードリセットトークンの検証
hash_equals($storedResetToken, $userProvidedToken);
===で問題ない場面
// ✅ ユーザー名の比較(秘密情報ではない) $username === $inputUsername; // ✅ ロール名の比較 $user->role === 'admin'; // ✅ ステータスの比較 $order->status === 'completed'; // ✅ 設定値の比較 config('app.env') === 'production';
password_verify()を使うべき場面
// ✅ パスワードの検証 password_verify($inputPassword, $storedHash); // ✅ ログイン処理 if (password_verify($request->input('password'), $user->password)) { // 認証成功 }
password_verify()は内部で定数時間比較を行うため、hash_equalsを別途呼ぶ必要はありません。パスワードの検証には必ずpassword_verify()を使いましょう。
hash_equalsを使う際の注意点
パスワード検証にhash_equalsや===を使わない
password_hash()は呼び出すたびにランダムなソルトを生成するため、同じ入力でも毎回異なるハッシュ値を返します。そのため、以下のような比較は常に失敗します。
// ✗ password_hash() は毎回異なる値を返すため、常に不一致になる hash_equals(password_hash($input, PASSWORD_DEFAULT), $storedHash); // ✗ 同上 password_hash($input, PASSWORD_DEFAULT) === $storedHash;
パスワードの検証には、保存されたハッシュからソルトを抽出して正しく比較してくれるpassword_verify()を使いましょう。
引数の順序に意味がある
PHP公式マニュアルには以下の注意があります。
"It is important to provide the user-supplied string as the second parameter, rather than the first." (ユーザーが提供した文字列を第1引数ではなく、第2引数として渡すことが重要です。)
// ✅ 正しい順序:既知の値が第1引数、ユーザー入力が第2引数 hash_equals($knownSecret, $userInput); // ✗ 逆にしない hash_equals($userInput, $knownSecret);
セキュリティコミュニティの慣習として、定数時間比較関数では既知の秘密を第1引数に置く。PHP公式マニュアルでもそのように推奨されています。
型に注意する
hash_equalsは両方の引数が文字列である必要があります。nullやintが渡されるとTypeErrorが発生します。LaravelのtokensMatchメソッドがis_string()で事前チェックしているのは、この問題を防ぐためです。
// Laravel の実装 return is_string($request->session()->token()) && is_string($token) && hash_equals($request->session()->token(), $token);
こうした細かい防御の積み重ねが、フレームワークの堅牢性を支えていることがわかります。
迷ったときの判断基準
判断に迷う場面もあるかと思います。そんなときは、次の問いを考えてみてください。
「この値が攻撃者に1文字ずつ漏れたら、何が起きるか?」
- 何か悪用できる →
hash_equals - 特に問題ない →
===
hash_equalsのオーバーヘッドは数十ナノ秒程度です。迷ったらhash_equalsを選んでおけば、パフォーマンスへの影響はほぼなく、安全側に倒すことができます。
防御の多層化
タイミング攻撃への防御はhash_equalsだけに頼る必要はありません。
Rate Limiting
攻撃には数万〜数十万のリクエストが必要です。Rate Limitingにより試行回数を制限できます。
// Laravel の Rate Limiting
Route::middleware(['throttle:60,1'])->group(function () {
// 1分間に60リクエストまで
});
ネットワーク遅延
リモート攻撃では、ネットワーク遅延(数十ミリ秒)がノイズとして加わります。ナノ秒単位のタイミング差を検出するには、より多くのサンプルが必要になります。
実用的な攻撃の難易度
ircmaxell氏は以下のように述べています。
"From a practical standpoint, I wouldn't worry about timing attacks until I was confident that the other potential vectors are secured." (実用的な観点からは、他の潜在的な攻撃ベクトルが保護されていると確信できるまで、タイミング攻撃を心配する必要はありません。)
SQLインジェクションやXSSなど、より一般的な脆弱性の対策が優先されるべきです。とはいえ、hash_equalsを使うコストはほぼゼロですので、防げるものは防いでおくに越したことはありません。
まとめ
今回は「===ではなくhash_equalsを使う理由」について、内部実装まで掘り下げて調べてみました。
===は早期リターンにより処理時間が入力に依存するhash_equalsは全文字を必ず比較することで定数時間を実現- 長さのリークは許容される(内容の推測には使えないため)
- 使い分けの基準は「比較する値に秘密情報が含まれ、それをユーザー入力と照合しているか」
- 引数の順序は既知の秘密が第1引数、ユーザー入力が第2引数
- パスワード検証は
password_verify()を使う - 防御は多層化(
hash_equals+ Rate Limiting)が効果的
皆さんのプロジェクトにも、===でトークン比較をしている箇所はありませんか? 一度grepしてみると、意外な発見があるかもしれません。「攻撃が難しいから大丈夫」ではなく、「簡単に防げるなら防いでおく」。その姿勢が、システムのセキュリティを支えているのだと改めて感じました。
最後まで読んでいただきありがとうございました。
参考資料
- PHP Manual: hash_equals - PHP公式ドキュメント。引数の順序に関する注意も記載
- It's All About Time - ircmaxell's Blog - タイミング攻撃の包括的な解説。ランダム遅延が無効な理由、長さリークの許容性など
- Preventing Timing Attacks on String Comparison with a Double HMAC Strategy - Paragon Initiative - Double HMAC戦略と長さリークが問題にならない理由
- Remote Timing Attacks are Practical (PDF) - 49,000サンプルで15nsの差を検出できることを示した学術論文
- PHP RFC: timing_attack -
hash_equalsがPHP 5.6で導入された経緯 - Security Tip: Compare keys with hash_equals() - Laravel向けのセキュリティTips
- Wikipedia: Timing attack - タイミング攻撃の概要と歴史