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)  # 返回函数执行后的返回值

它的工作原理是:

  1. 创建一个call_state,设置好参数
  2. 模拟执行到函数返回
  3. 读取返回值

嗯,这里要注意:Callable默认会清理调用后的状态。如果你想保留调用后的内存快照,需要设置perform_taint_analysis=False

我曾经踩过的坑:用Callable调用一个修改全局变量的函数,结果发现全局变量没变。后来才意识到,Callable默认使用独立的状态,不会影响原始Project。如果你需要模拟真实调用链,得手动管理状态传递。

2.6 五个概念的关系图

下面这张图,是我自己总结的五个概念之间的关系。你看完应该能明白它们是怎么协作的。

Project State SolverEngine CFG Callable factory.entry_state() state.solver analyses.CFGFast() factory.callable() 约束求解 路径探索 创建调用状态 核心关系:Project 创建其他对象,State 依赖 SolverEngine 求解,CFG 指导路径探索,Callable 封装函数调用 基础设施组 分析工具组

2.7 小结

这五个概念,你不需要一次全记住。但你要理解它们的定位:

  • Project —— 你的分析目标,一切操作的起点
  • State —— 程序的某个瞬间,你可以随意修改
  • SolverEngine —— 回答"有没有解"的引擎
  • CFG —— 程序的"地图",告诉你代码怎么走
  • Callable —— 让你像调Python函数一样调二进制函数

下一章,我们会用这些概念组合起来,写一个真正的符号执行脚本。到时候你会发现,原来逆向可以这么自动化。


专注资料整理