逆向自动化工具

sky123

Keystone

Keystone 是一个开源的汇编器(assembler),用于生成二进制机器码。它支持多种 CPU 架构和操作模式,并且具有高效、轻量级、跨平台等特点,适合用在逆向工程、漏洞研究、二进制分析等领域。

keystone-engine 是 Keystone 项目的 Python 绑定,它允许开发者在 Python 环境中使用 Keystone 汇编器的强大功能。通过 keystone-engine,你可以在 Python 中执行汇编代码,将汇编转换为机器码,或者将机器码反汇编成汇编代码。

Keystone 官方文档

Keystone 安装

keystone-engine 库已经发布到 PyPI(Python Package Index),所以你可以直接使用 pip 来安装它:

1
pip install keystone-engine

注意

这里安装的是 keystone-engine 而不是 keystonekeystone 是另外一个模块。

基本使用

创建 Keystone 对象

要开始使用 Keystone,我们首先需要创建一个 Keystone 对象。这个对象将负责后续的汇编和反汇编工作。

1
2
3
from keystone import Ks, KS_ARCH_X86, KS_MODE_32

ks = Ks(KS_ARCH_X86, KS_MODE_32)

Ks 构造函数的两个参数是 架构类型模式类型。这两个参数定义了要汇编或反汇编的目标架构和模式。

  • 架构类型指定了目标处理器的架构。Keystone 支持多种不同的处理器架构,包括:

    • KS_ARCH_ARM:ARM 处理器架构
    • KS_ARCH_ARM64:ARM 64 位处理器架构
    • KS_ARCH_MIPS:MIPS 处理器架构
    • KS_ARCH_X86:x86 处理器架构
    • KS_ARCH_PPC:PowerPC 处理器架构
    • KS_ARCH_SPARC:SPARC 处理器架构
    • KS_ARCH_SYSTEMZ:SystemZ 架构(IBM Z 系列)
    • KS_ARCH_HEXAGON:Hexagon 架构(Qualcomm Hexagon DSP)
    • KS_ARCH_EVM:EVM 架构(TCS/EVM 虚拟机)
  • 模式类型用于指定特定架构下的工作模式(如 32 位或 64 位模式),Keystone 在同一架构下支持多种模式。

    提示

    在 Keystone 中,如果你希望同时设置多个模式,可以通过 按位“或”操作 (|) 来组合不同的模式。这样可以在一个构造函数中同时指定多个模式类型。

    • 首先是最基本的大小端序,Keystone 默认是小端序

      • KS_MODE_LITTLE_ENDIAN :小端模式(默认)。
      • KS_MODE_BIG_ENDIAN :大端模式。
    • 另外就是一些针对特定架构下的模式类型。

      • ARM 架构相关模式:

        • KS_MODE_ARM :ARM 模式。
        • KS_MODE_THUMB :Thumb 模式(ARM 特有,表示压缩指令集模式)。
        • KS_MODE_V8 :ARMv8 模式(用于支持 ARMv8 架构)。
      • MIPS 架构相关模式:

        • KS_MODE_MIPS3 :MIPS3 模式。

        • KS_MODE_MIPS32R6 :MIPS32R6 模式。

        • KS_MODE_MIPS32 :MIPS32 模式。

        • KS_MODE_MIPS64 :MIPS64 模式。

      • x86 架构相关模式:

        • KS_MODE_16 :16 位模式。

        • KS_MODE_32 :32 位模式。

        • KS_MODE_64 :64 位模式。

      • PowerPC 架构相关模式:

        • KS_MODE_PPC32 :PowerPC 32 位模式。

        • KS_MODE_PPC64 :PowerPC 64 位模式。

        • KS_MODE_QPX :QPX 模式(专门用于某些 PowerPC 处理器)。

      • SPARC 架构相关模式:

        • KS_MODE_SPARC32 :SPARC 32 位模式。

        • KS_MODE_SPARC64 :SPARC 64 位模式。

        • KS_MODE_V9 :SPARC V9 模式(64 位)。

这里给出我们在逆向辅助工具开发中常用的 Keystone 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from keystone import *

# X86-64
from keystone.x86_const import *
ks = Ks(KS_ARCH_X86, KS_MODE_64)

# ARM
from keystone.arm_const import *
ks = Ks(KS_ARCH_ARM, KS_MODE_ARM)
ks = ks(KS_ARCH_ARM, KS_MODE_THUMB)

# AARCH64
from keystone.arm64_const import *
ks = ks(KS_ARCH_ARM64, KS_MODE_ARM)

汇编

Keystone 对象只有一个公开的成员函数 asm,该函数定义如下:

1
def asm(self, string: str, addr: int = 0, as_bytes: bool = False) -> tuple:

参数:

  • string: str :表示输入参数 string 是一个字符串,包含汇编语言的代码。

  • addr: int = 0addr 是一个整数类型的参数,表示机器码开始的内存地址,默认值为 0

  • as_bytes: bool = Falseas_bytes 是一个布尔值,表示是否返回机器码的字节表示。默认值为 False,即返回一个整数列表。

返回值:该函数返回一个元组,元组包含两个元素。

  • encoded :机器码的字节表示。
    • 如果 as_bytes=True,返回 bytes 类型。
    • 如果 as_bytes=False,返回 list[int] 类型,表示机器码的字节序列对应的整数。
  • count:生成的机器码指令的数量。

示例:

1
2
3
4
5
6
7
8
>>> from keystone import *
>>> ks = Ks(KS_ARCH_X86, KS_MODE_64)

>>> ks.asm("inc rax; inc rax")
([72, 255, 192, 72, 255, 192], 2)

>>> ks.asm("inc rax; inc rax", as_bytes=True)
(b'H\xff\xc0H\xff\xc0', 2)

提示

默认情况下,Keystone 使用 Intel 语法解析 x86 架构的汇编指令,如果要切换为 AT&T 语法,可以使用以下代码:

1
2
ks = Ks(KS_ARCH_X86, KS_MODE_32)
ks.syntax = KS_OPT_SYNTAX_ATT

Capstone

Capstone 是一个流行的反汇编框架,它与 Keystone 密切相关,但其功能侧重点不同。Keystone 主要负责将汇编代码转换为机器码(即汇编器),而 Capstone 则是一个反汇编库,专注于将机器码转换为汇编代码(即反汇编器)。

Capstone 官方文档

Capstone 安装

Capstone 提供了 Python 绑定,可以通过 pip 安装:

1
pip install capstone

基本使用

创建 Capstone 对象

首先,你需要创建一个 Capstone 反汇编对象,并设置目标架构和模式。Cs 类用于创建 Capstone 对象,CS_ARCH_*CS_MODE_* 常量用于指定架构和模式。

这里架构和模式定义和 Keystone 库的类似,但具体类别稍有不同。不过对于我们绝大多数逆向需求来说我们不需要关系两者的区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from capstone import *

# X86-64
from capstone.x86_const import *
cs = Cs(CS_ARCH_X86, CS_MODE_64)

# ARM
from capstone.arm_const import *
cs = Cs(CS_ARCH_ARM, CS_MODE_ARM)
cs = Cs(CS_ARCH_ARM, CS_MODE_THUMB)

# AARCH64
from capstone.arm64_const import *
cs = Cs(CS_ARCH_ARM64, CS_MODE_ARM)

反汇编

我们可以使用 Capstone 反汇编对象的 disasm 方法来进行反汇编,该函数定义如下:

1
def disasm(self, code, offset, count=0) -> 'CsInsn':

参数:

  • code: bytes :表示输入参数 code 是一个字节串(bytes 类型),包含要反汇编的机器码。这个字节串通常来自二进制文件或内存中的机器码表示。

  • offset: intoffset 是一个整数类型的参数,表示机器码开始的地址(通常是内存中的起始地址)。这个参数是必需的,用来确定反汇编指令的起始位置,帮助计算每条指令的地址。

  • count: int = 0count 是一个整数类型的参数,表示最多反汇编的指令数。默认值为 0,表示反汇编尽可能多的指令,直到代码结束或者遇到无效指令。如果设置了非零的 count,则最多反汇编 count 条指令。

返回值: 该函数返回一个生成器,每次迭代返回一个 CsInsn 对象,代表一条反汇编指令。CsInsn 对象包含以下信息:

  • address :指令的地址,表示这条指令在内存中的位置(十六进制格式)。
  • mnemonic :指令的助记符(如:movaddpush 等)。
  • op_str :指令的操作数(如:eax, ebxmem[rax])。
  • size :指令的字节大小,表示该指令在内存中占用了多少字节。

示例:

1
2
3
4
5
6
7
>>> from capstone import *
>>> cs = Cs(CS_ARCH_X86, CS_MODE_64)
>>> for insn in cs.disasm(bytes([72, 255, 192, 72, 255, 192]),0):
... print(f"{insn.mnemonic} {insn.op_str}")
...
inc rax
inc rax

提示

默认情况下,Capstone 使用 Intel 语法解析 x86 架构的汇编指令,如果要切换为 AT&T 语法,可以使用以下代码:

1
2
cs = Cs(CS_ARCH_X86, CS_MODE_32)
cs.syntax = CS_OPT_SYNTAX_ATT

disasm 的函数定义我们发现,Capstone 不仅仅是简单的将机器码转为汇编指令,而是提供了一个描述汇编指令的对象 CsInsn,从而对汇编指令提供更细粒度的描述。

因此在打印汇编的时候我们需要手动将 CsInsn.mnemonicCsInsn.op_str 拼接成一条汇编。

1
2
for insn in cs.disasm(machine_code, address):
print(f"{insn.address:#x}: {insn.mnemonic} {insn.op_str}")

另外由于 disasm 返回的是一个迭代器,因此我们还可以作如下封装:

1
2
3
4
5
def disasm(machine_code, address=0):
asm_code = ""
for insn in cs.disasm(machine_code, address):
asm_code += "{:8s} {};\n".format(insn.mnemonic, insn.op_str)
return asm_code.strip('\n')

另外除了上述四个成员外,如果我们开启 Cs.detail = True 选项时,CsInsn 还会多一个名为 operands 的字段,这个字段对于不同的架构是不同的。CsInsnoperands 在我们逆向辅助工具编写过程中十分重要,后面会有单独一节介绍。

当然如果我们将机器码反汇编得到 CsInsn 对象,尤其是 Cs.detail 选项开启时性能损耗将相当大。如果我们只需要基本数据,如地址、大小、助记符和操作数,我们可以使用更轻量的 API disasm_lite()

1
def disasm_lite(self, code: bytes, offset: int, count: int = 0) -> Generator[Tuple[int, int, str, str], None, None]:

从版本 2.1 开始,Python 绑定提供了这个新的 disasm_lite() 方法。与 disasm() 不同,disasm_lite() 只返回一个包含 (address, size, mnemonic, op_str) 的元组。基准测试显示,这个轻量级 API 比 disasm() 快最多 30%。

CsInsn 对象

CsInsn 是 Capstone 库中用于表示反汇编指令的对象。在反汇编过程中,CsInsn 对象提供了每条指令的详细信息。通过 CsInsn,你可以访问与该指令相关的各种信息,如指令地址、助记符、操作数、寄存器访问、指令分组等。

提示

为了描述不同架构的指令,CsInsn 的成员比较多样,并且在的架构、指令不同时这成员也不同。因此这里给出一段辅助代码,可以方便在编写代码的时候快速查看 CsInsn 中的成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
from capstone import *
from capstone.x86 import *
from capstone.arm import *
from capstone.arm64 import *
from capstone.mips import *
from capstone.ppc import *


# 通用操作数处理函数
def handle_common_operands(insn: CsInsn):
for i, op in enumerate(insn.operands):
print(f"Operand {i}:")
if op.type == CS_OP_REG:
op_type = "CS_OP_REG"
value = insn.reg_name(op.reg)
print(f" Type: {op_type}")
print(f" Value: {value}")
elif op.type == CS_OP_IMM:
op_type = "CS_OP_IMM"
value = f"0x{op.imm:x}"
print(f" Type: {op_type}")
print(f" Value: {value}")
elif op.type == CS_OP_MEM:
op_type = "CS_OP_MEM"
mem = op.mem
print(f" Type: {op_type}")
print(f" Value:")
indent = " "
if mem.segment != 0:
print(f"{indent}Segment: {insn.reg_name(mem.segment)}")
if mem.base != 0:
print(f"{indent}Base: {insn.reg_name(mem.base)}")
if mem.index != 0:
print(f"{indent}Index: {insn.reg_name(mem.index)}")
print(f"{indent}Scale: {mem.scale}")
if mem.disp != 0:
print(f"{indent}Disp: {mem.disp:#x}")
if mem.segment == 0 and mem.base == 0 and mem.index == 0 and mem.disp == 0:
print(f"{indent}None")
elif op.type == CS_OP_FP:
op_type = "CS_OP_FP"
value = f"{op.fp}"
print(f" Type: {op_type}")
print(f" Value: {value}")
else:
print(" Type: Unknown")
print(" Value: N/A")


# 架构特有的操作数处理函数
def handle_x86_operands(insn: CsInsn):
# 这里只处理 x86 特有的操作数类型
# 其他通用操作数类型在 handle_common_operands 里处理
pass

def handle_arm64_operands(insn: CsInsn):
# 这里只处理 ARM64 特有的操作数类型
# 其他通用操作数类型在 handle_common_operands 里处理
pass

def handle_arm_operands(insn: CsInsn):
# 这里只处理 ARM 特有的操作数类型
# 其他通用操作数类型在 handle_common_operands 里处理
pass

def handle_mips_operands(insn: CsInsn):
# 这里只处理 MIPS 特有的操作数类型
# 其他通用操作数类型在 handle_common_operands 里处理
pass

def handle_ppc_operands(insn: CsInsn):
# 这里只处理 PPC 特有的操作数类型
# 其他通用操作数类型在 handle_common_operands 里处理
pass


# 定义架构到操作数处理函数的映射
ARCH_OPERAND_HANDLERS = {
CS_ARCH_X86: handle_x86_operands,
CS_ARCH_ARM64: handle_arm64_operands,
CS_ARCH_ARM: handle_arm_operands,
CS_ARCH_MIPS: handle_mips_operands,
CS_ARCH_PPC: handle_ppc_operands,
# 添加更多架构的处理函数
}


def print_insn_detail(insn: CsInsn):
try:
# 打印指令的基本信息
print("========== Instruction Detail ==========")
print(f"{insn.address:#x} [{' '.join([f'{b:02X}' for b in insn.bytes])}]: {insn.mnemonic} {insn.op_str}")

# 打印寄存器读写情况
regs_read, regs_write = insn.regs_access()

if regs_read:
regs_read_names = ', '.join(insn.reg_name(reg) for reg in regs_read)
print(f"Registers read: {regs_read_names}")

if regs_write:
regs_write_names = ', '.join(insn.reg_name(reg) for reg in regs_write)
print(f"Registers written: {regs_write_names}\n")

# 打印操作数详细信息
# 获取并调用对应架构的操作数处理函数
arch = insn._cs.arch
handler = ARCH_OPERAND_HANDLERS.get(arch)
if handler:
handler(insn)
else:
print(f"Unsupported architecture: {arch}\n")

# 调用通用的操作数处理函数
handle_common_operands(insn)

print("=========================================\n")
except Exception as e:
print(f"Error processing instruction at 0x{insn.address:x}: {e}\n")

基本字段

CsInsn 对象提供了对反汇编指令的基本信息的访问,主要包括以下四个基本字段:

  • address :指令的地址,表示这条指令在内存中的位置(十六进制格式)。
  • mnemonic :指令的助记符(如:movaddpush 等)。
  • op_str :指令的操作数(如:eax, ebxmem[rax])。
  • size :指令的字节大小,表示该指令在内存中占用了多少字节。

寄存器读写

CsInsn 还提供了关于寄存器操作的详细信息。通过该信息可以知道指令涉及到哪些寄存器,并且是如何使用它们的:

  • regs_read() :该方法返回一个指令中被读取的寄存器的集合,通常是一个整数列表,代表寄存器的编号。

  • regs_write() :该方法返回一个指令中被写入的寄存器的集合,通常也是一个整数列表,表示写入的寄存器编号。

  • reg_read(reg_id) :该方法用于检查特定的寄存器是否被指令读取。传入寄存器的 ID,如果该寄存器被读取,返回 True,否则返回 False

  • reg_write(reg_id) :该方法用于检查特定的寄存器是否被指令写入。传入寄存器的 ID,如果该寄存器被写入,返回 True,否则返回 False

  • regs_access() :返回一个元组,包含 regs_read()regs_write() 两个方法的结果。

提示

这里得到的寄存器 ID 不是很直观,CsInsn 有一个 reg_name 方法可以将寄存器编号转换为寄存器的名称。

操作数字段

操作数结构

CsInsn 中的 operands 字段包含了与指令相关的操作数。由于一条指令可能涉及多个操作数,因此 operands 是一个数组其中每一个元素对应一个操作数,顺序和指令中操作数的顺序相同。

由于操作数是 Capstone 底层库获取到的,因此实际上这里 operands 中的元素是定义的 ctypes 结构体。对于不同的架构这个结构体的成员不同。

以 X86 架构为例,operands 中的元素是的类型 X86Op 定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class X86OpMem(ctypes.Structure):
# 该类表示x86架构中的内存操作数结构。
# 定义了用于内存寻址的不同部分:段、基址、索引、缩放因子和偏移量
_fields_ = (
('segment', ctypes.c_uint), # 内存段(如:段寄存器,通常为0)
('base', ctypes.c_uint), # 基址寄存器(如:eax、ebx等)
('index', ctypes.c_uint), # 索引寄存器(通常用于数组或结构体寻址)
('scale', ctypes.c_int), # 缩放因子(例如:4,用于字节对齐)
('disp', ctypes.c_int64), # 偏移量(例如:+0x10或-8)
)

class X86OpValue(ctypes.Union):
# 该类定义了x86操作数的值,可以是寄存器、立即数或内存。
# 使用ctypes.Union以节省内存,只能使用其中一个值
_fields_ = (
('reg', ctypes.c_uint), # 寄存器(如:eax、ebx等)
('imm', ctypes.c_int64), # 立即数(如:0x10、42等)
('mem', X86OpMem), # 内存操作数,包含内存段、基址、索引、缩放因子和偏移量
)

class X86Op(ctypes.Structure):
# 该类表示x86指令中的操作数,包含了操作数的类型、值、大小、访问权限等信息
_fields_ = (
('type', ctypes.c_uint), # 操作数类型,表示该操作数是寄存器、立即数、内存等
('value', X86OpValue), # 操作数的具体值(寄存器、立即数或内存)
('size', ctypes.c_uint8), # 操作数的大小(例如:32位或64位)
('access', ctypes.c_uint8), # 操作数的访问类型(读、写或读写)
('avx_bcast', ctypes.c_uint), # AVX广播类型
('avx_zero_opmask', ctypes.c_bool), # AVX零操作掩码
)

@property
def imm(self):
# 返回立即数值
return self.value.imm

@property
def reg(self):
# 返回寄存器编号
return self.value.reg

@property
def mem(self):
# 返回内存操作数
return self.value.mem

从上述代码中可以看出,操作数可以是寄存器、立即数、内存地址等不同的类型。Capstone 通过操作数结构体中的 type 字段来描述操作数的类型,见的操作数类型有(当然除了这些之外还有一些各架构独有的操作数类型,这些类型的名称通常为 <架构>_OP_<类型>,例如 ARM64_OP_CIMM):

  • CS_OP_REG :表示寄存器操作数(如 eaxebx)。
  • CS_OP_IMM :表示立即数操作数(如 0x1042)。
  • CS_OP_MEM :表示内存操作数(如 mem[eax+4])。
  • CS_OP_FP :表示浮点数操作数。

对应不同类型的操作数,value 字段作为联合体有 regimmmem 三种类型。我们可以根据操作数的类型从对应的结构中获取我们想要的值。

由于操作数对象提供了 immregmem 三种方法来封装 value 字段的访问,因此我们可以使用例如 insn.operands[1].mem.base 这种方式跳过 value 直接访问联合体中的成员。

内存操作数

因为会经常用到,这里要着重说明一下内存操作数的结构。内存操作数是指令中用于描述内存地址的操作数类型,通常在汇编指令中通过指定内存地址、基址寄存器、索引寄存器和偏移量来进行内存访问。

在 Capstone 库中,内存操作数的类型为 CS_OP_MEM,并且通过 CsOperand 对象的 mem 字段来描述相关的内存信息。还是以前面的 X86OpMem 结构体举例:

1
2
3
4
5
6
7
8
class X86OpMem(ctypes.Structure):
_fields_ = (
('segment', ctypes.c_uint), # 内存段寄存器,通常为 0
('base', ctypes.c_uint), # 基址寄存器,例如 eax、rbx 等
('index', ctypes.c_uint), # 索引寄存器,例如 esi、edi 等
('scale', ctypes.c_int), # 缩放因子,例如 4(字节对齐)
('disp', ctypes.c_int64), # 偏移量,表示地址的固定偏移
)
  • segment :内存段寄存器,在现代架构中通常为 0,表示直接访问内存,而不通过特定的段寄存器。如果涉及到段寻址(例如在 x86 16 位模式下),此字段可能会有所变化。
  • base :基址寄存器,是计算内存地址的基础寄存器。通过将基址寄存器的值与偏移量 disp 相加,可以确定内存地址。例如,eaxebx 通常用于存储基地址。
  • index :索引寄存器,是一个可选的寄存器,通常用于数组、结构体等类型的数据访问。通过 base + (index * scale) + disp 可以计算出最终的内存地址。index 通常用于访问内存中的多个元素,例如数组的每个元素,scale 会控制每次索引跳跃的大小。
  • scale :缩放因子,用于乘以索引寄存器的值,以调整访问的大小。对于数据结构的访问,这个字段决定了如何调整内存访问的步长。例如,访问一个 int 类型数组时,scale 通常为 4(因为一个 int 占 4 字节)。
  • disp :偏移量,表示相对于基址和索引寄存器的地址偏移量。它通常是一个常数,表示在基地址或索引基础上额外的偏移量。例如,[eax + 4] 中的偏移量就是 4
x86 架构

在 x86-64 架构中,内存操作数常常包含基址寄存器、索引寄存器、缩放因子和偏移量,下面的汇编指令是 x86-64 中的一个常见例子:

1
mov rax, [rbx + rcx*4 + 0x10]

这条指令的操作数对应 Capstone 的内存操作数结构如下:

1
2
3
4
5
6
7
8
# 对应 Capstone 解析的结构体
mem = insn.operands[1].mem

print(f"Segment: {mem.segment}") # 通常为 0
print(f"Base: {insn.reg_name(mem.base)}") # rbx
print(f"Index: {insn.reg_name(mem.index)}") # rcx
print(f"Scale: {mem.scale}") # 4
print(f"Disp: {hex(mem.disp)}") # 0x10
  • segment0(通常为 0,因为没有特别指定段寄存器)
  • baserbx(基址寄存器)
  • indexrcx(索引寄存器)
  • scale4(数组或数据元素大小,通常是 4
  • disp0x10(偏移量)

通常来说,X86 从全局变量读取数据的指令为:

1
mov reg, qword ptr ds:[rip + disp]

因此我们可以计算全局变量的地址:

1
insn.operands[1].mem.disp + insn.address + insn.size
ARM 架构

ARM 架构使用类似于 x86 的寻址模式,但是语法和寄存器命名方式不同。例如下面这条汇编指令是将 r1 + (r2 << 2) 计算出的内存地址中的值加载到 r0 寄存器:

1
ldr r0, [r1, r2, lsl #2]

这条指令的操作数对应 Capstone 的内存操作数结构如下:

1
2
3
4
5
6
7
# 对应 Capstone 解析的结构体
mem = op.mem
print(f"Segment: {mem.segment}") # 通常为 0
print(f"Base: {insn.reg_name(mem.base)}") # r1
print(f"Index: {insn.reg_name(mem.index)}") # r2
print(f"Scale: {mem.scale}") # 4 (因为 lsl #2 是左移两位,相当于乘 4)
print(f"Disp: {hex(mem.disp)}") # 0
  • segment0(默认段选择符)
  • baser1(基址寄存器)
  • indexr2(索引寄存器)
  • scale4(因为 lsl #2 是左移 2 位,相当于乘 4)
  • disp0(没有偏移量)

由于 ARM 架构的指令不能直接操作内存,因此无法直接根据全局变量的地址读取全局变量的数据,而是:

  1. 先将编译在函数后面的地址表中的全局变量的地址加载到寄存器中。
  2. 然后还可能跟 pc 寄存器相加顺便实现地址无关代码
  3. 最后再通过寄存器访问全局变量所在的地址读取数据。

也就是通常来说 ARM 架构从全局变量读取数据的指令为:

1
2
3
ldr r1, [pc, #disp]	; 从 (pc + disp) 计算出的内存地址加载数据到 r1
add r1, r1, pc ; 将 pc 加上偏移量后的地址值加到 r1,得到目标地址
ldr r2, [r1] ; 从 r1 指向的内存地址加载数据到 r2

由于编译器的针对 CPU 流水线的优化,这些指令之间可能还会穿插其它的指令。因此我们不能直接通过指令得出全局变量的地址。不过这里我们可以先获取指令序列 insn_list,然后简单模拟一下这个从内存加载数据的过程来计算全局变量的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
reg_value = {}
for insn in insn_list:
# ldr r1, [pc, #0xf0]
if insn.mnemonic == 'ldr' \
and insn.operands[0].type == ARM_OP_REG \
and insn.operands[1].type == ARM_OP_MEM \
and insn.operands[1].mem.base == ARM_REG_PC:
reg_value[insn.operands[0].reg] = idc.get_wide_dword((insn.address + 4 + insn.operands[1].mem.disp) & ~3)
# add r1, pc
elif insn.mnemonic == 'add' \
and insn.operands[0].type == ARM_OP_REG \
and insn.operands[1].type == ARM_OP_REG \
and insn.operands[1].reg == ARM_REG_PC \
and insn.operands[0].reg in reg_value.keys():
reg = insn.operands[0].reg
reg_value[reg] = reg_value[reg] + insn.address + 4
if idc.get_segm_name(reg_value[reg]) == '.rodata': # 全局变量应该放在 .rodata 段中
print(f"global var addr: {reg_value[reg]:#x}")
elif insn.mnemonic.startswith('b'):
reg_value.clear()
AArch64 架构

AArch64 是 ARM 的 64 位版本,它使用与 ARM 相似的寻址模式。AArch64 允许更复杂的寻址模式,包括有符号立即数、寄存器和可选的缩放因子。例如下面这条汇编指令是将 x1 + (x2 << 3) 计算出的内存地址中的值加载到 x0 寄存器:

1
ldr x0, [x1, x2, lsl #3]

这条指令的操作数对应 Capstone 的内存操作数结构如下:

1
2
3
4
5
6
7
# 对应 Capstone 解析的结构体
mem = op.mem
print(f"Segment: {mem.segment}") # 通常为 0
print(f"Base: {insn.reg_name(mem.base)}") # x1
print(f"Index: {insn.reg_name(mem.index)}") # x2
print(f"Scale: {mem.scale}") # 8 (因为 lsl #3 是左移三位,相当于乘 8)
print(f"Disp: {hex(mem.disp)}") # 0
  • segment0(默认段选择符)
  • basex1(基址寄存器)
  • indexx2(索引寄存器)
  • scale8(因为 lsl #3 是左移 3 位,相当于乘 8)
  • disp0(没有偏移量)

AArch64 架构的指令同样不能直接操作内存,因此无法直接根据全局变量的地址读取全局变量的数据,而是:

  1. 通过 adrp 指令将全局变量地址所在内存页的基地址加载到寄存器中。由于 AArch64 是基于页面地址访问的,adrp 将会设置寄存器为地址的页对齐值。
  2. 将存有页基址的寄存器加上某个偏移量,使其指向全局变量。
  3. 通过 ldrldp 等指令从该寄存器指向的内存中读入数据。

也就是通常来说 AArch64 架构从全局变量读取数据的指令为:

1
2
3
adrp x2, #0x37ff000	; 将页面的基地址加载到寄存器 x2 中
add x2, x2, #0x148 ; 将基地址加上偏移量 #0x148 从而计算出完整的内存地址
ldp x4, x5, [x2] ; 从 x2 指向的地址加载两个数据项,分别存入寄存器 x4 和 x5

同样我们可以参考 ARM 架构的做法来计算全局变量的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
reg_value = {}
for insn in insn_list:
# adrp x2, #0x37ff000
if insn.mnemonic == 'adrp' \
and insn.operands[0].type == ARM64_OP_REG \
and insn.operands[1].type == ARM64_OP_IMM:
reg_value[insn.operands[0].reg] = insn.operands[1].imm
# add x2, x2, #0x148
elif insn.mnemonic == 'add' \
and insn.operands[0].type == ARM64_OP_REG \
and insn.operands[1].type == ARM64_OP_REG \
and insn.operands[2].type == ARM64_OP_IMM \
and insn.operands[0].reg == insn.operands[1].reg \
and insn.operands[0].reg in reg_value.keys():
reg = insn.operands[0].reg
reg_value[reg] += insn.operands[2].imm
if idc.get_segm_name(reg_value[reg]) == '.rodata':
print(f"global var addr: {reg_value[reg]:#x}")
elif insn.mnemonic.startswith('b'):
reg_value.clear()

angr

angr 是一个多架构二进制分析工具包,能够执行动态符号执行(类似于 Mayhem、KLEE 等)以及各种静态分析。

angr 官方文档

提示

我们可以简单的将 angr 理解为 IdaPython + 符号执行。也就是说:

  • 我们可以将 angr 作为 IdaPython 的替代品。
    • 可以批量对二进制程序进行静态分析,因为不需要将待分析的二进制文件逐个用 Ida 打开。
    • 可以加快分析速度,因为我们不需要像 IdaPython 那样对程序进行完整的分析,比如说我们可以先通过搜索特征定位到关键位置,然后只分析关键位置处的代码。
    • 可以结合 Unicorn 等模拟执行工具实现程序的动态分析,可以应对批量处理分析二进制程序时遇到不同程序之间代码存在差异的问题。
  • 我们还可以使用 angr 的符号执行功能,来弥补静态分析和动态分析的缺陷。
    • 静态分析可能难以处理复杂的控制流(如动态跳转),而动态分析需要依赖真实输入才能覆盖路径。angr 的符号执行功能通过符号化输入的方式探索路径,可以弥补这两者的不足。
    • 由于结合 Unicorn 等模拟执行工具实现程序的动态分析是手动结合的,对于一些特殊的二进制文件可能会出问题,如果可以的话直接用 angr 的符号执行计算一些结果会稳定一些。

angr 安装

直接通过 pip 安装。

1
pip3 install angr

另外还有一个 angr-management 是基于 angr 实现的低配版 IDA。

1
pip3 install angr-management

安装完之后运行 angr-management 命令即可启动,用法和 IDA 基本一样。不过这里我们主要还是在编写逆向辅助工具的时候使用里面的一些 API。

1
angr-management

静态分析

二进制文件加载

加载项目(Project)

加载选项

angr 的第一步是将二进制文件加载到一个项目中。

项目(Project)是你在 angr 中的控制中心。通过它,你可以对加载的可执行文件执行分析和模拟。在 angr 中,几乎所有你要使用的对象都在某种形式上依赖于一个项目。

我们以 /bin/true 为例:

1
2
>>> import angr
>>> proj = angr.Project('/bin/true')

当你使用 angr.Project 加载文件时,可以将选项直接传递给 Project 构造函数,它们会被转发给 CLE。CLE 有如下常用选项:

  • auto_load_libs :控制是否自动解析共享库依赖,默认值为 True
  • except_missing_libs :与 auto_load_libs 相反。如果设置为 True,当无法解析共享库依赖时会抛出异常。
  • force_load_libs :一个字符串列表,强制指定某些库为未解析的共享库依赖。
  • skip_libs :一个字符串列表,防止某些库名被解析为依赖。
  • ld_path :一个字符串或字符串列表,用作共享库的额外搜索路径,优先于默认路径。

你还可以使用 main_optslib_opts 来针对特定的二进制对象设置选项:

  • main_opts :是一个选项名称到值的映射,适用于主二进制。
  • lib_opts :是一个以库名为键、选项字典为值的映射,适用于特定共享库。

常见选项包括:

  • backend :指定使用的后端。

    CLE 当前支持以下静态加载后端:ELF、PE、CGC、Mach-O、ELF 核心转储(core dump)文件。通常情况下,CLE 会自动检测正确的后端,因此除非有非常特殊的需求,否则无需手动指定后端。

    如果需要强制使用某个后端,可以在选项字典中包含一个 backend 键。某些后端无法自动检测架构,必须通过 arch 参数指定。

    以下是支持的后端列表:

    后端名称 描述 是否需要指定架构(arch)?
    elf 基于 PyELFTools 的 ELF 文件静态加载器
    pe 基于 PEFile 的 PE 文件静态加载器
    mach-o Mach-O 文件静态加载器,不支持动态链接或重定位
    cgc Cyber Grand Challenge 二进制文件静态加载器
    backedcgc 支持指定内存和寄存器的 CGC 二进制加载器
    elfcore ELF 核心转储静态加载器
    blob 将文件作为平坦映像加载到内存中
  • base_addr :指定基址。

  • entry_point :指定入口点。

  • arch :指定架构。

1
2
3
4
5
6
>>> angr.Project(
... 'examples/fauxware/fauxware',
... main_opts={'backend': 'blob', 'arch': 'i386'},
... lib_opts={'libc.so.6': {'backend': 'elf'}}
... )
<Project examples/fauxware/fauxware>
基本属性

加载项目后,可以查看一些基本属性,比如 CPU 架构、文件名和入口点的地址。

1
2
3
4
5
6
7
>>> import monkeyhex  # 用于以十六进制格式显示数值结果
>>> proj.arch
<Arch AMD64 (LE)>
>>> proj.entry
0x401670
>>> proj.filename
'/bin/true'
  • arch 是一个 archinfo.Arch 对象的实例,表示程序的编译架构。在本例中,它是小端的 AMD64 架构。该对象包含关于 CPU 的大量信息,常用的属性有 arch.bits(位数)、arch.bytes(字节数)、arch.namearch.memory_endness
  • entry 是二进制文件的入口点地址。
  • filename 是二进制文件的绝对路径。

加载器(The Loader)

将二进制文件加载为虚拟地址空间的表示形式是一个复杂的过程。angr 中有一个模块叫 CLE(CLE Loads Everything)来处理这个问题。CLE 可以通过项目的 .loader 属性访问。

1
2
3
4
5
6
7
>>> proj.loader
<Loaded true, maps [0x400000:0x5004000]>

>>> proj.loader.min_addr
0x400000
>>> proj.loader.max_addr
0x5004000
已加载的对象

CLE 加载器将二进制文件及其所依赖的动态库(这里被称之为二进制对象)加载并映射到一个统一的内存空间中。每个二进制对象由能够处理其文件类型的加载器后端加载(如 cle.Backend 的子类)。例如,cle.ELF 用于加载 ELF 二进制文件。

此外,内存中还有一些对象并不对应任何加载的二进制文件,例如提供线程局部存储(TLS)支持的对象和用于未解析符号的 externs 对象。

CLE 加载器内部对这些对象简单做了一些分类,并存放在 loader 的几个属性中。常见的包括:

  • all_objects:即 CLE 加载的所有对象。

    1
    2
    3
    4
    5
    6
    7
    8
    # 所有加载的对象
    >>> proj.loader.all_objects
    [<ELF Object fauxware, maps [0x400000:0x60105f]>,
    <ELF Object libc-2.23.so, maps [0x1000000:0x13c999f]>,
    <ELF Object ld-2.23.so, maps [0x2000000:0x2227167]>,
    <ELFTLSObject Object cle##tls, maps [0x3000000:0x3015010]>,
    <ExternObject Object cle##externs, maps [0x4000000:0x4008000]>,
    <KernelObject Object cle##kernel, maps [0x5000000:0x5008000]>]
  • main_object :主对象,即 angr.Project 指定加载的二进制文件。

    1
    2
    >>> proj.loader.main_object  # 加载多个二进制文件时,这是主对象
    <ELF Object true, maps [0x400000:0x60721f]>
  • shared_objects:共享对象,即主对象所依赖的动态库。shared_objects 以字典的形式表示,内容为从共享对象名称到对象的映射。

    1
    2
    3
    4
    >>> proj.loader.shared_objects
    { 'fauxware': <ELF Object fauxware, maps [0x400000:0x60105f]>,
    'libc.so.6': <ELF Object libc-2.23.so, maps [0x1000000:0x13c999f]>,
    'ld-linux-x86-64.so.2': <ELF Object ld-2.23.so, maps [0x2000000:0x2227167]> }
  • all_elf_objects:所有从 ELF 文件加载的对象。如果是 Windows 程序,可以使用 all_pe_objects

    1
    2
    3
    4
    >>> proj.loader.all_elf_objects
    [<ELF Object fauxware, maps [0x400000:0x60105f]>,
    <ELF Object libc-2.23.so, maps [0x1000000:0x13c999f]>,
    <ELF Object ld-2.23.so, maps [0x2000000:0x2227167]>]
  • extern_object:用于为未解析的导入和 angr 内部地址。

    1
    2
    >>> proj.loader.extern_object
    <ExternObject Object cle##externs, maps [0x4000000:0x4008000]>
  • kernel_object:用于模拟系统调用的对象。

    1
    2
    >>> proj.loader.kernel_object
    <KernelObject Object cle##kernel, maps [0x5000000:0x5008000]>

除了从 loader 的某个属性中获取加载对象外,我们还可以通过 find_object_containing 方法获取指定地址所在的对象:

1
2
>>> proj.loader.find_object_containing(0x400000)
<ELF Object fauxware, maps [0x400000:0x60105f]>

也可以通过 find_object 方法根据对象名称获取对象:

1
2
>>> proj.loader.find_object('fauxware')
<ELF Object fauxware, maps [0x400000:0x60105f]>
对象基本信息

CLE 加载的二进制对象的属性中包含了一些基本信息:

  • entry:对象的入口点

    1
    2
    >>> proj.loader.main_object.entry
    0x400580
  • min_addrmax_addr:对象的最低地址和最高地址,即对象所在的地址空间范围。

    1
    2
    >>> proj.loader.main_object.min_addr, obj.max_addr
    (0x400000, 0x60105f)
  • linked_base:对象的预链接基址

    预链接基址(Prelinked Base Address) 是在预链接(Prelinking) 过程中为共享对象(Shared Objects,如共享库 *.so 或可执行文件)分配的固定加载地址

    1
    2
    >>> obj.linked_base
    0x400000
  • mapped_base:对象实际被 CLE 映射到内存的基址

    1
    2
    >>> obj.mapped_base
    0x400000
  • execstack:查询该二进制文件是否有可执行栈,即 NX 保护是否未被开启。

    1
    2
    >>> proj.loader.main_object.execstack
    False
  • pic:查询该二进制文件是否是地址无关,即 PIE 保护是否开启。

    1
    2
    >>> proj.loader.main_object.pic
    True
段(Segment)和节(Section)

CLE 加载的二进制对象还会解析获取对应二进制文件的段和节信息。这些信息分别存放在二进制对象的 segmentssections 属性中。

1
2
3
4
5
6
7
8
9
# 获取 ELF 的段(segments)和节(sections)
>>> obj.segments
<Regions: [<ELFSegment memsize=0xa74, filesize=0xa74, vaddr=0x400000, flags=0x5, offset=0x0>,
<ELFSegment memsize=0x238, filesize=0x228, vaddr=0x600e28, flags=0x6, offset=0xe28>]>
>>> obj.sections
<Regions: [<Unnamed | offset 0x0, vaddr 0x0, size 0x0>,
<.interp | offset 0x238, vaddr 0x400238, size 0x1c>,
<.note.ABI-tag | offset 0x254, vaddr 0x400254, size 0x20>,
...(省略部分输出)]>

我们可以通过二进制对象的 find_segment_containingfind_section_containing 获取指定地址所位于的段和节。

1
2
3
4
5
>>> obj.find_segment_containing(obj.entry)
<ELFSegment memsize=0xa74, filesize=0xa74, vaddr=0x400000, flags=0x5, offset=0x0>

>>> obj.find_section_containing(obj.entry)
<.text | offset 0x580, vaddr 0x400580, size 0x338>

angr 没有直接通过节的名称搜索节的 api,因此需要手动遍历节来获取。

1
text_section = [section for section in self.project.loader.main_object.sections if section.name == '.text'][0]
内存数据

angr 的内存操作接口分为 静态内存接口(加载时内存布局)和 动态内存接口(符号执行时的内存状态)。其中 project.loader.memory 属于静态内存接口,是二进制文件加载到内存后的初始布局(如代码段、数据段、符号表等)。

  • 搜索内存 :首先我们可以通过其中的 find 方法搜索我们想要的数据,返回结果是一个迭代器:

    1
    def find(self, data: bytes, search_min: int = None, search_max: int = None) -> Iterator[int]:
    • 参数:

      • data :要搜索的字节序列(bytes 类型)。这是你希望在内存中查找的数据模式。

      • search_min :可选参数,指定搜索的最小地址。只有在该地址之后或等于该地址的内存区域才会被搜索。如果不提供,默认从内存的起始位置开始。

      • search_max :可选参数,指定搜索的最大地址。只有在该地址之前或等于该地址的内存区域才会被搜索。如果不提供,默认搜索到内存的末尾。

    • 返回值:该方法返回一个迭代器,迭代器会逐一返回包含字节序列 data 的所有内存地址。

  • 读取内存 :通过 load 方法可以读取指定内存地址的数据,返回值是一个字节序列。此方法会读取指定地址开始的最多 n 个字节,直到达到指定字节数或者遇到未分配的内存区域:

    1
    def load(self, addr: int, n: int) -> bytes:
    • 参数:

      • addr :指定读取的起始内存地址。

      • n :要读取的字节数。

    • 返回值:返回一个字节对象(bytes 类型),包含读取到的数据。如果在读取过程中遇到未分配的内存区域,方法会停止,并返回已读取的字节数据。

  • 写入内存 :通过 store 方法可以将字节数据 data 写入到指定的内存地址 addr。如果写入操作超过了当前内存区域的范围,方法会尝试更新现有的内存区域,并抛出 KeyError 异常。

    1
    def store(self, addr, data):
    • 参数:
      • addr :要写入的目标内存地址(int 类型)。
      • data :要写入内存的字节数据(bytes 类型)。
    • 返回值:此方法没有返回值。如果成功写入数据,它会直接修改内存中的数据;如果出现问题,它会抛出 KeyError 异常。

符号信息

符号地址

从 CLE 中获取符号最简单的方法是使用 loader.find_symbol,它接受一个名称或地址,并返回一个 Symbol 对象。

1
2
3
>>> strcmp = proj.loader.find_symbol('strcmp')
>>> strcmp
<Symbol "strcmp" in libc.so.6 at 0x1089cd0>

符号最有用的属性包括其名称(name)、所属对象(owner)以及地址(address)。但符号的“地址”可能是模糊的,Symbol 对象提供了三种方式报告其地址:

  • .rebased_addr :符号在全局地址空间中的地址,这也是打印输出中显示的地址。
  • .linked_addr :符号相对于二进制文件预链接基址(prelinked base)的地址。例如,这是 readelf 等工具中报告的地址。
  • .relative_addr :符号相对于其所属对象基址(object base)的地址。在文献(尤其是 Windows 文献)中,这被称为 RVA(Relative Virtual Address)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> strcmp.name
'strcmp'

>>> strcmp.owner
<ELF Object libc-2.23.so, maps [0x1000000:0x13c999f]>

>>> strcmp.rebased_addr
0x1089cd0

>>> strcmp.linked_addr
0x89cd0

>>> strcmp.relative_addr
0x89cd0

除了调试信息外,符号还支持动态链接(dynamic linking)的概念。例如,libc 提供了 strcmp 作为导出符号,而主二进制程序依赖它。如果我们让 CLE 直接从主对象中返回 strcmp 符号,它会告诉我们这是一个导入符号(import symbol)。导入符号没有有意义的地址,但它会提供一个引用,指向用于解析它的符号(通过 .resolvedby 属性)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>> strcmp.is_export
True

>>> strcmp.is_import
False

# 对 Loader 来说,使用的是 find_symbol 方法,因为它需要进行搜索以找到符号。
# 对单个对象来说,使用的是 get_symbol 方法,因为给定名称的符号在对象内部只能有一个。
>>> main_strcmp = proj.loader.main_object.get_symbol('strcmp')
>>> main_strcmp
<Symbol "strcmp" in fauxware (import)>

>>> main_strcmp.is_export
False

>>> main_strcmp.is_import
True

>>> main_strcmp.resolvedby
<Symbol "strcmp" in libc.so.6 at 0x1089cd0>
符号的 PLT 地址

对于某些符号,你可以通过加载的对象获取它们在 PLT表(Procedure Linkage Table)中的地址:

1
2
3
4
5
6
7
8
9
>>> obj = proj.loader.main_object

>>> addr = obj.plt['strcmp']
>>> addr
0x400550

# 根据 PLT 地址反向查找符号
>>> obj.reverse_plt[addr]
'strcmp'
符号的 GOT 地址

导入和导出符号之间的链接方式是通过重定位(relocations)管理的。重定位记录了以下信息:

当你将 [import] 符号与某个导出符号匹配时,请将导出符号的地址写入 [location](即符号对应的 GOT 表地址),格式为 [format]。

我们可以获取重定位的相关信息:

  • 通过 obj.relocs 获取某个对象的所有重定位列表(以 Relocation 实例表示)。
  • 通过 obj.imports 获取从符号名称到重定位的映射。注意,导出符号没有对应的列表。

例如我们可以通过 imports 获取 exit 函数的 GOT 表地址:

1
2
3
4
5
>>> proj.loader.main_object.imports['exit'].rebased_addr
0x406f88

>>> proj.loader.main_object.find_section_containing(0x406f88)
<.got | offset 0x5e88, vaddr 0x406e88, size 0x178>

二进制代码分析

基本块(Blocks)

在 angr 中,基本块(Basic Block) 是指一段连续的、没有跳跃(即没有分支指令)的指令序列,通常在程序执行过程中,这些指令是按顺序执行的。

具体来说,基本块有以下几个特点:

  • 没有跳跃或分支 :基本块内的指令是顺序执行的,不包含跳转(如 jmpcallret 等指令)或条件分支指令(如 ifbranch 等)。当程序执行到一个基本块时,它会按照顺序执行该块内的所有指令,直到遇到跳转指令或基本块结束。

    注意

    与 IDA 的 CFG 的代码块不同的是,angr 的代码块把函数调用(call)也作为代码块结束的标志。

  • 入口和出口 :每个基本块有一个入口(起始地址)和出口(结束地址)。出口通常是一个跳转或返回的指令,也可能是下一个基本块的开始。

  • 分析单元 :在程序分析过程中,基本块是分析的最小单元。angr 就是通过将程序拆分成多个基本块来进行符号执行(symbolic execution)和路径探索。

基本块的获取

在 angr 中,通过 project.factory.block() 方法可以提取某个地址的基本块。

angr 中有很多类,其中大多数需要实例化一个项目(project)。为了避免你到处传递项目实例,我们提供了 project.factory,它包含了几个方便的构造器,用于创建你经常需要使用的常见对象。

这些基本块的内容被封装在 Block 对象中,你可以通过该对象访问基本块的反汇编信息、指令数量、指令地址等数据。

注意

project.factory.block 提取的代码块并不是控制流程图中的代码块。控制流图会考虑到代码的所有跳转关系,包括跳转到基本块的中间位置,因此在控制流图中,基本块的划分会比单纯用 project.factory.block 提取的基本块更加复杂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>> block = proj.factory.block(proj.entry)  # 从程序入口点提取一个代码块
<Block for 0x401670, 42 bytes>

>>> block.pp() # 将反汇编结果打印到标准输出
0x401670: xor ebp, ebp
0x401672: mov r9, rdx
0x401675: pop rsi
0x401676: mov rdx, rsp
0x401679: and rsp, 0xfffffffffffffff0
0x40167d: push rax
0x40167e: push rsp
0x40167f: lea r8, [rip + 0x2e2a]
0x401686: lea rcx, [rip + 0x2db3]
0x40168d: lea rdi, [rip - 0xd4]
0x401694: call qword ptr [rip + 0x205866]

>>> block.instructions # 这个代码块有多少条指令?
0xb
>>> block.instruction_addrs # 指令的地址是什么?
[0x401670, 0x401672, 0x401675, 0x401676, 0x401679, 0x40167d, 0x40167e, 0x40167f, 0x401686, 0x40168d, 0x401694]
基本块的使用

每个 Block 对象包含一个反汇编的指令列表。你可以通过 block.capstone 获取该基本块的反汇编指令。capstone 是一个流行的反汇编库,angr 使用它来生成反汇编指令。

1
2
for insn in block.capstone.insns:
print(f"{insn.address:#x}: {insn.mnemonic} {insn.op_str}")

在我们开发逆向辅助脚本的时候,基本块的其中一个作用就是可以提取某个地址处的 gadget

1
2
3
def get_gadget(addr):
gadget_str = "; ".join([f"{insn.mnemonic} {insn.op_str}" for insn in proj.factory.block(addr).disassembly.insns]).strip()
return gadget_str + ";" if gadget_str != "" else None

我们还可以借助基本块来进行一些复杂的程序分析,不过这里先不做介绍。

控制流程图(CFG)

控制流图(Control Flow Graph,简称 CFG) 是一种图形化的表示方法,用于描述程序中各个基本块之间的控制流关系。它将程序中的 基本块 作为节点,表示控制流的 跳转指令(如 jmpcallret 等)作为边。

利用 angr 提取 CFG

angr 中,有两种类型的控制流图(CFG)可以生成:静态 CFG(CFGFast)和动态 CFG(CFGEmulated)。

提示

如果你不确定使用哪个 CFG,或者遇到 CFGEmulated 的问题,建议首先尝试使用 CFGFast

  • CFGFast 使用静态分析来生成控制流图。它显著更快,但理论上受限于一些控制流转换只能在执行时解析的事实。这是其他流行的逆向工程工具执行的同类控制流图分析,其结果与它们的输出可比。
  • CFGEmulated 使用符号执行来捕捉控制流图。尽管它理论上更精确,但它显著更慢。由于模拟精度的问题(如系统调用、缺少硬件特性等),通常它也不那么完整。

可以通过以下代码构建控制流图:

1
2
3
4
5
6
7
8
9
>>> import angr
# 加载项目
>>> p = angr.Project('/bin/true', load_options={'auto_load_libs': False})

# 生成静态控制流图(CFG)
>>> cfg = p.analyses.CFGFast()

# 生成动态控制流图(CFG)
>>> cfg = p.analyses.CFGEmulated(keep_state=True)

提示

控制流图分析不会区分来自不同二进制对象的代码。这意味着默认情况下,它会尝试分析通过加载的共享库进行的控制流。这几乎从来不是预期的行为,因为这会使分析时间变得极长。要加载没有共享库的二进制文件,可以在 Project 构造函数中添加以下关键字参数:load_options={'auto_load_libs': False}

控制流图的核心是一个 NetworkX 有向图(di-graph)。这意味着所有常规的 NetworkX API 都可用:

1
2
>>> print("This is the graph:", cfg.graph)
>>> print("It has %d nodes and %d edges" % (len(cfg.graph.nodes()), len(cfg.graph.edges())))

CFGNode 类的实例代表了控制流图中的每个基本块。你可以通过 cfg.get_any_node() 获取给定地址的任何一个节点,或者通过 cfg.get_all_nodes() 获取所有上下文下的节点。

由于程序可能在多个上下文中执行,相同的基本块在不同的上下文下可能会有不同的表现。因此,同一个基本块可能会在图中有多个节点(表示不同的执行上下文)。

1
2
3
4
5
# 获取给定位置(入口点)对应的任意一个节点
>>> entry_node = cfg.get_any_node(p.entry)

# 获取给定位置(入口点)所有上下文的节点
>>> print("There were %d contexts for the entry block" % len(cfg.get_all_nodes(p.entry)))

CFGNode 还具有 predecessorssuccessors 属性,分别表示当前节点的前驱和后继节点。

1
2
3
4
5
6
7
8
# 获取入口节点的前驱
>>> print("Predecessors of the entry point:", entry_node.predecessors)

# 获取入口节点的后继
>>> print("Successors of the entry point:", entry_node.successors)

# 获取入口节点的后继节点及跳转类型
>>> print("Successors (and type of jump) of the entry point:", [jumpkind + " to " + str(node.addr) for node, jumpkind in cfg.get_successors_and_jumpkind(entry_node)])

在 IDA 中,控制流图是以函数为单位的,也就是说,IDA 将每个函数作为一个单独的基本块进行分析,并将函数的入口和出口视为控制流的边界。而在 angr 中,控制流图是基于 基本块 的,call 指令通常被视为 跳转(或控制流转移)的一个标志,而不是函数的边界。控制流图会继续分析 call 指令后的指令,但不会自动将其视为函数的边界。

angrmanagementto_supergraph 函数用于将 angr 的控制流图转换成一个函数级别的控制流图。to_supergraph 会把 angr 的单个函数的 CFG 提取出来,并将其转化为 IDA 样式的图。

1
2
3
4
5
6
7
8
9
10
11
def get_cfg():
# 使用 CFGFast 分析生成控制流图
cfg = proj.analyses.CFGFast(normalize=True, force_complete_scan=False)

# 获取某个函数的控制流图
function_cfg = cfg.functions.get(start).transition_graph

# 使用 to_supergraph 将该函数的控制流图转化为类似 IDA 的图
super_cfg = to_supergraph(function_cfg)

return super_cfg
手动提取 CFG

如果一个二进制程序比较大,那么使用 angr 内置的 CFG 生成方法会非常的慢。对于这种情况通常我们都会自己实现一个近似的提取 CFG 的方法,针对一个特定函数开始提取 CFG。

下面这段代码是针对 x86 架构遍历一个特定函数的 CFG 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def bfs_cfg(self, block_handler: Callable[[List[CsInsn]], Any]):
"""
广度优先遍历函数内控制流图(CFG)的核心方法(x86架构专用)
:param block_handler: 基本块处理回调函数,接收指令列表,返回非空值时终止遍历
:return: block_handler的返回值或None
"""

# 缓存分支路径已分析的基本块(避免重复处理)
if self.blocks != None:
for block in self.blocks:
# 获取基本块的反汇编指令列表,调用处理函数
result = block_handler(list(self.project.factory.block(block).capstone.insns))
if result: return result # 根据回调结果提前终止
return None

# 内部函数:判断地址是否在当前函数范围内 --------------------------------
def in_func_range(addr):
# 首次运行初始化.text段地址范围
if self.text_range == None:
# 从二进制文件获取.text段信息(ELF/PE格式)
text_section = [section for section in self.project.loader.main_object.sections
if section.name == '.text'][0]
self.text_range = range(text_section.vaddr, text_section.vaddr + text_section.filesize)

# 地址超出.text段范围直接过滤
if addr not in self.text_range:
return False

# 排除其他函数入口(通过识别函数序言)
block = self.project.factory.block(addr)
insns: List[CsInsn] = list(block.capstone.insns)
return not (
len(insns) >= 2 and
insns[0].mnemonic == 'push' and insns[0].op_str == 'rbp' # 函数开头push rbp
and insns[1].mnemonic == 'mov' and insns[1].op_str == 'rbp, rsp' # mov rbp, rsp
)

# BFS初始化 --------------------------------------------------------
block_queue = Queue() # 使用队列实现广度优先搜索
self.blocks: Set[int] = set() # 记录已访问的基本块地址

# 将起始地址加入队列(函数入口地址)
block_queue.put(self.addr)
self.blocks.add(self.addr)

# BFS主循环 --------------------------------------------------------
while not block_queue.empty():
# 获取当前基本块(通过angr解析)
block = self.project.factory.block(block_queue.get())
insns: List[CsInsn] = block.capstone.insns # Capstone反汇编结果

# 处理当前基本块指令(回调机制)
result = block_handler(insns)
if result: return result # 回调返回非空值时终止遍历

# 获取最后一条指令(分支指令分析)
last_insn = insns[-1]

# 遇到返回指令(ret)停止当前路径探索
if last_insn.mnemonic.startswith('ret'):
continue

# 处理条件跳转指令(jz/jne等)------------------------------------
if (last_insn.mnemonic.startswith('j') and # 跳转指令族
last_insn.operands[0].type == X86_OP_IMM): # 立即数操作数
next_block = last_insn.operands[0].imm # 跳转目标地址

# 过滤跨函数跳转(通过地址范围和函数序言判断)
if next_block not in self.blocks and in_func_range(next_block):
self.blocks.add(next_block)
block_queue.put(next_block)

# 处理顺序执行流(非jmp指令)-------------------------------------
if last_insn.mnemonic != 'jmp': # 排除无条件跳转
next_block = last_insn.address + last_insn.size # 计算下一条指令地址
if next_block not in self.blocks:
self.blocks.add(next_block)
block_queue.put(next_block)

return None

下面这段代码是针对 arm 架构遍历一个特定函数的 CFG 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def bfs_cfg(self, block_handler: Callable[[List[CsInsn]], Any]):
"""
广度优先遍历控制流图(CFG)的核心方法
:param block_handler: 基本块处理回调函数,接收指令列表,返回非空值时终止遍历
:return: block_handler的返回值或None
"""

# 如果已缓存基本块地址,直接遍历处理(缓存优化)
if self.blocks != None:
for block in self.blocks:
# 获取基本块的反汇编指令列表,调用处理函数
result = block_handler(list(self.project.factory.block(block).capstone.insns))
if result: return result # 根据回调结果提前终止
return None

# BFS初始化 ----------------------------------------------------------
block_queue = Queue() # 使用队列实现广度优先搜索
self.blocks: Set[int] = set() # 记录已访问的基本块地址(Thumb模式地址需|1)

# 将起始地址加入队列(ARM Thumb模式处理:地址末位置1)
block_queue.put(self.addr | 1) # 例如 0x1000 -> 0x1001
self.blocks.add(self.addr | 1)

# BFS主循环 ----------------------------------------------------------
while not block_queue.empty():
# 获取当前基本块
block_addr = block_queue.get()
block = self.project.factory.block(block_addr) # 获取基本块对象
cs_block = block.capstone # Capstone反汇编对象

# 处理当前基本块指令
result = block_handler(cs_block.insns) # 传递指令列表给回调函数
if result: return result # 回调返回非空值时终止遍历

# 获取最后一条指令
last_insn = cs_block.insns[-1]

# 分支指令处理逻辑 -----------------------------------------------
# 情况1:跳过无条件跳转(b.w)
if last_insn.mnemonic == 'b.w':
continue # 不处理后续逻辑

# 情况2:处理非函数调用的直接跳转(非bl指令的立即数操作)
if (not last_insn.mnemonic.startswith('bl')) and \
(last_insn.operands[0].type == ARM_OP_IMM):
next_block = last_insn.operands[0].imm # 获取跳转目标地址
self._add_next_block(next_block, block_queue) # 添加到队列

# 情况3:顺序执行的下一个块(非分支/非返回指令)
if (last_insn.mnemonic != 'b') and \
(not last_insn.mnemonic.startswith('pop')) and \
(last_insn.mnemonic != 'bx'):
next_block = last_insn.address + last_insn.size # 计算下一条指令地址
self._add_next_block(next_block, block_queue)

# 情况4:处理条件分支指令(cbz/cbnz等)
for insn in cs_block.insns:
if insn.mnemonic.startswith('cb') and \
insn.operands[1].type == ARM_OP_IMM:
next_block = insn.operands[1].imm # 条件跳转目标地址
self._add_next_block(next_block, block_queue)

return None

下面这段代码是针对 aarch64 架构遍历一个特定函数的 CFG 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def bfs_cfg(self, block_handler: Callable[[List[CsInsn]], Any]):
"""
ARM64架构函数内控制流图广度优先遍历方法
:param block_handler: 基本块处理回调函数,返回非空值时终止遍历
:return: block_handler的返回值或None
"""

# 缓存分支路径已分析的基本块(避免重复处理)
if self.blocks != None:
for block in self.blocks:
# 获取基本块的反汇编指令列表,调用处理函数
result = block_handler(list(self.project.factory.block(block).capstone.insns))
if result: return result # 根据回调结果提前终止
return None

# 获取.text段地址范围 -----------------------------------------------
text_section = [section for section in self.project.loader.main_object.sections
if section.name == '.text'][0]
text_section_range = range(text_section.vaddr,
text_section.vaddr + text_section.filesize) # 注意修正结束地址

# 内部函数:判断地址是否在当前函数范围内 ------------------------------
def in_func_range(addr):
# 地址超出.text段范围直接过滤
if addr not in text_section_range:
return False

# 检查是否为其他函数入口(通过识别函数序言指令)
block = self.project.factory.block(addr)
for insn in block.capstone.insns:
# ARM64函数序言特征:stp x29, x30(保存帧指针和链接寄存器)
if insn.mnemonic == 'stp' and 'x29, x30' in insn.op_str:
return False
return True

# BFS初始化 --------------------------------------------------------
block_queue = Queue() # 使用队列实现广度优先搜索
self.blocks: Set[int] = set() # 记录已访问的基本块地址

# 将起始地址加入队列(函数入口地址)
block_queue.put(self.addr)
self.blocks.add(self.addr)

# BFS主循环 --------------------------------------------------------
while not block_queue.empty():
# 获取当前基本块(通过angr解析)
block = self.project.factory.block(block_queue.get())
cs_block = block.capstone # Capstone反汇编对象

# 处理当前基本块指令(回调机制)
result = block_handler(cs_block.insns)
if result: return result # 回调返回非空值时终止遍历

# 获取最后一条指令(分支指令分析)
last_insn = cs_block.insns[-1]

# 遇到返回指令(ret)停止当前路径探索
if last_insn.mnemonic == 'ret':
continue

# 处理非函数调用的直接跳转(b指令)------------------------------
if (not last_insn.mnemonic.startswith('bl')) and \ # 排除bl指令(函数调用)
(last_insn.operands[0].type == ARM64_OP_IMM): # 立即数跳转目标
next_block = last_insn.operands[0].imm
if next_block not in self.blocks and in_func_range(next_block):
self.blocks.add(next_block)
block_queue.put(next_block)

# 处理条件分支指令(cbz/cbnz等)-------------------------------
if last_insn.mnemonic.startswith('cb') and \ # 条件分支指令族
last_insn.operands[1].type == ARM64_OP_IMM:
next_block = last_insn.operands[1].imm
if next_block not in self.blocks and in_func_range(next_block):
self.blocks.add(next_block)
block_queue.put(next_block)

# 处理顺序执行流(非无条件跳转)-------------------------------
if last_insn.mnemonic != 'b': # 排除无条件跳转指令
next_block = last_insn.address + last_insn.size # 计算下一条指令地址
if next_block not in self.blocks:
self.blocks.add(next_block)
block_queue.put(next_block)

return None

上述示例代码只是近似遍历指定函数 CFG,然后针对其中的每一个代码块调用回调函数。由于没有对整个二进制文件进行完整分析,并且例如判断函数边界等都过于简略,因此并不适用于所有情况(例如有 switch 跳转表的函数会丢失大量分支代码,需要额外编写针对跳转表的处理逻辑)。

函数

angr 的函数分析

控制流图(CFG)的结果会生成一个名为 函数管理器(Function Manager) 的对象,可以通过 cfg.kb.functions 访问。该对象最常见的使用方式是像字典一样访问,它将地址映射到 Function 对象,Function 对象可以提供关于函数的各种属性。

1
>>> entry_func = cfg.kb.functions[p.entry]

Function 对象具有多个重要属性:

  • entry_func.block_addrs :一个集合,包含函数中所有基本块的起始地址。
  • entry_func.blocks :包含该函数所有基本块的集合,可以使用 Capstone 进行反汇编和探索。
  • entry_func.string_references() :返回一个列表,包含函数中所有被引用的常量字符串。列表中的每个项是一个元组 (addr, string),其中:
    • addr 是字符串所在的二进制数据段中的地址。
    • string 是一个 Python 字符串,包含字符串的实际内容。
  • entry_func.returning :一个布尔值,表示函数是否能返回。False 表示该函数的所有路径都不会返回。
  • entry_func.callable :一个 angrCallable 对象,表示该函数。你可以像调用 Python 函数一样调用它,并传入 Python 参数,返回的结果可能是实际结果(可能是符号化的),就像你运行了该函数一样。
  • entry_func.transition_graph :一个 NetworkX 的有向图(DiGraph),描述函数内部的控制流。它类似于 IDA 所显示的每个函数级别的控制流图。
  • entry_func.name :函数的名称。
  • entry_func.has_unresolved_callsentry_func.has_unresolved_jumps :这些属性与检测 CFG 的不精确性有关。有时,分析无法检测出间接调用或跳转的目标地址。如果发生这种情况,该函数的相关属性将被设置为 True
  • entry_func.get_call_sites() :返回一个列表,包含所有以调用指令结尾的基本块地址。
  • entry_func.get_call_target(callsite_addr) :给定一个调用地址 callsite_addr,返回该调用指令的目标地址。
  • entry_func.get_call_return(callsite_addr) :给定一个调用地址 callsite_addr,返回该调用指令应该返回的地址。
手动提取函数

实际上前面的遍历 CFG 本质上就是在遍历函数的代码,这里主要介绍一下如何确定函数的起始地址。

为了避免代码速度过慢,我们需要通过搜索特征而不是 angr 的函数分析来确定函数起始地址,这种方法虽然不严谨,但是在多数情况下是准确的。

下面这段代码是针对 x86 架构寻找函数开头的代码,主要思路是搜索函数开头的特征 push rbp; mov rbp, rsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def get_func_by_addr(addr, project, size=0x1000):
"""
根据给定的地址,查找该地址所在的函数的起始地址。

:param addr: 目标地址,想要查找的地址
:param project: angr 项目的实例
:param size: 查找范围的大小,默认为 0x1000 字节
:return: 该地址所在的函数的起始地址
"""
# 从目标地址往前加载指定大小的内存数据
data = project.loader.memory.load(addr - size, size)

# 在加载的数据中查找函数的起始指令(假设是 "push rbp; mov rbp, rsp" 作为函数的前导)
addr = addr - size + data.rfind(b"\x55\x48\x89\xE5") # 0x55 0x48 0x89 0xE5 是 "push rbp; mov rbp, rsp" 的机器码

# 返回查找到的函数起始地址
return addr

下面这段代码是针对 arm 架构寻找函数开头的代码,由于 arm 架构的机器码长度比较固定,因此可以精确的分析汇编代码。不过要注意的是 arm 架构的函数后面可能会有一些全局变量的地址指针,被识别为汇编可能会影响分析结果,这里简单的用经验规则过滤一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def get_func_by_addr(addr, project):
"""
根据给定的地址,逐步向上查找,直到找到函数的起始地址。
假设函数的开始是通过检查指令 `push lr` 来确定的。

:param addr: 目标地址,想要查找的地址
:param project: angr 项目的实例
:return: 找到的函数起始地址
"""
while True:
# 读取从给定地址开始的 8 个字节,并反汇编为指令
insns: List[CsInsn] = list(CS.disasm(project.loader.memory.load(addr, 8), addr))

# 判断是否找到函数的起始指令
# 规则:第一条指令是 'push lr',第二条指令不是 'lsls'
if len(insns) > 1 \
and insns[0].mnemonic.startswith('push') \
and 'lr' in insns[0].op_str \
and insns[1].mnemonic != 'lsls': # 经验值,对应代码段中嵌入的地址表。
# 如果符合条件,返回当前地址作为函数的起始地址
return addr

# 如果不符合条件,继续向上移动地址(减去 2 字节)查找
addr -= 2

下面这段代码是针对 aarch64 架构寻找函数开头的代码,同样是搜索汇编实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def get_func_by_addr(addr, project):
"""
根据给定的地址,逐步向上查找,直到找到函数的起始地址。
假设函数的开始是通过检查指令 `stp x29, x30` 来确定的,通常这是 ARM64 函数的前导指令。

:param addr: 目标地址,想要查找的地址
:param project: angr 项目的实例
:return: 找到的函数起始地址
"""
while True:
# 读取从给定地址开始的 4 个字节,并反汇编为指令
insn: CsInsn = list(CS.disasm(project.loader.memory.load(addr, 4), addr))[0]

# 判断是否为 ARM64 函数的起始指令 "stp x29, x30"
if insn.mnemonic == 'stp' and 'x29, x30' in insn.op_str:
# 如果符合条件,返回当前地址作为函数的起始地址
return addr

# 如果不符合条件,继续向上移动地址(减去 4 字节)查找
addr -= 4

引用

前面定位函数起始地址的前提是需要有一个函数内部的地址,而我们通常是用一些特征数据(例如字符串)的引用来定位函数内部的地址的。

其中 arm32 架构由于全局变量的地址会被写到引用的函数后面,因此我们可以直接通过搜索全局变量的地址的方式来定位函数。而对于 x86 和 aarch64 架构则需要我们扫描汇编预处理出全局变量的引用关系。

x86 架构

在 x86 架构下,我们常见的字符串引用的汇编代码一般是如下两种形式:

  • lea 形式,适用于地址无关代码。对于这种形式被引用的字符串的地址在汇编指令的硬编码中体现不出来,因此不能直接通过搜索地址的方式定位到引用字符串的汇编代码,需要扫描分析汇编预处理引用表。

    1
    lea reg, [rip + offset];
  • mov 形式,直接将目标地址设置到寄存器中。对于这种形式被引用的字符串的地址在汇编指令的硬编码中自带字符串地址,因此可以通过在代码段中搜索字符串地址来定位。

    1
    mov reg, address;

最终的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import json
import os
from collections import defaultdict
from rich.progress import Progress # 用于显示进度条
import angr
from capstone import *
from capstone.x86 import *


class RefDictX86:
"""用于分析x86二进制文件并建立数据引用关系的工具类"""

def __init__(self, project: angr.project.Project):
self.project = project

# 根据二进制文件MD5生成缓存文件名
ref_file = f'{project.loader.main_object.md5.hex()}_ref.json'

# 如果存在缓存文件直接加载
if os.path.exists(ref_file):
self.ref_dict = {int(k): v for k, v in json.load(open(ref_file)).items()}
return

# 初始化引用字典并获取.text节数据
self.ref_dict = defaultdict(list)
text_section = [s for s in project.loader.main_object.sections if s.name == '.text'][0]
data = project.loader.memory.load(text_section.vaddr, text_section.filesize)

# 使用进度条显示反汇编进度
with Progress() as progress:
task = progress.add_task("[*] 分析引用关系...", total=text_section.filesize)
cs = Cs(CS_ARCH_X86, CS_MODE_64) # 初始化x64反汇编引擎
cs.detail = True # 启用详细模式获取操作数信息

disasm_offset = 0 # 反汇编偏移指针

# 遍历.text节的每条指令
while disasm_offset < text_section.filesize:
# 快速反汇编获取基础指令信息
for (addr, size, mnemonic, op_str) in cs.disasm_lite(data[disasm_offset:], text_section.vaddr + disasm_offset):
progress.update(task, advance=size)

# 处理RIP相对寻址的LEA指令(常见于字符串加载)
if mnemonic == 'lea' and 'rip' in op_str:
# 完整反汇编获取详细操作数信息
insn = next(cs.disasm(data[disasm_offset:disasm_offset + size], text_section.vaddr + disasm_offset))
# 验证LEA指令格式:lea reg, [rip+offset]
if (insn.operands[1].mem.base == X86_REG_RIP
and insn.operands[1].type == X86_OP_MEM):
# 计算实际内存地址 = 下条指令地址 + 偏移量
target_addr = insn.address + insn.size + insn.operands[1].mem.disp
self.ref_dict[target_addr].append(insn.address)

# 处理直接加载立即数的MOV指令(常见于全局变量访问)
elif mnemonic == 'mov' and '[' not in op_str: # 排除内存操作数
insn = next(cs.disasm(data[disasm_offset:disasm_offset + size], text_section.vaddr + disasm_offset))
# 验证MOV指令格式:mov reg, imm
if (insn.operands[1].type == X86_OP_IMM
and insn.operands[1].imm in self.project.loader.main_object.reverse_plt):
# 检查目标地址是否在.rodata节
sec = self.project.loader.main_object.find_section_containing(insn.operands[1].imm)
if sec and sec.name == '.rodata':
self.ref_dict[insn.operands[1].imm].append(insn.address)

disasm_offset += size # 移动反汇编指针

# 将结果序列化到JSON文件
json.dump(self.ref_dict, open(ref_file, 'w+'))

def str_ref(self, s: str) -> list:
"""查找字符串引用地址"""
# 在内存中搜索字符串
str_addrs = list(self.project.loader.memory.find(s.encode()))
print(f"[*] 字符串地址: {' '.join(f'0x{addr:x}' for addr in str_addrs)}")

# 收集所有引用该字符串的指令地址
return [ref for addr in str_addrs for ref in self.ref_dict.get(addr, [])]


# 测试用例
if __name__ == "__main__":
# 示例:分析文件"init"中"r+"字符串的引用
ref_dict = RefDictX86(angr.Project("init")) # 初始化分析器
print("引用指令地址:", ' '.join(f'0x{addr:x}' for addr in ref_dict.str_ref("r+")))
AArch64 架构

aarch 架构可以参考前面计算内存操作数对应的地址的方法扫描汇编进行预处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import json
import os
from collections import defaultdict
from rich.progress import Progress # 更现代的进度条库
import angr
from capstone import *
from capstone.arm64 import *


class RefDictARM64:
"""用于分析ARM64二进制文件并建立数据引用关系的工具类"""

def __init__(self, project: angr.project.Project):
self.project = project

# 根据二进制文件MD5生成缓存文件名
ref_file = f'{project.loader.main_object.md5.hex()}_ref.json'

# 如果存在缓存文件直接加载
if os.path.exists(ref_file):
with open(ref_file, 'r') as f:
self.ref_dict = {int(k): v for k, v in json.load(f).items()}
return

# 初始化引用字典并获取.text节数据
self.ref_dict = defaultdict(list)
text_section = next(s for s in project.loader.main_object.sections if s.name == '.text')
data = project.loader.memory.load(text_section.vaddr, text_section.filesize)

# 初始化反汇编引擎
cs = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
cs.detail = True

# 使用上下文管理器管理进度条
with Progress() as progress:
task = progress.add_task("[*] 分析ARM64引用关系...", total=text_section.filesize)
reg_value = {} # 用于跟踪寄存器存储的基地址

disasm_offset = 0

for (address, size, mnemonic, op_str) in cs.disasm_lite(data[disasm_offset:], text_section.vaddr + disasm_offset):
progress.update(task, advance=size)
if mnemonic == 'adrp' or mnemonic == 'add':
insn = next(cs.disasm(data[disasm_offset:disasm_offset + size], text_section.vaddr + disasm_offset))

# 处理ADRP指令(页地址加载)
if insn.mnemonic == 'adrp':
if (insn.operands[0].type == ARM64_OP_REG and
insn.operands[1].type == ARM64_OP_IMM):
# 记录寄存器存储的页基地址
reg_value[insn.operands[0].reg] = insn.operands[1].imm

# 处理ADD指令(偏移地址计算)
elif insn.mnemonic == 'add':
if (insn.operands[0].type == ARM64_OP_REG and
insn.operands[1].type == ARM64_OP_REG and
insn.operands[2].type == ARM64_OP_IMM and
insn.operands[0].reg == insn.operands[1].reg and
insn.operands[1].reg in reg_value):
# 计算完整地址 = 页基地址 + 偏移量
base = reg_value[insn.operands[1].reg]
full_addr = base + insn.operands[2].imm
self.ref_dict[full_addr].append(insn.address)

# 分支指令清空寄存器跟踪状态
if mnemonic.startswith('b'):
reg_value.clear()

disasm_offset += size

# 保存结果到JSON文件
os.makedirs('refs', exist_ok=True)
with open(ref_file, 'w+') as f:
json.dump(self.ref_dict, f)

def str_ref(self, s: str) -> list:
"""查找字符串引用地址"""
# 在内存中搜索字符串
str_addrs = list(self.project.loader.memory.find(s.encode()))
print(f"[*] 字符串地址: {' '.join(f'0x{addr:x}' for addr in str_addrs)}")

# 收集所有引用该字符串的指令地址
return [ref for addr in str_addrs for ref in self.ref_dict.get(addr, [])]


# 测试用例
if __name__ == "__main__":
# 示例:分析文件"init"中"r+"字符串的引用
ref_dict = RefDictARM64(angr.Project("init", auto_load_libs=False)) # 初始化分析器
print("引用指令地址:", ' '.join(f'0x{addr:x}' for addr in ref_dict.str_ref("r+")))

符号执行

符号执行原理

基本概念

符号执行(Symbolic Execution)是一种程序分析技术,它通过使用符号(而不是具体的值)来代替程序中的输入数据,在程序执行时跟踪符号变量的值。这使得我们能够推理出程序的行为,而不需要实际运行它。符号执行可以帮助发现程序的潜在问题,如漏洞、错误和安全问题,尤其在静态分析、漏洞挖掘和逆向工程等领域有广泛应用。

符号执行中的 符号状态路径约束 是符号执行中两个非常重要的概念,它们帮助我们表达程序的执行过程和各种条件。

  • 符号状态(Symbolic State):当前状态所有参数的集合,用 σσ 表示。集合中的每个元素用表示初始参数的变量表示。
  • 路径约束(Path Constraint):到达当前路径需要表示初始参数满足的关系,通常用 PC\text{PC} 表示。

例如下面的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <bits/stdc++.h>

using namespace std;

int main() {
int x, y, z;
cin >> x >> y;
z = 2 * y;
if (x == z) {
if (x > y + 10) {
cout << "Path-1";
} else {
cout << "Path-2";
}
} else {
cout << "Path-3";
}
return 0;
}

对应的程序框图如下:

simulation

我们用 xsimx_{sim}ysimy_{sim} 分别表示初始输入的参数 xy 。如果程序执行到 Path-1 ,则:

  • σ={x=xsim,y=ysim,z=2ysim}σ=\{x=x_{sim},y=y_{sim},z=2\cdot y_{sim}\}
  • PC=(xsim=2ysim)(xsim>ysim+10)\text{PC}=(x_{sim}=2\cdot y_{sim}) \wedge ( x_{sim}>y_{sim}+10)

约束求解

即根据符号执行求得的执行到目标位置时的状态,反推出初始时假设的各个变量的值。

例如上面计算出执行到 Path-1 时的 σσ 和 PC 。如果执行到 Path-1 则应当满足 PC 为真,进一步推出 xsim=22,ysim=11x_{sim} = 22,y_{sim}=11 为一组合法解。

为了进行约束求解,angr 内置了 z3 约束求解器(封装为 claripy)。

动态符号执行

由于 angr 分析基于的是低级语言,会涉及内存、寄存器等结构,如果全部符号化会使得路径约束变得十分复杂且没有必要。

因此 angr 采取动态符号执行(Dynamic Symbloic Execution)或者叫做混合执行(Concolic Execution)的方式,即将关键变量符号化,其他变量都赋一个合理的初始值。

angr 在默认情况下,只有从标准输入流中读取的数据会被符号化,其他数据都是具有实际值的。

符号执行引擎(Claripy)

Claripy 是由 z3 封装的二进制分析框架 angr 的核心符号执行引擎,专注于 符号表达式操作约束求解。它为二进制分析提供了一套高级抽象接口,简化了符号变量管理、约束构建和求解过程,使复杂的符号执行任务更易实现。

位向量创建

位向量(Bitvectors) 是符号执行中一个非常核心的概念,特别是在像 angr 这样的符号执行框架中,位向量用于表示程序中变量的值和各种计算结果。

位向量是一个由多个比特(bit)组成的向量。在符号执行的上下文中,位向量被用来表示程序中未确定的数值(如变量、内存中的数据等)。每个比特的位置可以表示不同的数值或者数据状态。

我们可以通过 Claripy 创建位向量常量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> import claripy

# 64 位位向量,具体值为 1 和 100
>>> one = claripy.BVV(1, 64)
>>> one
<BV64 0x1>
>>> one._model_concrete.value
1

>>> one_hundred = claripy.BVV(100, 64)
>>> one_hundred
<BV64 0x64>

# 创建一个 27 位位向量,具体值为 9
>>> weird_nine = claripy.BVV(9, 27)
>>> weird_nine
<BV27 0x9>

除了位向量常量,我们还可以创建位向量符号:

1
2
3
4
5
6
7
# 创建一个名为 "x" 的 64 位位向量符号
>>> x = claripy.BVS("x", 64)
>>> x
<BV64 x_9_64>
>>> y = claripy.BVS("y", 64)
>>> y
<BV64 y_10_64>

z3 支持 IEEE754 浮点数理论,因此 angr 也可以使用它们。主要的区别是,浮点数不是通过宽度来表示的,而是通过 claripy.fp.FSORT_FLOAT/claripy.fp.FSORT_DOUBLE 来表示。你可以使用 FPVFPS 来创建浮点符号和浮点值。

1
2
3
4
5
6
7
>>> a = claripy.FPV(3.2, claripy.fp.FSORT_DOUBLE)  # 创建浮点值
>>> a
<FP64 FPV(3.2, DOUBLE)>

>>> b = claripy.FPS('b', claripy.fp.FSORT_DOUBLE) # 创建浮点符号
>>> b
<FP64 FPS('FP_b_0_64', DOUBLE)>

浮点数和整数类型的向量可以互相转换。

如果是使用 raw_to_bvraw_to_fp 转换则表示的是数据不变,数据的解释方式改变(就像你将浮点数指针转换为整数指针或反之一样)。

1
2
3
4
5
6
7
8
9
>>> a.raw_to_bv()
<BV64 0x400999999999999a>
>>> b.raw_to_bv()
<BV64 fpToIEEEBV(FPS('FP_b_0_64', DOUBLE))>

>>> claripy.BVV(0, 64).raw_to_fp()
<FP64 FPV(0.0, DOUBLE)>
>>> claripy.BVS('x', 64).raw_to_fp()
<FP64 fpToFP(x_1_64, DOUBLE)>

如果是类型转换则需要使用 val_to_fpval_to_bv

1
2
3
4
5
6
>>> a
<FP64 FPV(3.2, DOUBLE)>
>>> a.val_to_bv(12)
<BV12 0x3>
>>> a.val_to_bv(12).val_to_fp(claripy.fp.FSORT_FLOAT)
<FP32 FPV(3.0, FLOAT)>

位向量运算

同样长度的位向量可以进行运算,其中 Pyhton 的整数类型也可以参与运算,在运算过程中会被强制转换为适当的类型。

1
2
3
4
5
6
>>> one + one_hundred
<BV64 0x65>
>>> one_hundred + 0x100
<BV64 0x164>
>>> one_hundred - one*200
<BV64 0xffffffffffffff9c>

但是,你不能执行 one + weird_nine,因为操作数位向量的长度不同,这是一个类型错误。然而,你可以扩展 weird_nine 使它具有适当的位数:

1
2
3
4
>>> weird_nine.zero_extend(64 - 27)
<BV64 0x9>
>>> one + weird_nine.zero_extend(64 - 27)
<BV64 0xa>

zero_extend 将在位向量的左侧填充给定数量的零位。你还可以使用 sign_extend 来用最高位的副本进行填充,保持位向量在二进制补码有符号整数语义下的值。

位向量符号同样也可以参与到位向量运算中。你可以对它们进行任意算术运算,但你不会得到一个数字,而是得到一个 AST(抽象语法树)。

1
2
3
4
5
6
7
8
>>> x + one
<BV64 x_9_64 + 0x1>

>>> (x + one) / 2
<BV64 (x_9_64 + 0x1) / 0x2>

>>> x - y
<BV64 x_9_64 - y_10_64>

每个 AST 都有 .op.args 属性:

  • op 是一个字符串,表示正在执行的操作。
  • args 是该操作接受的输入值。

除非 opBVVBVS(或其他少数几种情况),否则 args 都是其他的 AST,最终树将终止于 BVVBVS

AST

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> tree = (x + 1) / (y + 2)
>>> tree
<BV64 (x_9_64 + 0x1) / (y_10_64 + 0x2)>
>>> tree.op
'__floordiv__'
>>> tree.args
(<BV64 x_9_64 + 0x1>, <BV64 y_10_64 + 0x2>)
>>> tree.args[0].op
'__add__'
>>> tree.args[0].args
(<BV64 x_9_64>, <BV64 0x1>)
>>> tree.args[0].args[1].op
'BVV'
>>> tree.args[0].args[1].args
(1, 64)

另外浮点数向量也支持数学运算:

1
2
3
4
5
6
7
8
>>> a + b
<FP64 fpAdd('RNE', FPV(3.2, DOUBLE), FPS('FP_b_0_64', DOUBLE))>

>>> a + 4.4
<FP64 FPV(7.6000000000000005, DOUBLE)>

>>> b + 2 < 0
<Bool fpLT(fpAdd('RNE', FPS('FP_b_0_64', DOUBLE), FPV(2.0, DOUBLE)), FPV(0.0, DOUBLE))>

符号约束(Symbolic Constraints)

对任何两个相同类型的 AST 执行比较操作将生成另一个 AST。这个新生成的 AST 不是位向量,而是一个符号布尔值。

注意

AST 默认情况下的比较是无符号的。最后一个例子中的 -5 会被强制转换为 <BV64 0xfffffffffffffffb>,它显然不小于 100。如果你想要进行有符号的比较,可以使用 one_hundred.SGT(-5)(即“有符号大于”)。

1
2
3
4
5
6
7
8
9
10
11
12
>>> x == 1
<Bool x_9_64 == 0x1>
>>> x == one
<Bool x_9_64 == 0x1>
>>> x > 2
<Bool x_9_64 > 0x2>
>>> x + y == one_hundred + 5
<Bool (x_9_64 + y_10_64) == 0x69>
>>> one_hundred > 5
<Bool True>
>>> one_hundred > -5
<Bool False>

符号布尔值可以通过 claripy.is_true/claripy.is_false 或本身的 is_trueis_false 方法来判断真假。

注意

is_trueis_false 只是用来判断符号布尔值是否永真或永假。对于结果不确定的符号布尔值两个方法都会返回 False

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> yes = one == 1
>>> no = one == 2
>>> maybe = x == y
>>> claripy.is_true(yes) # 等价于 yes.is_true()
True
>>> claripy.is_false(yes)
False
>>> claripy.is_true(no)
False
>>> claripy.is_false(no)
True
>>> claripy.is_true(maybe)
False
>>> claripy.is_false(maybe)
False

另外符号布尔值不应直接在 if 语句或 while 语句的条件中使用,因为答案可能没有具体的真值,并且即使有具体真值也会触发异常。

通常情况下,Claripy 支持所有常见的 Python 操作符(如 +-|== 等),并通过 Claripy 实例对象提供了额外的一些操作。这些操作是 Claripy 提供的用于处理符号表达式的基本操作,通过它们可以进行位运算、条件判断、扩展和提取等操作,在符号执行和分析中非常有用。

名称 描述 示例
LShR 逻辑右移位操作(适用于位表达式,如 BV、SI)。 claripy.LShR(x, 10)
SignExt 对位表达式进行符号扩展。 claripy.SignExt(32, x)x.sign_extend(32)
ZeroExt 对位表达式进行零扩展。 claripy.ZeroExt(32, x)x.zero_extend(32)
Extract 从位表达式中提取指定的位(从右侧零索引开始,包含边界)。 提取 x 最右边的字节:claripy.Extract(7, 0, x)x[7:0]
Concat 将多个位表达式拼接成一个新的位表达式。 claripy.Concat(x, y, z)
RotateLeft 将位表达式左旋转。 claripy.RotateLeft(x, 8)
RotateRight 将位表达式右旋转。 claripy.RotateRight(x, 8)
Reverse 对位表达式进行字节序反转。 claripy.Reverse(x)x.reversed
And 逻辑与(适用于布尔表达式)。 claripy.And(x == y, x > 0)
Or 逻辑或(适用于布尔表达式)。 claripy.Or(x == y, y < 10)
Not 逻辑非(适用于布尔表达式)。 claripy.Not(x == y) 等同于 x != y
If 条件选择(If-then-else)。 选择 xy 中的最大值:claripy.If(x > y, x, y)
ULE 无符号小于或等于。 检查 x 是否小于或等于 yclaripy.ULE(x, y)
ULT 无符号小于。 检查 x 是否小于 yclaripy.ULT(x, y)
UGE 无符号大于或等于。 检查 x 是否大于或等于 yclaripy.UGE(x, y)
UGT 无符号大于。 检查 x 是否大于 yclaripy.UGT(x, y)
SLE 有符号小于或等于。 检查 x 是否小于或等于 yclaripy.SLE(x, y)
SLT 有符号小于。 检查 x 是否小于 yclaripy.SLT(x, y)
SGE 有符号大于或等于。 检查 x 是否大于或等于 yclaripy.SGE(x, y)
SGT 有符号大于。 检查 x 是否大于 yclaripy.SGT(x, y)

约束求解(Constraint Solving)

你可以将任何符号布尔值视为对符号变量有效值的断言,通过将其作为约束添加到状态中。然后,你可以通过请求对符号表达式的求值,查询符号变量的有效值。

1
2
3
4
5
6
7
8
>>> solver = claripy.Solver()
>>> solver.add(x > y)
>>> solver.add(y > 2)
>>> solver.add(10 > x)
>>> solver.eval(x, 4) # 第二个参数表示获取的解的个数
(8, 9, 4, 5)
>>> solver.eval(x + y, 3) # 支持求解表达式
(7, 15, 16)

如果我们添加了相互冲突或矛盾的约束,导致没有任何值可以赋给变量以满足约束条件,状态将变得不可满足(unsat),查询时会引发异常。你可以通过 solver.satisfiable() 检查状态是否可满足。

1
2
>>> solver.satisfiable()
True

另外如果我们想要获取解的最大或最小值则应当使用 maxmin 方法:

1
2
3
4
>>> solver.max(x)
9
>>> solver.min(x)
4

状态(States)

Project 对象只代表程序的一个“初始化镜像”。当你在 angr 中执行程序时,你实际上是在操作一个代表程序状态的对象——SimState

状态创建

我们可以通过 factoryentry_state 创建状态。

1
2
>>> state = proj.factory.entry_state()
<SimState @ 0x401670>

当然 entry_state 只是项目工厂提供的多个状态构造函数之一,常见的状态构造函数有:

  • .blank_state()构造一个“空白”状态,数据大部分没有初始化。当访问未初始化的数据时,会返回一个没有约束的符号值。适用于要完全控制初始条件的场景。
    • addr :状态应该开始的地址,而不是入口点。
  • .entry_state():构造一个准备从主二进制的入口点开始执行的状态。
    • argc :用作程序 argc 的自定义值,可以是整数或比特向量。如果未提供,则默认为 args 的长度。
    • args :一个值的列表,用作程序的 argv。可以是混合字符串和比特向量。
    • env :一个字典,用作程序的环境。键和值都可以是混合的字符串和比特向量。
    • stdin :程序的输入流。可以是字符串或比特向量,不过最好长度给的要足够。
  • .full_init_state():构造一个准备执行所有需要在主二进制入口点之前运行的初始化器的状态,例如共享库构造函数或预初始化器。当这些完成后,它将跳转到入口点。它可以接受 entry_state 可以提供的任何参数,除了 addr
  • .call_state():构造一个准备执行给定函数的状态。
    • addr :状态应该开始的地址,而不是入口点。
    • args :任何额外的位置参数将作为函数调用的参数。

SimState 包含了程序的内存、寄存器、文件系统数据等内容……任何在执行过程中可能被修改的“实时数据”都存储在这个状态中。

1
2
3
4
5
6
7
8
9
10
11
>>> state.regs.rip        # 获取当前的指令指针
<BV64 0x401670>
>>> state.regs.rax
<BV64 0x1c>
>>> state.mem[proj.entry].int.resolved # 将入口点处的内存作为 C int 解释
<BV32 0x8949ed31>

# 标准输入内容
# state.posix.dumps(fileno) 获取对应文件描述符上的流
>>> state.posix.dumps(0)
b''

可以看到无论是内存还是寄存器,angr 的 SimState 都是用位向量的形式来维护。这种策略方便符号执行完之后进行约束求解。另外就是除了输入和用户指定的数据外,其余数据都是给定一个合理的初始值而不都是符号化,这样可以极大的简化最终生成的表达式的复杂程度。

内存设置

在 angr 中,state.memstate.memory 是用于操作内存的两个核心接口,分别提供 类型化内存访问原始字节级操作 的功能。

state.memory 提供 原始字节级操作,这意味着你可以直接访问内存的字节,而不需要考虑类型的转换。state.memory 用于处理低级操作,适合那些需要进行细粒度控制的场景,或者需要直接修改内存数据的情况。

  • load(addr, size):从地址 addr 读取 size 字节,返回位向量(claripy.BV)。
  • store(addr, data):将数据 data(位向量或字节)写入地址 addr
1
2
3
4
>>> state = proj.factory.blank_state()
>>> state.memory.store(0x4000, s.solver.BVV(0x0123456789abcdef0123456789abcdef, 128))
>>> state.memory.load(0x4004, 6)
<BV48 0x89abcdef0123>

注意

state.memory 的主要用途是加载和存储数据块,没有附加语义,因此数据默认按照“大端序”读写。如果你想对加载或存储的数据进行字节交换,你可以传递一个关键字参数 endness

endness 应该是 archinfo 包中的 Endness 枚举的成员,该包用于保存关于 angr 中 CPU 架构的声明性数据。此外,正在分析的程序的字节序可以通过 arch.memory_endness 获取,比如 state.arch.memory_endness

1
2
3
>>> import archinfo
>>> s.memory.load(0x4000, 4, endness=archinfo.Endness.LE)
<BV32 0x67452301>

state.mem 提供 类型化内存访问,允许你对内存进行更高层次的操作,通常与寄存器、结构体、数组等数据结构的交互更为便捷。

注意

  • state.mem 赋值的时候可以使用 bytes、数字、位向量,但是位向量要确保类型长度一致。
  • state.mem 按照二进制默认的大小端序读写。
1
2
3
4
5
6
7
8
>>> state = proj.factory.blank_state()
>>> state.mem[0x4000].int64_t = 0xdeadbeefcafebabe
>>> state.mem[0x4000].uint32_t
<uint32_t <BV32 0xcafebabe> at 0x4000>
>>> state.mem[0x4000].uint32_t.resolved
<BV32 0xcafebabe>
>>> state.mem[0x4000].uint32_t.resolved._model_concrete.value
0xcafebabe

寄存器设置

和内存接口一样,angr 的寄存器接口也有 state.regsstate.registers 两种。和 state.memory 一样,state.registers 也提供了没有具体类型的底层数据访问接口,因为 angr 的寄存器本质上也是通过某个地址空间的内存来模拟的。

不过在实际使用中我们通常还是使用 state.regs 接口来读写寄存器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> import angr, claripy
>>> proj = angr.Project('/bin/true')
>>> state = proj.factory.entry_state()

# 将 rsp 复制到 rbp
>>> state.regs.rbp = state.regs.rsp

# 将 rdx 存储到内存地址 0x1000
>>> state.mem[0x1000].uint64_t = state.regs.rdx

# 解引用 rbp
>>> state.regs.rbp = state.mem[state.regs.rbp].uint64_t.resolved

# 执行 add rax, qword ptr [rsp + 8]
>>> state.regs.rax += state.mem[state.regs.rsp + 8].uint64_t.resolved

文件设置

SimFileangr 中用于模拟文件操作的类,它实现了对文件的模拟,包括文件读取、写入、以及其他文件操作。它设计的目标是模拟磁盘文件的行为,并且允许符号执行引擎(symbolic execution engine)对文件的内容和文件系统操作进行符号化处理。

SimFile 构造函数中常用的参数有:

  • name :文件的名称,用于标识文件。这个名称通常是文件路径的一部分。
  • content :可选的初始内容,可以是字符串或者位向量(bitvector)。如果没有提供内容,文件内容将默认为零。
  • size :可选的文件大小。如果没有提供大小,文件大小默认为零。如果提供了 content,则文件大小将根据内容的大小确定。

例如下面的示例代码,我们将 password.txt 这个文件符号化,这样如果程序的执行受到了该文件内容的影响,那么我们就可以在目标状态下求解文件的内容。

1
2
3
4
>>> state = proj.factory.entry_state()
>>> password = claripy.BVS('password', 0x40)
>>> sim_file = angr.SimFile(name='password.txt', content=password, size=0x40)
>>> state.fs.insert('password.txt', sim_file)

仿真管理器(Simulation Managers)

angr 中,仿真管理器(Simulation Managers)是用于管理模拟状态(SimState)的核心组件之一。仿真管理器负责维护符号执行过程中所有可能的路径,并为每个路径创建并管理相应的状态。

仿真管理器创建

仿真管理器通过 factorysimulation_manager 构造函数生成,该函数接收一个状态或状态列表。一个仿真管理器可以包含多个状态堆栈。默认的状态堆栈是 active,它使用我们传入的状态进行初始化。

1
2
3
4
>>> simgr = proj.factory.simulation_manager(state)
<SimulationManager with 1 active>
>>> simgr.active
[<SimState @ 0x401670>]

在 angr 当中,不同的状态被组织到 simulation manager 的不同的 stash 当中,我们可以按照自己的需求进行步进、过滤、合并、移动等。在 angr 当中一共有以下几种 stash:

  • simgr.active:活跃的状态列表。在未指定替代的情况下会被模拟器默认执行。
  • simgr.deadended:死亡的状态列表。当一个状态无法再被继续执行时(例如没有有效指令、无效的指令指针、不满足其所有的后继(successors))便会被归入该列表。
  • simgr.pruned:被剪枝的状态列表。在指定了 LAZY_SOLVES 时,状态仅在必要时检查可满足性,当一个状态在指定了 LAZY_SOLVES 时被发现是不可满足的(unsat),状态层(state hierarchy)将会被遍历以确认在其历史中最初变为不满足的时间,该点及其所有后代都会被 剪枝 (pruned)并放入该列表。
  • simgr.unconstrained:不受约束的状态列表。当创建 SimulationManager 时指定了 save_unconstrained=True,则被认为不受约束的(unconstrained,即指令指针被用户数据或其他来源的符号化数据控制)状态会被归入该列表。
  • simgr.unsat:不可满足的状态列表。当创建 SimulationManager 时指定了 save_unsat=True,则被认为无法被满足的(unsatisfiable,即存在约束冲突的状态,例如在同一时刻要求输入既是"AAAA" 又是 "BBBB")状态会被归入该列表。

还有一种不是 stash 的状态列表——errored,若在执行中产生了错误,则状态与其产生的错误会被包裹在一个 ErrorRecord 实例中(可通过 record.staterecord.error 访问),该 record 会被插入到 errored 中,我们可以通过 record.debug() 启动一个调试窗口。

我们可以使用 stash.move() 来在 stash 之间转移放置状态,用法如下:

1
>>> simgr.move(from_stash = 'unconstrained', to_stash = 'active')

在转移当中我们还可以通过指定 filter_func 参数来进行过滤:

1
2
3
4
>>> def filter_func(state):
... return b'Right!' in state.posix.dumps(1)
...
>>> simgr.move(from_stash = 'unconstrained', to_stash = 'active', filter_func = filter_func)

stash 本质上就是个 list,因此在初始化时我们可以通过字典的方式指定每个 stash 的初始内容:

1
2
3
4
5
>>> simgr = proj.factory.simgr(init_state,
... stashes = {
... 'active':[init_state],
... 'found':[],
... })

路径探索

仿真管理器以基本块为单位对程序进行符号执行,对应的方法为 simgr.step()。每当仿真管理器调用一次 step 方法时:

  • 内部维护的 active 列表中的所有活跃状态都会执行一个基本块。
  • 每个状态在执行完一个基本块后根据基本块后根据执行的结果决定状态是否分裂或从 active 中移除。

我们可以循环调用 simgr.step() 然后遍历 active 列表判断是否有执行到我们预想的目标地址的状态。然后再对执行到目标地址的状态求解所需的输入。

1
2
3
4
5
6
while len(simgr.active):
for active in simgr.active:
if active.addr == target_addr:
# 执行到目标地址
# [...]
simgr.step()

上述过程实际上在仿真管理器中被封装成一个路径探索函数 simgr.explore()

explore 函数主要有两个参数:

  • find :一个地址或条件,表示我们希望探索到的目标状态。当仿真管理器的任何路径到达该地址时,仿真过程会停止或返回该路径。
  • avoid :一个地址或条件,表示我们希望避免的状态。即仿真管理器会尽量避免路径到达此地址或条件,通常用于避开错误路径或崩溃点。

提示

findavoid 可以接受多种类型的参数:

  • 如果参数类型是数字表示的是地址,即仿真管理器应当或不应当执行到的地址。

  • 如果参数是一个回调函数(或者 lambda 表达式),则会根据函数的返回结果对当前探索的路径进行剪枝。

    1
    2
    3
    4
    simgr.explore(
    find=lambda state: b'Good Job.' in state.posix.dumps(1),
    avoid=lambda state: b'Try again.' in state.posix.dumps(1)
    )

simgr.explore 执行完之后所有能执行到目标地址的状态都会放到 simgr.found 列表中。

另外仿真管理器还提供了多种技术来防止路径探索过程中出现路径爆炸的问题。例如出自2014年的一篇论文 Enhancing Symbolic Execution with Veritesting 的路径归并算法:

1
2
3
4
5
# 在创建仿真管理器的时候指定开启 veritesting
>>> simgr = proj.factory.simgr(state, veritesting=True)

# 另一种方式,可以通过 use_technique 方法使用。
>>> simgr.use_technique(angr.exploration_techniques.Veritesting())

路径归并算法主要是结合了先前两种符号执行算法 DSE(动态符号执行)和 SSE(静态符号执行)的优缺点:

  • 动态符号执行(DSE) :DSE 在执行过程中针对每一条路径进行符号执行,能够精确模拟每个路径的执行。然而,当程序包含大量条件分支时,路径数量会呈指数级增长,导致路径爆炸的问题,这对计算资源造成很大的压力。
  • 静态符号执行(SSE) :SSE 在静态分析阶段通过控制流图(CFG)来处理路径,通常能减少路径的数量,避免路径爆炸问题。然而,它在处理包含复杂系统调用、间接跳转或其他难以静态推理的语句时效果较差(很难用符号约束表示整个程序的逻辑)。

Veritesting 算法结合了动态符号执行和静态符号执行的优势。当程序中遇到不适合静态分析的部分(如系统调用、间接跳转等),可以切换到静态符号执行;而对于可以精确分析的部分,则使用动态符号执行,从而提高了符号执行的效率和精度。同时 Veritesting 使用路径合并技术,将多个路径合并成一个路径,避免了 DSE 中路径数量爆炸的问题。通过合并路径,Veritesting 在保持精确度的同时,显著减少了需要处理的路径数量。当然具体的细节还得阅读论文。

另外根据官方的说法,Versitesting 通常与其他 exploration techniques 不兼容。

Note that it frequenly doesn’t play nice with other techniques due to the invasive way it implements static symbolic execution.

函数 hook

仿真管理器在路径探索的过程中,可能因为某个函数导致路径爆炸。例如:

  • 程序自身实现的函数 :例如字符串比较函数,在比较到不同字符时跳出循环。如果该函数被符号执行,那么每循环一次所有状态都会因为跳出和不跳出循环两种情况而“分裂”一次。

  • 静态链接的程序中调用的比较复杂的库函数 :例如 malloc。这些函数在静态链接的程序中无法自动 hook,因为 angr 默认只会自动 hook 动态链接的库函数。

    在 angr 中,动态链接的程序调用的库函数会被自动 hook 为 angr 自身实现的库函数,这样可以有效避免路径爆炸问题。但是对于静态链接的程序,即使有调试符号 angr 也不会去自动 hook 这些函数,从而可能导致路径爆炸的问题。

为了解决这一问题,我们需要对造成路径爆炸的函数进行 hook。angr 提供了两种主要的方式来 hook 函数:proj.hookSimProcedure

project.hook 方法类似于二进制层面的 hook,即将指定位置的指定长度的二进制指令替换为调用我们自己实现的 Python 函数。具体来说,可以通过以下方式来 hook 掉对应的 call 指令:

1
project.hook(addr = call_insn_addr, hook = my_function, length = n)
  • call_insn_addr :被 hook 的 call 指令的地址。
  • my_function :我们自定义的 Python 函数。
  • lengthcall 指令的长度。

其中我们的自定义函数应该接受 state 作为参数,我们可以通过操作 state 模拟该函数对程序执行状态造成的影响。

1
2
3
4
5
def my_hook_func(state):
# 执行自定义的操作,这里是一个示例
state.regs.eax = 0xdeadbeef

proj.hook(addr = 0x5678, hook = my_hook_func, length = 5)

另外 angr 还支持注解的方式进行 hook,下面这段代码与前面的代码等价:

1
2
3
4
@project.hook(0x5678, length=5)
def my_hook_func(state):
# 执行自定义的操作,这里是一个示例
state.regs.eax = 0xdeadbeef

SimProcedure 主要用于替换文件中的原有函数,例如 angr 默认会使用一些内置的 SimProcedure 来替换掉一些常见的库函数。在二进制程序中,像 malloc 这样的复杂库函数通常会被自动 hook,以避免路径爆炸和进行符号化分析。

如果我们已经有该二进制文件的符号表,我们可以直接使用 project.hook_symbol(symbol_str, sim_procedure_instance) 来自动 hook 掉文件中所有的对应符号,run() 方法的参数为被替换函数所接收的参数。

1
2
3
4
5
6
7
class MyCheckEquals(angr.SimProcedure):
def run(self, buffer_addr, length):
buffer = self.state.memory.load(buffer_addr, length)
return claripy.If(buffer == b'XCKPBIWXXTQAFOST', claripy.BVV(1, 32), claripy.BVV(0, 32))


proj.hook_symbol(symbol_name='check_equals_XCKPBIWXXTQAFOST', simproc=MyCheckEquals())

SimProcedurerun() 方法中,我们可以使用一些有用的成员函数来控制执行过程,例如:

  • ret(expr) :返回一个表达式值。
  • jump(addr) :跳转到指定的地址。
  • exit(code) :终止程序执行,通常用于模拟程序退出。
  • call(addr, args, continue_at) :调用文件中的一个函数,args 是传递给函数的参数,continue_at 是继续执行的位置。
  • inline_call(procedure, *args) :内联地调用另一个 SimProcedure

这些成员函数使得我们可以更灵活地控制程序的模拟执行,尤其在处理复杂的系统调用、库函数和跳转时非常有用。

符号求解

在完成路径探索之后,如果目标位置可达,则我们可以从仿真管理器的 found 列表中找到执行到目标位置的所有路径对应的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 要求解的内容
bvs_to_solve = claripy.BVS('bvs_to_solve', 64)

# 创建初始状态
state = proj.factory.entry_state()
state.memory.store(0xdeadbeef, bvs_to_solve)

# 路径探索
simgr = proj.factory.simgr(state)
simgr.explore(find = 0xbeefdead)

# 从 found 列表中取出任意一个执行到目标位置的状态
found = simgr.found[0]

state 中实际上内置了符号执行引擎 claripy,前面的路径探索本质上就是为每个 state 内置的符号执行引擎中添加对应的条件。当执行到目标位置时,state 中的符号执行引擎已经添加了能够执行目标位置所需的所有条件。因此我们可以利用符号执行引擎的约束求解功能(state.solver)求解出前面设置的需要求解的内容。

1
found.solver.eval(bvs_to_solve)

如果是标准输入之类的则不需要我们显式的调用约束求解,直接获取即可:

1
simgr.found[0].posix.dumps(0)

符号执行引擎不仅可以通过路径探索添加约束条件,还可以手动添加条件。因此在有些场景下我们不需要完整的执行整个过程,而是只执行前面一部分内容,而后的部分可以手动添加相应的规则。这种策略可以一定程度上避免一些路径爆炸的情况。

1
2
3
found = simgr.found[0]
found.add_constraints(found.memory.load(buffer_addr, 16) == b'XCKPBIWXXTQAFOST')
print(found.solver.eval(password, cast_to=bytes))

IdaPython

IDAPython 是 IDA Pro (Interactive Disassembler)的一个插件,它为 IDA Pro 提供了 Python 脚本支持,使得用户能够使用 Python 来进行二进制分析、自动化任务、定制插件以及扩展 IDA Pro 的功能。IDAPython 提供了访问 IDA 内部数据结构、功能和 API 的能力,因此开发人员可以通过编写 Python 脚本来执行诸如代码分析、数据提取、自动化任务和图形化操作等任务。

IDAPython 的核心 API 基于 IDA 的 C++ API,用户可以通过 Python 来访问这些功能。

  • idaapi 类idaapi 是 IDAPython 的核心类,包含许多用于操作 IDA 的函数和数据结构。

  • idc 类idc 类提供了更多面向用户的接口,通常用于处理汇编代码。

  • ida_bytes 类ida_bytes 主要用于内存操作,提供了读取和写入内存的功能。

  • ida_nalt 类da_nalt 主要用于符号表的操作,如获取、设置和删除符号。

  • idautils 类 :高级实用的模块,主要是前面几种类的封装或者基于前面的类的 api 实现的常用功能。

IdaPython 官方文档

环境配置

切换 python 版本

管理员权限运行 IDA 安装目录下的 idapyswitch.exe ,选择使用的 python 解释器。

安装 IPyIDA 插件

IPyIDA 是一个仅依赖 Python 的解决方案,用于将 IPython 控制台添加到 IDA Pro 中。通过使用 <Shift+>,你可以打开一个内嵌 Qt 控制台的窗口。这样你就可以享受到 IPython 的自动补全、在线帮助、等宽字体输入框、图表等功能。

IPyIDA 提供了一个安装脚本,可以自动从 IDA 控制台安装 IPyIDA 及其依赖项。只需将以下代码复制到 IDA 控制台中即可完成 IPyIDA 的安装:

  • python2:

    1
    import urllib2; exec urllib2.urlopen('https://github.com/eset/ipyida/raw/stable/install_from_ida.py').read()
  • python3:

    1
    import urllib.request; exec(urllib.request.urlopen('https://github.com/eset/ipyida/raw/stable/install_from_ida.py').read())

脚本将执行以下操作:

  1. 如果尚未安装 pip,将安装 pip
  2. 从 PyPi 安装 ipyida 包。
  3. ipyida_plugin_stub.py 复制到用户的插件目录。
  4. 加载 IPyIDA 插件。

另外 IPyIDA 也可以手动安装,用户只需要将 ipyida_plugin_stub.pyipyida 目录复制到 IDA 的插件目录中即可。

手动安装需要用户自己管理依赖项和更新。IPyIDA 需要 ipykernelqtconsole 包,并且如果使用 ipykernel 版本 5 或更高版本,还需要 qasync 包。

1
2
pip install ipykernel qtconsole
pip install qasync

pycharm 自动补全

在 PyCharm 的设置→项目→Python 解释器点击设置选择全部显示...

image-20250206083959462

点击显示解释器路径图标,然后在弹出的解释器路径对话框中添加 IdaPython 的 python 库路径。

image-20250206084626008

旧版本的 IDA 的 python 库有 3 和 2 的区分,分别表示 Python3 和 Python2 对应的 python 库。

image-20250206084747455

添加完 IdaPython 的代码库之后就可以使用 PyCharm 的自动补全功能了。

image-20250206085043394

基本使用

段相关

  • idc.get_segm_name(addr):获取地址 addr 所在段的名字(参数为当前的地址)。
  • idc.get_segm_start(addr):获取地址 addr 所在段的开始地址
  • idc.get_segm_end(addr):获取地址 addr 所在段的结束地址
  • idc.get_first_seg():获取第一个段的地址
  • idc.get_next_seg(addr):获取地址大于 addr 的第一个段的起始地址
  • idautil.Segments():返回一个列表记录所有段的地址

例如遍历所有的段:

1
2
3
4
5
6
7
8
9
import idc
import idaapi
import idautils

for seg in idautils.Segments():
segname = idc.get_segm_name(seg)
segstart = idc.get_segm_start(seg)
segend = idc.get_segm_end(seg)
print("段名:" + segname + " 起始地址:" + hex(segstart) + " 结束地址:" + hex(segend))

地址相关

交叉引用

  • idautils.CodeRefsTo(ea, flow):获取引用 ea 地址处的代码的地址。
    • 参数
      • ea:被引用的代码的地址
      • flow:表示代码顺序执行的是否计算在内(布尔值,0/1 或 False/True),比如如果 flow = True 那么认为当前指令的上一条指令引用了当前指令。
    • 返回值 :代码引用列表(可能是空列表)
  • idautils.CodeRefsFrom(ea, flow)ea 地址处的代码引用了何处的代码。
    • 参数
      • ea:查询的代码的地址
      • flow:表示代码顺序执行的是否计算在内(布尔值,0/1 或 False/True),比如如果 flow = True 那么认为当前指令的下一条指令被当前指令引用。
    • 返回值 :代码引用列表(可能是空列表)
  • idautils.DataRefsTo(ea):获取引用 ea 地址处的内容的地址。
  • idautils.DataRefsFrom(ea)ea 地址处的数据引用了何处的数据。

符号地址

  • idc.get_name_ea_simple(name):获取名称对应的地址,如果获取不到则返回 ida_idaapi.BADADDR

  • 获取符号在 got 表中的地址:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    def get_got_addr(symbol):
    # 获取符号的地址
    symbol_addr = idc.get_name_ea_simple(symbol)

    # 如果该地址不在 'extern' 段中,尝试获取对应的 __imp_ 符号地址
    if idc.get_segm_name(symbol_addr) != 'extern':
    symbol_addr = idc.get_name_ea_simple(f'__imp_{symbol}')

    # 确保符号地址在 'extern' 段中
    assert idc.get_segm_name(symbol_addr) == 'extern'

    # 查找所有引用该符号地址的代码或数据引用
    for ref_addr in (list(idautils.CodeRefsTo(symbol_addr, False)) + list(idautils.DataRefsTo(symbol_addr))):
    # 如果引用地址位于 .got 或 .got.plt 段中,返回该引用地址
    if idc.get_segm_name(ref_addr) in ['.got', '.got.plt']:
    return ref_addr
  • 获取符号在 plt 表中的地址:

    1
    2
    3
    4
    5
    6
    def get_plt_addr(symbol):
    # 获取符号的 GOT 地址
    for ref_addr in (list(idautils.CodeRefsTo(get_got_addr(symbol), False)) + list(idautils.DataRefsTo(get_got_addr(symbol)))):
    # 如果引用地址位于 .plt 或 .plt.got 段中,返回该函数的起始地址
    if idc.get_segm_name(ref_addr) in ['.plt', '.plt.got']:
    return idaapi.get_func(ref_addr).start_ea

数据相关

数据读写

  • ida_bytes.get_wide_byte(addr):以 1 字节为单位获取地址处的值。

  • ida_bytes.patch_byte(addr, value):以 1 字节为单位修改地址处的值。

  • ida_bytes.get_wide_word(addr):以 2 字节为的单位获取地址处的值。

  • ida_bytes.patch_word(addr, value):以 2 字节为的单位修改地址处的值。

  • ida_bytes.get_wide_dword(addr):以 4 字节的单位获取地址处的值。

  • ida_bytes.patch_dword(addr, value):以 4 字节为的单位修改地址处的值。

  • ida_bytes.get_qword(addr):以 8 字节的单位获取地址处的值。

  • ida_bytes.patch_qword(addr, value):以 8 字节为的单位修改地址处的值。

  • idc.get_bytes(addr, len):获取 addr 地址处 len 长度的数据。

  • idc.patch_bytes(addr, data):在 addr 地址处写入 data(bytes 类型数据)。

数据类型

  • idc.get_item_size(addr):获取 addr 地址处的数据大小,例如汇编指令长度。
  • idc.del_items(addr):去除目标地址处数据的属性。
  • idc.set_name(ea, name, flags=0): 为指定地址的对象设置或删除名称,并将名称添加到名称列表中。对象可以是任何类型(如指令、函数、数据字节、字符串、结构体等)。其中参数 name 如果是空字符串则表示删除名称。
  • idc.create_insn(addr):将目标地址处的数据设置为代码。有可能会失败,可以与 ida_name.set_name(addr, '') 配合来避免失败。

数据查找

IdaPython 没有什么好用的数据查找的 API,因此通常搜索数据都是先将数据读出来然后手动搜索。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import idaapi
import idautils
import ida_bytes

def find_bytes_in_segment(segment_name, byte_sequence):
# 将目标字节序列转换为字节对象
target_bytes = bytearray(byte_sequence)

# 获取指定段的起始地址和结束地址
for seg in idautils.Segments():
if idaapi.get_segm_name(seg) == segment_name:
start_addr = idaapi.get_segm_start(seg)
end_addr = idaapi.get_segm_end(seg)
break
else:
print(f"Segment {segment_name} not found")
return []

# 读取整个段的字节
segment_bytes = ida_bytes.get_bytes(start_addr, end_addr - start_addr)

# 使用 find() 查找目标字节序列
results = []
pos = segment_bytes.find(target_bytes)
while pos != -1:
results.append(start_addr + pos)
pos = segment_bytes.find(target_bytes, pos + 1)

return results

# 示例:在 .text 段中查找字节序列
byte_sequence = b'\x55\x48\x89\xe5' # 比如这是一段x86_64的指令
matches = find_bytes_in_segment('.text', byte_sequence)

# 输出匹配的地址
for match in matches:
print(f"Found at address: {hex(match)}")

字符串相关

在 IdaPython 脚本中,字符串起到了非常关键的作用。例如通过字符串定位关键代码,或者通过字符串恢复函数符号等。因此这里单独介绍一下 IdaPython 中字符串相关的功能。

字符串提取

字符串获取主要有 idautils.Stringsidc.get_strlit_contents两种方式。

idautils.Strings

idautils.Strings() 是一个非常常用的 API,它会自动识别和遍历整个二进制文件中的所有字符串,得到一个 Strings 对象。

Strings 对象支持下标访问,得到的结果是一个 StringItem 对象,该对象有如下属性:

  • ea:字符串的地址。
  • strtype:字符串类型,有下面几种常见类型:
    • STRTYPE_TERMCHR :字符终止的字符串,字符串以特定字符结束。
    • STRTYPE_C :C 风格字符串(零终止)。
    • STRTYPE_C_16 :16 位字符的 C 风格字符串(零终止)。
    • STRTYPE_C_32 :32 位字符的 C 风格字符串(零终止)。
    • STRTYPE_PASCAL :Pascal 风格的字符串,带一个字节长度前缀。
    • STRTYPE_PASCAL_16 :Pascal 风格的 16 位字符字符串,带一个字节长度前缀。
    • STRTYPE_LEN2 :Pascal 风格的字符串,带两个字节的长度前缀。
    • STRTYPE_LEN2_16 :Pascal 风格的 16 位字符字符串,带两个字节的长度前缀。
    • STRTYPE_LEN4 :Pascal 风格的字符串,带四个字节的长度前缀。
    • STRTYPE_LEN4_16 :Pascal 风格的 16 位字符字符串,带四个字节的长度前缀。
  • length:字符串长度。

另外我们可以通过 str()StringItem 对象对象强制转换为字符串形式。

1
2
3
4
5
6
7
8
9
>>> import idautils
>>> s = idautils.Strings()

>>> str(s[0])
'/lib64/ld-linux-x86-64.so.2'
>>> s[0].ea
0x318
>>> s[0].length
0x1c
idc.get_strlit_contents

idc.get_strlit_contents 主要用于从指定地址提取字符串:

1
def get_strlit_contents(ea, length = -1, strtype = STRTYPE_C):
  • 参数
    • ea:字符串起始地址。
    • length:字符串长度。默认是 -1,此时 IDA 会计算最大字符串长度。
    • strtype:字符串类型,使用 STRTYPE_* 常量。
  • 返回值 :返回字符串内容。如果在指定的地址无法找到有效的字符串,或者字符串内容为空,则返回空字符串。

字符串查找

我们可以通过 idautils.Strings() 构建出程序的字符串表,之后就可以在这个表中查询字符串的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import idautils
from collections import defaultdict

# 使用 defaultdict 直接管理字符串缓存
string_cache = defaultdict(list)


def find_string(string):
"""
在缓存的字符串表中查找指定字符串的地址
:param string: 要查找的字符串
:return: 字符串的地址列表
"""
# 如果缓存为空,则初始化缓存
if not string_cache:
for string_info in idautils.Strings():
string_cache[hash(str(string_info))].append(string_info.ea)

# 返回匹配的地址列表,如果没有找到则返回空列表
return string_cache.get(hash(string), [])


# 示例:查找字符串
address_list = find_string("Hello, World!")
if address_list:
print(f"String found at addresses: {address_list}")
else:
print("String not found.")

字符串创建

idc.create_strlit 可以在指定地址创建字符串(字符串类型由 get_inf_attr(INF_STRTYPE) 的值决定):

1
def create_strlit(ea, endea):
  • 参数
    • ea:字符串起始地址。
    • endea:字符串的结束地址(不包括该地址)。如果 endea == BADADDR 则 IDA 会计算最大字符串长度。
  • 返回值 :返回 1 表示成功,0 表示失败。

汇编指令相关

IdaPython 内置了汇编指令相关的 API,但是 IDA 的汇编与主流的汇编库 Keystone 和 Capstone 不通用,进而导致与主流的二进制相关库不兼容。

因此我们通常只是使用 IdaPython 的部分汇编指令功能(主要是获取指令地址和长度),而实际的汇编和反汇编操作主要还是通过 keystone 和 Capstone 实现。

  • idc.GetDisasm(addr)idc.generate_disasm_line(addr,flags):获取地址处的汇编语句,这里 flags 通常为 0 。

  • idc.print_operand(addr,index):获取指定地址 addr 的汇编指令的第 index 个操作数(字符串形式),如果 index 索引超过操作数的个数则返回空字符串。下面简单举几个例子感受一下:

    汇编 inxex = 0 index = 1
    pop rax rax ‘’
    mov [rsp+10h], rax [rsp+10h] rax
    call $+5 $+5 ‘’
    add rax, 68FBh rax 68FBh
    jz short loc_1400100CC loc_1400100CC ‘’
    popfq ‘’ ‘’
    retn ‘’ ‘’
  • idc.get_operand_type(addr, index):获取操作数的类型。

    • o_void (0):无效操作数,表示没有操作数。
    • o_reg (1):寄存器操作数,表示一个寄存器。
    • o_mem (2):内存操作数,表示一个内存地址。
    • o_phrase (3):短语操作数,表示根据寄存器和偏移量计算的内存地址。
    • o_displ (4):带偏移量的内存操作数,表示根据寄存器、偏移量和可选标志寄存器计算的内存地址。
    • o_imm (5):立即数操作数,表示一个立即数值。
    • o_far (6):远跳转操作数,表示一个远跳转地址。
    • o_near (7):相对跳转操作数,表示一个相对于当前指令地址的跳转地址。
  • idc.get_operand_value(addr, index):获取指定索引操作数中的值。

    • 对于寄存器操作数 (o_reg),返回寄存器的编号。
    • 对于内存操作数 (o_mem),返回内存地址的值。
    • 对于立即数操作数 (o_imm),返回立即数的值。
    • 对于相对跳转操作数 (o_near),返回跳转的地址。
    • 对于其他特定于处理器的操作数类型,返回相应的值,具体含义需要参考相关文档。
  • idc.print_insn_mnem(addr):获取指定地址 addr 的汇编指令的操作指令(如 movadd)。

  • idc.next_head(addr):获取当前地址的汇编的下一条汇编的地址。

  • idc.prev_head(addr):获取当前地址的汇编的上一条汇编的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import idc
import ida_ida
from keystone import *
from capstone import *

if idc.get_inf_attr(idc.INF_PROCNAME) == 'metapc':
if idc.get_inf_attr(idc.INF_LFLAGS) & ida_ida.LFLG_64BIT:
ks = Ks(KS_ARCH_X86, KS_MODE_64)
cs = Cs(CS_ARCH_X86, CS_MODE_64)
else:
ks = Ks(KS_ARCH_X86, KS_MODE_32)
cs = Cs(CS_ARCH_X86, CS_MODE_32)
elif idc.get_inf_attr(idc.INF_PROCNAME) == 'ARM':
if idc.get_inf_attr(idc.INF_LFLAGS) & ida_ida.LFLG_64BIT:
ks = Ks(KS_ARCH_ARM64, KS_MODE_ARM)
cs = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
else:
ks = Ks(KS_ARCH_ARM, KS_MODE_THUMB)
cs = Cs(CS_ARCH_ARM64, CS_MODE_THUMB)
else:
raise Exception(f"unsport arch")


def get_disasm(addr):
insn = next(cs.disasm(idc.get_bytes(addr, idc.get_item_size(addr)), addr))
print(f"{insn.address:#x}: {insn.mnemonic} {insn.op_str}")
return insn

def asm(asm_code, addr=0):
return bytes(ks.asm(asm_code, addr)[0])

函数相关

  • ida_funcs.FuncItems(start)FuncItems 函数接收一个函数起始地址,并返回一个迭代器,允许你遍历并打印函数内部的每条汇编的地址。

  • idautils.Functions(startaddr,endaddr):获取指定地址之间的所有函数

  • idc.get_func_name(addr):获取指定地址所在函数的函数名

  • get_func_cmt(addr, repeatable):获取函数的注释

    • repeatable:0 是获取常规注释,1 是获取重复注释。
  • idc.set_func_cmt(ea, cmt, repeatable):设置函数注释

  • idc.find_func_end(ea):寻找函数结尾,如果函数存在则返回结尾地址,否则返回 BADADDR

  • ida_funcs.set_func_end(addr, newend):设置函数结尾为 newend

  • ida_funcs.set_func_start(addr, newstart):设置函数开头为 newstart

  • ida_funcs.get_func(addr).start_ea:获取 addr 所在函数的地址

  • idc.get_prev_func(addr):获取 addr 所在函数的前一个函数的地址

  • idc.get_next_func(addr):获取 addr 所在函数的后一个函数的地址

  • ida_funcs.add_func(addr):在 addr 地址创建函数

  • idaapi.FlowChart(func):接受一个函数对象,返回这个函数所有的代码块。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    def get_basic_block(address):
    for block in idaapi.FlowChart(idaapi.get_func(address)):
    if block.start_ea <= address < block.end_ea:
    return block
    return None

    def prev_call(addr):
    addr = idc.prev_head(addr)
    block = get_basic_block(addr)
    while addr >= block.start_ea:
    if arch_ctx.is_call(next(arch_ctx.cs.disasm(ida_bytes.get_bytes(addr, idc.get_item_size(addr)), addr))):
    return addr
    addr = idc.prev_head(addr)
    return block.start_ea

    def next_call(addr):
    addr = idc.next_head(addr)
    block = get_basic_block(addr)
    while addr < block.end_ea:
    if arch_ctx.is_call(next(arch_ctx.cs.disasm(ida_bytes.get_bytes(addr, idc.get_item_size(addr)), addr))):
    return addr
    addr = idc.next_head(addr)
    return block.end_ea

常用脚本

全局符号错误修复

c535ebdc-9440-4019-8bbe-580daa4842bf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import idc
import ida_segment
import ctypes

data_seg = range(ida_segment.get_segm_by_name(".data").start_ea, ida_segment.get_segm_by_name(".data").end_ea)
rodata_seg = range(ida_segment.get_segm_by_name(".rodata").start_ea, ida_segment.get_segm_by_name(".rodata").end_ea)
text_seg = range(ida_segment.get_segm_by_name(".text").start_ea, ida_segment.get_segm_by_name(".text").end_ea)
got_plt_base = ida_segment.get_segm_by_name(".got.plt").start_ea

start_ea = text_seg.start
end_ea = text_seg.stop

ea = start_ea
while ea < end_ea:
if idc.print_insn_mnem(ea) == 'lea':
sym_addr = ctypes.c_int64(idc.get_operand_value(ea, 1)).value + got_plt_base
if sym_addr in data_seg or sym_addr in rodata_seg or sym_addr in text_seg:
idc.op_plain_offset(ea, 1, got_plt_base)
print("%08x: %s" % (ea, idc.generate_disasm_line(ea, 0)))
ea = idc.next_head(ea)

修复函数参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
      
import idaapi
import idautils
import idc

for func_ea in idautils.Functions():
tif = idaapi.tinfo_t()
if not idaapi.get_tinfo(tif, func_ea):
continue # 无法获取类型信息,跳过

func_type = idaapi.func_type_data_t()
if not tif.get_func_details(func_type):
continue # 无法获取函数详细信息,跳过

# 统计 double 类型参数的数量
double_param_count = 0
for arg in func_type:
if arg.type.is_double():
double_param_count += 1

if double_param_count <= 4:
continue # 如果 double 参数数量不超过 4 个,跳过

# 创建新的函数类型数据,去除 double 参数
new_func_type = idaapi.func_type_data_t()
new_func_type.cc = func_type.cc
new_func_type.rettype = func_type.rettype

for arg in func_type:
if not arg.type.is_double():
new_func_type.push_back(arg)

# 应用新的函数类型信息
new_tif = idaapi.tinfo_t()
new_tif.create_func(new_func_type)
idaapi.apply_tinfo(func_ea, new_tif, idaapi.TINFO_DEFINITE)
print(f"已更新函数 {idc.get_func_name(func_ea)} 的原型,删除了 {double_param_count} 个 double 参数。")

Unicorn

Unicorn 是一个轻量级、跨平台的开源模拟器(Emulator),可以用于模拟不同架构的程序执行。它基于 QEMU 项目,但比 QEMU 更加轻量和易于使用,主要面向动态分析、漏洞挖掘和逆向工程等领域。Unicorn 允许开发者模拟目标平台的指令集架构(ISA),并且能够执行二进制代码,模拟程序运行时的行为,捕捉寄存器、内存等状态。

Unicorn 官方文档

Unicorn 安装

可以通过 Python 的包管理工具 pip 安装:

1
pip install unicorn

基本使用

创建 Unicorn 对象

和前面提到的 Keystone 和 Capstone 的流程类似,Unicorn 模拟执行首先需要创建一个 Unicorn 对象,并设置目标架构和模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from unicorn import *

# X86-64
from unicorn.x86_const import *
mu = Uc(UC_ARCH_X86, UC_MODE_64)
mu.mem_map(0, 0x1000) # 避免异常 "mov rax, gs:[0x28]"

# ARM
from unicorn.arm_const import *
mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB)

# AARCH64
from unicorn.arm64_const import *
mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)

注意

在模拟 x86 架构时,程序可能会访问特定段(如 gsfs)中的数据,这些数据通常与操作系统、线程信息、TLS(线程局部存储)等有关(例如 mov rax, gs:[0x28] 指令获取 canary)。

由于 Unicorn 模拟器仅提供模拟功能,导致像 fsgs 这样的段寄存器都被默认初始化为 0,因此访问这些段中数据的指令会触发程序内存读写异常。

为了避免这种情况影响程序模拟执行,最好在 0 地址处映射一段内存。

1
mu.mem_map(0, 0x1000) # 避免异常 "mov rax, gs:[0x28]"

寄存器操作

Unicorn 允许你模拟寄存器的读写操作,你可以通过 mu.reg_write() 来写入寄存器的值,使用 mu.reg_read() 来读取寄存器的值。

写入寄存器

mu.reg_write(reg_id, value) 用来写入寄存器的值,reg_id 是寄存器的标识符,value 是要写入的值。

注意

一些特殊的寄存器如 gs 寄存器不能直接通过 reg_write 修改。

1
mu.reg_write(UC_X86_REG_RIP, 0x1000)  # 设置 RIP 寄存器的值

读取寄存器

mu.reg_read(reg_id) 返回指定寄存器的当前值。

1
2
rip_value = mu.reg_read(UC_X86_REG_RIP)  # 读取 RIP 寄存器的值
print(f"RIP: {rip_value}")

内存操作

Unicorn 允许你模拟内存的读写操作,模拟程序运行时的内存访问。你可以通过 mu.mem_map() 来映射内存区域,通过 mu.mem_write() 来写入数据,通过 mu.mem_read() 来读取内存数据。

映射内存

mu.mem_map(start, size) 用来映射一段内存区域,start 是内存起始地址,size 是内存的大小。

注意

  • startend 要关于 0x1000 对齐。
  • 要确保映射的内存地址空间不要与之前映射的内存出现重叠。
1
mu.mem_map(0x1000, 0x1000)  # 映射 0x1000 大小的内存

写入内存

mu.mem_write(addr, data) 将数据写入指定地址的内存区域。data 是二进制数据(字节串),addr 是写入的地址。

1
2
code = b"\x48\x31\xc0"  # x86_64: xor rax, rax
mu.mem_write(0x1000, code) # 将机器码写入模拟内存

读取内存

mu.mem_read(addr, size) 从指定地址读取 size 字节的数据。

1
2
data = mu.mem_read(0x1000, 4)  # 从地址 0x1000 读取 4 字节
print(data)

加载二进制程序

实际情况下我们可能需要模拟执行一个二进制程序中的某个解密算法函数。与 shellcode 不同,在二进制程序中即使一个纯算法函数也不是只加载代码段就可以正常仿真的,因为这个函数在执行过程中还可能访问全局变量。因此我们需要想办法将这个二进制程序加载到内存中,因此通常 Unicorn 还要配合 angr 的静态分析功能使用。

下面这段代码是加载二进制到 Unicorn 中的模板,可以应对大多数情况。

提示

  • 这个模板的段映射部分写的很奇怪,这是因为有的二进制程序(比如一些 Linux 内核镜像)的段并不是关于 0x1000 对齐,但是 Unicorn 的 mem_map 要求地址范围按 0x1000 对齐,并且不能与之前映射的内存重叠。因此这里要做内存对齐处理并且多次捕获异常。
  • x86 架构设置 bp 寄存器是因为有的函数会通过 bp 寄存器访问函数的参数和局部变量。
  • 为了实现代码复用,通常的做法是把每个架构相关的操作抽象为接口类,然后每个架构实现这个接口类,而真正的核心逻辑不需要关注架构的细节。下面的模板就是遵循这一设计原则。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# 定义 amd64 架构的上下文
class amd64_ctx:
# 架构寄存器映射
regs = {
'sp': UC_X86_REG_RSP, # 堆栈指针寄存器(RSP)
'bp': UC_X86_REG_RBP, # 基指针寄存器(RBP)
}

def get_emu(self):
# 创建并返回一个 X86-64 架构的模拟器对象
emu = Uc(UC_ARCH_X86, UC_MODE_64)
emu.mem_map(0, 0x1000) # 映射内存,以避免访问 gs 或 fs 时发生异常
return emu

# 定义 ARM 架构的上下文
class arm_ctx:
# 架构寄存器映射
regs = {
'sp': UC_ARM_REG_SP, # 堆栈指针寄存器(SP)
}

def get_emu(self):
# 创建并返回一个 ARM 架构的模拟器对象(使用 THUMB 模式)
return Uc(UC_ARCH_ARM, UC_MODE_THUMB)

# 定义 AARCH64 架构的上下文
class aarch64_ctx:
# 架构寄存器映射
regs = {
'sp': UC_ARM64_REG_SP, # 堆栈指针寄存器(SP)
}

def get_emu(self):
# 创建并返回一个 AARCH64 架构的模拟器对象(使用 ARM 模式)
return Uc(UC_ARCH_ARM64, UC_MODE_ARM)

# 初始化 angr 项目,加载目标二进制文件,禁用自动加载库
project = angr.Project(binary, load_options={'auto_load_libs': False})

# 根据架构类型选择对应的上下文对象
if project.arch.name == "ARMEL":
arch_ctx = arm_ctx()
elif project.arch.name == "AMD64":
arch_ctx = amd64_ctx()
elif project.arch.name == "AARCH64":
arch_ctx = aarch64_ctx()
else:
# 如果不支持的架构,抛出异常
raise Exception("Unsupported architecture")

# 获取模拟器对象
emu = arch_ctx.get_emu()

# 设置堆栈地址和大小
STACK_ADDRESS = 0x1234000
STACK_SIZE = 0x3000

# 为堆栈区域映射内存
emu.mem_map(STACK_ADDRESS, STACK_SIZE)

# 设置堆栈指针(SP)寄存器
emu.reg_write(arch_ctx.regs['sp'], STACK_ADDRESS + STACK_SIZE - 0x1000)

# 如果有基指针(BP)寄存器,设置 BP 寄存器
if 'bp' in arch_ctx.regs:
emu.reg_write(arch_ctx.regs['bp'], STACK_ADDRESS + STACK_SIZE - 0x1000 + 0x300)

# 映射二进制文件的各个段到模拟器内存
for seg in project.loader.main_object.segments:
# 计算段的起始地址和结束地址(对齐到页面边界)
mem_start = seg.vaddr & ~0xFFF
mem_end = (seg.vaddr + seg.memsize + 0xFFF) & ~0xFFF

try:
# 映射内存区域
emu.mem_map(mem_start, mem_end - mem_start)
except:
# 如果映射失败,尝试重新计算起始地址
mem_start = (seg.vaddr + 0xFFF) & ~0xFFF
if mem_start < mem_end and mem_start:
emu.mem_map(mem_start, mem_end - mem_start)
else:
continue

# 将段的内容写入模拟器内存
emu.mem_write(seg.vaddr, project.loader.memory.load(seg.vaddr, seg.filesize))

模拟执行

虚拟机启动

当前期虚拟机初始化完成之后(主要是程序加载、栈初始化、寄存器设置等),就可以启动虚拟机进行模拟了。虚拟机通过 emu_start 函数启动,该函数定义如下:

1
def emu_start(self, begin: int, until: int, timeout: int=0, count: int=0) -> None:
  • begin :仿真开始的地址。表示从该地址开始执行机器码,通常是程序的入口点。
  • until :仿真结束的地址。表示程序执行到该地址时仿真停止。until 应大于 begin,否则可能导致仿真无限执行。
  • timeout :超时设置(单位:毫秒)。在仿真执行过程中,如果超时值非零,仿真会在超时后自动停止。如果设置为 0,表示不限制时间。
  • count :执行次数。如果 count 非零,仿真会执行指定的次数后停止。如果为 0,表示按地址范围执行,直到 end 地址或其他条件触发。

在虚拟机执行过程中可能会抛出异常,如访问非法内存地址、执行未定义指令等,通常这些异常会触发钩子函数或返回错误信息。为了应对这些可能的异常,可以通过在模拟中添加错误处理代码,保证仿真顺利进行或在发生错误时及时停止仿真。

常见的异常包括:

  • UC_ERR_READ_UNMAPPED :尝试读取未映射的内存。
  • UC_ERR_WRITE_UNMAPPED :尝试写入未映射的内存。
  • UC_ERR_FETCH_UNMAPPED :尝试获取未映射的指令。
  • UC_ERR_READ_PROT :尝试读取保护内存。
  • UC_ERR_WRITE_PROT :尝试写入保护内存。
  • UC_ERR_FETCH_PROT :尝试获取受保护的指令。

异常处理可以通过 try-except 结构来完成。下面是一个例子,展示了如何捕捉异常并处理:

1
2
3
4
5
try:
# 启动仿真,从地址 0x1000 开始执行,直到 0x1003
mu.emu_start(0x1000, 0x1003)
except UcError as e:
print(f"Error during emulation: {e}")

事件监控

Unicorn 提供了事件钩子(hooks)功能,用于在程序执行过程中捕获特定的事件(如指令执行、内存读写、寄存器访问等)。通过钩子机制,用户可以在仿真时监控程序的特定行为并调用用户自己实现的回调函数处理。

Unicorn 提供了多种类型的监控事件,这里只介绍我们常用的事件类型。

注意

在添加事件钩子之后 Unicorn 的执行效率会变得非常低。因此在实际编写脚本的时候应当尽量避免使用 Unicorn 的事件监控功能,而是将其作为调试脚本的工具。

例如在一段代码中间的某个位置处需要做一些额外操作,时候可以可以以该位置为边界将代码分为两部分模拟,而不是设置一个指令执行事件来监控程序只不是执行到需要做额外操作的位置了。

指令执行事件

指令执行事件 UC_HOOK_CODE 在每执行一条指令时触发,该事件对应的回调函数如下:

1
def hook_code(uc, address, size, user_data):
  • uc :当前的 Unicorn 仿真对象,类型为 Uc。它是对当前仿真状态的一个引用,用户可以通过它访问模拟环境中的寄存器、内存等。
  • address :当前执行指令的地址(内存中的位置)。这个地址是指指令被执行时在仿真内存中加载的地址。
  • size :当前指令的大小(以字节为单位)。这表示指令在内存中占用的字节数。
  • user_data :用户自定义的数据。可以在调用 hook_add 时传入,通常用于传递上下文信息或者保持状态。

我们通常使用下面这个模板打印模拟执行的汇编指令来调试脚本:

1
2
3
4
5
6
7
8
9
from capstone import *
cs = Cs(CS_ARCH_X86, CS_MODE_64)

# 定义一个回调函数,用于监控指令执行
def hook_code(uc, address, size, user_data):
insn = next(cs.disasm(uc.mem_read(address, size), address))
print(f"{address:#x}: {insn.mnemonic} {insn.op_str}")

mu.hook_add(UC_HOOK_CODE, hook_code) # 注册指令执行钩子
内存事件

我们常用的内存事件有内存读入事件UC_HOOK_MEM_READ)和内存写入事件UC_HOOK_MEM_WRITE),另外针对指令的有获取指令事件UC_HOOK_MEM_FETCH)。对应的回调函数定义如下:

1
def hook_mem(uc, access, address, size, value, user_data):
  • uc :当前的 Unicorn 仿真对象,类型为 Uc,同样提供对仿真环境的访问。
  • access :内存访问的类型。可以是以下几种类型:
    • UC_MEM_READ:表示是数据读取。
    • UC_MEM_WRITE:表示是数据写入。
    • UC_MEM_FETCH:表示读取的是指令(通常用于指令获取)。
  • address :发生内存访问的地址(内存中的位置)。这个地址指的是访问的内存单元的地址。
  • size :内存读取操作的大小(以字节为单位)。表示读取或写入了多少字节的数据。
  • value :读取或写入的值。指在指定地址处读取或写入的实际内容(以整数形式表示)。
  • user_data :用户自定义的数据,通常用于在钩子中传递一些额外的上下文信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 定义一个回调函数,用于监控内存读取
def hook_mem_read(uc, access, address, size, value, user_data):
print(f"Memory read at address: 0x{address:X}, size: {size}, value: 0x{value:X}")

# 定义一个回调函数,用于监控内存写入
def hook_mem_write(uc, access, address, size, value, user_data):
print(f"Memory write at address: 0x{address:X}, size: {size}, value: 0x{value:X}")

# 定义一个回调函数,用于监控内存获取指令
def hook_mem_fetch(uc, address, size, user_data):
print(f"Instruction fetched from address: 0x{address:X}, size: {size} bytes")

mu.hook_add(UC_HOOK_MEM_READ, hook_mem_read) # 注册内存读取钩子
mu.hook_add(UC_HOOK_MEM_WRITE, hook_mem_write) # 注册内存写入钩子
mu.hook_add(UC_HOOK_MEM_FETCH, hook_mem_fetch) # 注册内存获取指令钩子

常用脚本

trace shellcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import time

from unicorn import *
from unicorn.x86_const import *
from capstone import *

CODE_ADDRESS = 0x74B000 # 0x6F8618 # Shellcode 的加载地址
STACK_ADDRESS = 0x0019C000 # 栈的起始地址(分配在高地址区域)
STACK_SIZE = 0x00080000 # 栈大小
ORIG_SP = STACK_ADDRESS + STACK_SIZE // 2
JUMP_BLOCK_RANGE = range(CODE_ADDRESS + 0x3fd0, CODE_ADDRESS + 0x137f0)

shellcode = open("shellcode.bin", "rb").read()

# 初始化 Unicorn 模拟器
mu = Uc(UC_ARCH_X86, UC_MODE_32)

# 映射内存
mu.mem_map(CODE_ADDRESS & ~0xFFF, ((len(shellcode) + 0xFFF) & ~0xFFF) + 0x1000) # 为代码分配内存

mu.mem_map(STACK_ADDRESS, STACK_SIZE) # 为栈分配内存

# 写入 Shellcode
mu.mem_write(CODE_ADDRESS, shellcode)

# 初始化栈指针(ESP 指向栈顶)
mu.reg_write(UC_X86_REG_ESP, STACK_ADDRESS + 0x0019F998 - 0x0019C000)

# 初始化 Capstone 用于反汇编
cs = Cs(CS_ARCH_X86, CS_MODE_32)


f = open("trace.txt", "w+")


class TraceInfo:
def __init__(self, uc: unicorn.Uc, address, size):
code = uc.mem_read(address, size)
self.addr = address
self.insn = next(cs.disasm(code, address))
self.regs = self.get_registers(uc)
self.regs_change = ""
self.mem_change = ""

def get_registers(self, uc: unicorn.Uc):
return {
'eax': uc.reg_read(UC_X86_REG_EAX),
'ebx': uc.reg_read(UC_X86_REG_EBX),
'ecx': uc.reg_read(UC_X86_REG_ECX),
'edx': uc.reg_read(UC_X86_REG_EDX),
'esi': uc.reg_read(UC_X86_REG_ESI),
'edi': uc.reg_read(UC_X86_REG_EDI),
'esp': uc.reg_read(UC_X86_REG_ESP),
'ebp': uc.reg_read(UC_X86_REG_EBP),
# 'eip': uc.reg_read(UC_X86_REG_EIP),
'eflags': uc.reg_read(UC_X86_REG_EFLAGS),

# 段寄存器(如需要可以添加调试信息)
# 'cs': uc.reg_read(UC_X86_REG_CS),
# 'ds': uc.reg_read(UC_X86_REG_DS),
# 'es': uc.reg_read(UC_X86_REG_ES),
# 'fs': uc.reg_read(UC_X86_REG_FS),
# 'gs': uc.reg_read(UC_X86_REG_GS),
# 'ss': uc.reg_read(UC_X86_REG_SS),
}

def set_regs_change(self, uc: unicorn.Uc):
new_regs = self.get_registers(uc)
diffs = []
for reg, old_val in self.regs.items():
new_val = new_regs[reg]
if old_val != new_val:
diffs.append(f"{reg}: {hex(old_val)}-> {hex(new_val)}")
self.regs_change = " ".join(diffs)

def set_mem_change(self, uc: unicorn.Uc, address, size, value):
old_value = int.from_bytes(uc.mem_read(address, size), byteorder="little")
self.mem_change = f"{hex(address)}: {hex(old_value)}-> {hex(value)}"

def get_state_trace_info(self):
addr_str = f"{hex(self.addr)}".ljust(10)
insn_str = f"{self.insn.mnemonic} {self.insn.op_str}".ljust(40)
regs_str = self.regs_change.ljust(60)
mem_str = self.mem_change.ljust(60)
return f"{addr_str} | {insn_str} | {regs_str} | {mem_str}"


trace_info: TraceInfo = None


# Hook:捕获内存写入
def hook_mem_write(uc, access, address, size, value, user_data):
trace_info.set_mem_change(uc, address, size, value)

# 初始化计数器和时间
trace_count = 0
start_time = time.time()

# Hook:捕获指令执行后的状态
def hook_code(uc, address, size, user_data):
global trace_info, trace_count, start_time

# 更新指令计数器
trace_count += 1

# 动态显示执行速度
current_time = time.time()
elapsed_time = current_time - start_time
if elapsed_time > 1:
print(
f"\rProcessed: {trace_count} instructions, Speed: {trace_count / elapsed_time:.2f} instructions/sec",
end="",
)

if trace_info is not None:
trace_info.set_regs_change(uc)
print(trace_info.get_state_trace_info(), file=f)

trace_info = TraceInfo(uc, address, size)


# 添加 Hook
mu.hook_add(UC_HOOK_MEM_WRITE, hook_mem_write) # 捕获内存写入
mu.hook_add(UC_HOOK_CODE, hook_code) # 捕获指令执行后的状态

# 开始模拟执行
try:
print("Starting execution...")
mu.emu_start(CODE_ADDRESS, CODE_ADDRESS + len(shellcode))
print("Execution finished.")
except UcError as e:
print(f"Unicorn execution failed: {e}")
print(hex(mu.reg_read(UC_X86_REG_ESP)))
print(hex(mu.reg_read(UC_X86_REG_EIP)))
esp = mu.reg_read(UC_X86_REG_ESP)
for i in range(esp, esp + 0x50, 4):
print(hex(int.from_bytes(mu.mem_read(i, 4), byteorder="little")))
  • Title: 逆向自动化工具
  • Author: sky123
  • Created at : 2025-01-02 01:21:55
  • Updated at : 2025-02-22 20:25:31
  • Link: https://skyi23.github.io/2025/01/02/逆向自动化工具/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
逆向自动化工具