#!/usr/bin/env python3 """Generate SceShellCore bridge offsets from unpacked firmware files. The generator identifies functions through semantic log strings, finds the containing function prologue and emits a compact firmware offset table. It is intentionally independent from IDA databases so the checked-in table can be reproduced from unpacked firmware files. """ from __future__ import annotations import argparse import re import struct from dataclasses import dataclass from pathlib import Path from capstone import CS_ARCH_X86, CS_MODE_64, Cs from capstone.x86 import X86_OP_IMM PROLOGUE = b"\x55\x48\x89\xe5" TARGET_NAMES = ( "launch_app", "sandbox_ready", "install_title_dir", "install_all", ) PAGE_SIZE = 0x4000 RIP_REFERENCE = re.compile( rb"(?=([\x48\x4c][\x8d\x8b][\x05\x0d\x15\x1d\x25\x2d\x35\x3d].{4}))", re.DOTALL, ) @dataclass class LoadSegment: file_offset: int virtual_address: int file_size: int memory_size: int flags: int class ShellCore: def __init__(self, path: Path): self.path = path self.data = path.read_bytes() header = struct.unpack_from("<16sHHIQQQIHHHHHH", self.data, 0) phoff, phentsize, phnum = header[5], header[9], header[10] self.loads: list[LoadSegment] = [] self.executable: LoadSegment | None = None for index in range(phnum): values = struct.unpack_from( " int: for segment in self.loads: relative = offset - segment.file_offset if 0 <= relative < segment.file_size: return segment.virtual_address + relative raise ValueError(f"file offset 0x{offset:x} is not mapped") def virtual_to_file(self, address: int) -> int: for segment in self.loads: relative = address - segment.virtual_address if 0 <= relative < segment.file_size: return segment.file_offset + relative raise ValueError(f"virtual address 0x{address:x} is not mapped") def string_addresses(self, needle: bytes, use_string_start: bool) -> list[int]: result: list[int] = [] offset = 0 while True: offset = self.data.find(needle, offset) if offset < 0: return result string_offset = offset if use_string_start: string_offset = self.data.rfind( b"\0", max(0, offset - 256), offset ) + 1 result.append(self.file_to_virtual(string_offset)) offset += 1 def rip_xrefs(self, targets: list[int], slop: int = 0) -> list[int]: if self._rip_references is None: segment = self.executable assert segment is not None code = self.data[ segment.file_offset : segment.file_offset + segment.file_size ] references: list[tuple[int, int]] = [] for match in RIP_REFERENCE.finditer(code): index = match.start() displacement = struct.unpack_from(" int: end = self.virtual_to_file(address) start = max(self.executable.file_offset, end - window) # type: ignore[union-attr] found = self.data.rfind(PROLOGUE, start, end) if found < 0: raise ValueError(f"no prologue before 0x{address:x}") return self.file_to_virtual(found) def locate_targets(self) -> dict[str, int]: launch_strings = self.string_addresses(b"launchApp(%s)", True) launch_xrefs = self.rip_xrefs(launch_strings) if len(launch_xrefs) != 1: raise ValueError(f"launchApp xrefs: {launch_xrefs!r}") launch = self.preceding_prologue(launch_xrefs[0]) sandbox_strings = self.string_addresses( b"sceApplicationSpawn2(appId, outPid, path, root, argv", True ) sandbox_xrefs = self.rip_xrefs(sandbox_strings) if len(sandbox_xrefs) != 1: raise ValueError(f"sceApplicationSpawn2 xrefs: {sandbox_xrefs!r}") decoder = Cs(CS_ARCH_X86, CS_MODE_64) decoder.detail = True sandbox_xref = sandbox_xrefs[0] sandbox_xref_file = self.virtual_to_file(sandbox_xref) calls = [ (instruction.address, instruction.operands[0].imm, instruction.size) for instruction in decoder.disasm( self.data[sandbox_xref_file : sandbox_xref_file + 0xA0], sandbox_xref, ) if instruction.mnemonic == "call" and instruction.operands and instruction.operands[0].type == X86_OP_IMM ] if len(calls) < 2: raise ValueError( f"sceApplicationSpawn2 call after 0x{sandbox_xref:x} not found" ) # The first call writes the optional trace line. The next direct call # is sceApplicationSpawn2, after ShellCore has finished the sandbox. sandbox_ready, sandbox_ready_target, sandbox_call_size = calls[1] if sandbox_call_size != 5: raise ValueError( f"sceApplicationSpawn2 call at 0x{sandbox_ready:x} " f"has size {sandbox_call_size}" ) install_strings = self.string_addresses(b"AppInstallTitleDirMain", False) install_groups: dict[int, int] = {} for xref in self.rip_xrefs(install_strings, slop=8): prologue = self.preceding_prologue(xref) install_groups[prologue] = install_groups.get(prologue, 0) + 1 if not install_groups: raise ValueError("AppInstallTitleDirMain xrefs not found") best_count = max(install_groups.values()) best = [address for address, count in install_groups.items() if count == best_count] if len(best) != 1: raise ValueError(f"ambiguous AppInstallTitleDirMain: {install_groups!r}") app_install_all_strings = self.string_addresses(b"AppInstallAll()", True) app_install_all_xrefs = self.rip_xrefs(app_install_all_strings) call_targets: dict[int, int] = {} for xref in app_install_all_xrefs: xref_file = self.virtual_to_file(xref) for call_file in range(xref_file - 32, xref_file): if self.data[call_file] != 0xE8: continue call_address = self.file_to_virtual(call_file) displacement = struct.unpack_from(" tuple[int, int]: segment = self.executable assert segment is not None segment_end = segment.virtual_address + segment.memory_size cave_start = (segment_end + 0xF) & ~0xF cave_end = (segment_end + PAGE_SIZE - 1) & ~(PAGE_SIZE - 1) return cave_start, cave_end - cave_start def patch_size(self, address: int, minimum: int = 12) -> int: offset = self.virtual_to_file(address) decoder = Cs(CS_ARCH_X86, CS_MODE_64) size = 0 for instruction in decoder.disasm( self.data[offset : offset + 64], address ): size += instruction.size if size >= minimum: return size raise ValueError(f"cannot determine patch size at 0x{address:x}") def firmware_key(name: str) -> int: major, minor = (int(part) for part in name.split(".", 1)) if major > 99 or minor > 99: raise ValueError(f"firmware version is not BCD-compatible: {name}") major_bcd = ((major // 10) << 4) | (major % 10) minor_bcd = ((minor // 10) << 4) | (minor % 10) return (major_bcd << 8) | minor_bcd def generate(root: Path) -> str: firmware_dirs = sorted( ( path for path in root.iterdir() if (path / "system/vsh/SceShellCore.elf").is_file() ), key=lambda path: tuple(int(part) for part in path.name.split(".")), ) if not firmware_dirs: raise ValueError(f"{root}: no firmware directories found") records: list[str] = [] for firmware_dir in firmware_dirs: firmware = firmware_key(firmware_dir.name) shellcore = ShellCore(firmware_dir / "system/vsh/SceShellCore.elf") targets = shellcore.locate_targets() cave_offset, cave_size = shellcore.bridge_cave() records.append( " {\n" f" .firmware = 0x{firmware:04x}u,\n" f" .name = \"{firmware_dir.name}\",\n" f" .bridge_cave_offset = 0x{cave_offset:x}u,\n" f" .bridge_cave_size = 0x{cave_size:x}u,\n" f" .sandbox_call_target_offset = " f"0x{targets['sandbox_ready_target']:x}u,\n" " .targets = {" + ", ".join( "{.offset = " f"0x{targets[name]:x}u, .patch_size = " f"{5 if name == 'sandbox_ready' else shellcore.patch_size(targets[name])}u}}" for name in TARGET_NAMES ) + "},\n }" ) if not records: raise ValueError(f"{root}: no SceShellCore firmware files found") return "/* Generated by tools/generate_shellcore_offsets.py. */\n" + ",\n".join(records) + "\n" def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("root", type=Path) parser.add_argument("--output", type=Path) args = parser.parse_args() output = generate(args.root) if args.output: with args.output.open("w", encoding="utf-8", newline="\n") as file: file.write(output) else: print(output, end="") return 0 if __name__ == "__main__": raise SystemExit(main())