angr 学到现在,基础流程已经跑熟了。但有一次在分析某嵌入式固件里的堆分配逻辑时,发现一个很怪的现象:程序明明从 stdin 读完数据写进了 malloc 返回的缓冲区,后面拿缓冲区首地址做偏移参与比较,angr 却总在几个分支上来回剪枝,找不到能触发漏洞的路径。排查到最后,根因就是malloc 返回的那个地址,以及它对应的内存单元,在符号执行里没有按我预期的方式被符号化。
这篇笔记就专门记录这个事。它解决的核心问题是:当目标程序对 malloc 返回的地址做算术运算、作为索引基址、或者直接把地址值本身参与条件分支时,angr 的默认堆模拟和具体地址模型会限制路径探索,此时需要主动对“malloc 地址单元”做符号化处理。文章会从 angr 默认的 malloc 行为讲起,用三个递进的小实验把那层窗户纸捅破,最后给出我在实际分析中踩过的一堆坑。适合已经能跑通 angr 基础脚本、但对符号化地址和符号化内存还一知半解的人。
1. 为什么要做 malloc 地址单元符号化
1.1 先看 angr 默认怎么处理 malloc
大多数人的第一个 angr 脚本,大概就是加载一个样例程序,跑entry_state,然后explore到某个地址。这时候 angr 会通过库函数模拟(SimProcedures)接管malloc。默认的SimHeapBrk会维护一个模拟的 brk 堆区,每次调用 malloc 都返回一个具体的地址,比如第一次分配返回0x603000,第二次返回0x603100。
也就是说,在默认配置下,malloc 返回值的BitVec是一个常数,不是符号变量。这个常数本身没有任何约束能力,它只代表“程序认为这块堆区域在哪”。你不需要为此做什么,但你要意识到这一点:malloc 给你的是一个具体地址,而具体地址意味着它的值不可能“变化”。
如果你只是单纯地让程序读完数据、做比较,那具体地址完全够用。但一旦程序里出现下面这类逻辑:
char *buf = malloc(0x100); char *p = buf + user_offset; if (p >= buf + 0x80) { ... }或者更直接一点:
if ((unsigned long)buf % 0x10 == 0) { ... }那具体地址就带来限制了。buf + user_offset这条式子里面,符号部分只有user_offset,所以 angr 能探索的路径只能沿着这个偏移量展开。万一真实程序里buf的地址校验和某个外部条件耦合,angr 就完全模拟不出来了。
1.2 “地址是具体值、地址单元是符号值”的心智模型
这里我建议先把一个概念掰清楚:地址和地址单元是两回事。
- 地址:一个用于映射内存位置的数值,比如
0x603000。它可以是常数,也可以是符号表达式。 - 地址单元:内存建模时“地址 → 字节”的映射关系。
0x603000处那 8 个字节,就是 0x603000 单元的内容。内容值可以是符号。
angr 默认状态就是:地址是具体值,地址单元的内容可以是符号。你用read(0, buf, len)读入一堆符号字节,angr 内部就是在0x603000 + offset这些具体地址上挂了一个个符号表达式。路径分支的时候,求解器只需要处理这些符号内容。
所以“malloc 地址单元符号化”实际上有两种含义,缺一不可:
- 对 malloc 返回地址指向的单元做符号读写:让分配区内各个字节的内容符号化。
- 对 malloc 返回地址本身做符号化:让 “buf 指向哪里” 这件事也成为一个可被约束的变量。
第二种比第一种更“重”,大部分场景用第一种就够了,但坑恰恰出在第一次想尝试第二种的人身上。
1.3 必须符号化地址单元的两种典型场景
我复盘了一下,真正需要主动做 malloc 地址单元符号化的情况基本就两类:
第一类是输入长度不可控导致的“符号宽度写内存”。比如程序分配malloc(len),其中len来自输入。angr 默认遇到符号长度分配时,返回的地址依然是具体的,只是分配大小是符号。写数据进去时就会出现“符号大小往具体地址写”的问题,如果不做约束,求解器要同时考虑长度和内容,很容易路径爆炸。
第二类是地址参与敏感运算。比如程序把malloc返回地址强转成整数,然后和用户输入做异或、取模、比较;或者利用malloc返回地址作为数组基址,而索引是符号值。这种情况下,具体地址和符号地址的行为差异会直接影响可达路径。你只有把 malloc 地址本身也符号化,让求解器在合法堆区间内把头几个字节的“地址值”约束出来,后面的路径才可能打开。
搞清楚这两类场景,你就可以按图索骥了。
2. 第一个实验:对 malloc 返回地址指向的单元写入符号值
2.1 构造一个最小可复现的样例程序
为了把问题讲透,我手动构造了一个最小样例,没有任何真实业务逻辑,纯粹演示“malloc 分配 → 读入 → 比较→ 分支”的完整链路。C 代码如下:
#include <stdio.h> #include <stdlib.h> void win(void) { puts("win"); } int main(void) { char *buf = malloc(0x100); if (!buf) return 1; read(0, buf, 0x40); unsigned long v = *(unsigned long *)(buf + 0x18); if (v == 0xdeadbeefcafebabeULL) { win(); } return 0; }用 gcc 编译成静态链接的二进制,命名为sample_x。
这个程序的核心逻辑是这样的:读取输入到一个具体地址buf指向的内存单元,取出buf+0x18处的 8 字节,与一个常量比较。对 angr 来说,read(0, buf, 0x40)应该产生 0x40 个符号字节,它们分布在 malloc 返回的地址单元中,后续load可以取到这些符号字节。
2.2 从状态对象看符号地址单元
在 angr 里,往 malloc 返回的地址单元里放符号内容,最简单的方式就是让程序自己跑read。但如果read不是标准调用,或者程序是从文件映射读数据,你就要手动处理。
手动往地址单元写符号值的 API 是state.memory.store和state.memory.load。下面这段代码演示在模拟状态下,如何把 8 个字节的符号值放到一个具体地址0x603018上:
import angr proj = angr.Project('./sample_x', auto_load_libs=False) state = proj.factory.entry_state() # 取 malloc 返回地址:默认具体地址,通常为 0x603000 附近 malloc_addr = 0x603018 # 把 8 个字节的符号值依次写入该地址单元 val8 = state.solver.BVS('magic_value', 64) state.memory.store(malloc_addr, val8, endness='Iend_BE') # 读出来验证 loaded = state.memory.load(malloc_addr, 8, endness='Iend_BE') print(state.solver.simplify(loaded))这里有个容易忽略的坑:如果程序是小端,store和load的endness必须一致。大多数 x86 程序是小端,所以默认你应该用'Iend_LE',而不是大端。之前我图省事不传endness,结果读出来的值与写入的符号表达式拼接顺序完全反了,求解结果自然对不上。
2.3 验证一:符号值确实进入了目标地址单元
写完store之后,不能只看打印结果,你要真正验证“这个符号值是在那个地址单元里”。最直接的办法是给符号值加一个约束,然后求解:
state.solver.add_constraints(loaded == 0xdeadbeefcafebabeULL) if state.solver.satisfiable(): print("SAT, solution:", hex(state.solver.eval(loaded))) else: print("UNSAT")如果求解器返回 SAT,说明loaded这个符号表达式确实可以等于目标常量,也就是说这个地址单元的内容完全可控。这一步验证的是:具体地址单元符号化成功。后面程序里的if分支自然能走上真实路径。
2.4 验证二:读出的符号值能参与条件分支求解
验证完单元可写,再把这一步接到程序分支上。直接跑explore:
proj = angr.Project('./sample_x', auto_load_libs=False) state = proj.factory.entry_state() simgr = proj.factory.simulation_manager(state) simgr.explore(find=0x400000 + win_offset) # win 的真实偏移按反汇编结果填默认情况下,angr 能自己处理read的符号化,也可以到达win函数地址。这说明对于“把符号内容写入 malloc 地址单元”这件事,内置机制已经帮你完成了,你不需要手动store符号值。
但是!如果你的目标程序不走 stdin,而是从内存映射文件读数据、或者直接从一个固定地址读内存,那么手动store就是必需品。很多模拟设备固件分析里,指针和地址单元全部要靠手动符号化,因为 angr 不知道设备寄存器什么时候写入。
3. 进阶一:把 malloc 返回的地址本身变成符号
3.1 通过 hook malloc 返回符号值
现在进入更硬核的部分:让 malloc 返回的地址本身是一个符号值。先看一个需要这么干的样例:
#include <stdio.h> #include <stdlib.h> int main(void) { unsigned long addr; char *buf = malloc(0x100); scanf("%lx", &addr); if (addr == (unsigned long)buf) { puts("equal"); } else if (addr == (unsigned long)buf + 0x80) { puts("half"); } return 0; }这个程序把用户输入的地址和buf地址做比较。如果 angr 使用默认 malloc 模拟,buf是一个固定值,那么addr == buf这条分支自然可以被探索,因为addr是符号输入,可以等于具体地址。但如果buf本身不确定,情况就反过来了。假设程序来自一个高度混淆的固件,真实的 malloc 地址受环境随机化影响,你需要把buf建模成符号值。
hook malloc 的写法并不复杂:
import angr class SymbolicMalloc(angr.SimProcedure): def run(self, size): # size 是符号值,但这里最好具体化 concrete_size = self.state.solver.eval(size) # 返回一个符号地址,并约束该地址指向合法的堆区间 sym_addr = self.state.solver.BVS('malloc_sym_addr', self.state.arch.bits) self.state.solver.add_constraints(sym_addr >= 0x600000) self.state.solver.add_constraints(sym_addr + concrete_size <= 0x700000) return sym_addr proj = angr.Project('./sample_y', auto_load_libs=False) proj.hook_symbol('malloc', SymbolicMalloc()) state = proj.factory.entry_state()这段代码有几个要点:
size在run里可能是一个符号表达式,因为malloc(len)中的len可能来自外部输入。先eval求出当前状态下的一个可行值,再作为约束区间右端的参考。- 符号地址需要加范围约束,否则求解器可能给你一个
0x414141414141这种完全无效的地址,后面读写内存直接报错。 - 约束区间要留足空间。如果
concrete_size是 0x100,右端不能刚好是0x700000,否则越界读写依然可能落在未映射区域。建议把区间放宽到0x700000 - concrete_size。
3.2 让符号地址参与指针运算
地址符号化之后,最直接的收益是:程序里buf + offset这种指针运算不再是一个“具体地址加一个符号”,而是“符号地址加一个符号”。这种情况下,angr 的内存模型会面临“符号地址读写”的挑战。
比如下面的分支:
if (addr == (unsigned long)buf + 0x80) { puts("half"); }buf + 0x80是符号表达式,addr也是符号表达式。angr 会把它们都转成 BitVec 表达式放到求解器里,两边都可以约束,看起来没什么问题。
但如果你在这个符号地址上读写内存,事情就麻烦了:
char *buf = malloc(0x100); char c = buf[user_index];user_index是符号值时,buf[user_index]实际上等价于load(symbolic_base + user_index)。angr 默认支持符号地址的 load,但它会把这个符号地址“具体化”成若干候选地址,再分别尝试读取。候选地址的集合如果太大,性能会骤降。
这就是为什么很多场合下,符号地址不一定是好事。你要结合具体场景决定:是符号化 malloc 返回值,还是只符号化地址单元内容。
3.3 约束符号地址指向合法堆区
刚才的 hook 代码里加了地址范围约束,但那只算“粗约束”。在实际分析中,你还需要考虑对齐问题。
一个典型坑:很多指令集要求 16 字节对齐的栈/堆访问。如果 malloc 返回符号地址没有对齐约束,后续涉及到对齐假设的指令就会产生不可满足路径。加约束的方式如下:
self.state.solver.add_constraints((sym_addr & 0xF) == 0)还有一点,符号地址最好不要每次都新建一个新的BVS,否则两个 malloc 调用之间没有任何关联,无法模拟“第二次分配位于第一次分配之后”的堆布局。可以引入一种“连续分配”约束:
self.last_malloc_addr = 0x600000 self.solver.add_constraints(sym_addr >= last_malloc_addr + 0x10)这类约束会在你的路径分析里形成一定的顺序语义,让地址模型更接近真实堆。
3.4 地址具体化策略的控制参数
地址符号化之后,你不一定希望所有内存读写都走符号地址,那样求解器压力太大。angr 提供了一组具体化策略(concretization strategies),可以精确控制哪些符号地址在什么时候被解析成具体值。
相关参数主要是state.memory.address_concretization这个列表。比如:
from angr.state_plugins.sim_memory import concretization_strategies state.memory.address_concretization = ( concretization_strategies.SimpleConcretizationStrategy(limit=4), concretization_strategies.AnyConcretizationStrategy(), )如果希望某个符号地址直接具体化为前几个可行解,可以用带limit的策略。如果希望 angr 把符号地址当作符号值保留,就用SymbolicConcretizationStrategy。
记住一个原则:符号地址参与读写的目的是扩展路径,不是为了给求解器增加负担。凡是你能通过少量约束把地址锁定的地方,尽量锁定,只在关键位置保留符号地址。
4. 进阶二:符号长度下的批量单元符号化
4.1 大小也是符号值时,内存单元批量处理
有的目标程序是这么写的:
size_t len; char *buf; scanf("%zu", &len); buf = malloc(len); read(0, buf, len);输入控制len,再控制 read 的长度。angr 默认处理这种模式时,会试图把符号长度的 read 转换成对地址单元的一串符号字节赋值。问题在于:len是符号值,你不知道具体写多少个符号字节。
这时常见的思路是,把len约束到一个有限区间,例如len <= 0x80,然后按最大可能长度符号化所有地址单元:
state.solver.add_constraints(len <= 0x80) for i in range(0x80): b = state.solver.BVS('cell_%d' % i, 8) state.memory.store(buf_addr + i, b)但这样会导致读出的数据量比实际 len 大。所以程序里如果后续用len做边界条件,这套方法仍然能跑,只是冗余符号字节会带来额外的分支。
4.2 用 state.solver.If 构造边界判断
另一个更精细的做法:不手动铺满所有单元,而是用state.solver.If构造“条件内容”。比如你只想符号化len个字节:
for i in range(0x80): cond = i < len b = state.solver.If(cond, state.solver.BVS('cell_%d' % i, 8), state.solver.BVV(0, 8)) state.memory.store(buf_addr + i, b, endness='Iend_LE')这样每个单元的内容都是If(len > i, 符号值, 0)。求解器在判定某条路径是否可行时,会自动把len和cell_i联合约束。这个方案的缺点是表达式变大,求解变慢,优点是很精确,不会引入多余的真实分支。
4.3 探索路径爆炸时如何限流
符号长度加上符号内容,很快会撞上路径爆炸。如果发现 explore 卡死,第一件事就是压缩符号维度:
- 用
state.solver.concretize(len)直接把长度烧成一个固定值再往下跑,比如依次固定为0x10、0x20、0x80,分别探索。 - 对符号数据字节,只对参与比较的相对偏移位置做符号化,其余字节用具体值
0。 - 设置
find和avoid列表,缩小模拟路径范围。
我有一个习惯:第一遍跑,先加一堆具体约束,验证逻辑通顺;第二遍再逐步放宽,观察新增可达路径。直接一上来就全部符号化的结果往往是“看起来能跑,但你等不到结果”。
5. 常见问题与避坑记录
5.1 UnsafeSymbolicAssignmentError 的三种解决套路
这是做符号地址写入时最常踩的异常。当 angr 试图在符号地址上执行store,而当前地址具体化策略无法找到一个可行解时,就会直接抛UnsafeSymbolicAssignmentError。
我实践下来有三次有效解法:
第一,给符号地址增加明确的数值范围约束。很多人只写了>= 0x600000,忘记加<= 0x700000,导致求解器可以选一个高地址区间,但该区间没有映射,store 无法完成。
第二,使用state.options.add(angr.options.CONCRETIZE)。这会把所有符号地址强制具体化,简单粗暴,适合快速验证目标分支是否存在。代价是丢失了地址相关的路径覆盖。
第三,改用符号地址的“临时具体化”。在 store 前手动求解一个候选地址,然后把这个候选地址具体化,store 完再保留原有符号约束:
candidate = state.solver.eval(sym_addr) state.memory.store(candidate, value, ...这条路适合你已经知道一个安全地址区间,但不想全局开 CONCRETIZE 的场景。
5.2 为什么求出的模型数值看起来不对
符号求解完成后,你eval某个地址或数据,经常发现结果稀奇古怪。比如你期望buf+0x18的值为0xdeadbeefcafebabe,但拿到的却是0xbabe...之类。多数时候是小端序没有对齐,多数字节访问时符号位都留了洞。
排查步骤很简单:
- 确认
load的endness和你的目标架构字节序一致。 - 确认
size到底是多少字节。如果程序读取 4 字节你却 load 8 字节,会带进来未约束的高 32 位,结果自然不稳定。 - 检查是否漏了
state.solver.add_constraints。内存单元里不只包含你主动放的符号值,还可能包含 read 产生的一大堆未约束符号字节,它们也会参与求解,干扰模型结果。
5.3 hook malloc 与 angr 自带堆模拟的冲突
有些人自定义 hook malloc 之后发现,后续的free、calloc、realloc全乱了。原因很简单:你只 hook 了malloc,其他堆函数仍然是 angr 默认的堆模拟,两套堆状态不互通。
解决思路有两种:
- 如果你只是想把 malloc 返回值变成符号,那么不要拆掉默认的 SimHeap,而是在默认 SimProcedure 的基础上包装,先调用原实现,再在返回前把地址改成符号值。
- 如果你要完全接管堆逻辑,就必须同时 hook
malloc、free、calloc、realloc等一套函数,并且自己维护一个模拟堆的符号地址分配表。
我一般推荐第二种只用在高度定制化的固件分析里,普通用户态程序保持默认就好。
5.4 性能优化:符号地址与具体地址混用
最后一个建议,也是我实际分析中最受益的一点:不要把所有 malloc 地址都符号化。你只要把关键路径上参与比较、运算的 malloc 返回地址符号化即可,其他函数内部的 malloc 完全可以交给默认堆模拟。
用一张表总结几种策略的适用情形:
| 策略 | 地址性质 | 单元内容 | 适用场景 | 主要限制 |
|---|---|---|---|---|
| 默认 malloc 模拟 | 具体 | 可符号 | 常规 stdin/file 输入 | 地址值固定,无法参与复杂分支 |
| 手动向具体地址单元写符号 | 具体 | 符号 | 模拟非标准输入源 | 需要知道地址 |
| hook malloc 返回符号地址 | 符号 | 可符号 | 地址参与运算/比较 | 求解慢,需约束合法区间 |
| CONCRETIZE 全开 | 符号→具体 | 符号 | 快速验证可达性 | 丢失地址相关路径 |
这张表基本覆盖了我后面大半年的 angr 使用场景。遇到问题先对照一下,往往能少走很多弯路。
最后再说一点个人的体会:angr 本身的 API 并不难背,真正卡住人的是“什么时候需要符号化、符号化到什么程度”。malloc 地址单元符号化这件事,核心不是写几行 hook,而是你要想清楚这个符号地址到底参与哪些运算、会影响哪些分支。想明白了,代码只是顺手的事。后面我再遇到类似堆管理逻辑分析,基本都是一上来先画清楚地址流向,再决定哪些 malloc 该符号化、哪些该保持具体,整个分析效率一下子提升了很多。