391 lines
13 KiB
Python
391 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
"""Generate deterministic Phase-0.7 static ELF evidence without execution."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
EXPECTED_COMMITS = {
|
|
"lifecycle": "fe08300339a13f899fb78ea404ada381a5cba87c",
|
|
"loader": "197623058f509eddde18868dafcb92fdcac66464",
|
|
"manager": "e23d94ff91233aa770e2342800c1467875bdef44",
|
|
}
|
|
EXPECTED_HASHES = {
|
|
"lifecycle": "bfb4a5cc768e162fe4c2fddf41c3978e152722918a39085277fd172cb95a7182",
|
|
"loader": "63e810982471eb40cae3a20aa9df9a0a02892f420e429874fae4e99aa400b561",
|
|
"manager": "8fecf8241a46246eddbd21e8bb4d875f5d76f1f4f4c6a11384df1f131aa5e5b1",
|
|
}
|
|
EXPECTED_SIZES = {"lifecycle": 112680, "loader": 397000, "manager": 99560}
|
|
LIFECYCLE_IMPORTS = {"_exit", "sceKernelSendNotificationRequest"}
|
|
LIFECYCLE_NEEDED = {"libSceLibcInternal.sprx", "libkernel_web.sprx"}
|
|
LIFECYCLE_FORBIDDEN = re.compile(
|
|
r"(sceGnm|VideoOut|SDL_|sceNet|socket|connect|dlopen|dlsym|"
|
|
r"kernel_copyin|kernel_copyout|kernel_set_ucred|__patch_init)"
|
|
)
|
|
SENSITIVE_CATEGORIES = {
|
|
"dynamic_loading": re.compile(
|
|
r"(^|_)(dlopen|dlsym)$|^__(dlopen|dlsym)$|dynlib|"
|
|
r"LoadStartModule|StopUnloadModule"
|
|
),
|
|
"graphics_or_display": re.compile(
|
|
r"sceGnm|[Vv]ideoOut|SDL_|[Gg]pu|[Mm][Mm][Ii][Oo]"
|
|
),
|
|
"kernel_runtime_write": re.compile(
|
|
r"__patch_init|kernel_copy(in|out)|kernel_set_ucred|"
|
|
r"kernel_overlap_sockets|syscall.*(patch|set)|"
|
|
r"(patch|set).*syscall"
|
|
),
|
|
"network": re.compile(r"(^|_)(socket|connect|listen|accept)$|sceNet"),
|
|
"ptrace_or_jit": re.compile(r"ptrace|PT_[A-Z]|[Jj][Ii][Tt]"),
|
|
}
|
|
|
|
|
|
def sha256_bytes(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
return sha256_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_head(path: Path, expected: str, lifecycle_source: bool = False) -> str:
|
|
git = [
|
|
"git",
|
|
"-c",
|
|
"core.autocrlf=true",
|
|
"-c",
|
|
"core.fileMode=false",
|
|
"-C",
|
|
str(path),
|
|
]
|
|
head = run([*git, "rev-parse", "HEAD"]).strip()
|
|
if lifecycle_source:
|
|
subprocess.run(
|
|
[*git, "merge-base", "--is-ancestor", expected, head],
|
|
check=True,
|
|
)
|
|
changed = subprocess.run(
|
|
[
|
|
*git,
|
|
"diff",
|
|
"--quiet",
|
|
expected,
|
|
"--",
|
|
"samples/lifecycle_probe/main.c",
|
|
],
|
|
check=False,
|
|
)
|
|
if changed.returncode != 0:
|
|
raise ValueError(f"{path}: lifecycle source differs from {expected}")
|
|
return expected
|
|
if head != expected:
|
|
raise ValueError(f"{path}: expected {expected}, got {head}")
|
|
if run([*git, "status", "--porcelain"]):
|
|
raise ValueError(f"{path}: source tree is dirty")
|
|
return head
|
|
|
|
|
|
def parse_undefined(symbols: str) -> list[str]:
|
|
imports: set[str] = set()
|
|
for line in symbols.splitlines():
|
|
match = re.search(r"\bU\s+(\S+)\s*$", line)
|
|
if match:
|
|
imports.add(match.group(1))
|
|
return sorted(imports)
|
|
|
|
|
|
def parse_needed(dynamic: str) -> list[str]:
|
|
return sorted(set(re.findall(r"Shared library: \[([^\]]+)\]", dynamic)))
|
|
|
|
|
|
def parse_callgraph(disassembly: str) -> list[dict[str, Any]]:
|
|
current = "<outside-symbol>"
|
|
edges: list[dict[str, Any]] = []
|
|
for line in disassembly.splitlines():
|
|
function = re.match(r"^[0-9a-fA-F]+\s+<([^>]+)>:$", line)
|
|
if function:
|
|
current = function.group(1)
|
|
continue
|
|
call = re.match(
|
|
r"^\s*([0-9a-fA-F]+):.*\bcallq?\b\s+(.+?)\s*$", line
|
|
)
|
|
if not call:
|
|
continue
|
|
operand = call.group(2).strip()
|
|
target_match = re.search(r"<([^>]+)>", operand)
|
|
edges.append(
|
|
{
|
|
"address": f"0x{call.group(1).lower()}",
|
|
"from": current,
|
|
"indirect": "*" in operand or target_match is None,
|
|
"target": target_match.group(1) if target_match else operand,
|
|
}
|
|
)
|
|
return edges
|
|
|
|
|
|
def function_name(name: str) -> str:
|
|
return re.sub(r"\+0x[0-9a-fA-F]+$", "", name)
|
|
|
|
|
|
def defined_functions(disassembly: str) -> set[str]:
|
|
return {
|
|
match.group(1)
|
|
for match in re.finditer(
|
|
r"^[0-9a-fA-F]+\s+<([^>]+)>:$", disassembly, re.MULTILINE
|
|
)
|
|
}
|
|
|
|
|
|
def map_symbols(linker_map: str) -> set[str]:
|
|
symbols = set()
|
|
pattern = re.compile(
|
|
r"^\s*[0-9a-fA-F]+\s+[0-9a-fA-F]+\s+[0-9a-fA-F]+"
|
|
r"\s+\d+\s+([A-Za-z_][A-Za-z0-9_.$@]*)\s*$"
|
|
)
|
|
for line in linker_map.splitlines():
|
|
match = pattern.match(line)
|
|
if match:
|
|
symbols.add(match.group(1))
|
|
return symbols
|
|
|
|
|
|
def direct_reachable(
|
|
callgraph: list[dict[str, Any]], entrypoint: str
|
|
) -> set[str]:
|
|
adjacency: dict[str, set[str]] = {}
|
|
for edge in callgraph:
|
|
if edge["indirect"]:
|
|
continue
|
|
source = function_name(edge["from"])
|
|
target = function_name(edge["target"])
|
|
adjacency.setdefault(source, set()).add(target)
|
|
reachable = {entrypoint}
|
|
pending = [entrypoint]
|
|
while pending:
|
|
source = pending.pop()
|
|
for target in adjacency.get(source, set()):
|
|
if target not in reachable:
|
|
reachable.add(target)
|
|
pending.append(target)
|
|
return reachable
|
|
|
|
|
|
def sensitive_inventory(
|
|
disassembly: str,
|
|
linker_map: str,
|
|
callgraph: list[dict[str, Any]],
|
|
entrypoint: str,
|
|
) -> dict[str, Any]:
|
|
defined = defined_functions(disassembly)
|
|
mapped = map_symbols(linker_map)
|
|
reachable = direct_reachable(callgraph, entrypoint)
|
|
categories = {}
|
|
for category, pattern in SENSITIVE_CATEGORIES.items():
|
|
disassembly_linked = sorted(
|
|
name for name in defined if pattern.search(name)
|
|
)
|
|
map_linked = sorted(name for name in mapped if pattern.search(name))
|
|
categories[category] = {
|
|
"directly_reachable_from_entrypoint": sorted(
|
|
name for name in disassembly_linked if name in reachable
|
|
),
|
|
"linked": sorted(set(disassembly_linked) | set(map_linked)),
|
|
"linked_in_disassembly": disassembly_linked,
|
|
"linked_in_linker_map": map_linked,
|
|
}
|
|
return {
|
|
"categories": categories,
|
|
"direct_call_reachability_available": entrypoint in defined,
|
|
"direct_call_reachability_only": True,
|
|
"entrypoint": entrypoint,
|
|
"indirect_call_edges_retained_but_not_resolved": sum(
|
|
1 for edge in callgraph if edge["indirect"]
|
|
),
|
|
}
|
|
|
|
|
|
def normalize_map(data: str) -> str:
|
|
return re.sub(
|
|
r"/tmp/([A-Za-z0-9_]+)-[0-9a-f]{6}\.o",
|
|
r"/tmp/\1-<deterministic-temp>.o",
|
|
data.replace("\r\n", "\n"),
|
|
)
|
|
|
|
|
|
def analyze(
|
|
*,
|
|
name: str,
|
|
first: Path,
|
|
second: Path,
|
|
map_path: Path,
|
|
nm: Path,
|
|
readelf: Path,
|
|
objdump: Path,
|
|
output: Path,
|
|
) -> dict[str, Any]:
|
|
first_bytes = first.read_bytes()
|
|
second_bytes = second.read_bytes()
|
|
if first_bytes != second_bytes:
|
|
raise ValueError(f"{name}: the two clean builds are not byte-identical")
|
|
digest = sha256_bytes(first_bytes)
|
|
if digest != EXPECTED_HASHES[name] or len(first_bytes) != EXPECTED_SIZES[name]:
|
|
raise ValueError(f"{name}: identity differs from the reviewed profile")
|
|
|
|
dynamic = run([str(readelf), "-d", str(first)])
|
|
symbols = run([str(nm), "-u", str(first)])
|
|
headers = run([str(readelf), "-h", "-l", "-S", str(first)])
|
|
relocations = run([str(readelf), "-r", str(first)])
|
|
all_symbols = run([str(readelf), "-Ws", str(first)])
|
|
disassembly = run([str(objdump), "-d", str(first)])
|
|
callgraph = parse_callgraph(disassembly)
|
|
normalized_map = normalize_map(map_path.read_text(encoding="utf-8"))
|
|
sensitive = sensitive_inventory(
|
|
disassembly, normalized_map, callgraph, "_start"
|
|
)
|
|
|
|
reports = {
|
|
"callgraph": json.dumps(callgraph, indent=2, sort_keys=True) + "\n",
|
|
"disassembly": disassembly,
|
|
"dynamic": dynamic,
|
|
"headers_sections": headers,
|
|
"linker_map_normalized": normalized_map,
|
|
"relocations": relocations,
|
|
"symbols": all_symbols,
|
|
"undefined": symbols,
|
|
}
|
|
report_hashes: dict[str, str] = {}
|
|
for report_name, content in reports.items():
|
|
report_path = output / f"{name}.{report_name}.txt"
|
|
report_path.write_text(content, encoding="utf-8", newline="\n")
|
|
report_hashes[report_name] = sha256_file(report_path)
|
|
|
|
imports = parse_undefined(symbols)
|
|
needed = parse_needed(dynamic)
|
|
if name == "lifecycle":
|
|
if set(imports) != LIFECYCLE_IMPORTS:
|
|
raise ValueError(f"lifecycle: unexpected imports {imports}")
|
|
if set(needed) != LIFECYCLE_NEEDED:
|
|
raise ValueError(f"lifecycle: unexpected DT_NEEDED {needed}")
|
|
forbidden_matches = sorted(set(LIFECYCLE_FORBIDDEN.findall(symbols)))
|
|
if forbidden_matches:
|
|
raise ValueError(f"lifecycle: forbidden import {forbidden_matches}")
|
|
if b"phase07-fw960-v1" not in first_bytes or b"9.60" not in first_bytes:
|
|
raise ValueError("lifecycle: firmware/build gates are absent")
|
|
|
|
return {
|
|
"byte_identical_clean_builds": True,
|
|
"call_edges": callgraph,
|
|
"dt_needed": needed,
|
|
"filename": first.name,
|
|
"imports": imports,
|
|
"report_sha256": report_hashes,
|
|
"sensitive_static_inventory": sensitive,
|
|
"sha256": digest,
|
|
"size": len(first_bytes),
|
|
}
|
|
|
|
|
|
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("--manager-source", type=Path, required=True)
|
|
parser.add_argument("--artifact-dir", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
parser.add_argument("--manifest-output", type=Path, required=True)
|
|
parser.add_argument("--nm", type=Path, required=True)
|
|
parser.add_argument("--readelf", type=Path, required=True)
|
|
parser.add_argument("--objdump", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
|
|
root = args.root.resolve()
|
|
sources = {
|
|
"lifecycle": root,
|
|
"loader": args.loader_source.resolve(),
|
|
"manager": args.manager_source.resolve(),
|
|
}
|
|
source_commits = {
|
|
name: git_head(
|
|
path, EXPECTED_COMMITS[name], lifecycle_source=name == "lifecycle"
|
|
)
|
|
for name, path in sources.items()
|
|
}
|
|
artifact_dir = args.artifact_dir.resolve()
|
|
output = args.output.resolve()
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
artifacts: dict[str, Any] = {}
|
|
for name, stem in {
|
|
"lifecycle": "chimera-gfx-lifecycle-probe-phase07",
|
|
"loader": "chimera-elfldr-phase07",
|
|
"manager": "chimera-pldmgr-phase07",
|
|
}.items():
|
|
artifacts[name] = analyze(
|
|
name=name,
|
|
first=artifact_dir / f"{stem}-a.elf",
|
|
second=artifact_dir / f"{stem}-b.elf",
|
|
map_path=artifact_dir / f"{stem}-b.map",
|
|
nm=args.nm.resolve(strict=True),
|
|
readelf=args.readelf.resolve(strict=True),
|
|
objdump=args.objdump.resolve(strict=True),
|
|
output=output,
|
|
)
|
|
|
|
document = {
|
|
"schema_version": 1,
|
|
"decision": "READY_FOR_HARDENED_RUNTIME_DEPLOYMENT",
|
|
"firmware": "9.60",
|
|
"offline_only": True,
|
|
"ps5_actions": {
|
|
"connected": False,
|
|
"installed": False,
|
|
"transferred": False,
|
|
"executed": False,
|
|
},
|
|
"source_commits": source_commits,
|
|
"artifacts": artifacts,
|
|
"forbidden_lifecycle_imports": [],
|
|
"permanent_denylist_sha256": (
|
|
"4be1c17b4964f2b68c39b5145bc4af4619c32512d60269ecf5c39728b390fa63"
|
|
),
|
|
}
|
|
machine_output = output / "phase-0.7-offline-audit.json"
|
|
content = json.dumps(document, indent=2, sort_keys=True) + "\n"
|
|
machine_output.write_text(content, encoding="utf-8", newline="\n")
|
|
args.manifest_output.resolve().write_text(
|
|
content, encoding="utf-8", newline="\n"
|
|
)
|
|
print(
|
|
"Phase-0.7 audit passed: three byte-identical ELF pairs, exact "
|
|
"firmware 9.60 lifecycle imports, and no PS5 action"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except (OSError, subprocess.CalledProcessError, ValueError) as error:
|
|
print(f"Phase-0.7 artifact audit failed: {error}")
|
|
raise SystemExit(1) from error
|