PHPオブジェクトのシリアライズ・デシリアライズにおけるメモリ確保の最適化とZend VMの暗部
PHPにおいて `serialize()` と `unserialize()` は、永続化やプロセス間通信(IPC)の根幹を支える極めて強力なプリミティブである。しかし、この一見枯れた関数群の裏側では、Zend Engineのメモリアロケータ(Zend MM)、HashTableの動的再構築、そして参照カウンタの精密な舞蹈が繰り広げられている。
本稿では、PHPオブジェクトの直列化・非直列化がZend VMおよびZend Engineの内部でどのようにメモリを蝕み、あるいは効率化しているのかを、C言語レベルの低レイヤ視点から解き明かす。さらに、このメカニズムの歪みを突いたオブジェクトインジェクションの極限と、OPcacheプリローディング環境下におけるメモリ空間の物理構造まで、一切の妥協を排して踏破する。
—
1. Zend VM内部における `serialize()` と `unserialize()` の実態
PHPスクリプト上で `serialize($obj)` を実行した瞬間、Zend VMはオペコード `ZEND_DO_FCALL` を経由してC言語レベルの内部関数 `php_serialize_data_init()` を呼び出す。
メモリ確保とバッファの動的伸長
シリアライズの本質は、任意のZend値(`zval`)のグラフを、線形なバイトストリーム(文字列)へ変換することだ。この過程で、Zend Engineは `smart_str`(可変長文字列バッファ)を使用する。
/ ext/standard/serialize.c の概念的構造 /
typedef struct {
char c;
size_t a; // アロケートされたサイズ
size_t len; // 現在の長さ
} smart_str;
1. 再帰的走査と `zval` の検査: エンジンは `zval` の型タグ(`IS_OBJECT`, `IS_ARRAY` 等)を判定し、オブジェクトであればそのクラスエントリー(`zend_class_entry`)からプロパティテーブルを引く。
2. チャンク単位のメモリ確保: `smart_str` は、メモリ断片化(Fragmentation)を防ぎつつ高速なアロケーションを行うため、必要に応じて2倍のキャパシティ拡張(Exponential Growth)を繰り返しながらバッファを伸長する。
3. `__sleep()` と `__serialize()` の介入: オブジェクトにこれらのマジックメソッドが定義されている場合、Zend VMは一時的なスタックフレームを構築し、ユーザースクリプトへ実行権を委譲する。この際、戻り値として生成された配列を再度 `zval` ツリーに組み込むため、メモリの二重アロケーションや不要な参照カウントのインクリメントが発生する。
—
2. `unserialize()` における HashTable の爆発とメモリ空間の再構築
デシリアライズ(`unserialize()`)は、シリアライズよりも遥かに重厚な処理を要求する。バイトストリームをパースし、メモリ上に再び `zval` のグラフを構築するからだ。
Zend Memory Manager (Zend MM) とヒープの断片化
`unserialize()` が実行されると、Zend MMはヒープ領域から細切れのメモリブロックを大量に切り出す。
各プロパティ名(文字列)はシンボルテーブルまたは内部のグローバルな文字列テーブル(Interned Strings)と突合され、存在しない場合は新たにヒープ上にアロケートされる。
特に巨大な配列や深くネストしたオブジェクト構造をアンシリアライズする場合、以下のボトルネックが顕在化する。
- HashTableの再ハッシュ化(Re-hashing): プロパティが追加されるたびに、`zend_hash_add()` や `_zend_hash_op()` が呼び出され、バケツ(Bucket)の衝突解決とポインタの付け替えが発生する。
- 循環参照のトラッキング: `unserialize()` 中も、ポインタの指し先が循環している可能性を考慮し、参照解決のためのポインタマップが一時的にメモリ上に構築される。
/
$payload = get_huge_serialized_payload(); // 数十MBの文字列
// メモリのピークを監視
$start_memory = memory_get_usage(true);
$obj = unserialize($payload);
$peak_memory = memory_get_peak_usage(true);
echo “Base: ” . number_format($start_memory) . ” bytes\n”;
echo “Peak: ” . number_format($peak_memory) . ” bytes\n”;
このコードが実行される時、Zend Engine内部では数百万回の `emalloc()` と `efree()` が発生し、CPUキャッシュヒット率の低下を招く。これを回避するためには、後述するOPcacheプリローディングや、データ構造自体の軽量化(DTOの採用など)が不可欠となる。
—
3. OPcacheプリローディングとシリアライズデータの物理構造
PHP 7.4以降で導入された OPcache Preloading は、スクリプトのロード・コンパイルフェーズを起動時に一度だけ行い、その結果を共有メモリ(Shared Memory: SHM)に常駐させる技術である。
ここで重要なのは、「プリロードされたクラスの定義(`zend_class_entry` やオペコード配列)」は共有メモリ上に固定化されるが、インスタンス化されたオブジェクトのプロパティや `zval` はリクエストごとのプロセス空間(ヒープ)に存在し続けるという点だ。
プリロード環境下でのオブジェクトキャッシュの罠
しばしば「よく使うオブジェクトをシリアライズしてファイルやSHMにキャッシュし、リクエスト時に `unserialize()` すれば高速化する」という誤った最適化論が囁かれる。しかし、低レイヤの視点ではこれは悪手となり得る。
1. ポインタの不整合: シリアライズされたデータ内には、文字列やクラス名への参照が含まれている。これらをアンシリアライズすると、共有メモリ上の文字列を参照するのではなく、プロセス個別のヒープ上に新規にメモリがアロケートされる。
2. コピーオンライト(COW)の破壊: 配列や文字列の大部分がヒープ上で複製されるため、実質的なメモリ消費量が増加し、FPMプロセスのメモリフットプリントが肥大化する。
—
4. Fiberによる並行処理とシリアライズ文脈の衝突
PHP 8.1で導入された Fiber は、スタックレス(実際にはコールスタックを独立して保持する)な協調的並行処理を実現する。Zend VMは、Fiberのコンテキストスイッチ時に実行コンテキスト(`zend_execute_data`)を退避・復元する。
もし、あるFiberの実行中に大規模な `serialize()` または `unserialize()` が走っており、その最中に yield が発生した場合(※通常のPHPコードではユーザーランドから直接yieldを挟むことは稀だが、非同期I/Oや拡張モジュール経由でブロックが発生しうる)、Zend VMの状態管理はどうなるか?
- エンジンは再入可能(Re-entrant)ではない: `serialize` の内部ステートマシンはグローバルまたは関数スコープのスタックに依存しており、Fiber間で同一のシリアライズコンテキストを共有することはメモリ破壊(Segmentation Fault)を引き起こす。
- 並行処理におけるデータ分離: Fiberを駆使した非同期ワーカー間でデータを共有する際、グローバルなオブジェクトを直接渡すことはできないため、シリアライズを介したメッセージパッシングが行われる。この際、シリアライズ処理そのものがブロッキング処理となり、イベントループのレイテンシを悪化させる最大の要因となる。
—
5. セキュリティハック:オブジェクトインジェクションとGadget Chainのメカニズム
メモリ管理とZend VMの挙動を極限まで理解した者にとって、`unserialize()` は単なるデータ復元機能ではなく、「任意のコード実行(RCE)を引き起こすための強力なプリミティブ」へと変貌する。これが PHP Object Injection である。
脆弱性の本質:マジックメソッドの自動実行
`unserialize()` は、復元されたオブジェクトのクラスに `__wakeup()` や `__destruct()` が定義されている場合、デシリアライズ完了時やスクリプト終了時に自動的にそれらのメソッドを呼び出す。
Zend VMの視点から見ると、これは「任意の型としてマークされたオブジェクトに対し、特定の名前を持つ関数ポインタ(メソッドエントリ)を強制的にディスパッチする」挙動に他ならない。
/
class Logger {
protected $logFile;
protected $logData;
// デストラクタがトリガーされると、意図しないファイル書き込みが発生する
public function __destruct() {
file_put_contents($this->logFile, $this->logData);
}
}
// 攻撃者が細工したペイロードを投入
// $payload = ‘O:6:”Logger”:2:{s:7:”logFile”;s:11:”/var/www/html/shell.php”;s:7:”logData”;s:20:”“;}’
// unserialize($payload);
ガジェットチェーン(Gadget Chain)の構築
単一のクラスだけでRCEに到達できない場合、複数の既存クラス(フレームワークやライブラリの内部クラス)を連鎖させる「ガジェットチェーン」が構築される。
1. 起点(Sink): `__wakeup()`, `__destruct()`, `__toString()` などの自動実行されるマジックメソッド。
2. 中継地点: プロパティの型不一致を利用したマジックメソッド(`__get()`, `__call()` 等)の誘発。
3. 終着点(RCE/LFI): `eval()`、システムコマンド実行関数、任意のファイル書き込みを行うメソッドへの到達。
Zend VMのメモリ空間において、攻撃者はシリアライズされたバイトストリームを巧みに改ざんすることで、本来存在し得ないプロパティの型(例:文字列を期待している箇所にオブジェクトを挿入する)を強制的に注入し、エンジンを欺くのだ。
防御の極意
この脆弱性を根本から断つには、現代のPHPアプリケーションでは以下の原則を徹底しなければならない。
- `unserialize()` に信頼できない入力を絶対に渡さない: 代わりに `json_decode()` を使用する。JSONはデータの表現に留まり、オブジェクトのインスタンス化やマジックメソッドの自動実行を一切行わないため、ガジェットチェーンの起点を完全に排除できる。
- `allowed_classes` オプションの厳格な利用: どうしても `unserialize()` を避けられない場合は、必ず `allowed_classes` を指定してホワイトリスト方式をとること。
// 許可されたクラス以外は ‘__PHP_Incomplete_Class’ に変換し、マジックメソッドの暴走を防ぐ
$data = unserialize($payload, [“allowed_classes” => [App\DTO\UserDTO::class]]);
—
結びにかえて
PHPオブジェクトのシリアライズとデシリアライズは、利便性の裏にZend VMの複雑なメモリ管理とリスクを内包している。
スマートストラットによる動的なメモリ確保、HashTableの再構築コスト、そしてマジックメソッドの自動ディスパッチという仕様の隙間。これらを低レイヤの文脈から正確に把握して初めて、真に堅牢で高速なWebシステムを設計・実装することが可能となる。
コードの表面的な美しさにとどまらず、Zend Engineの心臓部で何が起きているのかを常に想像せよ。それこそが、最高峰のWebシステムアーキテクトに求められる唯一の視座である。