mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-10-27 00:28:14 +00:00
Merge pull request #9213 from fma-rc/feat/tools-annotate-module
feat(tools): name a relinked module's imports, stubs and exports in its disassembly
This commit is contained in:
4 files changed
+580
No files matched your search
@@ -169,6 +169,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)
|
||||
add_test(NAME audit_dump COMMAND ${Python3_EXECUTABLE}
|
||||
|
||||
@@ -63,6 +63,12 @@ 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 <module> --out <prefix>` does this cut and
|
||||
disassembly for every executable segment and writes `<prefix>.asm`, `<prefix>.imports.txt` and
|
||||
`<prefix>.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.
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/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_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+(?:\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]+)")
|
||||
|
||||
|
||||
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.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()
|
||||
|
||||
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("<H", 0x12)[0] != 0x3E:
|
||||
raise ModuleError("not an x86-64 ELF")
|
||||
phoff, = self.unpack("<Q", 0x20)
|
||||
phentsize, phnum = self.unpack("<HH", 0x36)
|
||||
for index in range(phnum):
|
||||
kind, flags, offset, vaddr, _, filesz, _, _ = self.unpack("<IIQQQQQQ", phoff + index * phentsize)
|
||||
if kind == PT_LOAD:
|
||||
self.loads.append((vaddr, offset, filesz, flags))
|
||||
elif kind == PT_DYNAMIC and self.dynamic is None:
|
||||
self.dynamic = (offset, filesz)
|
||||
if self.dynamic is None:
|
||||
raise ModuleError("no PT_DYNAMIC program header")
|
||||
|
||||
def read_tags(self):
|
||||
tags = {}
|
||||
offset, size = self.dynamic
|
||||
for position in range(offset, offset + size, 16):
|
||||
tag, value = self.unpack("<qQ", position)
|
||||
if tag == DT_NULL:
|
||||
break
|
||||
tags.setdefault(tag, value)
|
||||
return tags
|
||||
|
||||
def require_tag(self, tag, name):
|
||||
if tag not in self.tags:
|
||||
raise ModuleError("the dynamic section has no %s" % name)
|
||||
return self.tags[tag]
|
||||
|
||||
def address(self, vaddr):
|
||||
for start, offset, size, _ in self.loads:
|
||||
if start <= vaddr < start + size:
|
||||
return offset + vaddr - start
|
||||
raise ModuleError("address %#x is outside every PT_LOAD segment" % vaddr)
|
||||
|
||||
def count_symbols(self):
|
||||
if DT_HASH in self.tags:
|
||||
return self.unpack("<I", self.address(self.tags[DT_HASH]) + 4)[0]
|
||||
if DT_GNU_HASH in self.tags:
|
||||
return self.count_gnu_symbols(self.address(self.tags[DT_GNU_HASH]))
|
||||
raise ModuleError("the dynamic section has neither DT_HASH nor DT_GNU_HASH, so the symbol count is unknown")
|
||||
|
||||
def count_gnu_symbols(self, table):
|
||||
buckets, symoffset, bloom_size, _ = self.unpack("<IIII", table)
|
||||
buckets_at = table + 16 + bloom_size * 8
|
||||
chains_at = buckets_at + buckets * 4
|
||||
last = max((self.unpack("<I", buckets_at + 4 * index)[0] for index in range(buckets)), default=0)
|
||||
if last < symoffset:
|
||||
return symoffset
|
||||
while not self.unpack("<I", chains_at + 4 * (last - symoffset))[0] & 1:
|
||||
last += 1
|
||||
return last + 1
|
||||
|
||||
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, info, _, shndx, value, _ = self.unpack("<IBBHQQ", self.symtab + index * SYMBOL_SIZE)
|
||||
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, self.strtab + self.strsz)
|
||||
if end < 0:
|
||||
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):
|
||||
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):
|
||||
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 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, RELA_SIZE):
|
||||
offset, info, _ = self.unpack("<QQq", position)
|
||||
if info >> 32:
|
||||
slots[offset] = self.symbol(info >> 32)[0]
|
||||
return slots
|
||||
|
||||
def exports(self):
|
||||
found = {}
|
||||
thread_local = {}
|
||||
for index in range(1, self.symbol_count):
|
||||
name, shndx, value, kind = self.symbol(index)
|
||||
if shndx == 0 or not name:
|
||||
continue
|
||||
(thread_local if kind == STT_TLS else found).setdefault(value, name)
|
||||
return found, thread_local
|
||||
|
||||
def code_segments(self):
|
||||
segments = []
|
||||
for vaddr, offset, size, flags in sorted(self.loads):
|
||||
if flags & PF_X:
|
||||
if offset + size > len(self.data):
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
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",
|
||||
"--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 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, 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", export_lines(exports, thread_local, namer))
|
||||
write_lines(prefix + ".asm", annotate_listing(listing, slots, exports, stubs, namer))
|
||||
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):
|
||||
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 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)")
|
||||
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 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)))
|
||||
except (ModuleError, OSError, UnicodeError) as error:
|
||||
sys.exit("%s: %s" % (args.module, error))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,259 @@
|
||||
import contextlib
|
||||
import io
|
||||
import re
|
||||
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
|
||||
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
|
||||
CALL_ADDRESS = 0x1006
|
||||
EXPORT_ADDRESS = 0x100B
|
||||
|
||||
|
||||
def nid(name):
|
||||
return nid_names.compute_nid(name)
|
||||
|
||||
|
||||
def symbol_name(name):
|
||||
return nid(name) + "#A#A"
|
||||
|
||||
|
||||
def code_bytes(prefix=b""):
|
||||
stub = prefix + b"\xff\x25" + struct.pack("<i", SLOT - (CODE_ADDRESS + len(prefix) + 6))
|
||||
call_address = CODE_ADDRESS + len(stub)
|
||||
call = b"\xe8" + struct.pack("<i", CODE_ADDRESS - (call_address + 5))
|
||||
return stub + call + b"\xc3"
|
||||
|
||||
|
||||
DEFAULT_SYMBOLS = [(IMPORT_NAME, 0, 0x12, 0), (EXPORT_NAME, 1, 0x12, EXPORT_ADDRESS)]
|
||||
DEFAULT_TAGS = (4, 5, 6, 10, 11, 23, 2, 20)
|
||||
|
||||
|
||||
def hash_bytes(kind, count):
|
||||
if kind == "sysv":
|
||||
return struct.pack("<II", 1, count) + bytes(4 + 4 * count)
|
||||
chains = [0] * (count - 1)
|
||||
chains[-1] = 1
|
||||
return struct.pack("<IIII", 1, 1, 1, 0) + bytes(8) + struct.pack("<I", 1) + struct.pack("<%dI" % len(chains), *chains)
|
||||
|
||||
|
||||
def data_bytes(tags, values, symbols, relocations, glob_dats, kind):
|
||||
strings = b"\0"
|
||||
entries = struct.pack("<IBBHQQ", 0, 0, 0, 0, 0, 0)
|
||||
for name, shndx, info, value in symbols:
|
||||
entries += struct.pack("<IBBHQQ", len(strings), info, 0, shndx, value, 1)
|
||||
strings += symbol_name(name).encode() + b"\0"
|
||||
resolved = {4: DATA_ADDRESS, GNU_HASH: DATA_ADDRESS, 5: DATA_ADDRESS + 0x100, 6: DATA_ADDRESS + 0x40, 10: len(strings), 11: 24,
|
||||
7: DATA_ADDRESS + 0x200, 8: 24 * len(glob_dats), 23: DATA_ADDRESS + 0x280, 2: 24 * len(relocations), 20: 7,
|
||||
9: 24, 17: DATA_ADDRESS + 0x200}
|
||||
resolved.update(values)
|
||||
dynamic = b"".join(struct.pack("<qQ", tag, resolved[tag]) for tag in tags) + struct.pack("<qQ", 0, 0)
|
||||
image = bytearray(0x810)
|
||||
pack = lambda rows, kind_code: b"".join(struct.pack("<QQq", slot, (index << 32) | kind_code, 0) for slot, index in rows)
|
||||
for offset, blob in ((0, hash_bytes(kind, len(symbols) + 1)), (0x40, entries), (0x100, strings), (0x200, pack(glob_dats, 6)),
|
||||
(0x280, pack(relocations, 7)), (0x300, dynamic)):
|
||||
image[offset:offset + len(blob)] = blob
|
||||
return bytes(image), len(dynamic)
|
||||
|
||||
|
||||
def build_module(tags=DEFAULT_TAGS, values=None, symbols=DEFAULT_SYMBOLS, relocations=((SLOT, 1),), glob_dats=(), code=None,
|
||||
code_flags=5, extra_code=None, kind="sysv", machine=0x3E):
|
||||
code = code_bytes() if code is None else code
|
||||
data, dynamic_size = data_bytes(tags, values or {}, symbols, relocations, glob_dats, kind)
|
||||
headers = [(1, code_flags, 0x1000, CODE_ADDRESS, len(code)), (1, 6, 0x2000, DATA_ADDRESS, len(data)),
|
||||
(2, 6, 0x2300, DATA_ADDRESS + 0x300, dynamic_size)]
|
||||
if extra_code is not None:
|
||||
headers.append((1, 7, 0x3000, 0x3000, len(extra_code)))
|
||||
image = bytearray(0x3000 + len(extra_code or b""))
|
||||
image[:64] = struct.pack("<16sHHIQQQIHHHHHH", b"\x7fELF\x02\x01\x01" + bytes(9), 3, machine, 1, 0, 64, 0, 0, 64, 56, len(headers), 0, 0, 0)
|
||||
for index, (kind_code, flags, offset, vaddr, size) in enumerate(headers):
|
||||
image[64 + index * 56:120 + index * 56] = struct.pack("<IIQQQQQQ", kind_code, flags, offset, vaddr, vaddr, size, size, 0x1000)
|
||||
image[0x1000:0x1000 + len(code)] = code
|
||||
image[0x2000:0x2000 + len(data)] = data
|
||||
if extra_code is not None:
|
||||
image[0x3000:] = extra_code
|
||||
return bytes(image)
|
||||
|
||||
|
||||
def instruction(listing, mnemonic):
|
||||
pattern = re.compile(r"^\s*[0-9a-f]+:\s+(?:\w+\s+)?%s\b" % 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(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)
|
||||
self.database = self.root / "db.csv"
|
||||
names = (IMPORT_NAME, SECOND_IMPORT_NAME, TLS_NAME, EXPORT_NAME)
|
||||
self.database.write_text("".join("%s %s%s\n" % (nid(name), name, "_nid_postfix" if name == EXPORT_NAME else "#A#A") for name in names),
|
||||
encoding="utf-8")
|
||||
|
||||
def run_tool(self, module, *extra):
|
||||
path = self.root / "module.prx"
|
||||
path.write_bytes(module)
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
annotate_module.main([str(path), "--out", str(self.root / "out")] + list(extra))
|
||||
return stdout.getvalue()
|
||||
|
||||
def annotate(self, module):
|
||||
return self.run_tool(module, "--db", str(self.database))
|
||||
|
||||
def read(self, suffix):
|
||||
return (self.root / ("out" + suffix)).read_text(encoding="utf-8")
|
||||
|
||||
def assert_fails(self, module, message):
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
self.run_tool(module, "--no-names")
|
||||
self.assertIn(message, str(raised.exception))
|
||||
|
||||
def test_names_imports_stubs_calls_and_exports(self):
|
||||
summary = self.annotate(build_module())
|
||||
self.assertIn("1 imports, 1 exports, 0 tls, 1 stubs", summary)
|
||||
self.assertEqual("%#x %s %s\n" % (SLOT, symbol_name(IMPORT_NAME), IMPORT_NAME), self.read(".imports.txt"))
|
||||
self.assertEqual("%#x %s %s\n" % (EXPORT_ADDRESS, symbol_name(EXPORT_NAME), EXPORT_NAME), self.read(".exports.txt"))
|
||||
listing = self.read(".asm").splitlines()
|
||||
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))
|
||||
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(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(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("<i", SLOT + 8 - (CODE_ADDRESS + 6)) + b"\xc3"
|
||||
symbols = [(IMPORT_NAME, 0, 0x12, 0), (SECOND_IMPORT_NAME, 0, 0x12, 0)]
|
||||
self.annotate(build_module(tags=(4, 5, 6, 10, 11, 7, 8, 23, 2, 20), symbols=symbols, relocations=(), glob_dats=((SLOT + 8, 2),), code=code))
|
||||
self.assertIn("%#x %s %s\n" % (SLOT + 8, symbol_name(SECOND_IMPORT_NAME), SECOND_IMPORT_NAME), self.read(".imports.txt"))
|
||||
self.assertTrue(instruction(self.read(".asm").splitlines(), "call").endswith("; GOT " + SECOND_IMPORT_NAME))
|
||||
|
||||
def test_prefixed_stub_is_recognised(self):
|
||||
self.annotate(build_module(code=code_bytes(b"\xf2")))
|
||||
self.assertTrue(instruction(self.read(".asm").splitlines(), "call").endswith("; -> " + 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):
|
||||
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):
|
||||
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: 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")
|
||||
|
||||
def test_wrong_machine_fails(self):
|
||||
self.assert_fails(build_module(machine=0xB7), "not an x86-64 ELF")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user