Skip to content

Commit ee8a154

Browse files
committed
Document the expanded PHP-FPM chain
README: the status table now lists the five verified FPM builds, the features section describes the remote calibrate/poison/no-oracle flow and the version registry, and the FPM lab quick start uses the corrected `up --only` CLI with build/calibrate/poison/matrix examples. Update the repository layout and drop the stale claim that the FPM chain always needs a /proc oracle -- only the lab endgame/primitive modes do. docs/fpm-chain.md: add the affected-version table, the PHP <= 7.x session-file plant (poison), remote calibration and the unpatched matrix, expand the lab port map to every verified build, and fix `up --only`. docs/labs.md: give the rootless nginx+FPM laboratory its own section with the build steps and the full port map, linking back to fpm-chain.md.
1 parent d7e1615 commit ee8a154

3 files changed

Lines changed: 216 additions & 35 deletions

File tree

‎README.md‎

Lines changed: 75 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ not match the profile.
3737
| PHP 8.5.10 aarch64 | `php:8.5.10-apache` | `php-8.5.10-bookworm-aarch64` | yes | yes | **yes** | `uid=33(www-data)` |
3838
| PHP 8.4.26 amd64 | `php:8.4.26-apache` | `php-8.4.26-bookworm-amd64` | yes | yes | **yes** | reference build |
3939
| PHP 8.4.26 FPM amd64 | php-8.4.26 source tarball (nginx+FPM) | `php-8.4.26-fpm` | http (stale string) | http (stale string) | **yes** | `BG(unserialize)` chain; two-slot reclaim; HTTP-only `no-oracle` endgame |
40+
| PHP 8.4.25 FPM amd64 | php-8.4.25 source tarball (nginx+FPM) | `php-8.4.25-fpm` | http (stale string) | http (stale string) | **yes** | same chain, calibrated geometry |
41+
| PHP 8.4.0 FPM amd64 | php-8.4.0 source tarball (nginx+FPM) | `php-8.4.0-fpm` | http (stale string) | http (stale string) | **yes** | earliest 8.4 release of the branch |
42+
| PHP 8.3.35 FPM amd64 | php-8.3.35 source tarball (nginx+FPM) | `php-8.3.35-fpm` | http (stale string) | http (stale string) | **yes** | last vulnerable 8.3 |
43+
| PHP 8.2.34 FPM amd64 | php-8.2.34 source tarball (nginx+FPM) | `php-8.2.34-fpm` | http (stale string) | http (stale string) | **yes** | last vulnerable 8.2 |
4044
| PHP 8.4.25 aarch64 | `php:8.4.25-apache` | `php-8.4.25-bookworm-aarch64` | yes | yes | **yes** | probabilistic stage 3; retries |
4145
| PHP 8.5.0 aarch64 | `php:8.5.0-apache` | `php-8.5.0-bookworm` | yes | yes | no | stage-3 layout not found |
4246
| PHP 8.3.35 aarch64 | `php:8.3.35-apache` | `php-8.3.35-bookworm-aarch64` | yes | yes | no | stage-3 reference alive |
@@ -48,13 +52,14 @@ not match the profile.
4852
| PHP 7.4.33 aarch64 | `php:7.4-apache` | `php-7.4.33-bookworm-aarch64` | no | no | no | UAF; leak shape differs |
4953
| PHP 8.2.0 aarch64 | `php:8.2.0-apache` | — (offsets only) | n/a | n/a | no | no heap profile |
5054

51-
**14 profiles (13 heap + 1 FPM), 6 with verified RCE. Offsets extracted for
52-
12 builds.** The FPM profile has no Apache leak stages: `endgame` resolves the stale
53-
`BG(unserialize)` struct and the php-fpm PIE base with the
54-
`/proc/<pid>/mem` oracle each run; `no-oracle` leaks the two absolute bases
55-
over HTTP from a stale-session string OOB read, or takes them from
56-
`--chunk`/`--pie` or local `/proc/<pid>/maps`, then brute-forces the two
57-
slots ([docs/fpm-chain.md](docs/fpm-chain.md)).
55+
**18 profiles (13 Apache/mod_php + 5 PHP-FPM), 10 with verified RCE. Offsets
56+
extracted for 12 Apache builds.** The PHP-FPM profiles have no Apache leak
57+
stages: the chain abuses the stale `BG(unserialize)` struct instead.
58+
`no-oracle` is fully remote — it leaks the chunk and php-fpm PIE bases over
59+
HTTP from a stale-session string OOB read (or takes them from `--chunk`/
60+
`--pie`) and fires without touching `/proc/<pid>/mem`; the lab-only `endgame`
61+
can still read them through the oracle
62+
([docs/fpm-chain.md](docs/fpm-chain.md)).
5863

5964
`Heap leak` is stage 1 and `libphp leak` is stage 2; `oracle (heap)` and
6065
`oracle (PIE)` mean the FPM chain reads the heap chunk and the php-fpm PIE base
@@ -90,9 +95,17 @@ default retries because its stage 3 is probabilistic.
9095
CVEs against PHP-FPM behind nginx: an out-of-memory request leaves the
9196
`BG(unserialize)` pointer stale, the next request reclaims the struct, and a
9297
forged session variable turns the session shutdown path into `zif_system`.
93-
Verified command execution on 8.4.26; `patchcheck` proves 8.4.27 is clean.
94-
Requires a `/proc/<pid>/mem` oracle (`ptrace_scope=0`), so it runs in the
95-
local laboratory mode (`--builder` endpoint for the 4 MB arm phase).
98+
Verified command execution on 8.2.34, 8.3.35, 8.4.0, 8.4.25 and 8.4.26;
99+
`patchcheck` proves 8.4.27 is clean. `calibrate` derives the leak and
100+
reclaim geometry of any 8.0+ build over HTTP only; `poison` (the
101+
`session.upload_progress` session-file plant) covers PHP 5.4-7.x where the
102+
struct-freeing chain does not apply; `php2shell_fpm_matrix.py` verifies the
103+
whole unpatched set in one run. The `no-oracle` endgame is fully remote —
104+
no `/proc/<pid>/mem`, no local shell.
105+
- **PHP version registry** — `tools/php2shell_fpm_versions.py` is the single
106+
source of truth for the affected-version matrix: `is_vulnerable()`,
107+
`fixed_in()`, `source_is_fixed()` classifies a tarball by the presence of the
108+
upstream fix marker instead of trusting a version string.
96109
- **Calibration factory** — `php2shell_calibrate.py` discovers stages 1-2 and
97110
verifies stage 3 with a real proof before writing `rce: true`;
98111
`php2shell_matrix.py` runs the factory over a matrix of images.
@@ -159,25 +172,43 @@ python3 tools/php2shell_inventory.py
159172

160173
### PHP-FPM laboratory (no Docker)
161174

162-
The FPM chain runs against an amd64 user-prefix PHP build with nginx. The
163-
default `endgame` uses the `/proc/<pid>/mem` oracle (`ptrace_scope=0`);
164-
`no-oracle` avoids it at exploit time and leaks chunk/PIE over HTTP by default:
175+
The FPM chain runs against an amd64 user-prefix PHP build with nginx — no
176+
Docker, no root. Build nginx and a vulnerable PHP (any of 8.2.34, 8.3.35,
177+
8.4.0, 8.4.25 or 8.4.26), then start the lab for that version:
178+
179+
```sh
180+
lab/fpm/build_nginx.sh
181+
lab/fpm/build_php.sh 8.4.26
182+
python3 tools/php2shell_fpm_labs.py up --only 8.4.26
183+
python3 tools/php2shell_fpm_labs.py status
184+
```
185+
186+
The default `endgame` uses the `/proc/<pid>/mem` oracle (`ptrace_scope=0`);
187+
`no-oracle` is fully remote and leaks chunk/PIE over HTTP by default:
165188

166189
```sh
167-
python3 tools/php2shell_fpm_labs.py up --version 8.4.26
168190
python3 tools/php2shell_auto.py -u http://127.0.0.1:8110/index.php --sapi fpm \
169191
--builder http://127.0.0.1:8111
170192
# or drive the chain directly:
171193
python3 tools/php2shell_fpm.py \
172194
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 \
173195
endgame
174-
# no /proc/<pid>/mem at exploit time (HTTP leak; --chunk/--pie override)
196+
# fully remote RCE: HTTP leak, no /proc at exploit time (--chunk/--pie override)
175197
python3 tools/php2shell_fpm.py \
176198
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 \
177199
--command 'id>out/p1' --proof-file out/p1 no-oracle
178200
# print only the leaked bases
179201
python3 tools/php2shell_fpm.py \
180202
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 leak
203+
# derive a profile for any 8.0+ build over HTTP, then trust it:
204+
python3 tools/php2shell_fpm.py \
205+
--version 8.2.34 --base http://127.0.0.1:8406 --builder http://127.0.0.1:8407 calibrate
206+
# PHP <= 7.x: plant chosen bytes into a chosen session file (pre-auth)
207+
python3 tools/php2shell_fpm.py \
208+
--version 7.4.33 --base http://127.0.0.1:8336 --builder http://127.0.0.1:8337 \
209+
poison --sid plant7x --payload 'PLANTED_MARKER'
210+
# verify the remote chain on every unpatched build in one run (7.2 -> 8.4)
211+
python3 tools/php2shell_fpm_matrix.py
181212
python3 tools/php2shell_fpm_labs.py down
182213
```
183214

@@ -268,10 +299,12 @@ The shipped calibration targets:
268299
A hardened production PHP (128M+, `display_errors=Off`, multiple workers, FPM)
269300
does not match the shipped Apache calibration. Multi-worker Apache targets are
270301
handled with `--pin-workers`; `display_errors=Off` remains an open research
271-
item. The FPM chain has its own requirements: nginx in front of FPM, a
272-
`/proc/<pid>/mem` oracle (`yama ptrace_scope=0`), a small consumer pool
273-
(`memory_limit=8M`, cleanup on) and a larger builder pool (64M, cleanup off)
274-
for the 4 MB arm phase — see [docs/fpm-chain.md](docs/fpm-chain.md).
302+
item. The FPM chain has its own requirements: nginx in front of FPM, a small
303+
consumer pool (`memory_limit=8M`, cleanup on) and a larger builder pool (64M,
304+
cleanup off) for the 4 MB arm phase, sharing a session directory. The
305+
`no-oracle` endgame is fully remote; only the lab `endgame`/`primitive` modes
306+
additionally need `/proc/<pid>/mem` (`yama ptrace_scope=0`) — see
307+
[docs/fpm-chain.md](docs/fpm-chain.md).
275308

276309
## Multi-worker targets
277310

@@ -350,19 +383,29 @@ git -C vendor/php2shell-upstream checkout 4586f5c
350383

351384
```text
352385
catalog/vulnerable.json vulnerable branches and fixed releases
353-
.github/workflows/ci.yml tests and gitleaks
354-
profiles/*.json heap profiles (offsets, heap params, verified flag)
386+
.github/workflows/ci.yml tests, profile validation and gitleaks
387+
.github/workflows/live-fpm.yml manual live FPM lab (build + exploit 8.4.x)
388+
.github/workflows/release.yml GitHub release on v* tags
389+
profiles/*.json build profiles (offsets, heap params, verified flag)
390+
profiles/*-fpm.json verified PHP-FPM profiles (8.2.34 - 8.4.26)
391+
profiles/schema.json generic build-profile validator
392+
profiles/fpm-schema.json strict PHP-FPM profile validator
355393
profiles/offsets/*.json ELF symbol offsets per image
394+
requirements-dev.txt jsonschema + ruff (dev/CI)
356395
vendor/ generated upstream checkout (git-ignored)
357396
tools/
358397
php2shell_auto.py version-aware dispatcher (Apache + FPM)
359-
php2shell_fpm.py PHP-FPM chain client (arm/reclaim/fire)
398+
php2shell_fpm.py PHP-FPM client (calibrate/poison/leak/no-oracle/endgame)
360399
php2shell_fpm_labs.py user-prefix FPM laboratories (no Docker)
361-
php2shell_calibrate.py profile generator with proof verification
362-
php2shell_matrix.py profile factory over image tags
400+
php2shell_fpm_matrix.py verify the remote chain on every unpatched build
401+
php2shell_fpm_profile.py draft FPM profile generator (nm + offsets)
402+
php2shell_fpm_versions.py affected-version registry (single source of truth)
403+
php2shell_fpm_offsets.c struct offsets compiled per build
404+
php2shell_calibrate.py Apache profile generator with proof verification
405+
php2shell_matrix.py Apache profile factory over image tags
363406
php2shell_rce.py manual upstream launcher
364407
php2shell_assess.py exposure/leak assessment (no RCE claim)
365-
php2shell_labs.py disposable local laboratories
408+
php2shell_labs.py disposable local Apache laboratories
366409
php2shell_offsets.py ELF offset extraction for any image
367410
php2shell_inventory.py profile/offset table
368411
php2shell_paths.py upstream checkout resolution
@@ -372,7 +415,7 @@ tools/
372415
lab/
373416
wordpress/ WordPress 8.3.33 + mod_php laboratory
374417
rce/ single-worker calibration lab and configs
375-
fpm/ nginx + PHP-FPM laboratory (8.4.26 / 8.4.27)
418+
fpm/ nginx + PHP-FPM laboratory (7.2 - 8.4; build_*.sh)
376419
docs/
377420
php2shell-analysis.md technical analysis
378421
fpm-chain.md PHP-FPM chain and laboratory
@@ -450,10 +493,12 @@ OOM, which is why the 8.4/8.5 stage-3 carrier does not transfer.
450493

451494
## Limitations
452495

453-
- **mod_php prefork and FPM.** The Apache chain needs prefork + `mod_php`;
454-
the FPM chain needs nginx + FPM with a `/proc/<pid>/mem` oracle
455-
(`ptrace_scope=0`) and two endpoints (consumer + builder pool). Other SAPIs
456-
are not supported.
496+
- **mod_php prefork and FPM.** The Apache chain needs prefork + `mod_php`; the
497+
FPM chain needs nginx + FPM with two endpoints (a low-memory consumer pool and
498+
a high-memory builder pool) and a session directory shared between them. Only
499+
the lab-only `endgame`/`primitive` oracle modes need `/proc/<pid>/mem`
500+
(`ptrace_scope=0`); `no-oracle` is fully remote. Other SAPIs are not
501+
supported.
457502
- **One worker / pinning.** Without pinning, the per-worker session state breaks
458503
stage 1.
459504
- **Per-build offsets.** A profile is tied to a `libphp.so` build; the same

‎docs/fpm-chain.md‎

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ needs a pre-RINIT out-of-memory bailout. Under PHP-FPM that bailout is fatal:
66
Apache progress-cache UAF never survives to the next request.
77

88
The FPM-viable primitive is the **stale `BG(unserialize)` state** — the same
9-
CVEs through a different window. Verified end-to-end on PHP 8.4.26 (amd64,
10-
Debian) in the local laboratory; `cmd_patchcheck` confirms 8.4.27 leaves
11-
`level=0` (clean).
9+
CVEs through a different window. Verified end-to-end on PHP 8.2.34, 8.3.35,
10+
8.4.0, 8.4.25 and 8.4.26 (amd64, Debian-style user-prefix builds) in the local
11+
laboratory; `cmd_patchcheck` confirms 8.4.27 leaves `level=0` (clean). PHP
12+
<= 7.x cannot use this struct-freeing chain, but the same
13+
`session.upload_progress` surface gives a pre-auth **session-file plant**
14+
(`poison`), covered below.
1215

1316
> **Authorized testing only.** The chain is disruptive: it sends multi-megabyte
1417
> bodies, triggers out-of-memory conditions and kills FPM workers. Use it only
@@ -30,6 +33,25 @@ The chain therefore has to *re-claim* the stale struct inside the request that
3033
still sees it, before RINIT runs. The rfc1867 multipart upload handler gives
3134
exactly that window.
3235

36+
## Affected versions
37+
38+
`tools/php2shell_fpm_versions.py` is the single source of truth for the
39+
affected-version matrix. Every release before the branch fix is vulnerable:
40+
41+
| Branch | Vulnerable | Fixed | This repository |
42+
| --- | --- | --- | --- |
43+
| 8.4 | 8.4.0 – 8.4.26 | 8.4.27 | **verified profiles (8.4.0, 8.4.25, 8.4.26)** |
44+
| 8.3 | 8.3.0 – 8.3.35 | 8.3.36 | **verified profile (8.3.35)** |
45+
| 8.2 | 8.2.0 – 8.2.34 | 8.2.35 | **verified profile (8.2.34)** |
46+
| 7.4 / 7.2 (and older) | every release | **never patched** | session-file plant (`poison`); no struct-freeing chain |
47+
48+
(PHP 8.5 is fixed in 8.5.12 and is intentionally out of scope here.)
49+
`is_vulnerable()`, `fixed_in()`, `affected_versions()` and `source_is_fixed()`
50+
(which classifies a tarball by the presence of the upstream fix marker rather
51+
than a version string) all resolve through that registry, and
52+
`has_session_cancel_decode()` decides whether the struct-freeing chain can
53+
apply to a build at all (it needs PHP 8.0+).
54+
3355
## The chain
3456

3557
All offsets are per build and live in `profiles/8.4.26-fpm.json`
@@ -192,9 +214,18 @@ target arguments.
192214

193215
| Build | chain HTTP | builder HTTP | chain FPM | builder FPM |
194216
| --- | --- | --- | --- | --- |
217+
| 7.2.34 | :8268 | :8269 | :9168 | :9169 |
218+
| 7.4.33 | :8336 | :8337 | :9236 | :9237 |
219+
| 8.2.34 | :8406 | :8407 | :9306 | :9307 |
220+
| 8.3.35 | :8478 | :8479 | :9378 | :9379 |
221+
| 8.4.0 | :8480 | :8481 | :9380 | :9381 |
222+
| 8.4.25 | :8130 | :8131 | :9021 | :9022 |
195223
| 8.4.26 | :8110 | :8111 | :9001 | :9002 |
196224
| 8.4.27 | :8120 | :8121 | :9011 | :9012 |
197225

226+
Every other built version gets a block of its own from
227+
`php2shell_fpm_labs.builds_map()` (`make labs-fpm-ports` prints the mapping).
228+
198229
Pools (from `lab/fpm/php-fpm.conf.in`):
199230

200231
- **chain** — `memory_limit=8M`, session `cleanup=On` (the consumer worker);
@@ -211,7 +242,7 @@ Requirements: Python 3.11+, gcc for the user-prefix PHP builds
211242
ptrace_scope=0` for the `/proc/<pid>/mem` oracle; `no-oracle` does not.
212243

213244
```sh
214-
python3 tools/php2shell_fpm_labs.py up --version 8.4.26
245+
python3 tools/php2shell_fpm_labs.py up --only 8.4.26
215246
python3 tools/php2shell_fpm_labs.py status
216247
python3 tools/php2shell_fpm_labs.py smoke
217248
python3 tools/php2shell_fpm_labs.py test # primitive + endgame end-to-end
@@ -264,11 +295,83 @@ python3 tools/php2shell_auto.py \
264295
Tests: the unit suite always runs; the live lab tests are gated:
265296

266297
```sh
267-
python3 -m unittest discover -s tests -v # 51 tests, skips live FPM
298+
python3 -m unittest discover -s tests -v # unit suite, skips live FPM
268299
PHP2SHELL_FPM_LAB=1 python3 -m unittest \
269300
tests.test_fpm.FpmLabIntegrationTests -v # primitive + endgame + no-oracle live
270301
```
271302

303+
## Session-file plant (PHP 5.4-8.x, `poison`)
304+
305+
The other half of `session.upload_progress` works on **every** version,
306+
including PHP <= 7.x where the struct-freeing chain does not apply:
307+
308+
1. `MULTIPART_EVENT_FORMDATA` copies the session id **from the POST body**
309+
without consulting `session.use_only_cookies`;
310+
2. `MULTIPART_EVENT_FILE_START` installs it as `PS(id)` and writes the progress
311+
data there — kept by `session.upload_progress.cleanup=Off` on the builder;
312+
3. the upload **filename** is stored and serialized into that file, so chosen
313+
bytes land in a session file of the attacker's choosing
314+
(`php_session_valid_key` limits the name to `[a-zA-Z0-9,-]`, so it is inside
315+
`save_path`, not a path traversal);
316+
4. the application deserializes the file on its next `session_start()` and
317+
reflects it — over HTTP, no `/proc` anywhere.
318+
319+
```sh
320+
python3 tools/php2shell_fpm.py --version 7.4.33 \
321+
--base http://127.0.0.1:8336 --builder http://127.0.0.1:8337 \
322+
poison --sid plant7x --payload 'PLANTED_MARKER'
323+
```
324+
325+
This is arbitrary **session-file write** pre-auth, not RCE by itself: the
326+
serializer rejects keys containing `|` (so the key cannot smuggle objects) and
327+
the files handler validates the id, which rules out path traversal. Turning the
328+
plant into code execution needs an application gadget or a local file include
329+
in the target — which is why PHP <= 7.x is reported as *session plant* and RCE
330+
is reserved for 8.0+, where the struct-freeing chain applies.
331+
332+
## Remote calibration (`calibrate`)
333+
334+
`calibrate` derives everything a profile needs **with HTTP requests only** —
335+
no `/proc`, no local shell, no FPM PID. It is the same traffic the exploit
336+
sends, so a build that calibrates is a build that exploits:
337+
338+
```sh
339+
python3 tools/php2shell_fpm.py \
340+
--version 8.2.34 --base http://127.0.0.1:8406 --builder http://127.0.0.1:8407 \
341+
calibrate
342+
```
343+
344+
Three phases, each validated by the answer of the target:
345+
346+
1. **Leak geometry** — the forged `zend_string` header is tried at a few
347+
content offsets; accepted when two consecutive OOB reads expose a chunk and
348+
a PIE pointer at the *same* offsets (which also fixes `chunk_base_off`).
349+
2. **Reclaim geometry** — a forged `IS_LONG` zval reflected by `app.php` needs
350+
no PIE base, so it isolates the heap geometry (pad/size shapes and
351+
`(chunk offset, content offset)` pairs).
352+
3. **PIE base** — the leaked pointer is rounded to 2 MB boundaries and each
353+
candidate is proven with a remote endgame until the proof file appears.
354+
355+
The result is written into `profiles/<version>-fpm.json`; `no-oracle` then
356+
reproduces the RCE against that build.
357+
358+
## Unpatched matrix (`php2shell_fpm_matrix.py`)
359+
360+
`make matrix` (or `python3 tools/php2shell_fpm_matrix.py`) verifies the remote
361+
chain on every unpatched build, one lab at a time so the ports never fight. For
362+
each version it starts the lab, calibrates the profile over HTTP when it is not
363+
verified yet, runs the 100%-remote `no-oracle` endgame, and prints one line per
364+
version (patched releases are skipped by design):
365+
366+
```text
367+
7.2.34 SESSION PLANT pre-auth write into sess_<sid>
368+
7.4.33 SESSION PLANT pre-auth write into sess_<sid>
369+
8.2.34 REMOTE RCE uid=1000(...) ...
370+
8.3.35 REMOTE RCE uid=1000(...) ...
371+
8.4.25 REMOTE RCE uid=1000(...) ...
372+
8.4.26 REMOTE RCE uid=1000(...) ...
373+
```
374+
272375
## Limitations and future work
273376

274377
- **Oracle lab mode.** `endgame` still uses the `/proc/<pid>/mem` oracle to

0 commit comments

Comments
 (0)