Skip to content

About

No description or website provided.

Topics

Resources

Stars

9 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

php2shell — PHP 8.4.26 session 漏洞复现

中文 | English


中文

在“完全远程、只用 HTTP、目标页面无 session_start()、不写任何目标侧 PHP”的约束下,用一个 exploit.py 打通 PHP 8.4.26 + Apache prefork + mod_php 的 RCE。目标页面只有 <?php echo "OK\n"; ?>。

支持范围(先读)

唯一支持的运行环境是官方 php:8.4.26-apache 镜像(PHP 8.4.26 / Debian 12 / Apache prefork + mod_php)配合本仓库 docker/ 下的配置。

这不是一个通用的 ASLR bypass,也没有关闭 ASLR:

  • 利用过程只通过两次越界读泄漏 Zend MM chunk base 和 libphp base,不泄漏 libc、栈或完整内存映射。
  • 地址空间仍然由 ASLR 随机化。宿主机内核 ASLR(kASLR)、容器运行时、内核 mmap 行为、分配器以及 PHP/Apache 的具体构建都会影响堆布局和泄漏结果。
  • 链中的堆几何、标定值和 libphp 偏移是针对官方镜像实测的,不包含地址扫描或暴力枚举。换镜像、换 PHP 构建或换发行版后,即使地址仍能泄漏,后续伪造对象的布局也不一定成立。

Apache 侧也有硬性前提:

  • 必须是 prefork MPM + 单 worker + mod_php。子进程 abort 后,父进程直接 fork 出新 worker,不重新 exec,因此 libphp 映射和各阶段依赖的地址空间得以保留。
  • docker/apache.conf 固定 StartServers/MinSpareServers/MaxSpareServers/MaxRequestWorkers/ServerLimit = 1,MaxConnectionsPerChild = 0,并关闭 KeepAlive。
  • 多 worker、event/worker MPM、Apache 重启/优雅重启、worker 重新 exec、容器重建或父进程退出,都可能重新随机化地址或让请求落到不同进程,整条链会失效。
  • 如果任一阶段泄漏失败,ASLR 仍然有效;重试只是换一个 worker,并不会“击败”ASLR。

当前 RCE 还要求:display_errors=1、memory_limit=8M、max_input_vars=1000、session.use_cookies=1、session.use_strict_mode=0、session.upload_progress.enabled=1。

一键复现

本仓库不编译 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 轮换、两阶段泄漏、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
页面 仅 <?php echo "OK\n"; ?> docker/www/index.php

官方镜像仓库: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'

常用参数:--cmd、--proof-file、--fetch、--zif-offset、--dtor-offset、--htdata-offset、--no-pre-rotate、--retries。--offsets-file '' 可关闭自动读取;容器名不是 php2shell-target 时用 --docker-container 指定。

官方镜像的堆标定已内置: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。

机制概览

漏洞面是 session.upload_progress 在 RINIT 之前触发的 session 初始化,以及 OOM bailout 后跨请求残留的悬空指针。整条链的攻击流量全部是普通 multipart HTTP,目标页面只有 <?php echo "OK\n"; ?>:

  1. worker 轮换:A_bigfields(3MB 存活 POST 字段 + 12MB OOM)后紧跟一个会初始化 session 的 B,child 以 SIGABRT 退出;prefork fork 出的新 worker 继承相同的 libphp/chunk 映射。

  2. 堆泄漏: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 用填充字段重占旧 reference,并用存活的 zend_string 承载 fake HashTable;php_url_encode() 越界读把 Zend MM chunk 指针写进 Set-Cookie。

  3. libphp 泄漏:A 把悬空 PS(id) 放到目标 offset;B 的 HashTable 页让 ZSTR_LEN(PS(id)) 读到 nTableSize=16,数据区正好是 nNextFreeElement|pDestructor;pDestructor = ZVAL_PTR_DTOR = libphp+dtor_offset,从 Set-Cookie 解出 libphp base。

  4. 命令执行:A 用 pad 把悬空 reference 固定到目标位置;B 用 raw buffer / carrier 覆盖 reference,fake HashTable 放在 chunk+0x690d8 的 167B 存活字符串里,pDestructor = zif_system = libphp+0x465370。触发链:

    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。

ASLR、kASLR 与 Apache 限制

泄漏内容:

  • 阶段 1 的 Set-Cookie 越界读泄漏 Zend MM chunk base(按 2MiB 对齐取众数),后续用于计算 HTDATA = chunk + 0x690d8。
  • 阶段 2 的 Set-Cookie 越界读泄漏 pDestructor 指针,减去 ZVAL_PTR_DTOR 偏移后得到 libphp base,后续用于计算 zif_system = libphp + 0x465370。
  • 不需要 libc base、stack 或完整 maps。

ASLR 没有被击败:

  • ASLR 始终开启;利用只是泄漏了当前进程需要的两个运行时地址,属于 per-process 的信息泄漏绕过,不是通用绕过。
  • 没有暴力扫描、没有重启爆破、没有依赖 /proc。地址泄漏失败时链就失败,ASLR 仍然有效。
  • 由于标定依赖官方镜像的确切堆几何和 libphp 构建,只支持官方 php:8.4.26-apache 镜像。kASLR、宿主机/容器运行时、内核 mmap 行为、分配器版本、PHP/Apache 重新构建等都可能改变布局,不能把该链当作跨镜像的 ASLR 利用。

Apache 问题:

  • 链要求 prefork + 单 worker + mod_php。worker abort 后由父进程 fork 新 child,映射继承,因此阶段之间地址空间不变;这是整条链成立的关键。
  • 多 worker 会让不同请求落到不同进程;event/worker MPM 不适用;Apache 重启、优雅重启、父进程退出、容器重建或重新 exec 都可能重新随机化地址。
  • MaxConnectionsPerChild 非 0、外部进程管理器强制重启、或任何改变 worker 生命周期/映射的配置,都可能破坏 stale pointer 的跨请求复用。
  • 因此,README 中的 Docker 配置不是“推荐配置”,而是复现条件的一部分。

复现声明

  • 一个 exp 复现全部:python3 exploit.py -u ... 一条命令完成 worker 轮换、堆泄漏、libphp 泄漏和 zif_system 执行;不需要目标侧文件、不需要 session_start()、不需要本地信号。
  • 纯 HTTP:攻击流量只有 multipart POST,目标 webroot 只有 <?php echo "OK\n"; ?>。
  • 仅官方镜像:官方 php:8.4.26-apache(PHP 8.4.26 / Debian 12 / Apache 2.4.69 prefork)+ docker/php.ini、docker/apache.conf 是唯一支持环境;详细限制见上文。
  • 边界:当前 RCE 要求 display_errors=1、memory_limit=8M、max_input_vars=1000、session.use_cookies=1、session.use_strict_mode=0、单 worker prefork;display_errors=0 不在本版 RCE 支持范围内。
  • 仅用于授权环境中的漏洞研究与复现。

English

Under the constraints of being fully remote, HTTP-only, with no session_start() on the target page and no PHP written on the target side, a single exploit.py achieves RCE on PHP 8.4.26 + Apache prefork + mod_php. The target page is only <?php echo "OK\n"; ?>.

Supported environment (read first)

The only supported target is the official php:8.4.26-apache image (PHP 8.4.26 / Debian 12 / Apache prefork + mod_php) together with the configuration under docker/.

This is not a general ASLR bypass, and ASLR is not disabled:

  • The chain leaks only two runtime values through out-of-bounds reads: the Zend MM chunk base and the libphp base. It does not leak libc, the stack, or the full memory map.
  • Address space randomization remains active. Host kernel ASLR (kASLR), the container runtime, kernel mmap behavior, the allocator, and the exact PHP/Apache build all affect heap geometry and whether the leaks work.
  • The heap geometry, calibration values, and libphp offsets are measured against the official image. There is no address scanning or brute force. A different image, PHP build, or distribution may still leak addresses but will not necessarily satisfy the forged-object layout.

The Apache side has hard requirements:

  • Prefork MPM + one worker + mod_php is required. When a child aborts, the parent forks a new worker without exec, so the libphp mappings and the address space needed by later stages survive.
  • docker/apache.conf fixes StartServers/MinSpareServers/MaxSpareServers/MaxRequestWorkers/ServerLimit = 1, sets MaxConnectionsPerChild = 0, and disables KeepAlive.
  • Multiple workers, event/worker MPM, Apache restart/graceful restart, worker re-exec, container recreation, or parent exit can re-randomize addresses or route requests to a different process and break the chain.
  • If any leak fails, ASLR remains effective. Retries only rotate a worker; they do not defeat ASLR.

The current RCE also requires: display_errors=1, memory_limit=8M, max_input_vars=1000, session.use_cookies=1, session.use_strict_mode=0, session.upload_progress.enabled=1.

One-shot reproduction

This repository does not compile PHP/Apache and has no docker build: the environment is simply the official php:8.4.26-apache image plus the config mounts under docker/.

# Requirements: 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) Pull the official image (PHP 8.4.26 / Debian 12 / Apache prefork)
docker compose -f docker/docker-compose.yml pull

# 2) Start the target: single worker, display_errors=On, memory_limit=8M
docker compose -f docker/docker-compose.yml up -d

# 3) Wait for Apache and confirm a normal page
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) One command completes the whole exploit; first run reads libphp offsets automatically
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)

If you are already inside the repository directory, skip git clone/cd; pull can also be omitted, since up -d pulls the image automatically.

exploit.py automatically performs worker rotation, the two-stage leak, zif_system execution, and proof retrieval, with built-in retries for each stage. Runtime environment:

Component Version Source
PHP 8.4.26 (NTS, mod_php) php:8.4.26-apache
Base system Debian 12 (bookworm) Same image
Apache 2.4.69 (Debian 12 package in the official image) Same image
MPM prefork, one worker docker/apache.conf
Page only <?php echo "OK\n"; ?> docker/www/index.php

Official image repository: docker-library/php (Docker Hub: php:8.4.26-apache); PHP source: php/php-src (tag php-8.4.26).

On first run docker/offsets.py runs automatically: it reads the real zif_system / zval_ptr_dtor offsets from the container's libphp.so and writes out/offsets.env; exploit.py loads it, so image updates do not require hand-editing offsets. The default command is id>/out/p1 (10 bytes); the container's /out is mounted to the repository's ./out, so the proof lands on the host:

[*] 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)

The command inside the payload is limited to 14 bytes. To run an arbitrary command, write a script to the mounted directory and invoke it with sh</out/c (8 bytes):

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'

Common options: --cmd, --proof-file, --fetch, --zif-offset, --dtor-offset, --htdata-offset, --no-pre-rotate, --retries. --offsets-file '' disables automatic offset loading; use --docker-container if the container is not named php2shell-target.

The official-image heap calibration is built in: stage-1 filler count 841; stage-2 target array 406, element value length 16; stage 3 uses HTDATA=chunk+0x690d8, six (31,31) pads on side A, and a 32-byte valid Cookie: PHPSESSID so side B reuses the stale id (send_cookie=0) and the session Set-Cookie header does not overwrite val.type at R0+16; side B uses a 15-byte carrier to overwrite the reference's val.value. The libphp offsets are read automatically as zif_system=0x465370, zval_ptr_dtor=0x65eb60.

Mechanism overview

The vulnerable surface is session initialization triggered by session.upload_progress before RINIT, plus dangling pointers left across requests after an OOM bailout. All attack traffic is ordinary multipart HTTP, and the target page only contains <?php echo "OK\n"; ?>:

  1. Worker rotation: A_bigfields (3 MB of live POST fields + a 12 MB OOM) is followed by a session-initializing B; the child exits with SIGABRT and prefork forks a new worker, which inherits the same libphp/chunk mappings.

  2. Heap leak: A uses 1001 fields to trigger a max_input_vars warning (display_errors=On → headers_sent=1), sets PHPSESSID=\x00 to generate a new id and send_cookie=1, then the final 12 MB field OOMs inside php_session_initialize() and leaves PS(http_session_vars) dangling. B reclaims the old reference with filler fields and uses a live zend_string to hold a fake HashTable; an out-of-bounds read in php_url_encode() writes Zend MM chunk pointers into Set-Cookie.

  3. libphp leak: A places the dangling PS(id) at the target offset; B's HashTable page makes ZSTR_LEN(PS(id)) read nTableSize=16, and the data region is exactly nNextFreeElement|pDestructor; pDestructor = ZVAL_PTR_DTOR = libphp+dtor_offset, so the cookie yields the libphp base.

  4. Command execution: A uses pads to pin the dangling reference at the target location; B overwrites the reference with a raw buffer/carrier, places the fake HashTable in a live 167-byte string at chunk+0x690d8, and sets pDestructor = zif_system = libphp+0x465370. Trigger chain:

    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
    

    Only zif_system inside libphp is used; libc is not needed.

ASLR, kASLR, and the Apache problem

What is leaked:

  • Stage 1's out-of-bounds Set-Cookie read leaks the Zend MM chunk base (the mode of 2 MiB-aligned pointers), later used as HTDATA = chunk + 0x690d8.
  • Stage 2's out-of-bounds Set-Cookie read leaks a pDestructor pointer; subtracting the ZVAL_PTR_DTOR offset gives the libphp base, later used as zif_system = libphp + 0x465370.
  • No libc base, stack, or full maps are needed.

ASLR is not defeated:

  • ASLR stays enabled. The exploit only discloses the two runtime addresses needed by this process: a per-process information-leak bypass, not a generic ASLR bypass.
  • There is no brute-force scan, no reboot guessing, and no /proc dependency. If the leaks fail, the chain fails and ASLR remains effective.
  • Because the calibration depends on the exact heap geometry and libphp build of the official image, only the official php:8.4.26-apache image is supported. kASLR, the host/container runtime, kernel mmap behavior, allocator version, and PHP/Apache rebuilds can all change the layout; this chain must not be treated as a cross-image ASLR exploit.

Apache problem:

  • The chain requires prefork + one worker + mod_php. When a worker aborts, the parent forks a new child and mappings are inherited, so the address space remains stable across stages. This is essential to the chain.
  • Multiple workers route consecutive requests to different processes; event/worker MPM does not apply; Apache restarts, graceful restarts, parent exit, container recreation, or re-exec can re-randomize addresses.
  • A non-zero MaxConnectionsPerChild, an external process manager forcing restarts, or any configuration that changes worker lifetime/mappings can break reuse of the stale pointer across requests.
  • The Docker configuration in this README is therefore part of the reproduction conditions, not merely a recommendation.

Reproduction statement

  • One exploit for the whole chain: python3 exploit.py -u ... performs worker rotation, heap leak, libphp leak, and zif_system execution in one command; no target-side files, no session_start(), and no local signal are required.
  • Pure HTTP: attack traffic is only multipart POST, and the target webroot only contains <?php echo "OK\n"; ?>.
  • Official image only: the official php:8.4.26-apache (PHP 8.4.26 / Debian 12 / Apache 2.4.69 prefork) plus docker/php.ini and docker/apache.conf is the only supported environment; see the limitations above.
  • Boundaries: the current RCE requires display_errors=1, memory_limit=8M, max_input_vars=1000, session.use_cookies=1, session.use_strict_mode=0, and single-worker prefork; display_errors=0 is outside this version's RCE support.
  • For authorized vulnerability research and reproduction only.

About

No description or website provided.

Topics

Resources

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages