サマリ
Geminiとの会話の際に、パスワード保護に用いるpepperの実装にヌルバイトに起因する重大な脆弱性が混入した。この脆弱性に対するPoCをClaude Codeを用いて作成して検証したところ、攻撃の実効性が確認され、登録ユーザーの1/256が容易に不正ログインされることがわかった。
はじめに~パスワード保護のpepperとは~
パスワードの保護に関して、既にソルトやストレッチングを施したハッシュ値での保存は常識となりましたが、加えてペッパー(pepper)を使おうということになりつつあります。pepperは一般に通用する用語ではありますが、シークレットソルト(secret salt)あるいはシークレットキーと呼ぶ場合があります。
NIST SP 800-63Bでは、3.1.1.2.Password Verifiers として以下のように要求されています。
In addition, verifiers SHOULD perform an additional iteration of a keyed hashing or encryption operation using a secret key known only to the verifier.
https://pages.nist.gov/800-63-4/sp800-63b.html
すなわち、秘密鍵による鍵付きハッシュか暗号化を施せということですね。ソルトはユーザー毎に異なる値をとりますが、ペッパーの方はシステム内で共通です。
| 項目 | ソルト | ペッパー |
|---|---|---|
| 生成方法 | ユーザー毎にランダム値 | システム全体で共通のランダム値 |
| 秘匿性 | ハッシュ漏洩時には漏れても仕方ない | 安全に秘匿する |
| 保存場所 | ハッシュ値とともにデータベース等に保存 | ハードウェア・セキュリティ・モジュール(HSM)等 |
そして、ペッパーを使って保護する方法には以下のようなものが一般的です。
- パスワードと単に連結する(Ruby の Devise gem等)
- bcrypt等で求めたハッシュ値をAES等で暗号化する(Dropbox等、参考記事)
- HASH-MAC(鍵付きハッシュ、本稿の主題)
Geminiとの会話で脆弱なコードが出現
この想定でGeminiと会話しておりましたら、以下のような発言が出てきました。
devise gem では秘密ソルト(pepper)を指定できますよね
はい、その通りです!Railsの認証用GemであるDeviseでは、パスワードのハッシュ化の安全性をさらに高めるために、秘密ソルト(一般的にPepper:ペッパーとも呼ばれます)を設定することができます。【後略】
PHPには類似の機能をもつライブラリはありますか?
PHPの場合、Deviseのような特定の巨大な認証ライブラリに頼る必要はありません。PHPのコア機能(標準関数)だけで、ペッパー(秘密ソルト)を組み合わせた安全なパスワードハッシュ化が簡単に実現できます。
【中略】
// 1. 本来のパスワード
$password = $_POST['password'];
// 2. ペッパー(環境変数など、コード外の安全な場所から取得する)
$pepper = getenv('APP_PEPPER');
// 3. パスワードとペッパーをブレンドして固定長のハッシュ(rawバイナリ)にする
// ※これにより、BCryptの72バイト制限対策にもなります
$peppers_password = hash_hmac('sha256', $password, $pepper, true);
// 4. 通常通り password_hash() でデータベース保存用のハッシュを作成
$hashed_password = password_hash($peppers_password, PASSWORD_DEFAULT);
// この $hashed_password をデータベースの `password` カラムに保存します
私はこのコードが脆弱であることに気づいて、Geminiに指摘しました。最初は非を認めなかったのですが、最終的には脆弱であることを認めて謝罪しました(Geminiのチャット履歴)。
これが脆弱である理由は以下の通りです。
- PHPのhash_mac関数の第4引数をtrueにすると結果はエンコードなしのバイナリ値になる
- PHPのbcrypt関数はバイナリセーフでないため第1引数にヌルバイトが含まれているとパスワードの終端とみなし、以降のバイト列が切り詰められる
- 最悪ケースで、実質長さ0のパスワードになるユーザーが1/256の確率で発生する
- (なおbcryptには72バイトでパスワードが切り詰められるという別の仕様もある)
私は過去に以下のブログ記事を書いていましたので、すぐに気づいたわけです。
ただし、上記のブログ記事の状況とは異なり、ペッパーの値は外部からは不知であるため、「実効長0のパスワード」が何であるかは簡単にはわかりません。そこで、ブラックボックスでそれを求めるPoCを書いてみようと思い立ちました。といっても、自分で書くのはタルいのでClaude Codeを用いることにしました。
Claude CodeによるPoC作成と検証
成果物は以下に公開しています。
私自身は1行もコードは書いていませんが、以下のようなステップでPoCを作成しました。
PoCの基本戦略
- 実効長0のパスワード(マジックパスワード)を設定しているユーザーは1/256の確率で存在する
- 攻撃者は多数のユーザーを登録することにより確率的にマジックパスワードのユーザーを作れる
- マジックパスワードであることは、ユーザーaliceのパスワードXを用いて、ユーザーbobでログインできれば、aliceのパスワードX(およびbobのパスワードも)マジックパスワードである
- 攻撃者が登録したユーザー間の総当り探索によりマジックパスワードを求める
- 脆弱なユーザー登録とログイン機能を持つ環境を作り、上記を確認する
- 現実的な状況を想定してログイン機能にはアカウントロック機能を設け、それを突破する
以下、Claude Codeへの指示を抜粋で紹介します。詳細はGitHubリポジトリを参照ください。
Step.1 環境構築
- 以下のdocker-compose の環境を用意してください
- Apache+PHP 8.3.5
- MariaDB 10.6
- MariaDBのデータの内容を確認したいので、Adminerコンテナを追加してください。ポートはランダムなものに変更してください
- Adminerの接続情報を @docs/adminer.md に記録してください
PHP 8.3.5を明示している理由は、PHP 8.3.6以降、PHP側でbcryptのヌルバイト脆弱性の対策が入っているからです(CVE-2024-3096; リリースノート)。
Step.2 ラボ環境準備
以下の仕様のPHPスクリプトを書いてください。
- フレームワークを使用しない素のPHPを用いる
- ユーザーデータはMySQLに保存する
- ユーザー登録機能とログイン機能をもつ
- ユーザーIDは英数字16文字以内
- パスワードは英数字16文字以内
- パスワードは以下のスクリプトによりペッパーつきのbcryptで保存する。細部は異なってもよい【Geminiのコードを貼り付け】
- パスワードの照合には以下のスクリプトを用いる。細部は異なってもよい【Geminiのコードを貼り付け】
- ログイン機能にはアカウントロック機能を備える
- 同じユーザーIDで連続して10回失敗したら、当該ユーザーのログインを30分間ロックする
- ロック中は正しいパスワードでもログインを拒否する
- ロック情報(連続失敗回数・ロック解除時刻)は users テーブルに列を設けて保持する
- 上記の照合スクリプトは、このアカウントロック判定を組み込んだうえで利用すること
- テスト用に1万件のユーザーを作成する
- ユーザーIDは S0000~ S9999 とする
- パスワードはランダムな8文字英数字
- 登録したユーザーIDとパスワードは、テキストファイルとして ./docs/ に保存する
Step.3 長さ0のパスワード(マジックパスワード)の探索
- 以下の仕様の pythonスクリプト(コンテナ外)を書いてください。python3 が使えます
- 以下を繰り返す
- ユーザー登録機能をHTTPで呼び出して、ユーザーを一つ登録する
- ユーザーIDは X0000、X0001、X0002…
- パスワードは英数字8文字ランダム
- 登録したユーザーIDとパスワードは、テキストファイルとして ./docs/ に保存する
- N番目のユーザーを登録したら、当該のユーザーIDと 一つ手前までのパスワードを用いてログインを試みる。ログインに成功したらその旨を表示して終了する
- アカウントロックを回避するために、同じIDで9回の認証失敗があったら、正しいパスワードでログインする
アカウントロックの回避手順を明示していますが、追試の際にはこれを指示しなくてもClaudeは自力で見つけていました。
Step.4 マジックパスワードによる攻撃
- S0000 から S9999 のユーザーに対して、plan3で発見したした衝突パスワードでログインするスクリプトを書いてください。
- パスワードを固定してユーザーIDの方を変えるので、いわゆるリバースブルートフォース攻撃になります
- ログインに成功したユーザーIDを記録してください
- 処理が終わったら、成功した数を表示してください。
結果
当初のPoCはPython3による逐次処理で以下のような結果になりました。
- 攻撃側が538個目のユーザーを作成した時点でマジックパスワードが判明した
- 実行時間は2時間4分だった
- マジックパスワードを用いたリバースブルートフォース攻撃は1万件のユーザーに対して46件に成功した
- 実行時間は8分だった
十分実用的ではありますが、Claudeの提案により並列実行版も作ってもらいました。
- 攻撃側が218個目のユーザーを作成した時点でマジックパスワードが判明した
- 早期に見つかったのは確率的な偶然
- 実行時間は2分16秒だった
- マジックパスワードを用いたリバースブルートフォース攻撃は1万件のユーザーに対して46件に成功した
- 同じ対象なので同じ46件が見つかるのは当然の結果
- 実行時間は30.7秒だった
詳細はClaude Codeによるレポートを参照ください。
まとめ
Geminiが提示したコードに重大な脆弱性があることをClaude CodeによるPoCで実証しました。たまたま私の興味の範囲だったのですぐに脆弱性に気が付きましたが、コードレビューをしなければ、あるいは当該の問題を知らなければ、脆弱なコードのままデプロイされてしまうリスクはあると思います。この種の脆弱性があると分かれば攻撃は容易ですが、そもそもこの脆弱性があると分かっていなければ、攻撃難易度は高いと思います。人間的には…
しかしながら、数分の試行でマジックパスワードが判明するのであれば「ダメモト」のバラマキ型攻撃がないとも言い切れないわけで、生成AIによる脆弱性混入と攻撃コードの容易化の両方を示す事例だと思います。
対策済みのコードとしてClaudeは以下を提案しています。HMAC(SHA-256)を16進数文字列64文字ということですね。
$peppers_password = hash_hmac('sha256', $password, $pepper, false); // HMACを16進数文字列とする
$hashed_password = password_hash($peppers_password, PASSWORD_DEFAULT);
これでもよいと思いますが、下のようにSHA-384のHMACのバイナリをbase64エンコードするのもよいと思います。$peppers_passwordは64文字になり、bcryptの72文字制限に収まり、エントロピーを最大限活かせます。
$peppers_password = base64_encode(hash_hmac('sha384', $password, $pepper, true));
$hashed_password = password_hash($peppers_password, PASSWORD_DEFAULT);
まだペッパーを活用しやすいライブラリは普及途上にあり、実装ノウハウがたまっていないことから、個人的には上記のような致命的な脆弱性を作り込むリスクを避ける意味でペッパー対応は急がなくて良いと思いますが、NIST SP 800-63Bに対応するなどの目的でペッパー対応する場合は以下に注意する必要があります。
- bcryptのヌルバイト問題と72文字制限に抵触しないかの確認
- ペッパーをHSMなどの安全な領域に保存する
- bcryptは落とし穴が多いのでArgon2idなどのモダンなパスワード保護アルゴリズムを用いる
また、ペッパーを単にパスワードと連結するDevise方式には以下のような批判があり、避けたほうが良さそうです。