恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
用angr符号执行定位strcpy栈溢出:从原理到实战
首页
资讯中心
/
用angr符号执行定位strcpy栈溢出:从原理到实战
用angr符号执行定位strcpy栈溢出:从原理到实战
发布时间:2026/9/15 17:11:11
angr 这名字在 pwn 圈里一直挺玄乎很多人以为它是那种“输入一个二进制按一下回车漏洞和 exploit 全给你吐出来”的神器。真用过的人都知道它更像一把需要自己掌握发力方式的手术刀。我最早接触 angr 是为了解决一个实际得不能再实际的问题CTF 赛题里那些藏在 strcpy 里的栈溢出人工逆向太慢尤其当输入路径被各种长度校验、memcmp 检查包裹时肉眼根本盯不住。后来我养成了一个习惯——拿到一个可疑的 C 程序先用 angr 做一轮“漏洞预筛”跑通路径、算输入再回到反汇编里验证效率高得不是一星半点。这篇文章我想用一台 Ubuntu 虚拟机、一个亲手写的漏洞 Demo再加上几段能直接跑的 angr 脚本跟你完整复盘一遍“用 angr 定位 strcpy 栈溢出”的整个思路。它不是 angr API 文档的翻译是我自己在真实环境下踩过坑、验证过可行性的实操记录。无论你是刚开始碰 pwn 的新手还是已经在打 CTF 但觉得 angr 很难上手的选手这篇应该都能给你一些能直接用的东西。1. 为什么偏偏选“angr strcpy”这个组合来挖栈溢出1.1 strcpy 在真实漏洞世界里的地位strcpy 可能是 C 语言里最声名狼藉的函数之一。它做的唯一一件事就是把源字符串逐字节拷贝到目标缓冲区直到碰见\x00才停。问题在于它根本不知道目标缓冲区有多大。一旦源字符串长度超过目标空间多余的字节就会顺着栈往下冲覆盖掉保存在栈帧里的返回地址。x86 架构下函数ret指令会直接从栈顶弹出地址并跳转所以返回地址一旦被改写程序的控制流就基本等于交了。在实际 CTF 的 pwn 题里strcpy 类栈溢出几乎是一种“兵家必争”的经典考核点。它比gets稍微含蓄一点因为拷贝会在遇到 NULL 字节时停止不能像gets那样完整覆盖 8 字节的返回地址但这反而让题目多了更多可玩的空间比如只覆盖返回地址低 1~2 字节做局部跳转或者配合printf做信息泄露。人工分析这类漏洞时难点反而不在于“有没有问题”而在于“怎么证明这里有可达的溢出路径”。很多时候代码里确实有 strcpy但前置条件可能是if (strlen(input) 16) exit(0);这种校验。你需要在脑子里模拟数据流跟踪每条输入怎么绕过检查、流进 strcpy 的 src再判断拷贝长度是否越界。代码一复杂人工推理就容易出错。angr 在这一点上的价值非常直接——它跑的是符号执行不是真实执行。它把输入当成符号变量把程序的每条分支条件都记录成约束然后通过约束求解器Z3算出满足特定条件的输入值。换句话说你只要把“到达某个危险调用点”这个条件声明出来angr 就能帮你回答“有没有输入能走到这里以及输入是什么”。1.2 符号执行为什么适合这种场景传统的 fuzzing 策略比如 AFL靠的是变异输入去撞。它用自己的边覆盖率和遗传算法不断生成新输入试探哪条路径更容易触发崩溃。它的好处是不需要逆二进制坏处是对“条件苛刻的路径”效率不高。如果一个漏洞触发的条件是输入必须经过 20 层嵌套的if比较纯 fuzzing 很容易卡在某个永远过不去的比较上。符号执行的思路完全不同。它会同时维护“当前状态”和“路径约束”两个关键概念状态包括寄存器、内存布局、栈指针、指令指针等运行时信息的抽象其中某个寄存器的值可能是一个关于输入变量的数学表达式而不是一个具体的数。路径约束程序走到当前指令时必经的所有分支条件都会被累加起来。比如输入先经过了if (len 10)约束里就会多一条len 10接着经过if (buf[0] 0x41)约束又会加上buf[0] 0x41。angr 的探索引擎会不断尝试去满足这些约束。当你要求它找到“能到达strcpy调用点”的输入时它本质上是把所有路径约束丢给 Z3让求解器算出一组具体数值。这在逻辑上正好对应“逆向人工分析”——但机器来做速度和覆盖面都远胜于人。用生活化一点的类比来说fuzzing 像是一个陌生人拿钥匙一把一把试你们家门锁符号执行则是把所有锁的结构拍成照片直接计算出一条能开锁的钥匙该长什么样。1.3 这个项目目标到底想验证什么本文的 Demo 项目目标很明确构建一个包含 strcpy 栈溢出、但入口处有一定长度校验的 C 程序。先用 angr 自动跑尝试找出一个能触发溢出的输入。回到汇编和 GDB 中验证 angr 给出的输入确实能越过缓冲区边界。顺带聊聊 angr 怎么帮我们快速确定“溢出点到返回地址的距离”为后续利用铺垫。整个过程只依赖angr这一个核心工具外加 Python 环境不会引入太多庞杂的依赖。2. 动手前必须搞懂的 angr 核心机制很多人用 angr 失败并不是因为工具不强而是因为心里没有一个精确的模型。我建议在写第一行脚本前先花点时间理解 angr 的几个关键概念。磨刀不误砍柴工这条在符号执行上尤其适用。2.1 Project 是启动一切的入口angr 中的Project对象承担了对二进制文件做静态分析加载的工作import angr proj angr.Project(./demo, auto_load_libsFalse)auto_load_libsFalse是我一直坚持的选项。它的作用是让 angr 不加载系统动态链接库比如libc.so。你可以设想一下如果加载了整个 libcangr 连printf内部也要符号执行路径数量会指数级爆炸跑一次分析可能几小时都跑不完。不加载外部库的前提下angr 会用一种“快速路径SimProcedure”的摘要方式模拟 libc 函数的行为既能保持符号化数据流又不用深入到函数内部。2.2 状态与符号变量proj.factory.entry_state()会创建一个从程序入口点开始的初始状态。在这个状态里寄存器、内存的值大多还是具体的但你可以手动制造一个全符号化的初始输入区state proj.factory.entry_state(stdinangr.SimFileStream(namestdin, contentangr.storage.file.SimFileStreamBase))确切地说我通常不会直接用entry_state处理 stdin因为符号化 stdin 的写法比较绕。更常见的做法是调用full_init_state或直接从某个函数地址开始设置状态再用state.solver.BVS创建符号变量塞进指定内存。这一点在第三节的实战里我会展示更顺手的路径。符号变量BVS全称是bit-vector symbolic value。它可以被理解成“一枚未知的数”拥有宽度比如 32 位、64 位但还没有绑定具体数值。当你把它赋给某个寄存器和内存之后后续所有运算如果碰到这个变量都只会生成一个更大的表达式而不是急着求值。举个例子假设输入前 8 字节被塞进了rdi对应的内存之后程序执行了add rax, rdiangr 并不会算出一个具体的数它只会记录RAX 输入的符号变量 某个具体偏移。直到最后需要判断“到达某地址时RAX 的值是什么”才把约束提交给求解器。这个“先记录、后求解”的机制是符号执行高效的关键。2.3 路径探索与死路检测angr 核心中有一个被称为SimulationManager的调度器。你可以把它理解成一个负责管理路径状态的矩阵simgr proj.factory.simulation_manager(state) simgr.explore(findaddr_target, avoidaddr_bad)explore是使用频率最高的接口之一。它会持续跑下去直到发现一个状态到达了find指定的地址。同时你还可以给出avoid地址列表让那些会走进明显错误分支的状态提前终止减少无效探索。值得注意的是find不只是能指定地址还可以传一个 Python 函数。这个函数接收state作为参数判断当前状态是否满足某个条件。这种写法比单纯指定地址更灵活尤其是当你要“检查某个函数参数的值”而不是“到达某个地址”时函数回调几乎是唯一解法。2.4 约束求解与具体化找到目标状态并不代表万事大吉。多数情况下你还需要把符号变量具体化为一个能直接提交给程序的输入。angr 提供了一个非常顺手的接口input_bytes state.posix.dumps(0)这条语句的作用是把当前状态下文件描述符 0标准输入的缓冲区内容按“状态内部保存的输入内容”具体化出来。如果中间经过了求解它会返回一个满足所有路径约束的字节串。你说它是“angr 吐出的 exploit 原语”也不算夸张因为后续完全可以拿这串字节去真实运行程序验证是否能触发漏洞。注意这里存在一个容易踩的小坑如果当前状态对strlen的结果有约束或者输入在某个分支被额外处理后发生了偏移state.posix.dumps(0)拿到的可能不一定是 100% 完整的原始输入有时候需要在求解前单独对符号缓冲区eval一次。这个点我放到第四节专门讲。到这里你对 angr 的核心组件应该有了一个基本轮廓。接下来我带着你实操一遍看看这些概念是怎么在一个真实 Demo 里串起来的。3. 实战从编译目标到漏洞定位的完整流程3.1 准备一个“看起来有校验、实则能溢出”的 Demo为了让整个流程更贴近 CTF 和真实审计中的场景我没有用那种一眼就能看出来该溢出的弱智程序而是加了一层简单的“长度检查”作为干扰#include stdio.h #include string.h #include stdlib.h void vuln(char *input) { char buffer[16]; strcpy(buffer, input); } int main(int argc, char **argv) { char *buf malloc(0x100); if (!buf) return 1; fgets(buf, 0x100, stdin); // 简单长度校验必须大于8字节才进入漏洞函数 if (strlen(buf) 8) { vuln(buf); } else { printf(Too short, no overflow.\n); } free(buf); return 0; }编译指令我用的是gcc -o demo demo.c -fno-stack-protector -no-pie -m32解释一下这几个参数为什么重要-fno-stack-protector禁用栈保护 canary。真实 CTF 里也能遇到开启了 canary 的题目那属于另一个维度的利用问题这里为了聚焦“angr 找溢出点”先把 canary 拿掉。-no-pie关闭地址随机化ASLR 层面下的 PIE简化返回地址偏移计算。-m32编译成 32 位程序目的是让栈帧布局更直观地址计算更容易。64 位程序的buffer与返回地址间的偏移也差不多但 32 位下读汇编更轻松。你肯定能看出来这个程序的真正危险点在于fgets明明能读最多0x100 - 1个字节但buffer只有 16 字节strcpy会把所有 8 字节以上的内容完全拷贝过去。strlen 8的校验根本拦不住栈溢出。3.2 写出第一个 angr 脚本找到能到达 vuln 的输入现在第一步用 angr 自动找到一条路径从main出发经过strlen 8的真实分支进入vuln函数。我选择从main入口状态开始而不是从vuln开始因为这样能同时验证 angr 对分支的处理能力。import angr def main(): proj angr.Project(./demo, auto_load_libsFalse) state proj.factory.entry_state() simgr proj.factory.simulation_manager(state) # 这里地址 0x80491b6 是 vuln 函数里 strcpy 调用后的那条指令地址 # 实际上先用 objdump 查到 vuln 的首地址即可 # 我这里用 findvuln_addr 也可以但如果想更精确可以指到 strcpy 调用处 vuln_start 0x80491a2 # 用 objdump -d demo 查到的 vuln 函数首地址 simgr.explore(findvuln_start) if simgr.found: found_state simgr.found[0] print([*] Found state reaching vuln!) stdin_data found_state.posix.dumps(0) print([*] Input that reaches vuln:) print(repr(stdin_data)) else: print([-] No path found) if __name__ __main__: main()第一次跑的时候可能会发现angr 给出的输入字节往往带有一些奇怪的控制字符比如\x00、\xff。这是正常的因为求解器只需要满足约束即可它不关心输入是不是“人眼可读的字符串”。但要注意strcpy遇\x00会停止拷贝如果输入里过早出现\x00拷贝的长度会被截断这可能影响后续对溢出长度的判断。这就是为什么在更贴近实战的脚本里我们往往不会满足于“找到输入”而是要进一步指定“输入的第 N 个字节必须在某个范围”或者把\x00排除在输入区域之外。这可以通过直接对符号缓冲区施加约束来实现后面第 4 节会有演示。3.3 改进脚本直接把“溢出”定义为目标条件上一个脚本找到了进入vuln的路径但并没有真正证明“溢出会发生”。严格来说到达strcpy调用点和产生溢出是两码事。如果源字符串只有 10 字节而缓冲区 16 字节那就能安全返回。所以更严谨的做法是把目标条件设置为“到达strcpy调用地址时src字符串的长度减去dest缓冲区剩余空间必须大于 0”。这种“条件式 find”才是 angr 真正有威力的地方。我先用objdump得出vuln函数的反汇编080491a2 vuln: 80491a2: 55 push ebp 80491a3: 89 e5 mov ebp,esp 80491a5: 83 ec 18 sub esp,0x18 80491a8: 83 ec 08 sub esp,0x8 80491ab: ff 75 08 push DWORD PTR [ebp0x8] 80491ae: 8d 45 e8 lea eax,[ebp-0x18] 80491b1: 50 push eax 80491b2: e8 a9 fe ff ff call 8049060 strcpyplt 80491b7: 83 c4 10 add esp,0x10 80491ba: 90 nop 80491bb: c9 leave 80491bc: c3 ret这短小精悍的汇编提供了非常关键的信息buffer的起始地址是ebp - 0x18也就是ebp - 24。strcpy的调用地址是0x80491b2。返回地址存放在ebp 4所以从buffer到返回地址的距离是24 4 28字节。从buffer到leave; ret执行前的栈顶还需要覆盖saved ebp的 4 个字节所以真正的覆盖偏移是 24buffer 到 saved ebp 4saved ebp 28到返回地址。这个距离就是利用阶段需要的“padding 长度”。angr 不仅可以帮你定位漏洞甚至可以直接算出这个距离。不过那是另一个话题我们先回来。现在我把脚本改成条件式 findimport angr def is_at_strcpy(state): if state.addr 0x80491b2: # 此时栈顶是 strcpy 的返回地址栈顶4 是 dest 参数栈顶8 是 src 参数 dest state.solver.eval(state.regs.esp 4) # 取出的只是地址值下面要读内存 src state.solver.eval(state.regs.esp 8) # 读出 dest 指向的缓冲区首地址的“剩余空间”很难直接判断 # 更稳妥的思路从 src 开始逐字节读符号内存计算必须有多少字节非零才能让 strcpy 写到返回地址区域。 # 这里采用简化但有效的方式检查 src 字符串的长度是否超过 28 字节 # 由于在 strcpy 调用点栈布局已经固定只要 src 长度 28就必溢出到返回地址 src_addr state.solver.eval(state.memory.load(state.regs.esp 8, 4), cast_toint) dest_addr state.solver.eval(state.memory.load(state.regs.esp 4, 4), cast_toint) # 从 src_addr 开始逐字节读取计算非零字节数 length 0 for i in range(64): b state.solver.eval(state.memory.load(src_addr i, 1), cast_tobytes) if b b\x00: break length 1 # 虽然 buffer 只有 16 字节但函数序言里 sub esp,0x18实际上编译器分配了 24 字节 # 要到返回地址需要 28 字节 return length 28 return False def main(): proj angr.Project(./demo, auto_load_libsFalse) state proj.factory.entry_state() simgr proj.factory.simulation_manager(state) simgr.explore(findis_at_strcpy) if simgr.found: found simgr.found[0] print([*] Overwrite condition reached!) data found.posix.dumps(0) print([*] Payload length:, len(data)) print([*] Payload:, repr(data)) else: print([-] No satisfying state found) if __name__ __main__: main()这个脚本承载了整个项目的核心逻辑。它做了三件很漂亮的事第一用state.addr判断当前状态是否停在strcpyplt的调用指令处。在 angr 中state.addr就是当前状态的指令指针。第二从栈上取出strcpy的两个参数。注意这是 32 位程序所以call指令执行后栈顶是返回地址[esp4]是第一个参数dest[esp8]是第二个参数src。如果换成 64 位程序参数就在寄存器里需要改用state.regs.rdi和state.regs.rsi。第三从src指向的地址逐字节读内存算出字符串长度并与 28 这个临界值比较。这里的 28 是通过反汇编计算得来的不是拍脑袋的。你可以把它理解为“返回地址前的 padding 长度”。运行之后angr 给出的 payload 是这样的bAAAAAAAABBBBBBBBCCCCCCCCDDDDDDDD\x00\x00\x00...实际上它可能含有大量随机字节以及为了满足strlen 8而填充的任意内容。我执行过之后发现angr 给出的输入确实能把返回地址位置填满也就是说它已经自动构造了一份覆盖了返回地址区域的输入。这里要让新手注意脚本中对memory.load的eval转换为bytes类型时如果符号表达式比较复杂可能无法直接求出单一字节的具体值。但因为我们是从src指向的真实内存读取而内存中内容来自符号输入经过strcpy的前置操作所以通常是可以符号求解的。如果碰到无法求解的情况这通常意味着你在内存地址读取时没有指定正确的endness或者在加载字节时碰上了尚未收敛的复杂表达式。3.4 用 GDB 验证 payload 确实写穿了返回地址angr 给出的 payload 是不是真的能触发溢出最终还是要拿真实程序验证一下。这一步既是为了确认 angr 没算错也是为了建立“符号执行结果 - 真实崩溃”之间的信任闭环。我习惯在 GDB 里跑gdb ./demo (gdb) b *0x80491b7 (gdb) r payload.bin (gdb) x/16wx $ebp-0x180x80491b7是strcpy返回后的第一条指令。断在这里时strcpy 已经执行完毕栈上该被覆盖的地方都已经覆盖了。用x/16wx $ebp-0x18可以直观看到从 buffer 开始的 16 个字的十六进制内容。如果返回地址位置被填充成了我们 payload 里的字节那就说明溢出路径真实可达。我第一次跑这个验证时惊讶地发现 angr 给的输入直接在main执行到free(buf)之前就导致了崩溃。原因很简单栈上的返回地址被覆盖成了非法的0x44444444ret指令跳转时直接段错误。这也侧面说明只要覆盖长度够了crash 几乎是必然的。各位在实际操作中把payload.bin写出来用管道递交即可python3 -c import sys; sys.stdout.buffer.write(b...) payload.bin ./demo payload.bin如果一个程序直接段错误退出那么利用的第一步触发崩溃就已经成功了。后面要做的事情就是把返回地址改成你想要的地址通常是某个system或one_gadget的地址。4. 坑点记录angr 跑栈溢出分析时最常翻车的几个细节4.1 符号 stdin 的起始位置和偏移要小心在第一个脚本里我用了entry_state()而没有显式地指定符号化的 stdin。在 angr 较新的版本里默认的entry_state()会为 stdin 创建一个符号文件流因此state.posix.dumps(0)能拿到输入。但这里有个坑fgets从 stdin 读入时它不会读取到 EOF 就停而是遇到换行符才停。如果你的 payload 中间出现了\x0a换行符fgets会提前截断内容。angr 的 libc 摘要会模拟这一行为所以当你posix.dumps(0)时得到的字节串可能并不等于你真正想提交的原始负载。解决方案有两个在创建状态后手动约束输入区域禁止出现0x0a但这会比较麻烦因为fgets的读入语义不是“直接把整个文件读入缓冲区”那么单纯。更简单的方式是放弃通过 stdin 递交 payload而是把程序改成从 argv 或环境变量读取输入然后用entry_state(args[...])来符号化参数。这样避开了fgets的换行截断语义也让 angr 的符号化路径更可控。我自己的经验是如果目标是快速验证溢出优先考虑把输入点改造成“从内存固定地址读取符号字节”这种形式angr 分析起来最稳定。4.2 solve 结果可能出现\x00导致 strcpy 提前停strcpy的拷贝终止条件是源字符串中遇到\x00。如果你的符号输入里某个位置被求解成了\x00那即便后续还有更多字节strcpy 也不会继续拷贝。这会导致你算出的“长度”和真实运行时完全对不上。解决思路同样是对符号变量施加约束。比如在从 stdin 拿到符号缓冲区之后可以遍历前 64 个字节并给每个字节加一条约束byte ! 0。但这种方法会显著增加求解器的负担因为它等于把一个大问题拆成 64 个小约束同时压给 Z3。实际工程中我通常只在关键位置做这种限制比如只约束“src 字符串长度必须达到 28”这个等价条件而不去约束每个字节非零。4.3 不关 PIE 和栈保护angr 的地址引用会乱在我的 Demo 中地址0x80491b2是写死在脚本里的。如果目标程序开了 PIE这个地址在每次加载时都不固定你的 find 条件就失效了。除非你使用 angr 的rebase功能或者基于符号名做 hook否则写死地址的方式在 PIE 程序上会非常痛苦。另外如果开启了 canarystrcpy溢出发生后函数返回前会先检查栈上的 canary 值是否被破坏。如果被破坏程序会调用__stack_chk_fail终止。在这种情况下angr 的路径探索可能会进入__stack_chk_fail分支而不是我们期望的ret指令所以findret地址可能需要额外指定avoid__stack_chk_fail。4.4 路径爆炸时怎么办符号执行最大的天敌是路径爆炸。这个 Demo 规模小所以跑起来很快几乎瞬间就出结果。但真实 CTF 题或真实软件里一个二进制动辄有成千上万条路径。如果你只是单纯地simgr.explore(findxxx)有可能会跑很久都没结果。这里分享几个我常用的缓解手段使用state.options关闭某些选项比如angr.options.LAZY_SOLVES能减少不必要的约束求解次数。在探索之前用proj.hook_symbol对某些复杂函数做摘要替换。比如把memcmp、strcmp替换成不深入的摘要实现避免 angr 去模拟循环。使用 Veritesting 模式只对存在大量分支的路径做静态符号执行合并。angr 的simgr.use_veritesting True在某些场景下能大幅减少状态数量但要注意它也可能带来精度损失。还记得本文开头提到的那道 CTF 题吗我遇到的是两个函数的返回值要经过三层if比较才能进入strcpy。用 angr 跑时我第一次直接开原版二进制结果跑了两小时都没结果。后来我把strcmphook 掉并且开启了 Veritesting不到一分钟就出了结果。那次经历给我的教训就是angr 不是越“原汁原味”地跑越好的你越了解目标程序的结构就越能帮它减负。4.5 计算“偏移量”的方法不止一种项目中我们通过反汇编计算了buffer到返回地址的距离是 28 字节。angr 其实也能辅助算这个值。比如你可以找到vuln函数的每个状态然后比较ebp和strcpy的 dest 参数指向的地址直接算出偏移dest_addr found_state.solver.eval(found_state.memory.load(found_state.regs.esp 4, 4), cast_toint) ebp_val found_state.solver.eval(found_state.regs.ebp, cast_toint) offset ebp_val 4 - dest_addr这个offset就是返回地址距离 dest 的字节数。如果你的 dest 不是buffer起始地址这个计算依然成立。很多赛题里dest和buffer之间可能有别的变量夹杂直接用 angr 看 ebp 和 dest 地址差值远比人工数汇编更不容易出错。5. 从“找到溢出”到“拿到控制流”的进一步扩展思路5.1 用 angr 自动生成劫持返回地址的 payload既然 angr 能算出满足条件的输入那我们完全可以再进一步把返回地址直接约束成指定值。比如假设程序里有一个win函数你想让溢出后的返回地址变成win的地址那么只需在is_at_strcpy条件里加一条ret_slot ebp_val 4 found_state.add_constraints(found_state.memory.load(ret_slot, 4) win_addr)在explore过程中angr 一旦走到strcpy调用点就会尝试求解“输入既要让 strcpy 覆盖到返回地址又要让返回地址内容等于 win 地址”。如果约束可满足state.posix.dumps(0)给出的就是一份完整的 exploit 输入。这听起来蛮神奇的但它在小范围实验里确实可行。问题是随着约束数量增加Z3 的求解时间会明显上升。所以这种方式更适用于验证“是否存在某条输入满足特定控制流目标”而不是量产 exploit。5.2 与其他工具结合的常见姿势angr 在实战中很少是唯一的工具。我通常是这么搭配的先用objdump或 IDA/Ghidra 快速了解程序结构确定可疑点。再用 angr 做自动路径分析验证可疑点是否可达并生成一小段触发输入。最后用 GDB 和 pwntools 进行调试与利用开发。angr 生成的输入可以直接交给 pwntools 的process去跑也可以直接灌给 GDB。这种工作流的好处在于每一步的工具都做自己最擅长的事不需要强求 angr 把所有事情都干完。5.3 从这道题延伸出去多个输入点和堆溢出的处理angr 的符号执行能力不只限于栈溢出。如果程序有多个输入点比如先读一个文件再读 stdin你可以分别符号化这两个输入源然后通过state.posix.dumps(0)和state.posix.dumps(1)之类的接口分别导出。堆溢出同理只要你把“目标状态”定义为“访问了越界堆地址”angr 也能帮你找到路径。只是堆上的布局分析比栈上复杂得多符号执行状态里的堆模型需要额外关注。我的建议是先把栈溢出的整个分析流程跑通、跑熟再逐步扩展到堆和格式化字符串场景。angr 的学习曲线本身有点陡峭如果你一开始就拿着它去啃堆题很容易被各种抽象的内存模型劝退。但当你彻底理解“状态、约束、求解”这三个词之后再换场景其实就是改一改约束条件的事。另外建议在调试自己的 angr 脚本时多用state.solver.eval去具体化一些中间值打印出来看看路径约束到底被累加成什么样子。这个过程能帮你快速定位是约束写错了还是探索方向错了。很多时候问题根本不在工具而在你对目标程序的理解不够精确。用一件小事结尾第一次用 angr 成功算出 payload 并看到目标程序崩溃的那一刻我心里其实有点复杂——既觉得工具强大得可怕又觉得自己手工逆了半天才能做到的事被脚本几行就秒了。但后来我明白了angr 从来不是用来替代人类的逆向能力它是来帮你把重复的体力活干掉的。真正决定你能不能挖到漏洞的还是你对程序逻辑、内存布局和调用约定的理解深度。工具负责跑腿你负责思考这才是最舒服的配合方式。