こんにちは!PHPハッシュ関数シリーズ第4回として、今回は重要なセキュリティ関数である hash_equals()
について詳しく解説します。この関数は一見シンプルですが、セキュリティ上とても重要な役割を果たしています。
hash_equals 関数とは?
hash_equals()
関数は、2つの文字列を「定時間(constant-time)」で比較するための関数です。PHP 5.6.0 から導入されました。主にハッシュ値やセキュリティトークンの比較に使用されます。
bool hash_equals ( string $known_string , string $user_string )
パラメータ
- $known_string: 既知の文字列(ハッシュ値など)
- $user_string: ユーザー入力や検証対象の文字列
戻り値
2つの文字列が同一であれば true
、異なれば false
を返します。
なぜ hash_equals が必要なのか?
一般的な文字列比較(==
や ===
演算子など)は、最初に異なる文字が見つかった時点で比較を中止します。この挙動は通常のプログラミングでは効率的ですが、セキュリティ上の問題を引き起こす可能性があります。
これが問題となるのが「タイミング攻撃」です。攻撃者は、比較処理にかかる時間を精密に測定することで、どの位置まで文字列が一致しているかを推測できてしまいます。これにより少しずつ正しい文字列を解読していく可能性があります。
hash_equals()
関数は、2つの文字列の長さが異なる場合でも、また内容が異なる場合でも、常に同じ時間で比較処理を行います。これにより、処理時間からの情報漏洩を防止します。
基本的な使用例
hash_equals()
の基本的な使い方を見てみましょう:
<?php
// ストアされているハッシュ値(通常はデータベースなどから取得)
$stored_hash = hash('sha256', 'correct_password');
// ユーザーが入力したパスワードからハッシュを生成
$user_input = 'user_entered_password';
$input_hash = hash('sha256', $user_input);
// 安全な比較
if (hash_equals($stored_hash, $input_hash)) {
echo "パスワードが一致しました!";
} else {
echo "パスワードが一致しません。";
}
?>
一般的な比較演算子との違い
hash_equals()
と一般的な比較演算子(==
、===
)との違いを理解するため、次の例を見てみましょう:
<?php
// 2つのハッシュ値(64文字の16進数文字列)
$hash1 = str_repeat('a', 63) . 'b'; // 'aaa...aaab'
$hash2 = str_repeat('a', 63) . 'c'; // 'aaa...aaac'
// タイミング測定開始
$start_time = microtime(true);
// 通常の比較演算子を使用
$result1 = ($hash1 === $hash2);
$time1 = microtime(true) - $start_time;
// 再度タイミング測定開始
$start_time = microtime(true);
// hash_equals を使用
$result2 = hash_equals($hash1, $hash2);
$time2 = microtime(true) - $start_time;
echo "通常の比較(===)結果: " . ($result1 ? 'true' : 'false') . "<br>";
echo "処理時間: " . number_format($time1 * 1000000, 2) . " マイクロ秒<br><br>";
echo "hash_equals 結果: " . ($result2 ? 'true' : 'false') . "<br>";
echo "処理時間: " . number_format($time2 * 1000000, 2) . " マイクロ秒<br>";
?>
注意: 実際のマイクロ秒単位の測定は環境によって大きく異なり、このような単純なテストでは差が出ないこともありますが、理論的な違いを理解するためのデモとしてご覧ください。
実用例1:API認証トークンの検証
APIトークンの安全な検証を行う例:
<?php
class ApiAuthenticator {
private $api_tokens = [
'user1' => 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6',
'user2' => 'q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2'
];
public function validateApiRequest($username, $provided_token) {
// ユーザーが存在するか確認
if (!isset($this->api_tokens[$username])) {
return false;
}
// ストアされているトークンを取得
$stored_token = $this->api_tokens[$username];
// 安全な比較
return hash_equals($stored_token, $provided_token);
}
}
// 使用例
$auth = new ApiAuthenticator();
// 正しいトークンでテスト
$valid_result = $auth->validateApiRequest('user1', 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6');
echo "正しいトークンでの認証結果: " . ($valid_result ? '成功' : '失敗') . "<br>";
// 誤ったトークンでテスト
$invalid_result = $auth->validateApiRequest('user1', 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5XX');
echo "誤ったトークンでの認証結果: " . ($invalid_result ? '成功' : '失敗') . "<br>";
?>
実用例2:CSRFトークンの検証
Webフォームでのクロスサイトリクエストフォージェリ(CSRF)対策として、トークンを安全に検証する例:
<?php
class CsrfProtector {
public function generateToken() {
// 安全な乱数を生成
$token = bin2hex(random_bytes(32));
// セッションに保存
$_SESSION['csrf_token'] = $token;
return $token;
}
public function validateToken($user_token) {
// セッションにトークンが存在するか確認
if (!isset($_SESSION['csrf_token'])) {
return false;
}
// セッションに保存されているトークンを取得
$stored_token = $_SESSION['csrf_token'];
// 一度使用したトークンを破棄(オプション)
// unset($_SESSION['csrf_token']);
// 安全な比較
return hash_equals($stored_token, $user_token);
}
}
// 使用例(実際のコードではセッション開始などが必要)
// session_start();
$csrf = new CsrfProtector();
// 通常のフローをシミュレート
$token = $csrf->generateToken();
echo "生成されたCSRFトークン: " . $token . "<br>";
// フォーム送信をシミュレート(正しいトークン)
$valid = $csrf->validateToken($token);
echo "正しいトークンでの検証結果: " . ($valid ? '成功' : '失敗') . "<br>";
// 不正なリクエストをシミュレート(誤ったトークン)
$invalid = $csrf->validateToken($token . 'invalid');
echo "誤ったトークンでの検証結果: " . ($invalid ? '成功' : '失敗') . "<br>";
?>
実用例3:パスワードリセットトークンの検証
パスワードリセット処理での安全なトークン検証の例:
<?php
class PasswordResetManager {
// データベーステーブルをシミュレート
private $reset_tokens = [
'user@example.com' => [
'token' => '8f7d89a6c4b2e0f1d3c5a7b9e2d4f6a8',
'expires' => '2025-05-20 12:00:00'
]
];
public function validateResetToken($email, $token) {
// ユーザーにリセットトークンが発行されているか確認
if (!isset($this->reset_tokens[$email])) {
return [false, 'リセットトークンが見つかりません'];
}
$token_data = $this->reset_tokens[$email];
// トークンの有効期限をチェック
if (strtotime($token_data['expires']) < time()) {
return [false, 'リセットトークンの有効期限が切れています'];
}
// トークンを安全に比較
if (!hash_equals($token_data['token'], $token)) {
return [false, 'リセットトークンが無効です'];
}
return [true, 'トークンは有効です'];
}
}
// 使用例
$resetManager = new PasswordResetManager();
// 有効なトークンでテスト
list($valid, $message) = $resetManager->validateResetToken(
'user@example.com',
'8f7d89a6c4b2e0f1d3c5a7b9e2d4f6a8'
);
echo "有効なトークンでの検証: " . $message . "<br>";
// 無効なトークンでテスト
list($invalid, $message) = $resetManager->validateResetToken(
'user@example.com',
'8f7d89a6c4b2e0f1d3c5a7b9e2d4f6a9'
);
echo "無効なトークンでの検証: " . $message . "<br>";
?>
タイミング攻撃とは?より詳しく解説
タイミング攻撃は、暗号システムに対する副チャネル攻撃の一種です。攻撃者は、操作の実行にかかる時間を分析して、機密情報(パスワード、暗号鍵など)を推測します。
タイミング攻撃の仕組み
- 従来の文字列比較: 通常、2つの文字列を比較する場合、先頭から順に比較し、異なる文字が見つかった時点で「不一致」と判断して処理を終了します。
- 時間差の発生: 不一致が発生する位置が後ろになるほど、比較処理に時間がかかります。
- 攻撃の進行: 攻撃者は、少しずつ文字を変えながら処理時間を測定し、最も時間がかかったパターンを選びます。これを繰り返すことで、徐々に正しい文字列を解読していきます。
実際のシナリオを考えてみよう
例えば、32バイトのAPIトークンを使用するシステムがあるとします。攻撃者は、以下のように攻撃を進めます:
- トークンの最初の文字を「0」〜「f」(16進数)の範囲で変化させ、それぞれの応答時間を測定します。
- 最も応答時間が長かった文字を正しい文字と推測し、2文字目の攻撃に進みます。
- このプロセスを繰り返し、徐々にトークン全体を解読していきます。
理論的には、256回の試行(16進数の場合)×トークンの長さ(バイト数)の試行で、ブルートフォース攻撃(2^128試行)よりも効率的に解読できる可能性があります。
hash_equals による対策
hash_equals()
関数は、以下の方法でタイミング攻撃を防止します:
- 定時間比較: 文字列の長さや内容に関わらず、常に同じ基本手順を実行します。
- 全文字比較: 最初の不一致が見つかっても、残りの文字も比較し続けます。
- 最適化の抑制: コンパイラによる最適化を防ぎ、定時間性を保証します。
内部的には、バイトごとの比較結果をOR演算で組み合わせるなどの手法が使われており、結果がわかっても同じ処理ステップを実行するようになっています。
代替手段とフォールバック
PHP 5.6.0 より前のバージョンでは hash_equals()
関数が利用できません。そのような環境でもタイミング攻撃に対する基本的な保護を提供するフォールバック実装の例を示します:
<?php
if (!function_exists('hash_equals')) {
/**
* 定時間文字列比較の簡易実装
* 注意: 可能な限り組み込み関数を使用してください
*/
function hash_equals($known_string, $user_string) {
// 文字列の長さが異なる場合は不一致
if (strlen($known_string) !== strlen($user_string)) {
return false;
}
$result = 0;
// 全文字を比較し、異なるバイトが見つかってもすべて比較する
for ($i = 0; $i < strlen($known_string); $i++) {
$result |= ord($known_string[$i]) ^ ord($user_string[$i]);
}
return $result === 0;
}
}
// 使用例
$token1 = "abcdef123456";
$token2 = "abcdef123457";
if (hash_equals($token1, $token2)) {
echo "トークンは一致しています";
} else {
echo "トークンは一致していません";
}
?>
注意: このフォールバック実装は基本的な保護を提供しますが、ネイティブの hash_equals()
関数ほど安全ではありません。可能な限り、PHP 5.6.0 以上を使用することをお勧めします。
PHP 7.4以降の改良されたハッシュ関数
PHP 7.4以降では、hash_equals()
関数のパフォーマンスと安全性がさらに向上しています。また、PHP 8.0以降では、JIT(Just-In-Time)コンパイラを使用した場合でも、定時間比較の特性が保持されるように設計されています。
注意点とベストプラクティス
- 常に機密データの比較に使用する: パスワードハッシュ、セッションID、CSRF/APIトークン、署名などの比較には必ず
hash_equals()
を使用しましょう。 - 文字列長の事前チェックを省略する: 一般的なコードでは、文字列長が異なる場合に早期リターンすることがありますが、
hash_equals()
を使う場合はこれを避けてください。関数自体が文字列長の違いを安全に処理します。 ===
やstrcmp()
への依存を避ける: これらの関数は定時間比較を保証しないため、セキュリティに関わる比較には使用しないでください。- バージョン互換性に注意する: PHP 5.6.0 より前のバージョンでは、フォールバック実装を検討するか、可能であればPHPバージョンをアップグレードしてください。
- 関連するセキュリティ機能との連携:
random_bytes()
やpassword_hash()
などの関数と組み合わせて、総合的なセキュリティを確保しましょう。
まとめ
hash_equals()
関数は、一見シンプルですが、重要なセキュリティ機能を提供します。特にウェブアプリケーションでの認証処理、トークン検証、署名確認など、様々なセキュリティ要素に不可欠です。
タイミング攻撃は高度な攻撃手法に見えるかもしれませんが、実際には比較的容易に実行できるため、適切な対策を講じることが重要です。hash_equals()
関数を正しく使用することで、システムのセキュリティを大幅に向上させることができます。
PHP の他のハッシュ関数と組み合わせることで、より堅牢で安全なアプリケーションを構築できるでしょう。特に hash()
、hash_algos()
、hash_copy()
などの関数と併用することで、効率的かつ安全なハッシュ処理を実現できます。
次回のPHPハッシュ関数シリーズでは、また別のハッシュ関連関数について解説していきますので、お楽しみに!