CakePHP2 ベースのアプリケーションを PHP 7.1 にあげるときにハマったところ


これは CakePHP Advent Calendar 2018 の20日目の記事です。

いよいよ今月末に PHP5.6 が EOL(End Of Life) を迎えます。今年開催された各地のPHPConでも PHP5 から PHP7 へのバージョンアップの話が多数ありましたが、この記事では、CakePHP2 で作られたアプリケーションを PHP5.6 から PHP7.1 へバージョンアップした際にハマったポイントを紹介しようと思います。

PHP5.x から PHP7.x へのバージョンアップでは、言語仕様の変更や非推奨になった機能を使っていたために修正をしなくてはいけなくなった(なっている)という方が多いと思いますが、今回のものは CakePHP2 特有の問題になります。

Security.cipherSeed

CakePHP2 でアプリケーションを作ったことがある方ならば、アプリケーションを作るたびに変更しろと警告の出る2つの値があることはご存知だと思います。

app/Config/core.php に記述されている以下の2つの値です。

  • Security.salt
  • Security.cipherSeed

変更しろと言われるので知っているけど、それぞれが何に使われているかはご存知ですか?

例えば、 Security.salt は例えば、 Security::hash('xxxxx') という呼び出しで使われます。これを直接使ったことはないという方でも、 AuthComponent::password('xxxx') はこの Security::hash を呼び出しているだけなので、間接的に使っているということになります。

lib/Cake/Utility/Security.php
class Security {
()
    public static function hash($string, $type = null, $salt = false) {
        if (empty($type)) {
            $type = static::$hashType;
        }
        $type = strtolower($type);

        if ($type === 'blowfish') {
            return static::_crypt($string, $salt);
        }
        if ($salt) {
            if (!is_string($salt)) {
                $salt = Configure::read('Security.salt');
            }
            $string = $salt . $string;
        }

        if (!$type || $type === 'sha1') {
            if (function_exists('sha1')) {
                return sha1($string);
            }
            $type = 'sha256';
        }

        if ($type === 'sha256' && function_exists('mhash')) {
            return bin2hex(mhash(MHASH_SHA256, $string));
        }

        if (function_exists('hash')) {
            return hash($type, $string);
        }
        return md5($string);
    }
()
}

では、 Security.cipherSeed はどこで使われているかというと、以下の場所で使われています。

lib/Cake/Utility/Security.php
class Security {
()
    public static function cipher($text, $key) {
        if (empty($key)) {
            trigger_error(__d('cake_dev', 'You cannot use an empty key for %s', 'Security::cipher()'), E_USER_WARNING);
            return '';
        }

        srand((int)(float)Configure::read('Security.cipherSeed'));
        $out = '';
        $keyLength = strlen($key);
        for ($i = 0, $textLength = strlen($text); $i < $textLength; $i++) {
            $j = ord(substr($key, $i % $keyLength, 1));
            while ($j--) {
                rand(0, 255);
            }
            $mask = rand(0, 255);
            $out .= chr(ord(substr($text, $i, 1)) ^ $mask);
        }
        srand();
        return $out;
    }
()
}

Security::cipher メソッドなんか使ったことないよという方が多いとは思いますが、これは以下の流れで使われます。

  1. Cookieに値を書き込むために CookieComponent::write($key, $value) を呼び出す
  2. CookieComponent::_write($name, $value) が呼び出される
  3. CookieComponent::_encrypt($value) が呼び出される
  4. CookieComponent の 暗号化方式 が cipher の場合(何も設定してないとこれになる)、Security::cipher($text, $key) が呼び出される

つまり CookieComponet を使って Cookie の書き込みをしていて、特に暗号化方式を変更していない場合、 Security.cipherSeed という値を使っているということになります。

PHP7.1にあげるにあたって何が問題だったのか

再度 Security::cipher メソッドの中身を見てみましょう。

このメソッドは、「シードを固定すれば乱数発生器は毎回同じ数値を順番に発生する」ということを利用した暗号になっており、それにより暗号/復号ができるというものになっています。

つまり、一度決めた Security.cipherSeed を途中で変えると暗号化された文字列が復号できないということになります。

lib/Cake/Utility/Security.php
class Security {
()
    public static function cipher($text, $key) {
        if (empty($key)) {
            trigger_error(__d('cake_dev', 'You cannot use an empty key for %s', 'Security::cipher()'), E_USER_WARNING);
            return '';
        }

        srand((int)(float)Configure::read('Security.cipherSeed'));
        $out = '';
        $keyLength = strlen($key);
        for ($i = 0, $textLength = strlen($text); $i < $textLength; $i++) {
            $j = ord(substr($key, $i % $keyLength, 1));
            while ($j--) {
                rand(0, 255);
            }
            $mask = rand(0, 255);
            $out .= chr(ord(substr($text, $i, 1)) ^ $mask);
        }
        srand();
        return $out;
    }
()
}

このコード、PHP5 だろうが、PHP7 だろうが問題なく動きますし、PHP のバージョンごとの差異を警告してくれるようなツールでも特に警告は出ません。

しかし、PHP5.x/PHP7.0 で動いてたものを、PHP7.1 で動作させるととたんにおかしな動きをすることになります。

それは、PHP7.1 で以下のような修正がかかったからです。

rand() と srand() が、 それぞれ mt_rand() と mt_srand() のエイリアスとなる

rand() と srand() は、それぞれ mt_rand() と mt_srand() のエイリアスになりました。つまり、 rand()、shuffle()、 str_shuffle()、array_rand() の出力がこれまでのバージョンとは変わるということです。

http://php.net/manual/ja/migration71.incompatible.php#migration71.incompatible.rand-srand-aliases

PHP7.1 以降では randmt_rand を呼び出していることになるので、同じシードを与えてもPHP5.x/PHP7.0 までとは同じ数値を発生しません

そのために、PHP5.x/PHP7.0 上で動作していた Security::cipherで暗号化した文字列を、PHP7.1 で動作させた Security::cipher では復号できないということになります。

今回ハマったのは、PHP7.1 環境を作って、そこで十分テストをしていたのですが、それはあくまでも PHP7.1 で暗号化された Cookie が PHP7.1 で復号できただけで、PHP5.6で作った Cookie を保持してなかったからでした。(とてもつらい・・・)

回避策

回避策はいろいろあると思いますが、以下のようなものが考えられます。

  1. PHP5.x/PHP7.0 までと PHP7.1 以降で Cookie の name を変えてしまう。
  2. PHP5.x/PHP7.0 の段階で cipher 以外の方式に予め変更しておいて、いい感じにマイグレーションしておく

一旦、PHP7.1 にあげてしまうと、PHP7.0 までと同じロジックの rand は使えないので、PHP5.x/PHP7.0 時代に暗号化した文字列を復号する方法は一切なくなります。

なので、PHP7.1 に上げる前に予め別方式にスムーズにマイグレーションしておいてから、cipher を切り捨てるというのができればベストだとは思います。

1.の方式を取ると、それまで保持していた値がすべて吹っ飛ぶ形になりますので、例えばユーザのお気に入りをCookieで保持していたみたいなことがあった場合、それらがすべて吹っ飛ぶということになります。

最後に

Cookie を駆使していろいろしているみたいなことはあまりないかもしれないので、それほど大きな事故にはならないかもですが、もし今後 PHP5.x/PHP7.0 から PHP7.1 に上げる際に、CookieComponentを使っているという方はこの問題はチェックされたほうがいいと思います。

PHP5.6 -> PHP7.0 でこういうのが起きるならわかるんですが、PHP7.0 -> PHP7.1 というタイミングのため、意外と見落としがちかなと思いますので、これからバージョンアップされる方の参考になればいいなと思ってます。