From dceb4d68ec89274c42dbeaedf95fffb70cd0340b Mon Sep 17 00:00:00 2001 From: r0ny123 <49360849+r0ny123@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:33:53 +0530 Subject: [PATCH 1/3] build: pin capstone below 6 Capstone 6 renames the arm64 modules and constants to aarch64. It ships a compatibility shim for the old names, but the shim is partial: six of the twenty ARM64_* constants this codebase uses are missing from it, and it only takes effect once capstone.arm64 or capstone.arm64_const has been imported. Three modules import CS_ARCH_ARM64 straight from the capstone package before touching either submodule, so on capstone 6 they raise ImportError as soon as they load: smda/aarch64/AArch64Backend.py, smda/common/SmdaReport.py and smda/ida/IdaExporter.py. Reordering those imports is not enough on its own. With the shim loaded first, smda/aarch64/AArch64CapstoneVerification.py still fails on ARM64_OP_BARRIER, which capstone 6 no longer provides under that name. Moving to 6.x is a port rather than an import fix, so pin the dependency until someone does that port. Measured against capstone 6.0.0a10. --- pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5c809546..c9113105 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,11 @@ classifiers = [ ] dynamic = ["version"] dependencies = [ - "capstone", + # capstone 6 renames the arm64 modules and constants to aarch64. Its compatibility shim + # is partial - 6 of the 20 ARM64_* constants used here are absent from it, and the shim + # only loads at all once capstone.arm64* has been imported - so 6.x is a port, not an + # import-order fix. Measured against 6.0.0a10. + "capstone<6", "dncil", "dnfile", "lief>=0.16.0", From 0e0c92a5ed7b007b908cf65700d224f490818bc1 Mon Sep 17 00:00:00 2001 From: r0ny123 <49360849+r0ny123@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:34:10 +0530 Subject: [PATCH 2/3] fix(aarch64): recover signed jump tables and drop stale link registers Two defects in the AArch64 recovery path, both about a register write the analyzer could not see. A relative jump table is signed whenever its entries can point backwards. The analyzer worked that out from an ldrsw, or from a standalone sxtw, but ARM64 also lets the compiler fold the sign-extension into the add that merges the index with the table base, as in "add x8, x8, w9, sxtw #2". Nothing else in that sequence marks the entries as signed, so the table was read as unsigned, every backward entry became a multi-gigabyte offset, and the walk aborted on the first one and lost the rest of the table. The add chase now reads the operand's extend field next to its shift. Separately, bl and blr overwrite x30, but capstone reports that write only in its implicit register list: bl's single operand is the branch target, and blr's operand is read rather than written. The constant tracker walks operands, so a value it had resolved into x30 survived a call and could still be handed to a later indirect-call or jump-table lookup. It now drops every register capstone marks as an implicit write, which covers both. One caveat for reviewers: the folded sign-extension is legal ARM64 but rare. It shows up zero times in 1105 branch-register sites across 669 system arm64 binaries, zero times in this repository's own AArch64 fixtures, and zero times in clang output at five optimisation levels for two targets. ldrsw is the usual choice there and was already handled. This is kept as a completeness path, not a measured recovery win. Instances: 2 files Validation: make lint, make test --- src/smda/aarch64/analyzers.py | 20 +++++++-- src/smda/aarch64/dataflow.py | 13 ++++++ tests/testAArch64Dataflow.py | 28 +++++++++++++ tests/testAArch64JumpTableLsl.py | 69 ++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) diff --git a/src/smda/aarch64/analyzers.py b/src/smda/aarch64/analyzers.py index edfd28ef..787e38b2 100644 --- a/src/smda/aarch64/analyzers.py +++ b/src/smda/aarch64/analyzers.py @@ -11,7 +11,13 @@ from collections import Counter from copy import deepcopy -from capstone.arm64_const import ARM64_SFT_LSL +from capstone.arm64_const import ( + ARM64_EXT_SXTB, + ARM64_EXT_SXTH, + ARM64_EXT_SXTW, + ARM64_EXT_SXTX, + ARM64_SFT_LSL, +) from .dataflow import ( _applyConstantWrite, @@ -30,6 +36,8 @@ # just burn time chasing edges that don't exist yet. JUMPTABLE_PREDECESSOR_DEPTH = 2 +SIGNED_EXTENDS = frozenset((ARM64_EXT_SXTB, ARM64_EXT_SXTH, ARM64_EXT_SXTW, ARM64_EXT_SXTX)) + def _updateConstantTracking(d, cap_ins, constants): """Extend `constants` from one instruction via the shared dataflow tracker.""" @@ -603,8 +611,14 @@ def getJumpTargets(self, jump_instruction, state): table_base = constants[reg2] is_relative = True index_op = op1 - if index_op is not None and index_op.shift.type == ARM64_SFT_LSL: - entry_shift = index_op.shift.value + if index_op is not None: + if index_op.shift.type == ARM64_SFT_LSL: + entry_shift = index_op.shift.value + # `add Xd, Xbase, Wi, sxtw #2` folds the index's sign-extension into + # the merging add, so a signed table can reach this point without any + # ldrsw/sxtw of its own to mark it. + if index_op.ext in SIGNED_EXTENDS: + is_signed = True elif op1.type == 1 and op2.type == 2: # REG + IMM reg1 = norm_reg(ins.reg_name(op1.reg)) tracked_regs.add(reg1) diff --git a/src/smda/aarch64/dataflow.py b/src/smda/aarch64/dataflow.py index 406c1182..a26600b3 100644 --- a/src/smda/aarch64/dataflow.py +++ b/src/smda/aarch64/dataflow.py @@ -61,9 +61,22 @@ def _invalidateWritebackBase(cap_ins, constants): constants.pop(norm_reg(cap_ins.reg_name(op.mem.base)), None) +def _invalidateImplicitWrites(cap_ins, constants): + """Pop every register the instruction writes without naming it in an operand. + + ``bl`` and ``blr`` clobber x30, which capstone reports only in the implicit + ``regs_write`` list - ``bl``'s sole operand is the branch immediate, so neither the + modelled branches (which read ``operands[0]``) nor the CS_AC_WRITE fallback (which + walks register operands) can see it. + """ + for reg in cap_ins.regs_write: + constants.pop(norm_reg(cap_ins.reg_name(reg)), None) + + def _applyConstantWrite(cap_ins, constants, disassembler): """Apply one instruction's effect (if any) to a dest-register -> value map, in place.""" _applyRegisterWrite(cap_ins, constants, disassembler) + _invalidateImplicitWrites(cap_ins, constants) # after the instruction's own effect: a post-index load still reads the base's # pre-increment value, so the invalidation must not run before it is resolved _invalidateWritebackBase(cap_ins, constants) diff --git a/tests/testAArch64Dataflow.py b/tests/testAArch64Dataflow.py index 04c8dd7f..4dd3abf5 100644 --- a/tests/testAArch64Dataflow.py +++ b/tests/testAArch64Dataflow.py @@ -117,6 +117,34 @@ def test_plain_offset_load_keeps_base_register(self): self.assertEqual(constants.get("x1"), 0x100) + def test_bl_invalidates_the_link_register_it_writes_implicitly(self): + # adrp x30, #0x1000 ; bl #... -> the branch clobbers x30 (capstone names it lr and + # reports it only in the implicit regs_write list, since bl's sole operand is the + # branch immediate), so the tracked page value must not survive the call. + instructions = _decode(self.cs, [0x9000001E, 0x94000001]) + self.assertEqual(instructions[0].mnemonic, "adrp") + self.assertEqual(instructions[1].mnemonic, "bl") + + self.assertEqual(propagateConstants(instructions[:1], None).get("lr"), 0x1000) + self.assertNotIn("lr", propagateConstants(instructions, None)) + + def test_blr_invalidates_the_link_register_it_writes_implicitly(self): + # The same implicit x30 write on the register-indirect call, whose own operand is a + # register - so it clears the modelled branches' operands[0] check and would still + # never reach the link register without the implicit-write pass. + instructions = _decode(self.cs, [0x9000001E, 0xD63F0100]) + self.assertEqual(instructions[1].mnemonic, "blr") + + self.assertNotIn("lr", propagateConstants(instructions, None)) + + def test_br_leaves_tracked_registers_alone(self): + # Counter-case: a plain indirect branch writes no register, so the implicit-write + # pass must not invalidate anything the modelled branches resolved. + instructions = _decode(self.cs, [0x9000001E, 0xD61F0100]) + self.assertEqual(instructions[1].mnemonic, "br") + + self.assertEqual(propagateConstants(instructions, None).get("lr"), 0x1000) + class DataRefImmediateGateTestSuite(unittest.TestCase): """The gate that decides whether _recordDataRefs re-decodes an instruction for details.""" diff --git a/tests/testAArch64JumpTableLsl.py b/tests/testAArch64JumpTableLsl.py index 2c258ebc..9754f6c3 100644 --- a/tests/testAArch64JumpTableLsl.py +++ b/tests/testAArch64JumpTableLsl.py @@ -140,6 +140,75 @@ def addDataRef(self, from_addr, to_addr, size=1): self.assertEqual(targets[:3], [0x411108, 0x411110, 0x411118]) + def test_extended_register_add_marks_a_signed_table(self): + # `add x8, x8, w9, sxtw #2` folds the index's sign-extension into the merging add, + # so the table is signed with no ldrsw/sxtw anywhere for the reverse scan to see. + # Read as unsigned, every backward entry becomes a multi-gigabyte delta and the walk + # aborts on the first one, losing the whole table. + from capstone import CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN, Cs + + from smda.aarch64.analyzers import AArch64JumpTableAnalyzer + + base = 0x400000 + words = [ + 0x90000088, # adrp x8, #0x411000 + 0x91040108, # add x8, x8, #0x100 (x8 = 0x411100, the table) + 0xB9400109, # ldr w9, [x8] (plain 32-bit load, unsigned by itself) + 0x8B29C908, # add x8, x8, w9, sxtw #2 (sign-extend + scale, folded in) + 0xD61F0100, # br x8 + ] + + mapped = bytearray(0x15000) + for i, w in enumerate(words): + addr = 0x401000 + i * 4 + mapped[addr - base : addr - base + 4] = w.to_bytes(4, "little") + + for entry, delta in enumerate((-0x40, -0x80, 0x40)): + struct.pack_into(" Date: Wed, 12 Aug 2026 00:34:23 +0530 Subject: [PATCH 3/3] fix(intel): stop popa from hiding the syscall number it overwrites popa and popad restore eax from the stack and carry no explicit operand, so the syscall-number backtrack treated them as instructions that touch nothing and kept walking past them. A "mov eax, N" from before the pop was then reported as the syscall number even though the pop had already replaced it. That is a wrong answer rather than an unresolved one, which is the worse of the two failures here. capstone spells the two forms popal and popaw, and lists eax among the registers they write implicitly. pushal and pushaw stay out of the set: they write only esp, so backtracking through them is correct. No bundled fixture output moves. The bundled 32-bit ELF has thirty-nine syscall sites and no popa anywhere, and the 32-bit dump that does contain a popa has no syscall sites. Instances: 1 file Validation: make lint, make test --- src/smda/intel/X86Backend.py | 4 ++++ tests/testIntelDisassembler.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/smda/intel/X86Backend.py b/src/smda/intel/X86Backend.py index 301fbec2..68da84d8 100644 --- a/src/smda/intel/X86Backend.py +++ b/src/smda/intel/X86Backend.py @@ -88,6 +88,10 @@ "aam", "daa", "das", + # capstone spells the 32-bit pop-all forms popal/popad and popaw; both restore eax + # from the stack with no explicit operand, so nothing else here can see the write. + "popal", + "popaw", } SYSCALL_READ_ONLY_INS = {"cmp", "test", "push", "bt"} diff --git a/tests/testIntelDisassembler.py b/tests/testIntelDisassembler.py index 90a8a1f1..faa8dc8b 100644 --- a/tests/testIntelDisassembler.py +++ b/tests/testIntelDisassembler.py @@ -991,6 +991,18 @@ def test_syscall_number_continues_past_read_only_instructions(self): preceding = [self._ins("mov", "rax, 0x3c"), read_only] self.assertEqual(disassembler._resolveSyscallNumber(preceding, 64), 60) + def test_syscall_number_unresolved_on_pop_all_clobber(self): + disassembler = self._create_disassembler() + # popa/popad restores eax from the stack and carries no explicit operand, so the + # operand-less arm let the stale mov value through as the syscall number. 32-bit + # only - the encoding is invalid in long mode. capstone spells them popal/popaw. + for pop_all in (self._ins("popal", ""), self._ins("popaw", "")): + preceding = [self._ins("mov", "eax, 0x1"), pop_all] + self.assertIsNone(disassembler._resolveSyscallNumber(preceding, 32)) + # pushal writes only esp, so it must not stop resolution + pushed = [self._ins("mov", "eax, 0x1"), self._ins("pushal", "")] + self.assertEqual(disassembler._resolveSyscallNumber(pushed, 32), 1) + def test_syscall_number_unresolved_on_implicit_rax_clobber(self): disassembler = self._create_disassembler() # instructions that implicitly write rax/eax must stop resolution (no false 60)