angr核心概念:Project、State、SolverEngine、CFG、Callable
各位同学,欢迎来到第二章。上一章我们搭好了环境,跑通了第一个demo。今天要聊的这五个概念,是angr的骨架。说白了,你把这五个东西搞明白了,angr在你手里就不再是黑盒子。
我个人习惯把这五个概念分成两组:基础设施组(Project、State、SolverEngine)和分析工具组(CFG、Callable)。前者是angr的"身体",后者是angr的"手脚"。咱们一个一个来。
2.1 Project —— 一切从这里开始
Project是angr的入口。你加载一个二进制文件,返回的就是一个Project对象。它代表了你整个分析目标。
核心理解:Project = 二进制文件 + 架构信息 + 加载器 + 分析引擎的集合体
import angr
# 加载一个二进制文件
proj = angr.Project('./crackme', auto_load_libs=False)
这里有个坑,我刚开始用的时候踩过。auto_load_libs这个参数,默认是True。它会自动加载所有依赖的动态库。你想想看,一个简单的crackme,可能就链接了libc、libstdc++,如果全加载了,符号爆炸,分析速度慢得你想哭。
我的建议:分析CTF题目或小型二进制时,一律设置auto_load_libs=False。只有当你需要分析库函数内部行为时,才考虑加载。
Project对象暴露了几个重要属性:
proj.arch—— 架构信息(x86、ARM、MIPS等)proj.loader—— 加载器,负责把二进制映射到虚拟地址空间proj.factory—— 工厂,用来创建State、Block等对象proj.analyses—— 分析模块的入口
嗯,这里要注意:Project本身不执行任何分析,它只是"容器"。真正的分析工作,由它创建出来的其他对象完成。
2.2 State —— 模拟执行的核心
State是什么?它代表了程序在某个时刻的快照。包括寄存器值、内存内容、文件描述符、符号信息等等。
我打个比方:Project是游戏本体,State就是你的存档。你可以随时保存、读取、修改这个存档。
# 创建一个初始状态
state = proj.factory.entry_state()
# 查看某个寄存器的值
print(state.regs.rax)
# 修改内存
state.memory.store(0x400000, b'\x90\x90\x90\x90')
State有三种常见创建方式:
| 方法 | 说明 | 适用场景 |
|---|---|---|
entry_state() |
从程序入口点开始 | 大多数情况 |
blank_state() |
空白状态,地址全零 | 需要手动设置初始条件 |
call_state() |
模拟调用某个函数 | 分析特定函数行为 |
我曾经踩过的坑:用entry_state()分析带命令行参数的二进制时,忘记设置args。结果angr默认argv为空,程序直接报错退出。正确的做法是:proj.factory.entry_state(args=['./crackme', 'flag123'])
2.3 SolverEngine —— 符号执行的"大脑"
SolverEngine,全称是符号求解引擎。angr内部集成了Z3求解器。它的工作就是:给定一堆约束条件,问它"有没有解?"
你想想看,符号执行的核心是什么?就是把具体值变成符号变量,然后一路传播,最后问求解器"什么样的输入能让程序走到这里?"
# 创建一个符号变量
import claripy
flag_len = 32
flag_chars = [claripy.BVS(f'flag_{i}', 8) for i in range(flag_len)]
flag = claripy.Concat(*flag_chars)
# 把符号变量写入内存
state.memory.store(0x600000, flag)
# 添加约束
state.solver.add(flag_chars[0] == ord('f'))
state.solver.add(flag_chars[1] == ord('l'))
# 求解
solution = state.solver.eval(flag, cast_to=bytes)
print(solution) # 输出: b'fl...'
这里有个关键点:state.solver就是SolverEngine的接口。它支持的操作包括:
add(constraint)—— 添加约束eval(expr, n=1)—— 求解一个可行解eval_upto(expr, n)—— 求解最多n个解min()/max()—— 求解最小值/最大值satisfiable()—— 检查当前约束是否可满足
避坑指南:求解器不是万能的。遇到复杂的非线性约束(比如乘法、除法、取模),求解时间会指数级增长。我曾经分析一个用了大量乘法的算法,求解器跑了半小时没出结果。后来改用具体值执行+符号值混合的方式,才搞定。
2.4 CFG —— 控制流图
CFG(Control Flow Graph)是静态分析的基础。它把程序的所有基本块和它们之间的跳转关系画出来。
angr的CFG分析比IDA的强在哪?它结合了符号执行,能处理间接跳转、虚函数调用这些IDA搞不定的情况。
# 构建CFG
cfg = proj.analyses.CFGFast()
# 获取所有基本块
for node in cfg.nodes():
print(f"地址: {hex(node.addr)}, 大小: {node.size}")
# 查看某个函数的CFG
func = cfg.functions['main']
for block in func.blocks:
print(hex(block.addr))
CFG有两种模式:
- CFGFast —— 快速模式,适合大多数情况
- CFGEmulated —— 模拟执行模式,更精确但更慢
我个人习惯:先用CFGFast跑一遍,如果发现间接跳转没解析出来,再换CFGEmulated。
核心价值:CFG让你从"看汇编指令"升级到"看程序结构"。你一眼就能看出哪些代码是循环、哪些是条件分支、哪些是死代码。
2.5 Callable —— 把函数当函数调
Callable是angr里一个很贴心的设计。它让你可以像调用Python函数一样,调用二进制中的某个函数。
# 创建一个Callable对象
func = proj.factory.callable(0x400500)
# 调用它
result = func(10, 20)
print(result) # 返回函数执行后的返回值
它的工作原理是:
- 创建一个call_state,设置好参数
- 模拟执行到函数返回
- 读取返回值
嗯,这里要注意:Callable默认会清理调用后的状态。如果你想保留调用后的内存快照,需要设置perform_taint_analysis=False。
我曾经踩过的坑:用Callable调用一个修改全局变量的函数,结果发现全局变量没变。后来才意识到,Callable默认使用独立的状态,不会影响原始Project。如果你需要模拟真实调用链,得手动管理状态传递。
2.6 五个概念的关系图
下面这张图,是我自己总结的五个概念之间的关系。你看完应该能明白它们是怎么协作的。
2.7 小结
这五个概念,你不需要一次全记住。但你要理解它们的定位:
- Project —— 你的分析目标,一切操作的起点
- State —— 程序的某个瞬间,你可以随意修改
- SolverEngine —— 回答"有没有解"的引擎
- CFG —— 程序的"地图",告诉你代码怎么走
- Callable —— 让你像调Python函数一样调二进制函数
下一章,我们会用这些概念组合起来,写一个真正的符号执行脚本。到时候你会发现,原来逆向可以这么自动化。