こんにちは。PHPの裏側で動いているZendエンジンや、メモリの息づかいを感じながらコードを書いていますか?
他の言語(JavaやRuby、あるいはGoなど)を深く経験してきた優秀なエンジニアほど、PHPの`unserialize()`という関数に出会ったとき、その手軽さと裏腹にある「黒魔術的な挙動」にギョッとされることが多いんですよね。「なぜ、ただ文字列を戻すだけで勝手にオブジェクトが生成され、挙句の果てに任意のコードまで実行できてしまうのか」と。
今回は、このPHPのデシリアライズ脆弱性(ガジェットチェーン)の本質を、Zendエンジンのメモリ構造やライフサイクルから紐解き、私たちが実務のアーキテクチャとしてどう立ち向かうべきかを、少し深掘りしてお話ししていきますね。
ここを理解すれば、PHPの裏側が驚くほど綺麗に見えるようになりますよ。
—
1. なぜ `unserialize()` は「魔界」と呼ばれるのか?
まずは、PHPがリクエストを受け取ってからデータを復元するまでの裏側の動きを、少し低レイヤの視点から覗いてみましょう。
私たちが普段何気なく使う `unserialize($data)` ですが、Zendエンジンの内部(C言語レベル)では、渡されたバイトストリームをパースし、シンボルテーブルやクラスエントリ(`zend_class_entry`)を参照しながら、動的にメモリ上にzval構造体を組み上げていきます。
ここで重要なのは、「データの復元(構造体の構築)」と「オブジェクトの初期化ライフサイクル(マジックメソッドの実行)」が、エンジン内部で不可分に行われるという点です。
ガジェットチェーンのメカニズム
攻撃者は、この仕組みの隙を突きます。
アプリケーションが予期しない悪意ある文字列を `unserialize()` に流し込むと、次のようなコンテキストが強制的に作られます。
1. 不正なプロパティの注入: 存在しない、あるいはアクセス制御を無視したプロパティ値がzvalとしてZendのメモリ空間に書き込まれる。
2. マジックメソッドの自動発火: デシリアライズの完了時(あるいはスクリプト終了時)に、Zendエンジンは容赦なく `__wakeup()` や `__destruct()` を呼び出す。
3. ガジェットの連鎖: すでに改ざんされたプロパティを持つオブジェクトが、別のオブジェクトのメソッドを呼び出し、最終的にファイル書き込みやRCE(リモートコード実行)へと繋がる。
つまり、ガジェットチェーンとは、「PHPが親切心から勝手に実行してくれるライフサイクル・フックを利用して、既存のクラス群(ガジェット)を部品に見立てて組み立てるパズル」なのです。
—
2. 伝統的な防御の限界と「検証と生成の分離」
この問題に対する古典的な対策として、「`allowed_classes` オプションを使う」という方法があります。
// PHP 7.0以降で使えるホワイトリスト方式
$data = unserialize($payload, [‘allowed_classes’ => [User::class, Product::class]]);
確かに、想定外のクラスのインスタンス化を防ぐには有効ですが、これだけでは「ホワイトリストに含まれているクラス内部のプロパティが汚染されているケース」や、「そのクラスが持つ `__wakeup()` 内の脆弱性」を防ぎきれません。
私たちがアーキテクチャとして目指すべきなのは、「データを信用しない(検証)」と「オブジェクトを作る(生成)」を完全に分離するというアプローチです。
—
3. 実践:シリアライズデータ検証とオブジェクト生成の分離アーキテクチャ
では、実際のモダンなPHPアプリケーション(フレームワークの基盤層など)で、これをどう実装すべきかを見ていきましょう。
コンセプトはこうです。
1. `unserialize()` を直接使わず、一度安全なフォーマット(JSONなど)に落とし込むか、あるいはシリアライズデータ構造を厳密にパース・検証する。
2. オブジェクトのプロパティには外部からの入力を直接入れず、専用のビルダーやファクトリーを経由して安全にコンストラクトする。
ここでは、「安全に構造を検査してからインスタンスを復元する」ためのカプセル化されたレイヤーの例を書いてみます。
/
class SecureUnserializer
{
/
- 許可されたクラスのホワイトリスト
- @var array
/
private array $allowedClasses;
public function __construct(array $allowedClasses)
{
$this->allowedClasses = $allowedClasses;
}
/
- 文字列を検証しつつ、安全にオブジェクトを復元する
- @param string $serialized
- @return object
/
public function unserialize(string $serialized): object
{
// 1. プリプロセッシング:文字列が不正な形式でないか、インジェクションの兆候がないか検査
$this->inspectPayload($serialized);
// 2. Zendエンジンのデシリアライザを安全な制限下で実行
// allowed_classes を強制し、かつ __wakeup の暴走を防ぐための前段階とする
$data = @unserialize($serialized, [
‘allowed_classes’ => $this->allowedClasses
]);
if ($data === false && $serialized !== serialize(false)) {
throw new UnexpectedValueException(‘シリアライズデータの復元に失敗しました。’);
}
// 3. 復元されたオブジェクトが期待通りの契約(インターフェース)を満たしているか検証
$this->validateObjectIntegrity($data);
return $data;
}
/
- ペイロードの構造的特徴をスキャンする(簡易的な例)
/
private function inspectPayload(string $payload): void
{
// 例: オブジェクトの型宣言(O: または C:)が含まれているが、
// ホワイトリスト外の怪しい文字列がないかを正規表現や専用パーサでチェックする
if (preg_match(‘/[OC]:\d+:”[^”]+”:/’, $payload, $matches)) {
// クラス名部分を抽出して事前チェックすることも可能
}
// 危険なマジックバイトや制御文字の混入を防ぐ
if (preg_match(‘/[\x00-\x08\x0B\x0C\x0E-\x1F]/’, $payload)) {
// バイナリセーフだが想定外の制御文字がある場合は弾く
}
}
/
- オブジェクトの整合性を保証する
/
private function validateObjectIntegrity(mixed $object): void
{
if (!is_object($object)) {
throw new UnexpectedValueException(‘期待されたオブジェクトではありません。’);
}
$className = $object::class;
if (!in_array($className, $this->allowedClasses, true)) {
// 二重のチェック
throw new SecurityException(sprintf(‘未許可のクラス [%s] のインスタンス化が検出されました。’, $className));
}
// もしオブジェクトが特定の安全検証インターフェースを実装していれば、そのメソッドを叩く
if ($object instanceof ValidatableInterface) {
if (!$object->validateState()) {
throw new SecurityException(sprintf(‘クラス [%s] の内部状態が不正(汚染されている可能性)です。’, $className));
}
}
}
}
/
- ドメインモデル側で実装する検証インターフェース
/
interface ValidatableInterface
{
public function validateState(): bool;
}
このアーキテクチャがもたらす優位性
上記のコードのように、`unserialize()` を生のままコントローラやドメインロジックに露出させず、インフラ層やセキュリティ層でラップすることで、以下のメリットが生まれます。
- ライフサイクルのコントロール: `__wakeup()` の中で外部リソースにアクセスしたり危険な処理を行うクラスであっても、その前に `validateState()` でプロパティの型や値のレンジを厳密にチェックできます。
- 関心事の分離: 「データをバイト列から戻す処理」と「ビジネスロジックとしてそのデータが健全か確認する処理」が分離され、コードのメンテナンスタリティが劇的に向上します。
—
4. アーキテクトからの提言:そもそも `unserialize()` を使うべきか?
ここまでガジェットチェーンの防御策についてお話ししてきましたが、リードチーフアーキテクトとしての私の本音を最後に一つお伝えさせてください。
「可能であれば、PHPネイティブの `serialize()` / `unserialize()` をドメイン間のデータ授受や外部入力の処理に使わないこと」。これが最大の防御策であり、究極の最適化です。
現代のWebアーキテクチャにおいて、永続化やAPI通信、ジョブキューのペイロードには、型安全で言語非依存な JSON (`json_encode` / `json_decode`) や、より堅牢なシリアライザ(Protocol Buffersなど)を採用するべきです。JSONであれば、どれほど巧妙に細工された文字列を渡そうとも、PHPのオブジェクトが勝手に生成されることはありません。ただの「連想配列とスカラー値」に変換されるだけだからです。
ネイティブシリアライズが必要になるのは、PHP特有のオブジェクトグラフ(参照関係の維持など)をそのままファイルキャッシュなどに閉じ込めるときくらいでしょう。その場合でも、HMAC署名(`hash_hmac`)を必ずデータに付与し、改ざんされていないことを検証した上でデシリアライズするパイプラインを必ず構築してください。
PHPのエンジンは非常に高速で柔軟ですが、その柔軟性ゆえに「設計者の意図しないパス」を許容してしまいます。表層的な書き方にとらわれず、Zendエンジンのメモリとライフサイクルの動きを脳内でトレースしながら、堅牢な防壁をデザインしていきましょう。
あなたの書くコードが、安全で美しいシステムの一部になることを応援しています。