ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

malloc地址单元符号化:angr符号执行的关键技巧

malloc地址单元符号化:angr符号执行的关键技巧

1. 问题起源:为什么单独把“malloc地址单元符号化”拎出来讲

写这一篇纯属被坑出来的。前面几篇笔记里,我一直在处理“标准输入符号化”“argv符号化”这类常规操作,当时觉得 angr 已经很顺手了,直到遇到一个程序,逻辑看起来非常简单,就是读入一段数据,经过一个校验函数,对了就打印 flag。结果 angr 跑下来,路径倒是探索了一堆,但求解器死活给不出一个合法的输入。最后排查了半天,问题就出在 malloc 分配的地址内容没有被符号化上。

这里先解释一下背景。符号执行的本质,是把输入变成符号变量,让程序带着符号跑,碰到分支就把条件收集起来,最后交给约束求解器去解。但这里的“输入”是有范围的,你符号化的是 stdin、argv 还是某个全局缓冲区,angr 只会把明确标成 BVS(BitVectorSymbolic)的那部分内存当成“可变”的,其余内存内容 angr 会按具体值处理。malloc 出来的堆区,怎么处理就很微妙了。它不像栈上声明的数组,有明确的地址和大小,malloc 返回的地址在符号执行里是动态生成的,angr 默认不知道你这个地址指向的内存区域代表什么含义,更不会主动给这块内存灌入符号变量。结果就是:程序从堆区读数据、做比较,angr 真以为堆里那些字节是“固定值”,于是产生了大量不可能满足的条件分支。

这也就引出了本篇笔记要讲的方案:如何让 malloc 分配出来的那块地址空间,其内容也成为符号变量,从而让符号执行真正覆盖到程序的核心校验逻辑。

这篇笔记适合两类人看。一类是刚接触 angr、只跑通过几道入门题,准备挑战真实程序的人,malloc 符号化是绕不过去的一关。另一类是已经会用 angr 但经常遇到“求解无解”“分支全走死”这类问题、排查无头绪的人。本篇会从原理讲到实操,最后给出排查技巧,核心是一个可复现的 Demo,万金油思路。

2. 能力铺垫:angr 符号执行里“内存状态”的本质

2.1 从符号变量到约束求解,angr 到底在做什么

angr 跑一个程序时,维护的是一个“符号状态”。这个状态可以理解成一张大表,记录了当前执行到的指令地址、寄存器的值、内存中每个地址的内容。这些“值”有两种形态:一种是确定的具体数值,比如0x401000;另一种是符号变量,比如一个 32 位的BVS("input_0", 32),它不是一个固定值,而是一个“待定值”。

angr 在模拟执行过程中,遇到分支指令(比如cmp; jz)时,会同时探索两个方向,每条路径上都会记录一个路径条件。比如某条路径可能记录了input_0 > 0x41,另一条路径可能记录了input_0 <= 0x41。到最后你想让某一条路径走到 “success” 分支,就把这条路径上所有的路径条件收集出来,连同你自己手动添加的其他约束,一起交给底层求解器(z3)去解。z3 会返回一个具体的数值,使得这些条件全部成立。这个数值,就是让程序走进目标分支的输入。

这个模型看似简单,但注意一个关键点:约束求解器只能基于符号变量去求解。如果一个值不是符号变量,而是一个具体常量,那 angr 是不可能“反过来算”出一个输入去改变它的。说得夸张一点,如果你压根没把一个内存位置标记成符号,那 angr 眼里它就永远是某个固定值,哪怕程序逻辑上它初始依赖 stdin。

所以 malloc 地址单元符号化,本质上就是在回答一个问题:堆上这块内存的“值”能不能随输入变化?如果能,angr 才有机会通过路径条件反推出输入;如果不能,那 angr 就只能看见一个死板的具体值,路径探索也戛然而止。

2.2 malloc 在符号执行里是“黑盒”,问题就出在这

真实程序里 malloc 可不是拿来看的。常见模式是,程序从标准输入读固定字节数,存到 malloc 得到的缓冲区里,然后对这个缓冲区做字节变换、查表、比较。比如下面这类简化逻辑:

char *buf = malloc(10); read(0, buf, 10); if (buf[0] == 'f' && buf[1] == 'l' && buf[2] == 'a' && buf[3] == 'g') { puts("correct"); }

如果是在真实系统上跑,read会从 stdin 往 buf 指向的地址填充数据。但在 angr 里,问题出现了:angr 默认的read系统调用如果遇到未知的文件描述符,通常是提前处理后返回确定值或触发异常(具体行为取决于 angr 对read的模拟方式)。即使你通过SimFile或者 hook 方式把 stdin 提供了,也存在一个更深层的坑:angr 的read模拟是写在系统调用层面的,它的“目标地址”是 malloc 返回的那个地址。而 malloc 在 angr 的 SimHeap 里大多被 hook 成返回某个地址,但这个地址指向的内存并没有被初始化为符号。

于是你在状态里做了state.solver.BVS("stdin", 10*8),以为万事大吉,实际上 angr 执行read时,确实把符号变量“拷贝”到了 malloc 地址上,但前提是你正确配置了 stdin 的文件描述符,并且 angr 把这次 read 识别为读符号数据。如果程序不是走read,而是先从 stdin 读到一个临时栈缓冲,再手动memcpy到堆区,那 angr 在 memcpy 时是逐字节复制符号变量的,这种情况反而没事。

真正出问题的场景,是程序校验逻辑直接作用在 malloc 内容上,但 malloc 的内容来源不是 angr 认识的符号通道,而是来自程序内部的某种初始化逻辑。举个典型例子:程序从 stdin 读入一串十六进制字符串,strtol转换成整数,再当作种子去初始化一个伪随机序列,最后用这个序列去校验堆区的某个数据。strtol、srand、rand这些函数在 angr 里的模拟,如果不做额外处理,会产生具体数值,所以堆区数据就不可能被符号化。这种情况下,你必须跳过“生成链条”,直接在 malloc 地址单元上做符号化——人为声明“这块内存的内容是符号变量”。

2.3 为什么“符号化地址”不等于“符号化内容”

这里有必要区分两个概念。一个是把 malloc 返回的指针值符号化,一个是把 malloc 指向的内存内容符号化。很多入门教程讲“malloc 符号化”时,说的是前者:让 malloc 的返回值成为一个符号变量,这样能让程序走不同的堆分配分支,比如检查 malloc 是否失败。字符串比较常见的一个坑,是在分析带堆喷的程序时,需要让 malloc 返回不同的地址来探索堆布局分支。

但本文讨论的“malloc 地址单元符号化”,指的是后者:malloc 返回的地址是确定的(或可计算的),但该地址上的内存单元内容是符号化的。打个比方,你和朋友约好在一家餐厅见面,地址是固定的;但你不知道朋友在那家餐厅坐哪一桌(内容),你想通过一定规则反推出来。符号化内容就是给“坐哪一桌”这个变量建立数学模型,让求解器去算。

两者在 angr 里的具体操作位置也不同:符号化返回值需要 hook malloc 本身;符号化地址内容需要在 malloc 之后拿到地址,再用 store 操作往这个地址写入 BVS。理解了这一点,后面看别人代码就不容易晕。

3. 方案选型:四种“让 malloc 区域符号化”的方式对比

3.1 方式一:hook malloc,拿到返回值后再 store BVS

最直接的做法:通过project.hook_symbol("malloc", hook_func)劫持 malloc 符号,在 hook 函数里先返回一块地址(通常用state.heap相关接口或直接返回一个固定地址),然后立刻在这个地址上 store 一个 BVS。这样,程序后续对这块地址的读取,就读到了符号变量。

import angr import claripy def malloc_hook(state): # 手动指定一块地址,避开已有内存区域 addr = 0x5000000 # 只是一个示例地址,实际要选堆区 size = 0x40 # 让 malloc 返回这个地址 state.regs.rax = addr # 在这块区域写入 64 字节的符号值 sym = claripy.BVS("heap_data", 8 * size) state.memory.store(addr, sym)

这种方式的好处是直观,你能控制全流程。坏处也很明显:一个程序里不止一处调用 malloc,如果你全都 hook 到一个统一处理,所有 malloc 区域内容都一样(同一个 BVS),逻辑就乱了。所以这种方式更适合目标明确、malloc 调用点不多的情况,一般实验里也就一两个关键 malloc。

3.2 方式二:在输入符号化阶段,同步填充堆区域

不少初学者看到“malloc 地址单元符号化”第一反应是:我先不碰 malloc,直接把符号变量写到那个地址不就行了?比如你知道 malloc 返回地址的规律(像 glibc 的 fastbin 在固定偏移),就硬编码一个地址,在initial_state里storeBVS。这种方式在很多 CTF 题目里很有效,因为题目程序简单,甚至不校验 malloc 的返回值。

不过这种方式有个前提:你必须知道 malloc 返回地址到底是多少。angr 里 malloc 的默认 implementation 通常返回一块固定的模拟堆地址,每次运行基本一致。但不同版本的 angr,SimHeap 的地址策略不一样,你硬编码的地址可能会踩到 angr 给栈或全局区域预留的空间。稳妥起见,可以在 setup 阶段 hook 住 malloc,打印返回地址,再决定往哪填充。

3.3 方式三:利用 angr 的 SimHeap 自定义分配器

这是“正统”做法。angr 内部有SimHeap类体系,比如SimHeapBrk、SimHeapLibc。通过自定义一个继承SimHeap的分配器,重写_allocate方法,在分配时自动记录所有 malloc 返回的地址,并在返回前将地址写入符号变量。这样程序里所有 malloc 出来的区域,内容都是“全新符号”,每个 malloc 对应一个独立 BVS,互不干扰。

class SymHeap(angr.state_plugins.heap.SimHeapLibc): def _allocate(self, size, **kwargs): addr = super()._allocate(size, **kwargs) sym = claripy.BVS(f"heap_{addr:x}", 8 * size) self.state.memory.store(addr, sym) return addr # 使用时 state.heap = SymHeap()

这种方式约束力最强,但复杂度也高。还有坑:过度的自动符号化会让路径条件爆炸。每个 malloc 区域都有一整块 BVS,一旦程序对这块区域做逐字节比较,angr 会为每个字节引入分叉,状态数呈指数级增长。所以这种方式适合研究目的,不适合直接对真实大型程序用。

3.4 方式四:高阶玩法——用 concretization 和约束定向符号化

前面几种叫“宽泛符号化”,还有更精细的定向符号化:不让整个 malloc 区域一开始就是 BVS,而是让它先保持具体值,等 angr 执行到某个比较指令时,发现该分支不可满足,再“回头”把相关内存地址符号化。angr 确实存在某种程度上“从失败中学习”的机制——如果你在探索时设置了save_unconstrained,常见的是保存未约束状态,而不是自动回填符号。这里说的“回头符号化”,通常要你手动根据失败路径的特征,在特定地址上补充约束。

这种方式我已经一年没在实战中用了,因为多数情况下方式一和方式二够用。但了解这个概念有助于理解 angr 的局限:符号执行不是魔法,它要在“尽可能多地探索”和“避免状态爆炸”之间取舍。手动符号化 malloc 区域,本质上就是一种人工引导这种取舍的行为。

3.5 选型建议:按判定条件选,不按喜好选

说实话,我在自己的实践里没用过一种包打天下的方案。我建议按下面这个标准来选:

场景特征推荐方式理由
程序 malloc 调用点少,关键区域只有一处方式一代码短,好控制,调试直观
CTF 题目、程序结构简单且 malloc 地址稳定方式二写起来最快,能跑就行
想系统分析某类程序,需要让所有堆区域都可变方式三自动化程度最高,但要防状态爆炸
求解出现 unsat 或路径走死,需要精确定位方式四(实际是排查+局部符号化)灵活,但依赖经验判断

本篇下面的实操,用方式一做演示,因为最能体现“malloc 地址单元符号化”的思路:先拿到地址,再填充符号。把这一步走通,其他方式就是在它的基础上做变通。

4. 实操演示:从零跑通“malloc 内容符号化”完整流程

4.1 准备实验环境与模拟程序

首先准备好 angr 环境。我用的版本是 angr 9.2,往下的 9.0 和往上的 10.x 在 API 上有细微差别,但核心思路一致。安装就是常规的pip install angr,需要注意的是 angr 依赖的 z3-solver 版本可能跟系统其他包冲突,建议建虚拟环境,避免污染系统 Python。

然后是模拟程序。本文要分析的 Demo 用 C 语言写成,为了简化后面解释原理,我刻意写得很“人工”:

#include <stdio.h> #include <stdlib.h> #include <string.h> int check(char *buf, int len) { if (buf[0] * buf[1] == 0x861) { if (buf[2] + buf[3] == 0xcb) { if (buf[4] ^ buf[5] == 0x5c) { return 1; } } } return 0; } int main(int argc, char **argv) { char *heap_buf = (char *)malloc(0x20); // 模拟从某处填充数据,关键是在符号执行中这块内容没有被正常符号化 memcpy(heap_buf, "ABCDEF", 6); if (check(heap_buf, 6)) { puts("ok"); } else { puts("bad"); } return 0; }

编译:gcc -o demo demo.c -no-pie -fno-stack-protector。这里关掉 PIE 不是为了别的,只是后面分析时地址固定,方便说明问题。真实复现时你可以保留 PIE,angr 也支持。

这个 Demo 里,check函数对 heap_buf 的内容做了几组算术校验。如果按常规思路直接跑,会看到 angr 走到check之后,两个分支都不可满足,因为内存里是固定的"ABCDEF",算不出满足条件的解法。这正是我们要解决的。

4.2 常规分析脚本与失败现象记录

先写一个最常规的 angr 脚本,看看失败长什么样:

import angr def main(): proj = angr.Project("./demo", auto_load_libs=False) state = proj.factory.entry_state() sm = proj.factory.simulation_manager(state) sm.explore(find=0x4006a4, avoid=0x4006ad) # 目标是 "ok" 分支 if sm.found: print("Found!") print(sm.found[0].posix.dumps(0)) else: print("No found") # 看看活跃状态情况 print("active:", len(sm.active)) for st in sm.active[:3]: print(st.solver.constraints[-3:]) if __name__ == "__main__": main()

跑一下,结果大概率是No found。这就说明内存内容确确实实没有被符号化,程序执行到比较时就没有合法路径了。只用看路径活跃数,会发现所有状态都是 unsatisfiable(在sm.active里检查state.solver.satisfiable()会返回 False)。

这里要专门提醒一下:在 angr 里,一个状态“活着”不代表它能继续往后执行。很多人在sm.explore没找到结果时,就打印sm.active里的状态数,发现数量不少,就觉得是不是其他 bug。实际上,这些 active 状态很可能全是 unsat,后续指令执行全是死胡同。所以排查时第一件事就是检查状态的可满足性。

4.3 方案落地:hook malloc 并符号化返回地址指向的内容

现在按方式一改造。先 hookmalloc符号,让 malloc 返回一个我们指定的地址,并在这个地址上写入 BVS。angr 的hook_symbol接受一个符号名和 hook 函数。hook 函数的输入是state,你可以在里面修改寄存器、内存、求解器约束。

import angr import claripy def setup_malloc_sym(state): # 选一个地址作为“堆区”,注意避开已知区域 fake_heap = 0x600000 size = 0x20 sym_data = claripy.BVS("malloc_0", 8 * size) state.memory.store(fake_heap, sym_data) # malloc 的返回结果放在 rax state.regs.rax = fake_heap

然后把 hook 挂到符号上。angr 里hook_symbol的第一个参数是字符串,对应程序里malloc在动态符号表中的名字。

def main(): proj = angr.Project("./demo", auto_load_libs=False) proj.hook_symbol("malloc", setup_malloc_sym, length=0) # length 为 0 表示完全替换

写完这个 hook 之后,重新用同样的 explore 跑,就能在found里看到状态了。这时候可以 dump 一下标准输入(如果你程序里有读入),或者直接查看malloc_0这个符号变量的值:

found_state = sm.found[0] solver = found_state.solver solution = solver.eval(claripy.BVS("malloc_0", 8 * 6), cast_to=bytes) print(solution)

如果不出意外,你得到的 bytes 就是满足check条件的“目标值”,比如b'KI\xe5TZ'之类。验证方式很简单:用这个字节串替换 Demo 里 memcpy 的数据,重新编译,跑一下发现打印ok。

这一步的关键点在于顺序:必须先 store BVS 到地址,再让 malloc 返回地址。如果反过来,先设置 rax 再 store,store 的地址还是我们自己指定的,理论上没区别;但如果你 store 的地址依赖 rax 的内容,那顺序就很重要。另外,store 的 size 要跟后面读取的长度匹配,只 store 6 字节也行;但为了稳妥,我会按整个分配大小 store 一整块 BVS,后面读取哪一段都能命中。

4.4 完整脚本与边界参数说明

下面给一个完整的脚本,可以直接替换地址、符号名跑别的程序用:

import angr import claripy fake_heap_base = 0x200000 malloc_size = 0x100 def malloc_hook(state): # 可以在这里打印调用栈或者分配大小,便于调试 # state.inspect 相关用法可以拿到调用参数,这里略过 addr = fake_heap_base sym = claripy.BVS("heap_0", 8 * malloc_size) state.memory.store(addr, sym) state.regs.rax = addr def main(): proj = angr.Project("./demo", auto_load_libs=False) proj.hook_symbol("malloc", malloc_hook, length=0) state = proj.factory.entry_state() sm = proj.factory.simulation_manager(state) sm.explore(find=0x4006a4, avoid=0x4006ad) if sm.found: st = sm.found[0] # 解析整个堆区域的具体解 heap_bytes = st.solver.eval(claripy.BVS("heap_0", 8 * 6), cast_to=bytes) print("Found, heap bytes:", heap_bytes) else: print("No found") for i, st in enumerate(sm.active[:5]): print(f"active[{i}] satisfiable:", st.solver.satisfiable()) if __name__ == "__main__": main()

说几个容易踩的坑。第一,fake_heap_base一定要选一个 angr 不会主动使用的地址。前面我用 0x600000,对大多数小型二进制没问题;但如果你程序本身加载地址就在这个范围,就要避开。可以用state.memory._get_region或者干脆打印state.regs.rsp、加载基地址后估算。第二,malloc_size建议取你实际需要大小的两倍以上,留余量。第三,hook_symbol的length参数,如果不传,angr 默认会以符号对应的函数长度做替换,容易出问题;hook 我们自己的纯 Python 函数时,length=0更安全。第四,如果你的二进制是静态链接的,可能没有malloc动态符号,hookmalloc会失败。处理方式是 hook 地址:先通过proj.loader.main_object.get_symbol("malloc")拿到地址,或者直接对proj.loader的 memory 地图,找到 malloc 所在的 PLT 入口,用proj.hook(addr, hook_func)。

4.5 设计点详解:为什么这里要“同时符号化整个 size 块”

很多人在成功跑出一次结果后,会有个疑问:为什么我要符号化整个 0x100 字节,而不是只在 hook 到的 malloc size(这里是 0x20)对应的空间上做符号化?

这有两个层面的原因。一方面,符号化整个保留区域的成本并不高。angr 的 BVS 在约束求解时只对实际参与比较的字节产生约束,那些没有被读取的字节不会影响求解复杂度。你把整个区域标成符号,相当于为后续可能发生的越界读取或者各种偏移访问留了后路。另一方面,真实程序里,malloc 后面往往还跟着realloc、memcpy、strlen等各种操作,你无法预测程序会读取哪一段偏移。动态规划一步到位,后续就不用反复回头看。

如果担心状态爆炸,可以只符号化实际参与校验的那几个字节。这需要你先摸清程序逻辑,指向性更强。操作方式是一样的,把 BVS 长度设成目标字节数,store 到对应偏移即可。自由度在你自己手上。

5. 实操过程与核心环节实现:从一个更真实的例子看符号化代码设计

5.1 新案例:程序接收字符串后存堆、校验,且带循环

上面 Demo 非常简单,主要是验证思路。现在换一个稍微真实一点的例子。假设程序从 argv[1] 接收字符串,长度限制 0x20,把它拷贝到 malloc 分配的缓冲区里,随后在一个循环中逐字节累加异或校验,最后比较结果。直接对 argv 符号化很简单,但问题来了:程序把 argv 字符串拷贝到 malloc 区域后,如果 angr 正确识别了 memcpy,该区域的值仍是符号变量。关键难点在于循环结构的处理。

循环在符号执行里容易造成路径爆炸,但如果校验逻辑是“无分支”的循环(比如只是累加、异或而不根据内存内容跳转),angr 的执行路径不会分叉,只是状态会包含大量循环变量。不要被“循环”两个字吓到,angr 并不是盲人骑瞎马,它会管理路径的。对无分支循环,执行次数就算成百上千次也完全没问题。真正麻烦的是循环内依赖内容的条件跳转。

这种情况下,malloc 地址单元符号化能帮你把循环条件变成符号约束,而不是把条件里出现的每个具体值都固定死。比如程序里有if (buf[i] == 'a')这样的判断,angr 在 i 未知时会尝试所有可能。如果你提前把整个 malloc 区域符号化了,angr 就能正确处理这种分支,探索所有可能字符值。

实战里,对这类循环条件,一个非常有效的技巧是“约束内生符号化”:你在 hook 的 malloc 里不光 store BVS,还可以主动 store 几个额外的约束。比如你知道校验一定要求某个字节是字母,就可以直接加state.solver.add(buf[i] >= 'A'),减少无用分支。这类约束越早加,状态精简越明显。

5.2 结合“查找目标地址”与“避免地址”的工程化脚本

实战中你一旦开始符号化 malloc,就不能只关注找目标那一支。很多程序校验失败的分支会调用 exit 或者 abort,这些地址如果不写进avoid,angr 会为了探索它们消耗大量资源。我给一个稍微工程化的脚本框架:

def run_analysis(proj, start_addr=None, find_addrs=[], avoid_addrs=[]): if start_addr is None: state = proj.factory.entry_state() else: state = proj.factory.blank_state(addr=start_addr) # 初始化符号化堆 fake_heap_base = 0x200000 sym_size = 0x200 state.memory.store(fake_heap_base, claripy.BVS("heap_symbolic", 8 * sym_size)) # 探索 sm = proj.factory.simulation_manager(state) sm.use_technique(angr.exploration_techniques.DFS()) # 深度优先,找路径失败后快速回溯 sm.explore(find=find_addrs, avoid=avoid_addrs) if sm.found: for st in sm.found: # 按需提取 return st return None

这个框架把 malloc 符号化的动作和执行解耦了,你可以在任意起点状态调用。注意我用的是DFS而不是默认的BFS。找 target 时深度优先往往更快,但如果你希望覆盖更全面的路径,BFS 更稳。实际场景中我会根据程序复杂度切换:小而精的程序用 DFS,大而杂的程序用 BFS。

另外,blank_state配合start_addr是处理复杂反调试、自修改代码的好姿势。你可以先手动分析程序逻辑,直接跳到某个关键函数入口,把上下文(寄存器、栈)设置好后,再符号化堆区开始探索。

5.3 参数计算:符号化范围与约束条件怎么写更经济

这个点我觉得很有必要详细展开,因为很多脚本卡死,不是符号化太少,而是符号化太多、约束太宽造成的。

假设程序有 10 个 malloc 调用点,每个分配 0x100 字节。如果你统一 hook,对每个 malloc 区域都写入一整块全新的 BVS,那 angr 需要同时跟踪的可能符号变量就是 10 × 0x100 × 8 字节 = 12800 个 bit。约束求解器面对这些符号,光维护可达性关系就可能内存爆炸。所以我给自己定了三条规矩:

  1. 只符号化必要地址。如果程序只校验 malloc 区域的前 16 字节,那 BVS 长度就设成 16 字节,甚至更小,别贪多。
  2. 同一时间只让少数几个 malloc 区域“活着”。一个技巧是:判断 malloc 的调用点,只有当它是关键位置(比如某个函数里的 malloc)才写入符号;其他 malloc 返回具体地址,内容不管。
  3. 尽早加约束。如果你从逆向分析或动态调试已经知道输入字节属于可打印字符,那就提前 add 约束,不要等求解器自己去探索 256 种可能。

用生活例子来说:你让一个快递员送包裹给某个神秘收件人,只给了他“地址在某某小区”这个信息,他要跑遍整个小区,你得让他跑多久?但如果你提前说“收件人姓王”,他直接去查门牌系统,效率完全不一样。符号执行里的约束约等于这个“提前信息”。

判断调用点的姿势:在 hook 函数里用state.inspect的 call 事件拿到调用返回地址。注意 hook 时length=0会完全替换函数,你可能拿不到原始调用参数;更好的办法是用state.inspect在函数入口处监听寄存器参数:

def malloc_hook(state): size_arg = state.regs.rdi # System V 下第一个参数在 rdi # 你要分析的那个关键 malloc 很可能 size 是一个特定常量或范围 if state.solver.eval(size_arg) > 0x100: # 粗略筛一下 addr = fake_heap_base sym = claripy.BVS("key_heap", 8 * state.solver.eval(size_arg)) state.memory.store(addr, sym) state.regs.rax = addr else: state.regs.rax = fake_heap_base + 0x1000 # 其他 malloc 返回另一个地址

不过要注意,state.solver.eval(size_arg)如果你不去约束它,它可能返回任意值。这个例子只是演示思路,真正使用时建议先约束size_arg或者直接用state.solver.min、max判断范围。更稳妥的做法是不依赖 size,直接在调用点返回地址上做判断,这需要你从反汇编结果里查到 malloc 的调用方地址。

5.4 现场问题一:符号化之后求解结果不对,如何定位

按上面的脚本执行,你可能成功 found 了,但拿到的字节串塞回程序却不对。这种问题一般有三个来源。

第一个来源是约束没加全。有些校验逻辑不只有一层比较,可能是算术运算后再比较,或者有可能通过查表实现。如果你的约束只覆盖了表层判断,求解器给出的解可能只满足表层条件,内层条件被绕过了。这时候需要结合反汇编确认整个校验链有多少步。

第二个来源是符号化地址选错了。你符号化的是 malloc 返回的地址,但程序后面可能把数据拷贝到了另一块地址,或者 malloc 返回的地址被某个指针变量改动过。用 angr 的state.memory.load检查那个地址的内容,就能发现是不是符号变量。很多次我发现符号变量在,但程序读取的来源却是一个别的地方,因为程序里有个中间变量保存了 malloc 结果,后来又改了这个变量。

第三个来源是内存模型默认的字节序问题。x86 用的是小端序,当你把整个 8 字节的 BVS store 到地址上,后续如果程序按字节读取,没问题;但如果程序把这块区域当成一个 uint64 来比较,小端序会把 BVS 的低字节放在低地址,与常规直觉相反。解决方式是统一用endness=state.arch.memory_endness或者显式使用state.memory.store(addr, sym, endness='Iend_BE'),具体按你程序里的读取方式调整。

5.5 现场问题二:符号化后路径爆炸,卡在循环出不来

另一个高频问题是,符号化成功后 angr 确实“活”了,但它非常兴奋,把每条路径都探索了个遍,尤其是程序中存在循环结构时,几秒钟就爆出几百万状态。这种情况下,不要慌,第一步先看是不是循环条件依赖了符号内容。比如for (i = 0; buf[i] != 0; i++),一旦 buf 被符号化,这个循环条件就变成了一个探索点:buf[i] 是否等于 0?angr 会同时探索“等于 0”和“不等于 0”的分支,然后继续执行更深层的逻辑。

对策有三个方向。第一,手动约束 buf[i] 的取值范围,比如可打印字符,减少穷举空间。第二,限制路径深度或使用veritesting技术,它能把部分循环条件合并。angr 的Veritestingexploration technique 是对付这类情况的常用工具,虽然原理复杂,但调用很简单:

sm = proj.factory.simulation_manager(state) sm.use_technique(angr.exploration_techniques.Veritesting()) sm.explore(find=..., avoid=...)

第三,直接对循环结构下手,用angr的SimProcedure替换循环函数。如果你的循环可以抽象成一个简单的数学函数,直接 hook 循环入口,计算完结果后放在指定内存里,能大幅加快分析。不过这需要你对目标代码结构有足够理解,属于高级玩法。

我自己实战中,70% 的情况用约束+DFS 能解决,20% 情况用 veritesting 解决,剩下 10% 只能换思路:放弃完全符号化,改用“混合符号执行”,把部分已知的具体值喷到堆区,只符号化真正未知的部分。

6. 常见问题排查与避坑指南

6.1 现象对照:看到这些情况,优先怀疑 malloc 符号化问题

现象可能原因排查动作
sm.active全是 unsat,没有 found符号化范围没覆盖关键内存,或约束过强打印活跃状态约束,检查是否包含 address 对应的内存内容
explore 卡死,内存暴涨符号化范围过大,循环条件生成海量状态收缩符号化大小,添加取值范围约束
found 了但塞回程序不对约束缺失或符号化地址/字节序问题反汇编比较链,逐步验证
malloc 返回值不对hook 方式错误或 Return address 被覆盖用state.inspect监听返回值,同步检查寄存器
符号化后无法执行到malloc后续可能 malloc 内部还有分支或间接跳转检查 hook 是否完全替换了原实现,length=0确认

这个表既是我自己排查的索引,也是写给后面看笔记的自己的速查卡。不少问题看起来五花八门,根源指向同一个方向:符号变量的位置、范围、约束与真实程序逻辑不对齐。

6.2 遇到 unsat 时如何拆解

不少同学看到unsat就以为 angr 坏了。这里有个很重要的调试方法:把活跃状态的约束打印出来,逐个检查。

for i, st in enumerate(sm.active[:5]): print("State", i, "satisfiable:", st.solver.satisfiable()) if not st.solver.satisfiable(): for idx, c in enumerate(st.solver.constraints): print(" Constraint", idx, ":", c)

逐个看约束列表,你会发现很多 unsat 是因为你手动 add 了范围约束,比如buf[0] >= 'A'和buf[0] <= 'Z',但程序本身的比较条件要求buf[0] == '-',这就产生矛盾。如果是这种情况,说明你的全局约束跟程序固有逻辑冲突,要放宽手动约束。另一个常见原因是符号区域里有多个不同 BVS,程序把它们做了乘法或除法操作,导致某些非线性的算术关系不可满足。z3 对非线性算术(乘法、除法)的支持并不好,遇到就可能直接抛出 unsat 或返回 unknown。这种情况的解法是尽量在源头把符号变量拆分,避免一次符号化一整块后让程序做整体乘法校验。

6.3 hook malloc 时被递归调用或被静态链接坑

hook 符号碰到“递归”问题的时候不多,但碰到一次就很折磨。如果你的 hook 函数内部又调用了state.memory.store,而某个后续指令再次调用 malloc,你没有在 hook 里做区分处理,就会导致内存被反复覆盖。比如程序在一个循环里 malloc 三次,每次 malloc 都返回同一个 fake_heap_base,那第二次 malloc 会把第一次符号化区域冲掉。解决方法是维护一个分配计数,每次 store 一个不同地址。

静态链接的情况下,hook_symbol("malloc")往往静默失效。我排查这类问题时,就用proj.loader.main_object.symbols看有没有 malloc 符号。没有就改用 hook 地址方式:从 IDA 或 Ghidra 里找到 malloc 函数的起始地址(通常在 .text 段里),直接proj.hook(addr, my_malloc_hook)。要注意地址在 PIE 开启时是相对偏移,angr 内部处理加载基址时会换算,你只要确认地址与 IDA 里显示的静态地址一致就行。

6.4 关于 angr 版本差异的提醒

我一直强调这一点,因为不同版本的 angr API 变化太大。老版本 7.x 和现在 9.x/10.x,在sim_manager、hook_symbol、BVS的位置上都有改动。我笔记里的代码基于 9.2,如果你用的是其他版本,跑不通时先看 API 变化,其次才怀疑分析思路。尤其是 10.x 中SimState的插件体系和state.memory.store的默认参数有调整,BVS 包导入也从claripy.BVS迁移到state.solver.BVS这类方式。版本变了,但“malloc 地址单元符号化”的思想不变:确定地址、写入符号、约束求解。

7. 一点个人经验收尾

我在这篇笔记里写的方法,说白了就是一套“给 angr 补内存上下文”的思路。malloc 地址单元符号化只是这套思路里最常见的一种落地形式。实际分析中,很多东西都能人为符号化:栈上的未初始化变量、文件读入的缓冲区、甚至全局对象内部的某个字段。你只要记住一个原则:凡是你希望求解器能“推算”出来的值,就必须让它成为一个符号变量,并且最好提前加上已知约束。这样想,后面遇到各种奇奇怪怪的程序,思路就不会乱。

最后给一个新手的实用建议:如果你刚开始学 angr,不要一上来就研究各种高深技巧。先把我这篇笔记里的 Demo 完整跑通,然后试着改几个参数,比如改 malloc 返回地址、改符号化大小、加一个取值范围约束,看求解结果怎么变。等你亲手撞过一次 unsat、亲手把约束从矛盾里解放出来,你对符号执行的直觉会上一个台阶。这个领域没有捷径,但每一次“哇,原来是这里”的瞬间,都会让你离真正的约定求解玩家更近一步。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进