干这行的都知道打CTF遇到逆向题最磨人的不是算法有多难而是那种“逻辑其实很简单但就是纯纯要你手算复杂校验”的题目。尤其是带S盒、长循环校验、白盒混淆这类你要是在IDA里F5盯着伪代码硬看脑子很容易看成一团浆糊。angr就是在这种场景下性价比最高的工具没有之一。它是一个基于符号执行和约束求解的二进制分析平台说白了你把它理解成一个能“帮你暴力试答案但是自带最强大脑”的机器人你把程序扔给它告诉它“我要执行到哪一行”它就能自动推导出能走到那一行的输入数据。这篇笔记我就按我自己从“按图索骥”到“能灵活拆解”的过程来写把angr在CTF逆向里的常见思路、核心API、实际例子和踩坑经验一次讲清楚。如果你是一个刚接触二进制安全、或者被某道逆向前置校验卡住过的新手这篇内容应该能帮你省下不少和IDA硬刚的焦虑时间。1. angr的核心思路先搞清楚它凭什么能猜出答案1.1 符号执行和普通逆向着眼点的根本区别传统逆向是静态分析你在IDA里看的是代码的形状输入从哪个寄存器进、中间经过哪些运算、最后和哪个常量比较。动态调试是看某个具体输入在运行时的走向。angr走的是另一条路叫符号执行。简单比喻一下普通调试的时候你给程序一个具体的数比如输入“12345”然后跟着它跑符号执行是给程序贴上“一个未知数x”的标签让它在关键分支上不选择一条路而是把约束条件记录成“这里要求x 10”然后在遇到后期校验的时候把所有约束丢给一个求值器反解出所有满足条件的x值。这个机制对CTF逆向解题非常关键。因为CTF里的大量逆向题本质就是“你输入一个字符串程序做了一堆校验全过了就输出flag”。传统方法你得把校验逻辑逆向清楚然后自己写脚本解方程组但如果你用angr你只需要做两件事定好哪个输入是“符号化”的定好程序里哪个地址是“校验成功”的地址然后让它自动探索。校验用的常数、S盒、异或规则angr不需要你手工去逆约束求解器会直接解出来。这就是它最大的价值。1.2 angr到底适合解什么样的逆向题从实战经验来看angr最适合的是三类题目带复杂校验的“密码校验器”类题目。比如输入一串字符程序用一堆查表、移位、多层异或做校验正确就跳到输出flag的地址。这类题逆向逻辑繁重但分支结果很明确符号执行几乎是为它们量身定做的。带大量分支的“迷宫题”或“路径题”。程序内部有一张地图输入决定机器人怎么走你需要找出能走到终点的输入序列。这种题如果手算很快就会被状态空间淹没angr的路径探索机制天然适合。程序带本地校验但校验逻辑藏得很深的“黑盒式”题目。比如某个自定义的哈希函数你不想也没必要去还原它的数学结构直接用符号执行让程序自己判定就行。反过来也有三类题目不适合angr这个认知比学会API更重要。第一类是输入空间极大且约束条件极少的题比如“找一个字符串它的MD5值是已知的”这种情况下符号执行会退化成一个暴力搜索效率远不如专门写脚本来哈希碰撞。第二类是内嵌了大量阻塞调用、随机因素、时间依赖或网络交互的程序符号执行很难建模这些系统行为。第三类是涉及大量浮点运算的校验约束求解器对浮点数支持通常不稳定很容易无解或超时。1.3 别把angr当万能药理解比炫技重要很多时候你会看到一些人发帖说“一碰到逆向题就angr一把梭”这种行为我其实不太推荐。倒不是说angr不好而是这种心态会让人逐渐丢掉逆向工程里最核心的底层能力结构化理解程序的能力。angr更像是一个“近战武器”适合处理计算密集型的、分支清晰的校验逻辑。但当你遇到需要理解复杂数据结构、堆操作、算法逻辑的题目时纯angr根本无从下手你还是得回到IDA里看反汇编。我的习惯是先用IDA快速浏览伪代码判断这道题是否适合符号执行。如果核心校验是个黑盒子分支结果很明确才拿出angr。这样才叫工具为人服务。2. angr的四大核心组件把它真正搞明白2.1 Project加载二进制的总入口angr的所有操作都从一个Project对象开始。简单说angr.Project(./target)就会加载这个二进制文件并形成一套完整的中间语言分析环境。我第一次用angr的时候看到别人写p angr.Project(./crackme)觉得平平无奇但后来才发现这个对象背后包含的东西很多它加载了二进制的基本模块可执行节、导入表、符号表、架构信息以及可选的动态链接信息。需要注意两个我在实战中经常用的参数。第一个是auto_load_libsFalse。这个参数特别关键如果加载的是一个动态链接的ELFangr默认会把共享库也全部加载进来做符号执行这会直接导致路径空间爆炸绝大多数实际题目你根本跑不完。第二个是main_opts参数比如angr.Project(./task, main_opts{base_addr: 0x400000})在你需要动态调整加载基址时很有用。CTF里还会遇到去除段表、加壳的二进制这些场景就需要配合angr.loader做更细的控制不过大多数常规题目用默认方式就够了。Project还提供了factory接口比如p.factory.entry_state()生成入口状态的Statep.factory.block(addr)解析某个地址的基本块等等。把这些想成一个“程序快照工厂”你从Project里可以抽出某种状态下、某个地址的代码快照这是后续所有操作的起点。2.2 State与BitVector把输入变成“未知数”State是angr中一个很核心的概念它代表程序在某一时刻的“快照”包括寄存器值、内存内容、文件系统状态、约束条件集合等等。你可以通过p.factory.entry_state()创建程序入口状态也可以用p.factory.blank_state(addr...)创建某个指定地址的空白状态还可以用p.factory.full_init_state()模拟带动态加载器的初始化入口。当我们想分析一个程序时一般不会把所有输入都当作具体的值而是定义一个“符号变量”。angr依赖一个名为claripy的符号库来处理这些变量。最常用的写法是claripy.BVS(flag_byte_%d % i, 8)意思是创建8比特的符号位向量名字叫flag_byte_0。把这些8位向量拼接起来就构成一个符号化的输入缓冲区。之后再创建一个State并且把程序的stdin或者内存缓冲区指向这些符号变量angr就知道“这些字节未知但我要尝试所有可能的值直到找到一组满足后续所有校验的值。”这里有一个常见误区符号变量并不等于“无限大的字符串”。angr的符号求解基于SMT求解器它本质上是在约束系统里找可行解不是把字符串从0到9一个个试完。所以它的时间复杂度并不直接取决于变量取值空间而取决于约束的复杂度和路径分支的数量。理解了这一点你就会明白为什么angr适合密码校验题因为校验逻辑再复杂只要它不是强随机、强依赖外部环境求解器都能较快收敛。2.3 Simulation Manager路径探索的核心引擎当State创建好之后真正驱动程序往前走的是Simulation Manager。它维护一堆活跃的State模拟程序的执行过程。一个入口State进入这个管理器后会逐步执行到各个分支点每到一处分支管理者会把状态分成多个分别沿分支推进每个子状态都继承父状态的全部约束并在分支条件上追加“必须走这边”的约束。最常用的两个方法是step()和explore()。step()走一步适合你想亲手控制推进节奏的场景。explore(find..., avoid...)则是自动探索直到找到目标地址。find可以传一个地址也可以传一个可以接收State并返回布尔值的函数用于更复杂的终止条件avoid用来排除不想要的路径。对于CTF逆向题explore基本就是核心兵器。此外Simulation Manager还提供run()、explore()内部的优先级控制以及simgr.use_technique接口可以挂载各种探索技术比如DFS、BFS、LoopSeer、Veritesting等。这些在路径特别多的程序里很有用但在最常规的CTF题目里默认的探索策略往往就够了。2.4 Solver最后让答案落地一旦探索到了目标地址那个State里会保存一组约束。角gr的solver接口就是用来对这套约束进行“求解”的。常见操作是state.solver.eval(expr)得到一组满足所有约束的某个具体值或者state.solver.eval_upto(expr, 10)可以列出最多10个解。如果是处理stdin的输入常见做法是让State的stdin符号化最后用state.posix.dumps(0)取出具体的输入内容。这套组合在实战里就这么用find0x401010找到了目标状态然后state.posix.dumps(0)打印出程序通过stdin读取的候选输入。在我用来求解某一类密码校验器时这几乎就是模板代码两三行就拿到答案了。但要提醒一点find地址必须是程序实际会到达的地址。如果把地址写错——比如把校验失败地址当成目标——angr也会乖乖地通过然后给你一个“错误”的输入看起来还挺像那么回事。定位地址之前务必用IDA或objdump确认函数的真正返回点和成功分支的跳转目标。3. 完整实操一个密码校验器的自动化求解3.1 题目场景和目标拆解我这里先给出一个自己搭建的模拟题目“crackme_x”结构是CTF里最常见的那类程序要求你输入一个字符串内部进行若干轮处理和校验全部通过后输出“You are correct”否则输出“Try again”。我特意把校验逻辑做得比较复杂让手算非常耗时但对angr来说只是几个约束的事。用IDA打开之后我发现程序的主流程是把输入的字符串长度与0x21比较对每个字符做一个查表替换把替换后的结果再按位置异或进一个常量数组最后与一个硬编码的全局数组逐字节比较。如果这些字节全部相等就跳到输出正确的分支。核心校验地址大约在0x4012A0错误输出地址在0x401260。我在实验里最常遇到的是两种情况一种是要逆向查表逻辑手写脚本逐字符逆推另一种是干脆用angr直接找输入。对于这种“硬编码比较数组查表变换”的题目angr确实可以一锤定音。3.2 环境准备与angr安装angr的安装非常简单最省心的方式是使用虚拟环境因为angr的依赖项比较重和其他Python库的版本偶尔会互相干扰。常规命令是python3 -m venv angr-env source angr-env/bin/activate pip install angrangr目前主要支持Python 3.9到3.12的版本建议装的时候别用太老的Python解释器。装完之后可以用python -c import angr; print(angr.__version__)确认版本。如果你在调试过程中遇到莫名其妙的模块加载错误十有八九是依赖库的版本冲突重建虚拟环境往往比逐个排错要快。另外我建议准备一个canary问题的处理心态angr在分析某些包含未初始化数据读取的程序时会给出大量警告。这些警告很多并不致命只需设置p angr.Project(./crackme_x, auto_load_libsFalse)并使用合理的State策略就能降低干扰。3.3 编写求解脚本一步步来针对crackme_x我写了一个最精简的可运行脚本并做了注释。这里有一个关键操作用符号变量表示stdin的全部输入内容然后进入入口状态。import angr import claripy # 加载二进制文件不载入共享库避免路径爆炸 p angr.Project(./crackme_x, auto_load_libsFalse) # 当前题目要求输入长度为0x21 flag_len 0x21 # 创建符号化字节流 flag_chars [claripy.BVS(flag_byte_%d % i, 8) for i in range(flag_len)] flag claripy.Concat(*flag_chars) # 通过标准输入提供一个符号缓冲区作为程序输入 state p.factory.full_init_state(stdinflag) # 为了减小搜索空间预先约束每个输入字节为可打印字符 for i in range(flag_len): state.solver.add(flag_chars[i] 0x20) state.solver.add(flag_chars[i] 0x7e) simgr p.factory.simulation_manager(state) # 设置成功与失败的终止地址 find_addr 0x4012A0 avoid_addr 0x401260 simgr.explore(findfind_addr, avoidavoid_addr) if simgr.found: solve_state simgr.found[0] # 把符号输入的具体取值解出来 ans solve_state.posix.dumps(0) print([] found flag:, ans) else: print([-] no solution)这个脚本的核心逻辑很短但每一步都值得展开说第一full_init_state和entry_state的选择。entry_state从入口点开始执行适合简单的静态二进制full_init_state会模拟动态加载器和初始化逻辑对于带PIE、需要初始化全局符号的二进制更稳。在CS里的很多题entry_state就够用但一旦遇到依赖全局数组初始化的场景entry_state可能因为跳过初始化而给出错误结果。第二为什么要在输入字节上加打印字符的约束。有些校验器除了比较内容还会比较长度甚至会把字符当作索引来使用。如果我们允许任意字节求解器虽然也能工作但产生的解可能包含不可打印字符程序运行时根本无法正确输入。预先约束成可见字符既能缩小搜索空间又能在逻辑上符合实际字符串输入场景。第三find和avoid的使用核心。当探索到find_addr时angr返回一个状态此时该状态包含了所有附加的路径约束。这个约束里既有程序本身的分支条件也有我们手动添加的符号范围约束。posix.dumps(0)会考虑所有这些约束并给出一个满足全部条件的具体值。这也就是整棵树的答案。3.4 结果验证与耗时对比我的机器不算强跑这个脚本大约花了几十秒到一两分钟的时间。打印出来的答案是一个长度为0x21的字符串内容看起来像一串人工可读的flag组合。为了确保结果正确我把这个输入直接交给原程序运行程序输出“You are correct”验证通过。对比一下ID里手写逆推加写脚本我大概需要超过二十分钟而且很容易在查表时算错angr虽然花了一点时间“自动探索”但优势在于它可以处理更复杂的密码校验而不用增加多少人力。还有一个现象很有意思angr脚本对目标代码的指令数非常敏感。假设某道题的校验过程包含1000个指令angr跑得很快但如果校验内部有特别大的循环比如几万次的查表angr会慢很多。遇到这种情况可以尝试用state.solver.add人为添加强约束或者改用符号化内存而不是符号化stdin来减少解析开销。4. 实践中的常见问题以及我踩过的坑4.1 路径爆炸程序分支太多怎么办路径爆炸是符号执行最经典的痛点。当程序内部有大量循环、随机分支或对同一变量进行多次条件判断时Simulation Manager里的状态数量会指数级增长。我在调试某一道含有大量switch-case的题目时就摸到过这个天花板。应对路径爆炸的方法有几种。第一种是使用simgr.use_technique(angr.exploration_techniques.DFS())把默认的探索方式改成深度优先在某些情况下能更快深入目标。第二种是用veritesting技术它能在一定程度上合并等价路径。第三种是给explore增加更精确的avoid直接封杀掉失败分支减少有效状态数量。第四种是手动缩短符号化范围——比如在校验之前先调用真实函数来处理一些独立字符而不是让整个字符串全部符号化。不过这些技巧都是“对症下药”没有万能解法。实际题目里最常见的问题其实是你忘了加avoid导致程序在失败分支上疯狂探索却迟迟找不到目标分支。大多数CTF的密码校验器失败分支占了绝大多数所以只要avoid定位准确探索效率通常会提升几个数量级。4.2 符号化的输入是从stdin读的但程序用的是别的接口angr在自动化处理CTF赛题时最需要适配的就是“输入从哪里来”的问题。很多节目不读stdin而是通过命令行参数、socket、文件读取等方式接收输入。angr对这些都有各自的处理方式命令行参数在blank_state或full_init_state里可以设置args列表然后把某个参数符号化最后探索结束后从该寄存器或内存中提取。文件输入需要先创建一个模拟文件系统对象把某个文件路径映射为符号缓冲区。常见的写法是state.fs.insert(input.txt, angr.storage.file.SimFile(input.txt, contentflag))然后把stdin留空。这样程序fopen这个文件时就会读到符号数据。内存全局变量有些程序直接把输入的字符串拷贝到全局数组后进行比较此时更推荐直接对那块内存地址做符号化而不是去模拟文件或stdin。可以先用blank_state指定一个栈地址或全局地址然后往其中写入符号字节。解决了输入接口的适配剩下的事情就完全一样了。4.3 遇到反调试、反虚拟机或自校验怎么办有些CTF题目为了增加难度会加入反调试或代码自校验逻辑。angr并不实际执行真实机器指令因此反调试机制通常对它无效但自校验逻辑会构成额外的分支和约束往往会导致求解变慢。我的经验是先去IDA找到自校验比较的位置如果它在主流程前面且不影响后续逻辑可以直接把比较结果改成“恒真”。具体做法是利用angr的Hook机制把某个地址上的指令替换成一条或一组等价指令或者干脆让某个比较函数的返回值固定为1。angr的p.hook(addr, hook_func)和p.hook_symbol(unary_check, ...)在这种场景下非常灵活。你可以把反汇编里确认不会影响最终flag的校验部分“短路”掉路径空间一下子小很多。另外有些程序会依赖time()、rand()、getpid()等外部函数。angr默认也有它的hook库但并不是每个平台都覆盖得很全。遇到这些函数导致求解卡死时最直接的方式是手动hook掉它返回一个具体值。4.4 约束求解太慢或失败约束求解失败有两种常见情况。第一种是约束矛盾。比如我手动给输入字节加了“必须等于某个值”的约束但程序本身又要求它不能等于那个值造成无解。这种时候求解器会“卡”很久或者最终返回“无解”。解决方案很简单检查你手动添加的所有约束是否合理尤其注意字符串长度和字节范围的约束。有时你可能错误地添加了flag_chars[i] ! 0但实际上程序要求结尾必须是\x00导致整体无解。第二种是约束太复杂求解器时间爆炸。这类问题最常出现在你需要大范围搜索、但只给了很少的约束条件时。解决方法有两种思路一是增加更精确的约束比如题目已知flag是某个格式就可以把开头几个字符直接固定如state.solver.add(flag_chars[0] ord(f))二是让angr在探索过程中用solver.eval_upto提前求出部分解逐步筛选。4.5 调试技巧如何在angr里“看”状态angr虽然能自动探索但有时你根本不知道程序到底走到了哪、卡在了哪。这时候我会使用三种调试手段。第一种是用simgr.move把某个状态单独拎出来手动执行。比如有一组状态全部卡在某个地址附近你可以取其中一个状态调用state.step()逐步执行并打印当前指令地址和寄存器值。第二种是开启angr自带的日志。使用angr_utils或直接设置logging.getLogger(angr).setLevel(DEBUG)它会把每个状态的基础块地址打印出来。对于定位“探索到一半卡住”非常高效。不过日志量巨大我只在需要精确问题时才打开。第三种是state.solver.constraints。可以随时查看某个State里所有约束条件的收集情况。如果发现约束数量异常少说明程序可能还没有来得及执行到关键校验如果约束数量异常多说明路径上产生的分支太多了要考虑精简。5. 那些文档里不会写的angr实战心得5.1 少用find0x...多用“成功条件”来终止探索很多入门教程都喜欢用固定地址作为find条件。这确实直观但也容易踩坑。现代CTF的二进制普遍启用PIE位置无关可执行文件运行时基址会随机化你从IDA里看到的静态地址并不是运行时地址。如果直接用静态地址作为findangr可能永远找不到。更稳妥的做法是使用函数作为find条件当某个状态满足特定条件时就认为探索成功。比如设定了findlambda s: bCorrect in s.posix.dumps(1)这样即使程序印出正确文字的位置发生了变化也能通过输出内容来终止探索。或者直接通过angr的simprocedure执行某些关键函数后判定。此外当find设成lambda时需要对状态中的输出做一次具体化这里可能会有性能开销但只要不频繁触发整体影响不大。实际使用中我发现“用标志输出文字作为成功条件”的效果通常比硬编码“命中地址”要稳定得多尤其是面对带PIE和多层的二进制。5.2 学会用Hook来优化求解但不滥用angr的Hook能力在逆向题里是非常强大的。常见用途包括劫持printf、strcmp这类标准库函数避免angr对复杂的系统调用或外部库函数做符号执行把某个函数直接替换为返回常量比如把check_func改成直接返回固定值从而跳过整轮复杂的比较逻辑通过Hook把无意义的随机数调用固定下来保证路径收敛。这里有一个关键判断标准能跳过的逻辑尽量跳过。符号执行最怕的是执行大量无用的标准库代码。很多新手在跑angr时明明题目很简单却因为加载了libc而卡到超时原因就在于没有用Hook或auto_load_libsFalse来控制动态库的加载。我通常在分析一个新题时会先做一个“轻量版本”的脚本把所有标准库的调用全部Hook掉给每个函数一个简单的模拟行为。这样即使程序里有很多puts、printf、fgets之类的调用angr也不会去执行libc内部实现求解速度会大幅提升。当然Hook也不能滥用。如果Hook把程序主逻辑里的关键函数也给屏蔽了最后当然求解不出来。所以Hook之前一定要先用IDA看清楚这个函数的作用想清楚“它是否对最终flag有多大的影响”。5.3 在比赛里angr应该是“第二方案”但也是兜底方案我参与到某次线上赛经常遇到一个现象同一批人面对同样一道看似简单的逆向题手速快的高手可能只用IDA加Python就秒掉而只有菜鸟才会一上来就开angr。这里就引出一个重要定位angr不应该成为你的第一思考方案但一定要是能救场的“兜底”。对于简单题目比如只有一次异或加比较你用IDA看两分钟就清楚了上angr纯属浪费计算时间。对于中难度的密码校验器时间成本上angr和手写脚本差不多但angr的正确性更稳手写容易漏符号。对于高难度题目比如算法本身是一个自定义的快排字符串经过非常复杂的变换angr反而会因为约束爆炸而失败这时候你又必须回到IDA。所以我的决策矩阵大概是题目逻辑清晰校验简单看IDA直接写脚本。题目逻辑模糊但分支结果非常明确先用angr试一把。题目包含大量复杂状态更新、循环嵌套先上angr试但不要恋战如果几分钟不出解立刻改用IDA逐段分析辅以angr处理片段逻辑。题目明显是反angr的比如大量混淆、阻塞调用、随机不可控直接不用angr老老实实逆汇编。我用这种方式打CTF几乎没有碰到过完全解不出来的逆向题。关键不是让工具替代思路而是提高听思路的效率。5.4 性能优化angr跑得慢时先检查哪几个因素如果你在用angr求解时发现速度明显低于预期我会建议优先检查这几个因素第一是否加载了共享库。auto_load_libsFalse是必备项大部分题目都能直接闭掉动态库分析效率提升非常明显。第二是否对所有标准函数做了Hook。如果程序调用了Libc的strlen、strspn、toupper等angr默认会采用符号执行的方式去模拟这些函数虽然准确但开销极大。用Hook直接把strlen替换为一个读取内存并返回具体值的过程往往比让angr走一条几百条指令的路径快很多。第三是否设置了打印字符约束。前面提到的0x20 byte 0x7e这类约束能裁剪大量“不可打印但无意义”的解这既是逻辑上的合理约束也是性能上的关键优化。第四是否滥用full_init_state。如果程序不依赖全局初始化用entry_state更轻量。反之如果程序有初始化逻辑全init状态更可靠。具体选哪个靠实验判断。6. 总结一下angr的学习路径该从哪里往前走很多人一上手angr就被它的API数量吓到觉得又要学Project又要学Simulation Manager又要学Solver。但我的建议是先把最常见的模板代码跑通然后用你手边已有的CTF历史题去练手。最初你可以只把angr当作一个黑盒输入二进制设置成功地址打印stdin。跑通之后再逐步深挖内部机制比如去看看它如何解析ELF如何用中间语言表示指令State的约束是怎么累积起来的。一旦你理解了这些底层机制再回头写脚本就基本不会遇到意料之外的翻车了。这也是为什么我觉得这篇笔记记的不只是“angr怎么用”更是一套“逆向自动化”的分析框架学会之后会对整个二进制分析都有很好的触类旁通。我在实际使用中最深的一个体会是angr的bug远比你想象中要多它的版本更新也非常频繁。假如你在求解时遇到了奇怪的现象先别怀疑自己的逻辑先去看看是不是版本问题。把angr升级到较新版本或者换一台Linux环境跑很多问题都会自动消失。另外不要迷信“一个脚本打天下”同样的模板在不同架构、不同编译器甚至不同优化级别下表现差异很大。写脚本的时候最好能顺手把出题者的思路和校验结构快速摸一遍做到有的放矢。这样才能在比赛里把angr真正变成你的“第二大脑”而不是给GPU一定时钟的赌注。