写了这么久的学习笔记我一直觉得 angr 最值得玩味的不是“它很强大”这种空话而是“你把它用在哪个入口”。前面几篇笔记我跑通了最基础的流程加载二进制、构造状态、用 exploration 技术找目标路径、最后用 solver 把符号值变成具体数字。但说实话那些 demo 大多从 stdin 读输入符号化的是文件里的一段字节。可真到实际分析目标时哪来那么多“请从标准输入读取”的程序函数参数才是最常见的输入载体而函数参数在 x86_64 上就是 rdi、rsi、rdx 这几个寄存器。这就是今天这篇 angr 学习笔记4要解决的问题寄存器符号化。这篇笔记不会复述官方文档而是从“为什么要符号化寄存器”讲起然后给一个可以亲手复现的极简 demo从编译、反汇编到 angr 脚本一步步拆开。如果你已经会跑 angr 的基础流程但对“怎么把某个寄存器变成符号量”还有点懵那这篇就是为你写的。1. 寄存器符号化到底在解决什么问题1.1 先想清楚输入到底从哪里来用 angr 分析程序时第一步其实是回答“输入在哪里”。常规教程喜欢让你分析一个只从 stdin 读数据的程序因为这样做最省事state proj.factory.entry_state()然后 angr 会自动把标准输入抽象成一个符号缓冲区程序里所有read(0, buf, n)都会往这个符号缓冲区里读数据。路径约束会自动记录你输入了多少字节、哪些字节被if判断过。最后求解时直接state.solver.eval(sym_input)就能拿到一组让目标路径成立的输入。但真实场景往往不是这样。我遇到过的几种情况你想分析的不是整个程序而是其中的一个函数。这个函数从某个设备驱动、某个网络回调、某个 API 接口里拿到参数参数就是寄存器。目标程序有复杂的启动逻辑、自解密、花指令甚至会在入口处做一堆浪费时间的初始化。你根本不想从_start开始跑你想直接跳到关键函数入口把参数当作符号。你手里只有一个固件包或一个 so没有完整可执行环境没法把它当普通进程跑起来。你想分析 shellcode 或者一段反汇编出来的裸代码压根没有“标准输入”这个概念。这些情况下最自然的抽象就是寄存器就是输入。你把 rdi、rsi 这类的寄存器符号化就等于告诉 angr——“我不知道调用者传了什么值进来请你把这些值当作未知数去求解”。1.2 符号化寄存器比符号化标准输入好在哪从工程角度讲符号化寄存器最大的价值是“缩小分析范围”。从entry_state开始跑angr 要把程序入口、初始化、动态链接等等全部模拟一遍很多无关路径会拖垮性能。而直接从某个函数地址开始符号化它的入参寄存器等于直接把分析边界画在了函数调用边界上路径数量瞬间小了一个数量级。举个例子你想知道某个函数在什么参数组合下会进入特定分支。从主函数开始分析你得先熬过一大堆参数解析、环境检查、配置读取但从函数入口开始分析只需要几步用blank_state(addrfunc_addr)创建一个从目标地址开始的空状态。手动把入参寄存器赋成符号变量。跑 explore找目标地址。用 solver 求出一组寄存器值。这就是寄存器符号化的核心套路。不是 angr 里藏了什么奇特的魔法而是你主动选择了分析起点。1.3 典型应用场景一览分析某 API 函数的边界条件比如某个处理函数的参数是(size_t len, void *buf)你想知道 len 取什么值会触发漏洞分支。分析固件里的某个回调函数固件里往往有大段初始化代码但核心逻辑集中在几个回调里寄存器就是这些回调的输入。分析函数库导出函数只给你一个.so你可以直接把导出函数地址作为入口符号化参数。绕过反调试/反虚拟化从入口开始跑会被反调试代码干扰直接从关键算法处开始则能绕开大半干扰。代码洞/补丁分析对比补丁前后某个函数的输入边界变化。2. 前置知识位向量、状态构造与求解器2.1 claripy 与位向量BitVecangr 的符号执行底层依赖 claripy它引入了一个核心概念位向量BitVec。你可以把它理解成一个“未知数”但它有确定的位宽。计算机里所有数据本质上都是位向量寄存器、内存单元、立即数都一样。在 claripy 里创建符号变量用BVS创建具体常量用BVVimport claripy sym_x claripy.BVS(x, 64) # 一个 64 位的符号变量名叫 x one claripy.BVV(1, 64) # 一个 64 位的常量 1名字随便起但位宽必须正确。在 x86_64 下rax 是 64 位eax 是它的低 32 位ax 是低 16 位al 是低 8 位。你符号化 rdi 时如果用 32 位BVSangr 内部会类型错乱。这个坑后面我会单独讲。用 BVS 构造的符号值可以参与运算expr (sym_x 3) ^ (sym_y 0x88)angr 不会直接算出结果而是把这个表达式记成一棵抽象语法树。路径约束越积越多最后交给 solver 去解。2.2 三种构造 State 的方式angr 提供了几种构造初始状态的方法用途完全不同。学习寄存器符号化之前得先分清楚它们方法含义适用场景entry_state()从程序入口_start开始执行分析完整程序适合符号化 stdin/argvcall_state(addr)模拟call addr之后的状态返回地址和栈已准备好分析函数级逻辑符号化函数参数blank_state(addr)从任意地址开始不执行入口逻辑跳过程序初始化直接进入感兴趣代码寄存器符号化最常用的其实是后两者。call_state更“规范”一些它会把返回地址压栈符合调用约定blank_state则更自由它不模拟调用过程适合直接落在某个指令地址上。很多人在这一步容易困惑到底用call_state还是blank_state我的实践是如果目标函数是普通 C 函数且我关心它的返回值用call_state更省心因为返回地址已经被 angr 处理好了如果我只是想从某段代码中间开始跑或者这个“函数”根本不是标准调用约定固件里的裸函数那就用blank_state手动控制寄存器和栈。2.3 核心 API 一览API作用proj.factory.blank_state(addr...)创建从指定地址开始的状态state.regs.rdi sym_x将 rdi 寄存器设置为符号变量state.regs.rsi sym_y将 rsi 寄存器设置为符号变量state.add_constraints(expr)手动添加约束条件simgr.explore(find..., avoid...)探索路径寻找目标地址state.solver.eval(expr)在当前路径约束下解一个具体值state.solver.eval(expr, n3)解多个解3. 搭一个可复现的验证程序3.1 为什么要自己造 demo网上很多 angr 教程爱分析现成的 CTF 题目但你不知道题目二进制有什么额外特性调试起来很费劲。我自己学习时的习惯是先造一个逻辑可控、能快速验证的二进制把 angr 的机制搞明白再上真正的目标。所以这个 demo 的条件是逻辑简单但又不是一眼就能看出答案需要 angr 来解约束。不依赖动态库、不调用 libc避免 angr 在外部函数上卡死。支持用 objdump 直观地找出关键分支地址。条件最好能手工算出一组解用来验证 angr 求出的结果对不对。3.2 demo 的 C 源码#include stdint.h volatile uint64_t g_flag 0; void check(uint64_t x, uint64_t y) { uint64_t r (x 3) ^ (y 0x88); if (r 0xdeadbeef (x 0xf) 7 y 0x1000) { g_flag 0x12345678; // 成功分支 } else { g_flag 0xdead; // 失败分支 } } int main(void) { return 0; }这里的check函数模拟一个真实场景某个外部调用者把参数放在 rdi、rsi 里传给我我需要分析check内部逻辑。g_flag用volatile修饰并初始化成 0是为了防止编译器在优化时把分支合并或删掉当然-O0下基本不会但加上心里踏实。编译命令gcc -O0 -fno-pie -no-pie -o demo demo.c注意这里-fno-pie是编译选项-no-pie是链接选项。加上这两个是为了让生成的指令用绝对地址objdump 里看地址时更直观也避免 PIE 后 angr 的地址要来回换算。3.3 用 objdump 定位关键地址编译完之后用普通反汇编工具查看check函数objdump -d demo | grep -A 80 check:你会看到类似下面的代码具体地址取决于环境这里用占位符演示0000000000401156 check: 401156: 55 push %rbp 401157: 48 89 e5 mov %rsp,%rbp 40115a: 48 89 7d e8 mov %rdi,-0x18(%rbp) 40115e: 48 89 75 e0 mov %rsi,-0x20(%rbp) ... 40119a: 48 c7 05 xx xx xx xx 78 56 34 12 movq $0x12345678, ... 4011d8: 48 c7 05 xx xx xx xx ad de 00 00 movq $0xdead, ...我们要记两个地址成功分支写入0x12345678的那条mov指令地址记为FIND_ADDR。失败分支写入0xdead的那条mov指令地址记为AVOID_ADDR。FIND_ADDR是 angr 搜索的目标AVOID_ADDR是提前剪枝的分支。这两个地址直接在 objdump 输出里找常量12345678和00000000000000dead就能定位。3.4 为什么这样设计的 demo 适合学寄存器符号化第一它没有任何外部调用angr 不会因为puts、printf这种库函数而陷入无穷的库内部逻辑。第二条件表达式里既有算术运算又有位运算还有范围比较可以把 angr 的约束求解能力完整调动起来。第三我们人为地把main留空、把分析目标放在未调用的check函数上这是为了强制自己使用“从中间开始分析”的思路——这正好是寄存器符号化的经典场景这函数根本不在主流程里被调用所以从入口开始跑永远到不了这里。顺手验证一下约束是否有解。取x 0x7则(x 3) 0x38且x 0xf 7。要让0x38 ^ (y 0x88) 0xdeadbeef只需y 0x88 0xdeadbed7得到y 0xdeadbe4f它大于0x1000。所以逻辑上这组解是存在的后面 angr 求出来时我们可以对照。4. 核心实操寄存器符号化与求解4.1 流程总览寄存器符号化的标准流程可以拆成五步加载二进制。创建一个从目标函数地址开始的 blank state。把入参寄存器赋值成 BVS 符号变量。用 simulation manager 跑 explorefind 到成功分支。从 found state 里求解符号变量的具体值。其中第 2 步是“从哪开始”第 3 步是“输入是什么”。很多教程讲 angr 时只讲后面三步导致新手以为一定要从entry_state跑完整个程序。实际上跳过前两步分析范围一下子就聚焦了。4.2 第一步创建 blank_state 并从 check 入口开始import angr import claripy proj angr.Project(./demo, auto_load_libsFalse) CHECK_ADDR 0x401156 # 用你本机 objdump 看到的 check 地址代替 FIND_ADDR 0x40119a # 写入 0x12345678 的指令地址 AVOID_ADDR 0x4011d8 # 写入 0xdead 的指令地址 state proj.factory.blank_state(addrCHECK_ADDR)这里auto_load_libsFalse很关键。对于动态链接的二进制angr 默认会尝试去加载依赖的共享库。这个 demo 虽然不调用库函数但也是动态链接出来的关掉自动加载共享库可以避免 angr 在分析时追进一堆系统库代码。如果你是在call_state模式下可以更省心一些但我觉得用blank_state才能真正理解“寄存器符号化”的底层逻辑你手动告诉 angr现在的状态就是某个函数刚进入时的状态寄存器和栈都由你说了算。4.3 第二步符号化 rdi 和 rsix86_64 System V 调用约定里第一个参数放在 rdi第二个放在 rsi。我们的check(x, y)对应的就是sym_x claripy.BVS(sym_x, 64) sym_y claripy.BVS(sym_y, 64) state.regs.rdi sym_x state.regs.rsi sym_y这两行就是整篇笔记的核心。之前我们用entry_state启动时angr 会为read的缓冲区创建符号变量现在我们自己动手把寄存器变成符号变量。效果其实是一样——程序后续所有对 rdi、rsi 的操作都会在这些符号变量的抽象语法树上累积约束。那我为什么不用call_state然后state.regs.rdi ...也一样其实可以但call_state还会自动处理返回地址、栈布局对新手来说容易“知其然不知其所以然”。blank_state逼着你把一切状态要素想清楚。4.4 第三步建立 simulation manager 并探索路径simgr proj.factory.simulation_manager(state) simgr.explore(findFIND_ADDR, avoidAVOID_ADDR)explore的含义是让 angr 从当前状态开始做符号执行所有分支路径都会产生新的状态并继续跑直到某个状态执行到了FIND_ADDR这个状态被放进simgr.found。某个状态要执行AVOID_ADDR直接丢弃不再扩展。状态走到尽头或出错进入deadended/errored。如果路径约束是满足的angr 会很快找到成功分支。这里它到底怎么“决定”走哪条路呢不是猜而是把每个if分支的 condition 收集成路径约束送给求解器判断可满足性。分支两边都会探索但不可满足的那边走了一步就会觉得发现不了新路径自然停掉。这一步你会看到find和avoid一起用的威力find是目标avoid是剪枝。如果找不到FIND_ADDR可以试试加simgr.explore(findFIND_ADDR, avoidAVOID_ADDR, num_find5)或者用simgr.use_technique(angr.exploration_techniques.DFS())改为深度优先策略防止广度优先卡在无关路径上。4.5 第四步从 found state 中求解符号值探索结束后如果找到了目标状态它就在simgr.found列表里。这个状态带着一路累积下来的路径约束可以从中求解出具体的寄存器值if simgr.found: found_state simgr.found[0] x_val found_state.solver.eval(sym_x) y_val found_state.solver.eval(sym_y) print(x , hex(x_val)) print(y , hex(y_val)) else: print(not found)重点理解found_state.solver.eval(sym_x)不是随便给一个数字它是在当前状态的全部路径约束条件下求出一个能令所有约束成立的解。因为我们用寄存器符号化建立了两个符号变量所有程序分支路径都会把条件逐步加到约束里最后eval就能还原出“如果成功分支可达那么 rdi、rsi 至少要满足什么”。结合之前手算的结果输出大概率会是x 0x7 y 0xdeadbe4f注意求解器返回的是某一组可行解不一定是这组。比如 x 取0x17低 4 位也是 7也可以y 也会相应变。求出的值只要满足约束就是合法解。4.6 完整脚本把上面整合成一个脚本import angr import claripy proj angr.Project(./demo, auto_load_libsFalse) CHECK_ADDR 0x401156 FIND_ADDR 0x40119a AVOID_ADDR 0x4011d8 state proj.factory.blank_state(addrCHECK_ADDR) sym_x claripy.BVS(sym_x, 64) sym_y claripy.BVS(sym_y, 64) state.regs.rdi sym_x state.regs.rsi sym_y simgr proj.factory.simulation_manager(state) simgr.explore(findFIND_ADDR, avoidAVOID_ADDR) if simgr.found: found_state simgr.found[0] x_val found_state.solver.eval(sym_x) y_val found_state.solver.eval(sym_y) print(x , hex(x_val)) print(y , hex(y_val)) else: print(not found)跑完这个脚本你就完整地走了一遍寄存器符号化的流程。以后遇到任何“某函数参数满足什么条件会走进某分支”的问题核心套路就长这样。4.7 进阶手动加约束缩小求解范围很多时候你不想拿任意一组解而是想限定输入范围。比如我想让 y 的下一个解在0x10000到0x20000之间if simgr.found: found_state simgr.found[0] y_val found_state.solver.eval(sym_y, extra_constraints[sym_y 0x10000, sym_y 0x20000]) print(y , hex(y_val))extra_constraints是eval的一个常用参数它不会改动状态本身的约束只会在这一次求值时临时加入额外条件。这个技巧在分析实际样本时非常有用能帮你快速把解空间收窄。5. 常见问题与避坑经验5.1 寄存器位宽匹配这是新手最容易踩的坑。x86_64 的 rdi 是 64 位你在符号化时必须用 64 位的 BVS。但如果你用的是 rdi 的低 32 位语义比如edi也不能直接把一个 32 位 BVS 赋给state.regs.rdi因为 angr 会认为整个寄存器宽度变了后续指令按 64 位读取时会出现位宽不匹配。正确做法是构造统一的 64 位符号变量然后用Extract约束低 32 位比如sym claripy.BVS(sym, 64) state.regs.rdi sym state.add_constraints(sym[31:0] claripy.BVV(0x12345678, 32))再或者直接用state.regs.edi sym32angr 会把 32 位值写进 rdi 的低 32 位高 32 位变不变取决于具体实现但最好别依赖它。5.2 blank_state 的栈与返回地址用blank_state从函数入口开始函数的push rbp; mov rsp, rbp一般没问题但函数末尾的ret会面临一个问题栈顶的返回地址是什么blank_state的栈是默认栈内容全是 0所以ret会把 0 当作下一条指令地址angr 尝试从 0 地址取指令状态会被置为 errored。对explore(find...)来说这通常不影响搜索结果因为它已经先把FIND_ADDR状态记录到found了。但如果函数后面继续跑了很多条ret相关路径会白白增加状态数量。稳妥做法是手动设置一个“无害返回地址”state.regs.rsp 0x7fff0000 state.mem[state.regs.rsp].uint64_t 0x400000 # 指向一个可执行但无关紧要的地址或者干脆在blank_state之后别让它跑太久用explore(num_find1)尽早收手。5.3 find/avoid 写不好导致路径爆炸find和avoid不是随便填的。如果你把一个“两个分支都会经过”的普通指令地址当成findangr 会在还没拿到关键约束时就提前停止。反过来如果你把avoid设得太宽泛可能把有效状态全剪掉。我的习惯是先通过 objdump/IDA 静态看一遍目标分支确认find地址是“成功分支所独有”的指令avoid地址是“失败分支所独有”的指令。如果目标分支没有明显的独有指令就用find(条件函数)来匹配状态属性。5.4 state.solver 与 state.eval 的版本差异angr 更新比较快不同版本 API 略有差异。老教程里常见state.solver.eval(expr)写法在较新版本中仍然可用但很多代码已经迁移到state.eval(expr)。实际跑的时候如果报AttributeError: SimState object has no attribute solver就把那行改成found_state.eval(sym_x)如果反过来老版本不支持state.eval就用found_state.solver.eval(sym_x)。我在文中的代码用的是found_state.solver.eval兼容性更好。反正你只要理解eval 的作用是在所有已知约束下求一个具体的比特向量值。5.5 多解与额外约束符号执行求解器默认返回任意一个可行解。同一个成功分支可能对应大量输入所以如果你拿到一个解却感觉“不对劲”不要慌。可以先看看路径约束再决定要不要加约束print(found_state.solver.constraints)输出是一堆布尔表达式。如果你想拿多个解做对照用eval(expr, n5)solutions found_state.solver.eval(sym_x, n5)它会返回最多 5 个不同解。5.6 求解结果跟预期不符最常见的原因就是初始寄存器没有完全初始化。blank_state下除了你手动符号化的 rdi、rsi其他寄存器默认可能是 0。如果目标函数里某些分支依赖了未初始化的寄存器angr 可能把它当常量 0 处理导致路径约束和真实情况不一致。解决办法是把所有可能被用到的入参寄存器都显式写成符号变量或者用call_state让它基于调用约定生成更完整的初始状态。寄存器符号化很强大但前提是你要把“输入面”覆盖全。写在最后的个人体会学习 angr 的过程中我最大的收获不是背会了多少 API而是理解了“从哪里开始分析”和“把什么当作未知数”才是解决问题的关键。寄存器符号化看起来只是几行state.regs.rdi sym_x但它背后代表的思维转变是不再把程序当作从入口到出口的一条流而是把它切成一个个可独立分析的函数边界。当你把某个函数入口处的寄存器变成符号你实际上是在问“在什么输入条件下这个函数里的某个路径是可达的”这个问题在漏洞分析、协议逆向、补丁比对里都极其常用。最后再分享一个小技巧如果你跑完 explore 发现状态数量失控先别急着加 DFS/BFS 策略先回头数数自己到底符号化了几个寄存器。很多路径爆炸是因为符号变量太少导致 angr 在分支上“无中生有”出大量不可控状态反而把该符号化的都符号化、该预先约束的都约束住路径会干净得多。寄存器符号化不是一个孤立操作它是和路径约束、求解器、探索策略配合起来的整套方法论希望这篇笔记能帮你把这根线串起来。