前几篇笔记把 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/BVV
angr 的符号表达式底层由名为 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 的某个寄存器,可能会得到类型错误,或者在运算时出现隐式位宽不匹配。
| 类型 | 名称 | 用途 | 示例 |
|---|---|---|---|
| BVS | BitVectorSymbol | 符号变量,代表未知值 | claripy.BVS("x", 32) |
| BVV | BitVectorValue | 具体值节点 | claripy.BVV(0x10, 32) |
这两个概念是所有符号化操作的基石。在 angr 的SimState里,你通常可以直接用state.solver.BVS来创建符号变量,它其实就是 claripy.BVS 的一层封装,效果完全一样。
2.2 SimState与state.regs
angr 模拟执行的核心对象是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 里叫esp,ARM 里叫sp,angr 都会映射到对应架构的寄存器集合里。你不需要记“这个架构到底用哪个寄存器存返回值”,只要用state.regs.eax或者state.regs.rax,angr 会按照架构的寄存器名约定进行访问。如果你的目标架构比较偏门,还可以用state.arch.registers查看到底有哪些寄存器名可用。
2.3 最小符号化骨架代码
一个最基础的寄存器符号化流程可以压缩成四步:加载程序、创建状态、符号化寄存器、求解。下面的代码是一个可以照着改的最小骨架:
import angr import claripy proj = angr.Project("./target_binary", auto_load_libs=False) state = proj.factory.blank_state(addr=0x401120) state.regs.rcx = claripy.BVS("arg0", 64) simgr = proj.factory.simulation_manager(state) simgr.explore(find=0x401156, avoid=0x40116a) 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输出为准。再反汇编看一眼结构,确认两个分支分别落在哪里。为了叙述方便,我假设反汇编后的布局是这样的:
0x401126:mystery函数入口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_libs=False) 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.rdi,angr 会做隐式零扩展,后面求解时会出现一些不好理解的约束。先统一用 64 位符号变量,配合int语义,是相对省心的选择。
这里插一句我自己的经验:如果你不想关心调用约定,也可以直接用blank_state加手动赋值:
state = proj.factory.blank_state(addr=0x401126) 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(find=0x401156, avoid=0x40116a) 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.eax,angr 会返回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_libs=False:把外部库加载关掉,让不认识的调用直接返回未约束值,而不是尝试去执行库代码。
另一种情况是state.regs.rax = state.solver.BVS(...)之后又被某条mov指令覆盖了。比如程序开头就有一条xor eax, eax,你的符号变量在第一条指令就被清零了。排查这类问题时,最有效的工具是打印 AST:
print(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, timeout=10000),超时返回一个已知解或抛异常,至少不会让脚本无限卡死。
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,动手实操几次就能找到感觉。