ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

angr寄存器符号化实战:从符号执行到逆向约束求解

angr寄存器符号化实战:从符号执行到逆向约束求解

前几篇笔记把 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 的某个寄存器,可能会得到类型错误,或者在运算时出现隐式位宽不匹配。

类型名称用途示例
BVSBitVectorSymbol符号变量,代表未知值claripy.BVS("x", 32)
BVVBitVectorValue具体值节点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,动手实操几次就能找到感觉。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进