CodeIgniter 4.1.5 までのオブジェクトインジェクション脆弱性

この記事は CodeIgniter Advent Calendar 2022 - Qiita の7日目です。まだ、空きがありますので、興味のある方は気軽に参加してください。

CodeIgniter 4.1.6 で修正された CVE-2022-21647 Deserialization of Untrusted Data in Codeigniter4 の解説です。

オブジェクトインジェクションが何かについては、 PHPにおけるオブジェクトインジェクション脆弱性について を参照してください。

脆弱性のあったコード

以下が実際のコードです。

https://github.com/codeigniter4/CodeIgniter4/blob/8c7f70188e2bb7f19e6c7fcbbadb4dcb94c1cbc7/system/Common.php#L788-L823

    /**
     * Provides access to "old input" that was set in the session
     * during a redirect()->withInput().
     *
     * @param null        $default
     * @param bool|string $escape
     *
     * @return mixed|null
     */
    function old(string $key, $default = null, $escape = 'html')
    {
        // Ensure the session is loaded
        if (session_status() === PHP_SESSION_NONE && ENVIRONMENT !== 'testing') {
            // @codeCoverageIgnoreStart
            session();
            // @codeCoverageIgnoreEnd
        }

        $request = Services::request();

        $value = $request->getOldInput($key);

        // Return the default value if nothing
        // found in the old input.
        if ($value === null) {
            return $default;
        }

        // If the result was serialized array or string, then unserialize it for use...
        if (is_string($value) && (strpos($value, 'a:') === 0 || strpos($value, 's:') === 0)) {
            $value = unserialize($value); // ここが問題
        }

        return $escape === false ? $value : esc($value, $escape);
    }
}

最後の if文の中で $valueunserialize() しています。

では、$value とは何でしょうか?

        $value = $request->getOldInput($key);

上記のコードが $value に値を代入しています。

$requestIncomingRequest のオブジェクトです。 IncomingRequest::getOldInput() メソッドは以下のようになっていました。

    /**
     * Attempts to get old Input data that has been flashed to the session
     * with redirect_with_input(). It first checks for the data in the old
     * POST data, then the old GET data and finally check for dot arrays
     *
     * @return mixed
     */
    public function getOldInput(string $key)
    {
        // If the session hasn't been started, or no
        // data was previously saved, we're done.
        if (empty($_SESSION['_ci_old_input'])) {
            return null;
        }

        // Check for the value in the POST array first.
        if (isset($_SESSION['_ci_old_input']['post'][$key])) {
            return $_SESSION['_ci_old_input']['post'][$key];
        }

        // Next check in the GET array.
        if (isset($_SESSION['_ci_old_input']['get'][$key])) {
            return $_SESSION['_ci_old_input']['get'][$key];
        }

        helper('array');

        // Check for an array value in POST.
        if (isset($_SESSION['_ci_old_input']['post'])) {
            $value = dot_array_search($key, $_SESSION['_ci_old_input']['post']);
            if ($value !== null) {
                return $value;
            }
        }

        // Check for an array value in GET.
        if (isset($_SESSION['_ci_old_input']['get'])) {
            $value = dot_array_search($key, $_SESSION['_ci_old_input']['get']);
            if ($value !== null) {
                return $value;
            }
        }

        // requested session key not found
        return null;
    }

セッションに保存されたデータを返すことがわかります。

では、$_SESSION['_ci_old_input'] はどこでセットされるのでしょうか? _ci_old_input でソースコードを検索すると、RedirectResponse::withInput() メソッドでした。

    /**
     * Specifies that the current $_GET and $_POST arrays should be
     * packaged up with the response.
     *
     * It will then be available via the 'old()' helper function.
     *
     * @return $this
     */
    public function withInput()
    {
        $session = Services::session();

        $session->setFlashdata('_ci_old_input', [
            'get'  => $_GET ?? [],
            'post' => $_POST ?? [],
        ]);

        // If the validation has any errors, transmit those back
        // so they can be displayed when the validation is handled
        // within a method different than displaying the form.
        $validation = Services::validation();

        if ($validation->getErrors()) {
            $session->setFlashdata('_ci_validation_errors', serialize($validation->getErrors()));
        }

        return $this;
    }

このメソッドはリダイレクトする際に、入力データをフラッシュセッションに保存するメソッドのようです。 スーパーグローバルの $_GET$_POST をそのままセッションに保存しています。

ということで、$value には直前ページでの入力データが代入されることがわかりました。

そして、$value の値をチェックしてから、unserialize()関数に渡しています。

        // If the result was serialized array or string, then unserialize it for use...
        if (is_string($value) && (strpos($value, 'a:') === 0 || strpos($value, 's:') === 0)) {
            $value = unserialize($value);
        }

$value の値はGETまたはPOSTデータなので、文字列であることは確定しています。

strpos($value, 'a:') === 0 || strpos($value, 's:') === 0 でシリアライズされた値が配列か文字列であるかをチェックしたつもりかも知れませんが、このチェックは不十分です。配列の値にオブジェクトを含めればいいだけだからです。

結果として、オブジェクトインジェクションが可能であり、脆弱性のあるコードです。

脆弱性の修正

オブジェクトインジェクションの対策は、unserialize()関数にユーザー入力を渡さないことです。

実際の修正は、問題のあった if文が丸ごと削除されています。

https://github.com/codeigniter4/framework/commit/5b34d725f94fb0675d761bc55cb886480c85a5a4#diff-99c83f6a1ccaee1c1ebabad8faa8dac62c69ecee1bab9ec323ea127c6a2263d0L816-L820

なぜこの脆弱性が発生したのか?

そもそもなぜ入力データを unserialize() していたのかは、よくわかりません。

通常、ユーザーが HTMLフォームでシリアライズされた文字列を送信してくることは考えられませんから、 それをわざわざデシリアライズする必要もありません。

何らかの機能を意図したものだったのでしょうが、ドキュメントにも記述はありません。

根本的な問題は、ユーザー入力を正しく検証せずに使っていたことです。

しかし、シリアライズされたデータの検証は難しいので、ユーザー入力を unserialize() に一切渡さないことをお薦めします。 その方が簡単で確実です。

まとめ

  • ユーザー入力を unserialize()関数に渡してはいけません。脆弱性を作り込むことになります。
  • unserialize() を使う前に、JSONが使えないか検討しましょう。
  • 外部に保存されているシリアル化されたデータをデシリアライズする必要がある場合は、hash_hmac() を使ったデータの検証を検討しましょう。

この記事は CodeIgniter Advent Calendar 2022 - Qiita の7日目です。まだ、空きがありますので、興味のある方は気軽に参加してください。

参考

Date: 2022/12/07

Tags: codeigniter, codeigniter4, security, php