#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Generate the exact Phase-0.6 PS5 loader/runtime audit without execution.""" from __future__ import annotations import argparse import hashlib import json import re import subprocess from pathlib import Path from typing import Any ELFLDR_COMMIT = "699e8bcff03e91e8d6ca6eba281af25c5a58d8c2" ELFLDR_SHA256 = "092d16ee0ede0c494947efd38d1a17bbd7cc4b022d3858ea898833c188c703e8" ELFLDR_SIZE = 397000 PLDMGR_COMMIT = "cfbc70f30f419b09bf2b52283f7409e2d3117ee1" PLDMGR_SHA256 = "518740adbacccb9094fadb07dd424c53ee290f38306449ccc9d6957fdf813c0b" SDK_COMMIT = "d2e2e585740362976a39fdd5ccf390f199a7bc37" def digest_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def digest_file(path: Path) -> str: return digest_bytes(path.read_bytes()) def run(command: list[str]) -> str: result = subprocess.run( command, check=True, capture_output=True, text=True, encoding="utf-8", errors="replace", ) return result.stdout.replace("\r\n", "\n") def git_identity(path: Path, expected: str) -> dict[str, Any]: git = ["git", "-c", "core.autocrlf=true", "-C", str(path)] commit = run([*git, "rev-parse", "HEAD"]).strip() if commit != expected: raise ValueError(f"{path}: expected commit {expected}, got {commit}") status = run([*git, "status", "--porcelain"]) if status: raise ValueError(f"{path}: source checkout is dirty") return {"commit": commit, "dirty": False} def source_record(root: Path, path: Path) -> dict[str, Any]: data = path.read_bytes().replace(b"\r\n", b"\n") return { "normalization": "lf", "path": path.relative_to(root).as_posix(), "sha256": digest_bytes(data), "size": len(data), } def require_tokens(path: Path, tokens: list[str]) -> None: text = path.read_text(encoding="utf-8") missing = [token for token in tokens if token not in text] if missing: raise ValueError(f"{path}: required evidence missing: {missing}") def parse_binary(readelf_output: str, disassembly: str) -> dict[str, Any]: entry_match = re.search(r"Entry point address:\s+(0x[0-9a-f]+)", readelf_output) relocation_match = re.search( r"Relocation section '\.rela\.dyn'.*contains (\d+) entries", readelf_output, ) relative_match = re.search(r"\(RELACOUNT\)\s+(\d+)", readelf_output) if entry_match is None or relocation_match is None or relative_match is None: raise ValueError("readelf output lacks required header/relocation evidence") needed = sorted(re.findall(r"\(NEEDED\).*\[([^\]]+)\]", readelf_output)) dynsym_match = re.search( r"Symbol table '\.dynsym'.*?\n(?P
.*?)(?:\nSymbol table|\Z)", readelf_output, flags=re.DOTALL, ) if dynsym_match is None: raise ValueError("readelf output lacks .dynsym") undefined: list[str] = [] for line in dynsym_match.group("body").splitlines(): match = re.search(r"\bUND\s+(\S+)\s*$", line) if match and match.group(1): undefined.append(match.group(1)) array_sizes: dict[str, int] = {} for name in ("PREINIT_ARRAY", "INIT_ARRAY", "FINI_ARRAY"): match = re.search(rf"\({name}SZ\)\s+(\d+)", readelf_output) if match is None: raise ValueError(f"readelf output lacks {name}SZ") array_sizes[name.lower()] = int(match.group(1)) load_segments = [] for line in readelf_output.splitlines(): match = re.match( r"\s*LOAD\s+\S+\s+\S+\s+\S+\s+(\S+)\s+(\S+)\s+([RWE ]+)\s+\S+", line, ) if match: load_segments.append( { "file_size": int(match.group(1), 16), "memory_size": int(match.group(2), 16), "permissions": match.group(3).replace(" ", ""), } ) return { "disassembly_sha256": digest_bytes(disassembly.encode("utf-8")), "dt_needed": needed, "entry_point": entry_match.group(1), "init_fini_array_sizes": array_sizes, "load_segments": load_segments, "readelf_report_sha256": digest_bytes(readelf_output.encode("utf-8")), "relocations": { "relative": int(relative_match.group(1)), "total": int(relocation_match.group(1)), }, "tls_present": bool( re.search(r"^\s*TLS\s", readelf_output, flags=re.MULTILINE) or re.search(r"\.(?:tdata|tbss)\b", readelf_output) ), "undefined_dynamic_symbols": sorted(undefined), } def effect( effect_id: str, classification: str, scope: str, evidence: list[str], blocker: bool, ) -> dict[str, Any]: return { "blocker": blocker, "classification": classification, "evidence": evidence, "id": effect_id, "scope": scope, } def build_audit(args: argparse.Namespace) -> dict[str, Any]: root = args.root.resolve() loader_source = args.loader_source.resolve() manager_source = args.payload_manager_source.resolve() sdk_source = args.sdk_source.resolve() asset = args.loader_asset.resolve() loader_git = git_identity(loader_source, ELFLDR_COMMIT) manager_git = git_identity(manager_source, PLDMGR_COMMIT) sdk_git = git_identity(sdk_source, SDK_COMMIT) if asset.stat().st_size != ELFLDR_SIZE or digest_file(asset) != ELFLDR_SHA256: raise ValueError("elfldr release asset identity mismatch") loader_files = [ loader_source / name for name in ("main.c", "elfldr.c", "elfldr.h", "pt.c", "pt.h", "socksrv.c", "Makefile") ] manager_files = [ manager_source / name for name in ( "src/ps5_launcher.c", "src/http_server.c", "src/autoload.c", "src/main.c", "src/payload_mgr.c", "include/pldmgr.h", "Makefile", ) ] sdk_files = [ sdk_source / name for name in ( "crt/crt.c", "crt/patch.c", "crt/kernel.c", "include/ps5/payload.h", "crt/Makefile", ) ] require_tokens( loader_source / "elfldr.c", [ "rfork_thread(RFPROC | RFCFDG | RFMEM", "execve(SceSpZeroConf, argv, 0)", "pt_setlong(pid, r.r_rsp-8, r.r_rip)", "r.r_rip = entry", "r.r_rdi = args", "kernel_overlap_sockets", "kernel_set_ucred_uid(pid, 0)", "pt_detach(pid, 0)", ], ) require_tokens( loader_source / "pt.c", [ "kernel_set_ucred_authid(mypid, 0x4800000000010003l)", "kernel_set_ucred_authid(mypid, authid)", "while(jmp_reg.r_rsp <= bak_reg.r_rsp)", ], ) require_tokens( loader_source / "main.c", ["kernel_set_qaflags(qa_flags)", "elfldr_raise_privileges(mypid)"], ) require_tokens( loader_source / "socksrv.c", ["signal(SIGCHLD, SIG_IGN)", "while(1)", "serve_elfldr(port)"], ) require_tokens( manager_source / "src/ps5_launcher.c", ['server_addr.sin_addr.s_addr = inet_addr("127.0.0.1")', "send(sock"], ) require_tokens( manager_source / "src/http_server.c", [ "ps5_launch_elf(final_path)", "fopen(path, \"wb\")", "payload_mgr_import_to_storage", ], ) require_tokens( sdk_source / "crt/crt.c", ["__patch_init()", "payload_terminate(void)", "_start(payload_args_t *args)"], ) readelf_output = run( [ str(args.readelf), "-h", "-l", "-S", "-d", "-r", "-s", "-W", str(asset), ] ) disassembly = run( [str(args.objdump), "-d", "--no-show-raw-insn", str(asset)] ) binary = parse_binary(readelf_output, disassembly) effects = [ effect( "elfldr_first_stage_qaflags_enable", "EXPECTED_VOLATILE_RUNTIME_EFFECT", "existing_loader_bootstrap", ["elfldr/main.c:48-59"], False, ), effect( "elfldr_first_stage_privilege_restore", "UNBOUNDED_OR_UNKNOWN", "existing_exploit_host_process", [ "elfldr/main.c:61-105 restores jail/root/caps/authid", "UID is changed by elfldr_raise_privileges but is not backed up or restored", ], True, ), effect( "payload_process_creation", "PAYLOAD_PROCESS_LOCAL", "new_SceSpZeroConf_child", ["elfldr/elfldr.c:570-710"], False, ), effect( "ptrace_authid_restore_success_path", "RESTORED_BY_LOADER", "elfldr_service_process", ["elfldr/pt.c:35-54"], False, ), effect( "ptrace_authid_restore_failure_path", "UNBOUNDED_OR_UNKNOWN", "elfldr_service_process", [ "elfldr/pt.c:50-51 returns after failed restoration", "no second restoration or process shutdown is present", ], True, ), effect( "ptrace_single_step_completion", "UNBOUNDED_OR_UNKNOWN", "loader_control_path", ["elfldr/pt.c:238-246", "elfldr/pt.c:291-299"], True, ), effect( "breakpoint_byte", "RESTORED_BY_LOADER", "payload_child", ["elfldr/elfldr.c:675-700"], False, ), effect( "breakpoint_page_permissions", "PAYLOAD_PROCESS_LOCAL", "payload_child", [ "elfldr/elfldr.c:669 changes page to RWX", "no source edge restores the original protection", ], False, ), effect( "payload_credentials", "UNBOUNDED_OR_UNKNOWN", "payload_child", [ "elfldr/elfldr.c:447-513 restores jail/root/caps/authid", "UID is set to zero and is not restored", ], True, ), effect( "payload_mapping_args_sockets_pipes", "UNBOUNDED_OR_UNKNOWN", "payload_child", [ "elfldr/elfldr.c:143-343", "successful detach has no explicit unmap/close cleanup", "cleanup depends on an unproven child termination path", ], True, ), effect( "sdk_patch_init", "PAYLOAD_PROCESS_LOCAL", "payload_child", ["sdk/crt/crt.c", "sdk/crt/patch.c"], False, ), effect( "sdk_termination_branch", "UNBOUNDED_OR_UNKNOWN", "payload_child", [ "sdk/crt/crt.c payload_terminate may return, call exit, or trap", "the exact branch for the injected SceSpZeroConf child is not proven", ], True, ), effect( "payload_runtime_limit", "UNBOUNDED_OR_UNKNOWN", "detached_payload_child", [ "elfldr/elfldr.c:703-710 detaches and returns the PID", "no 2000 ms watchdog, wait, kill, or retry budget is present", ], True, ), effect( "child_reaping", "UNBOUNDED_OR_UNKNOWN", "elfldr_service_process", [ "elfldr/socksrv.c:400 ignores SIGCHLD", "post-detach exit/resource cleanup semantics are not documented", ], True, ), effect( "payload_manager_launch_hash_binding", "UNBOUNDED_OR_UNKNOWN", "payload_manager_to_elfldr", [ "pldmgr/http_server.c resolves a path then calls ps5_launch_elf", "pldmgr/ps5_launcher.c streams bytes without calculating or checking SHA-256", ], True, ), effect( "payload_manager_upload", "PERSISTENT_WRITE", "payload_manager_storage", [ "pldmgr/http_server.c writes /data/pldmgr/payloads/