なかでもbcryptは、PHPのpassword_hash関数のデフォルトアルゴリズムである他、他の言語でも安全なハッシュ保存機能として広く利用されていますが、パスワードが最大72文字で切り詰められるという実装上の特性があり、その点が気になる人もいるようです(この制限はDoS脆弱性回避が目的です)。
72文字による切り詰めを回避するためのアイデアとして、bcryptに与えるパスワードを前処理としてSHA-512ハッシュを求めてbcryptの入力とする方法を見かける場合があります。疑似コードで書くと以下となります。
今までは個人ブログ(これなど)で見かける程度でしたが、弊社のお客様から、Dropboxのパスワード保存方式がこれだと教えていただきました。$hash = bcrypt(sha512($password)); // パスワードをSHA-512ハッシュを求めた後bcryptで処理する
以下はDropboxで採用している方式の模式図です(上記ブログ記事より引用)。
この図から分かるように、SHA-512→bcrypt→AES256(暗号化)と3段階の処理が入っていることになります。過剰なまでにパスワード保護が入っている背景には、Dropboxは以前パスワード情報を漏洩している「前科」があるからかもしれません。
- Dropbox、6800万のアカウントデータを漏洩 - パスワードの変更を(2016年9月1日の記事)
SHA-512→bcrypt方式の詳細検討
SHA-512ハッシュは、その名前の通り512ビットすなわち64バイトのハッシュ値が生成されますが、多くの場合16進数文字列の形で用いられます。その場合は128バイト長となり、bcryptにより72文字に切り詰めされます。これだとかなりの情報が失われますし、そもそも切り詰めが嫌で始めたことなのに、半分近くの情報が失われるのでは何をやっているのかという気分になるでしょう。同様に、base64エンコードしても88バイト長なので、切り詰めが発生することには変わりません。このような事情から、先に紹介した個人ブログ記事では、SHA-512のバイナリ形式をbcryptにパスワードとして与える実装になっています。バイナリ形式だと64バイトですから、「切り詰め」の対象にはならないはずです。
すなわちPHPの場合、以下のような実装です。
// ハッシュ値の生成 $sha512 = hash('sha512', $password, true); // true はバイナリの指定 $hash = password_hash($sha512, PASSWORD_DEFAULT); // パスワードの照合 $sha512 = hash('sha512', $password, true); // true はバイナリの指定 $result = password_verify($sha512, $hash);
PHPのbcrypt実装はバイナリセーフでない
ここで問題が生じます。PHPのbcrypt実装はオペレーティングシステムのcrypt(3)ライブラリに依存しており、NULLターミネート形式の文字列でパスワードを受け取ります。すなわち、bcryptは72文字の切り詰め問題に加えて、「バイナリセーフでない」という仕様上の制約があります。このため、SHA-512ハッシュ(バイナリ)中にNULLバイトがあると、そこから先を「切り詰め」してしまうという問題があります。最悪ケースとしては、SHA-512ハッシュの先頭がNULLバイトになる場合で、crypt関数にはゼロ文字のパスワードが指定されることになります。この性質を持つパスワードの例として下記(8文字、99文字)があります。
以下のように、SHA-512ハッシュの先頭はNULLバイトになります。Aaaaaa3@ A very loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong password
これらは、SHA-512ハッシュの先頭バイトが共にNULLになるので、これらのハッシュ値によるログインでは、相互にパスワードを入れ替えてもログインできるはずです。試してみましょう。$ echo -n Aaaaaa3@ | sha512sum 0011780c00726845802482273be4b2e9329a5d403276b5088fc3e49ca866262632108b32dd2950b680a32eb3808ec9e5710af59c6f6f60f6bbcc9e17098f8685 - $ echo -n A very loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong password | sha512sum 00895faa444575854626475e154dcb8407670af59d9be977a0aa855a49702e45b9c2aad59befe77241bf48f870b08c06bcf80726a6887f41689dc1f0ed977506 -
このスクリプトは true を表示します。すなわち、ログイン成功となります。// 長いパスワードでハッシュ値を求める $password = 'A very loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong password'; $sha512 = hash('sha512', $password, true); $hash = password_hash($sha512, PASSWORD_DEFAULT); // 短いパスワード Aaaaaa3@ で照合する $password = 'Aaaaaa3@'; $sha512 = hash('sha512', $password, true); $result = password_verify($sha512, $hash); var_dump($result);
ここでは2例のみ示しましたが、SHA-512ハッシュの先頭バイトがNULLとなる確率は1/256ですから、上記方式で実装したログインプログラムでは、任意ユーザーが1/256の確率でパスワード「Aaaaaa3@」で不正ログインできることになります。これはとんでもない大穴ですよね。
ではどうすればよいか
NULLバイトを防ぐ目的であれば、バイナリハッシュ値を255進数に変換し、1~255のバイト値にマップする(base255とでも言いますか)ことで防げます。しかし、そのような複雑な処理を追加することは、バグの原因になり、ひいては脆弱性の要因になります。なので、bcryptを使う場合は、72文字制限やバイナリセーフでない問題は仕様として受け入れ、そのままで使うことをお勧めします。
先に紹介したDropboxのブログでは、「For ease of elucidation, in the figure and below we omit any mention of binary encoding (base64) (私訳: 説明を簡単にするために、下図および以下の説明では、バイナリエンコーディング(base64)については言及しません)」とあり、base64エンコーディングを採用しているようでもありますが、詳細は不明です。
どうしても、これら制限が受容できない場合は、Argon2などのアルゴリズムを使うことで回避できます。PHPの場合は、以下で実現可能です(PHP 7.2以降)。
PHPのArgon2実装はcrypt(3)に依存しておらず、72文字制限はなくバイナリセーフであるようです。PHP 7.3以降で使用できるオプションPASSWORD_ARGON2IDは、サイドチャネル攻撃とGPUによるクラッキングの両方の耐性を備えたアルゴリズムです。$hash = password_hash($sha512, PASSWORD_ARGON2I); // PHP 7.2以降 $hash = password_hash($sha512, PASSWORD_ARGON2ID); // PHP 7.3以降
まとめ
bcryptとSHA-512ハッシュを組み合わせて使う方式の注意点を説明しました。私は、拙著「安全なWebアプリケーションの作り方 第2版」で以下のように説明しました。
PHPには、これらの施策をまとめて使いやすくしたpassword_hashという安全で便利な関数があります(PHP5.5.0以降)。極力password_hashを使うことを推奨します。「独自実装せずに、安全なライブラリやフレームワークの機能を用いる」とは、ライブラリ等を使う際に余計なことをしないということでもあり、本稿で紹介した例は「余計なこと」をやってしまった副作用の例であると考えます。
また、PHPに限らず、パスワード保存機能は独自実装せずに、安全なライブラリやフレームワークの機能を用いることを推奨します。
一般論としても、プログラムは複雑になればなるほどバグが混入しやすくなり、ひいては脆弱性の原因にもなります。プログラムを簡明に保つことは、安全なプログラムを開発する上でも重要だと考えます。