574 lines
19 KiB
Python
574 lines
19 KiB
Python
#!/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<body>.*?)(?:\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/<name>.tmp",
|
|
"the upload is committed into payload storage",
|
|
],
|
|
True,
|
|
),
|
|
]
|
|
|
|
source_files = {
|
|
"elfldr": [source_record(root, path) for path in loader_files],
|
|
"payload_manager": [source_record(root, path) for path in manager_files],
|
|
"sdk": [source_record(root, path) for path in sdk_files],
|
|
}
|
|
hard_blockers = [
|
|
item["id"]
|
|
for item in effects
|
|
if item["classification"] in ("PERSISTENT_WRITE", "UNBOUNDED_OR_UNKNOWN")
|
|
and item["blocker"]
|
|
]
|
|
hard_blockers.extend(
|
|
[
|
|
"exact_exploit_and_autoloader_identity_unproven",
|
|
"firmware_9_60_not_independently_device_attested",
|
|
"return_continuation_after_payload_start_unproven",
|
|
]
|
|
)
|
|
|
|
return {
|
|
"artifact": {
|
|
"built": False,
|
|
"execution_eligible": False,
|
|
"filename": None,
|
|
"sha256": None,
|
|
"size": None,
|
|
},
|
|
"binary_evidence": binary,
|
|
"callgraph": {
|
|
"entry": "Payload Manager /loadpayload:<path>",
|
|
"edges": [
|
|
["Payload Manager /loadpayload:<path>", "ps5_launch_elf"],
|
|
["ps5_launch_elf", "connect 127.0.0.1:9021"],
|
|
["ps5_launch_elf", "send ELF bytes"],
|
|
["serve_elfldr", "elfldr_spawn"],
|
|
["elfldr_spawn", "rfork_thread"],
|
|
["rfork_thread child", "elfldr_rfork_entry"],
|
|
["elfldr_rfork_entry", "ptrace PT_TRACE_ME"],
|
|
["elfldr_rfork_entry", "execve SceSpZeroConf"],
|
|
["elfldr_spawn parent", "pt_syscall 599"],
|
|
["elfldr_spawn parent", "install then restore INT3 byte"],
|
|
["elfldr_spawn parent", "elfldr_exec"],
|
|
["elfldr_exec", "elfldr_raise_privileges"],
|
|
["elfldr_exec", "elfldr_prepare_exec"],
|
|
["elfldr_prepare_exec", "elfldr_load"],
|
|
["elfldr_prepare_exec", "elfldr_payload_args"],
|
|
["elfldr_prepare_exec", "push observed RIP at RSP-8"],
|
|
["elfldr_prepare_exec", "set RIP=payload entry"],
|
|
["elfldr_prepare_exec", "set RDI=payload_args"],
|
|
["elfldr_exec", "restore subset of credentials"],
|
|
["elfldr_exec", "ptrace PT_DETACH"],
|
|
["payload _start", "SDK __patch_init"],
|
|
["payload _start", "payload main"],
|
|
["payload _start", "payload_terminate"],
|
|
["payload_terminate", "return or exit or trap"],
|
|
],
|
|
"extraction": "reviewed source edges with required-token assertions",
|
|
},
|
|
"decision": "BLOCKED_VERSION_OR_UNBOUNDED_EFFECT",
|
|
"effects": effects,
|
|
"firmware": {
|
|
"device_attested": False,
|
|
"exact": "9.60",
|
|
"evidence": "user_provided_only",
|
|
},
|
|
"hard_blockers": sorted(hard_blockers),
|
|
"identity": {
|
|
"elfldr": {
|
|
**loader_git,
|
|
"installed_asset_hash_match": True,
|
|
"observed_inventory_path": (
|
|
"/data/pldmgr/payloads/elfldr/elfldr_v0.23.elf"
|
|
),
|
|
"observed_inventory_sha256": ELFLDR_SHA256,
|
|
"observed_inventory_version": "v0.23",
|
|
"release": "v0.23",
|
|
"release_asset_sha256": ELFLDR_SHA256,
|
|
"release_asset_size": ELFLDR_SIZE,
|
|
"repository": "https://github.com/ps5-payload-dev/elfldr.git",
|
|
},
|
|
"exact_exploit_autoloader": {
|
|
"identified": False,
|
|
"local_candidate": {
|
|
"filename": "Y2JB-Autoloader-403-1240.zip",
|
|
"sha256": (
|
|
"805e3f87f0c371223619ffc7d3a7b3c0d41a1fae8a8b1171d9e2f162659e8291"
|
|
),
|
|
"size": 504159435,
|
|
"status": "local_backup_candidate_not_installed_identity_proof",
|
|
},
|
|
"status": "UNPROVEN",
|
|
},
|
|
"payload_manager": {
|
|
**manager_git,
|
|
"installed_asset_hash_match": True,
|
|
"observed_inventory_path": (
|
|
"/data/pldmgr/payloads/pldmgr/pldmgr_v0.3.1.elf"
|
|
),
|
|
"observed_inventory_sha256": PLDMGR_SHA256,
|
|
"observed_inventory_version": "v0.3.1",
|
|
"observed_version_endpoint": "0.3.1",
|
|
"release": "v0.3.1",
|
|
"release_asset_sha256": PLDMGR_SHA256,
|
|
"repository": "https://github.com/itsPLK/ps5-payload-manager.git",
|
|
},
|
|
"sdk": {
|
|
**sdk_git,
|
|
"release": "v0.41",
|
|
"repository": "https://github.com/ps5-payload-dev/sdk.git",
|
|
},
|
|
},
|
|
"no_console_actions": {
|
|
"elf_executed": False,
|
|
"elf_transferred": False,
|
|
"gnm": False,
|
|
"raw_port_9021_contacted": False,
|
|
"rendering": False,
|
|
"videoout": False,
|
|
},
|
|
"observation_scope": {
|
|
"date": "2026-07-17",
|
|
"payload_manager_routes": [
|
|
"/autoload_status",
|
|
"/get_config",
|
|
"/list_payloads",
|
|
"/log",
|
|
"/sources_list",
|
|
"/version",
|
|
],
|
|
"payload_manager_routes_read_only": True,
|
|
"strict_status_port_744_result": "ECONNREFUSED",
|
|
},
|
|
"phase": "0.6",
|
|
"schema_version": 1,
|
|
"source_evidence": source_files,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--root", type=Path, required=True)
|
|
parser.add_argument("--loader-source", type=Path, required=True)
|
|
parser.add_argument("--loader-asset", type=Path, required=True)
|
|
parser.add_argument("--payload-manager-source", type=Path, required=True)
|
|
parser.add_argument("--sdk-source", type=Path, required=True)
|
|
parser.add_argument("--readelf", type=Path, required=True)
|
|
parser.add_argument("--objdump", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
document = build_audit(args)
|
|
output = args.output.resolve()
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(
|
|
json.dumps(document, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
print(
|
|
"Phase-0.6 loader audit: BLOCKED_VERSION_OR_UNBOUNDED_EFFECT; "
|
|
f"{len(document['hard_blockers'])} hard blockers"
|
|
)
|
|
return 0
|
|
except (OSError, subprocess.CalledProcessError, ValueError) as error:
|
|
print(f"Phase-0.6 loader audit failed: {error}")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|