こんにちは。PHPの裏側でうごめくZend Engineの鼓動、そしてWebリクエストがミリ秒単位で処理されていくそのダイナミクスに魅せられた開発者の皆さん。
普段、私たちはLaravelやSymfonyといったモダンなフレームワークを使い、洗練されたオブジェクト指向のコード書いていますよね。オブジェクト同士が美しく協調し、ビジネスロジックをスマートに表現していく。PHPでの開発は本当に快適なものです。
しかし、ふと立ち止まって考えてみてください。
私たちが何気なく使っている `serialize()` や `unserialize()`、そしてセッション管理。これらは内部のZend VM(Zendバーチャルマシン)にとって、「外部から持ち込まれた未検証のバイナリ構造体を、生きたオブジェクトとしてメモリ上に再構築する」という、極めてスリリングで危険なプロセスなんです。
今回は、PHPのシリアライズ・デシリアライズの深淵を覗き、悪名高い「オブジェクトインジェクション(ガジェットチェーン)」がZend VMのメモリ上でどのように引き起こされるのか、そしてそれをどうやって物理的に破壊・無力化するのかについて、私の知見をすべてお話しします。
ここを理解すれば、PHPのセキュリティと裏側の仕組みが驚くほどクリアに見えますよ。
—
1. Zend VMは `unserialize()` をどう処理しているのか
まず、PHPの内部エンジンがシリアライズデータをどう扱っているかを知る必要があります。
PHPのシリアライズ形式は、JSONのような単なるテキストデータではありません。あれは一種の「型付きバイトストリーム」です。
例えば、`O:4:”User”:1:{s:4:”name”:s:3:”Alice”;}` という文字列を `unserialize()` に渡したとします。Zend VMは以下のステップを踏んでメモリを書き換えます。
1. 字句解析・パース: 文字列のプレフィックス(`O`はオブジェクト、`s`は文字列など)を読み取り、クラス名やプロパティの構造をトークンに分解します。
2. クラスの存在確認: 指定されたクラス(この場合は `User`)がシンボルテーブルにロードされているか確認します。
3. インスタンスの生成(ここが重要): コンストラクタ(`__construct`)を一切呼ばずに、オブジェクトのメモリ領域(`zend_object`)をヒープ上にアロケートし、プロパティの値を直接流し込みます。
4. マジックメソッドの呼び出し: もしそのクラスに `__wakeup()` や `__unserialize()` が定義されていれば、復元のタイミングで強制的にそれらの関数がコールされます。
そう、問題は3番と4番です。
「コンストラクタを通さずに、任意のプロパティを持ったオブジェクトが勝手に生成され、特定のマジックメソッドが勝手に実行される」。
攻撃者がこの挙動をハックできたらどうなるか……想像がつくはずです。
—
2. ガジェットチェーンの物理的メカニズム
オブジェクトインジェクション単体では、ただ「クラスが存在し、プロパティが復元される」だけです。それだけでは即座にリモートコード実行(RCE)には至りません。
ここで登場するのが「ガジェットチェーン(Gadget Chain)」です。
ガジェットとは、アプリケーション内(あるいは依存しているサードパーティ製ライブラリ内)に存在する既存のクラスのパーツのことです。
攻撃者は、次のようなマジックメソッドを持つクラスの連鎖を見つけ出します。
- `__destruct()`: スクリプト終了時やオブジェクト破棄時に必ず呼ばれる。
- `__toString()`: オブジェクトが文字列として扱われたときに呼ばれる。
- `__call()`: 存在しないメソッドが呼ばれたときに呼ばれる。
脆弱なコードの典型例
例えば、アプリケーション内に以下のようなロギングクラスがあったとしましょう。
class FileLogger {
private $logFile;
private $logData;
public function __destruct() {
// 危険な兆候:オブジェクトが破棄されるときに、外部から注入可能なプロパティを使ってファイル操作をしている
file_put_contents($this->logFile, $this->logData);
}
}
もし、攻撃者がこのアプリケーションに対して以下のようなシリアライズデータを送り込むことができたらどうなるでしょうか?
// 攻撃者が巧妙に構築したペイロードのイメージ
// $logFile に “/var/www/html/shell.php” を、$logData に “” を仕込む
スクリプトが終了し、Zend VMがガベージコレクションやリクエスト終了処理でこのオブジェクトを破棄(`__destruct` を実行)した瞬間、勝手にWebシェルが生成されます。
これが、ガジェットチェーンの恐怖の本質です。攻撃者は新しいコードを書いているのではなく、「元からそこにあるパーツを組み合わせて、エンジンの仕組みを利用して別の目的(破壊)に強制労働させている」のです。
—
3. 根本対策:シリアライズデータの「物理的保護(署名検証)」
「じゃあ、`unserialize()` なんて二度と使わなければいい。すべて JSON に置き換えればいいんだ」
そう思われるかもしれません。確かに、JSON(`json_encode` / `json_decode`)はパース時にオブジェクトを勝手に復元せず、ただの配列やStdClassにするため安全です。
しかし、PHPのオブジェクト状態をそのまま永続化したい場合や、既存のフレームワークのセッション機構などで、どうしてもシリアライズが必要な場面は存在します。
では、どう守るべきか?
答えはシンプルです。「信頼できない入力を絶対にデシリアライズしない」。そして、どうしても外部とやり取りするデータにシリアライズを使う場合は、暗号学的署名(HMAC)によってデータの改ざんを完全に封じることです。
安全なシリアライズ・デシリアライズの実装例
以下のコードは、アプリケーションの秘密鍵を使ってシリアライズデータにHMAC署名を付与し、改ざんやすり替えを物理的にブロックする堅牢なラッパーの例です。
class SecureSerializer {
// アプリケーション固有の強固な秘密鍵(環境変数から読み込むこと)
private static string $secretKey = ‘your-super-secret-key-change-in-production’;
/
- 安全にシリアライズ(データ + 改ざん検知用の署名を生成)
/
public static function serialize(mixed $data): string {
// 1. 通常のシリアライズでバイトストリームに変換
$serialized = serialize($data);
// 2. 秘密鍵をベースにHMAC-SHA256署名を計算
$signature = hash_hmac(‘sha256’, $serialized, self::$secretKey, true);
// 3. 署名とシリアライズデータを結合してベースポンencode(バイナリ安全のため)
return base64_encode($signature . $serialized);
}
/
- 署名を検証してから安全にデシリアライズ
/
public static function unserialize(string $payload): mixed {
$decoded = base64_decode($payload, true);
if ($decoded === false || strlen($decoded) < 32) {
throw new \RuntimeException('不正なペイロードフォーマットです。');
}
// 4. 先頭の32バイト(SHA-256のバイナリサイズ)を署名として切り出す
$signature = substr($decoded, 0, 32);
$serialized = substr($decoded, 32);
// 5. タイミング攻撃を防ぐ安全なハッシュ比較(hash_equals)
$expectedSignature = hash_hmac('sha256', $serialized, self::$secretKey, true);
if (!hash_equals($expectedSignature, $signature)) {
// 署名が一致しない=データが改ざんされている、または別のデータにすり替えられた
throw new \SecurityException('データの署名検証に失敗しました。オブジェクトインジェクションの可能性があります。');
}
// 6. 検証済みの安全なデータだけをZend VMに渡す
return unserialize($serialized);
}
}
// --- 使用例 ---
try {
// ユーザーデータを安全にシリアライズしてクッキーやDBに保存する想定
$userData = ['user_id' => 42, ‘role’ => ‘editor’];
$safePayload = SecureSerializer::serialize($userData);
// 復元
$restoredData = SecureSerializer::unserialize($safePayload);
print_r($restoredData);
} catch (\Throwable $e) {
// 攻撃検知時のハンドリング
error_log($e->getMessage());
// ログに記録し、処理を中断する
}
このアプローチの美しさは、「Zend VMに到達する前に、不正なバイト列を完全に排除できる」点にあります。
仮に攻撃者が巧妙なガジェットチェーンのペイロードを外部から送り込んできたとしても、アプリケーション側で保持している秘密鍵と一致する正しいHMAC署名を作ることができないため、`hash_equals()` のチェックで即座に弾かれます。
—
4. アーキテクトからのメッセージ
PHPの進化はめざましく、最新のバージョンではパフォーマンスも型システムも劇的に洗練されました。しかし、C言語で書かれたZend Engineの「メモリを直接解釈する」という根本的な挙動は今も変わりません。
フレームワークが裏側で何をしてくれているのか、`unserialize()` やマジックメソッドが呼ばれたときにメモリ上で何が起きているのか。その「一段下のレイヤ」を脳内トレースできるようになると、あなたの書くコードの安全性とパフォーマンスは次元の違うものになります。
「動くからいいや」ではなく、「なぜ安全なのか」を自分の言葉で説明できるエンジニアへ。
今回の知見が、皆さんのアーキテクチャ設計における確かな盾となれば幸いです。
それでは、また次の深淵でお会いしましょう。