PHPのmt_rand()とrand()には状態の初期化/再初期化に問題があります。話しを簡単にするためにrand()をmt_rand()のエイリアスにする提案
https://wiki.php.net/rfc/rng_fixes
が適用された状態を前提にMT rand問題として解説します。しかし、基本的には古いPHPでrand()を使う場合も同じ(かそれ以上)の問題があります。
そもそもMT randは暗号理論的に安全な乱数生成器ではありません。安全な乱数の取得に使ってはなりませんが、MT rand以前の乱数に比べ予測が困難、ということで不適切な箇所で利用しているケースが散見されます。
結論から言うと、MT randで生成された乱数値は安全ではなく非常に危険1、です。
MT randのシード問題
PHPはMT randを自動的にシードします。
<?php var_dump(mt_rand()); ?>
とするとMT randの状態がCombined LCG(時間とPIDを組み合わせた疑似乱数)を用いて初期化されます。
Combined LCGの問題
Combined LCGはシステム時間とPIDを組み合わせた疑似乱数であるため、PRNG(疑似乱数生成器)の初期状態を設定するには十分なエントロピー(ランダム性)がありません。比較的容易に推測できます。
MT randの再初期化問題
PHPはMT randを自動的に初期化しますが、再初期化しません。一旦MT randの状態がシードによって初期化されると、その状態を使い続けます。つまり、最初のシードが判ってしまえばその後のランダム値を予測することが容易にできます。
しかも、初期化された状態はリクエストにまたがって有効です。つまり、同じプロセスのPHPは一旦MT randが初期化されると、その後ずっと使い続けます。シードが判ってしまえば、別のリクエストでどのような乱数値が生成されるのか容易に予測できます。
MT randの初期化問題
現在のPHPのMT randの初期化コードにも問題があります。MT randは約2.5KBの状態バッファーによって2^19937−1という長大なサイクルで乱数を生成します。MT randのサイクルが長大であるためMT randはランダム値が予測可能な乱数生成器であっても、MT randより古い乱数生成器に比べ予測が難しい乱数生成器になっています。
MT randの仕様にしたがって2^19937−1のサイクルのどこかに状態を設定すれば予測が難しい乱数値を取得できます。しかし、現在のPHPのMT randはたった2^32のシードしかサポートしておらず、初期状態は2^32しかありません。本来のMT randに比べ遥かに簡単に予測できます。2
これはPHPのMT randを用いてパスワードやアクセストークンを生成した場合3、攻撃者が簡単にパスワードやアクセストークンを予測できることを意味します。
ユーザーによる初期化問題
ユーザーによるMT randの初期化はmt_srand()によって行えます。「MT randの再初期化問題」で解説した通り、一旦初期化された状態はそのまま使われます。mt_srand(1234)などが呼ばれた場合は別で、指定したシードで初期化されます。ユーザー/システムによるシードに区別がなく、さらに初期化済みとフラグが立った場合は再初期化なく使われ続けます。4
例えば、mt_srand(1234)がPHPスクリプトのどこかで実行された場合、それに続くリクエストでmt_srand(1234)と呼ばなくても、mt_srand(1234)で初期化されたMT randが生成する乱数を使うだけで簡単に乱数を予測できます。(リクエストをまたがって状態が維持される、つまり乱数に見えても適切なシードによって初期化された乱数でない)
ユーザーがシード値を指定してmt_srand()を呼んだ場合、続くリクエストでmt_rand()を呼んだ場合、乱数としての意味をなしません。乱数ではなく、決まった乱数のような値、になります。
PHP 7.1からrand()がmt_rand()のエイリアスになった問題
PHP 7.1からrand()/srand()がmt_rand()/mt_srand()のエイリアスになりました。これにより思わぬところで乱数にならない場合があります。
[yohgaki@dev PHP-7.1]$ ./php-bin -r 'srand(0);var_dump(rand());' int(1178568022) [yohgaki@dev PHP-7.1]$ ./php-bin -r 'srand(0);var_dump(mt_rand());' int(1178568022)
srand()を呼んでもmt_rand()の出力に影響します。つまり
<?php // 特定のランダム整数が欲しいのでsrand(1234)でシード srand(1234); var_dump(rand()); // アプリの他の箇所でmt_rand()を呼んだ場合 // srand()とmt_srand()は別の状態を持つので、 // mt_rand()は適当な乱数 var_dump(mt_rand()); // PHP 7.1からは乱数ではない ?>
このような事が起きます。しかも、srand(1234)でシードされた状態は他のリクエストでも有効で乱数と呼べる結果になりません。
※ さらにPHP 7.2ではmt_randのアルゴリズム実装の間違いが修正されています。このため、同じシードでも同じ疑似ランダム整数を返しません。C言語で書かれたモジュールなどからは旧コードのアルゴリズムも使えるようになっていますが、PHPスクリプトからは旧アルゴリズムを利用できません。同じ疑似ランダムにより同じパターンの動作を行わせるゲームなどでは動作が異り困るケースがあると思われます。
PHPユーザーはどうすべきか?
mt_srand()でシード値を指定した場合、完全にランダムなシード値でMT randを再初期化する必要があります。
<?php mt_srand(1234); var_dump(mt_rand()); mt_srand(random_int(-2**31, 2**31-1); ?>
これでも初期状態が2^32しかないので不十分ですが、mt_srand(1234) の状態より比較にならないくらい良いです。
シードしないで利用する場合に脆弱なCombined LCGによる初期化が気になる場合は
<?php mt_srand(random_int(-2**31, 2**31-1); ?>
を実行してからmt_rand()を使うと良いです。
ただし、シードが2^32しかない点は変わらず、MT randが本来持つ仕様より非常に脆弱である点には留意してください。
今後はどうなるのか?
現在の仕様は非常にまずいので修正提案をしています。
https://wiki.php.net/rfc/improve_predictable_prng_random
uniqid()の追加エントロピー生成の方法が良くない点を修正しようとしていて、その途中でMT randの初期化と状態管理がとても悪い状態であることに気がつきました。今まで誰も指摘しなかった事が不思議なくらいです。。
まとめ
そもそもパスワードやアクセストークンなど、セキュリティが必要なランダム文字列や乱数にmt_rand()やrand()を使ってはいけません。もしこのようなコードがある場合、直ぐにrandom_bytes()やrandom_int()を使った実装に修正すべきです。
もしmt_rand()を使っている場合、攻撃者はシステムが生成したランダム(であるハズの)パスワードやランダム(であるハズの)アクセストークンなどから、次に生成されるパスワード/アクセストークンを比較的容易に予測できます。
修正提案はしていますが、実装されるのは早くてもPHP 7.2からです。それまでは、mt_srand()による初期化が必要な箇所で適切に初期化してから使うようにしてください。5
繰り返しになりますが、セキュリティが必要なランダム文字列や乱数にmt_rand()やrand()を使ってはなりません。もしOSSのコードなどで見かけたら、開発者に修正を依頼するか、パッチを作って送りましょう!
- 本来あるべき状態に比べ非常に危険、PHPのバージョンと使い方によってはランダムでなくなります。 ↩
- Rubyは任意精度整数をサポートしており、大きな整数値でMT randを初期化できます。PythonはバイナリデータでMT randの初期状態を初期化できます。PHPもrandom_bytes()で取得したバイナリデータで初期化するように変更すればより安全になります。 ↩
- そもそもパスワードやアクセストークンの生成にMT randを使ってはいけません。しかし、使っているコードが少い、とは言えないのが現状です。 ↩
- PHPのMT randは一旦初期化(seed)されると、明示的に再初期化されない限り以前の状態からランダム値を生成します。通常、PHPのプロセスはリクエストをまたがって生き続けます。このため、攻撃者からすると比較的にランダム値の結果を予測しやすくなっています。 ↩
- 2017/02/26現在、まだmt_rand()は改善されていません。 ↩