#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Audit SDK v0.41 startup and decide whether a minimal ELF may be built.""" from __future__ import annotations import argparse import hashlib import json import re import subprocess import tempfile from collections import deque from pathlib import Path from typing import Any EXPECTED_SDK_COMMIT = "d2e2e585740362976a39fdd5ccf390f199a7bc37" CRT_OBJECTS = { "crt.o": "crt/crt.c", "syscall.o": "crt/syscall.c", "klog.o": "crt/klog.c", "nid.o": "crt/nid.c", "kernel.o": "crt/kernel.c", "rtld.o": "crt/rtld.c", "rtld_so.o": "crt/rtld_so.c", "rtld_sprx.o": "crt/rtld_sprx.c", "rtld_payload.o": "crt/rtld_payload.c", "rtld_dlfcn.o": "crt/rtld_dlfcn.c", "mdbg.o": "crt/mdbg.c", "patch.o": "crt/patch.c", } EMPTY_CRT_ARCHIVES = ( "crti.o", "crtn.o", "crtbegin.o", "crtend.o", "crtbeginS.o", "crtendS.o", ) DANGEROUS_REACHABLE = { "__patch_init", "kernel_copyin", "kernel_copyout", "kernel_set_ucred_attrs", "kernel_set_ucred_caps", } LINKED_PROHIBITED = DANGEROUS_REACHABLE | { "__dlopen", "__dlsym", "kernel_mprotect", "kernel_overlap_sockets", "kernel_set_vmem_protection", } def run(command: list[str], allowed: tuple[int, ...] = (0,)) -> str: result = subprocess.run(command, check=False, capture_output=True, text=True) if result.returncode not in allowed: raise ValueError( f"command failed ({result.returncode}): {' '.join(command)}\n" f"{result.stdout}{result.stderr}" ) return result.stdout + result.stderr def sha256_bytes(content: bytes) -> str: return hashlib.sha256(content).hexdigest() def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def require(text: str, snippets: tuple[str, ...], source: str) -> None: for snippet in snippets: if snippet not in text: raise ValueError(f"{source}: missing pinned evidence: {snippet}") def normalize_target(symbol: str) -> str: symbol = re.sub(r"[-+]0x[0-9a-f]+$", "", symbol) symbol = re.sub(r"[-+][0-9a-f]+$", "", symbol) return symbol def parse_symbols(nm_output: str) -> tuple[set[str], set[str]]: functions: set[str] = set() undefined: set[str] = set() for line in nm_output.splitlines(): parts = line.split() if len(parts) < 2: continue name, kind = parts[0], parts[1] if kind in {"T", "t"}: functions.add(name) elif kind.lower() == "u" or kind == "w": undefined.add(name) return functions, undefined def parse_callgraph(disassembly: str) -> list[dict[str, str]]: label = re.compile(r"^[0-9a-f]+ <([^>]+)>:$") instruction = re.compile( r"^\s*([0-9a-f]+):\s+([a-z][a-z0-9.]*)\s*(.*?)\s*$" ) relocation = re.compile(r"^\s*[0-9a-f]+:\s+R_X86_64_\S+\s+(\S+)") current: str | None = None pending: dict[str, str] | None = None edges: list[dict[str, str]] = [] def flush() -> None: nonlocal pending if pending is None: return operand = pending.pop("operand") direct = re.search(r"<([^>]+)>", operand) if "target" not in pending: if direct is not None and "+0x" not in direct.group(1): pending["target"] = direct.group(1) else: pending["target"] = f"INDIRECT:{operand}" edges.append(pending) pending = None for raw_line in disassembly.splitlines(): line = raw_line.rstrip() match = label.match(line) if match is not None: flush() current = match.group(1) continue match = relocation.match(line) if match is not None and pending is not None: pending["target"] = normalize_target(match.group(1)) flush() continue match = instruction.match(line) if match is None: continue flush() if current is None: continue address, mnemonic, operand = match.groups() if mnemonic in {"call", "callq"}: pending = { "address": address, "caller": current, "kind": "call", "operand": operand, } elif mnemonic in {"jmp", "jmpq"} and ( operand.startswith("*") or ("<" in operand and "+0x" not in operand) ): pending = { "address": address, "caller": current, "kind": "tail_call", "operand": operand, } flush() return edges def reachable_callgraph( edges: list[dict[str, str]], functions: set[str] ) -> tuple[list[str], list[dict[str, str]]]: by_caller: dict[str, list[dict[str, str]]] = {} for edge in edges: by_caller.setdefault(edge["caller"], []).append(edge) visited: set[str] = set() selected: list[dict[str, str]] = [] queue: deque[str] = deque(["_start"]) while queue: caller = queue.popleft() if caller in visited: continue visited.add(caller) for edge in by_caller.get(caller, []): selected.append(edge) target = edge["target"] if target in functions and target not in visited: queue.append(target) return sorted(visited), sorted( selected, key=lambda item: (item["caller"], item["address"], item["target"]) ) def driver_trace(compiler: Path, source: Path, freestanding: bool) -> str: command = [str(compiler), "-###", "-Qunused-arguments", "-Werror"] if freestanding: command.extend(["-nostartfiles", "-nodefaultlibs"]) command.extend([str(source), "-o", str(source.with_suffix(".never.elf"))]) trace = run(command) if source.with_suffix(".never.elf").exists(): raise ValueError("compiler -### unexpectedly produced an ELF") trace = trace.replace(str(source.parent), "") return re.sub(r"trace-[0-9a-f]+\.o", "trace-.o", trace) def git_grep_loader_contract(checkout: Path) -> list[str]: if not (checkout / ".git").exists(): return [] output = run( [ "git", "-C", str(checkout), "grep", "-n", "-e", "payload_args_t", "-e", "payloadout", "--", ], allowed=(0, 1), ) return sorted(line for line in output.splitlines() if line.strip()) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--root", type=Path, required=True) parser.add_argument("--sdk-source", type=Path, required=True) parser.add_argument("--sdk-install", type=Path, required=True) parser.add_argument("--objdump", type=Path, required=True) parser.add_argument("--readelf", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() root = args.root.resolve(strict=True) sdk_source = args.sdk_source.resolve(strict=True) sdk_install = args.sdk_install.resolve(strict=True) sdk_head = run(["git", "-C", str(sdk_source), "rev-parse", "HEAD"]).strip() if sdk_head != EXPECTED_SDK_COMMIT: raise ValueError("SDK source does not match the pinned v0.41 commit") source_files = set(CRT_OBJECTS.values()) | { "Makefile.inc", "crt/Makefile", "crt/payload.h", "include/ps5/payload.h", "host/bin/prospero-clang", "host/bin/prospero-lld", "host/elf_x86_64.x", "host/Makefile", "host/toolchain/prospero.cmake", "host/toolchain/prospero.mk", "host/toolchain/prospero.sh", } source_files.update( str(path.relative_to(sdk_source)).replace("\\", "/") for path in (sdk_source / "crt").glob("*.h") ) source_hashes = { relative: sha256_file(sdk_source / relative) for relative in sorted(source_files) } makefile = (sdk_source / "crt/Makefile").read_text(encoding="utf-8") object_text_match = re.search(r"OBJ := (.+?)\n\n", makefile, re.DOTALL) if object_text_match is None: raise ValueError("could not parse CRT object inventory") makefile_objects = object_text_match.group(1).replace("\\\n", " ").split() if makefile_objects != list(CRT_OBJECTS): raise ValueError(f"CRT object inventory changed: {makefile_objects}") require( makefile, ( "-ffreestanding -fno-builtin -nostdlib -fPIC", "-target x86_64-sie-ps5", "crt1.o: $(OBJ)", "$(LD) -m elf_x86_64 -r -o $@ $^", ), "crt/Makefile", ) crt = (sdk_source / "crt/crt.c").read_text(encoding="utf-8") patch = (sdk_source / "crt/patch.c").read_text(encoding="utf-8") payload_header = (sdk_source / "include/ps5/payload.h").read_text( encoding="utf-8" ) linker_script = (sdk_source / "host/elf_x86_64.x").read_text(encoding="utf-8") compiler_wrapper = (sdk_source / "host/bin/prospero-clang").read_text( encoding="utf-8" ) require( crt, ( "_start(payload_args_t *args)", "for(unsigned char* bss=__bss_start; bss<__bss_end; bss++)", "__crt_syscall_init(args)", "__kernel_init(args)", "__klog_init()", "__patch_init()", "__rtld_init()", "return payload_terminate()", ), "crt/crt.c", ) require( patch, ( "kernel_set_ucred_caps(pid, caps)", "kernel_set_ucred_attrs(pid, attrs)", "caps[15] |= 0x40", "attrs[3] |= 0x80", "kernel_copyin(&uaddr, kaddr + 0xf0, sizeof(uaddr))", "kernel_copyin(&uaddr, kaddr + 0xf8, sizeof(uaddr))", ), "crt/patch.c", ) require( payload_header, ( "int (*sys_dynlib_dlsym)(int, const char*, void*)", "int* rwpipe", "int* rwpair", "intptr_t kpipe_addr", "intptr_t kdata_base_addr", "int* payloadout", ), "include/ps5/payload.h", ) require( linker_script, ( "OUTPUT_ARCH(i386:x86-64)", "PROVIDE_HIDDEN (__bss_start = .)", "PROVIDE_HIDDEN (__bss_end = .)", ".init_array", ".fini_array", "PT_DYNAMIC", ), "host/elf_x86_64.x", ) require( compiler_wrapper, ( 'LIBS_CRT="${PS5_PAYLOAD_SDK}/target/lib/crt1.o"', 'if [[ "$ARG" == "-nostdlibc" ||', '"$ARG" == "-nodefaultlibs"', 'if [[ "$ARG" == "-nostartfiles" ||', "-target x86_64-sie-ps5", ), "host/bin/prospero-clang", ) crt1 = sdk_install / "target/lib/crt1.o" nm = sdk_install / "bin/llvm-nm" ar = sdk_install / "bin/llvm-ar" compiler = sdk_install / "bin/prospero-clang" linker = sdk_install / "bin/prospero-lld" for path in (crt1, nm, ar, compiler, linker, args.objdump, args.readelf): path.resolve(strict=True) nm_output = run([str(nm), "--format=posix", str(crt1)]) functions, undefined = parse_symbols(nm_output) expected_undefined = { "_DYNAMIC", "__bss_end", "__bss_start", "__fini_array_end", "__fini_array_start", "__image_end", "__image_start", "__init_array_end", "__init_array_start", "main", } if undefined != expected_undefined: raise ValueError(f"crt1.o undefined symbol inventory changed: {undefined}") if not DANGEROUS_REACHABLE.issubset(functions): raise ValueError("crt1.o no longer contains the reviewed dangerous functions") if not LINKED_PROHIBITED.issubset(functions): raise ValueError("crt1.o linked prohibited-function inventory changed") empty_archives: dict[str, list[str]] = {} for filename in EMPTY_CRT_ARCHIVES: members = run([str(ar), "t", str(sdk_install / "target/lib" / filename)]) empty_archives[filename] = members.splitlines() if empty_archives[filename]: raise ValueError(f"{filename} unexpectedly contains members") disassembly = run( [str(args.objdump), "-dr", "--no-show-raw-insn", str(crt1)] ) all_edges = parse_callgraph(disassembly) reachable_functions, reachable_edges = reachable_callgraph(all_edges, functions) reachable_set = set(reachable_functions) if not DANGEROUS_REACHABLE.issubset(reachable_set): missing = sorted(DANGEROUS_REACHABLE - reachable_set) raise ValueError(f"disassembly call graph lost expected reachable writes: {missing}") section_table = run([str(args.readelf), "-SW", str(crt1)]) relocation_table = run([str(args.readelf), "-Wr", str(crt1)]) elf_header = run([str(args.readelf), "-h", str(crt1)]) if ".text._start" not in section_table or ".rela.text._start" not in section_table: raise ValueError("crt1.o lacks the audited _start sections") if "__patch_init" not in relocation_table or "kernel_copyin" not in relocation_table: raise ValueError("crt1.o relocation evidence no longer exposes the write path") tls_sections = sorted( match.group(1) for line in section_table.splitlines() if (match := re.search(r"\]\s+(\.\S+)\s+\S+.*\sT\s", line)) is not None ) with tempfile.TemporaryDirectory() as directory: trace_source = Path(directory) / "trace.c" trace_source.write_text("int main(void) { return 0; }\n", encoding="utf-8") default_trace = driver_trace(compiler, trace_source, freestanding=False) free_trace = driver_trace(compiler, trace_source, freestanding=True) for expected in ("crt1.o", '"-lc"', '"-lkernel_web"', '"-lSceLibcInternal"', '"-lSceNet"'): if expected not in default_trace: raise ValueError(f"default driver trace lacks {expected}") for forbidden in ("crt1.o", '"-lc"', '"-lkernel_web"', '"-lSceLibcInternal"', '"-lSceNet"'): if forbidden in free_trace: raise ValueError(f"freestanding driver trace retains {forbidden}") upstream = root / "work/upstream" loader_search: dict[str, list[str]] = {} for name in ("SDL", "RetroArch", "pacbrew-repo", "ps5-linux-loader"): loader_search[name] = git_grep_loader_contract(upstream / name) if any(loader_search.values()): raise ValueError("an unreviewed local loader caller appeared in the evidence set") document: dict[str, Any] = { "schema_version": 1, "audit_date": "2026-07-17", "decision": "BLOCKED", "scope": "phase-0.5-kernelwrite-free-startup-offline-only", "artifact": { "built": False, "execution_eligible": False, "reason": "safe loader return and cleanup are not proven", "sha256": None, }, "sdk": { "release": "v0.41", "commit": EXPECTED_SDK_COMMIT, "target": "x86_64-sie-ps5", "source_hashes": source_hashes, }, "tool_versions": { "prospero_clang": run([str(compiler), "--version"]).splitlines()[0], "prospero_lld": run([str(linker), "--version"]).splitlines()[0], "objdump": run([str(args.objdump), "--version"]).splitlines()[0], "readelf": run([str(args.readelf), "--version"]).splitlines()[0], }, "startup_linkage": { "default_driver_additions": [ "target/lib/crt1.o", "libc", "libkernel_web", "libSceLibcInternal", "libSceNet", ], "default_driver_trace_sha256": sha256_bytes(default_trace.encode()), "freestanding_flags": ["-nostartfiles", "-nodefaultlibs"], "freestanding_driver_additions": [], "freestanding_driver_trace_sha256": sha256_bytes(free_trace.encode()), "crt1_sha256": sha256_file(crt1), "installed_linker_script_sha256": sha256_file( sdk_install / "ldscripts/elf_x86_64.x" ), "installed_main_script_sha256": sha256_file( sdk_install / "target/lib/main.script" ), "installed_prx_script_sha256": sha256_file( sdk_install / "target/lib/prx.script" ), "crt1_composition": [ {"object": obj, "source": source} for obj, source in CRT_OBJECTS.items() ], "empty_startup_archives": empty_archives, "crt1_undefined_symbols": sorted(undefined), }, "crt1_static_evidence": { "disassembly_sha256": sha256_bytes(disassembly.encode()), "elf_header_sha256": sha256_bytes(elf_header.encode()), "section_table_sha256": sha256_bytes(section_table.encode()), "relocation_table_sha256": sha256_bytes(relocation_table.encode()), "relocation_count": len(re.findall(r"R_X86_64_", relocation_table)), "tls_sections": tls_sections, "reachable_functions": reachable_functions, "reachable_callgraph": reachable_edges, "reachable_prohibited_functions": sorted( DANGEROUS_REACHABLE & reachable_set ), "linked_prohibited_functions": sorted(LINKED_PROHIBITED & functions), "linked_but_not_startup_reachable": sorted( LINKED_PROHIBITED - reachable_set ), "source_token_inventory": { "GNM": any( "sceGnm" in (sdk_source / source).read_text(encoding="utf-8") for source in CRT_OBJECTS.values() ), "SDL": any( "SDL_" in (sdk_source / source).read_text(encoding="utf-8") for source in CRT_OBJECTS.values() ), "VideoOut": any( "sceVideoOut" in (sdk_source / source).read_text(encoding="utf-8") for source in CRT_OBJECTS.values() ), "module_loading": True, "network_socket_helper": True, }, }, "loader_evidence": { "sdk_readme_loader_candidates": [ "ps5-payload-dev/elfldr", "cryonumb/elfloader via ps5-jar-loader", "shahrilnet/remote_lua_loader", ], "local_non_sdk_contract_hits": loader_search, "exact_loader_used_for_firmware_9_60_identified": False, "caller_source_present": False, "entry_argument_layout": "SAFE_SOURCE_FACT", "callee_first_argument_register_rdi": "SAFE_DISASSEMBLY_FACT", "caller_stack_alignment": "UNPROVEN", "payloadout_lifetime_and_writability": "UNPROVEN", "return_address_and_return_consumption": "UNPROVEN", "post_return_cleanup": "UNPROVEN", "crash_cleanup": "UNPROVEN", "pre_entry_loader_process_changes": "UNPROVEN", }, "review": [ { "component": "payload_args_t field order and 48-byte x86_64 layout", "status": "SAFE", "basis": "public SDK header plus _start payloadout access at offset 0x28", }, { "component": "SDK v0.41 default crt1.o", "status": "UNSAFE", "basis": "source, relocation, and reachable disassembly prove kernel-write paths", }, { "component": "-nostartfiles -nodefaultlibs removes SDK CRT/default libs", "status": "SAFE", "basis": "compiler -### trace only; no output ELF was produced", }, { "component": "custom BSS zeroing implementation", "status": "SAFE", "basis": "linker symbols and byte-zero loop can be freestanding", }, { "component": "loader entry stack and ABI preconditions", "status": "UNPROVEN", "basis": "callee disassembly does not prove caller behavior", }, { "component": "return, exit, crash, and cleanup behavior", "status": "UNPROVEN", "basis": "no exact pinned public loader caller source is locally available", }, { "component": "loader changes before _start", "status": "UNPROVEN", "basis": "payload args expose pre-existing kernel access but not how it was established", }, ], "custom_artifact_verification": { "linker_map": "NOT_PERFORMED_BLOCKED_BEFORE_BUILD", "disassembly": "NOT_PERFORMED_BLOCKED_BEFORE_BUILD", "undefined_symbols": "NOT_PERFORMED_BLOCKED_BEFORE_BUILD", "dt_needed": "NOT_PERFORMED_BLOCKED_BEFORE_BUILD", "relocations": "NOT_PERFORMED_BLOCKED_BEFORE_BUILD", "init_fini_arrays": "NOT_PERFORMED_BLOCKED_BEFORE_BUILD", "tls": "NOT_PERFORMED_BLOCKED_BEFORE_BUILD", "double_clean_build": "NOT_PERFORMED_BLOCKED_BEFORE_BUILD", "artifact_sha256": None, }, "minimal_missing_evidence": [ "the exact loader and pinned source commit used on firmware 9.60", "the caller instruction sequence that establishes RDI, RSP alignment, and return address", "payload_args_t allocation, payloadout lifetime, writability, and ownership", "the caller path after _start returns, including restoration and cleanup", "crash/fault behavior before and during _start", "all process, credential, syscall, module, and memory changes made before entry", ], "safe_alternatives": [ "continue libchimera-gfx through mock and software backends", "develop a separately scoped Linux-on-PS5 backend as a long-term track", ], } args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text( json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) print( "Phase-0.5 startup audit: BLOCKED; loader return contract is unproven; " "no PS5 ELF built" ) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except (KeyError, OSError, TypeError, ValueError) as error: print(f"startup feasibility audit failed: {error}") raise SystemExit(1) from error