2019年7月29日月曜日

PHPサーバーサイドプログラミングパーフェクトマスターのCSRF対策に脆弱性

サマリ

PHPサーバーサイドプログラミングパーフェクトマスターには、PHP入門書としては珍しくクロスサイト・リクエストフォージェリ(CSRF)対策についての説明があるが、その方法には問題がある。アルゴリズムとして問題があることに加えて、実装上の問題があり、そのままコピペして用いると脆弱性となる。

はじめに

古庄親方の以下のツイートを見て驚きました。

CSRFトークンの生成に、password_hash関数を使うですと?
親方に書籍名を教えていただき、購入したのが、この記事で紹介する「PHPサーバーサイドプログラミングパーフェクトマスター」です。同書では、CSRF対策にpassword_hashを2種類の方法で使っています(!)が、本稿では、セクション10.3 (P726~) にて説明されている方法を取り上げます。

当該コード

当該コードを示します。同書では、認証・入力フォーム・登録の3機能が1つのPHPファイルにまとめられていますが、そこから抜き出す形で、以下は入力フォームです。
<?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;
トークンの「素(もと)」としてmt_rand()関数が呼ばれ、その値をセッション変数$_SESSION['rand']に保存しています。その乱数値をpassword_hash関数で処理することで、トークンを生成しています。入力フォームの生成例を以下に示します。
<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トークンにしているわけ?」と勘違いする人が続出しそうですね。
そして、このトークンを受け取り、処理するプログラムが下記の部分です。
<?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();
    }
}
$_POST['token']がNULLでないことを確認した上で、check関数で、セッション変数$_SESSION['rand']をパスワードに見立て、パスワードのハッシュ値に相当する$_POST['token']にてpassword_verify関数で照合する形で、トークンを検証しています。

何が問題か

このプログラムには以下の問題があります。
  1. 設計上の問題
  2. 実装上の問題
以下、順に説明します。

設計上の問題

そもそも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() を使いましょう。

https://php.net/manual/ja/function.mt-rand.php より引用
また、mt_rand関数を引きなしで呼び出すと、0からmt_getrandmax()の戻り値までの値を返しますが、mt_getrandmax()は64ビット環境でも2147483647なので、31ビットの範囲になります。これはトークンに用いる乱数のエントロピーとしては不足しています。
さらに、password_hash関数はパスワードの保護を強化するためにストレッチング(ハッシュ計算を繰り返すこと)を施していますが、そのために、password_hashおよびpassword_verify関数は非常に低速です。CSRF対策のために、わざわざ処理を遅くする必要はありません。

実装上の問題

紹介したプログラムには実装上の問題もあります。使われているトークンはワンタイムのものであり、使用済みになるとunsetされます。すなわち、トークンがNULLとなる時期が存在します。そのタイミングを狙った攻撃ができるのです。
すなわち、以下の関数呼び出しがtrueになるような$tokenを渡せばよいことになりますが…
password_verify(NULL, $token)
実は、password_verifyの第一引数にNULLを渡すと、空文字列を渡したのと同じ結果になります。なので、空文字列に対するpassword_hash関数の結果を使って、攻撃が可能です。空文字列に対するハッシュ値はソルトにより無数に存在しますが、たとえば以下で攻撃ができます。
$2y$10$V3V9iX2iR6ZqaNuFAyUlPeiIvmQrGKDbrJQWdkXWVECGUodZON0Iu
検証例を示します。
<?php
var_dump(password_verify(null, '$2y$10$V3V9iX2iR6ZqaNuFAyUlPeiIvmQrGKDbrJQWdkXWVECGUodZON0Iu'));

// bool(true) が表示される
これは、前述した「余計なことをやればやるほど、バグの原因になり、ひいては脆弱性の原因にな」った例といえます。

まとめ

PHPサーバーサイドプログラミングパーフェクトマスターにおけるCSRF対策の問題を報告しました。実務上でCSRF対策をする場合は、アプリケーションフレームワークの機能を使うか、よく検証されたライブラリを使うべきですが、特別な事情があって独自実装する場合には、定石的な手法を用い、かつできるだけ簡素な、検証のしやすい実装にすべきと考えます。
また、password_hash関数をトークン生成に用いる例が、同書に限らず散見されますが、これは百害あって一利なしですので、トークン生成には単にCSPRNGを使うと覚えておきましょう。



2019年7月1日月曜日

PHPカンファレンス福岡2019のSST社ブースにてPHPクイズ出題を担当しました

PHPカンファレンス福岡2019のセキュアスカイテクノロジー(SST)社ブースにて、PHPクイズの出題を担当しました。以下は、そのパネルのイメージです。SST社は弊社EGセキュアソリューションズ株式会社のパートナー企業で、私はSST社のEラーラニングコンテンツ(PHP編)の監修を担当しています。


問題はすべて2択で、回答は下の写真のように、赤または青のシールをボードに貼り付ける形になっています。回答がわりとバラけていて、出題者的にはいい感じですね。


以下、出題と解答、解説を記載します。

問題1

以下のPHPスクリプトで、クッキーPHPSESSIDの削除として機能するのはどちら?
PHPのバージョンは7.3.6とする。

A) (正解)31人が選択=正答率 83.8%
<?php
setcookie('PHPSESSID');

B) 6人が選択
<?php
header('Set-Cookie: PHPSESSID=');

解説

setcookie関数の第2引数の省略時の値は '' (空文字列)ですので、A)は setcookie('PHPSESSID', ''); としたのと同じはずです。そして、第2引数が空文字列の場合は、実際にはクッキーの値としてdeletedが指定され、expires属性が過去日時(PHP-7.3.6の場合は、Thu, 01-Jan-1970 00:00:01 GMT)になります。すなわち、setcookie関数の第2引数を省略すると、クッキーの削除として機能します。
B) header関数の方は、クッキーの値が空文字列になりますが、クッキーそのものは削除されません。

参考記事: PHPのsetcookie関数で空文字列を設定しようとするとクッキーが削除される

ところが、出題の確認時にmodphpallで確認したところ、古いPHPでは上記の挙動にはならず、以下のレスポンスヘッダが送信されることがわかりました。この動作は、setcookie関数の第2引数を省略した場合のみで、空文字列またはnullを指定した場合はマニュアルどおりの動作となります。
Set-Cookie: PHPSESSID=
正確に言えば、PHP 4.1.0~PHP 5.6.13までが上記挙動になります。すべてのPHP 4.0と PHP 5.6.14以降、PHP 7以降では、マニュアルどおりの動作となります。この変更は、恐らく以下のバグレポートに対応したものと思われます。

Bug #67131 setcookie() conditional for empty values not met

プログラミング言語のエッジケースの出題をする場合は特に、実環境での動作確認と、処理系の想定バージョンの明記が重要だなとあらためて感じました。

問題2

以下のPHPスクリプトはどちらが表示される? PHPのバージョンは7.3.6とする。
<?php
   $mail = "a@b@example.jp";
   var_dump(filter_var($mail, FILTER_SANITIZE_EMAIL));

A) 31人が選択
bool(false)

B) (正解)6人が選択=正答率 16.2%
string(14) "a@b@example.jp"

解説

この問題は「ひっかけ」でして、実際に多くの方がひっかかりました。
マニュアルにあるように、filter_var関数に FILTER_SANITIZE_EMAIL を指定した場合は、英字、数字および !#$%&'*+-=?^_`{|}~@.[] 以外のすべての文字を取り除きます。出題の場合は、除去対象の文字がないので、入力値がそのまま出力されます。
メールアドレスの形式を検証するためには、FILTER_SANITIZE_EMAIL ではなく、FILTER_VALIDATE_EMAIL を使用します。

率直に言って、FILTER_SANITIZE_EMAILのユースケースは思いつきません。要らんでしょ、こんなもの。

問題3(PHP考古学)

PHP-5.2.17にてregister_globals=onの環境で、あらかじめ以下のようにセッション変数が設定されている。
$_SESSION['user'] = 'yamada';
クエリ文字列 user=tanaka
を指定して以下のスクリプトを実行した場合の表示はどちら?
<?php
   session_start();
   echo $user;
A)yamada  (正解) 23人が選択=正答率 62.2%
B)tanaka 14人が選択

解説

register_globals=on の環境では、session_start()を実行時にセッション変数の値がグローバル変数として初期設定されます。したがって、$_SESSION['user'] = 'yamada'; が設定されている場合、$user の初期値は 'yamada' になります。
仮に、このセッション変数がセットされていない場合、クエリ文字列 user=tanaka の方が使われ、$user の初期値は 'tanaka' になります。セッション変数とクエリ文字列(やPOST変数など)の両方に同じパラメータ名がある場合、セッション変数の方が優先されます。その理由は、セッション変数のグローバル変数へのセットは、起動時ではなく、session_start()の実行時に行われるからです。すなわち、同名のパラメータがあった場合は、セッション変数が上書きします。
したがって、register_globals=on の使用は脆弱性の原因になりやすいことはよく知られていますが、セッション変数をグローバル変数の初期値として用いると、特に脆弱性が混入しやすくなります。

例えば、以下のログインチェックのプログラムについて
session_start();
if (! isset($_SESSION['user']) {
  die('ログインしてください');
}
$user = $_SESSION['user'];

これを register_globals=on を想定して「直訳」すると以下のようになります。
session_start();
if (! isset($user)) {
   die('ログインしてください');
}
// $user = $_SESSION['user'];  // これは不要となる
register_globals=on の場合、session_start()を実行した時点で、セッション変数 $_SESSION['user'] の値はグローバル変数 $user にセットされるため、「これは不要となる」とコメントした行は不要となります。便利ですね!
しかし、$_SESSION['user'] がない場合、$user=nullが実行されるわけではないので、クエリ文字列等にuser=tanakaがあった場合、$userには 'tanaka' がセットされます。すなわち、パスワードを知らなくても誰にでもなりすましできます。これは重大な問題ですね。
register_globals=on を用いて、上記の脆弱性を修正するには以下のように書くべきですが…
$user = null;  // register_globals=onによる初期設定を無効化する
session_start();
// $_SESSION['user'] がある場合、$userにその値がセットされる
if (! isset($user)) {
  die('ログインしてください');
}
上記は著しく直感に反するプログラムですし、それゆえに初期化漏れが盛大に発生しそうです。なので、register_globalsがPHP 5.4で削除されたのは、必然のことと言えましょう。


[PR]
徳丸が代表を務めるEGセキュアソリューションズ株式会社では、ウェブサイトを堅牢にするための各種セキュリティサービスを提供しています。

フォロワー

ブログ アーカイブ