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报错,不要慌。先检查样本是否加壳。如果是,先用脱壳工具处理一下,再丢给angr。我遇到过最离谱的一个样本,PE头里把节区数量写成了0xFFFFFFFF,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调用。

小技巧:如果你只是想快速了解样本的结构,先用CFGFast。它虽然不完美,但能给你一个宏观的视图。

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,看看它的函数调用图。实践出真知嘛。

angr核心架构图 Project 对象 Loader CFG 分析 State 状态 符号变量 解析PE/ELF 加载依赖库 CFGFast (静态) CFGEmulated (模拟) entry_state blank_state call_state claripy.BVS 约束求解 核心流程:Project → Loader加载 → CFG分析 → State创建 → 符号变量注入 → 模拟执行

专注资料整理