悪夢のガジェットチェーン:Zend VMとオブジェクトインジェクションの物理的破壊
コードレビューの現場で、次のようなコードを見たことはないか?
// 最悪のアンチパターン:外部入力をそのままデシリアライズする
$userSession = unserialize($_COOKIE[‘session_data’]);
この一行は、Webアプリケーションにおける「時限爆弾」だ。PHPの `unserialize()` は、単なるデータの復元機能ではない。Zend Engineの内部において、任意のクラスのインスタンスを生成し、そのライフサイクル(`__wakeup`, `__destruct` 等)に割り込むための「実行権限の強奪装置」として機能する。
今回は、Zend VMのメモリ構造とオブジェクトのライフサイクルという低レイヤの視点からオブジェクトインジェクション(ガジェットチェーン)の本質を解き明かし、実務で絶対に破られない堅牢な防御アーキテクチャを構築する方法を伝授する。
—
1. Zend VM内部における `unserialize()` の脅威
PHPのシリアライズデータは、文字列化されたオブジェクトのブループリント(設計図)だ。
Zend VMが `unserialize()` を実行するとき、内部では次のような処理が走る。
1. トークナイゼーション: 文字列をパースし、クラス名やプロパティの型、値を特定する。
2. クラスの存在確認: 該当するクラスがメモリ上にロードされているか(オートローダーの起動含む)確認する。
3. インスタンスの生成: `zend_class_entry` を元に、コンストラクタ(`__construct`)を一切バイパスしてオブジェクトのメモリ領域(`zval`)をヒープ上に確保する。
4. プロパティの復元: 保持していた値を各プロパティに直接書き込む(`protected` や `private` のスコープさえも無視して)。
5. マジックメソッドの呼び出し: `__wakeup()` や `__unserialize()` が定義されていれば、それを実行する。
コンストラクタを通さずにオブジェクトが生成されるという点が、セキュリティ上の最大の肝だ。バリデーションロジックが組み込まれたコンストラクタは完全に無視され、攻撃者が意図した不正な状態(State)を持ったオブジェクトがメモリ上に直接構築される。
ガジェットチェーンとは何か?
単一のオブジェクトを不正生成するだけでは、被害は限定的かもしれない。しかし、アプリケーション内に存在する無害なクラス群のパーツ(ガジェット)を組み合わせ、次のようなマジックメソッドを連鎖させることで、リモートコード実行(RCE)や任意のファイル削除といった致命的な攻撃に昇華させる手口をガジェットチェーンと呼ぶ。
- `__destruct()`: スクリプト終了時やガベージコレクション(GC)時に必ず呼ばれる。
- `__toString()`: オブジェクトが文字列として扱われたときに呼ばれる。
- `__call()`: 存在しないメソッドが呼ばれたときに呼ばれる。
攻撃者は、ファイルシステムを操作するクラスや、動的にメソッドを呼び出すクラスを組み合わせ、デシリアライズ完了から破棄までの間に意図した処理を実行させる。
—
2. 徹底的な防御:HMAC署名によるシリアライズデータの保護
「信頼できないデータを `unserialize()` しない」というのは大原則だが、セッション管理やキューのペイロードなどで、どうしてもPHPのネイティブシリアライズ構造を扱わなければならないシーンもある。
その場合、データの「完全性(Integrity)」と「認証(Authenticity)」を暗号学的に保証しなければならない。単なるBase64エンコードや難読化では、攻撃者に簡単に推測・改ざんされる。
以下に、実務のプロダクション環境でそのまま使える、HMAC-SHA256署名付きセキュア・シリアライザのリファレンスコードを示す。
セキュア・シリアライズ実装例
declare(strict_types=1);
namespace Security\Serialization;
class SecureSerializer
{
private string $secretKey;
private string $cipherAlgo;
/
- @param string $secretKey アプリケーションごとに一意な強固な秘密鍵(ENV等から注入)
- @param string $cipherAlgo 使用する暗号アルゴリズム
/
public function __construct(string $secretKey, string $cipherAlgo = ‘aes-256-gcm’)
{
if (mb_strlen($secretKey, ‘8bit’) < 32) {
// Zend VMのメモリ安全性と同様、鍵の長さに妥協は許さない
throw new \InvalidArgumentException('Secret key must be at least 32 bytes long.');
}
$this->secretKey = $secretKey;
$this->cipherAlgo = $cipherAlgo;
}
/
- データをシリアライズし、暗号化とHMAC署名を付与する
- @param mixed $data
- @return string
/
public function serialize(mixed $data): string
{
// 1. 標準の安全なシリアライズ
$serialized = serialize($data);
// 2. AES-GCMによる暗号化(機密性の確保)
$ivLength = openssl_cipher_iv_length($this->cipherAlgo);
$iv = random_bytes($ivLength);
$tag = ”;
$encrypted = openssl_encrypt(
$serialized,
$this->cipherAlgo,
$this->secretKey,
OPENSSL_RAW_DATA,
$iv,
$tag
);
if ($encrypted === false) {
throw new \RuntimeException(‘Encryption failed: ‘ . openssl_error_string());
}
// 3. IV, 暗号文, Auth Tagを結合
$payload = json_encode([
‘iv’ => base64_encode($iv),
‘ciphertext’ => base64_encode($encrypted),
‘tag’ => base64_encode($tag),
]);
// 4. 改ざん検知のためのHMAC-SHA256署名を付与(完全性の確保)
$signature = hash_hmac(‘sha256’, $payload, $this->secretKey, true);
// ペイロードと署名をまとめてBase64URLエンコードして返す
return $this->base64UrlEncode($signature . $payload);
}
/
- 署名を検証し、安全にデシリアライズを行う
- @param string $signedPayload
- @return mixed
/
public function unserialize(string $signedPayload): mixed
{
$raw = $this->base64UrlDecode($signedPayload);
if ($raw === false) {
throw new \SecurityException(‘Invalid encoding format.’);
}
// 署名長(SHA-256は32バイト)の切り出し
$signatureLength = 32;
if (mb_strlen($raw, ‘8bit’) < $signatureLength) {
throw new \SecurityException('Payload too short.');
}
$signature = mb_substr($raw, 0, $signatureLength, '8bit');
$payload = mb_substr($raw, $signatureLength, null, '8bit');
// 恒等時間比較(Timing Attackを防ぐ)
$expectedSignature = hash_hmac('sha256', $payload, $this->secretKey, true);
if (!hash_equals($expectedSignature, $signature)) {
// 署名不一致は改ざんまたは鍵違いの証拠
throw new \SecurityException(‘Integrity violation: HMAC signature mismatch.’);
}
$data = json_decode($payload, true);
if (!isset($data[‘iv’], $data[‘ciphertext’], $data[‘tag’])) {
throw new \SecurityException(‘Malformed payload structure.’);
}
$iv = base64_decode($data[‘iv’], true);
$ciphertext = base64_decode($data[‘ciphertext’], true);
$tag = base64_decode($data[‘tag’], true);
// 復号化処理
$decrypted = openssl_decrypt(
$ciphertext,
$this->cipherAlgo,
$this->secretKey,
OPENSSL_RAW_DATA,
$iv,
$tag
);
if ($decrypted === false) {
throw new \SecurityException(‘Decryption failed.’);
}
// 5. 【重要】型ホワイトリストを適用した安全なデシリアライズ
// クライアントからのデータにはオブジェクトの混入を原則として許可しない
$unserialized = unserialize($decrypted, [‘allowed_classes’ => false]);
return $unserialized;
}
private function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), ‘+/’, ‘-_’), ‘=’);
}
private function base64UrlDecode(string $data): string|false
{
$remainder = mb_strlen($data, ‘8bit’) % 4;
if ($remainder) {
$data .= str_repeat(‘=’, 4 – $remainder);
}
return base64_decode(strtr($data, ‘-_’, ‘+/’), true);
}
}
—
3. `unserialize()` のオプションによる最後の防衛線
PHP 7.0以降、`unserialize()` には第2引数として `allowed_classes` オプションが導入されている。これを利用することで、Zend VMがインスタンス化を許可するクラスを厳密に制限できる。
// 例:特定のクラス(DTOなど)のみデシリアライズを許可する場合
$data = unserialize($payload, [
‘allowed_classes’ => [
UserSessionDto::class,
AuthTokenDto::class
]
]);
もし `allowed_classes` に `false` を指定した場合、シリアライズデータ内に含まれていたすべてのオブジェクトは自動的に `__PHP_Incomplete_Class` というプレースホルダー(ただの空の破綻したオブジェクト)に変換される。これにより、ガジェットチェーンの起動を根本から断つことが可能だ。
現代のWebアプリケーションにおいて、任意のオブジェクトグラフをそのまま永続化層やトランスポート層でやり取りする設計自体が、アーキテクチャ上の負債である。
—
テクニカルリードからの最終提言
1. JSONなどのデータフォーマットへ移行せよ: そもそも `serialize()` / `unserialize()` を業務コードで使う必然性は現代のPHPではない。JSONやMessagePackなど、純粋な「データ構造」のみを扱うシリアライザを使用せよ。
2. マジックメソッドに依存したライフサイクルを排除せよ: `__destruct()` や `__wakeup()` の中で外部I/Oや複雑なロジックを回す設計自体が、バグや脆弱性の温床になる。
3. フレームワークの内部実装を過信しないこと: 古いサードパーティ製ライブラリやORMの一部には、未だに危険なデシリアライズを行っているものが存在する。Composerの依存関係も含めて常に監査の目を光らせろ。
Zend VMの挙動を熟知していれば、「なぜこのコードがセキュリティホールになるのか」がメモリレベルでクリアに見えるはずだ。セキュアなコードとは、単に動くコードではなく、悪意ある入力に対して壊れない要塞のようなコードを指す。今すぐプロジェクト内の `unserialize` を全検索し、適切な対策が講じられているか確認してほしい。