● CWE-916 · OWASP A02:2021

安全でないパスワードハッシュ(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() は保存済みのパラメーターが現在より弱いときに知らせるので、次に正しくログインしたときにハッシュを更新できます。
応答時間とアカウントの列挙
存在しないメールアドレスには即座に応答し、実在するものにはハッシュ検証の時間がかかると、応答時間からどのアカウントが存在するかが分かります。ダミーのハッシュで検証すれば、どちらの経路も同じ時間になります。

ステップ・バイ・ステップの攻撃フロー

ステップ 1

ユーザーテーブルが漏れる

SQL インジェクション、公開されたバックアップ、盗まれたノート PC などで、攻撃者が password_hash 列を手に入れます。

ステップ 2

攻撃者が GPU でクラッキングする

ソルトのない SHA-256 なら単語リストを一度ハッシュ化するだけで、すべての行と照合できます。パスワードが Summer2024! のテスト用アカウントは、よくあるパターンがいかに早く破られるかを示しています。

ステップ 3

同じパスワードはまとめて破られる

同じパスワードを選んだ利用者は全員同じダイジェストなので、1 つ破られれば全員が露出します。

ステップ 4

パスワードがほかのサービスで使い回される

破られたパスワードはメール、銀行、クラウドのアカウントで試されます。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

エンジニアリング&システム堅牢化チェックリスト

参考資料

← セキュリティ目録をすべて見る 脆弱性ガイド一覧 →