diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index 660ff52ea..d19fb0ae8 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -318,34 +318,54 @@ def _containing_procedure(document, address): def _basic_block_end(procedure, block): """Normalize native block endpoints to an exclusive byte address.""" + owner_at = getattr(procedure, "getBasicBlockAtAddress", None) + if not callable(owner_at): + raise CapabilityUnavailableError( + "Instruction enumeration requires Hopper basic-block ownership" + ) end = block.getEndingAddress() # Hopper 6.1 returns the final instruction address, despite the Python # documentation describing an exclusive end. Membership distinguishes # that representation from an actual exclusive endpoint in other builds. - if procedure.getBasicBlockAtAddress(end) == block: + if owner_at(end) == block: instruction = procedure.getSegment().getInstructionAtAddress(end) if instruction is not None and instruction.getInstructionLength() > 0: return end + instruction.getInstructionLength() return end +def _block_instruction_addresses(procedure, block): + """Walk either endpoint convention using Hopper's block ownership API. + + Some Hopper builds return the final instruction start, despite the public + API documenting an exclusive end. Equality compares fresh native wrappers; + probing ownership also keeps an exclusive endpoint in the next block out. + """ + segment = procedure.getSegment() + address = block.getStartingAddress() + end = _basic_block_end(procedure, block) + while address < end: + if procedure.getBasicBlockAtAddress(address) != block: + break + instruction = segment.getInstructionAtAddress(address) + if instruction is None: + break + length = instruction.getInstructionLength() + if length <= 0 or address + length > end: + break + yield address + address += length + + def _instruction_addresses(procedure): result = [] seen = set() - segment = procedure.getSegment() for block in procedure.basicBlockIterator(): - address = block.getStartingAddress() - end = _basic_block_end(procedure, block) - while address < end and address not in seen: + for address in _block_instruction_addresses(procedure, block): + if address in seen: + continue seen.add(address) - instruction = segment.getInstructionAtAddress(address) - if instruction is None: - break result.append(address) - length = instruction.getInstructionLength() - if length <= 0: - break - address += length return result diff --git a/tests/boundary/providers/hopper/hopperInstructions.test.ts b/tests/boundary/providers/hopper/hopperInstructions.test.ts new file mode 100644 index 000000000..db22c3b9f --- /dev/null +++ b/tests/boundary/providers/hopper/hopperInstructions.test.ts @@ -0,0 +1,105 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +const execute = promisify(execFile); +const bridge = fileURLToPath( + new URL("../../../../bridge/hopper_bridge.py", import.meta.url), +); +const probe = fileURLToPath( + new URL("../../../fixtures/hopperInstructionProbe.py", import.meta.url), +); +const resultSchema = z.object({ + addresses: z.array(z.number()), + blocks: z.array(z.object({ start: z.string(), end: z.string() })), + length: z.number(), + assembly: z.array(z.string()), + fast: z.array(z.string()), + dossier: z.array(z.string()), + references: z.array(z.object({ source_address: z.string() })), +}); + +type ProbeResult = z.infer; + +async function runProbe(scenario: string): Promise { + const { stdout } = await execute("python3", [probe, bridge, scenario], { + encoding: "utf8", + timeout: 3_000, + maxBuffer: 1_024 * 1_024, + }); + return resultSchema.parse(JSON.parse(stdout)); +} + +describe("Hopper instruction block boundaries", () => { + it.each(["inclusive", "exclusive", "duplicate"])( + "preserves branches and returns with %s endpoints without duplicates or adjacent instructions", + async (scenario) => { + const result = await runProbe(scenario); + expect(result.addresses).toEqual([0x1000, 0x1004, 0x1008]); + if (scenario !== "duplicate") { + expect(result.blocks).toEqual([ + { start: "0x1000", end: "0x1008" }, + { start: "0x1008", end: "0x100c" }, + ]); + expect(result.length).toBe(12); + } + const lines = ["0x1000: mov", "0x1004: b.ne", "0x1008: ret"]; + expect(result.assembly).toEqual(lines); + expect(result.fast).toEqual(lines); + expect(result.dossier).toEqual(lines); + expect(result.references.map((edge) => edge.source_address)).toEqual([ + "0x1004", + ]); + }, + ); + + it.each([ + ["gap", [0x1000]], + ["zero_length", [0x1000]], + ["negative_length", [0x1000]], + ["cross_boundary", []], + ["foreign_owner", [0x1000, 0x1004]], + ["reversed", []], + ])("stops safely at %s", async (scenario, addresses) => { + const result = await runProbe(scenario); + expect(result.addresses).toEqual(addresses); + expect(result.assembly).toHaveLength(addresses.length); + expect(result.fast).toEqual(result.assembly); + expect(result.dossier).toEqual(result.assembly); + }); + + it("uses instruction lengths without assuming ARM64 fixed-width encoding", async () => { + const result = await runProbe("variable_length"); + expect(result.addresses).toEqual([0x1000, 0x1003, 0x1005]); + expect(result.blocks).toEqual([ + { start: "0x1000", end: "0x1005" }, + { start: "0x1005", end: "0x1006" }, + ]); + expect(result.length).toBe(6); + expect(result.assembly).toEqual([ + "0x1000: mov", + "0x1003: b.ne", + "0x1005: ret", + ]); + expect(result.fast).toEqual(result.assembly); + expect(result.dossier).toEqual(result.assembly); + }); + + it("reports missing ownership support instead of guessing endpoint semantics", async () => { + const { stdout } = await execute( + "python3", + [probe, bridge, "no_ownership"], + { + encoding: "utf8", + timeout: 3_000, + maxBuffer: 1_024 * 1_024, + }, + ); + expect( + z.object({ error: z.string() }).parse(JSON.parse(stdout)).error, + ).toBe("Instruction enumeration requires Hopper basic-block ownership"); + }); +}); diff --git a/tests/fixtures/hopperInstructionProbe.py b/tests/fixtures/hopperInstructionProbe.py new file mode 100644 index 000000000..8e023a0ce --- /dev/null +++ b/tests/fixtures/hopperInstructionProbe.py @@ -0,0 +1,191 @@ +"""Exercise the real bridge instruction walker with Hopper-shaped objects.""" + +import json +from pathlib import Path +import sys + + +class Block: + def __init__(self, key, start, end, members): + self.key, self.start, self.end, self.members = key, start, end, members + + def __eq__(self, other): + return isinstance(other, Block) and self.key == other.key + + def __hash__(self): + return hash(self.key) + + def getStartingAddress(self): + return self.start + + def getEndingAddress(self): + return self.end + + def getSuccessorCount(self): + return 0 + + +class Instruction: + def __init__(self, mnemonic, length=4): + self.mnemonic, self.length = mnemonic, length + + def getInstructionLength(self): + return self.length + + def getInstructionString(self): + return self.mnemonic + + def getArgumentCount(self): + return 0 + + +class Segment: + def __init__(self, instructions): + self.instructions = instructions + + def getInstructionAtAddress(self, address): + return self.instructions.get(address) + + def getNameAtAddress(self, address): + return "fixture" + + def getReferencesFromAddress(self, address): + return [0x9000] if self.instructions[address].mnemonic.startswith("b") else [] + + def getReferencesOfAddress(self, address): + return [] + + def getCommentAtAddress(self, address): + return None + + def getInlineCommentAtAddress(self, address): + return None + + +class Procedure: + def __init__(self, blocks, segment): + self.blocks, self.segment = blocks, segment + + def basicBlockIterator(self): + return iter(self.blocks) + + def getBasicBlockAtAddress(self, address): + for block in self.blocks: + if address in block.members: + # Hopper returns fresh wrappers whose native handles compare equal. + return Block(block.key, block.start, block.end, block.members) + return None + + def getBasicBlockCount(self): + return len(self.blocks) + + def getAllCallees(self): + return [] + + def getAllCallers(self): + return [] + + def getSegment(self): + return self.segment + + def getEntryPoint(self): + return self.blocks[0].start + + def signatureString(self): + return "int fixture()" + + def getLocalVariableList(self): + return [] + + def getAllCallerProcedures(self): + return [] + + def getAllCalleeProcedures(self): + return [] + + def decompile(self): + return "fixture pseudocode" + + +def main(): + path = sys.argv[1] + bridge = {"__file__": path, "__name__": "rea_hopper_bridge"} + exec(compile(Path(path).read_text(encoding="utf-8"), path, "exec"), bridge) + scenario = sys.argv[2] + instructions = {0x1000: Instruction("mov"), 0x1004: Instruction("b.ne"), + 0x1008: Instruction("ret"), 0x100C: Instruction("nop")} + if scenario == "inclusive": + blocks = [Block(0, 0x1000, 0x1004, {0x1000, 0x1004}), + Block(1, 0x1008, 0x1008, {0x1008})] + elif scenario == "exclusive": + blocks = [Block(0, 0x1000, 0x1008, {0x1000, 0x1004}), + Block(1, 0x1008, 0x100C, {0x1008})] + elif scenario == "duplicate": + block = Block(0, 0x1000, 0x1008, {0x1000, 0x1004, 0x1008}) + blocks = [block, block] + elif scenario == "gap": + instructions.pop(0x1004) + blocks = [Block(0, 0x1000, 0x1008, {0x1000, 0x1004, 0x1008})] + elif scenario == "zero_length": + instructions[0x1004] = Instruction("invalid", 0) + blocks = [Block(0, 0x1000, 0x1008, {0x1000, 0x1004, 0x1008})] + elif scenario == "negative_length": + instructions[0x1004] = Instruction("invalid", -4) + blocks = [Block(0, 0x1000, 0x1008, {0x1000, 0x1004, 0x1008})] + elif scenario == "cross_boundary": + instructions[0x1000] = Instruction("invalid", 8) + blocks = [Block(0, 0x1000, 0x1004, {0x1000})] + elif scenario == "foreign_owner": + blocks = [Block(0, 0x1000, 0x100C, {0x1000, 0x1004})] + elif scenario == "reversed": + blocks = [Block(0, 0x1008, 0x1000, {0x1008})] + elif scenario == "variable_length": + instructions = {0x1000: Instruction("mov", 3), + 0x1003: Instruction("b.ne", 2), + 0x1005: Instruction("ret", 1), + 0x1006: Instruction("nop", 1)} + blocks = [Block(0, 0x1000, 0x1003, {0x1000, 0x1003}), + Block(1, 0x1005, 0x1005, {0x1005})] + elif scenario == "no_ownership": + blocks = [Block(0, 0x1000, 0x1004, {0x1000})] + else: + raise ValueError("Unknown scenario") + segment = Segment(instructions) + procedure = Procedure(blocks, segment) + if scenario == "no_ownership": + procedure.getBasicBlockAtAddress = None + try: + bridge["_instruction_addresses"](procedure) + except bridge["CapabilityUnavailableError"] as error: + print(json.dumps({"error": str(error)})) + return + raise AssertionError("Missing ownership API was silently accepted") + class API: + def require_analysis_complete(self, document, method): + pass + + class Document: + def getSegmentsList(self): + return [] + + document = Document() + bridge["_api"] = lambda: API() + bridge["_document"] = lambda name=None: document + bridge["_procedure"] = lambda document, value=None: procedure + bridge["_segment"] = lambda document, address: segment + bridge["_containing_procedure"] = lambda document, address: (None, None) + bridge["_search_inventory"] = lambda document, kind: [] + dossier = bridge["_analyze_function"](document, {}) + print(json.dumps({ + "blocks": dossier["basic_blocks"], + "length": bridge["_dispatch"]("procedure_info", {})["length"], + "addresses": bridge["_instruction_addresses"](procedure), + "assembly": bridge["_assembly"](procedure).splitlines(), + "fast": bridge["_read_function_instructions"](document, {})["instructions"], + "dossier": dossier["assembly"], + "references": bridge["_procedure_references"](document, {})["references"], + })) + + +if __name__ == "__main__": + main()