From 72f397a37434223b3219dabdb0ae573d1a3120d4 Mon Sep 17 00:00:00 2001 From: fma-rc <188743521+fma-rc@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:46:55 +0200 Subject: [PATCH 1/4] feat(tools): name a relinked module's imports, stubs and exports in its disassembly --- docs/dev/REVERSE_ENGINEERING.md | 5 + tools/annotate_module.py | 254 ++++++++++++++++++++++++++++ tools/tests/test_annotate_module.py | 136 +++++++++++++++ 3 files changed, 395 insertions(+) create mode 100644 tools/annotate_module.py create mode 100644 tools/tests/test_annotate_module.py diff --git a/docs/dev/REVERSE_ENGINEERING.md b/docs/dev/REVERSE_ENGINEERING.md index bcbcd1ef3a..2c4dce6e01 100644 --- a/docs/dev/REVERSE_ENGINEERING.md +++ b/docs/dev/REVERSE_ENGINEERING.md @@ -63,6 +63,11 @@ read that always reports no data, or a write that drops its data, is not UI. grep -nE 'call +0x1f2e480$' text.dis # callers of the JUMP_SLOT stub at 0x1f2e480 ``` + For a relinked module (`*.guest.prx`), `tools/annotate_module.py --out ` does this cut and + disassembly and writes `.asm`, `.imports.txt` and `.exports.txt`. It names each import + call, PLT stub, GOT reference and export entry from the module's NIDs through the database of + `tools/nid_names.py` (`--db` picks the CSV, `--no-names` prints the NIDs). It needs binutils `objdump`. + A disassembler that loads the program headers (Ghidra, IDA) does the same. 4. Log strings near the call confirm which import it is: `strings -t x eboot.elf`, then the `lea` that loads that address. diff --git a/tools/annotate_module.py b/tools/annotate_module.py new file mode 100644 index 0000000000..8147b0c5ea --- /dev/null +++ b/tools/annotate_module.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +import argparse +import re +import shutil +import struct +import subprocess +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import nid_names + +PT_LOAD = 1 +PT_DYNAMIC = 2 +PF_X = 1 +PF_W = 2 +DT_NULL = 0 +DT_HASH = 4 +DT_STRTAB = 5 +DT_SYMTAB = 6 +DT_RELA = 7 +DT_RELASZ = 8 +DT_JMPREL = 23 +DT_PLTRELSZ = 2 +DT_GNU_HASH = 0x6ffffef5 +SYMBOL_SIZE = 24 +RELA_SIZE = 24 + +LINE_ADDRESS = re.compile(r"^\s*([0-9a-f]+):") +STUB = re.compile(r"^\s*([0-9a-f]+):\s+jmp\s+\*(?:-?0x[0-9a-f]+)?\(%rip\)\s+# 0x([0-9a-f]+)") +BRANCH = re.compile(r"\b(?:call|callq|jmp|jmpq)\s+0x([0-9a-f]+)") +REFERENCE = re.compile(r"# 0x([0-9a-f]+)") + + +class ModuleError(Exception): + pass + + +class Module: + def __init__(self, data): + self.data = data + self.loads = [] + self.dynamic = None + self.read_headers() + self.tags = self.read_tags() + self.strtab = self.address(self.require_tag(DT_STRTAB, "DT_STRTAB")) + self.symtab = self.address(self.require_tag(DT_SYMTAB, "DT_SYMTAB")) + self.symbol_count = self.count_symbols() + + def unpack(self, form, offset): + size = struct.calcsize(form) + if offset < 0 or offset + size > len(self.data): + raise ModuleError("read of %d bytes at file offset %#x is outside the file" % (size, offset)) + return struct.unpack_from(form, self.data, offset) + + def read_headers(self): + if len(self.data) < 64 or self.data[:4] != b"\x7fELF": + raise ModuleError("not an ELF file") + if self.data[4] != 2 or self.data[5] != 1: + raise ModuleError("not a 64-bit little-endian ELF") + if self.unpack("= self.symbol_count: + raise ModuleError("symbol index %d is outside the %d symbols of the module" % (index, self.symbol_count)) + name, _, _, shndx, value, _ = self.unpack("> 32: + slots[offset] = self.symbol(info >> 32)[0] + return slots + + def exports(self): + found = {} + for index in range(1, self.symbol_count): + name, shndx, value = self.symbol(index) + if shndx != 0 and value and name: + found.setdefault(value, name) + return found + + def code_segment(self): + for vaddr, offset, size, flags in self.loads: + if flags & PF_X and not flags & PF_W: + if offset + size > len(self.data): + raise ModuleError("the executable PT_LOAD segment extends past the end of the file") + return vaddr, self.data[offset:offset + size] + raise ModuleError("no executable, non-writable PT_LOAD segment") + + +class Namer: + def __init__(self, db): + self.db = db + + def resolve(self, symbol): + nid = symbol.split("#", 1)[0] + name = nid_names.strip_nid_postfix(self.db.get(nid, "")) + return name if name and nid_names.compute_nid(name) == nid else nid + + +def disassemble(code, vaddr): + with tempfile.TemporaryDirectory() as directory: + blob = Path(directory) / "text.bin" + blob.write_bytes(code) + command = ["objdump", "-D", "-b", "binary", "-m", "i386:x86-64", "--no-show-raw-insn", + "--adjust-vma=%d" % vaddr, str(blob)] + result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace") + if result.returncode != 0: + raise ModuleError("objdump failed: %s" % result.stderr.strip()) + return result.stdout.splitlines() + + +def find_stubs(listing, slots, namer): + stubs = {} + for line in listing: + match = STUB.match(line) + if match and int(match.group(2), 16) in slots: + stubs[int(match.group(1), 16)] = namer.resolve(slots[int(match.group(2), 16)]) + return stubs + + +def line_note(line, slots, stubs, namer): + note = "" + branch = BRANCH.search(line) + if branch and int(branch.group(1), 16) in stubs: + note = " ; -> " + stubs[int(branch.group(1), 16)] + reference = REFERENCE.search(line) + if reference and int(reference.group(1), 16) in slots: + note = " ; GOT " + namer.resolve(slots[int(reference.group(1), 16)]) + return note + + +def annotate_listing(listing, slots, exports, stubs, namer): + out = [] + for line in listing: + head = LINE_ADDRESS.match(line) + if head and int(head.group(1), 16) in exports: + out.append("") + out.append("; ===== export " + namer.resolve(exports[int(head.group(1), 16)])) + out.append(line + line_note(line, slots, stubs, namer)) + return out + + +def write_lines(path, lines): + with open(path, "w", encoding="utf-8", newline="\n") as handle: + for line in lines: + handle.write(line + "\n") + + +def annotate(module_path, prefix, namer): + module = Module(Path(module_path).read_bytes()) + slots = module.imports() + exports = module.exports() + vaddr, code = module.code_segment() + listing = disassemble(code, vaddr) + stubs = find_stubs(listing, slots, namer) + write_lines(prefix + ".imports.txt", ["%#x %s %s" % (slot, slots[slot], namer.resolve(slots[slot])) for slot in sorted(slots)]) + write_lines(prefix + ".exports.txt", ["%#x %s %s" % (address, exports[address], namer.resolve(exports[address])) for address in sorted(exports)]) + write_lines(prefix + ".asm", annotate_listing(listing, slots, exports, stubs, namer)) + return "%s: %d imports, %d exports, %d stubs, %d lines" % (prefix, len(slots), len(exports), len(stubs), len(listing)) + + +def parse_arguments(argv): + parser = argparse.ArgumentParser( + description="Annotated disassembly of a relinked guest module (no section headers). Writes PREFIX.imports.txt, " + "PREFIX.exports.txt and PREFIX.asm, with imports, PLT stubs, GOT references and exports named " + "through the NID database. Requires binutils objdump.") + parser.add_argument("module", help="relinked guest module, for example libc.prx.guest.prx") + parser.add_argument("--out", help="output prefix (default: the module path)") + parser.add_argument("--db", help="NID database CSV (downloaded to a cache dir by default)") + parser.add_argument("--no-names", action="store_true", help="print NIDs without loading the database") + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_arguments(sys.argv[1:] if argv is None else argv) + if shutil.which("objdump") is None: + sys.exit("binutils objdump is required but was not found in PATH") + try: + db = {} if args.no_names else nid_names.load_db(Path(args.db) if args.db else nid_names.DEFAULT_CACHE) + print(annotate(args.module, args.out or args.module, Namer(db))) + except (ModuleError, OSError, UnicodeError) as error: + sys.exit("%s: %s" % (args.module, error)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/tests/test_annotate_module.py b/tools/tests/test_annotate_module.py new file mode 100644 index 0000000000..7b7a2a2f2b --- /dev/null +++ b/tools/tests/test_annotate_module.py @@ -0,0 +1,136 @@ +import contextlib +import io +import shutil +import struct +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import annotate_module +import nid_names + +IMPORT_NAME = "sceKernelGetProcessTime" +EXPORT_NAME = "sceKernelUsleep" +CODE_ADDRESS = 0x1000 +DATA_ADDRESS = 0x2000 +SLOT = 0x2800 +CALL_ADDRESS = 0x1006 +EXPORT_ADDRESS = 0x100B + + +def code_bytes(): + stub = b"\xff\x25" + struct.pack(" " + IMPORT_NAME), call) + self.assertIn("; ===== export " + EXPORT_NAME, listing) + self.assertLess(listing.index("; ===== export " + EXPORT_NAME), next(i for i, line in enumerate(listing) if "ret" in line)) + self.assertEqual([], [path.name for path in self.root.iterdir() if path.suffix == ".bin"]) + + def test_unknown_nid_prints_the_nid(self): + empty = self.root / "empty.csv" + empty.write_text("", encoding="utf-8") + self.run_tool(build_module(), "--db", str(empty)) + nid = nid_names.compute_nid(IMPORT_NAME) + self.assertEqual("%#x %s#A#A %s\n" % (SLOT, nid, nid), self.read(".imports.txt")) + self.assertIn("; -> " + nid, self.read(".asm")) + + def test_no_names_skips_the_database(self): + self.run_tool(build_module(), "--no-names") + self.assertIn("; -> " + nid_names.compute_nid(IMPORT_NAME), self.read(".asm")) + + def test_mismatching_database_name_is_not_used(self): + wrong = self.root / "wrong.csv" + wrong.write_text("%s sceKernelOther\n" % nid_names.compute_nid(IMPORT_NAME), encoding="utf-8") + self.run_tool(build_module(), "--db", str(wrong)) + self.assertNotIn("sceKernelOther", self.read(".asm")) + + def test_missing_hash_fails(self): + with self.assertRaises(SystemExit) as raised: + self.run_tool(build_module(tags=(5, 6, 10, 11, 23, 2, 20)), "--no-names") + self.assertIn("neither DT_HASH nor DT_GNU_HASH", str(raised.exception)) + + def test_missing_symtab_fails(self): + with self.assertRaises(SystemExit) as raised: + self.run_tool(build_module(tags=(4, 5, 10, 11, 23, 2, 20)), "--no-names") + self.assertIn("DT_SYMTAB", str(raised.exception)) + + def test_no_executable_segment_fails(self): + with self.assertRaises(SystemExit) as raised: + self.run_tool(build_module(executable=False), "--no-names") + self.assertIn("no executable, non-writable PT_LOAD", str(raised.exception)) + + def test_wrong_machine_fails(self): + with self.assertRaises(SystemExit) as raised: + self.run_tool(build_module(machine=0xB7), "--no-names") + self.assertIn("not an x86-64 ELF", str(raised.exception)) + + +if __name__ == "__main__": + unittest.main() From b069757f4e2f094f410fb889a71bf3a45734cb6c Mon Sep 17 00:00:00 2001 From: fma-rc <188743521+fma-rc@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:52:05 +0200 Subject: [PATCH 2/4] fix(tools): keep TLS symbols out of the code map, validate relocation tags and disassemble every executable segment --- CMakeLists.txt | 5 + docs/dev/REVERSE_ENGINEERING.md | 7 +- tools/annotate_module.py | 81 +++++++---- tools/tests/test_annotate_module.py | 204 ++++++++++++++++++++-------- 4 files changed, 214 insertions(+), 83 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9eaa66c8db..f9ed71da78 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,6 +158,11 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_progress_read_encoding.py) add_test(NAME import_audit COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_import_audit.py) + if(NOT APPLE) + add_test(NAME annotate_module COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_annotate_module.py) + set_tests_properties(annotate_module PROPERTIES TIMEOUT 20) + endif() add_test(NAME unwrap_self COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_unwrap_self.py) foreach(shard RANGE 0 11) diff --git a/docs/dev/REVERSE_ENGINEERING.md b/docs/dev/REVERSE_ENGINEERING.md index 2c4dce6e01..ec217b1936 100644 --- a/docs/dev/REVERSE_ENGINEERING.md +++ b/docs/dev/REVERSE_ENGINEERING.md @@ -64,9 +64,10 @@ read that always reports no data, or a write that drops its data, is not UI. ``` For a relinked module (`*.guest.prx`), `tools/annotate_module.py --out ` does this cut and - disassembly and writes `.asm`, `.imports.txt` and `.exports.txt`. It names each import - call, PLT stub, GOT reference and export entry from the module's NIDs through the database of - `tools/nid_names.py` (`--db` picks the CSV, `--no-names` prints the NIDs). It needs binutils `objdump`. + disassembly for every executable segment and writes `.asm`, `.imports.txt` and + `.exports.txt`. It names each import call, PLT stub, GOT reference and export entry from the module's NIDs + through the database of `tools/nid_names.py`, which is downloaded on first use unless `--db` names a CSV or + `--no-names` prints the NIDs instead. It needs binutils `objdump`. A disassembler that loads the program headers (Ghidra, IDA) does the same. 4. Log strings near the call confirm which import it is: `strings -t x eboot.elf`, then the `lea` that loads that address. diff --git a/tools/annotate_module.py b/tools/annotate_module.py index 8147b0c5ea..60976fd840 100644 --- a/tools/annotate_module.py +++ b/tools/annotate_module.py @@ -23,13 +23,19 @@ DT_RELA = 7 DT_RELASZ = 8 DT_JMPREL = 23 DT_PLTRELSZ = 2 +DT_PLTREL = 20 +DT_STRSZ = 10 +DT_RELAENT = 9 +DT_REL = 17 +DT_RELA_KIND = 7 +STT_TLS = 6 DT_GNU_HASH = 0x6ffffef5 SYMBOL_SIZE = 24 RELA_SIZE = 24 LINE_ADDRESS = re.compile(r"^\s*([0-9a-f]+):") -STUB = re.compile(r"^\s*([0-9a-f]+):\s+jmp\s+\*(?:-?0x[0-9a-f]+)?\(%rip\)\s+# 0x([0-9a-f]+)") -BRANCH = re.compile(r"\b(?:call|callq|jmp|jmpq)\s+0x([0-9a-f]+)") +STUB = re.compile(r"^\s*([0-9a-f]+):\s+(?:\w+\s+)?jmpq?\s+\*(?:-?0x[0-9a-f]+)?\(%rip\)\s+# 0x([0-9a-f]+)") +BRANCH = re.compile(r"\b(?:call|jmp)q?\s+0x([0-9a-f]+)") REFERENCE = re.compile(r"# 0x([0-9a-f]+)") @@ -46,7 +52,9 @@ class Module: self.tags = self.read_tags() self.strtab = self.address(self.require_tag(DT_STRTAB, "DT_STRTAB")) self.symtab = self.address(self.require_tag(DT_SYMTAB, "DT_SYMTAB")) + self.strsz = self.require_tag(DT_STRSZ, "DT_STRSZ") self.symbol_count = self.count_symbols() + self.check_relocation_tags() def unpack(self, form, offset): size = struct.calcsize(form) @@ -114,22 +122,36 @@ class Module: def symbol(self, index): if index >= self.symbol_count: raise ModuleError("symbol index %d is outside the %d symbols of the module" % (index, self.symbol_count)) - name, _, _, shndx, value, _ = self.unpack("= self.strsz: + raise ModuleError("symbol %d has a name offset %#x outside DT_STRSZ %#x" % (index, name, self.strsz)) start = self.strtab + name end = self.data.find(b"\0", start) if end < 0: raise ModuleError("symbol %d has an unterminated name" % index) - return self.data[start:end].decode("utf-8", errors="replace"), shndx, value + return self.data[start:end].decode("utf-8", errors="replace"), shndx, value, info & 0xF + + def check_relocation_tags(self): + if DT_REL in self.tags: + raise ModuleError("DT_REL relocations are not supported") + if DT_RELA in self.tags: + self.require_tag(DT_RELASZ, "DT_RELASZ") + if DT_JMPREL in self.tags: + self.require_tag(DT_PLTRELSZ, "DT_PLTRELSZ") + if self.require_tag(DT_PLTREL, "DT_PLTREL") != DT_RELA_KIND: + raise ModuleError("DT_PLTREL is not DT_RELA") + if self.tags.get(DT_RELAENT, RELA_SIZE) != RELA_SIZE: + raise ModuleError("DT_RELAENT is not %d" % RELA_SIZE) def relocation_tables(self): - yield self.tags.get(DT_RELA), self.tags.get(DT_RELASZ, 0) - yield self.tags.get(DT_JMPREL), self.tags.get(DT_PLTRELSZ, 0) + if DT_RELA in self.tags: + yield self.tags[DT_RELA], self.tags[DT_RELASZ] + if DT_JMPREL in self.tags: + yield self.tags[DT_JMPREL], self.tags[DT_PLTRELSZ] def imports(self): slots = {} for table, size in self.relocation_tables(): - if table is None: - continue start = self.address(table) for position in range(start, start + size - size % RELA_SIZE, RELA_SIZE): offset, info, _ = self.unpack(" len(self.data): - raise ModuleError("the executable PT_LOAD segment extends past the end of the file") - return vaddr, self.data[offset:offset + size] - raise ModuleError("no executable, non-writable PT_LOAD segment") + raise ModuleError("the executable PT_LOAD segment at %#x extends past the end of the file" % vaddr) + segments.append((vaddr, self.data[offset:offset + size])) + if not segments: + raise ModuleError("no executable PT_LOAD segment") + return segments class Namer: @@ -213,17 +240,25 @@ def write_lines(path, lines): handle.write(line + "\n") +def export_lines(exports, thread_local, namer): + lines = ["%#x %s %s" % (address, exports[address], namer.resolve(exports[address])) for address in sorted(exports)] + lines += ["tls+%#x %s %s" % (offset, thread_local[offset], namer.resolve(thread_local[offset])) for offset in sorted(thread_local)] + return lines + + def annotate(module_path, prefix, namer): module = Module(Path(module_path).read_bytes()) slots = module.imports() - exports = module.exports() - vaddr, code = module.code_segment() - listing = disassemble(code, vaddr) + exports, thread_local = module.exports() + listing = [] + for vaddr, code in module.code_segments(): + listing.append("; ===== segment %#x" % vaddr) + listing.extend(disassemble(code, vaddr)) stubs = find_stubs(listing, slots, namer) write_lines(prefix + ".imports.txt", ["%#x %s %s" % (slot, slots[slot], namer.resolve(slots[slot])) for slot in sorted(slots)]) - write_lines(prefix + ".exports.txt", ["%#x %s %s" % (address, exports[address], namer.resolve(exports[address])) for address in sorted(exports)]) + write_lines(prefix + ".exports.txt", export_lines(exports, thread_local, namer)) write_lines(prefix + ".asm", annotate_listing(listing, slots, exports, stubs, namer)) - return "%s: %d imports, %d exports, %d stubs, %d lines" % (prefix, len(slots), len(exports), len(stubs), len(listing)) + return "%s: %d imports, %d exports, %d tls, %d stubs, %d lines" % (prefix, len(slots), len(exports), len(thread_local), len(stubs), len(listing)) def parse_arguments(argv): diff --git a/tools/tests/test_annotate_module.py b/tools/tests/test_annotate_module.py index 7b7a2a2f2b..4a75d2609f 100644 --- a/tools/tests/test_annotate_module.py +++ b/tools/tests/test_annotate_module.py @@ -1,5 +1,6 @@ import contextlib import io +import re import shutil import struct import sys @@ -13,6 +14,9 @@ import nid_names IMPORT_NAME = "sceKernelGetProcessTime" EXPORT_NAME = "sceKernelUsleep" +SECOND_IMPORT_NAME = "sceKernelGetTscFrequency" +TLS_NAME = "sceKernelGetCurrentCpu" +GNU_HASH = 0x6FFFFEF5 CODE_ADDRESS = 0x1000 DATA_ADDRESS = 0x2000 SLOT = 0x2800 @@ -20,42 +24,76 @@ CALL_ADDRESS = 0x1006 EXPORT_ADDRESS = 0x100B -def code_bytes(): - stub = b"\xff\x25" + struct.pack(" " + IMPORT_NAME), call) - self.assertIn("; ===== export " + EXPORT_NAME, listing) - self.assertLess(listing.index("; ===== export " + EXPORT_NAME), next(i for i, line in enumerate(listing) if "ret" in line)) + self.assertTrue(instruction(listing, "jmp").endswith("; GOT " + IMPORT_NAME)) + self.assertTrue(instruction(listing, "call").endswith("; -> " + IMPORT_NAME)) + header = listing.index("; ===== export " + EXPORT_NAME) + self.assertEqual(instruction(listing, "ret"), listing[header + 1]) self.assertEqual([], [path.name for path in self.root.iterdir() if path.suffix == ".bin"]) def test_unknown_nid_prints_the_nid(self): empty = self.root / "empty.csv" empty.write_text("", encoding="utf-8") self.run_tool(build_module(), "--db", str(empty)) - nid = nid_names.compute_nid(IMPORT_NAME) - self.assertEqual("%#x %s#A#A %s\n" % (SLOT, nid, nid), self.read(".imports.txt")) - self.assertIn("; -> " + nid, self.read(".asm")) + self.assertEqual("%#x %s %s\n" % (SLOT, symbol_name(IMPORT_NAME), nid(IMPORT_NAME)), self.read(".imports.txt")) + self.assertIn("; -> " + nid(IMPORT_NAME), self.read(".asm")) def test_no_names_skips_the_database(self): self.run_tool(build_module(), "--no-names") - self.assertIn("; -> " + nid_names.compute_nid(IMPORT_NAME), self.read(".asm")) + self.assertIn("; -> " + nid(IMPORT_NAME), self.read(".asm")) def test_mismatching_database_name_is_not_used(self): wrong = self.root / "wrong.csv" - wrong.write_text("%s sceKernelOther\n" % nid_names.compute_nid(IMPORT_NAME), encoding="utf-8") + wrong.write_text("%s sceKernelOther\n" % nid(IMPORT_NAME), encoding="utf-8") self.run_tool(build_module(), "--db", str(wrong)) self.assertNotIn("sceKernelOther", self.read(".asm")) + def test_gnu_hash_gives_the_symbol_count(self): + symbols = [(IMPORT_NAME, 0, 0x12, 0), (SECOND_IMPORT_NAME, 0, 0x12, 0), (EXPORT_NAME, 1, 0x12, EXPORT_ADDRESS)] + self.annotate(build_module(tags=(GNU_HASH, 5, 6, 10, 11, 23, 2, 20), symbols=symbols, kind="gnu")) + self.assertIn(EXPORT_NAME, self.read(".exports.txt")) + + def test_glob_dat_slot_referenced_by_an_indirect_call(self): + code = b"\xff\x15" + struct.pack(" " + IMPORT_NAME)) + + def test_tls_symbol_is_listed_but_not_an_address(self): + symbols = DEFAULT_SYMBOLS + [(TLS_NAME, 1, 0x16, EXPORT_ADDRESS)] + summary = self.annotate(build_module(symbols=symbols)) + self.assertIn("1 exports, 1 tls", summary) + self.assertIn("tls+%#x %s %s\n" % (EXPORT_ADDRESS, symbol_name(TLS_NAME), TLS_NAME), self.read(".exports.txt")) + self.assertNotIn("export " + TLS_NAME, self.read(".asm")) + + def test_export_at_address_zero_is_kept(self): + symbols = [(IMPORT_NAME, 0, 0x12, 0), (EXPORT_NAME, 1, 0x12, 0)] + self.annotate(build_module(symbols=symbols)) + self.assertEqual("0x0 %s %s\n" % (symbol_name(EXPORT_NAME), EXPORT_NAME), self.read(".exports.txt")) + + def test_every_executable_segment_is_disassembled(self): + self.annotate(build_module(extra_code=b"\xc3")) + listing = self.read(".asm").splitlines() + first, second = listing.index("; ===== segment 0x1000"), listing.index("; ===== segment 0x3000") + self.assertLess(first, second) + self.assertTrue(any(re.match(r"^\s*3000:\s+ret\b", line) for line in listing[second:])) + def test_missing_hash_fails(self): - with self.assertRaises(SystemExit) as raised: - self.run_tool(build_module(tags=(5, 6, 10, 11, 23, 2, 20)), "--no-names") - self.assertIn("neither DT_HASH nor DT_GNU_HASH", str(raised.exception)) + self.assert_fails(build_module(tags=(5, 6, 10, 11, 23, 2, 20)), "neither DT_HASH nor DT_GNU_HASH") def test_missing_symtab_fails(self): - with self.assertRaises(SystemExit) as raised: - self.run_tool(build_module(tags=(4, 5, 10, 11, 23, 2, 20)), "--no-names") - self.assertIn("DT_SYMTAB", str(raised.exception)) + self.assert_fails(build_module(tags=(4, 5, 10, 11, 23, 2, 20)), "DT_SYMTAB") + + def test_missing_strsz_fails(self): + self.assert_fails(build_module(tags=(4, 5, 6, 11, 23, 2, 20)), "DT_STRSZ") + + def test_missing_pltrelsz_fails(self): + self.assert_fails(build_module(tags=(4, 5, 6, 10, 11, 23, 20)), "DT_PLTRELSZ") + + def test_missing_relasz_fails(self): + self.assert_fails(build_module(tags=(4, 5, 6, 10, 11, 7)), "DT_RELASZ") + + def test_pltrel_must_be_rela(self): + self.assert_fails(build_module(values={20: 17}), "DT_PLTREL is not DT_RELA") + + def test_dt_rel_is_rejected(self): + self.assert_fails(build_module(tags=DEFAULT_TAGS + (17,)), "DT_REL relocations are not supported") + + def test_relaent_must_be_24(self): + self.assert_fails(build_module(tags=DEFAULT_TAGS + (9,), values={9: 16}), "DT_RELAENT") + + def test_name_offset_past_strsz_fails(self): + self.assert_fails(build_module(values={10: 4}), "outside DT_STRSZ") def test_no_executable_segment_fails(self): - with self.assertRaises(SystemExit) as raised: - self.run_tool(build_module(executable=False), "--no-names") - self.assertIn("no executable, non-writable PT_LOAD", str(raised.exception)) + self.assert_fails(build_module(code_flags=6), "no executable PT_LOAD segment") def test_wrong_machine_fails(self): - with self.assertRaises(SystemExit) as raised: - self.run_tool(build_module(machine=0xB7), "--no-names") - self.assertIn("not an x86-64 ELF", str(raised.exception)) + self.assert_fails(build_module(machine=0xB7), "not an x86-64 ELF") if __name__ == "__main__": From d5e423a63a0a75cbff4f1c9c664902ca7b15ce3f Mon Sep 17 00:00:00 2001 From: fma-rc <188743521+fma-rc@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:45:18 +0200 Subject: [PATCH 3/4] fix(tools): find GNU objdump by name and refuse an LLVM objdump with a clear error --- tools/annotate_module.py | 25 +++++++++++++++++++++---- tools/tests/test_annotate_module.py | 26 +++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/tools/annotate_module.py b/tools/annotate_module.py index 60976fd840..02c27a5019 100644 --- a/tools/annotate_module.py +++ b/tools/annotate_module.py @@ -191,11 +191,28 @@ class Namer: return name if name and nid_names.compute_nid(name) == nid else nid +OBJDUMP_NAMES = ("objdump", "gobjdump", "x86_64-linux-gnu-objdump", "x86_64-elf-objdump") + + +def gnu_objdump(): + for name in OBJDUMP_NAMES: + path = shutil.which(name) + if path is None: + continue + result = subprocess.run([path, "--version"], capture_output=True, text=True, encoding="utf-8", errors="replace") + if result.returncode == 0 and result.stdout.startswith("GNU objdump"): + return path + return None + + def disassemble(code, vaddr): + objdump = gnu_objdump() + if objdump is None: + raise ModuleError("GNU binutils objdump was not found as any of %s" % ", ".join(OBJDUMP_NAMES)) with tempfile.TemporaryDirectory() as directory: blob = Path(directory) / "text.bin" blob.write_bytes(code) - command = ["objdump", "-D", "-b", "binary", "-m", "i386:x86-64", "--no-show-raw-insn", + command = [objdump, "-D", "-b", "binary", "-m", "i386:x86-64", "--no-show-raw-insn", "--adjust-vma=%d" % vaddr, str(blob)] result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace") if result.returncode != 0: @@ -265,7 +282,7 @@ def parse_arguments(argv): parser = argparse.ArgumentParser( description="Annotated disassembly of a relinked guest module (no section headers). Writes PREFIX.imports.txt, " "PREFIX.exports.txt and PREFIX.asm, with imports, PLT stubs, GOT references and exports named " - "through the NID database. Requires binutils objdump.") + "through the NID database. Requires GNU binutils objdump (objdump, or gobjdump from Homebrew binutils).") parser.add_argument("module", help="relinked guest module, for example libc.prx.guest.prx") parser.add_argument("--out", help="output prefix (default: the module path)") parser.add_argument("--db", help="NID database CSV (downloaded to a cache dir by default)") @@ -275,8 +292,8 @@ def parse_arguments(argv): def main(argv=None): args = parse_arguments(sys.argv[1:] if argv is None else argv) - if shutil.which("objdump") is None: - sys.exit("binutils objdump is required but was not found in PATH") + if gnu_objdump() is None: + sys.exit("GNU binutils objdump is required but none of %s in PATH is GNU objdump" % ", ".join(OBJDUMP_NAMES)) try: db = {} if args.no_names else nid_names.load_db(Path(args.db) if args.db else nid_names.DEFAULT_CACHE) print(annotate(args.module, args.out or args.module, Namer(db))) diff --git a/tools/tests/test_annotate_module.py b/tools/tests/test_annotate_module.py index 4a75d2609f..38eea32e46 100644 --- a/tools/tests/test_annotate_module.py +++ b/tools/tests/test_annotate_module.py @@ -5,8 +5,10 @@ import shutil import struct import sys import tempfile +import subprocess import unittest from pathlib import Path +from unittest import mock sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import annotate_module @@ -94,9 +96,31 @@ def instruction(listing, mnemonic): return next(line for line in listing if pattern.search(line)) +class ObjdumpDiscoveryTests(unittest.TestCase): + def discover(self, versions): + def which(name): + return "/bin/" + name if name in versions else None + + def run(command, **kwargs): + return subprocess.CompletedProcess(command, 0, versions[Path(command[0]).name], "") + + with mock.patch.object(annotate_module.shutil, "which", which), mock.patch.object(annotate_module.subprocess, "run", run): + return annotate_module.gnu_objdump() + + def test_llvm_objdump_is_passed_over_for_gnu_gobjdump(self): + found = self.discover({"objdump": "Apple LLVM version 21.0.0\n", "gobjdump": "GNU objdump (GNU Binutils) 2.45\n"}) + self.assertEqual(found, "/bin/gobjdump") + + def test_only_llvm_objdump_is_refused_by_name(self): + self.assertIsNone(self.discover({"objdump": "Apple LLVM version 21.0.0\n"})) + with mock.patch.object(annotate_module, "gnu_objdump", lambda: None): + with self.assertRaisesRegex(annotate_module.ModuleError, "GNU binutils objdump was not found as any of objdump, gobjdump"): + annotate_module.disassemble(b"\xc3", 0x1000) + + class AnnotateModuleTests(unittest.TestCase): def setUp(self): - self.assertIsNotNone(shutil.which("objdump"), "binutils objdump is required to run these tests") + self.assertIsNotNone(annotate_module.gnu_objdump(), "GNU binutils objdump (objdump or gobjdump) is required to run these tests") self.directory = tempfile.TemporaryDirectory() self.addCleanup(self.directory.cleanup) self.root = Path(self.directory.name) From d1ac8d9117f909fb344e56d7aa66253f1e243711 Mon Sep 17 00:00:00 2001 From: fma-rc <188743521+fma-rc@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:25:26 +0200 Subject: [PATCH 4/4] fix(tools): bound symbol names to DT_STRSZ and reject relocation tables that are not whole entries --- tools/annotate_module.py | 10 +++++++--- tools/tests/test_annotate_module.py | 11 ++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/tools/annotate_module.py b/tools/annotate_module.py index 02c27a5019..b57b34178a 100644 --- a/tools/annotate_module.py +++ b/tools/annotate_module.py @@ -53,6 +53,8 @@ class Module: self.strtab = self.address(self.require_tag(DT_STRTAB, "DT_STRTAB")) self.symtab = self.address(self.require_tag(DT_SYMTAB, "DT_SYMTAB")) self.strsz = self.require_tag(DT_STRSZ, "DT_STRSZ") + if self.strtab + self.strsz > len(self.data): + raise ModuleError("the string table of DT_STRSZ %#x extends past the end of the file" % self.strsz) self.symbol_count = self.count_symbols() self.check_relocation_tags() @@ -126,9 +128,9 @@ class Module: if name >= self.strsz: raise ModuleError("symbol %d has a name offset %#x outside DT_STRSZ %#x" % (index, name, self.strsz)) start = self.strtab + name - end = self.data.find(b"\0", start) + end = self.data.find(b"\0", start, self.strtab + self.strsz) if end < 0: - raise ModuleError("symbol %d has an unterminated name" % index) + raise ModuleError("symbol %d has a name unterminated within DT_STRSZ %#x" % (index, self.strsz)) return self.data[start:end].decode("utf-8", errors="replace"), shndx, value, info & 0xF def check_relocation_tags(self): @@ -152,8 +154,10 @@ class Module: def imports(self): slots = {} for table, size in self.relocation_tables(): + if size % RELA_SIZE: + raise ModuleError("relocation table at %#x has %d bytes, not a multiple of %d" % (table, size, RELA_SIZE)) start = self.address(table) - for position in range(start, start + size - size % RELA_SIZE, RELA_SIZE): + for position in range(start, start + size, RELA_SIZE): offset, info, _ = self.unpack("> 32: slots[offset] = self.symbol(info >> 32)[0] diff --git a/tools/tests/test_annotate_module.py b/tools/tests/test_annotate_module.py index 38eea32e46..bb01a2b717 100644 --- a/tools/tests/test_annotate_module.py +++ b/tools/tests/test_annotate_module.py @@ -237,7 +237,16 @@ class AnnotateModuleTests(unittest.TestCase): self.assert_fails(build_module(tags=DEFAULT_TAGS + (9,), values={9: 16}), "DT_RELAENT") def test_name_offset_past_strsz_fails(self): - self.assert_fails(build_module(values={10: 4}), "outside DT_STRSZ") + self.assert_fails(build_module(values={10: len(symbol_name(IMPORT_NAME)) + 2}), "symbol 2 has a name offset") + + def test_strsz_past_the_end_of_the_file_fails(self): + self.assert_fails(build_module(values={10: 0x100000}), "string table of DT_STRSZ 0x100000 extends past the end of the file") + + def test_name_unterminated_within_strsz_fails(self): + self.assert_fails(build_module(values={10: 2}), "unterminated within DT_STRSZ") + + def test_relocation_size_not_a_multiple_of_24_fails(self): + self.assert_fails(build_module(values={2: 25}), "not a multiple of 24") def test_no_executable_segment_fails(self): self.assert_fails(build_module(code_flags=6), "no executable PT_LOAD segment")