我从一个真实的折腾场景讲起:用angr做二进制分析时,分配内存的地址,默认是"具体"的——即一个确定的整数,例如0x602010。但在某些场景下,我们需要让malloc的返回值变成一个"符号表达式",也就是一个尚未确定、受约束的未知值。这个符号化的"malloc地址单元",坑了我好几天,也让我把angr的内存模型从头到尾梳理了一遍。
这篇笔记聚焦angr环境下的malloc地址符号化,也就是如何让malloc返回的指针成为符号值,以及由此引出的内存模型、指针运算、约束求解和状态爆炸问题。适合正在啃angr动态符号执行、遇到底层内存分析卡壳的读者,尤其是想搞懂"为什么别人能符号化堆指针而我不行"这类问题的人。废话不多说,直接进入正题。
1. 从"具体地址"到"符号地址":malloc模拟背后的一次模式切换
1.1 默认情况:angr把malloc当作一个"具体分配器"
angr本身是一个符号执行框架,但它对libc函数做了大量的过程模拟(SimProcedures)。其中malloc这类堆分配函数,默认走的是angr自己实现的堆模拟器,叫SimHeap。这个堆模拟器像一个小型ptmalloc,维护空闲块列表、分配块元数据,并在每次malloc时返回一个具体地址。
什么叫做"具体地址"?就是一个正常的位向量常量(BVV),比如0x602010。这个值不是你手动指定的,而是堆模拟器根据当前堆状态计算出来的。在大多数静态分析和CTF题目中,这种具体化处理完全够用:分析器不需要知道堆块的"真实"位置,只要保证两个不同malloc返回地址不重合就行。
但问题在于:具体地址意味着地址空间的确定性。如果输入数据中包含了某个偏移量,之后程序用这个偏移量去访问一个动态分配的缓冲区,那么真实的程序里这个偏移量是运行期决定的。你把这个地址固定成一个具体值,就等于提前替程序做了决定,这会丢失所有与偏移相关的路径探索空间。
1.2 SYMBOLIC_MALLOC选项:让malloc返回值变成一个BVS
angr提供了一个状态选项:angr.options.SYMBOLIC_MALLOC。启用这个选项后,malloc的返回值不再是一个具体地址,而是一个符号位向量(BVS),通常命名为heap_XXX。这个BVS不是完全无约束的,angr会把它的取值范围约束在堆空间范围内(具体地址范围的上下界),并且通过求解器在后续的路径约束中一起求解。
这才是"malloc地址单元符号化"的本质:地址被提升为一等公民的符号表达式。指针不再是固定数值,而是一个携带约束条件的未知值。对指针的赋值、算术运算、内存访问,全部变成对符号表达式的操作。
1.3 为什么需要这一个模式开关
可能有人会问:既然默认具体地址那么好用,为什么还要搞一个符号allocator?答案在于:符号执行器的核心不是"解释程序",而是"建模程序的可能性空间"。
当你分析以下构造时,具体分配器会失效:
- 指针索引依赖于输入:程序根据输入选择第几个堆对象写入数据,例如
objects[user_index]->data = x。如果对象数组的基址是具体的、每个对象大小固定,那user_index符号化后要做地址运算,你必须知道每个对象的具体地址。默认情况下angr能做到,但一旦涉及跨对象重叠、偏移越界,具体地址会很快把状态空间绑架到某一条specific分配序列上。 - 堆布局本身是漏洞利用的关键:在堆利用分析中,攻击者真正关心的是"能否让两个指针指向重叠区域"。具体堆模拟器默认保证不重叠,这就直接排除了很多利用模式。符号化malloc则允许你建立"重叠堆块"的约束,再交给求解器判断可达性。
- 自定义分配器:有些程序不用libc的malloc,而是自己在data段上切一块内存,维护自己的堆元数据。此时angr的SimHeap不生效,你只能自己构造符号指针并写入内存单元。
说白了,默认具体分配器是"分析能不能跑通"的下限保障;符号化malloc是"分析能不能建模出真实程序行为"的上限工具。
2. angr内存模型速览:符号地址到底是怎么存、怎么取的
2.1 状态对象与内存后端
在angr中,每个执行状态由SimState表示,内部挂着多个插件(plugins),例如:
state.mem:内存读写的高级接口,支持按类型访问,比如state.mem[addr].int64.resolvedstate.memory:底层存储后端,默认是SimMemory的某种实现state.heap:堆模拟器state.solver:约束求解器接口,所有符号表达式的运算、约束添加都走这里
angr的存储后端区分具体地址和符号地址。具体地址会直接落在一个类似扁平数组的抽象模型中,符号地址则会进入另一个分支:内存插件维护一张"地址区域描述符"的集合,通过解析符号表达式,确定这个符号地址可能落在哪些区域、偏移量是多少。这个解析结果是一个叫AddressSet或类似的描述对象,里面可能包含一个或多个可能的区域。
2.2 符号地址与约束求解的联动
假设我们启用了SYMBOLIC_MALLOC,并拿到一个符号指针heap_0。接着程序做了一系列指针运算,例如:
addr = heap_0 + 8因为heap_0是BVS,addr也会是一个新的符号表达式,依然是heap_0加常量8。angr不会立刻把它变成一个具体地址,而是把这个表达式延后到真正需要比较或分支时才求解。
再往下,程序执行了if (addr == 0x602018)这样的比较,angr就会向state.solver添加一条约束:heap_0 + 8 == 0x602018。求解器会检查这个约束是否可满足,并据此决定状态是否fork、走哪个分支。
看到关键了吗?符号地址的威力在于,你不必在写入内存时知道地址是多少,你只需记录它满足什么关系。所有"地址相等、地址大小、偏移指向"这些判断,最终统一变成约束求解问题。
2.3 内存读写中的符号地址解析
读取符号地址指向的内存时,angr会尝试决定这个符号地址对应哪个具体区域。如果约束已经把heap_0限定在一个窄区间内,后端可以精确解析出一个对象;如果约束不足,后端会返回一个抽象描述,表示"该地址可能落在多个对象之间",从而导致状态分裂。
举例来说,符号malloc一个大小为0x20的块,基址heap_0。接下来load(heap_0, 8),angr需要计算heap_0的所有可能取值。如果约束中含有类似heap_0 >= 0x600000 && heap_0 < 0x601000的宽泛范围,后端会为范围内所有可能的起始地址枚举候选,这种枚举虽然由angr内部优化过,但依然是状态爆炸的主要来源之一。
2.4 核心认识:地址符号化不等于无限制符号化
实践里最容易混淆的一点是:SYMBOLIC_MALLOC并不是让所有地址都变成符号值。它只影响malloc的返回值。全局变量地址、栈地址在多数情况下仍然是具体地址。没有特殊需求时,保持其他地址具体化是明智的,因为符号地址的求解成本极高,通常比符号数据高出几个数量级。
另一个容易忽略的是:angr对地址的符号化不像数据符号化那样"宽容"。内存后端对于符号地址的解析有极其严格的要求——要么能解析到确定区域,要么fork出多个状态并各行其是。如果你给符号地址一个宽泛无界的约束,轻则性能灾难,重则直接触发不可满足异常。
3. 上手实践:在angr里正确开启malloc地址符号化
3.1 三种初始化状态的方式
我们需要在创建初始状态时显式开启选项。最常用的是entry_state,另外两个是call_state和full_init_state,它们都接受add_options参数:
import angr proj = angr.Project('./demo_bin', auto_load_libs=False) state = proj.factory.entry_state( add_options={angr.options.SYMBOLIC_MALLOC} )如果你的目标程序不是从main开始,而是想从某个函数调用处开始模拟,可以用call_state:
state = proj.factory.call_state( addr=0x400700, add_options={angr.options.SYMBOLIC_MALLOC} )还有一点容易被忽略:auto_load_libs=False。默认情况下angr会尝试加载外部动态库并符号化模拟,这会干扰我们对malloc的自定义控制。做底层堆分析,几乎都应该关掉它。
3.2 验证malloc返回值是否为符号表达式
开启选项后,最好先做一个最小验证,确认我们的malloc走的是符号化分支,而不是SimHeap的具体分支。这里可以用一个最简单的方法:hook掉malloc,查看返回值的类型。
import angr import claripy proj = angr.Project('./demo_bin', auto_load_libs=False) # 找到malloc的地址 malloc_addr = proj.loader.find_symbol('malloc').rebased_addr @proj.hook(malloc_addr, length=0) def hook_malloc(state): size = state.solver.eval(state.regs.rdi) ptr = state.solver.BVS('my_malloc', state.arch.bits) state.regs.rax = ptr state.solver.add(ptr >= 0x600000) state.solver.add(ptr <= 0x601000)这段代码完全绕开了angr内置malloc,自己构造了一个符号指针,并给它加上地址区间约束。你可以把state.regs.rax理解为malloc的返回值,调用结束后程序拿到的是一个符号地址。
但注意,这个hook很粗糙——没有维护堆分配元数据,两个malloc可能返回相同地址。在真实分析中,你需要自己维护一个分配列表:
heap_base = 0x600000 heap_off = 0 def hook_malloc(state): nonlocal heap_off size = state.solver.eval(state.regs.rdi) ptr = state.solver.BVS('heap_%d' % heap_off, state.arch.bits) state.solver.add(ptr >= heap_base + heap_off) state.solver.add(ptr < heap_base + heap_off + size) heap_off += size state.regs.rax = ptr这样每个分配块不重叠,而且返回地址都是符号值。这个写法适合快速验证,实际复杂程序建议直接使用内置SYMBOLIC_MALLOC。
3.3 如何确认当前状态是否启用了符号化malloc
检查状态选项很简单:
print(angr.options.SYMBOLIC_MALLOC in state.options)如果输出True,说明malloc的SimProcedure会走符号分配逻辑。注意,在angr的不同版本中,这个选项的名目可能略有差异,但核心含义不变。老版本里可能要用state.options.add(angr.options.SYMBOLIC_MALLOC)显式加上,新版本则直接用add_options。如果你的版本中找不到这个选项,请检查angr版本并更新到较新版本。
3.4 使用内置堆时的约束回收与分支
使用内置符号malloc时,angr会在内部维护符号地址与分配大小的对应关系。每次malloc返回的符号地址都会与堆基址、堆大小建立约束。这些约束会被统一放进state.solver.constraints。
之后做条件分支时,angr自动用这些约束求解。如果你要主动判断某指针是否落在某个堆块内,可以增加额外约束并单独求解,比如:
current_range = [] for c in state.solver.constraints: current_range.append(c) # 追加一个临时约束,判断符号指针ptr是否等于0x605010 if state.solver.satisfiable(extra_constraints=[ptr == 0x605010]): print("possible")这种"只问不改"的求解方式很实用。它不会污染主约束集,适合在探索路径时临时判断某个条件是否可能成立。
4. 三种典型场景:符号化malloc的真正用武之地
4.1 场景A:符号偏移访问堆对象
假设某个程序从标准输入读取索引,然后用这个索引访问一个堆对象数组。伪代码如下:
struct item { char data[0x20]; }; struct item *items[8]; ... int idx; read(0, &idx, 4); items[idx]->data[0] = 0x41;在真实执行中,idx可以是任意值,items[idx]的寻址依赖运行时输入。如果items数组里的每个元素都是具体地址,符号执行器遇到items[idx]时就需要枚举idx的所有可能值。若idx是32位符号值,枚举空间大到不可接受。
但如果我们把每个malloc出来的item块地址符号化,并让求解器维护"块k的地址是heap_k,且块大小0x20",那么items[idx]会被自动建模成由符号索引决定的符号地址。这样求解器就不需要展开全部枚举,只需保留一般化约束,许多越界检查、数组边界问题反而更容易求解。
这里的小技巧是:分析前先梳理清楚哪些内存单元承载"指向堆对象的指针",对这些单元做load时,不要急着eval成具体值,保留符号结果。
ptr_val = state.mem[base_addr + idx * 8].uint64_t.resolved # ptr_val是一个表达式,可能是heap_0/heap_1...的组合4.2 场景B:自定义内存池分配器
很多嵌入式程序、游戏引擎、以及部分CTF题目会自己实现内存池。它们不是调用libc的malloc,而是在某块全局内存上手动移动指针,例如:
static char pool[0x1000]; static char *cur = pool; void *my_alloc(int size) { void *ret = cur; cur += size; return ret; }angr的SimHeap默认只处理libc malloc,对这种自定义allocator一无所知。如果你不想手写整套内存池模拟,一个非常有效的手段是把my_alloc的返回值符号化,同时维护cur的符号表达式。
做法是:hookmy_alloc,将返回值设为BVS('pool_alloc', arch.bits),并添加约束ret >= pool_base && ret < pool_base + 0x1000。然后每次调用后,还要让cur变量更新为cur + size。这里的cur可以用state内存中的符号表达式完成。
my_alloc_addr = 0x400800 @proj.hook(my_alloc_addr, length=0) def my_alloc_hook(state): size = state.solver.eval(state.regs.rdi) ret = state.solver.BVS('pool_alloc', state.arch.bits) state.solver.add(ret >= 0x601000) state.solver.add(ret + size <= 0x602000) state.regs.rax = ret这样你在后续分析里不用关心分配器内部逻辑,只关心"我拿到了一块未知地址的堆内存",所有验证都通过约束求解完成。
4.3 场景C:检查释放后使用与堆重叠
堆利用中常需要判断:某一块被free之后,再次分配的块是否与它重叠。默认具体堆模拟器里,这个判断是确定性的;但符号化后,你可以问求解器"是否存在一种分配序列,使得新块与旧块重叠"。
假设有两个符号地址heap_a和heap_b,大小分别是0x30和0x30。我们想知道它们是否可能重叠,可以添加约束:
overlap = claripy.And( heap_a < heap_b + 0x30, heap_b < heap_a + 0x30, ) if state.solver.satisfiable(extra_constraints=[overlap]): print("重叠可达")这种分析能力在传统具体模拟中是不存在的。符号malloc允许我们在一套状态里同时考察多种堆布局的可能性,而不必逐个fork模拟。
4.4 关于两个坑的提前警示
这三种场景中,最容易踩的坑有两个:第一个是符号地址参与memcpy等循环时,循环次数也可能变成符号值。angr对循环的处理会fork,如果malloc地址符号化后又被memcpy当作参数,状态数会迅速增加。
第二个坑是对象大小与符号地址耦合。比如你malloc了符号大小的块,那么分配器必须知道返回地址范围依赖size,可size本身又可能是符号值。这种情况下,你会遇到"符号大小的符号分配",angr的求解压力呈指数上升。建议先把size具体化(比如用solver.eval强行求一个可行值),再把地址符号化,分开处理。
5. 踩坑实录:符号指针落地时的四个典型翻车点
5.1 翻车点一:无约束符号地址导致内存后端报错
有个很典型的错误:自己hook malloc时只创建BVS,不添加任何地址范围约束。执行到store(ptr, data)时,angr会尝试解析ptr的所有可能取值。如果你没有约束,ptr理论上可以指向整个地址空间的任意位置,后端要么直接抛异常,要么构建一个极其巨大的CandidateSet,瞬间卡死。
解决办法是:符号地址必须有界。凡是创建符号指针,一律配套添加范围约束,例如堆块大小、起始基址、步长对齐等。宁可约束松一点,也不要完全无界。
5.2 翻车点二:混合具体地址和符号地址时,条件分支错乱
一个程序里同时存在具体堆块和符号堆块时,符号execution的条件分支判断要格外小心。例如:
if (ptr == global_buffer) { // branch A } else { // branch B }如果ptr是符号malloc地址,global_buffer是具体地址,那么angr会在每个分支判断时检查是否可满足。由于符号地址范围约束可能包含global_buffer,两个分支都可能可行,这个比较本身不是错误的根源,错误的根源在于约束不够精确:你把符号地址的范围设置得太宽,导致它"有机会"等于global_buffer。
处理方式永远是收紧约束,而不是试图在求解后手工裁剪路径。如果你明确知道某个symbolic指针不可能等于某个具体地址,就把这个互斥约束提前加入求解器:
state.solver.add(heap_ptr != 0x602000)5.3 翻车点三:求解器eval把符号地址变成具体值
Angr提供了state.solver.eval(),直接求一个符号表达式的一个可行解。许多初学者在处理符号指针时喜欢顺手eval,取一个具体值出来。但这其实背离了符号malloc的本意——一旦eval成具体值,后续所有基于该指针的分析都丧失了多路径能力。
一个常见的错误示例:
ptr = state.mem[0x601000].uint64_t.resolved ptr_val = state.solver.eval(ptr) # 错误:强行具体化 state.regs.rax = ptr_val正确做法是保留ptr的符号表达式,把它直接赋值给寄存器或内存单元。只有在你确定这个指针在当前状态已经不会影响后续符号路径时,才考虑eval。
5.4 翻车点四:auto_load_libs把malloc模拟覆盖掉
默认auto_load_libs=True时,angr会加载系统动态库,并尝试用符号执行模拟整个加载过程。这会让malloc的解析被外部库的复杂逻辑干扰,甚至不会走你hook的分支。排查这类问题时,你会看到malloc返回值变成一个莫名其妙的BVV,而不是你预期的符号值。
解决方法是分析顶层二进制时始终设置auto_load_libs=False。如果不得不加载某个库,也要对库中malloc相关函数做显式hook,确保所有分配都走你控制的符号逻辑。
我在一次分析某脱壳样本时就栽在这上面:malloc被自动加载的外部库解析掉,折腾了一下午,最终发现只是auto_load_libs没有关闭。经验就是——凡是跟libc内存分配较劲的项目,第一步就是把auto_load_libs关掉,再看malloc hook有没有生效。
6. 性能调优与约束策略:用最少的状态数解决最多的问题
6.1 用区域约束压缩符号指针的解空间
符号malloc的最大性能杀手是地址解空间太大。除非确有需要,不要给一个堆指针开放整个64位地址空间,而应限制在某个区域,比如:
# 限制单个堆块符号指针 state.solver.add(ptr >= 0x600000) state.solver.add(ptr + 0x40 <= 0x601000)上下界越紧,求解器解析内存目标的速度越快。如果担心漏掉跨区域访问,可以分块建立多个区域约束,但每个指针最多落到一到两个候选区域内。
6.2 按需fork:先判交集再切状态
在符号地址导致内存访问模糊时,angr会尝试对所有候选目标进行fork。避免无意义fork的方法是:在写store之前,先判断符号地址的可行区间和具体对象区间是否有交集。
比如,你知道全局数组在0x603000到0x604000,符号指针ptr可能的取值空间只有0x600000到0x601000,二者完全不相交,就没有必要让angr在store时尝试访问那个全局数组。可以通过显式约束减少无效候选:
state.solver.add(ptr < 0x603000)这种做法并不总是必要,但在大型分析中能显著降低状态数。
6.3 使用满足性检查代替枚举
很多时候,你不需要知道一个符号指针具体等于多少,你只想知道某个条件是否可能成立。这种情况下优先使用satisfiable(extra_constraints=...),而不是solver.eval枚举。
举个例子:程序在0x400abc处比较ptr == 0x604010,你就直接查这个等式是否与当前约束兼容。兼容就说明存在一个输入使它成立,路径可达;不兼容就说明这条路径永远不可能。这对漏洞可达性分析非常有用。
if state.solver.satisfiable(extra_constraints=[ptr == 0x604010]): # 可达 else: # 不可达这样可以避免具体化带来的状态爆炸,也避免引入不必要的分支探索。
7. 我的一点总结:malloc地址符号化的边界在哪
这轮学习笔记要收尾了。就我个人经验,malloc地址符号化最适合的领域是:堆布局依赖输入的程序分析、自定义内存池识别、以及需要探索多种堆块重叠可能性的漏洞研究。它最大的价值是把"地址"从固定的数字变成可求解的约束条件,让分析器具备模拟真实程序布局多样性的能力。
但符号化malloc不是银弹。如果目标程序本身堆操作不复杂、只是简单的malloc后写数据再free,完全不建议符号化。具体堆模拟器在这种场景下更快、更稳、结果也更直观。符号malloc的成本高在约束求解和状态爆炸,收益高在路径覆盖与堆布局不确定性建模,你需要根据具体分析目标权衡。
从实践角度看,我现在会先跑一遍默认具体分配的分析,看结果是否满足需求。只有遇到具体的堆布局问题、指针索引问题或自定义分配器问题时,才打开SYMBOLIC_MALLOC,并且第一时间收紧指针的地址范围约束。
最后分享一个调试技巧:当你怀疑符号malloc没有生效时,不要急着看整个程序输出,直接在任意路径点打印if 'BVS' in str(state.regs.rax),检查关键寄存器或内存单元是否为符号表达式。我的经验里,80%的"malloc符号化失败"问题都出在auto_load_libs没关、hook没命中、或者选项没传对这三件事上,排查顺序从这三点开始,能少走很多弯路。