Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 17 additions & 3 deletions src/smda/aarch64/analyzers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions src/smda/aarch64/dataflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/smda/intel/X86Backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
28 changes: 28 additions & 0 deletions tests/testAArch64Dataflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
69 changes: 69 additions & 0 deletions tests/testAArch64JumpTableLsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<i", mapped, 0x411100 - base + entry * 4, delta)

binary_info = BinaryInfo(bytes(mapped))
binary_info.base_addr = base
binary_info.binary_size = len(mapped)
binary_info.isInCodeAreas = lambda addr: 0x400000 <= addr < 0x415000

disassembly = DisassemblyResult()
disassembly.binary_info = binary_info

capstone = Cs(CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN)
capstone.detail = True

class FakeDisassembler:
def __init__(self, disassembly_result, capstone):
self.disassembly = disassembly_result
self.capstone = capstone

def getBitMask(self):
return 0xFFFFFFFFFFFFFFFF

class FakeState:
def __init__(self, instructions):
self.instructions = instructions
self.data_refs = []

def backtrackInstructions(self, addr_from, num_instructions):
return self.instructions

def addDataRef(self, from_addr, to_addr, size=1):
self.data_refs.append((from_addr, to_addr, size))

instructions = []
for i, w in enumerate(words):
addr = 0x401000 + i * 4
inst = next(capstone.disasm(w.to_bytes(4, "little"), addr))
instructions.append((addr, 4, inst.mnemonic, inst.op_str, inst.bytes))

analyzer = AArch64JumpTableAnalyzer(FakeDisassembler(disassembly, capstone))
targets = analyzer.getJumpTargets(instructions[-1], FakeState(instructions))

# anchor 0x411100 + (delta << 2): two entries resolve *before* the table, which is
# exactly what the unsigned read cannot express.
self.assertEqual(targets[:3], [0x411000, 0x410F00, 0x411200])


if __name__ == "__main__":
unittest.main()
12 changes: 12 additions & 0 deletions tests/testIntelDisassembler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down