
前几篇笔记把 angr 的环境搭建、Project 加载、符号执行的基本流程都过了一遍今天这篇单独写一个特别容易踩坑、但又特别出效果的点寄存器符号化。很多刚接触符号执行的朋友会有一个困惑我用 angr 跑一个二进制明明已经拿到了控制流为什么条件分支里的变量还是“看不懂”或者我想分析某个函数的输入输出关系但程序是从文件、网络、硬件里读数据参数根本不在内存里我怎么让 angr 知道“这里有一个未知值”答案就是寄存器符号化。把 CPU 寄存器从“具体的 0 或 1”变成“一个待求解的符号表达式”让 angr 在模拟执行时把这个符号一路携带、一路参与运算、一路约束下来最后用求解器反推出满足条件的取值。这篇笔记就围绕这个主题展开适合已经跑通 angr 基础 demo、但想深入做函数级分析的人。1. 寄存器符号化到底在解决什么问题1.1 具体执行和符号执行的差别先从一个最朴素的视角看起。普通调试器执行程序时寄存器里装的一定是具体数字比如eax 0x7fffffffe2a0、rcx 3。每一步运算都会产生一个确定的新值我们顺着执行流走一遍能看到的只是“某一条路径”上的结果。可如果程序有 100 个条件分支每次输入不同走的路径就不同用具体执行去试那真是大海捞针。符号执行的思路完全不同它允许寄存器里装的是一个“未知数”。比如我新建一个状态把rdi设成符号变量x之后 CPU 执行add rdi, 5时angr 不会去算出一个具体数字而是记下“此时 rdi 的表达式是 x 5”。等执行到某个判断指令cmp rdi, 100时angr 会分别往两个分支走一个分支里额外记录约束x 5 100另一个分支里记录约束x 5 ! 100。这个过程很像我们在高中数学里“带着未知数做题”只不过符号执行把每一步的表达式、每个分支的约束都自动化地累加了下来最后交给 SMT 求解器angr 默认用 Z3去解。寄存器符号化就是整个链条的起点告诉模拟器“这个寄存器里是一个我还没定下来的值”。1.2 寄存器符号化的典型应用场景寄存器符号化最常用的场景我总结成三类。第一类是逆向分析中的函数级分析。你拿到一个二进制在 IDA 或 Ghidra 里看到一个关键函数sub_401120它的参数来源是程序前面复杂的计算或者干脆是外部设备的输入。你不想把整个程序都跑一遍只想单独分析这个函数的行为那就在函数入口处新建一个状态把参数寄存器符号化然后只分析这个函数的逻辑。第二类是路径条件还原。程序里有一段加密、校验或者关键判断逻辑你希望知道“满足什么条件才能走到某个分支”。这种情况下符号化的参数会被带到分支判断中angr 自动收集路径约束你最后用 solver 求出满足条件的值这就是约束还原。第三类是物联网固件、驱动、Shellcode 这类“无操作系统上下文”的分析。这类样本经常直接从寄存器里拿数据比如从硬件寄存器读取状态、从某个约定的寄存器接收参数内存里的状态几乎不可依赖。这时候寄存器符号化几乎是唯一干净地建立抽象输入的方法。1.3 为什么偏偏是“寄存器”不是普通变量你可能会问符号执行不是也能符号化内存变量吗为什么单独把寄存器拎出来讲因为寄存器是 CPU 与程序逻辑交互的最短路径。函数调用约定规定参数先放寄存器系统调用通过寄存器传递调用号和参数硬件状态通过寄存器暴露给软件。你想给一个函数“喂输入”本质就是在给它“喂寄存器”。而内存符号化通常要处理地址空间、大小端、栈布局稍不留神就和真实执行对不上寄存器符号化则简单得多一个state.regs.rax BVS就能解决。另一个实践层面的原因是angr 对寄存器符号化的支持非常完善state.regs这个属性可以按架构自动映射所有通用寄存器的名字和位宽还会处理别名关系比如rax和eax。这比手动去构造内存地址、手动读写栈要可靠得多。后面我会具体展开这块。2. 动手之前angr符号化必备基础2.1 claripy与BVS/BVVangr 的符号表达式底层由名为 claripy 的库实现。你不需要把 claripy 的所有 API 都背下来但两个最基础的概念必须清楚。第一个是BVS全称 BitVectorSymbol用来创建一个符号位向量。它有两个核心参数名称和位宽。名称只是给这个符号起的字符串名字通常是给人看的比如flag、arg0不一定需要唯一但实践中建议起成有含义的名字因为后续调试 AST 时你会靠名字辨认变量。位宽必须是整数常见的有 8、16、32、64。创建一个 32 位符号变量写法是import claripy x claripy.BVS(x, 32)第二个是BVV全称 BitVectorValue用来创建一个具体值的位向量。比如claripy.BVV(10, 32)表示一个 32 位的具体数值 10。你可能觉得这不是多余吗直接用 Python 的int不就行了不行。因为在 claripy 的表达式体系里符号变量是 AST 节点具体值也要以 AST 节点形式参与运算才能统一进行位宽检查和表达式化简。如果你把一个 Python int 直接塞给 angr 的某个寄存器可能会得到类型错误或者在运算时出现隐式位宽不匹配。类型名称用途示例BVSBitVectorSymbol符号变量代表未知值claripy.BVS(x, 32)BVVBitVectorValue具体值节点claripy.BVV(0x10, 32)这两个概念是所有符号化操作的基石。在 angr 的SimState里你通常可以直接用state.solver.BVS来创建符号变量它其实就是 claripy.BVS 的一层封装效果完全一样。2.2 SimState与state.regsangr 模拟执行的核心对象是SimState中文常翻译成“状态”。一个状态里包含了 CPU 寄存器、内存、文件描述符、求解器、路径约束等一大堆东西。你可以把状态理解成“某一条执行路径在当前时刻的完整快照”。操作寄存器的入口是state.regs。这是一个类似字典的属性对象按寄存器名访问或赋值。例如在 x86-64 架构下state.regs.rax claripy.BVS(input, 64)这一行执行后rax就变成了一个 64 位的符号变量。接下来任何指令只要读取rax拿到的都是这个符号表达式的后续演化结果。读取时也可以直接取state.regs.rax返回的是一个 AST 对象它的类型是claripy.ast.BV。state.regs真正强大的地方在于它自动处理了架构差异。同样是“栈指针”x86 里叫espARM 里叫spangr 都会映射到对应架构的寄存器集合里。你不需要记“这个架构到底用哪个寄存器存返回值”只要用state.regs.eax或者state.regs.raxangr 会按照架构的寄存器名约定进行访问。如果你的目标架构比较偏门还可以用state.arch.registers查看到底有哪些寄存器名可用。2.3 最小符号化骨架代码一个最基础的寄存器符号化流程可以压缩成四步加载程序、创建状态、符号化寄存器、求解。下面的代码是一个可以照着改的最小骨架import angr import claripy proj angr.Project(./target_binary, auto_load_libsFalse) state proj.factory.blank_state(addr0x401120) state.regs.rcx claripy.BVS(arg0, 64) simgr proj.factory.simulation_manager(state) simgr.explore(find0x401156, avoid0x40116a) if simgr.found: found simgr.found[0] solution found.solver.eval(found.regs.rcx) print(hex(solution))这段代码里有一个很多人一开始不理解的细节为什么用blank_state(addr...)而不是entry_state()因为entry_state是从程序入口开始执行你很少能直接控制入口处的寄存器值而blank_state允许你指定“从哪个地址开始模拟”并自由设置初始寄存器内容。做函数级分析时blank_state配合一个函数入口地址是最干净的组合。不过这个骨架非常粗糙真实场景里你会发现很多坑比如find地址找不准、符号变量在某个指令里被偷偷截断了、求解器给出的解和预期不符等等。下面我用一个完整可复现的例子带你一步步搞清楚寄存器符号化到底应该怎么写。3. 寄存器符号化的完整实操流程3.1 准备一个可复现的目标程序纸上谈兵没意思我写一个足够小、但包含真实逻辑的程序来演示。假设我们有这样一个 C 文件// mystery.c #include stdio.h int mystery(int a, int b) { if (a * b 30 a b 11) { return 1; } return 0; } int main(int argc, char **argv) { int a 3, b 7; printf(%d\n, mystery(a, b)); return 0; }这个mystery函数就是我们的分析目标参数a、b通过寄存器传入函数内部有一个复合条件。我们希望做的是不修改程序、不手动试值让 angr 自己把满足条件的a和b求出来。编译时建议关掉优化和 PIE方便找地址gcc -O0 -fno-pie -no-pie -o mystery mystery.c编译后用nm mystery找到mystery函数的地址。在我本机上是0x401126你环境里的地址可能不同以后面nm输出为准。再反汇编看一眼结构确认两个分支分别落在哪里。为了叙述方便我假设反汇编后的布局是这样的0x401126mystery函数入口0x401156条件满足时设置eax 1并返回的基本块0x40116a条件不满足时设置eax 0并返回的基本块实际分析时请用objdump -d mystery确认不要照抄我的地址。3.2 把未知参数写进寄存器在 angr 里给函数传参除了最直接的state.regs.rdi a还有更规范的做法call_state。这个接口能按照目标架构的函数调用约定自动把参数放到对应的寄存器里并且自动初始化返回地址和栈帧。对 x86-64 来说mystery的两个整数参数会分别放到rdi和rsi。import angr import claripy proj angr.Project(./mystery, auto_load_libsFalse) a claripy.BVS(a, 64) b claripy.BVS(b, 64) state proj.factory.call_state(0x401126, a, b)为什么符号变量用 64 位而不是 32 位因为在 x86-64 调用约定里即使 C 语言函数参数是int参数寄存器也使用完整的 64 位寄存器rdi和rsi只是读取时只使用低 32 位。如果你用 32 位符号变量赋给state.regs.rdiangr 会做隐式零扩展后面求解时会出现一些不好理解的约束。先统一用 64 位符号变量配合int语义是相对省心的选择。这里插一句我自己的经验如果你不想关心调用约定也可以直接用blank_state加手动赋值state proj.factory.blank_state(addr0x401126) state.regs.rdi a state.regs.rsi b两种方式最终的效果差不多但call_state会自动把返回地址等信息准备好后续如果你想分析函数返回后的行为会方便很多。本篇以call_state为主线。3.3 设置探索目标和规避地址符号化好参数寄存器之后我们让 angr 从mystery函数入口开始跑。但这里有个关键点我们关心的不是“程序最终走到哪”而是“走到某个目标地址时参数应该满足什么条件”。用符号执行引擎自带的路径探索功能我们可以让 angr 同时探索所有可能路径并筛选出我们关心的那两条路径。find参数指定目标地址avoid参数指定要避开的地址simgr proj.factory.simulation_manager(state) simgr.explore(find0x401156, avoid0x40116a) if simgr.found: found_state simgr.found[0] print(found state at, hex(found_state.addr))find和avoid都可以是地址列表也可以是带addr属性的函数。用函数会更灵活比如“当eax 1时停下”但在我们的简单例子里直接按地址找最直观。走到这一步angr 内部已经发生了很多事情它沿着二进制指令一条条模拟执行每次遇到条件跳转就分叉出一个新状态每个状态各自保存自己的路径约束。mystery函数里那个复合条件会被 angr 拆成多个分支约束。我们找到的那个found_state里面的求解器已经携带了“走到地址 0x401156 必须满足的所有条件”。3.4 把符号解回具体值现在到了最关键也最让人兴奋的一步用求解器把符号变量解成具体数字。sol_a found_state.solver.eval(a) sol_b found_state.solver.eval(b) print(a , sol_a, b , sol_b)solver.eval的作用是在满足当前状态所有约束的前提下给符号变量求出一个可行的具体解。在我的环境里输出的结果是a 5、b 6正好满足5 * 6 30且5 6 11。不过要注意eval只返回一个解而满足条件的解可能不止一个。比如把函数条件改成a b 5那么解就有无数多个eval会随便挑一个。如果你想拿到所有有代表性的值可以用eval(expr, n)一次求多个解或者用min、max求极值min_a found_state.solver.min(a) max_a found_state.solver.max(a)每个符号执行状态里默认带有一个solver它内部维护着该状态的全部路径约束。你也可以用found_state.solver.add_constraint(...)手动追加约束比如你额外知道“a 必须大于 0”就追加一条再重新求解。追加约束不会抹掉之前的路径条件只是让可行域缩小。到这里一个最完整的寄存器符号化流程就跑通了。你可能会觉得这个例子这么简单手动心算也能出结果。没错但同样的流程放到几 MB 的二进制、几十个嵌套分支里手动算是不可能的而寄存器符号化依然能稳定地把约束收集起来这就是它的价值所在。4. 进阶玩法从单寄存器到整机状态4.1 寄存器别名与高低位处理真正写代码时会遇到一个绕不开的细节x86-64 的寄存器别名。rax、eax、ax、al都指向同一块物理寄存器只是位宽不同。angr 在state.regs里也模拟了这个行为但有一些细节必须注意。如果你先执行state.regs.rax claripy.BVS(x, 64)然后读取state.regs.eaxangr 会返回x的低 32 位表达式记作Extract(31, 0, x)。这没问题。但反过来就有讲究了如果你先执行state.regs.eax claripy.BVS(y, 32) state.regs.rax ...x86-64 的硬件语义是写 32 位寄存器会清零高 32 位angr 在大多数架构下也模拟了这一点。这意味着你写eax后再读rax得到的并不是“一个符号变量 y 另一个保留的 rax 高位”而是“y 的零扩展”。如果没意识到这一点后面求解出来的值可能和你预期的完全不一样。实操建议是在一个分析中对同一个物理寄存器始终使用同一种位宽访问。如果你主要关注函数参数就都用 64 位符号变量如果你主要关注返回值的低 32 位就始终用 32 位读取。混用别名是很多“莫名其妙”问题的根源。4.2 多寄存器协同约束单个寄存器符号化只是第一步。真实程序里一个函数的多个参数往往不是独立的它们会共同参与同一个判断条件。我们的mystery例子就是这样——a和b必须同时满足两个等式。当多个参数都是符号变量时angr 的路径约束会自动把它们的关联关系带上。你不需要手动写a b 11这样的额外约束因为模拟执行到cmp指令时angr 已经自动把a b 11这条约束加进当前路径的求解器了。你要做的只是确保每个参数的符号变量在创建后没有重复使用。这里有一个很容易踩的坑符号变量的“名字复用”。很多人会这样写param1 claripy.BVS(p, 64) param2 claripy.BVS(p, 64) state.regs.rdi param1 state.regs.rsi param2两个符号变量名字都叫p但它们是两个完全不同的 AST 对象。angr 不会因为它们名字相同就认为它们相等。如果你误以为同名的符号变量一定相等最终求解结果会让你困惑很久。正确的做法是给每个参数不同的名字并且全程复用同一个对象。4.3 与内存符号化配合使用说完了寄存器再说说寄存器和内存的连接。函数经常先把参数寄存器存到栈上之后再从栈里取出来判断代码经过编译优化后尤其常见。angr 在执行mov [rbp-0x4], rdi时会自动把符号表达式写入对应的内存位置之后从该地址读出的仍然是同一个符号表达式。这是符号执行非常优雅的一点符号值在寄存器和内存之间搬运时不会丢失其“身份”。更进一步你完全可以混合使用寄存器符号化和内存符号化。比如你想模拟一个结构体第一个字段由寄存器传入的某个值经过计算得到第二个字段是固定的内存值你就可以stack_var state.stack_load(8, claripy.BVS(stack_data, 64))这里stack_load会从当前栈指针偏移 8 的位置读入符号值并把这个地址标记为已初始化。这样内存和寄存器同时带着符号执行起来就和真实程序一样自然。4.4 还原入口参数约束的完整思路有了前面的基础我分享一个真正在做逆向分析时非常有用的思路还原某个关键函数的入口参数约束。假设你有一个加密函数输入是rdi指向的缓冲区、rsi是长度、rdx是密钥你手里只有密文和程序想知道密钥是什么。你可以这样做找到加密函数入口地址用call_state创建状态把rdi设成一个指向符号缓冲区的指针rsi设成具体的密文长度rdx设成符号密钥再准备一块符号化的缓冲区往里面填入密文字节作为约束让 angr 从函数入口执行到函数返回在返回后的状态里检查缓冲区内容是否等于明文把这个等号作为额外约束加入求解器用solver.eval(rdx)求出密钥。这套方法听起来很神奇但本质上就是“寄存器符号化 内存符号化 路径约束 求解”的组合拳。你在自己的分析任务里也可以把“哪些输入是已知的”设成具体值把“哪些输入是未知的”设成符号变量剩下的事情交给 angr 自动完成。5. 常见问题与避坑速查5.1 为什么符号化寄存器后程序直接崩了/状态不可行符号化寄存器之后最常见的报错是unsat也就是状态不可行。原因不外乎三类。第一类是初始约束就矛盾。比如你从一个blank_state开始前面已经手动加了state.regs.rax 10后面又符号化覆盖了rax之前的约束还挂在求解器里新的符号对象和旧约束之间就会出现矛盾。遇到这种情况清理掉不需要的约束或者干脆新建状态。第二类是参数位宽不匹配。你给一个 64 位寄存器赋了 32 位符号变量angr 自动扩展成零扩展而程序指令却按符号扩展或者按有符号比较来用导致求解得到的值完全对不上。解决办法是统一位宽。第三类是call_state调用方式不对。call_state(addr, arg1, arg2)这种写法必须让 arg 数量、类型和函数真实签名匹配。如果函数实际有三个参数你只给了两个angr 会拿未初始化的寄存器去凑这时候求解结果就会神游四方。用objdump看一眼函数开头的几条指令数一下到底用了哪些参数寄存器再回头检查调用。5.2 求解结果千奇百怪甚至符号消失了有时候你明明符号化了某个寄存器最后求出来的值却像是一个固定常量或者多跑几步就不见了。这通常不是 angr 的锅而是某条指令把符号值“具体化”了。最常见的元凶是系统调用和库函数调用。如果你分析的程序调用了外部函数而 angr 没有对应实现它可能返回一个未约束的具体值符号变量就断了。所以我在例子里一直强调auto_load_libsFalse把外部库加载关掉让不认识的调用直接返回未约束值而不是尝试去执行库代码。另一种情况是state.regs.rax state.solver.BVS(...)之后又被某条mov指令覆盖了。比如程序开头就有一条xor eax, eax你的符号变量在第一条指令就被清零了。排查这类问题时最有效的工具是打印 ASTprint(state.regs.rax) print(state.regs.rax.args)看输出结果里你的符号变量名字是否还保留着。如果 AST 里已经是一个常量说明在某一步符号被覆盖了如果 AST 里出现了Extract、ZeroExt等节点说明有隐式位扩展。很多时候问题一眼就能看出来。5.3 求解器跑得很慢怎么优化符号求解的耗时是另一个大问题。我的经验是约束越多、符号变量越多、位宽越大求解就越慢。尤其当程序里循环次数多路径约束会指数级累积一个很小的函数也可能让 Z3 卡上几十分钟。几个实用优化手段尽量减少符号变量的数量。能具体化的输入就具体化别偷懒全设成符号缩小寄存器位宽。比如int类型的参数用 32 位符号变量就行非要塞 64 位约束空间大一圈求解成本也上去用explore的avoid参数砍掉不关心的分支路径越少状态越少整体求解资源消耗就越低对单个状态的求解操作可以加超时控制。angr 的 solver 支持timeout参数比如eval(expr, timeout10000)超时返回一个已知解或抛异常至少不会让脚本无限卡死。5.4 我的寄存器值为什么对不上最后一种常见困惑是“我用 angr 解出来的值放到真实程序里跑结果不对”。我把它列成速查表现象可能原因排查方向求解出的值和预期差很大有符号/无符号不匹配检查代码里的条件跳转是jg还是ja目标分支找到了但参数解少得离谱函数入口地址找错了用nm或反汇编确认入口是函数头不是中间指令多个解互相冲突符号变量名或对象重复检查是否复用了同一个 BVS 对象解在多次运行中不稳定求解器选了不同可行解不用纠结用min/max或追加约束缩小范围寄存器值总是在中途归零某条指令覆盖了寄存器打印相关地址的反汇编逐条检查这套表是我在实际分析中一点点攒下来的基本上覆盖了大部分新手会踩的坑。真正遇到诡异问题时先别怀疑 angr先怀疑自己的地址和约束。最后再分享一个小技巧我个人在实际操作中的一个体会是做寄存器符号化分析建议先别急着直接上完整的大二进制而是自己动手写一个带三四个嵌套分支的小程序编译后用这个流程反复练。小程序的每个地址、每个分支都是你自己设计的遇到问题你能立刻判断是 angr 的锅还是自己思路的锅。等小例子跑顺了再拿 CTF 题或者真实逆向样本去试会顺手非常多。另外调试时一定要多习惯看 AST。很多人把state.regs.rax当成普通 Python 值打印一下就是一个对象地址于是放弃。实际上print(state.regs.rax)会输出一棵清晰可读的表达式树比如(x 5)、(Extract(31, 0, x))。学会读这棵树比看任何日志都管用。寄存器符号化这个主题入门看教程深入靠调 AST动手实操几次就能找到感觉。