2019年2月25日月曜日

bcryptの72文字制限をSHA-512ハッシュで回避する方式の注意点

宅ふぁいる便から平文パスワードが漏洩した件を受けて、あらためてパスワードの安全な保存方法が関心を集めています。現在のパスワード保存のベストプラクティスは、パスワード保存に特化したハッシュ関数(ソルトやストレッチングも用いる)であるbcryptやArgon2などを用いることです。PHPの場合は、PHP5.5以降で使用できるpassword_hash関数が非常に便利ですし、他の言語やアプリケーションフレームワークでも、それぞれ用意されているパスワード保護の機能を使うことはパスワード保護の第一選択肢となります。

なかでもbcryptは、PHPのpassword_hash関数のデフォルトアルゴリズムである他、他の言語でも安全なハッシュ保存機能として広く利用されていますが、パスワードが最大72文字で切り詰められるという実装上の特性があり、その点が気になる人もいるようです(この制限はDoS脆弱性回避が目的です)。
72文字による切り詰めを回避するためのアイデアとして、bcryptに与えるパスワードを前処理としてSHA-512ハッシュを求めてbcryptの入力とする方法を見かける場合があります。疑似コードで書くと以下となります。

$hash = bcrypt(sha512($password));  // パスワードをSHA-512ハッシュを求めた後bcryptで処理する
今までは個人ブログ(これなど)で見かける程度でしたが、弊社のお客様から、Dropboxのパスワード保存方式がこれだと教えていただきました。
以下はDropboxで採用している方式の模式図です(上記ブログ記事より引用)。


この図から分かるように、SHA-512→bcrypt→AES256(暗号化)と3段階の処理が入っていることになります。過剰なまでにパスワード保護が入っている背景には、Dropboxは以前パスワード情報を漏洩している「前科」があるからかもしれません。
Dropboxが採用している方式ということで、自分(自社)でもこの方式を採用しようと思う人がいるかもしれませんが、このSHA-512とbcryptを二重に適用するという方法は重大な落とし穴があるため、筆者としては、素直にbcryptのみを使うことを推奨します。以下、その落とし穴について説明します。

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文字)があります。
Aaaaaa3@
A very loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong password
以下のように、SHA-512ハッシュの先頭はNULLバイトになります。
$ echo -n Aaaaaa3@ | sha512sum
0011780c00726845802482273be4b2e9329a5d403276b5088fc3e49ca866262632108b32dd2950b680a32eb3808ec9e5710af59c6f6f60f6bbcc9e17098f8685  -

$ echo -n A very loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong password | sha512sum
00895faa444575854626475e154dcb8407670af59d9be977a0aa855a49702e45b9c2aad59befe77241bf48f870b08c06bcf80726a6887f41689dc1f0ed977506  -
これらは、SHA-512ハッシュの先頭バイトが共にNULLになるので、これらのハッシュ値によるログインでは、相互にパスワードを入れ替えてもログインできるはずです。試してみましょう。
// 長いパスワードでハッシュ値を求める
$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);
このスクリプトは true を表示します。すなわち、ログイン成功となります。
ここでは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以降)。
$hash = password_hash($sha512, PASSWORD_ARGON2I);   // PHP 7.2以降
$hash = password_hash($sha512, PASSWORD_ARGON2ID);  // PHP 7.3以降
PHPのArgon2実装はcrypt(3)に依存しておらず、72文字制限はなくバイナリセーフであるようです。PHP 7.3以降で使用できるオプションPASSWORD_ARGON2IDは、サイドチャネル攻撃とGPUによるクラッキングの両方の耐性を備えたアルゴリズムです。

まとめ

bcryptとSHA-512ハッシュを組み合わせて使う方式の注意点を説明しました。
私は、拙著「安全なWebアプリケーションの作り方 第2版」で以下のように説明しました。
PHPには、これらの施策をまとめて使いやすくしたpassword_hashという安全で便利な関数があります(PHP5.5.0以降)。極力password_hashを使うことを推奨します。
また、PHPに限らず、パスワード保存機能は独自実装せずに、安全なライブラリやフレームワークの機能を用いることを推奨します。
「独自実装せずに、安全なライブラリやフレームワークの機能を用いる」とは、ライブラリ等を使う際に余計なことをしないということでもあり、本稿で紹介した例は「余計なこと」をやってしまった副作用の例であると考えます。

一般論としても、プログラムは複雑になればなるほどバグが混入しやすくなり、ひいては脆弱性の原因にもなります。プログラムを簡明に保つことは、安全なプログラムを開発する上でも重要だと考えます。

フォロワー

ブログ アーカイブ