安全でないパスワードハッシュ(CWE-916):SHA-256 では足りない理由とパスワードの保存方法
パスワードをソルトなしの SHA-256、SHA-1、MD5 のダイジェストで保存すると、漏れたテーブルは GPU で毎秒数十億回の速さで試され、同じパスワードを使う利用者は全員同じハッシュになります。ソルト、Argon2id のようなメモリを多く使う関数、ログイン時の再ハッシュ、ダミー検証が盗まれたデータベースをどう守るかを、Python の argon2-cffi の例で解説します。
わかりやすい解説 (ELI5)
クロークでは、どのコートが誰のものかを確かめるために札の控えを保管しています。控えがただのコピーなら、ファイルを盗んだ泥棒は全部のコートを持ち出せます。速いハッシュは少しぼかしたコピーのようなもので、高速スキャナーを持つ泥棒なら毎秒何百万枚も元に戻せます。遅くてメモリを食うハッシュは、開けるのに丸 1 秒と大きな作業台が要る小さな金庫に控えをしまうようなもので、札ごとに金庫があり、錠もそれぞれ違います。持ち主は入口で 1 つ開けるだけなので待たされたとは感じませんが、泥棒は何百万個も開けなければなりません。
主要な概念と専門用語
- 速いハッシュ
- SHA-256、SHA-1、MD5 は速く計算できるように設計されています。最新の GPU 1 枚で毎秒数十億回の SHA-256 を計算できるため、短いパスワードやよくあるパスワードは総当たりや辞書攻撃で数時間のうちに破られます。
- ソルト
- ハッシュごとに一緒に保存するランダムな値で、同じパスワードでも異なるハッシュになります。ソルトがないと、事前計算した 1 つの表で全アカウントが一度に破られ、同じハッシュから同じパスワードを使う利用者も分かってしまいます。
- メモリを多く使う関数(Argon2id)
- Argon2id は推測 1 回ごとに時間とメモリの両方を消費させ、GPU の優位をほとんど奪います。OWASP の最低値はメモリ 19 MiB、反復 2 回、レーン 1 本(
memory_cost=19456、time_cost=2、parallelism=1)で、scrypt や bcrypt も許容される代替手段です。 - PHC 文字列と再ハッシュ
PasswordHasher.hash()は$argon2id$v=19$m=19456,t=2,p=1$...のような文字列を返し、アルゴリズム、パラメーター、ソルト、ハッシュをまとめて持ちます。check_needs_rehash()は保存済みのパラメーターが現在より弱いときに知らせるので、次に正しくログインしたときにハッシュを更新できます。- 応答時間とアカウントの列挙
- 存在しないメールアドレスには即座に応答し、実在するものにはハッシュ検証の時間がかかると、応答時間からどのアカウントが存在するかが分かります。ダミーのハッシュで検証すれば、どちらの経路も同じ時間になります。
ステップ・バイ・ステップの攻撃フロー
ユーザーテーブルが漏れる
SQL インジェクション、公開されたバックアップ、盗まれたノート PC などで、攻撃者が password_hash 列を手に入れます。
攻撃者が GPU でクラッキングする
ソルトのない SHA-256 なら単語リストを一度ハッシュ化するだけで、すべての行と照合できます。パスワードが Summer2024! のテスト用アカウントは、よくあるパターンがいかに早く破られるかを示しています。
同じパスワードはまとめて破られる
同じパスワードを選んだ利用者は全員同じダイジェストなので、1 つ破られれば全員が露出します。
パスワードがほかのサービスで使い回される
破られたパスワードはメール、銀行、クラウドのアカウントで試されます。2012 年に LinkedIn からソルトなしの SHA-1 パスワードハッシュ約 650 万件が流出し、2016 年には同じ事件の認証情報約 1 億 1,700 万件が売りに出されました。
ソースコード比較:脆弱 vs 堅牢化
# users.py: パスワードごとに速くてソルトのないダイジェストが 1 つ
import hashlib
from .db import db
# 同じパスワードは同じハッシュ。GPU なら毎秒数十億回試せる
def create_user(email: str, password: str) -> None:
digest = hashlib.sha256(password.encode()).hexdigest()
db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, digest))
# 存在しないメールには即答するので、時間からアカウントの有無が分かる
def check_login(email: str, password: str) -> bool:
row = db.execute("SELECT password_hash FROM users WHERE email = ?", (email,)).fetchone()
return row is not None and row[0] == hashlib.sha256(password.encode()).hexdigest()
# users.py: パスワードごとのソルト付き Argon2id、ログイン時に更新
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from .db import db
# OWASP の Argon2id 最低値: 19 MiB、反復 2 回、レーン 1 本
hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
DUMMY_HASH = hasher.hash("timing-equaliser")
# 保存する文字列にアルゴリズム、パラメーター、ソルト、ハッシュが入る
def create_user(email: str, password: str) -> None:
if not 12 <= len(password) <= 128:
raise ValueError("password must be 12 to 128 characters")
db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, hasher.hash(password)))
# 存在しない利用者でも検証 1 回分の時間をかける。弱いハッシュは更新する
def check_login(email: str, password: str) -> bool:
row = db.execute("SELECT id, password_hash FROM users WHERE email = ?", (email,)).fetchone()
stored = row[1] if row else DUMMY_HASH
try:
hasher.verify(stored, password)
except (VerificationError, InvalidHashError):
return False
if row is None:
return False
if hasher.check_needs_rehash(stored):
db.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hasher.hash(password), row[0]))
return True
エンジニアリング&システム堅牢化チェックリスト
- パスワードは Argon2id(メモリ 19 MiB 以上、反復 2 回、レーン 1 本)、scrypt、bcrypt でハッシュ化する。パスワード変数に対する
hashlibの呼び出しを検出する Semgrep ルールを追加し、MD5 と SHA-1 には Bandit B324 を実行する。 - ライブラリが返す PHC 文字列を丸ごと保存し、ソルトを取り出したり使い回したりしない。
- ログインに成功するたびに
check_needs_rehash()を呼び、弱いパラメーターで作られたハッシュを置き換える。 - アカウントが存在しない場合もダミーのハッシュで検証し、登録済みのメールアドレスが応答時間から分からないようにする。
- 12 文字以上を必須とし、64 文字以上までの長いパスフレーズを許可し、漏えいリストにあるパスワードを拒否する。