mirror of
https://github.com/EchoStretch/kstuff-lite.git
synced 2026-10-19 02:09:50 -05:00
302 lines
11 KiB
Python
302 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Statically validate retail PPR offsets against local PS5 kernel images.
|
|
|
|
The checker derives the kdata anchor from existing, independently recognizable
|
|
CR0 helper instructions. It then checks the relationships that make the PPR
|
|
trap ABI safe: both get-index calls and return addresses, cleanup call sites,
|
|
the clear-key miss instruction, the verifyImage mailbox return address, and the
|
|
success continuation. This is a static audit; console testing is still needed
|
|
before calling a firmware fully supported.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
import re
|
|
import struct
|
|
import sys
|
|
from typing import NamedTuple
|
|
|
|
|
|
PT_LOAD = 1
|
|
PF_X = 1
|
|
MASK64 = (1 << 64) - 1
|
|
|
|
PPR_NAMES = (
|
|
"ppr_pfs_get_xts_index",
|
|
"ppr_pfs_get_cmac_index",
|
|
"ppr_pfs_get_xts_return",
|
|
"ppr_pfs_get_cmac_return",
|
|
"ppr_pfs_cleanup_keys",
|
|
"ppr_pfs_clear_key_missing",
|
|
"sceSblServiceMailbox_lr_verifyImage",
|
|
"ppr_pfs_verify_image_no_key_success",
|
|
)
|
|
|
|
ANCHOR_SIGNATURES = {
|
|
"cr0_load": bytes.fromhex("48 8b 47 58"),
|
|
"cr0_clear_store": bytes.fromhex("48 83 e0 f7"),
|
|
"cr0_write_ret": bytes.fromhex("0f 22 c0"),
|
|
"store_rax_rdi": bytes.fromhex("48 89 07"),
|
|
}
|
|
|
|
|
|
class Profile(NamedTuple):
|
|
cleanup_cmac_delta: int
|
|
cleanup_xts_delta: int
|
|
clear_prefix: bytes
|
|
success_prefix: bytes
|
|
|
|
|
|
PROFILES = {
|
|
1: Profile(0x126, 0x15A, bytes.fromhex("bb fe ff ff ff"),
|
|
bytes.fromhex("49 c1 e7 20")),
|
|
101: Profile(0x145, 0x179, bytes.fromhex("bb fe ff ff ff"),
|
|
bytes.fromhex("49 c1 e7 20")),
|
|
2: Profile(0x144, 0x178, bytes.fromhex("bb fe ff ff ff"),
|
|
bytes.fromhex("49 c1 e7 20")),
|
|
3: Profile(0x137, 0x166, bytes.fromhex("41 be fe ff ff ff"),
|
|
bytes.fromhex("49 c1 e7 20")),
|
|
4: Profile(0x13D, 0x16C, bytes.fromhex("41 bc fe ff ff ff"),
|
|
bytes.fromhex("49 c1 e7 20")),
|
|
5: Profile(0x138, 0x167, bytes.fromhex("41 bc fe ff ff ff"),
|
|
bytes.fromhex("49 c1 e5 20")),
|
|
6: Profile(0x138, 0x167, bytes.fromhex("41 bc fe ff ff ff"),
|
|
bytes.fromhex("49 c1 e5 20")),
|
|
7: Profile(0x11C, 0x14B, bytes.fromhex("41 bf fe ff ff ff"),
|
|
bytes.fromhex("49 c1 e5 20")),
|
|
8: Profile(0x117, 0x147, bytes.fromhex("bb fe ff ff ff"),
|
|
bytes.fromhex("49 c1 e5 20")),
|
|
9: Profile(0x119, 0x149, bytes.fromhex("bb fe ff ff ff"),
|
|
bytes.fromhex("49 c1 e5 20")),
|
|
10: Profile(0x119, 0x149, bytes.fromhex("bb fe ff ff ff"),
|
|
bytes.fromhex("c4 63 fb f0")),
|
|
11: Profile(0x119, 0x149, bytes.fromhex("bb fe ff ff ff"),
|
|
bytes.fromhex("c4 63 fb f0")),
|
|
}
|
|
|
|
|
|
class Segment(NamedTuple):
|
|
vaddr: int
|
|
data: bytes
|
|
flags: int
|
|
|
|
|
|
def load_image(path: Path) -> list[Segment]:
|
|
data = path.read_bytes()
|
|
if data[:4] != b"\x7fELF":
|
|
return [Segment(0, data, PF_X)]
|
|
if len(data) < 64 or data[4] != 2 or data[5] != 1:
|
|
raise ValueError("expected a little-endian ELF64 or raw kernel image")
|
|
phoff = struct.unpack_from("<Q", data, 32)[0]
|
|
phentsize = struct.unpack_from("<H", data, 54)[0]
|
|
phnum = struct.unpack_from("<H", data, 56)[0]
|
|
if phentsize < 56 or phoff + phentsize * phnum > len(data):
|
|
raise ValueError("invalid program-header table")
|
|
segments = []
|
|
for index in range(phnum):
|
|
pos = phoff + index * phentsize
|
|
p_type, flags, offset, vaddr, _paddr, filesz, _memsz, _align = \
|
|
struct.unpack_from("<IIQQQQQQ", data, pos)
|
|
if p_type != PT_LOAD or not filesz:
|
|
continue
|
|
available = min(filesz, max(0, len(data) - offset))
|
|
segments.append(Segment(vaddr, data[offset:offset + available], flags))
|
|
if not segments:
|
|
raise ValueError("ELF has no readable PT_LOAD segment")
|
|
return segments
|
|
|
|
|
|
def bytes_at(segments: list[Segment], address: int, size: int) -> bytes:
|
|
for segment in segments:
|
|
delta = (address - segment.vaddr) & MASK64
|
|
if delta < len(segment.data):
|
|
return segment.data[delta:delta + size]
|
|
return b""
|
|
|
|
|
|
def find_all(segments: list[Segment], pattern: bytes) -> list[int]:
|
|
found = []
|
|
for segment in segments:
|
|
if not segment.flags & PF_X:
|
|
continue
|
|
start = 0
|
|
while True:
|
|
start = segment.data.find(pattern, start)
|
|
if start < 0:
|
|
break
|
|
found.append((segment.vaddr + start) & MASK64)
|
|
start += 1
|
|
return found
|
|
|
|
|
|
def segment_kdata_anchor(segments: list[Segment]) -> int:
|
|
"""Return the first non-executable PT_LOAD following executable text."""
|
|
executable = sorted(
|
|
(segment for segment in segments if segment.flags & PF_X),
|
|
key=lambda segment: segment.vaddr,
|
|
)
|
|
if not executable:
|
|
raise ValueError("image has no executable segment")
|
|
text_end = max(segment.vaddr + len(segment.data)
|
|
for segment in executable)
|
|
following = sorted(
|
|
segment.vaddr for segment in segments
|
|
if not segment.flags & PF_X and segment.vaddr >= text_end
|
|
)
|
|
if not following:
|
|
raise ValueError("image has no distinct post-text kdata segment")
|
|
return following[0]
|
|
|
|
|
|
def parse_header(path: Path) -> dict[str, int]:
|
|
text = path.read_text(encoding="utf-8")
|
|
return {
|
|
name: int(value, 0)
|
|
for name, value in re.findall(
|
|
r"DEF\((\w+),\s*(-?0x[0-9a-fA-F]+|0)\)", text
|
|
)
|
|
}
|
|
|
|
|
|
def infer_kdata_anchor(segments: list[Segment], offsets: dict[str, int]) \
|
|
-> tuple[int, int]:
|
|
candidates = Counter()
|
|
for name, signature in ANCHOR_SIGNATURES.items():
|
|
delta = offsets.get(name, 0)
|
|
if not delta:
|
|
continue
|
|
for address in find_all(segments, signature):
|
|
anchor = (address - delta) & MASK64
|
|
if anchor & 0xFFFF == 0:
|
|
candidates[anchor] += 1
|
|
if not candidates:
|
|
raise ValueError("could not infer an aligned kdata anchor")
|
|
anchor, score = candidates.most_common(1)[0]
|
|
if score < 2:
|
|
raise ValueError(f"ambiguous kdata anchor (best score {score})")
|
|
return anchor, score
|
|
|
|
|
|
def rel32_call_target(segments: list[Segment], address: int) -> int | None:
|
|
instruction = bytes_at(segments, address, 5)
|
|
if len(instruction) != 5 or instruction[0] != 0xE8:
|
|
return None
|
|
displacement = struct.unpack_from("<i", instruction, 1)[0]
|
|
return (address + 5 + displacement) & MASK64
|
|
|
|
|
|
def version_key(path: Path) -> tuple[str, int, int]:
|
|
match = re.match(r"^(\d+)\.(\d+)", path.stem)
|
|
if not match:
|
|
raise ValueError("filename does not begin with major.minor")
|
|
major, minor = map(int, match.groups())
|
|
return f"{major}_{minor:02d}", major, minor
|
|
|
|
|
|
def validate(path: Path, header_dir: Path) -> list[str]:
|
|
key, major, minor = version_key(path)
|
|
header = header_dir / f"{key}.h"
|
|
if not header.is_file():
|
|
return ["no matching offset header"]
|
|
offsets = parse_header(header)
|
|
if any(not offsets.get(name, 0) for name in PPR_NAMES):
|
|
return ["PPR is not enabled in the matching table"]
|
|
|
|
segments = load_image(path)
|
|
anchor, _score = infer_kdata_anchor(segments, offsets)
|
|
address = lambda name: (anchor + offsets[name]) & MASK64
|
|
profile_key = 101 if major == 1 and minor >= 5 else major
|
|
profile = PROFILES[profile_key]
|
|
errors = []
|
|
|
|
for name in PPR_NAMES:
|
|
if not bytes_at(segments, address(name), 1):
|
|
errors.append(f"{name}: address is outside the image")
|
|
|
|
for kind in ("xts", "cmac"):
|
|
entry = address(f"ppr_pfs_get_{kind}_index")
|
|
return_address = address(f"ppr_pfs_get_{kind}_return")
|
|
if bytes_at(segments, entry, 4) != bytes.fromhex("55 48 89 e5"):
|
|
errors.append(f"get_{kind}_index: not a function prologue")
|
|
target = rel32_call_target(segments, (return_address - 5) & MASK64)
|
|
if target != entry:
|
|
got = "not a call" if target is None else f"target {target:#x}"
|
|
errors.append(f"get_{kind}_return: {got}, expected {entry:#x}")
|
|
|
|
cleanup = address("ppr_pfs_cleanup_keys")
|
|
if bytes_at(segments, cleanup, 4) != bytes.fromhex("55 48 89 e5"):
|
|
errors.append("ppr_pfs_cleanup_keys: not a function prologue")
|
|
for kind, delta in (("cmac", profile.cleanup_cmac_delta),
|
|
("xts", profile.cleanup_xts_delta)):
|
|
if rel32_call_target(segments, (cleanup + delta) & MASK64) is None:
|
|
errors.append(f"cleanup {kind}: expected an E8 rel32 call")
|
|
|
|
clear = address("ppr_pfs_clear_key_missing")
|
|
if bytes_at(segments, clear, len(profile.clear_prefix)) \
|
|
!= profile.clear_prefix:
|
|
errors.append("ppr_pfs_clear_key_missing: unexpected result assignment")
|
|
|
|
verify_lr = address("sceSblServiceMailbox_lr_verifyImage")
|
|
mailbox = address("sceSblServiceMailbox")
|
|
target = rel32_call_target(segments, (verify_lr - 5) & MASK64)
|
|
if target != mailbox:
|
|
got = "not a call" if target is None else f"target {target:#x}"
|
|
errors.append(f"verifyImage LR: {got}, expected {mailbox:#x}")
|
|
|
|
success = address("ppr_pfs_verify_image_no_key_success")
|
|
if bytes_at(segments, success, len(profile.success_prefix)) \
|
|
!= profile.success_prefix:
|
|
errors.append("verifyImage no-key continuation: unexpected instruction")
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("kernels", type=Path,
|
|
help="kernel corpus root containing retail/")
|
|
parser.add_argument("--headers", type=Path,
|
|
default=Path(__file__).resolve().parents[1]
|
|
/ "prosper0gdb" / "offsets")
|
|
args = parser.parse_args()
|
|
retail = args.kernels / "retail"
|
|
if not retail.is_dir():
|
|
parser.error(f"retail directory not found: {retail}")
|
|
|
|
failures = 0
|
|
checked = 0
|
|
for path in sorted(retail.iterdir()):
|
|
if path.suffix.lower() not in (".elf", ".bin"):
|
|
continue
|
|
try:
|
|
key, _major, _minor = version_key(path)
|
|
except ValueError:
|
|
continue
|
|
if not (args.headers / f"{key}.h").is_file():
|
|
continue
|
|
try:
|
|
errors = validate(path, args.headers)
|
|
except (OSError, ValueError, struct.error) as exc:
|
|
errors = [str(exc)]
|
|
if errors == ["PPR is not enabled in the matching table"]:
|
|
print(f"SKIP {path.name}: {errors[0]}")
|
|
continue
|
|
checked += 1
|
|
if errors:
|
|
failures += 1
|
|
print(f"FAIL {path.name}")
|
|
for error in errors:
|
|
print(f" {error}")
|
|
else:
|
|
print(f"PASS {path.name}")
|
|
|
|
print(f"Checked {checked} retail image(s): "
|
|
f"{checked - failures} passed, {failures} failed.")
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|