PHPのserialize/unserializeは何が危ないのか – __serializeと allowed_classes の実務ライン

PHPの serialize() は、配列やオブジェクトを文字列に変換して保存・復元できる関数です。unserialize() で元に戻せますが、外部から来た文字列をそのまま渡すとオブジェクト注入という深刻な脆弱性になります。

この関数、キャッシュやセッション周りで今でも普通に出てきます。ただ「便利だから使う」で通してしまうと、後から静かに刺さるタイプの機能でもあります。私自身、既存システムの調査でシリアライズ文字列がDBに直接入っているのを見つけて、しばらく固まったことがありました。

なぜJSONではなくserializeなのか

まず前提として、多くの場面ではJSONで十分です。それでも serialize() が選ばれるのは、PHPの型をそのまま保てるからですね。オブジェクトのクラス、privateプロパティ、intとstringの区別、これらがJSONでは落ちます。

<?php
class Point {
    public function __construct(
        private int $x,
        private int $y,
    ) {}
}

$p = new Point(3, 5);

echo serialize($p);
// O:5:"Point":2:{s:8:"\0Point\0x";i:3;s:8:"\0Point\0y";i:5;}

echo json_encode($p);
// {}  ← privateは出ない

privateプロパティ名の前後に \0(NULバイト)が入っているのが分かります。この見た目が扱いづらさの正体で、うっかりエディタで開いても壊れて見えるし、DBのカラムに入れると文字コード周りで事故ります。型が保てるのは嬉しいのですが、可読性はゼロだと思っておいたほうがいいです。

unserializeの何がそんなに危ないのか

問題は、unserialize() が文字列の指示どおりに任意のクラスのインスタンスを生成することです。生成されたオブジェクトは、スコープを抜けるときに __destruct() が呼ばれます。__wakeup()__toString() も条件次第で走ります。

<?php
class TempFile {
    public string $path = '';

    public function __destruct()
    {
        // 本来は自分が作った一時ファイルを消すためのコード
        if ($this->path !== '') {
            @unlink($this->path);
        }
    }
}

// 攻撃者が投げてきた文字列
$evil = 'O:8:"TempFile":1:{s:4:"path";s:20:"/var/www/config.php";}';

unserialize($evil);
// この行を抜けた瞬間 config.php が消える

コード上のどこにも「攻撃者の入力でunlinkする」なんて書いていないのに、成立してしまう。これがPOP(Property Oriented Programming)チェーンと呼ばれるやつです。実際の攻撃では、フレームワークやライブラリに元から入っているクラスを数珠つなぎにしてRCEまで持っていきます。自分のコードが綺麗でも、vendor配下に材料が揃っていれば同じことなんですね。

allowed_classes はどう指定する?

どうしても unserialize() を使うなら、第2引数の allowed_classes は必須だと思っています。省略はデフォルトで true(全クラス許可)なので、書かないこと自体が設定ミスです。

<?php
// オブジェクトを一切許可しない(配列とスカラーだけ)
$data = unserialize($raw, ['allowed_classes' => false]);

// 特定のクラスだけ許可する
$data = unserialize($raw, ['allowed_classes' => [Point::class, Money::class]]);

許可されなかったクラスは __PHP_Incomplete_Class という別物になり、プロパティにアクセスしようとすると警告が出ます。つまり「知らないうちに復元されていた」状態を防げます。既存コードを直すときは、まず allowed_classes => false を入れて動くか試すのが早いです。動かないなら、そこは本当にオブジェクトを復元しているということなので、リストを絞って渡します。

そもそも外部入力をunserializeしない

もっと言えば、Cookie・リクエストボディ・URLパラメータから来た値を unserialize() に通す設計は、allowed_classes があっても避けたほうがいいです。用途がデータの受け渡しなら、素直にJSONにする。署名が必要なら hash_hmac() で検証してから読む。

<?php
// 保存側
$payload = json_encode($data, JSON_THROW_ON_ERROR);
$sig     = hash_hmac('sha256', $payload, $secretKey);
$cookie  = base64_encode($payload) . '.' . $sig;

// 読み込み側
[$b64, $sig] = explode('.', $cookie, 2) + [null, null];
$payload = base64_decode((string)$b64, true);

if ($payload === false
    || !hash_equals(hash_hmac('sha256', $payload, $secretKey), (string)$sig)) {
    throw new RuntimeException('改ざんされたペイロード');
}

$data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);

比較に === ではなく hash_equals() を使うのは、タイミング攻撃対策ですね。ここは反射で書けるようにしておくと安心です。

__serialize と __unserialize で中身を制御する

自分が管理するクラスをシリアライズする場合、PHP 7.4以降は __serialize() / __unserialize() を実装するのが基本です。古い Serializable インターフェースはPHP 8.1で非推奨になりました。

<?php
class ApiClient
{
    private ?\CurlHandle $handle = null;  // これは保存できない

    public function __construct(
        private string $endpoint,
        private string $token,
    ) {}

    public function __serialize(): array
    {
        // 保存したいものだけ、キー名も自分で決める
        return [
            'endpoint' => $this->endpoint,
            // token は保存しない
        ];
    }

    public function __unserialize(array $data): void
    {
        $this->endpoint = $data['endpoint'];
        $this->token    = getenv('API_TOKEN') ?: '';
        $this->handle   = null;  // 使うときに張り直す
    }
}

嬉しいのは、リソース型やクロージャのような「そもそも保存できないもの」を持つクラスでも安全に扱えることと、シークレットを保存対象から外せることです。キャッシュに突っ込んだオブジェクトの中にAPIトークンが平文で残っていた、みたいな事故はこれで防げます。

ついでに、__serialize() を実装しておくと後方互換の逃げ道も作れます。プロパティを増やしたときに __unserialize() の側で $data['newField'] ?? 'default' と書いておけば、古い形式のキャッシュが残っていても落ちません。プロパティ名を直接シリアライズしていると、リネームした瞬間に既存キャッシュが全滅します。地味ですが、運用ではここが一番効きます。

キャッシュとセッションでの現実的な線引き

実務でserializeが登場するのは、だいたいこの2つです。

セッションはPHPが内部で独自形式(php serialize handler)を使っているので、通常は意識しません。ただしセッションストアがRedisやDBで、そこに書き込み権限を持つ別システムがあるなら、それは実質的に外部入力です。session.serialize_handlerphp_serialize にしておくと、キー名に | が入ったときのパース事故を避けられます。

キャッシュは、値が自分のアプリだけで完結しているならserializeでもいいと思います。ただ、クラス定義を変えるとキャッシュの復元が壊れるという性質があるので、デプロイのたびにキャッシュキーへバージョンを混ぜておくのが安全です。

<?php
// クラス構造を変えたらCACHE_VERSIONを上げる運用にしておく
const CACHE_VERSION = 'v3';

$key = sprintf('%s:user:%d', CACHE_VERSION, $userId);

個人的には、キャッシュに入れるのはオブジェクトではなく配列やスカラーにして、復元側でファクトリを通すほうが好みです。allowed_classes => false で済むようになるし、クラスをリファクタしても壊れません。少し書く量は増えますが、後から効いてくるほうを選びたいところです。

まとめ

serializeは型を保てるという一点で強力ですが、その代償として「文字列がオブジェクトを作れる」という性質を抱えています。外から来た値には使わない、使うなら allowed_classes を必ず書く、自分のクラスは __serialize() / __unserialize() で中身を明示する。この3つを守るだけで、事故る確率はかなり下がると思います。

そして一番効くのは、そもそもJSONで済ませられないかを最初に考えることかもしれません。型が落ちるという不便は、たいてい復元側のファクトリで吸収できます。

よくある質問

Q. unserialize に allowed_classes を指定すれば安全ですか?
A. 攻撃面はかなり狭まりますが、許可したクラス自体に危険な __destruct()__wakeup() があれば依然として悪用されます。外部入力に対しては、そもそもserialize形式を使わない設計にするのが確実です。

Q. Serializable インターフェースはもう使ってはいけませんか?
A. PHP 8.1で非推奨になり、単独実装すると deprecation 警告が出ます。新規は __serialize() / __unserialize() を使ってください。既存コードは両方実装しておけば、移行期間中も警告なく動かせます。

Q. serialize した文字列をDBに保存するときの注意点は?
A. privateプロパティ名にNULバイトが含まれるため、カラムは BLOBVARBINARY のようなバイナリ型が安全です。文字列型に入れると照合順序や文字コード変換で壊れることがあります。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です