逆向自动化工具

Keystone
Keystone 是一个开源的汇编器(assembler),用于生成二进制机器码。它支持多种 CPU 架构和操作模式,并且具有高效、轻量级、跨平台等特点,适合用在逆向工程、漏洞研究、二进制分析等领域。
keystone-engine
是 Keystone 项目的 Python 绑定,它允许开发者在 Python 环境中使用 Keystone 汇编器的强大功能。通过 keystone-engine
,你可以在 Python 中执行汇编代码,将汇编转换为机器码,或者将机器码反汇编成汇编代码。
Keystone 安装
keystone-engine
库已经发布到 PyPI(Python Package Index),所以你可以直接使用 pip
来安装它:
1 | pip install keystone-engine |
注意
这里安装的是 keystone-engine
而不是 keystone
,keystone
是另外一个模块。
基本使用
创建 Keystone 对象
要开始使用 Keystone,我们首先需要创建一个 Keystone 对象。这个对象将负责后续的汇编和反汇编工作。
1 | from keystone import 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 | from keystone import * |
汇编
Keystone 对象只有一个公开的成员函数 asm
,该函数定义如下:
1 | def asm(self, string: str, addr: int = 0, as_bytes: bool = False) -> tuple: |
参数:
string: str
:表示输入参数string
是一个字符串,包含汇编语言的代码。addr: int = 0
:addr
是一个整数类型的参数,表示机器码开始的内存地址,默认值为0
。as_bytes: bool = False
:as_bytes
是一个布尔值,表示是否返回机器码的字节表示。默认值为False
,即返回一个整数列表。
返回值:该函数返回一个元组,元组包含两个元素。
encoded
:机器码的字节表示。- 如果
as_bytes=True
,返回bytes
类型。 - 如果
as_bytes=False
,返回list[int]
类型,表示机器码的字节序列对应的整数。
- 如果
count
:生成的机器码指令的数量。
示例:
1 | from keystone import * |
提示
默认情况下,Keystone 使用 Intel 语法解析 x86 架构的汇编指令,如果要切换为 AT&T 语法,可以使用以下代码:
1 | ks = Ks(KS_ARCH_X86, KS_MODE_32) |
Capstone
Capstone 是一个流行的反汇编框架,它与 Keystone 密切相关,但其功能侧重点不同。Keystone 主要负责将汇编代码转换为机器码(即汇编器),而 Capstone 则是一个反汇编库,专注于将机器码转换为汇编代码(即反汇编器)。
Capstone 安装
Capstone 提供了 Python 绑定,可以通过 pip
安装:
1 | pip install capstone |
基本使用
创建 Capstone 对象
首先,你需要创建一个 Capstone 反汇编对象,并设置目标架构和模式。Cs
类用于创建 Capstone 对象,CS_ARCH_*
和 CS_MODE_*
常量用于指定架构和模式。
这里架构和模式定义和 Keystone 库的类似,但具体类别稍有不同。不过对于我们绝大多数逆向需求来说我们不需要关系两者的区别。
1 | from capstone import * |
反汇编
我们可以使用 Capstone 反汇编对象的 disasm
方法来进行反汇编,该函数定义如下:
1 | def disasm(self, code, offset, count=0) -> 'CsInsn': |
参数:
code: bytes
:表示输入参数code
是一个字节串(bytes
类型),包含要反汇编的机器码。这个字节串通常来自二进制文件或内存中的机器码表示。offset: int
:offset
是一个整数类型的参数,表示机器码开始的地址(通常是内存中的起始地址)。这个参数是必需的,用来确定反汇编指令的起始位置,帮助计算每条指令的地址。count: int = 0
:count
是一个整数类型的参数,表示最多反汇编的指令数。默认值为 0,表示反汇编尽可能多的指令,直到代码结束或者遇到无效指令。如果设置了非零的count
,则最多反汇编count
条指令。
返回值: 该函数返回一个生成器,每次迭代返回一个 CsInsn
对象,代表一条反汇编指令。CsInsn
对象包含以下信息:
address
:指令的地址,表示这条指令在内存中的位置(十六进制格式)。mnemonic
:指令的助记符(如:mov
、add
、push
等)。op_str
:指令的操作数(如:eax, ebx
或mem[rax]
)。size
:指令的字节大小,表示该指令在内存中占用了多少字节。
示例:
1 | from capstone import * |
提示
默认情况下,Capstone 使用 Intel 语法解析 x86 架构的汇编指令,如果要切换为 AT&T 语法,可以使用以下代码:
1 | cs = Cs(CS_ARCH_X86, CS_MODE_32) |
从 disasm
的函数定义我们发现,Capstone 不仅仅是简单的将机器码转为汇编指令,而是提供了一个描述汇编指令的对象 CsInsn
,从而对汇编指令提供更细粒度的描述。
因此在打印汇编的时候我们需要手动将 CsInsn.mnemonic
和 CsInsn.op_str
拼接成一条汇编。
1 | for insn in cs.disasm(machine_code, address): |
另外由于 disasm
返回的是一个迭代器,因此我们还可以作如下封装:
1 | def disasm(machine_code, address=0): |
另外除了上述四个成员外,如果我们开启 Cs.detail = True
选项时,CsInsn
还会多一个名为 operands
的字段,这个字段对于不同的架构是不同的。CsInsn
的 operands
在我们逆向辅助工具编写过程中十分重要,后面会有单独一节介绍。
当然如果我们将机器码反汇编得到 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 | from capstone import * |
基本字段
CsInsn
对象提供了对反汇编指令的基本信息的访问,主要包括以下四个基本字段:
address
:指令的地址,表示这条指令在内存中的位置(十六进制格式)。mnemonic
:指令的助记符(如:mov
、add
、push
等)。op_str
:指令的操作数(如:eax, ebx
或mem[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 | class X86OpMem(ctypes.Structure): |
从上述代码中可以看出,操作数可以是寄存器、立即数、内存地址等不同的类型。Capstone 通过操作数结构体中的 type
字段来描述操作数的类型,见的操作数类型有(当然除了这些之外还有一些各架构独有的操作数类型,这些类型的名称通常为 <架构>_OP_<类型>
,例如 ARM64_OP_CIMM
):
CS_OP_REG
:表示寄存器操作数(如eax
、ebx
)。CS_OP_IMM
:表示立即数操作数(如0x10
、42
)。CS_OP_MEM
:表示内存操作数(如mem[eax+4]
)。CS_OP_FP
:表示浮点数操作数。
对应不同类型的操作数,value
字段作为联合体有 reg
、imm
、mem
三种类型。我们可以根据操作数的类型从对应的结构中获取我们想要的值。
由于操作数对象提供了 imm
、reg
、mem
三种方法来封装 value
字段的访问,因此我们可以使用例如 insn.operands[1].mem.base
这种方式跳过 value
直接访问联合体中的成员。
内存操作数
因为会经常用到,这里要着重说明一下内存操作数的结构。内存操作数是指令中用于描述内存地址的操作数类型,通常在汇编指令中通过指定内存地址、基址寄存器、索引寄存器和偏移量来进行内存访问。
在 Capstone 库中,内存操作数的类型为 CS_OP_MEM
,并且通过 CsOperand
对象的 mem
字段来描述相关的内存信息。还是以前面的 X86OpMem
结构体举例:
1 | class X86OpMem(ctypes.Structure): |
segment
:内存段寄存器,在现代架构中通常为0
,表示直接访问内存,而不通过特定的段寄存器。如果涉及到段寻址(例如在 x86 16 位模式下),此字段可能会有所变化。base
:基址寄存器,是计算内存地址的基础寄存器。通过将基址寄存器的值与偏移量disp
相加,可以确定内存地址。例如,eax
或ebx
通常用于存储基地址。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 | # 对应 Capstone 解析的结构体 |
- segment :
0
(通常为 0,因为没有特别指定段寄存器) - base :
rbx
(基址寄存器) - index :
rcx
(索引寄存器) - scale :
4
(数组或数据元素大小,通常是4
) - disp :
0x10
(偏移量)
通常来说,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 | # 对应 Capstone 解析的结构体 |
- segment :
0
(默认段选择符) - base :
r1
(基址寄存器) - index :
r2
(索引寄存器) - scale :
4
(因为lsl #2
是左移 2 位,相当于乘 4) - disp :
0
(没有偏移量)
由于 ARM 架构的指令不能直接操作内存,因此无法直接根据全局变量的地址读取全局变量的数据,而是:
- 先将编译在函数后面的地址表中的全局变量的地址加载到寄存器中。
- 然后还可能跟
pc
寄存器相加顺便实现地址无关代码 - 最后再通过寄存器访问全局变量所在的地址读取数据。
也就是通常来说 ARM 架构从全局变量读取数据的指令为:
1 | ldr r1, [pc, #disp] ; 从 (pc + disp) 计算出的内存地址加载数据到 r1 |
由于编译器的针对 CPU 流水线的优化,这些指令之间可能还会穿插其它的指令。因此我们不能直接通过指令得出全局变量的地址。不过这里我们可以先获取指令序列 insn_list
,然后简单模拟一下这个从内存加载数据的过程来计算全局变量的地址。
1 | reg_value = {} |
AArch64 架构
AArch64 是 ARM 的 64 位版本,它使用与 ARM 相似的寻址模式。AArch64 允许更复杂的寻址模式,包括有符号立即数、寄存器和可选的缩放因子。例如下面这条汇编指令是将 x1 + (x2 << 3)
计算出的内存地址中的值加载到 x0
寄存器:
1 | ldr x0, [x1, x2, lsl #3] |
这条指令的操作数对应 Capstone 的内存操作数结构如下:
1 | # 对应 Capstone 解析的结构体 |
- segment :
0
(默认段选择符) - base :
x1
(基址寄存器) - index :
x2
(索引寄存器) - scale :
8
(因为lsl #3
是左移 3 位,相当于乘 8) - disp :
0
(没有偏移量)
AArch64 架构的指令同样不能直接操作内存,因此无法直接根据全局变量的地址读取全局变量的数据,而是:
- 通过
adrp
指令将全局变量地址所在内存页的基地址加载到寄存器中。由于 AArch64 是基于页面地址访问的,adrp
将会设置寄存器为地址的页对齐值。 - 将存有页基址的寄存器加上某个偏移量,使其指向全局变量。
- 通过
ldr
或ldp
等指令从该寄存器指向的内存中读入数据。
也就是通常来说 AArch64 架构从全局变量读取数据的指令为:
1 | adrp x2, #0x37ff000 ; 将页面的基地址加载到寄存器 x2 中 |
同样我们可以参考 ARM 架构的做法来计算全局变量的地址。
1 | reg_value = {} |
angr
angr 是一个多架构二进制分析工具包,能够执行动态符号执行(类似于 Mayhem、KLEE 等)以及各种静态分析。
提示
我们可以简单的将 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 | import angr |
当你使用 angr.Project
加载文件时,可以将选项直接传递给 Project
构造函数,它们会被转发给 CLE。CLE 有如下常用选项:
auto_load_libs
:控制是否自动解析共享库依赖,默认值为True
。except_missing_libs
:与auto_load_libs
相反。如果设置为True
,当无法解析共享库依赖时会抛出异常。force_load_libs
:一个字符串列表,强制指定某些库为未解析的共享库依赖。skip_libs
:一个字符串列表,防止某些库名被解析为依赖。ld_path
:一个字符串或字符串列表,用作共享库的额外搜索路径,优先于默认路径。
你还可以使用 main_opts
和 lib_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 | angr.Project( |
基本属性
加载项目后,可以查看一些基本属性,比如 CPU 架构、文件名和入口点的地址。
1 | import monkeyhex # 用于以十六进制格式显示数值结果 |
arch
是一个archinfo.Arch
对象的实例,表示程序的编译架构。在本例中,它是小端的 AMD64 架构。该对象包含关于 CPU 的大量信息,常用的属性有arch.bits
(位数)、arch.bytes
(字节数)、arch.name
和arch.memory_endness
。entry
是二进制文件的入口点地址。filename
是二进制文件的绝对路径。
加载器(The Loader)
将二进制文件加载为虚拟地址空间的表示形式是一个复杂的过程。angr 中有一个模块叫 CLE(CLE Loads Everything)来处理这个问题。CLE 可以通过项目的 .loader
属性访问。
1 | proj.loader |
已加载的对象
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
4proj.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
4proj.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
2proj.loader.extern_object
<ExternObject Object cle##externs, maps [0x4000000:0x4008000]>kernel_object
:用于模拟系统调用的对象。1
2proj.loader.kernel_object
<KernelObject Object cle##kernel, maps [0x5000000:0x5008000]>
除了从 loader
的某个属性中获取加载对象外,我们还可以通过 find_object_containing
方法获取指定地址所在的对象:
1 | 0x400000) proj.loader.find_object_containing( |
也可以通过 find_object
方法根据对象名称获取对象:
1 | 'fauxware') proj.loader.find_object( |
对象基本信息
CLE 加载的二进制对象的属性中包含了一些基本信息:
entry
:对象的入口点1
2proj.loader.main_object.entry
0x400580min_addr
,max_addr
:对象的最低地址和最高地址,即对象所在的地址空间范围。1
2proj.loader.main_object.min_addr, obj.max_addr
(0x400000, 0x60105f)linked_base
:对象的预链接基址预链接基址(Prelinked Base Address) 是在预链接(Prelinking) 过程中为共享对象(Shared Objects,如共享库
*.so
或可执行文件)分配的固定加载地址。1
2obj.linked_base
0x400000mapped_base
:对象实际被 CLE 映射到内存的基址1
2obj.mapped_base
0x400000execstack
:查询该二进制文件是否有可执行栈,即NX
保护是否未被开启。1
2proj.loader.main_object.execstack
Falsepic
:查询该二进制文件是否是地址无关,即PIE
保护是否开启。1
2proj.loader.main_object.pic
True
段(Segment)和节(Section)
CLE 加载的二进制对象还会解析获取对应二进制文件的段和节信息。这些信息分别存放在二进制对象的 segments
和 sections
属性中。
1 | # 获取 ELF 的段(segments)和节(sections) |
我们可以通过二进制对象的 find_segment_containing
和 find_section_containing
获取指定地址所位于的段和节。
1 | obj.find_segment_containing(obj.entry) |
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 | 'strcmp') strcmp = proj.loader.find_symbol( |
符号最有用的属性包括其名称(name
)、所属对象(owner
)以及地址(address
)。但符号的“地址”可能是模糊的,Symbol
对象提供了三种方式报告其地址:
.rebased_addr
:符号在全局地址空间中的地址,这也是打印输出中显示的地址。.linked_addr
:符号相对于二进制文件预链接基址(prelinked base)的地址。例如,这是readelf
等工具中报告的地址。.relative_addr
:符号相对于其所属对象基址(object base)的地址。在文献(尤其是 Windows 文献)中,这被称为 RVA(Relative Virtual Address)。
1 | strcmp.name |
除了调试信息外,符号还支持动态链接(dynamic linking)的概念。例如,libc 提供了 strcmp
作为导出符号,而主二进制程序依赖它。如果我们让 CLE 直接从主对象中返回 strcmp
符号,它会告诉我们这是一个导入符号(import symbol)。导入符号没有有意义的地址,但它会提供一个引用,指向用于解析它的符号(通过 .resolvedby
属性)。
1 | strcmp.is_export |
符号的 PLT 地址
对于某些符号,你可以通过加载的对象获取它们在 PLT表(Procedure Linkage Table)中的地址:
1 | obj = proj.loader.main_object |
符号的 GOT 地址
导入和导出符号之间的链接方式是通过重定位(relocations)管理的。重定位记录了以下信息:
当你将 [import] 符号与某个导出符号匹配时,请将导出符号的地址写入 [location](即符号对应的 GOT 表地址),格式为 [format]。
我们可以获取重定位的相关信息:
- 通过
obj.relocs
获取某个对象的所有重定位列表(以Relocation
实例表示)。 - 通过
obj.imports
获取从符号名称到重定位的映射。注意,导出符号没有对应的列表。
例如我们可以通过 imports
获取 exit
函数的 GOT 表地址:
1 | 'exit'].rebased_addr proj.loader.main_object.imports[ |
二进制代码分析
基本块(Blocks)
在 angr 中,基本块(Basic Block) 是指一段连续的、没有跳跃(即没有分支指令)的指令序列,通常在程序执行过程中,这些指令是按顺序执行的。
具体来说,基本块有以下几个特点:
没有跳跃或分支 :基本块内的指令是顺序执行的,不包含跳转(如
jmp
、call
、ret
等指令)或条件分支指令(如if
、branch
等)。当程序执行到一个基本块时,它会按照顺序执行该块内的所有指令,直到遇到跳转指令或基本块结束。注意
与 IDA 的 CFG 的代码块不同的是,angr 的代码块把函数调用(
call
)也作为代码块结束的标志。入口和出口 :每个基本块有一个入口(起始地址)和出口(结束地址)。出口通常是一个跳转或返回的指令,也可能是下一个基本块的开始。
分析单元 :在程序分析过程中,基本块是分析的最小单元。angr 就是通过将程序拆分成多个基本块来进行符号执行(symbolic execution)和路径探索。
基本块的获取
在 angr 中,通过 project.factory.block()
方法可以提取某个地址的基本块。
在
angr
中有很多类,其中大多数需要实例化一个项目(project)。为了避免你到处传递项目实例,我们提供了project.factory
,它包含了几个方便的构造器,用于创建你经常需要使用的常见对象。
这些基本块的内容被封装在 Block
对象中,你可以通过该对象访问基本块的反汇编信息、指令数量、指令地址等数据。
注意
project.factory.block
提取的代码块并不是控制流程图中的代码块。控制流图会考虑到代码的所有跳转关系,包括跳转到基本块的中间位置,因此在控制流图中,基本块的划分会比单纯用 project.factory.block
提取的基本块更加复杂。
1 | # 从程序入口点提取一个代码块 block = proj.factory.block(proj.entry) |
基本块的使用
每个 Block
对象包含一个反汇编的指令列表。你可以通过 block.capstone
获取该基本块的反汇编指令。capstone
是一个流行的反汇编库,angr
使用它来生成反汇编指令。
1 | for insn in block.capstone.insns: |
在我们开发逆向辅助脚本的时候,基本块的其中一个作用就是可以提取某个地址处的 gadget
。
1 | def get_gadget(addr): |
我们还可以借助基本块来进行一些复杂的程序分析,不过这里先不做介绍。
控制流程图(CFG)
控制流图(Control Flow Graph,简称 CFG) 是一种图形化的表示方法,用于描述程序中各个基本块之间的控制流关系。它将程序中的 基本块 作为节点,表示控制流的 跳转指令(如 jmp
、call
、ret
等)作为边。
利用 angr 提取 CFG
在 angr
中,有两种类型的控制流图(CFG)可以生成:静态 CFG(CFGFast
)和动态 CFG(CFGEmulated
)。
提示
如果你不确定使用哪个 CFG,或者遇到 CFGEmulated
的问题,建议首先尝试使用 CFGFast
。
CFGFast
使用静态分析来生成控制流图。它显著更快,但理论上受限于一些控制流转换只能在执行时解析的事实。这是其他流行的逆向工程工具执行的同类控制流图分析,其结果与它们的输出可比。CFGEmulated
使用符号执行来捕捉控制流图。尽管它理论上更精确,但它显著更慢。由于模拟精度的问题(如系统调用、缺少硬件特性等),通常它也不那么完整。
可以通过以下代码构建控制流图:
1 | import angr |
提示
控制流图分析不会区分来自不同二进制对象的代码。这意味着默认情况下,它会尝试分析通过加载的共享库进行的控制流。这几乎从来不是预期的行为,因为这会使分析时间变得极长。要加载没有共享库的二进制文件,可以在 Project
构造函数中添加以下关键字参数:load_options={'auto_load_libs': False}
。
控制流图的核心是一个 NetworkX
有向图(di-graph)。这意味着所有常规的 NetworkX
API 都可用:
1 | print("This is the graph:", cfg.graph) |
CFGNode
类的实例代表了控制流图中的每个基本块。你可以通过 cfg.get_any_node()
获取给定地址的任何一个节点,或者通过 cfg.get_all_nodes()
获取所有上下文下的节点。
由于程序可能在多个上下文中执行,相同的基本块在不同的上下文下可能会有不同的表现。因此,同一个基本块可能会在图中有多个节点(表示不同的执行上下文)。
1 | # 获取给定位置(入口点)对应的任意一个节点 |
CFGNode
还具有 predecessors
和 successors
属性,分别表示当前节点的前驱和后继节点。
1 | # 获取入口节点的前驱 |
在 IDA 中,控制流图是以函数为单位的,也就是说,IDA 将每个函数作为一个单独的基本块进行分析,并将函数的入口和出口视为控制流的边界。而在 angr
中,控制流图是基于 基本块 的,call
指令通常被视为 跳转(或控制流转移)的一个标志,而不是函数的边界。控制流图会继续分析 call
指令后的指令,但不会自动将其视为函数的边界。
angrmanagement
的 to_supergraph
函数用于将 angr
的控制流图转换成一个函数级别的控制流图。to_supergraph
会把 angr
的单个函数的 CFG 提取出来,并将其转化为 IDA 样式的图。
1 | def get_cfg(): |
手动提取 CFG
如果一个二进制程序比较大,那么使用 angr 内置的 CFG 生成方法会非常的慢。对于这种情况通常我们都会自己实现一个近似的提取 CFG 的方法,针对一个特定函数开始提取 CFG。
下面这段代码是针对 x86 架构遍历一个特定函数的 CFG 的代码:
1 | def bfs_cfg(self, block_handler: Callable[[List[CsInsn]], Any]): |
下面这段代码是针对 arm 架构遍历一个特定函数的 CFG 的代码:
1 | def bfs_cfg(self, block_handler: Callable[[List[CsInsn]], Any]): |
下面这段代码是针对 aarch64 架构遍历一个特定函数的 CFG 的代码:
1 | def bfs_cfg(self, block_handler: Callable[[List[CsInsn]], Any]): |
上述示例代码只是近似遍历指定函数 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
:一个angr
的Callable
对象,表示该函数。你可以像调用 Python 函数一样调用它,并传入 Python 参数,返回的结果可能是实际结果(可能是符号化的),就像你运行了该函数一样。entry_func.transition_graph
:一个NetworkX
的有向图(DiGraph),描述函数内部的控制流。它类似于 IDA 所显示的每个函数级别的控制流图。entry_func.name
:函数的名称。entry_func.has_unresolved_calls
和entry_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 | def get_func_by_addr(addr, project, size=0x1000): |
下面这段代码是针对 arm 架构寻找函数开头的代码,由于 arm 架构的机器码长度比较固定,因此可以精确的分析汇编代码。不过要注意的是 arm 架构的函数后面可能会有一些全局变量的地址指针,被识别为汇编可能会影响分析结果,这里简单的用经验规则过滤一下。
1 | def get_func_by_addr(addr, project): |
下面这段代码是针对 aarch64 架构寻找函数开头的代码,同样是搜索汇编实现的。
1 | def get_func_by_addr(addr, project): |
引用
前面定位函数起始地址的前提是需要有一个函数内部的地址,而我们通常是用一些特征数据(例如字符串)的引用来定位函数内部的地址的。
其中 arm32 架构由于全局变量的地址会被写到引用的函数后面,因此我们可以直接通过搜索全局变量的地址的方式来定位函数。而对于 x86 和 aarch64 架构则需要我们扫描汇编预处理出全局变量的引用关系。
x86 架构
在 x86 架构下,我们常见的字符串引用的汇编代码一般是如下两种形式:
lea
形式,适用于地址无关代码。对于这种形式被引用的字符串的地址在汇编指令的硬编码中体现不出来,因此不能直接通过搜索地址的方式定位到引用字符串的汇编代码,需要扫描分析汇编预处理引用表。1
lea reg, [rip + offset];
mov
形式,直接将目标地址设置到寄存器中。对于这种形式被引用的字符串的地址在汇编指令的硬编码中自带字符串地址,因此可以通过在代码段中搜索字符串地址来定位。1
mov reg, address;
最终的代码实现如下:
1 | import json |
AArch64 架构
aarch 架构可以参考前面计算内存操作数对应的地址的方法扫描汇编进行预处理。
1 | import json |
符号执行
符号执行原理
基本概念
符号执行(Symbolic Execution)是一种程序分析技术,它通过使用符号(而不是具体的值)来代替程序中的输入数据,在程序执行时跟踪符号变量的值。这使得我们能够推理出程序的行为,而不需要实际运行它。符号执行可以帮助发现程序的潜在问题,如漏洞、错误和安全问题,尤其在静态分析、漏洞挖掘和逆向工程等领域有广泛应用。
符号执行中的 符号状态 和 路径约束 是符号执行中两个非常重要的概念,它们帮助我们表达程序的执行过程和各种条件。
- 符号状态(Symbolic State):当前状态所有参数的集合,用 表示。集合中的每个元素用表示初始参数的变量表示。
- 路径约束(Path Constraint):到达当前路径需要表示初始参数满足的关系,通常用 表示。
例如下面的程序:
1 |
|
对应的程序框图如下:
我们用 , 分别表示初始输入的参数 x
,y
。如果程序执行到 Path-1
,则:
约束求解
即根据符号执行求得的执行到目标位置时的状态,反推出初始时假设的各个变量的值。
例如上面计算出执行到 Path-1
时的 和 PC 。如果执行到 Path-1
则应当满足 PC 为真,进一步推出 为一组合法解。
为了进行约束求解,angr 内置了 z3 约束求解器(封装为 claripy)。
动态符号执行
由于 angr 分析基于的是低级语言,会涉及内存、寄存器等结构,如果全部符号化会使得路径约束变得十分复杂且没有必要。
因此 angr 采取动态符号执行(Dynamic Symbloic Execution)或者叫做混合执行(Concolic Execution)的方式,即将关键变量符号化,其他变量都赋一个合理的初始值。
angr 在默认情况下,只有从标准输入流中读取的数据会被符号化,其他数据都是具有实际值的。
符号执行引擎(Claripy)
Claripy 是由 z3 封装的二进制分析框架 angr 的核心符号执行引擎,专注于 符号表达式操作 和 约束求解。它为二进制分析提供了一套高级抽象接口,简化了符号变量管理、约束构建和求解过程,使复杂的符号执行任务更易实现。
位向量创建
位向量(Bitvectors) 是符号执行中一个非常核心的概念,特别是在像 angr 这样的符号执行框架中,位向量用于表示程序中变量的值和各种计算结果。
位向量是一个由多个比特(bit)组成的向量。在符号执行的上下文中,位向量被用来表示程序中未确定的数值(如变量、内存中的数据等)。每个比特的位置可以表示不同的数值或者数据状态。
我们可以通过 Claripy 创建位向量常量:
1 | import claripy |
除了位向量常量,我们还可以创建位向量符号:
1 | # 创建一个名为 "x" 的 64 位位向量符号 |
z3 支持 IEEE754 浮点数理论,因此 angr 也可以使用它们。主要的区别是,浮点数不是通过宽度来表示的,而是通过 claripy.fp.FSORT_FLOAT/claripy.fp.FSORT_DOUBLE
来表示。你可以使用 FPV
和 FPS
来创建浮点符号和浮点值。
1 | 3.2, claripy.fp.FSORT_DOUBLE) # 创建浮点值 a = claripy.FPV( |
浮点数和整数类型的向量可以互相转换。
如果是使用 raw_to_bv
和 raw_to_fp
转换则表示的是数据不变,数据的解释方式改变(就像你将浮点数指针转换为整数指针或反之一样)。
1 | a.raw_to_bv() |
如果是类型转换则需要使用 val_to_fp
和 val_to_bv
:
1 | a |
位向量运算
同样长度的位向量可以进行运算,其中 Pyhton 的整数类型也可以参与运算,在运算过程中会被强制转换为适当的类型。
1 | one + one_hundred |
但是,你不能执行 one + weird_nine
,因为操作数位向量的长度不同,这是一个类型错误。然而,你可以扩展 weird_nine
使它具有适当的位数:
1 | 64 - 27) weird_nine.zero_extend( |
zero_extend
将在位向量的左侧填充给定数量的零位。你还可以使用 sign_extend
来用最高位的副本进行填充,保持位向量在二进制补码有符号整数语义下的值。
位向量符号同样也可以参与到位向量运算中。你可以对它们进行任意算术运算,但你不会得到一个数字,而是得到一个 AST(抽象语法树)。
1 | x + one |
每个 AST 都有 .op
和 .args
属性:
op
是一个字符串,表示正在执行的操作。args
是该操作接受的输入值。
除非 op
是 BVV
或 BVS
(或其他少数几种情况),否则 args
都是其他的 AST,最终树将终止于 BVV
或 BVS
。
1 | 1) / (y + 2) tree = (x + |
另外浮点数向量也支持数学运算:
1 | a + b |
符号约束(Symbolic Constraints)
对任何两个相同类型的 AST 执行比较操作将生成另一个 AST。这个新生成的 AST 不是位向量,而是一个符号布尔值。
注意
AST 默认情况下的比较是无符号的。最后一个例子中的 -5
会被强制转换为 <BV64 0xfffffffffffffffb>
,它显然不小于 100。如果你想要进行有符号的比较,可以使用 one_hundred.SGT(-5)
(即“有符号大于”)。
1 | 1 x == |
符号布尔值可以通过 claripy.is_true/claripy.is_false
或本身的 is_true
和 is_false
方法来判断真假。
注意
is_true
和 is_false
只是用来判断符号布尔值是否永真或永假。对于结果不确定的符号布尔值两个方法都会返回 False
。
1 | 1 yes = one == |
另外符号布尔值不应直接在 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)。 | 选择 x 和 y 中的最大值:claripy.If(x > y, x, y) |
ULE | 无符号小于或等于。 | 检查 x 是否小于或等于 y :claripy.ULE(x, y) |
ULT | 无符号小于。 | 检查 x 是否小于 y :claripy.ULT(x, y) |
UGE | 无符号大于或等于。 | 检查 x 是否大于或等于 y :claripy.UGE(x, y) |
UGT | 无符号大于。 | 检查 x 是否大于 y :claripy.UGT(x, y) |
SLE | 有符号小于或等于。 | 检查 x 是否小于或等于 y :claripy.SLE(x, y) |
SLT | 有符号小于。 | 检查 x 是否小于 y :claripy.SLT(x, y) |
SGE | 有符号大于或等于。 | 检查 x 是否大于或等于 y :claripy.SGE(x, y) |
SGT | 有符号大于。 | 检查 x 是否大于 y :claripy.SGT(x, y) |
约束求解(Constraint Solving)
你可以将任何符号布尔值视为对符号变量有效值的断言,通过将其作为约束添加到状态中。然后,你可以通过请求对符号表达式的求值,查询符号变量的有效值。
1 | solver = claripy.Solver() |
如果我们添加了相互冲突或矛盾的约束,导致没有任何值可以赋给变量以满足约束条件,状态将变得不可满足(unsat),查询时会引发异常。你可以通过 solver.satisfiable()
检查状态是否可满足。
1 | solver.satisfiable() |
另外如果我们想要获取解的最大或最小值则应当使用 max
和 min
方法:
1 | max(x) solver. |
状态(States)
Project
对象只代表程序的一个“初始化镜像”。当你在 angr 中执行程序时,你实际上是在操作一个代表程序状态的对象——SimState
。
状态创建
我们可以通过 factory
的 entry_state
创建状态。
1 | state = proj.factory.entry_state() |
当然 entry_state
只是项目工厂提供的多个状态构造函数之一,常见的状态构造函数有:
.blank_state()
构造一个“空白”状态,数据大部分没有初始化。当访问未初始化的数据时,会返回一个没有约束的符号值。适用于要完全控制初始条件的场景。- addr :状态应该开始的地址,而不是入口点。
.entry_state()
:构造一个准备从主二进制的入口点开始执行的状态。- argc :用作程序
argc
的自定义值,可以是整数或比特向量。如果未提供,则默认为 args 的长度。 - args :一个值的列表,用作程序的
argv
。可以是混合字符串和比特向量。 - env :一个字典,用作程序的环境。键和值都可以是混合的字符串和比特向量。
- stdin :程序的输入流。可以是字符串或比特向量,不过最好长度给的要足够。
- argc :用作程序
.full_init_state()
:构造一个准备执行所有需要在主二进制入口点之前运行的初始化器的状态,例如共享库构造函数或预初始化器。当这些完成后,它将跳转到入口点。它可以接受entry_state
可以提供的任何参数,除了addr
。.call_state()
:构造一个准备执行给定函数的状态。- addr :状态应该开始的地址,而不是入口点。
- args :任何额外的位置参数将作为函数调用的参数。
SimState
包含了程序的内存、寄存器、文件系统数据等内容……任何在执行过程中可能被修改的“实时数据”都存储在这个状态中。
1 | # 获取当前的指令指针 state.regs.rip |
可以看到无论是内存还是寄存器,angr 的 SimState
都是用位向量的形式来维护。这种策略方便符号执行完之后进行约束求解。另外就是除了输入和用户指定的数据外,其余数据都是给定一个合理的初始值而不都是符号化,这样可以极大的简化最终生成的表达式的复杂程度。
内存设置
在 angr 中,state.mem
和 state.memory
是用于操作内存的两个核心接口,分别提供 类型化内存访问 和 原始字节级操作 的功能。
state.memory
提供 原始字节级操作,这意味着你可以直接访问内存的字节,而不需要考虑类型的转换。state.memory
用于处理低级操作,适合那些需要进行细粒度控制的场景,或者需要直接修改内存数据的情况。
load(addr, size)
:从地址addr
读取size
字节,返回位向量(claripy.BV
)。store(addr, data)
:将数据data
(位向量或字节)写入地址addr
。
1 | state = proj.factory.blank_state() |
注意
state.memory
的主要用途是加载和存储数据块,没有附加语义,因此数据默认按照“大端序”读写。如果你想对加载或存储的数据进行字节交换,你可以传递一个关键字参数 endness
。
endness
应该是 archinfo
包中的 Endness
枚举的成员,该包用于保存关于 angr
中 CPU 架构的声明性数据。此外,正在分析的程序的字节序可以通过 arch.memory_endness
获取,比如 state.arch.memory_endness
。
1 | import archinfo |
state.mem
提供 类型化内存访问,允许你对内存进行更高层次的操作,通常与寄存器、结构体、数组等数据结构的交互更为便捷。
注意
state.mem
赋值的时候可以使用bytes
、数字、位向量,但是位向量要确保类型长度一致。state.mem
按照二进制默认的大小端序读写。
1 | state = proj.factory.blank_state() |
寄存器设置
和内存接口一样,angr 的寄存器接口也有 state.regs
和 state.registers
两种。和 state.memory
一样,state.registers
也提供了没有具体类型的底层数据访问接口,因为 angr 的寄存器本质上也是通过某个地址空间的内存来模拟的。
不过在实际使用中我们通常还是使用 state.regs
接口来读写寄存器。
1 | import angr, claripy |
文件设置
SimFile
是 angr
中用于模拟文件操作的类,它实现了对文件的模拟,包括文件读取、写入、以及其他文件操作。它设计的目标是模拟磁盘文件的行为,并且允许符号执行引擎(symbolic execution engine)对文件的内容和文件系统操作进行符号化处理。
SimFile
构造函数中常用的参数有:
name
:文件的名称,用于标识文件。这个名称通常是文件路径的一部分。content
:可选的初始内容,可以是字符串或者位向量(bitvector)。如果没有提供内容,文件内容将默认为零。size
:可选的文件大小。如果没有提供大小,文件大小默认为零。如果提供了content
,则文件大小将根据内容的大小确定。
例如下面的示例代码,我们将 password.txt
这个文件符号化,这样如果程序的执行受到了该文件内容的影响,那么我们就可以在目标状态下求解文件的内容。
1 | state = proj.factory.entry_state() |
仿真管理器(Simulation Managers)
在 angr
中,仿真管理器(Simulation Managers)是用于管理模拟状态(SimState
)的核心组件之一。仿真管理器负责维护符号执行过程中所有可能的路径,并为每个路径创建并管理相应的状态。
仿真管理器创建
仿真管理器通过 factory
的 simulation_manager
构造函数生成,该函数接收一个状态或状态列表。一个仿真管理器可以包含多个状态堆栈。默认的状态堆栈是 active
,它使用我们传入的状态进行初始化。
1 | simgr = proj.factory.simulation_manager(state) |
在 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.state
与 record.error
访问),该 record 会被插入到 errored
中,我们可以通过 record.debug()
启动一个调试窗口。
我们可以使用 stash.move()
来在 stash 之间转移放置状态,用法如下:
1 | 'unconstrained', to_stash = 'active') simgr.move(from_stash = |
在转移当中我们还可以通过指定 filter_func
参数来进行过滤:
1 | def filter_func(state): |
stash 本质上就是个 list,因此在初始化时我们可以通过字典的方式指定每个 stash 的初始内容:
1 | simgr = proj.factory.simgr(init_state, |
路径探索
仿真管理器以基本块为单位对程序进行符号执行,对应的方法为 simgr.step()
。每当仿真管理器调用一次 step
方法时:
- 内部维护的
active
列表中的所有活跃状态都会执行一个基本块。 - 每个状态在执行完一个基本块后根据基本块后根据执行的结果决定状态是否分裂或从
active
中移除。
我们可以循环调用 simgr.step()
然后遍历 active
列表判断是否有执行到我们预想的目标地址的状态。然后再对执行到目标地址的状态求解所需的输入。
1 | while len(simgr.active): |
上述过程实际上在仿真管理器中被封装成一个路径探索函数 simgr.explore()
。
explore
函数主要有两个参数:
find
:一个地址或条件,表示我们希望探索到的目标状态。当仿真管理器的任何路径到达该地址时,仿真过程会停止或返回该路径。avoid
:一个地址或条件,表示我们希望避免的状态。即仿真管理器会尽量避免路径到达此地址或条件,通常用于避开错误路径或崩溃点。
提示
find
和 avoid
可以接受多种类型的参数:
如果参数类型是数字表示的是地址,即仿真管理器应当或不应当执行到的地址。
如果参数是一个回调函数(或者
lambda
表达式),则会根据函数的返回结果对当前探索的路径进行剪枝。1
2
3
4simgr.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 | # 在创建仿真管理器的时候指定开启 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.hook
和 SimProcedure
。
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 函数。length
:call
指令的长度。
其中我们的自定义函数应该接受 state
作为参数,我们可以通过操作 state
模拟该函数对程序执行状态造成的影响。
1 | def my_hook_func(state): |
另外 angr 还支持注解的方式进行 hook,下面这段代码与前面的代码等价:
1 |
|
SimProcedure
主要用于替换文件中的原有函数,例如 angr
默认会使用一些内置的 SimProcedure
来替换掉一些常见的库函数。在二进制程序中,像 malloc
这样的复杂库函数通常会被自动 hook,以避免路径爆炸和进行符号化分析。
如果我们已经有该二进制文件的符号表,我们可以直接使用 project.hook_symbol(symbol_str, sim_procedure_instance)
来自动 hook 掉文件中所有的对应符号,run()
方法的参数为被替换函数所接收的参数。
1 | class MyCheckEquals(angr.SimProcedure): |
在 SimProcedure
的 run()
方法中,我们可以使用一些有用的成员函数来控制执行过程,例如:
ret(expr)
:返回一个表达式值。jump(addr)
:跳转到指定的地址。exit(code)
:终止程序执行,通常用于模拟程序退出。call(addr, args, continue_at)
:调用文件中的一个函数,args
是传递给函数的参数,continue_at
是继续执行的位置。inline_call(procedure, *args)
:内联地调用另一个SimProcedure
。
这些成员函数使得我们可以更灵活地控制程序的模拟执行,尤其在处理复杂的系统调用、库函数和跳转时非常有用。
符号求解
在完成路径探索之后,如果目标位置可达,则我们可以从仿真管理器的 found
列表中找到执行到目标位置的所有路径对应的状态:
1 | # 要求解的内容 |
state
中实际上内置了符号执行引擎 claripy
,前面的路径探索本质上就是为每个 state
内置的符号执行引擎中添加对应的条件。当执行到目标位置时,state
中的符号执行引擎已经添加了能够执行目标位置所需的所有条件。因此我们可以利用符号执行引擎的约束求解功能(state.solver
)求解出前面设置的需要求解的内容。
1 | found.solver.eval(bvs_to_solve) |
如果是标准输入之类的则不需要我们显式的调用约束求解,直接获取即可:
1 | simgr.found[0].posix.dumps(0) |
符号执行引擎不仅可以通过路径探索添加约束条件,还可以手动添加条件。因此在有些场景下我们不需要完整的执行整个过程,而是只执行前面一部分内容,而后的部分可以手动添加相应的规则。这种策略可以一定程度上避免一些路径爆炸的情况。
1 | found = simgr.found[0] |
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 实现的常用功能。
环境配置
切换 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())
脚本将执行以下操作:
- 如果尚未安装
pip
,将安装pip
。 - 从 PyPi 安装
ipyida
包。 - 将
ipyida_plugin_stub.py
复制到用户的插件目录。 - 加载 IPyIDA 插件。
另外 IPyIDA 也可以手动安装,用户只需要将 ipyida_plugin_stub.py
和 ipyida
目录复制到 IDA 的插件目录中即可。
手动安装需要用户自己管理依赖项和更新。IPyIDA 需要 ipykernel
和 qtconsole
包,并且如果使用 ipykernel
版本 5 或更高版本,还需要 qasync
包。
1 | pip install ipykernel qtconsole |
pycharm 自动补全
在 PyCharm 的设置→项目→Python 解释器
点击设置选择全部显示...
点击显示解释器路径
图标,然后在弹出的解释器路径对话框中添加 IdaPython 的 python 库路径。
旧版本的 IDA 的 python 库有 3 和 2 的区分,分别表示 Python3 和 Python2 对应的 python 库。
添加完 IdaPython 的代码库之后就可以使用 PyCharm 的自动补全功能了。
基本使用
段相关
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 | import idc |
地址相关
交叉引用
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
16def 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
6def 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 | import idaapi |
字符串相关
在 IdaPython 脚本中,字符串起到了非常关键的作用。例如通过字符串定位关键代码,或者通过字符串恢复函数符号等。因此这里单独介绍一下 IdaPython 中字符串相关的功能。
字符串提取
字符串获取主要有 idautils.Strings
和 idc.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 | import idautils |
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 | import idautils |
字符串创建
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
的汇编指令的操作指令(如mov
、add
)。idc.next_head(addr)
:获取当前地址的汇编的下一条汇编的地址。idc.prev_head(addr)
:获取当前地址的汇编的上一条汇编的地址。
1 | import idc |
函数相关
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
23def 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
常用脚本
全局符号错误修复
1 | import idc |
修复函数参数
1 |
|
Unicorn
Unicorn 是一个轻量级、跨平台的开源模拟器(Emulator),可以用于模拟不同架构的程序执行。它基于 QEMU 项目,但比 QEMU 更加轻量和易于使用,主要面向动态分析、漏洞挖掘和逆向工程等领域。Unicorn 允许开发者模拟目标平台的指令集架构(ISA),并且能够执行二进制代码,模拟程序运行时的行为,捕捉寄存器、内存等状态。
Unicorn 安装
可以通过 Python 的包管理工具 pip
安装:
1 | pip install unicorn |
基本使用
创建 Unicorn 对象
和前面提到的 Keystone 和 Capstone 的流程类似,Unicorn 模拟执行首先需要创建一个 Unicorn 对象,并设置目标架构和模式。
1 | from unicorn import * |
注意
在模拟 x86 架构时,程序可能会访问特定段(如 gs
或 fs
)中的数据,这些数据通常与操作系统、线程信息、TLS(线程局部存储)等有关(例如 mov rax, gs:[0x28]
指令获取 canary)。
由于 Unicorn 模拟器仅提供模拟功能,导致像 fs
、gs
这样的段寄存器都被默认初始化为 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 | rip_value = mu.reg_read(UC_X86_REG_RIP) # 读取 RIP 寄存器的值 |
内存操作
Unicorn 允许你模拟内存的读写操作,模拟程序运行时的内存访问。你可以通过 mu.mem_map()
来映射内存区域,通过 mu.mem_write()
来写入数据,通过 mu.mem_read()
来读取内存数据。
映射内存
mu.mem_map(start, size)
用来映射一段内存区域,start
是内存起始地址,size
是内存的大小。
注意
start
和end
要关于 0x1000 对齐。- 要确保映射的内存地址空间不要与之前映射的内存出现重叠。
1 | mu.mem_map(0x1000, 0x1000) # 映射 0x1000 大小的内存 |
写入内存
mu.mem_write(addr, data)
将数据写入指定地址的内存区域。data
是二进制数据(字节串),addr
是写入的地址。
1 | code = b"\x48\x31\xc0" # x86_64: xor rax, rax |
读取内存
mu.mem_read(addr, size)
从指定地址读取 size
字节的数据。
1 | data = mu.mem_read(0x1000, 4) # 从地址 0x1000 读取 4 字节 |
加载二进制程序
实际情况下我们可能需要模拟执行一个二进制程序中的某个解密算法函数。与 shellcode 不同,在二进制程序中即使一个纯算法函数也不是只加载代码段就可以正常仿真的,因为这个函数在执行过程中还可能访问全局变量。因此我们需要想办法将这个二进制程序加载到内存中,因此通常 Unicorn 还要配合 angr 的静态分析功能使用。
下面这段代码是加载二进制到 Unicorn 中的模板,可以应对大多数情况。
提示
- 这个模板的段映射部分写的很奇怪,这是因为有的二进制程序(比如一些 Linux 内核镜像)的段并不是关于 0x1000 对齐,但是 Unicorn 的
mem_map
要求地址范围按 0x1000 对齐,并且不能与之前映射的内存重叠。因此这里要做内存对齐处理并且多次捕获异常。 - x86 架构设置
bp
寄存器是因为有的函数会通过bp
寄存器访问函数的参数和局部变量。 - 为了实现代码复用,通常的做法是把每个架构相关的操作抽象为接口类,然后每个架构实现这个接口类,而真正的核心逻辑不需要关注架构的细节。下面的模板就是遵循这一设计原则。
1 | # 定义 amd64 架构的上下文 |
模拟执行
虚拟机启动
当前期虚拟机初始化完成之后(主要是程序加载、栈初始化、寄存器设置等),就可以启动虚拟机进行模拟了。虚拟机通过 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 | try: |
事件监控
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 | from capstone import * |
内存事件
我们常用的内存事件有内存读入事件(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 | # 定义一个回调函数,用于监控内存读取 |
常用脚本
trace shellcode
1 | import time |
- 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.