タイミング攻撃の基礎
処理時間の差異を悪用するタイミング攻撃の仕組みと、定数時間比較による対策を理解する
TL;DR
- タイミング攻撃は、処理にかかる時間の差を観測して秘密情報を推測する攻撃
===での文字列比較は一致する文字数が多いほど遅くなるため、秘密値の比較に使ってはいけない- Node.js の
crypto.timingSafeEqualを使うか、bcrypt のcompareのような定数時間比較を使う
タイミング攻撃とは
タイミング攻撃(Timing Attack)は、処理の「かかる時間」を観測することで、本来知ることができない情報を推測するサイドチャネル攻撃の一種。
たとえば、あるAPIに対して何千回もリクエストを投げ、レスポンス時間の差を計測することで「秘密の値の最初の数文字は合っている」「全く合っていない」を判断できる。
攻撃者が試す:
"aXXXXXXX" → 10ms(最初の1文字が一致)
"aaXXXXXX" → 11ms(最初の2文字が一致)
"aaaXXXXX" → 12ms(最初の3文字が一致)
...
"aaaaaaaa" → 19ms(全一致)処理時間を測定するだけで、秘密値を1文字ずつ絞り込める。
なぜ === 比較が危険なのか
多くの言語で文字列比較は「先頭から1文字ずつ比べ、違う文字が見つかった時点で即終了する」実装になっている。
// 脆弱: 一致する文字が多いほど処理時間が長くなる
if (token === secret) { ... }これを攻撃者が悪用すると:
secret が "abc123xyz" だとする
試行 1: "aXXXXXXXX" → 't' !== 'X' で即終了 → 速い
試行 2: "abXXXXXXX" → 'b' !== 'X' で即終了 → 少し遅い
試行 3: "abcXXXXXX" → 'c' !== 'X' で即終了 → さらに遅い
...統計的に十分な回数試行すれば、1文字ずつ特定できる。ネットワーク越しでも、十分な回数があれば計測可能。
どこで起きるか
APIトークン・シークレットキーの照合
// 危険
if (req.headers["x-api-key"] === process.env.API_KEY) { ... }HMACシグネチャの検証
Webhookの署名検証などで特によく見られる。
// 危険
if (signature === expectedSignature) { ... }ユーザー登録済み判定
「このメールは登録済みですか?」の確認処理で、
- 登録済み: DBヒット → 速い
- 未登録: DBミス → 少し遅い
これを計測するとメールアドレスが登録済みかどうかがわかる(ユーザー列挙攻撃)。
対策: 定数時間比較を使う
Node.js: crypto.timingSafeEqual
import { timingSafeEqual, createHmac } from "crypto";
function isValidToken(provided: string, expected: string): boolean {
// 長さが違う場合は先にはじく(timingSafeEqualは同じ長さ必須)
if (provided.length !== expected.length) return false;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return timingSafeEqual(a, b);
}timingSafeEqual は2つのBufferを比較するが、常に全バイトを比較してから結果を返す。
一致しない文字が見つかっても途中終了しないため、処理時間が値の内容に依存しない。
Webhook署名検証の例
import { createHmac, timingSafeEqual } from "crypto";
function verifyWebhookSignature(
payload: string,
receivedSig: string,
secret: string,
): boolean {
const expected = createHmac("sha256", secret)
.update(payload)
.digest("hex");
// 文字列のままではなく、Bufferにしてから比較する
const a = Buffer.from(receivedSig, "utf-8");
const b = Buffer.from(expected, "utf-8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}パスワード比較はbcrypt.compareで問題ない
パスワードハッシュ化で説明しているように、パスワードの照合には bcrypt.compare を使う。
const ok = await bcrypt.compare(dto.password, user.passwordHash);bcryptは内部でハッシュ同士の比較を定数時間で行う設計になっているため、パスワード比較に === を使う必要はない。むしろ bcryptのAPIを使うかぎり、タイミング攻撃の心配は基本的にない。
問題が起きやすいのは、ハッシュ化せず生の文字列を直接比較する場面(APIキー、トークン、シークレット)。
ユーザー列挙攻撃への対策
処理時間の差でユーザーが存在するかどうかを特定される問題は、定数時間比較だけでは解決できない。
基本方針
- エラーメッセージを統一する(「メールアドレスかパスワードが間違っています」)
- 登録済みかどうかで処理を早期終了しない
// 危険: 未登録で即returnすると処理時間が短くなる
async validateUser(email: string, password: string) {
const user = await this.usersService.findByEmail(email);
if (!user) return null; // ← ここで早期終了 → 速い
const ok = await bcrypt.compare(password, user.passwordHash);
return ok ? user : null;
}// 改善: ユーザーがいない場合もbcrypt処理を走らせる
const DUMMY_HASH = "$2b$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
async validateUser(email: string, password: string) {
const user = await this.usersService.findByEmail(email);
const hash = user?.passwordHash ?? DUMMY_HASH;
const ok = await bcrypt.compare(password, hash);
return ok && user ? user : null;
}ユーザーが存在しない場合にもダミーハッシュに対してbcrypt処理を走らせることで、処理時間をほぼ均一にできる。
まとめ
| 対象 | 使うべき手段 |
|---|---|
| APIキー・トークンの文字列比較 | crypto.timingSafeEqual |
| HMAC署名の検証 | crypto.timingSafeEqual |
| パスワードの照合 | bcrypt.compare |
| ユーザー存在有無の隠蔽 | ダミー処理で時間を均一化 |