レインボーテーブル攻撃は、事前計算したデータを使って、漏洩したパスワードハッシュに一致する入力候補を探す攻撃です。 利用者ごとに異なるソルトを使えば、攻撃者が事前計算の結果を多数のハッシュへ使い回す利点を失わせられます。 ただし、ソルトは個々のパスワードに対するオフライン推測まで止めるものではありません。
レインボーテーブル攻撃とは
ハッシュ値から元のパスワードを復号することはできません。 それでも、攻撃者はパスワード候補をハッシュ化し、漏洩した値と一致するかを調べられます。
この照合を毎回最初から行う代わりに、計算結果を事前に用意する手法があります。 レインボーテーブル攻撃は、ハッシュ関数と還元関数を繰り返したチェーンの始点と終点を保存し、計算時間と保存容量を交換する時間メモリトレードオフ攻撃です。 「パスワードとハッシュ値をすべて並べた巨大な対応表」より少ない容量で、表がカバーする候補を探索できます。
表に含まれるすべてのハッシュが必ず見つかるわけではありません。 成功率と探索時間は、対象のハッシュ方式、候補空間、チェーンの長さ、表の規模などに左右されます。
レインボーテーブルの利点は、事前計算の結果を複数の利用者や漏洩データへ再利用できることです。 パスワードごとに異なるランダムなソルトを加えると、同じパスワードでも保存されるハッシュが変わります。 攻撃者はソルトごとに計算し直す必要があるため、汎用の表を多数のハッシュへ使い回せなくなります。
この意味で、レインボーテーブルは主にソルトのない古い保存形式で問題になる攻撃です。 しかし、ソルトが付いていても、攻撃者は漏洩したソルトを使って一つずつ辞書攻撃や総当たりを実行できます。 ソルトはオフライン解析を不可能にする秘密鍵ではなく、事前計算とハッシュ間の使い回しを封じる値です。
レインボーテーブル攻撃が起きるパターン
被害につながる実装は次のとおりです。
- パスワードをソルトなしでハッシュ化している
- すべての利用者へ同じ固定ソルトを使い、計算結果をハッシュ間で再利用できる
- MD5、SHA-1、SHA-256などの高速な汎用ハッシュ関数を、パスワード保存へ直接使っている
- 古い保存形式を移行せず、現在もログイン時に受け入れている
- フレームワークやライブラリの標準機能を使わず、独自のハッシュ形式を実装している
平文保存や可逆暗号による保存は、レインボーテーブル以前の問題です。 前者は漏洩した時点でパスワードが読め、後者は復号鍵も侵害されると一括して復元されます。
また、ハッシュ値の漏洩自体もセキュリティ事故です。 適切な保存形式は解析コストを引き上げますが、弱いパスワードが判明しないことを保証しません。 漏洩時には、保存方式の強さだけを理由に対応を省略せず、セッションの失効、パスワード変更、使い回し被害への通知などを検討します。
対策法
パスワードは、利用者ごとに異なるランダムなソルトを使い、パスワード保存用の低速なアルゴリズムでハッシュ化します。
- 新規実装ではArgon2idを第一候補にする
- Argon2idを利用できなければscryptを検討する
- bcryptは既存システムとの互換性が必要な場合に使い、72バイトの入力上限を扱う
- FIPS 140に準拠する実装が必要ならPBKDF2-HMAC-SHA-256を選択肢にする
- ライブラリが生成する利用者ごとのソルトを使い、独自のソルト付与処理を重ねない
- サーバ性能と想定負荷を測定してコスト係数を設定し、定期的に見直す
- 古いパラメータのハッシュは、ログイン成功時など平文パスワードを検証できる機会に再ハッシュする
OWASP Password Storage Cheat Sheetは、新規実装にArgon2idを推奨しています。 ソルトは秘密にする必要がなく、アルゴリズムやコスト係数とともにハッシュ文字列へ保存できます。
Railsでの事例
Railsのhas_secure_passwordは、bcryptを使ってソルト付きのハッシュを生成します。
モデルにpassword_digestカラムを用意し、bcrypt gemを追加すると利用できます。
# Gemfile
gem "bcrypt", "~> 3.1"
class User < ApplicationRecord
has_secure_password
end
user = User.create(
email: "a@example.com",
password: "correct horse battery staple"
)
# アルゴリズム、コスト、ソルト、ハッシュを含むbcrypt文字列
user.password_digest
# => "$2a$12$C6UzMDM.H6dfI/f/IKcEeO..."
user.authenticate("correct horse battery staple") # => user
user.authenticate("wrong") # => false
$2a$12$...の2aはbcryptの形式、12はコスト係数を表します。
同じパスワードでもソルトが異なるため、作成されるpassword_digestは通常それぞれ異なります。
既存のソルトなしレインボーテーブルをそのまま照合することはできませんが、各ハッシュへのオフライン推測は残ります。
現在のRailsでは、has_secure_passwordがbcryptの制約に合わせてパスワードを72バイト以下に検証します。
日本語などのマルチバイト文字では、72文字より少ない文字数で上限へ達する点にも注意が必要です。
bcryptを選ぶ場合は、入力を黙って切り捨てず、利用者へ上限を明示します。
NIST SP 800-63Bが示す「少なくとも64文字を受け入れる」という基準と両立できない文字種もあるため、新規設計ではArgon2idなども検討します。
TypeScript(Node.js)での事例
node-argon2では、ソルトの生成とハッシュ文字列への保存をライブラリへ任せられます。
import * as argon2 from "argon2";
const digest = await argon2.hash("correct horse battery staple", {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
const ok = await argon2.verify(
digest,
"correct horse battery staple"
); // => true
digestには、Argon2idのバージョン、パラメータ、ソルト、ハッシュがPHC文字列形式で含まれます。
パラメータは固定値を無条件にコピーせず、OWASPの推奨値を下限として、本番相当の環境で認証遅延と同時実行時のメモリ消費を測定します。
