Skip to content

Commit 340ecf8

Browse files
committed
Add PHP-FPM exploitation chain with verified RCE on 8.4.26
The Apache stage-1/2/3 chain does not transfer to PHP-FPM: a pre-RINIT OOM kills the worker (FPM_EXIT_SOFTWARE). This adds the FPM-viable primitive instead, exploiting the same CVEs through the stale BG(unserialize) state: - producer: post-RINIT OOM leaves {level=1, data=d} dangling - consume: pre-RINIT rfc1867 reuses d; a 4 MB builder-pool body reclaims the 8216-byte struct and forges var_hash - R:2 becomes an IS_REFERENCE -> fake HashTable whose pDestructor is zif_system; session shutdown fires the command (verified: uid=1000 proof in the nginx+FPM lab) Ships tools/php2shell_fpm.py (arm/reclaim/fire client, patchcheck), tools/php2shell_fpm_labs.py (user-prefix nginx+FPM lab, no Docker), profiles/8.4.26-fpm.json, schema extensions (sapi: fpm, fpm block), FPM dispatch in php2shell_auto.py (--sapi/--builder), unit and lab-gated integration tests, and docs/fpm-chain.md. patchcheck confirms 8.4.27 is clean (level=0, workers survive).
1 parent a3aa441 commit 340ecf8

23 files changed

Lines changed: 2346 additions & 90 deletions

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ __pycache__/
44
vendor/
55
lab/wordpress/wp/
66
lab/rce/out/
7+
lab/fpm/runtime/
8+
lab/fpm/webroot/out/

‎Makefile‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: bootstrap test labs-up labs-down labs-status labs-test lint clean
1+
.PHONY: bootstrap test labs-up labs-down labs-status labs-test labs-fpm-up labs-fpm-down labs-fpm-status labs-fpm-test lint clean
22

33
bootstrap: ## Fetch and pin the upstream exploit
44
python3 tools/bootstrap_upstream.py
@@ -21,6 +21,18 @@ labs-status: ## Show laboratory container status
2121
labs-test: ## Run the dispatcher against each lab
2222
python3 tools/php2shell_labs.py test
2323

24+
labs-fpm-up: ## Start the nginx + PHP-FPM laboratories (no Docker)
25+
python3 tools/php2shell_fpm_labs.py up
26+
27+
labs-fpm-down: ## Stop the nginx + PHP-FPM laboratories
28+
python3 tools/php2shell_fpm_labs.py down
29+
30+
labs-fpm-status: ## Show FPM laboratory status
31+
python3 tools/php2shell_fpm_labs.py status
32+
33+
labs-fpm-test: ## Run primitive + endgame against the FPM lab
34+
python3 tools/php2shell_fpm_labs.py test
35+
2436
inventory: ## Print the profile and offset inventory
2537
python3 tools/php2shell_inventory.py
2638

‎README.md‎

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515
> triggers pre-RINIT out-of-memory conditions and kills Apache children.
1616
1717
PHP2Shell is a research workspace for the `session.upload_progress` OOM /
18-
stale-pointer chain in PHP's Apache module. It packages a version-aware
19-
dispatcher, a per-build profile engine, reproducible laboratories, a calibration
20-
factory and runtime instrumentation around the public research exploit by
21-
[9bie](https://github.com/9bie/php2shell-bypass-aslr-exp).
18+
stale-pointer chain in PHP's Apache module, plus the PHP-FPM variant that
19+
exploits the same CVEs through the stale `BG(unserialize)` state. It packages a
20+
version-aware dispatcher, a per-build profile engine, reproducible laboratories,
21+
a calibration factory and runtime instrumentation around the public research
22+
exploit by [9bie](https://github.com/9bie/php2shell-bypass-aslr-exp).
2223

2324
The goal is reproducible research and defensive validation, not mass
2425
exploitation. The dispatcher refuses to guess: it stops when the build has no
@@ -35,6 +36,7 @@ not match the profile.
3536
| PHP 8.5.11 amd64 | `php:8.5.11-apache` | `php-8.5.11-bookworm-amd64` | yes | yes | **yes** | separate offsets, same `htdata` |
3637
| PHP 8.5.10 aarch64 | `php:8.5.10-apache` | `php-8.5.10-bookworm-aarch64` | yes | yes | **yes** | `uid=33(www-data)` |
3738
| PHP 8.4.26 amd64 | `php:8.4.26-apache` | `php-8.4.26-bookworm-amd64` | yes | yes | **yes** | reference build |
39+
| PHP 8.4.26 FPM | `php:8.4.26-fpm` (nginx) | `8.4.26-fpm` | n/a | n/a | **yes** | `BG(unserialize)` chain; worker runs `uid=1000` proof |
3840
| PHP 8.4.25 aarch64 | `php:8.4.25-apache` | `php-8.4.25-bookworm-aarch64` | yes | yes | **yes** | probabilistic stage 3; retries |
3941
| PHP 8.5.0 aarch64 | `php:8.5.0-apache` | `php-8.5.0-bookworm` | yes | yes | no | stage-3 layout not found |
4042
| PHP 8.3.35 aarch64 | `php:8.3.35-apache` | `php-8.3.35-bookworm-aarch64` | yes | yes | no | stage-3 reference alive |
@@ -47,6 +49,9 @@ not match the profile.
4749
| PHP 8.2.0 aarch64 | `php:8.2.0-apache` | — (offsets only) | n/a | n/a | no | no heap profile |
4850

4951
**13 heap profiles, 5 with verified RCE. Offsets extracted for 12 builds.**
52+
Plus one FPM profile (`8.4.26-fpm`) with verified RCE through the
53+
`BG(unserialize)` stale-state chain (no heap-leak stages; the stale struct is
54+
located with the oracle each run).
5055

5156
`Heap leak` is stage 1, `libphp leak` is stage 2 and `RCE` is a real command
5257
proof on an isolated lab. `no` means not verified, not necessarily impossible;
@@ -75,6 +80,13 @@ default retries because its stage 3 is probabilistic.
7580
- **Command staging** — on local labs `-c` accepts commands of any length,
7681
stages them in `/out/c` and captures the output. `--script` and `--interactive`
7782
provide batch and REPL modes.
83+
- **FPM exploitation chain** — `tools/php2shell_fpm.py` exploits the same
84+
CVEs against PHP-FPM behind nginx: an out-of-memory request leaves the
85+
`BG(unserialize)` pointer stale, the next request reclaims the struct, and a
86+
forged session variable turns the session shutdown path into `zif_system`.
87+
Verified command execution on 8.4.26; `patchcheck` proves 8.4.27 is clean.
88+
Requires a `/proc/<pid>/mem` oracle (`ptrace_scope=0`), so it runs in the
89+
local laboratory mode (`--builder` endpoint for the 4 MB arm phase).
7890
- **Calibration factory** — `php2shell_calibrate.py` discovers stages 1-2 and
7991
verifies stage 3 with a real proof before writing `rce: true`;
8092
`php2shell_matrix.py` runs the factory over a matrix of images.
@@ -139,6 +151,25 @@ python3 tools/php2shell_labs.py status
139151
python3 tools/php2shell_inventory.py
140152
```
141153

154+
### PHP-FPM laboratory (no Docker)
155+
156+
The FPM chain runs against a user-prefix PHP build with nginx, using the
157+
`/proc/<pid>/mem` oracle (`ptrace_scope=0`):
158+
159+
```sh
160+
python3 tools/php2shell_fpm_labs.py up --version 8.4.26
161+
python3 tools/php2shell_auto.py -u http://127.0.0.1:8110/index.php --sapi fpm \
162+
--builder http://127.0.0.1:8111
163+
# or drive the chain directly:
164+
python3 tools/php2shell_fpm.py endgame \
165+
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26
166+
python3 tools/php2shell_fpm_labs.py down
167+
```
168+
169+
The consumer request exits with HTTP 502 (the worker dies after firing
170+
`zif_system`); the proof is written by the command itself and served statically
171+
from `/out/`. See [docs/fpm-chain.md](docs/fpm-chain.md).
172+
142173
## How it works
143174

144175
The chain abuses the `session.upload_progress` callback that runs **before**
@@ -158,6 +189,30 @@ the request startup (pre-RINIT) and therefore before the target script:
158189
The full technical write-up, including the session lifecycle and the heap
159190
grooming, is in [docs/php2shell-analysis.md](docs/php2shell-analysis.md).
160191

192+
### PHP-FPM chain
193+
194+
The FPM lifecycle differs from mod_php: a pre-RINIT OOM kills the worker
195+
outright (`FPM_EXIT_SOFTWARE`), so the Apache stage-1/2/3 carrier does not
196+
transfer. The FPM chain instead abuses the stale `BG(unserialize)` state:
197+
198+
1. **Arm.** An out-of-memory multipart request aborts between
199+
`php_var_unserialize_init` and `php_var_unserialize_destroy`, leaving
200+
`BG(unserialize).data` dangling and `level=1`. The next request that reaches
201+
RINIT memsets `BG` — so the stale window is one request wide.
202+
2. **Locate and reclaim.** The stale 8216-byte `php_unserialize_data` struct is
203+
found with the `/proc/<pid>/mem` oracle (two candidate heap slots; a mismatch
204+
self-corrects on retry). A 4 MB body posted to a second endpoint (the
205+
builder pool) reclaims the chunk.
206+
3. **Forge and fire.** The reclaimed buffer carries a fake `zend_reference`
207+
whose `val` is a fake array whose `pDestructor` is `zif_system`. The consume
208+
request attaches it as session variable `r`; at session shutdown
209+
`zval_ptr_dtor(&PS(http_session_vars))` destroys the reference, the array
210+
destructor loop calls `zif_system(frame)` with the argument at `execute_data
211+
+ 0x50` — a fake `zend_string` holding the command.
212+
213+
`cmd_patchcheck` verifies the stale arm on 8.4.26 and that 8.4.27 (`level=0`)
214+
is clean. Details in [docs/fpm-chain.md](docs/fpm-chain.md).
215+
161216
A profile is per build:
162217

163218
```json
@@ -191,9 +246,12 @@ The shipped calibration targets:
191246
`session.use_strict_mode=0`.
192247

193248
A hardened production PHP (128M+, `display_errors=Off`, multiple workers, FPM)
194-
does not match the shipped calibration. Multi-worker targets are handled with
195-
`--pin-workers`; `display_errors=Off` and FPM remain open research items (see
196-
Limitations).
249+
does not match the shipped Apache calibration. Multi-worker Apache targets are
250+
handled with `--pin-workers`; `display_errors=Off` remains an open research
251+
item. The FPM chain has its own requirements: nginx in front of FPM, a
252+
`/proc/<pid>/mem` oracle (`yama ptrace_scope=0`), a small consumer pool
253+
(`memory_limit=8M`, cleanup on) and a larger builder pool (64M, cleanup off)
254+
for the 4 MB arm phase — see [docs/fpm-chain.md](docs/fpm-chain.md).
197255

198256
## Multi-worker targets
199257

@@ -277,7 +335,9 @@ profiles/*.json heap profiles (offsets, heap params, verified fla
277335
profiles/offsets/*.json ELF symbol offsets per image
278336
vendor/ generated upstream checkout (git-ignored)
279337
tools/
280-
php2shell_auto.py version-aware dispatcher
338+
php2shell_auto.py version-aware dispatcher (Apache + FPM)
339+
php2shell_fpm.py PHP-FPM chain client (arm/reclaim/fire)
340+
php2shell_fpm_labs.py user-prefix FPM laboratories (no Docker)
281341
php2shell_calibrate.py profile generator with proof verification
282342
php2shell_matrix.py profile factory over image tags
283343
php2shell_rce.py manual upstream launcher
@@ -292,8 +352,10 @@ tools/
292352
lab/
293353
wordpress/ WordPress 8.3.33 + mod_php laboratory
294354
rce/ single-worker calibration lab and configs
355+
fpm/ nginx + PHP-FPM laboratory (8.4.26 / 8.4.27)
295356
docs/
296357
php2shell-analysis.md technical analysis
358+
fpm-chain.md PHP-FPM chain and laboratory
297359
rce-guide.md full RCE guide and profile schema
298360
exploit-report.md laboratory results
299361
labs.md laboratory reference
@@ -368,8 +430,10 @@ OOM, which is why the 8.4/8.5 stage-3 carrier does not transfer.
368430

369431
## Limitations
370432

371-
- **mod_php prefork only.** PHP-FPM has a different lifecycle; the chain does
372-
not transfer as-is and FPM is not supported.
433+
- **mod_php prefork and FPM.** The Apache chain needs prefork + `mod_php`;
434+
the FPM chain needs nginx + FPM with a `/proc/<pid>/mem` oracle
435+
(`ptrace_scope=0`) and two endpoints (consumer + builder pool). Other SAPIs
436+
are not supported.
373437
- **One worker / pinning.** Without pinning, the per-worker session state breaks
374438
stage 1.
375439
- **Per-build offsets.** A profile is tied to a `libphp.so` build; the same
@@ -392,6 +456,7 @@ OOM, which is why the 8.4/8.5 stage-3 carrier does not transfer.
392456
## Documentation
393457

394458
- [Technical analysis](docs/php2shell-analysis.md)
459+
- [PHP-FPM chain](docs/fpm-chain.md)
395460
- [RCE guide and profile schema](docs/rce-guide.md)
396461
- [Laboratory results](docs/exploit-report.md)
397462
- [Laboratories](docs/labs.md)

0 commit comments

Comments
 (0)