こんにちは!セキュリティ対策の現場へようこそ。
新人のIT担当者さんや、「セキュリティについてこれからしっかり学んでいきたい!」という開発者の方に向けて、日々現場で私たちが直面するリアルな脅威と、そのスマートな防ぎ方をお伝えしていきますね。
今回は、Webアプリの心臓部とも言える「パスワードの認証」に潜む、ちょっとマニアックだけど絶対に知っておかなきゃいけない落とし穴、「タイミング攻撃」と、その鉄壁の防御策について紐解いていきます。
難しそうに聞こえるかもしれませんが、身近な「鍵と泥棒」の話に置き換えながら一歩ずつ解説していくので、リラックスして読んでくださいね。
—
1. 家の鍵と「タイミング攻撃」の仕組み
想像してみてください。あなたは、とっても頑丈な金庫の暗証番号(パスワード)を知っているとします。
泥棒があなたの金庫の暗証番号をこっそり破ろうと企んでいます。もし、金庫の鍵穴やボタンの仕組みがこんな風になっていたらどうでしょう?
- 正しい番号を上から順に打ち込んでいき、「1文字目が合っていると、ちょっとだけカチャッと反応が遅くなる(または早くなる)」
- つまり、間違った文字を入力したときは即座に「ブブー(不正解)」と弾かれるのに、正しい文字だと中の機械が少し考え込むようなタイムラグがある。
鋭い泥棒なら、この「わずかな時間差(タイミング)」をストップウォッチで測ってこう考えます。
> 「おっ、1文字目を『7』にした時だけ、コンマ数秒返事が遅かったぞ。ってことは、1文字目は『7』で確定だな!」
次に、1文字目を「7」に固定したまま、2文字目を「0」から「9」まで試します。また特定の数字の時だけ返事が遅れれば、2文字目も特定できますよね。
このように、「正解に近づくほど処理にかかる時間が微妙に変わる」というシステム側の特徴を利用して、時間を測るだけで秘密を暴き出す手口。これが、今回お話する「タイミング攻撃(Timing Attack)」の正体です。
—
2. プログラミングの世界で何が起きているのか?
現実のWebアプリでも、これと同じことが起きてしまいます。
例えば、ユーザーがログイン画面で入力したパスワードのハッシュ値と、データベースに保存されている正しいハッシュ値を比較するシーンを考えてみましょう。
初心者の頃によやってしまいがちなのが、普通の文字列比較演算子(例えばJavaScriptの === や、PHPの == など)を使って、以下のように比較してしまうことです。
// 【危険な例】普通の比較演算子を使ったチェック
function checkPassword(inputHash, storedHash) {
// 文字列の「先頭から順番に」1文字ずつ比較していきます
return inputHash === storedHash;
}
コンピュータの裏側の世界(文字列比較のアルゴリズム)では、通常、文字列を「先頭の文字から順に」比較していきます。
もし先頭の1文字目で「あ、違うわ」と分かったら、そこで面倒な比較をやめて即座に false(不一致)を返します。これを「早期リターン(Short-circuit evaluation)」と言います。
しかし、もし先頭から3文字目まで一致していたら? コンピュータは3文字目まで比較する手間をかけるため、1文字目で弾かれた時よりも、ほんのわずかに処理時間が長くなります。
この「一致している文字数が多いほど、完了するまでの時間が長くなる」という特徴を、世界中のネットワーク越しに何百万回もリクエストを送り、ミリ秒単位(あるいはマイクロ秒単位)の微小な時間差を統計的に分析して見抜いてしまうのが、サイバー攻撃者の手口なのです。
—
3. 防御の切り札:定数時間比較(Timing-Safe Equal)
「じゃあ、どうやってこの時間差を消せばいいの?」と思いますよね。
答えはとてもシンプルです。「結果がどうあれ、比較にかかる時間を常に一定にする」特別な関数を使えばいいのです。
これが、セキュリティの世界で必須とされる「定数時間比較関数(Constant-time comparison function)」です。
この関数は、途中で文字が一致しなかろうが何しようが、必ず文字列の最後まで(あるいは無駄な処理を含めて)同じ時間をかけて比較を行い、最後にまとめて結果を返します。 これにより、攻撃者に「時間差」というヒントを一切与えなくするわけです。
主要なプログラミング言語やプラットフォームでは、この安全な比較関数が標準で用意されています。実務でコードを書くときは、必ずこれらを使うように癖をつけていきましょう!
実装サンプル①:Node.js の場合
Node.jsでは、組み込みの crypto モジュールに専用の関数が用意されています。
const crypto = require('crypto');
function secureVerify(inputHash, storedHash) {
// バッファ(バイナリデータ)に変換する
const buf1 = Buffer.from(inputHash);
const buf2 = Buffer.from(storedHash);
// 長さが違う場合にエラー(例外)を発生させず、かつ安全に比較するための配慮
if (buf1.length !== buf2.length) {
// 長さが違う場合は、ダミーの比較を行ってから不一致を返すことで、
// 長さの違いによるタイミング攻撃をも防ぐテクニックが使われます。
return false;
}
// ★ここで「定数時間比較」を行う
// これにより、何文字目で間違えたかに関わらず、常に一定の時間が経過します。
return crypto.timingSafeEqual(buf1, buf2);
}
実装サンプル②:PHP の場合
PHPでハッシュ(特にパスワードハッシュ)を比較する場合は、専用の強力な関数があります。
// パスワードの検証には、PHP標準の password_verify() を使うのが鉄則です。
// この関数は内部で自動的にタイミング攻撃対策(定数時間比較)を行ってくれます。
$inputPassword = $_POST['password']; // ユーザーが入力したパスワード
$storedHashFromDB = '$2y$10$abcdef...'; // データベースから取得したハッシュ
if (password_verify($inputPassword, $storedHashFromDB)) {
echo "ログイン成功!";
} else {
echo "パスワードが違います。";
}
// ※ もし純粋な文字列(APIトークンやハッシュ値など)同士を比較したい場合は、
// hash_equals() 関数を必ず使いましょう!
// 例: if (hash_equals($expectedHash, $userProvidedHash)) { ... }
—
4. 現場のエンジニアが気をつけるべきポイント
さて、ここまで読んで「なるほど、専用の比較関数を使えばいいんだな」とご理解いただけたかと思います。最後に、現場で開発やインフラ構築を行う際に、もう一歩踏み込んで気をつけてほしいポイントをいくつかお伝えしますね。
1. 生パスワードの比較に == や === を絶対に使わない
そもそもパスワードの検証を自前で実装しようとせず、PHPの password_verify や、各種フレームワーク(LaravelやDjango、Railsなど)が標準で提供している認証機能をそのまま信頼して使いましょう。フレームワークの多くは、内部できちんとタイミング攻撃対策が施されています。
2. APIトークンやWebhookの署名検証にも注意
GitHubやStripeなどのWebhookを受け取る際、送られてきた署名(Signature)と、自分で計算した署名を比較する場面がありますよね。ここでも通常の文字列比較 === を使うのはNGです。必ず言語ごとの安全な比較関数(例: Pythonなら hmac.compare_digest など)を使いましょう。
3. 「長さ」のリークにも気を配る
先ほどのNode.jsのコード例でも少し触れましたが、入力された文字列の「長さ」自体が違う場合に即座に false を返すと、攻撃者に「あ、パスワードの桁数は本当は10文字なんだな」と推測されてしまう場合があります。高セキュリティを求められるシステムでは、長さのチェック方法にも工夫が必要です。
—
まとめ
セキュリティの世界は、一見すると「そんな細かいところまで突かれるの?」と驚くような盲点(今回のようなタイミング攻撃など)がたくさん潜んでいます。
しかし、先人たちの知恵によって「こういう危うい部分は、この安全な関数を使えば一発で防げる」というベストプラクティス(型)がちゃんと用意されています。
「なぜ普通の === ではダメなのか?」という理由を仕組みから理解しておけば、いざという時に正しい選択ができるようになります。一歩ずつ、確実に知識をアップデートしていきましょう!
それでは、次回のセキュリティ解説もお楽しみに!
コメント