2. angr核心架构:Project对象、加载二进制文件、CFG与CFGEmulated、状态与符号变量
好,咱们正式开始啃angr的核心架构。
说实话,我第一次接触angr的时候,也被它那一堆概念搞得有点懵。什么Project、CFG、状态、符号变量……感觉像是一堆乐高零件,不知道怎么拼起来。但后来我慢慢发现,其实angr的设计思路非常清晰——它就是把二进制分析这件事,拆成了几个固定的步骤。
你只要掌握了这几个核心组件,剩下的就是搭积木。
2.1 Project对象——一切从这里开始
在angr里,Project是最顶层的对象。你可以把它理解成「整个二进制文件的管家」。
我习惯这么说:Project = 二进制文件 + 分析引擎 + 所有配置。
创建Project很简单:
import angr
proj = angr.Project('/bin/ls', auto_load_libs=False)
这里有个小细节——auto_load_libs=False。我个人建议,做恶意代码分析时,一定要把这个参数设为False。为什么?因为恶意样本经常依赖一些乱七八糟的DLL,或者干脆就是加壳的。你让angr自动加载库,它可能会卡死,或者加载一堆你根本不需要的东西。
我曾经在分析一个勒索软件样本时,忘了关这个选项,结果angr花了10分钟去加载系统库……嗯,从那以后我再也不敢忘了。
Project对象里藏着什么?
- proj.loader:负责加载二进制文件,解析PE/ELF格式
- proj.factory:工厂模式,用来创建状态、块、模拟管理器等
- proj.arch:架构信息,比如x86、ARM、MIPS
- proj.analyses:各种分析工具的入口,比如CFG分析
说白了,你拿到一个恶意样本,第一件事就是创建Project。然后所有后续操作,都从这个对象出发。
2.2 加载二进制文件——Loader的魔法
angr的Loader模块,负责把二进制文件「请进」angr的世界。
它做的事情,其实和操作系统加载程序差不多:解析节区、处理重定位、加载依赖库。但angr的Loader更灵活——它可以在不实际执行的情况下,模拟整个加载过程。
来看个例子:
proj = angr.Project('malware.exe')
loader = proj.loader
# 查看所有加载的对象
for obj in loader.all_objects:
print(obj.binary, hex(obj.min_addr), hex(obj.max_addr))
# 查看主二进制文件的入口点
print(hex(loader.main_object.entry))
这里有个坑,我提醒一下大家。恶意代码经常使用反调试、反分析技巧,比如修改PE头、伪造节区表。angr的Loader虽然很强大,但遇到一些极端混淆的样本,也可能解析失败。
另外,Loader还支持指定基地址。这在分析位置无关代码(PIC)时特别有用:
proj = angr.Project('shellcode.bin', base_addr=0x100000)
嗯,这个技巧我在分析一段shellcode时用过,效果不错。
2.3 CFG与CFGEmulated——控制流图的两种姿势
控制流图(CFG)是二进制分析的基石。angr提供了两种构建CFG的方式:静态CFG和模拟CFG。
你想想看,为什么需要两种?
因为静态分析有局限——遇到间接跳转、动态计算地址,它就抓瞎了。而模拟执行可以「跑」一遍代码,把那些隐藏的分支都揪出来。
2.3.1 静态CFG(CFGFast)
CFGFast是angr的静态分析引擎。它通过扫描二进制代码,识别函数边界、基本块、跳转关系。
cfg = proj.analyses.CFGFast()
print("函数数量:", len(cfg.functions))
print("基本块数量:", len(cfg.graph.nodes()))
静态CFG的优点:快。几秒钟就能搞定一个几MB的样本。
缺点呢?遇到间接调用(比如call eax),它就无能为力了。你想想看,恶意代码最喜欢用这种技巧来隐藏API调用。
2.3.2 模拟CFG(CFGEmulated)
CFGEmulated就厉害了。它用符号执行引擎,实际模拟代码的执行路径。遇到间接跳转?没关系,它会把所有可能的目标都探索出来。
cfg = proj.analyses.CFGEmulated()
但代价是——慢。而且可能陷入路径爆炸。
我记得有一次分析一个加了混淆的样本,CFGEmulated跑了整整两个小时还没结束。后来我加了个context_sensitivity_level=2的参数,才把时间降下来。
什么时候用CFGEmulated?
- 样本使用了大量间接跳转
- 你需要精确的函数调用关系
- 静态CFG漏掉了关键路径
我个人的经验是:先用CFGFast探路,如果发现关键路径不完整,再上CFGEmulated。别一上来就开大招,容易把自己卡死。
2.4 状态与符号变量——angr的灵魂
好,终于到了最核心的部分——状态(State)和符号变量(Symbolic Variable)。
你可以把State理解成「某一时刻的CPU快照」。它包含了:
- 寄存器值
- 内存内容
- 文件描述符
- 符号约束
- ……
而符号变量,就是那些「未知的输入」。比如你分析一个程序,不知道用户输入是什么,那就把它设成符号变量。
来看个经典例子:
# 创建一个初始状态
state = proj.factory.entry_state()
# 创建一个符号变量(32位)
sym_input = claripy.BVS('input', 32)
# 把符号变量写入内存
state.memory.store(0x1000, sym_input)
# 模拟执行
simgr = proj.factory.simulation_manager(state)
simgr.explore(find=0x400000) # 找到目标地址
这里claripy.BVS就是创建符号变量的函数。BVS = BitVector Symbolic,说白了就是一个「未知的位向量」。
你可能会问:符号变量有什么用?
我举个例子。有一次我分析一个恶意软件,它从网络接收一个加密密钥。密钥长度32字节,但代码里没有硬编码。怎么办?
我把那32字节设成符号变量,然后让angr模拟执行。当程序走到某个判断分支时,angr会自动记录下约束条件。最后我用solver.eval()解出了密钥的具体值。
这就是符号执行的威力——把未知变成已知。
2.4.1 状态的几种类型
angr提供了几种预设的状态类型:
| 状态类型 | 说明 | 适用场景 |
|---|---|---|
entry_state() |
从程序入口点开始 | 分析整个程序 |
blank_state() |
空白状态,需要手动设置 | 分析特定函数 |
call_state() |
模拟函数调用 | 分析单个函数 |
reg_state() |
从指定地址开始 | 分析代码片段 |
我个人最常用的是blank_state()。为什么?因为恶意代码经常不按套路出牌——入口点可能被修改,或者你只想分析某个特定的函数。用blank_state()可以完全控制初始条件。
核心要点:
- Project是入口,Loader负责加载
- CFG有两种:静态快,模拟准
- State是快照,符号变量是未知输入
- 先静态后动态,别一上来就模拟
嗯,到这里,angr的核心架构你应该有个大概的轮廓了。下一节我们会深入符号执行的具体技巧——怎么用angr去解那些「不可能解」的问题。
但在此之前,我建议你动手试试。随便找个PE文件,创建Project,生成CFG,看看它的函数调用图。实践出真知嘛。