4、Angr核心概念(上):Project对象详解

好,咱们正式开始啃Angr的核心概念。说实话,我第一次接触Angr的时候,也被它那一堆对象搞得有点懵。但后来我发现,只要搞懂三个东西——ProjectStateSimEngine——剩下的都是锦上添花。

这一节,咱们先聊Project和State。这两个是你每天都会打交道的对象。

4.1 Project对象:一切从这里开始

Project,说白了就是你要分析的二进制文件的“代言人”。你加载一个ELF或者PE文件,Angr就会给你返回一个Project对象。这个对象里封装了所有你需要的信息——架构、入口点、段信息、符号表等等。

核心要点:Project对象是Angr分析的入口。没有它,你什么都干不了。

4.1.1 加载器(CLE)

Angr内部使用了一个叫CLE的加载器。CLE的全称是CLE Loads Everything,嗯,名字挺直白的。它的工作就是把二进制文件加载到内存中,解析它的格式,处理依赖库。

我个人习惯在创建Project时,指定auto_load_libs参数。为什么呢?因为默认情况下,Angr会尝试加载所有依赖的动态库。这在分析大型程序时,可能会让状态空间爆炸。我曾经在一个CTF题目里,就因为没关这个选项,导致符号执行跑了一整天都没出结果。

import angr

# 创建一个Project对象
proj = angr.Project('./target_binary', auto_load_libs=False)

# 查看加载器信息
print(proj.loader)
# 输出类似:<Loaded target_binary, maps [0x400000:0x601000]>

你看,加载器会告诉你二进制文件被映射到了哪个地址范围。这个信息在后续分析中非常有用。

小技巧:如果你只想分析主二进制文件,不关心动态库,记得设置auto_load_libs=False。这能大幅减少状态空间。

4.1.2 架构信息

Project对象里还包含了目标二进制文件的架构信息。比如它是x86、x64、ARM还是MIPS。这些信息存储在proj.arch中。

# 查看架构信息
print(proj.arch)
# 输出类似:<Arch AMD64 (LE)>

print(proj.arch.bits)   # 64
print(proj.arch.name)   # AMD64
print(proj.arch.registers)  # 所有寄存器的信息

我记得有一次,我拿到一个固件文件,怎么加载都报错。后来一看,原来是MIPS架构,而我默认用了x64的配置。嗯,这种坑踩过一次就记住了。

架构信息里最常用的,就是proj.arch.registers。它是一个字典,键是寄存器名字,值是寄存器的大小和偏移。比如:

# 查看rax寄存器的信息
print(proj.arch.registers['rax'])
# 输出类似:(0, 8)  # 偏移0,大小8字节

这个信息在后续操作State对象时,会频繁用到。

4.2 State对象:分析的核心

如果说Project是二进制文件的“静态快照”,那State就是“动态瞬间”。State对象代表了程序在某个时刻的状态——包括寄存器值、内存内容、符号变量等等。

你可以把State想象成一个虚拟CPU。它有自己的寄存器、内存、栈、堆,甚至还有文件系统。

4.2.1 创建State对象

从Project创建State很简单:

# 创建一个初始状态
state = proj.factory.entry_state()

# 或者从指定地址创建状态
state = proj.factory.blank_state(addr=0x400000)

entry_state()会创建一个从程序入口点开始的状态。而blank_state()则创建一个“空白”状态,你可以指定起始地址。我个人更常用blank_state(),因为它更灵活。

注意:使用blank_state()时,你需要自己负责初始化寄存器和内存。否则,Angr会使用默认值,这可能导致分析结果不准确。

4.2.2 寄存器操作

State对象里的寄存器,你可以像操作字典一样读写:

# 读取寄存器
rax_val = state.regs.rax
print(rax_val)  # 输出一个BitVec对象

# 写入寄存器
state.regs.rax = 0x1234

# 读取RSP(栈指针)
rsp_val = state.regs.rsp

注意,读取出来的值不是普通的整数,而是BitVec对象。这是Angr符号执行的核心——它可以是具体值,也可以是符号变量。

4.2.3 内存操作

内存操作稍微复杂一点。因为内存是按字节寻址的,而且有大小端之分。

# 读取内存:从地址0x1000读取4个字节
data = state.memory.load(0x1000, 4)

# 写入内存:在地址0x1000写入4字节的值
state.memory.store(0x1000, 0xdeadbeef, 4)

这里有个坑,我踩过好几次。就是loadstore的第三个参数——endness。默认情况下,Angr会使用架构的默认大小端。但如果你手动指定了大小端,一定要保持一致。

# 明确指定小端序
data = state.memory.load(0x1000, 4, endness='Iend_LE')

# 大端序
data = state.memory.load(0x1000, 4, endness='Iend_BE')

避坑指南:我曾经在分析一个ARM固件时,因为没注意大小端,导致符号执行的结果完全不对。后来排查了半天,才发现是内存读取的大小端设置错了。所以,一定要确认目标架构的大小端

4.2.4 符号变量

符号变量是Angr最强大的特性之一。你可以把某个寄存器或内存位置声明为“符号的”,然后Angr会自动推导出满足条件的值。

# 创建一个32位的符号变量
sym_var = state.solver.BVS('input', 32)

# 将符号变量写入寄存器
state.regs.eax = sym_var

# 添加约束:eax必须大于10且小于20
state.solver.add(state.regs.eax > 10)
state.solver.add(state.regs.eax < 20)

# 求解
solution = state.solver.eval(state.regs.eax)
print(solution)  # 输出一个满足条件的值,比如15

你看,这就是符号执行的魅力。你不需要知道具体值,只需要描述约束条件,Angr就能帮你找到答案。

我个人习惯在分析输入验证逻辑时,把输入数据全部声明为符号变量。然后让Angr自动探索所有可能的路径。这样,我就能快速找到哪些输入会导致程序崩溃,或者绕过安全检查。

4.3 SimEngine的执行模式

有了Project和State,接下来就是怎么“执行”了。Angr的执行引擎叫SimEngine。它负责模拟CPU执行指令的过程。

SimEngine有几种执行模式:

模式 说明 适用场景
符号执行 默认模式。遇到分支时,同时探索所有路径 路径探索、漏洞挖掘
具体执行 只沿着一条路径执行,不探索分支 快速验证、调试
静态执行 不实际执行,只分析指令序列 控制流分析

在实际使用中,最常用的是符号执行模式。你只需要调用simgr.explore(),Angr就会自动帮你探索所有可能的路径。

# 创建Simulation Manager
simgr = proj.factory.simulation_manager(state)

# 探索,直到找到目标地址
simgr.explore(find=0x400800)

# 如果找到了
if simgr.found:
    found_state = simgr.found[0]
    print("找到了目标状态!")
    # 可以进一步分析found_state

这里有个小细节:explore()find参数可以是一个地址,也可以是一个函数。如果你需要更复杂的条件,可以传一个回调函数进去。

核心思路:SimEngine的执行模式,说白了就是“怎么跑”。符号执行是“全都要”,具体执行是“一条道走到黑”。根据你的需求选择合适的模式。

4.4 本章小结

好了,这一节的内容就到这里。我们聊了Project对象(加载器、架构信息)、State对象(寄存器、内存、符号变量),以及SimEngine的执行模式。

这些东西,说白了就是Angr的“三驾马车”。你只要掌握了它们,后续的分析就会顺畅很多。

下一节,我们会继续深入State对象,聊聊更高级的用法——比如栈操作、堆操作、文件系统模拟等等。到时候,你会发现State对象远比今天介绍的更强大。

个人建议:学Angr最好的方式,就是动手敲代码。打开一个简单的二进制文件,创建Project和State,然后试着读写寄存器、内存,再添加几个符号变量。相信我,亲手操作一遍,比看十遍文档都管用。


公众号:蓝海资料掘金营,微信deep3321