ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

angr 中 malloc 地址与地址单元符号化:堆内存路径探索实战

angr 中 malloc 地址与地址单元符号化:堆内存路径探索实战 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指向的内存单元取出buf0x18处的 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_libsFalse) state proj.factory.entry_state() # 取 malloc 返回地址默认具体地址通常为 0x603000 附近 malloc_addr 0x603018 # 把 8 个字节的符号值依次写入该地址单元 val8 state.solver.BVS(magic_value, 64) state.memory.store(malloc_addr, val8, endnessIend_BE) # 读出来验证 loaded state.memory.load(malloc_addr, 8, endnessIend_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 验证二读出的符号值能参与条件分支求解验证完单元可写再把这一步接到程序分支上。直接跑exploreproj angr.Project(./sample_x, auto_load_libsFalse) state proj.factory.entry_state() simgr proj.factory.simulation_manager(state) simgr.explore(find0x400000 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_libsFalse) 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(limit4), 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, endnessIend_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某个地址或数据经常发现结果稀奇古怪。比如你期望buf0x18的值为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 的基础上包装先调用原实现再在返回前把地址改成符号值。如果你要完全接管堆逻辑就必须同时 hookmalloc、free、calloc、realloc等一套函数并且自己维护一个模拟堆的符号地址分配表。我一般推荐第二种只用在高度定制化的固件分析里普通用户态程序保持默认就好。5.4 性能优化符号地址与具体地址混用最后一个建议也是我实际分析中最受益的一点不要把所有 malloc 地址都符号化。你只要把关键路径上参与比较、运算的 malloc 返回地址符号化即可其他函数内部的 malloc 完全可以交给默认堆模拟。用一张表总结几种策略的适用情形策略地址性质单元内容适用场景主要限制默认 malloc 模拟具体可符号常规 stdin/file 输入地址值固定无法参与复杂分支手动向具体地址单元写符号具体符号模拟非标准输入源需要知道地址hook malloc 返回符号地址符号可符号地址参与运算/比较求解慢需约束合法区间CONCRETIZE 全开符号→具体符号快速验证可达性丢失地址相关路径这张表基本覆盖了我后面大半年的 angr 使用场景。遇到问题先对照一下往往能少走很多弯路。最后再说一点个人的体会angr 本身的 API 并不难背真正卡住人的是“什么时候需要符号化、符号化到什么程度”。malloc 地址单元符号化这件事核心不是写几行 hook而是你要想清楚这个符号地址到底参与哪些运算、会影响哪些分支。想明白了代码只是顺手的事。后面我再遇到类似堆管理逻辑分析基本都是一上来先画清楚地址流向再决定哪些 malloc 该符号化、哪些该保持具体整个分析效率一下子提升了很多。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表