【実務・中級編】PHPの『シリアライズ』におけるオブジェクトインジェクションの根本対策:__wakeupと__unserializeの内部挙動 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

Zend VMの深淵と安全保障:`__wakeup` vs `__unserialize` から紐解くPHPシリアライズの内部挙動と極限防御

テックリードの私から一つ聞こう。君のチームが書いているそのPHPコード、`unserialize()` に外部からの入力をそのまま突っ込んでいないか? 「`try-catch` で囲っているから大丈夫」「フレームワークがよしなにやってくれる」――もしそう思っているなら、今すぐその甘い認識を捨ててほしい。

PHPのシリアライズ機構は、Zend Engineのメモリ管理と密接に結びついた極めて強力な機能であると同時に、一歩使い方を誤ればアプリケーションの全権を奪われる諸刃の剣だ。今回は、Zend VMがデシリアライズ時にどのようにオブジェクトを再構築し、なぜガジェットチェーンが起動してしまうのか、その低レイヤのメカニズムを解き明かす。そして、現代のPHP開発において死守すべき堅牢な設計ルールを伝授しよう。

—

1. Zend VMにおけるデシリアライズとオブジェクト再構築の闇

PHPの `serialize()` と `unserialize()` は、単なるデータの文字列表現への変換・復元ではない。これらは、Zend Engineのヒープ上に存在する複雑な変数構造(zval)と、クラスのメソッドテーブル(zend_class_entry)を結びつける動的なインスタンス生成プロセスそのものだ。

メモリ空間とガジェットチェーンの起動メカニズム

悪名高い「オブジェクトインジェクション」の本質は、「型のない生データ(文字列)から、任意のクラスのインスタンスをコンストラクタをバイパスして復元できる」というZend VMの仕様にある。

1. ストリームの解析: `unserialize()` が呼び出されると、Zend VMはバイトストリームをパースし、クラス名とプロパティのペアを特定する。
2. コンストラクタのスキップ: 通常の `new ClassName()` と異なり、デシリアライズ時は `__construct()` は一切呼ばれない。Zend VMは直接オブジェクトの zval をメモリ上に割り当て、プロパティ値を無理やり流し込む。
3. マジックメソッドのフック: プロパティの注入が完了した直後、PHPのバージョンに応じたマジックメソッド(`__wakeup()` または `__unserialize()`)がトリガーされる。

ここに攻撃者の隙がある。攻撃者は、アプリケーション内の既存クラス(ガジェット)を巧みに組み合わせ、プロパティの値を書き換えることで、マジックメソッド内での意図しないメソッド呼び出し(コマンド実行やファイル操作など)を誘発するのだ。これが「ガジェットチェーン」の正体である。

—

2. 破壊された遺物:`__wakeup()` の構造的欠陥

PHP 7.4以前で主流だった `__wakeup()` には、設計上の致命的な欠陥があった。

// 【アンチパターン】旧来の __wakeup による検証
class VulnerableSession {
private $logger;
private $logFile;

public function __wakeup() {
// デシリアライズ直後にロガーを再初期化しようとする試み
$this->logger = new FileLogger($this->logFile);
}
}

このコードの何が問題か?
`__wakeup()` が呼ばれた時点で、すでにオブジェクトのプロパティ(`$logFile`)は外部から改ざんされた状態でメモリ上に展開されている。攻撃者は `__wakeup()` が実行される前に `$logFile` を悪意あるパス(例: `/var/www/html/shell.php`)に書き換えておくことができる。結果として、`FileLogger` のインスタンス化や内部のファイル書き込み処理が汚染された値で実行されてしまうのだ。

Zend VMの視点から言えば、`__wakeup()` は「すでに構築されてしまった不完全なzvalに対する事後的な修繕」にすぎず、セキュリティ境界としては完全に破綻している。

—

3. 救世主 `__unserialize()` と `__serialize()` の極意

PHP 7.4以降、我々はより安全で予測可能なデータ復元機構を手に入れた。それが `__serialize()` と `__unserialize(array $data)` だ。

この新しいマジックメソッドの最大の美しさは、オブジェクトのプロパティがZend VMによって直接メモリに書き込まれる前に、配列データとして一度フィルタリング・検証できる点にある。

実務で耐えうる堅牢な実装パターン

以下のコードを見てほしい。これは、型安全性を担保しつつ、不正なインジェクションを完全に遮断するモダンなPHPクラスの設計例だ。

declare(strict_types=1);

namespace App\Security;

use InvalidArgumentException;
use Serializable;

/

  • Class SecurePayload
  • 現代のZend VM仕様に準拠した、安全なシリアライズ制御の模範実装

/
final class SecurePayload implements \Serializable // 互換性のため残す場合は __serialize を優先
{
private string $identifier;
private int $accessLevel;
private array $metadata;

public function __construct(string $identifier, int $accessLevel, array $metadata)
{
$this->identifier = $identifier;
$this->accessLevel = $accessLevel;
$this->metadata = $metadata;
$this->validateState();
}

/

  • シリアライズ時に呼び出される(PHP 7.4+)
  • ハッシュテーブルから必要なプリミティブ値のみを抽出して配列化する

/
public function __serialize(): array
{
return [
‘identifier’ => $this->identifier,
‘accessLevel’ => $this->accessLevel,
‘metadata’ => $this->metadata,
];
}

/

  • デシリアライズ時に呼び出される(PHP 7.4+)
  • Zend VMはオブジェクトプロパティに直接書き込むのではなく、この配列を渡す。
  • ここで厳格な型チェックとバリデーションを行うことでインジェクションを根絶する。
  • @param array $data
  • @throws InvalidArgumentException

/
public function __unserialize(array $data): void
{
// 1. キーの存在チェック(構造の改ざん検知)
$requiredKeys = [‘identifier’, ‘accessLevel’, ‘metadata’];
foreach ($requiredKeys as $key) {
if (!array_key_exists($key, $data)) {
throw new InvalidArgumentException(“Invalid payload structure: missing key ‘{$key}’.”);
}
}

// 2. 厳格な型制約とドメインルールの検証
if (!is_string($data[‘identifier’]) || strlen($data[‘identifier’]) > 64) {
throw new InvalidArgumentException(“Invalid identifier type or length.”);
}

if (!is_int($data[‘accessLevel’]) || $data[‘accessLevel’] < 1 || $data['accessLevel'] > 10) {
throw new InvalidArgumentException(“Access level out of bounds.”);
}

if (!is_array($data[‘metadata’])) {
throw new InvalidArgumentException(“Metadata must be an array.”);
}

// 3. 検証済みの安全なデータをプロパティにバインド
$this->identifier = $data[‘identifier’];
$this->accessLevel = $data[‘accessLevel’];
$this->metadata = $data[‘metadata’];

// 4. 内部状態の最終検証
$this->validateState();
}

private function validateState(): void
{
// ドメイン固有の不変条件(Invariants)の強制
if (empty($this->identifier)) {
throw new InvalidArgumentException(“Identifier cannot be empty.”);
}
}

public function getIdentifier(): string
{
return $this->identifier;
}
}

—

4. チーフアーキテクトからの設計ルールとコードレビューの鉄則

現場のコードレビューにおいて、シリアライズ周りは以下の厳格なルールで監査しなければならない。

1. `unserialize()` には必ず `allowed_classes` を指定せよ

PHP 7.0以降、`unserialize()` の第2引数にホワイトリストを指定できる。外部入力をデシリアライズする際は、例外なくこのオプションを使用すること。

// 【推奨される安全な呼び出し】
// 予期しないクラスのインスタンス化をZend VMレベルで拒絶する
$data = @unserialize($inputString, [
‘allowed_classes’ => [SecurePayload::class]
]);

if ($data === false && $inputString !== serialize(false)) {
// デシリアライズ失敗または不正なクラスの検知
throw new \RuntimeException(“Deserialization failed: potential security violation.”);
}

2. 生のオブジェクトをデータベースやセッションに保存しない

アーキテクチャの観点から言えば、複雑なオブジェクト構造をそのままシリアライズしてストレージやキャッシュに保存する設計自体がレガシーであり、リスクが高い。
DTO(Data Transfer Object)や値オブジェクトを扱う場合でも、JSON(`json_encode` / `json_decode`)へのシリアライズを原則とせよ。JSONはオブジェクトの振る舞い(メソッドやガジェット)を保持しないため、オブジェクトインジェクションの脆弱性が物理的に発生しない。

3. レガシーコードの移行戦略

もし既存システムで `__wakeup()` を多用しているクラスが存在するなら、PHP 7.4以上へのバージョンアップを機に、速やかに `__serialize()` / `__unserialize()` へのリファクタリングを実施せよ。
古いコードは、エンジンの最適化恩恵を受けられないだけでなく、セキュリティ上の「時限爆弾」を抱えていると同義である。

—

結び

PHPは進化し続けている。Zend VMの内部構造を理解し、言語の進化に追従した堅牢な設計を行うことこそが、プロフェッショナルなWebシステムアーキテクトの責務だ。
「動けばいい」のコードは今日で終わりにする。明日のコードレビューでは、君自身の手でこの極意をチームに波及させ、堅牢なシステムを築き上げてほしい。

タイトルとURLをコピーしました