PHPコアの深淵:`unserialize()`のメモリ動的割当とオブジェクトインジェクションの低レイヤ防衛網
PHPという言語は、その極めて高い開発生産性の裏で、Zend Engineという極めて精巧な仮想マシンが稼働している。1つのHTTPリクエストがSAPI(Server API)を通過してZend VMに到達した瞬間から、メモリ空間では膨大な数の `zval`(Zend Value)構造体が生成され、破棄されていく。
とりわけ、外部から入力されるバイナリデータや文字列をオブジェクトのグラフ構造へと復元する `unserialize()` は、Zend VMのメモリ管理機構とセキュリティ境界において最も高負荷であり、かつ危険な領域の一つである。
本稿では、`unserialize()` が内部のC言語レベル(Zend Engine)でどのようにメモリを確保し、どのようなメカニズムでオブジェクトインジェクション(PHP Object Injection)という致命的な脆弱性を引き起こすのか。そして、それをOPcacheやモダンなセキュアコーディング手法によっていかにしてねじ伏せるのかを、低レイヤの視点から徹底的に解剖する。
—
1. `unserialize()` 実行時のZend VMメモリ構造と `HashTable` の動的構築
PHPの変数はすべて、8バイトの `zval` 構造体として表現される。スカラー値であればその値は `zval` の内部にインラインで格納されるが、オブジェクトや配列といった複合データ型は、ヒープメモリ上に別途確保された実体へのポインタを保持する。
`unserialize(string $data)` がコールされたとき、Zend Engineは以下の低レイヤ処理を直列実行する。
1. ストリームの字句解析(Lexing)とパース:
入力されたシリアライズ文字列(例: `O:8:”stdClass”:0:{}`)を、Zend Engine内部のシリアライズパーサがバイト単位で読み解く。
2. シンボルテーブルとクラスのエントリ解決:
指定されたクラス名(`stdClass` など)が、現在実行中のプロセス(またはOPcacheのシンボルテーブル)に存在するかどうかを、EG(Executor Globals)の機能を使って `CG(class_table)`(HashTable)から O(1) またはハッシュ衝突時の O(N) でルックアップする。
3. オブジェクトの領域確保(`zend_objects_new`):
クラスが特定されると、そのクラスが定義するプロパティ数に応じたメモリ領域がヒープ上に確保される。ここで `zend_object` 構造体が初期化され、プロパティを格納するための `HashTable`(Properties Table)が動的に割り当てられる。
/ Zend Engine 内部におけるオブジェクト生成の概念的イメージ (C言語ライクな疑似表現) /
zend_object zend_objects_new(zend_class_entry ce) {
// クラスサイズ + プロパティ用ストレージのメモリをエフェクティブに確保
zend_object object = emalloc(ce->create_object ? sizeof(zend_object) : 実際のオブジェクトサイズ);
zend_object_std_init(object, ce);
object.handlers = &std_object_handlers;
// プロパティ格納用HashTableの初期化
object_properties_init(object, ce);
return object;
}
このプロセスにおいて、シリアライズデータ内に悪意ある構造が仕込まれていた場合、Zend VMは「正当なデータ構造の復元」と「悪意あるコードパスへの誘導」の区別を失う。これがオブジェクトインジェクションの温床となる。
—
2. オブジェクトインジェクションとGadget Chainのメカニズム
PHPオブジェクトインジェクションは、単に「任意のクラスを復元できる」というだけでは終わらない。真の脅威は、デシリアライズの完了時、あるいはオブジェクト破棄のライフサイクルにおいて自動的に呼び出されるマジックメソッド(Magic Methods)にある。
特に以下のメソッド群は、アタッカーにとって格好の踏み台(Gadget)となる。
- `__wakeup()` または `__unserialize()`: デシリアライズ直後に実行される。
- `__destruct()`: スクリプト終了時、またはオブジェクトの参照カウントが `0` になりガベージコレクション(GC)の対象となった瞬間に実行される。
- `__toString()`: オブジェクトが文字列として評価される際に強制実行される。
ガジェットチェイン(Gadget Chain)の構築
アタッカーは、アプリケーションが依存しているサードパーティ製ライブラリやフレームワークのソースコードを解析し、「プロパティの値を書き換えることで、意図しないメソッド呼び出しを引き起こせるクラスの連鎖」を探す。
以下は、脆弱なコードベースと、それに対する概念的な攻撃ベクトルの構造である。
/
class Logger {
protected $logFile;
protected $logData;
// デストラクターでファイルを書き込む危険な実装
public function __destruct() {
if ($this->logFile) {
file_put_contents($this->logFile, $this->logData);
}
}
}
// 外部からの入力を十分な検証なしにデシリアライズしている致命的な箇所
$userInput = $_COOKIE[‘user_session’] ?? ”;
$data = unserialize($userInput);
もしアタッカーが以下のようなシリアライズ文字列をCookieに注入した場合、何が起きるか。
O:6:”Logger”:2:{s:8:”logFile”;s:19:”/var/www/html/shell.php”;s:7:”logData”;s:23:”“;
1. `unserialize()` によって `Logger` クラスのインスタンスがメモリ上に強制生成される。
2. その際、プロパティ `$logFile` には攻撃者指定のパスが、`$logData` にはWebシェルコードが直接代入される。
3. リクエストのライフサイクルが終了し、Zend VMがスクリプトのシャットダウンシーケンスに入ると、生成されたオブジェクトの参照カウントがゼロになり `__destruct()` が発動する。
4. 結果として、意図しない任意のファイルがシステム上に書き込まれ、RCE(Remote Code Execution)が完成する。
これは、Zend VMが「メモリ上にバイト列を正しく復元した」という忠実な仕事の結果であり、エンジン自体にはバグではなく、アプリケーション層のロジックの破綻である。しかし、このメモリ復元プロセスを言語仕様・VMレベルでどう安全に制御するかというアプローチが極めて重要になる。
—
3. Zend VMレベルでの対策と `allowed_classes` の強制
PHP 7.0以降、およびPHP 8.x系において、`unserialize()` にはセキュリティを担保するための堅牢なオプションが導入されている。それが第2引数である `$options` 配列の `allowed_classes` キーである。
// 安全なデシリアライズの実装パターン
$data = unserialize($userInput, [
‘allowed_classes’ => [App\Entity\UserPreference::class]
]);
内部でのチェック機構
Zend VMがこの `allowed_classes` をどのように処理しているか。C言語レベルのソースコード(拡張モジュールやコアの `ext/standard/var.c`)を覗くと、`unserialize()` のパース処理中にクラス名がホワイトリストに存在するかどうかの厳密な比較(`zend_hash_find`)が行われている。
- `allowed_classes` が `false` の場合:すべてのオブジェクトは強制的に `__PHP_Incomplete_Class` というプレースホルダーオブジェクトに変換される。これにより、マジックメソッドの実行トリガーを完全に遮断する。
- `allowed_classes` が配列の場合:リストに含まれないクラスが出現した瞬間、パース処理を中断し、警告(Warning)を発生させるとともに `false` を返す。
この仕組みにより、仮にアタッカーが任意のシリアライズデータを送り込んでも、想定外のクラス(Gadget)がメモリ上にインスタンス化されることを物理的に阻止できる。
—
4. 代替シリアライズフォーマットとしての JSON と Native Protocol の比較
現代の高負荷Webシステムアーキテクチャにおいて、オブジェクトグラフのシリアライズにPHPネイティブの `serialize()` を使うことは、セキュリティリスクの面だけでなく、パフォーマンス(メモリ効率)の観点からも推奨されない。
1. メモリフットプリントとCPUコスト
PHPネイティブのシリアライズ形式は、型の情報やプロパティのアクセス修飾子(`private` プロパティのクラス名マングリングなど)を保持するため、メタデータが冗長になりがちである。結果として、Zend VMがパースする際のCPUサイクルが増大する。
2. JSON(`json_encode` / `json_decode`)の優位性
一方、`ext/json` によるシリアライズは、純粋なデータ構造(配列とスカラー値)のみを対象とするため、オブジェクトのインジェクション脆弱性が原理的に発生しない。
// 安全かつ高速なデータ転送の標準
$payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
// オブジェクトとして復元したい場合は、連想配列から明示的にバインドする
$arrayData = json_decode($jsonInput, associative: true, flags: JSON_THROW_ON_ERROR);
$user = new User($arrayData[‘id’], $arrayData[‘name’]);
このアプローチでは、Zend VMは「未検証のバイナリからオブジェクトを自動構築する」という危険な責務から解放され、開発者が明示的に定義したコンストラクタを経由して安全にメモリ領域を初期化できる。
—
5. OPcacheプリローディングとクラス定義のイミュータブル化
シリアライズされたデータを安全にデシリアライズするためには、復元先のクラス定義が改ざんされておらず、かつ高速に解決される必要がある。ここで現代のPHPパフォーマンスの要である OPcache Preloading が深く関わってくる。
PHP 7.4で導入されたプリローディングは、サーバー起動時(`php-fpm` のプロセス立ち上げ時)に指定したスクリプト群を読み込み、それらを永続的な共有メモリ(Shared Memory)上に「イミュータブル(変更不可)」な状態でコンパイル・配置する機能である。
; php.ini でのプリローディング設定
opcache.preload=/var/www/html/config/preload.php
opcache.preload_user=www-data
プリロードされたクラスの `zend_class_entry` は、リクエストごとのプロセス空間の汚染から完全に隔離され、シンボルルックアップのオーバーヘッドが極限まで削減される。
これにより、`unserialize()` が実行された際のエントリ解決が高速化されるだけでなく、攻撃者が動的にクラス定義を書き換えるといったメモリ上の改ざん攻撃に対する耐性も間接的に向上する。
—
総括:Webシステムアーキテクトとしての提言
`unserialize()` は、PHPという言語が持つ「動的言語としての柔軟性」の象徴であると同時に、低レイヤのメモリ管理とセキュリティの境界線を最も曖昧にする諸刃の剣である。
アーキテクトとしてシステム設計を行う際、以下の鉄則をチーム全体に強制すべきである。
1. 外部からの入力に対して `unserialize()` を直接使用しないこと。 どうしてもシリアライズが必要な場合は、HMAC等による暗号学的署名(Integrity Check)を必ず伴わせるか、JSON等の安全なデータ交換フォーマットへ完全に移行すること。
2. プレビューやレガシーシステムの都合で利用せざるを得ない場合は、必ず `allowed_classes` を厳格にホワイトリスト方式で定義し、未検証のオブジェクトインスタンス化をZend VMレベルで拒絶すること。
3. アプリケーションの依存関係(Composerパッケージ群)を常に最新に保ち、既知のGadget Chainを含むライブラリが環境内に存在しない状態を維持すること。
PHPコアの挙動、Zend VMのメモリ確保、そしてシリアライズのメカニズムを真に理解した者だけが、堅牢かつスケーラブルなWebシステムを構築できる。コードの表面をなぞるだけのエンジニアを卒業し、エンジンの鼓動を聞きながらアーキテクチャを設計せよ。