Slide 1

Slide 1 text

2026年2月24日 第184回PHP勉強会@東京 なぜ秘密の比較に hash_equals を使うのか ー内部実装と実践ガイド

Slide 2

Slide 2 text

2 自己紹介 PHPer歴 11年目のエンジニア。PHPは私の育ての親。 1歳とトイプードルの父。 2月は1ヶ月が短くて、祝日もあるので得した気分になります。 塚原 彰仁 2025.11 コドモンに入社 セキュリティチームに所属 2026.02 コドモン入ってから初の外部登壇です! @AkitoTsukahara

Slide 3

Slide 3 text

3 きっかけ 業務でセキュリティに関する実装をする機会があり Laravelの内部実装を参考に読んでいたら CSRFトークンの検証で気になるコードを見つけました👀 vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/VerifyCsrfToken.php

Slide 4

Slide 4 text

4 ===じゃダメなんだっけ? 文字列の一致を確認するだけなら === で十分では? なぜわざわざ hash_equals を使うのか?

Slide 5

Slide 5 text

5 理由はタイミング攻撃対策 === は処理時間から秘密が漏れる可能性がある hash_equals はそれを防いでくれる

Slide 6

Slide 6 text

6 この発表の流れ ⚫ === の内部実装とタイミングリーク ⚫ hash_equals の内部実装と定数時間比較 ⚫ hash_equals が生まれた歴史 ⚫ 実践的な使い分けの基準を整理

Slide 7

Slide 7 text

タイミング攻撃の原理

Slide 8

Slide 8 text

8 タイミング攻撃とは 処理時間の微小な 差 を観測して 秘密情報を推測する サイドチャネル攻撃

Slide 9

Slide 9 text

9 PHPの === の内部を追ってみる 文字列の === 比較は内部で zend_string_equals() を呼び出します

Slide 10

Slide 10 text

10 PHPの === の内部実装を追ってみる さらに zend_string_equal_content() を呼び出します

Slide 11

Slide 11 text

11 PHPの === の内部実装を追ってみる そして zend_string_equal_val() を呼び出します

Slide 12

Slide 12 text

12 ポイントは2つ ⚫ 長さが異なる場合 即座に false を返す ⚫ 長さが同じ場合 memcmp() で内容を比較

Slide 13

Slide 13 text

13 memcmpとは C言語の標準ライブラリ関数で、 2つのメモリ領域をバイト単位で比較する PHPはシステムの libcを使用 glibcの実装は300行以上と複雑なため、シンプルな musl libc の実装で原理を説明します

Slide 14

Slide 14 text

14 memcmpの実装(musl libc) musl libc: 軽量なC標準ライブラリ実装。 glibcでも不一致検出時に即 returnする点は同様

Slide 15

Slide 15 text

15 この1行が問題 ⚫ n が残っている間、1バイトずつ比較 ⚫ *l == *r が成立する間だけループ継続 ⚫ 不一致を見つけた瞬間にループ終了

Slide 16

Slide 16 text

16 Linux man pageの警告 “ 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時間は比較されるアドレスの内容に依存するため、こ の関数はタイミングベースのサイドチャネル攻撃を受けやすいです。)

Slide 17

Slide 17 text

17 早期リターンの問題 一致している文字数が多いほど処理時間が長くなる 1文字目で不一致 すぐに終了 → リターンが早い 最後の文字で不一致 全部比較 → リターンが遅い

Slide 18

Slide 18 text

18 攻撃の3ステップ Step 1 文字列長の特定 長さ一致時のみmemcmpが実行され、わずかに処理時間が長くなる ▼ Step 2 1文字目の特定 正しい文字で2文字目比較に進み、処理時間が増加 ▼ Step 3 2文字目以降も同様 全文字を1文字ずつ特定していく

Slide 19

Slide 19 text

19 どれくらいの試行で特定できる? 約 49,000回 のサンプルで 15ナノ秒 の差を検出可能 出典: "Remote Timing Attacks are Practical" - Rice大学の論文

Slide 20

Slide 20 text

20 ランダムな遅延では防げない 「レスポンスにランダムな遅延を追加すれば防げるのでは?」 ランダムな値は試行回数を増やせば 平均化 されてしまう 本来のタイミング差(信号)は依然として残る → 根本的な解決にはならない

Slide 21

Slide 21 text

ライブデモ

Slide 22

Slide 22 text

22 本当に差が出るの? 理論は分かった でも本当に測定ができるほどの差が出るの? →実際に測ってみましょう!

Slide 23

Slide 23 text

23 ベンチマーク環境 ⚫ 環境 : Docker(Apple Silicon) ⚫ 文字列長 : 256文字 ⚫ 試行回数 : 1,000万回 × 7ラウンド(中央値を採用) ⚫ 測定対象 : 不一致位置を変えて === と hash_equals を比較

Slide 24

Slide 24 text

24 比較するPHPバージョン ⚫ PHP 5.6 : hash_equalsが初めて導入されたバージョン ⚫ PHP 7.4 : PHP 7系の最終安定版 ⚫ PHP 8.4 : PHP 8系(JIT無効) ⚫ PHP 8.4 : PHP 8系(JIT有効)←ライブデモ 時間の都合上、ライブデモは PHP 8.4(JIT有効) のみ実施。 他のバージョンは事前に実行した結果を利用しています。 実行するコードはこちら

Slide 25

Slide 25 text

25 測定パターン ⚫ 1文字目不一致 : 最初の文字だけ違う → 最速 ⚫ 最後不一致 : 256文字目で不一致 → 最遅 ⚫ 差分 : この2つの差がタイミング攻撃の根拠

Slide 26

Slide 26 text

ターミナルに切り替えます

Slide 27

Slide 27 text

27 ベンチマーク結果(=== 演算子) PHP Version 1文字目不一致 最後不一致 差分 5.6 14.09 ns 23.20 ns 9.11 ns 7.4 6.47 ns 9.55 ns 3.08 ns 8.4(JIT無効) 6.01 ns 9.26 ns 3.25 ns 8.4(JIT有効) 2.74 ns 5.17 ns 2.43 ns

Slide 28

Slide 28 text

28 バージョンによる変化 ⚫ PHP 5.6 → 8.4(JIT) で差分は約3分の1に ⚫ しかし ゼロにはならない ⚫ JITを有効にしても差は残る

Slide 29

Slide 29 text

29 hash_equalsとの比較(PHP 8.4 JIT) ケース === hash_equals 長さ不一致 3.15 ns 14.13 ns 1文字目不一致 2.74 ns 93.69 ns 中間不一致 3.63 ns 94.16 ns 最後不一致 5.17 ns 94.28 ns 完全一致 5.17 ns 94.52 ns

Slide 30

Slide 30 text

30 注目ポイント === の列 ⚫ 1文字目不一致 : 2.71ns ⚫ 最後不一致 : 5.13ns ⚫ 約2.4ns の差がある ⚫ 「どこまで一致しているか」が   処理時間に現れている hash_equals の列 ⚫ 長さ一致ケースは全て 約94ns で安定 ⚫ これが 定数時間比較 の効果 ⚫ オーバーヘッドは約 90ns   (処理時間は無視できるレベル)

Slide 31

Slide 31 text

hash_equalsの内部実装

Slide 32

Slide 32 text

32 hash_equalsはどう解決するのか 不一致があっても 全文字を必ず比較する

Slide 33

Slide 33 text

33 hash_equalsの内部実装① ⚫ PHP 8.0+ の型チェック強化 はここで実装 ⚫ 実際の比較ロジックは php_safe_bcmp に委譲

Slide 34

Slide 34 text

34 hash_equalsの内部実装② ===(memcmp)との決定的な違いは 不一致があっても処理を止めない こと

Slide 35

Slide 35 text

35 実装のキモ — XOR演算(^) 同じ文字 A ^ A = 0 ビットが全て同じ → 0 違う文字 A ^ B ≠ 0 異なるビットがある →非0

Slide 36

Slide 36 text

36 OR演算(!=)の役割 ⚫ 各文字のXOR結果を r(result) に累積 ⚫ 一度でも差があれば r ≠ 0 ⚫ 全文字を確認するまで結果が確定しない

Slide 37

Slide 37 text

37 なんでこれで定数時間に? ⚫ 途中で不一致を見つけても ループを抜けない ⚫ 必ず最後の文字まで比較を続ける ⚫ どこで不一致があっても同じ処理回数

Slide 38

Slide 38 text

38 volatileキーワードの重要性 ⚫ コンパイラに「このポインタ経由のアクセスを 最適化するな」と指示 ⚫ 最適化により早期リターンが発生するのを防止 ⚫ 定数時間を守るための重要な工夫 定数時間を脅かす要因(コンパイラ最適化・ CPUキャッシュ等)は多いが、 PHPコアで明示的に対処

Slide 39

Slide 39 text

39 なぜ長さのリークは許容されるのか? “ 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. CSRFトークンやHMACの出力長は 公開情報(固定長) 攻撃者が知りたいのは 内容 であり、長さだけでは内容を推測できない hash_equals も長さが異なると即座に false を返すが、問題ないか? — ircmaxell's Blog "It's All About Time"

Slide 40

Slide 40 text

hash_equalsの歴史

Slide 41

Slide 41 text

41 RFC: timing_attack ⚫ 2013年12月: Rouven Weßling氏がRFCを提出 ⚫ RFCの名前が「timing_attack」 ⚫ 投票結果: 賛成22、反対1 で可決 — Request for Comments: Timing attack safe string comparison function

Slide 42

Slide 42 text

42 PHP 5.6(2014年8月) ⚫ タイミング攻撃対策として 初登場 ⚫ ext/hash に実装 導入の背景 PHP 5.5の password_verify は定数時間比較を備えていたがパスワード検証専用。 CSRFトークンやHMAC署名など 文字列同士の汎用的な定数時間比較 が必要だった

Slide 43

Slide 43 text

43 PHP 8.0(2020年) ⚫ 型チェック強化 ⚫ 文字列以外を渡すと TypeError ⚫ 誤用を防ぐための改善

Slide 44

Slide 44 text

実践的な使い分け

Slide 45

Slide 45 text

45 使い分けの判断基準 「比較する値に 秘密情報 が含まれ、 それを ユーザー入力 と照合しているか」

Slide 46

Slide 46 text

46 判断フロー 比較する値にユーザーが知らない 秘密 が含まれている? その秘密をユーザーの 入力と 照合 している? === で問題なし hash_equals === で問題なし ↓ Yes ↓ No ↓ Yes ↓ No

Slide 47

Slide 47 text

47 迷ったときの判断基準 ⚫ 何か悪用できる → hash_equals ⚫ 特に問題ない → === hash_equalsのオーバーヘッドは数十ナノ秒程度。 迷ったら hash_equals を選んでおけば安全 「この値が攻撃者に 1文字ずつ 漏れたら何が起きるか?」

Slide 48

Slide 48 text

48 hash_equalsを使うべき場面

Slide 49

Slide 49 text

49 === で問題ない場面 → 秘密ではない値の比較(公開情報)

Slide 50

Slide 50 text

50 password_verify()を使うべき場面 パスワード検証には専用関数を使う 内部で定数時間比較も行うため、タイミング攻撃にも安全

Slide 51

Slide 51 text

51 注意①: 引数の順序にご注意を 第1引数にサーバー側の秘密、第 2引数にユーザー入力を渡すこと PHP公式マニュアルでも hash_equals のCautionとして明記されている注意点 です。

Slide 52

Slide 52 text

52 注意②: 型チェック is_string() で事前チェックしてTypeErrorを防止。 こうした細かい防御の積み重ねがフレームワークの堅牢性を支えている hash_equals は 文字列のみ 対応。nullやintでTypeError

Slide 53

Slide 53 text

まとめ

Slide 54

Slide 54 text

54 今日のポイント ⚫ === は 早期リターン により処理時間が入力に依存する ⚫ hash_equals は 全文字を必ず比較 して定数時間を実現 ⚫ 長さのリーク は許容される(内容の推測には使えない) ⚫ 使い分けの基準は「秘密情報をユーザー入力と照合しているか」 ⚫ 引数の順序は 既知の秘密が第1引数、ユーザー入力が第2引数 ⚫ パスワード検証は password_verify() を使う

Slide 55

Slide 55 text

55 多層防御の重要性 “ From a practical standpoint, I wouldn't worry about timing attacks until I was confident that the other potential vectors are secured. ⚫ Rate Limiting: 攻撃に必要な数万〜数十万の試行を制限 ⚫ トークンのローテーション: 有効期限を短くして試行回数を制限 ⚫ 他の対策も重要: SQLi、XSS対策が優先 — ircmaxell's Blog "It's All About Time" 「実用的には、他の脆弱性対策ができてからタイミング攻撃を心配すべき」

Slide 56

Slide 56 text

56 やってみよう 秘密の値を === で比較している箇所があれば hash_equals への置き換えを検討。 「攻撃が難しいから大丈夫」ではなく、「 簡単に防げるなら防いでおく 」 皆さんのプロジェクトをgrepしてみてください

Slide 57

Slide 57 text

57 参考資料 ⚫ PHP RFC: timing_attack ⚫ PHP Manual: hash_equals ⚫ Linux man page: memcmp ⚫ ircmaxell's Blog ⚫ Remote Timing Attacks are Practical (PDF) - Rice大学の論文 ⚫ 本発表の元記事

Slide 58

Slide 58 text

Q&A PHP勉強会でいただいた質問と回答を用意しました!

Slide 59

Slide 59 text

59 Q1. password_verifyとの違いは何ですか? ケース hash_equals password_verify 比較対象 文字列 × 文字列 平文パスワード × ハッシュ 内部処理 定数時間比較のみ ハッシュ化 → 定数時間比較 用途 CSRFトークン、HMAC署名、API キー等 パスワード検証専用 password_verify:平文パスワードをハッシュ化してから比較するパスワード専用関数 hash_equals:文字列同士をそのまま比較する汎用関数

Slide 60

Slide 60 text

60 Q2. hash_equalsができる以前はどうやって防御していたのか? ①同じロジックの自前実装(各フレームワーク等) RFCのReferencesに具体例が挙げられています ⚫ Symfony: StringUtils::equals() ⚫ Zend Framework 2: Zend\Crypt\Utils::compareStrings() ②別の戦略で回避 定数時間比較を正しく書くのは難しいため、比較の仕組み自体を変えるアプローチ ⚫ Double HMAC 戦略(Paragon Initiative Enterprises が提唱) ⚫ 比較する2つの文字列を毎回異なるランダムキーでHMAC値を計算してから === で比較 ⚫ 毎回結果が変わるため、タイミング差を統計的に蓄積できない

Slide 61

Slide 61 text

61 Q2. hash_equalsができる以前はどうやって防御していたのか? そもそも当時からメジャーな攻撃手法ではなかった “Timing attacks are not a widely recognized problem, since they require significant skill and resources of the attacker. OWASP Top 10 にも独立項目として掲載されたことがない ⚫ 2013年版〜2025年版すべてで不掲載 ⚫ SQLi・XSS 等と比べて対策の優先度が低かった サイドチャネル攻撃が広く意識されたのは hash_equals よりも後 ⚫ 2018年:Spectre / Meltdown、2022年:Hertzbleed いずれもCPUレベルの脆弱性であり、文字列比較のタイミング攻撃とはレイヤーが異なる ただし「処理の差から情報が漏れる」という原理は共通 — RFC 本文(2013年12月)

Slide 62

Slide 62 text

62 Q2. hash_equalsができる以前はどうやって防御していたのか? メジャーでない攻撃に対して、自前で対策を書く開発者は少ない → だからこそ言語が関数を提供し、使うだけで守れる状態にする必要があった “ this RFC aims to make it simpler for PHP developers to protect their applications. ⚫ 難しい定数時間比較の実装を開発者に任せない ⚫ hash_equals() を呼ぶだけで対策が完了する ⚫ 本発表のまとめでも紹介した「簡単に防げるなら防いでおく」はこの設計思想と同じ — RFC 本文(2013年12月)

Slide 63

Slide 63 text

63 Q3. libcなど他言語を読む時のソースコードを読むコツは? 生成AIにガイドしてもらいながら読み進めるのがおすすめ ⚫ ただし、AIの回答は鵜呑みにしない ⚫ 「なぜそう言えるのか?根拠となるコードはどこか?」を常に確認する 実際、今回libcを読んだのもこのアプローチの結果 ⚫ hash_equalsの仕様をAIに質問しながら深掘りしていった ⚫ エビデンスを辿っていった先が、libcの内部実装だった ⚫ 普段C言語を書いているわけではないので苦戦はしたが、それもAIのガイドで乗り越える

Slide 64

Slide 64 text

64 Q4. hash_equalsという名前なのにhash使ってないんですね? 命名の経緯 RFC では元々 hash_compare という名前で提案 投票は hash_compare で可決(賛成22 / 反対1) 実装時に hash_equals にリネーム RFC「Differences between this RFC and the implementation」に記載 hash_ プレフィックスの理由 RFCに「as part of ext/hash」と明記されている通り、hashモジュールの一部として実装 同モジュールの関数は慣例として hash_ プレフィックスを持つ hash_hmac, hash_file, hash_pbkdf2 等 →内部でハッシュ化しているわけではなく、所属モジュールに由来する命名

Slide 65

Slide 65 text

65 ② 比較処理の外側で守る:多層防御 Q5. hash_equalsを使わずにタイミング攻撃を防ぐ方法は? ① 比較処理自体を守る(Q2で紹介した手法) ⚫ 定数時間比較の自前実装(Symfony, Zend Framework 2 等の実績あり) ⚫ Double HMAC 戦略 対策 効果 Rate Limiting 数万〜数十万回の試行を制限 トークンのローテーション 有効期限内にサンプル収集を困難に 十分なエントロピー 1文字漏れても探索空間が膨大 異常検知 規則的アクセスパターンを検知 → 現実的には①と②の組み合わせで防御する

Slide 66

Slide 66 text

コドモン採用ページ コドモンでは、ともに課題を紐解き、一歩一歩前に進んでいく 仲間を募集しています! 開発チームX

Slide 67

Slide 67 text

No content