2、angr核心概念:符号执行、约束求解、路径爆炸、状态与插件

好,咱们直接切入正题。上一章我们把angr装好了,也跑了个简单的demo。但说实话,如果你不理解angr背后的几个核心概念,你写出来的脚本大概率只能跑跑玩具程序,遇到真实世界的二进制漏洞,立马就歇菜了。

我个人习惯,学任何工具,先搞懂它的“魂”,再学它的“形”。angr的魂,就是这四个东西:符号执行、约束求解、路径爆炸、状态与插件。今天咱们就把它们彻底讲透。

2.1 符号执行:把程序变成数学题

传统的动态分析,比如你用gdb调试,给的是具体值,比如eax=0x41。这叫具体执行。符号执行不一样,它给的是“符号”,说白了就是一个未知数x。

你想想看,程序里有个判断:if (input == 0xdeadbeef)。具体执行时,你得试一次才知道。符号执行呢?它直接说:“我不知道input是多少,但我记下了一个约束:input必须等于0xdeadbeef,才能走真分支。”

angr把每个寄存器、每个内存地址,都当成一个符号表达式。程序跑起来,就像在解一堆数学方程。

核心理解:符号执行不是“跑程序”,而是“翻译程序”。它把汇编指令翻译成一种叫中间表示(IR)的东西,angr用的是VEX IR。然后在这个IR上做符号传播。

我在项目中遇到过一个问题:一个混淆过的函数,有上千条指令,用IDA F5根本看不懂。但用angr做符号执行,我直接提取出函数输出和输入的关系,发现其实就是个简单的CRC校验。嗯,符号执行在对抗混淆时,有奇效。

2.2 约束求解:找到那条“隐藏路径”

符号执行生成了约束,谁来解?约束求解器。angr背后用的是Z3求解器,微软出品,非常强大。

说白了,约束求解器就是个“找茬专家”。你给它一堆条件,比如:

  • input > 10
  • input < 20
  • input % 2 == 0

它就能告诉你:input=12, 14, 16, 18都行。如果你再补一个条件:buffer[input] == 'A',它就能精确算出input的值。

在漏洞挖掘里,我们经常用它来“反推”输入。比如你发现一个栈溢出,需要覆盖返回地址为0x41414141。你只需要把约束设好:

# 伪代码示意
state = proj.factory.entry_state()
solver = state.solver

# 假设输入是符号化的
input_len = 100
symbolic_input = solver.BVS("input", input_len * 8)

# 约束:第20个字节开始必须是0x41414141
constraint = solver.And(
    symbolic_input.get_byte(20) == 0x41,
    symbolic_input.get_byte(21) == 0x41,
    symbolic_input.get_byte(22) == 0x41,
    symbolic_input.get_byte(23) == 0x41
)

solver.add(constraint)
if solver.satisfiable():
    solution = solver.eval(symbolic_input)
    print(f"找到触发漏洞的输入: {solution}")

避坑指南:我曾经在一个CTF题目里,约束求解一直返回unsat(无解)。查了半天,发现是符号变量的位宽设错了。你想想看,一个32位的变量,你按8位去约束,肯定矛盾。所以,符号变量的位宽一定要和实际数据对齐

2.3 路径爆炸:angr的“阿喀琉斯之踵”

这是个大问题。符号执行虽然强大,但它有个天生的缺陷——路径爆炸

什么意思?程序每遇到一个条件分支(if-else),就会分裂成两条路径。如果程序里有100个条件分支,理论上就有2^100条路径。这个数字,比宇宙中的原子还多。angr不可能全部探索完。

我刚开始用angr时,拿它去分析一个正常的PE文件,结果跑了半小时还没出结果。后来一看,路径数量已经上亿了。嗯,这就是路径爆炸。

怎么解决?实战中我常用这几个策略:

策略 说明 我的经验
路径剪枝 丢弃明显不可能满足的路径 比如约束求解发现某路径无解,直接扔掉
符号执行+具体执行混合 对复杂计算用具体值,对关键判断用符号 我最常用的方法,效率提升10倍以上
设置探索深度 限制angr最多探索多少条路径 适合漏洞验证,不适合全量分析
使用veritesting angr的一种优化模式,合并等价路径 效果不错,但有时会漏掉一些特殊路径

警告:不要指望angr能自动处理所有路径。真实世界的二进制,路径数量是天文数字。你必须手动指定探索目标,比如“从函数A到函数B”,或者“到达地址0x401234”。否则,angr会迷失在路径的海洋里。

2.4 状态与插件:angr的“乐高积木”

angr里,状态(State)是核心数据结构。它记录了某一时刻,CPU的所有寄存器值、内存内容、文件描述符、符号约束等等。你可以把状态理解为“程序在某一时刻的快照”。

angr的探索过程,本质上就是不断创建新状态、求解约束、然后根据结果决定下一步。

插件(Plugin),是angr的扩展机制。angr本身只提供基础框架,具体功能靠插件实现。比如:

  • SimState:最基础的状态类
  • SimInspect:调试插件,可以设置断点
  • SimProcedures:模拟库函数,比如printf、malloc
  • CFG:控制流图插件

我个人习惯,写angr脚本时,第一步就是配置好插件。比如:

# 创建一个带基础插件的状态
state = proj.factory.entry_state(
    add_options=angr.options.unicorn,
    remove_options=angr.options.LAZY_SOLVES
)

# 注册一个钩子,在malloc调用时打印信息
@proj.hook(0x401000, length=5)
def my_hook(state):
    print(f"执行到地址: {hex(state.regs.eip)}")
    # 这里可以修改状态,比如跳过某个检查

关键点:状态是可序列化的。你可以把一个状态保存下来,下次直接加载。这在分析大型程序时非常有用——先探索一部分,保存状态,下次接着来。我曾经用这个特性,把一个需要跑3天的分析任务,拆成了10个并行任务,3小时就跑完了。

知识体系总览

下面这张图,是我自己总结的angr核心概念关系图。你看一眼,就能明白它们是怎么配合的:

angr核心概念关系图 符号执行 约束求解 路径爆炸 状态 (State) 插件 (Plugin) 生成约束 分支分裂 求解结果 剪枝/优化 互相依赖 符号执行生成约束 → 约束求解器求解 → 路径爆炸需要插件优化 → 状态是这一切的载体

这张图你看懂了吗?说白了,angr的工作流就是:符号执行生成约束 → 约束求解器求解 → 遇到分支产生路径爆炸 → 用插件和状态管理来控制爆炸。这四个概念环环相扣,缺一不可。

好了,这一章的内容就到这里。下一章我们会动手实战,用angr去挖一个真实的缓冲区溢出漏洞。到时候,这些概念你都会用得上。


专注资料整理