サマリ
PHPサーバーサイドプログラミングパーフェクトマスターには、PHP入門書としては珍しくクロスサイト・リクエストフォージェリ(CSRF)対策についての説明があるが、その方法には問題がある。アルゴリズムとして問題があることに加えて、実装上の問題があり、そのままコピペして用いると脆弱性となる。はじめに
古庄親方の以下のツイートを見て驚きました。CSRF用のトークンの作成— がる (@gallu) July 17, 2019
$token = password_hash(mt_rand(), PASSWORD_DEFAULT);
ってのを書籍で見た………もンのすンげぇなぁ(苦笑
書籍名でググって調べる……評判が悪いので、まぁ、納得っちゃぁ納得。
CSRFトークンの生成に、password_hash関数を使うですと?
親方に書籍名を教えていただき、購入したのが、この記事で紹介する「PHPサーバーサイドプログラミングパーフェクトマスター」です。同書では、CSRF対策にpassword_hashを2種類の方法で使っています(!)が、本稿では、セクション10.3 (P726~) にて説明されている方法を取り上げます。
当該コード
当該コードを示します。同書では、認証・入力フォーム・登録の3機能が1つのPHPファイルにまとめられていますが、そこから抜き出す形で、以下は入力フォームです。トークンの「素(もと)」としてmt_rand()関数が呼ばれ、その値をセッション変数$_SESSION['rand']に保存しています。その乱数値をpassword_hash関数で処理することで、トークンを生成しています。入力フォームの生成例を以下に示します。<?php function getToken($rand='') { $_SESSION['rand'] = $rand; $token = password_hash($rand, PASSWORD_DEFAULT); return $token; } // セッションスタート session_start(); $rand = mt_rand(); $token = getToken($rand); print <<<EOD <form action="" method="post"> <input type="hidden" name="request" value="reg" /> <input type="hidden" name="token" value="$token" /> <input type="submit" value="登録"> </form> EOD;
<form action="" method="post">
<input type="hidden" name="request" value="reg" />
<input type="hidden" name="token"
value="$2y$10$alqDLwZSizuBYpwmBR6pj.WzxI7UeW9YutuxHjb.r2qw9QQOBqbw6" />
<input type="submit" value="登録">
</form>
赤字に示した部分($2y$...)がトークンですが、これを見ると、「なんでパスワードのハッシュ値をCSRFトークンにしているわけ?」と勘違いする人が続出しそうですね。そして、このトークンを受け取り、処理するプログラムが下記の部分です。
$_POST['token']がNULLでないことを確認した上で、check関数で、セッション変数$_SESSION['rand']をパスワードに見立て、パスワードのハッシュ値に相当する$_POST['token']にてpassword_verify関数で照合する形で、トークンを検証しています。<?php function check ($token = '') { return (password_verify($_SESSION['rand'], $token)); } session_start(); if (isset($_POST['request']) === true && $_POST['request'] === 'reg' && isUser() == true) { if(isset($_POST['token']) === false || check($_POST['token']) === false) { print '不正なアクセスが行われました。'; } else { print '処理が完了しました!'; unset($_SESSION['rand']); $_SESSION = array(); }}
何が問題か
このプログラムには以下の問題があります。- 設計上の問題
- 実装上の問題
設計上の問題
そもそもpassword_hash関数は、パスワードを安全に保存するための関数なので、CSRFトークンの処理のことはまったく考慮されておらず、一言で言えば「使う場所を間違えている」ことになります。通常トークンの生成には、暗号論的に安全な疑似乱数生成器(CSPRNG)を用います。PHP 7以降ではrandom_bytes関数があるので(*1)、それを素直に使うだけです。さらにハッシュ関数を通すようなことは必要ありません。余計なことをやればやるほど、バグの原因になり、ひいては脆弱性の原因になります。(追記 2019/7/29 20:36)
*1 当書籍はXAMPP上のPHP5.5を前提としているので、random_bytes関数(PHP 7.0以降)は使えませんが、代わりにopenssl_random_pseudo_bytes関数(PHP 5.3以降)が使えます。当関数はopensslの導入が前提ですが、XAMPPはデフォルトでopenssl関数が使用できます。
(追記終わり)
加えて、mt_rand()の使用も問題です。この関数は、マニュアル(下記引用)にある通り、セキュリティ目的で使うことは適切でありません。
警告 この関数が生成する値は、暗号学的に安全ではありません。そのため、これを暗号として使ってはいけません。暗号学的に安全な値が必要な場合は、random_int() か random_bytes() あるいは openssl_random_pseudo_bytes() を使いましょう。また、mt_rand関数を引きなしで呼び出すと、0からmt_getrandmax()の戻り値までの値を返しますが、mt_getrandmax()は64ビット環境でも2147483647なので、31ビットの範囲になります。これはトークンに用いる乱数のエントロピーとしては不足しています。
https://php.net/manual/ja/function.mt-rand.php より引用
さらに、password_hash関数はパスワードの保護を強化するためにストレッチング(ハッシュ計算を繰り返すこと)を施していますが、そのために、password_hashおよびpassword_verify関数は非常に低速です。CSRF対策のために、わざわざ処理を遅くする必要はありません。
実装上の問題
紹介したプログラムには実装上の問題もあります。使われているトークンはワンタイムのものであり、使用済みになるとunsetされます。すなわち、トークンがNULLとなる時期が存在します。そのタイミングを狙った攻撃ができるのです。すなわち、以下の関数呼び出しがtrueになるような$tokenを渡せばよいことになりますが…
実は、password_verifyの第一引数にNULLを渡すと、空文字列を渡したのと同じ結果になります。なので、空文字列に対するpassword_hash関数の結果を使って、攻撃が可能です。空文字列に対するハッシュ値はソルトにより無数に存在しますが、たとえば以下で攻撃ができます。password_verify(NULL, $token)
検証例を示します。$2y$10$V3V9iX2iR6ZqaNuFAyUlPeiIvmQrGKDbrJQWdkXWVECGUodZON0Iu
<?php
var_dump(password_verify(null, '$2y$10$V3V9iX2iR6ZqaNuFAyUlPeiIvmQrGKDbrJQWdkXWVECGUodZON0Iu'));
// bool(true) が表示される
これは、前述した「余計なことをやればやるほど、バグの原因になり、ひいては脆弱性の原因にな」った例といえます。まとめ
PHPサーバーサイドプログラミングパーフェクトマスターにおけるCSRF対策の問題を報告しました。実務上でCSRF対策をする場合は、アプリケーションフレームワークの機能を使うか、よく検証されたライブラリを使うべきですが、特別な事情があって独自実装する場合には、定石的な手法を用い、かつできるだけ簡素な、検証のしやすい実装にすべきと考えます。また、password_hash関数をトークン生成に用いる例が、同書に限らず散見されますが、これは百害あって一利なしですので、トークン生成には単にCSPRNGを使うと覚えておきましょう。