3. 符号变量与约束求解:BitVector与符号变量创建、约束添加与求解器、Z3后端交互

好,咱们进入第三章。这一章是angr的核心中的核心——符号变量和约束求解。说白了,angr之所以能分析那些混淆、加壳的恶意代码,靠的就是这手“符号化”的功夫。我当年第一次接触符号执行时,觉得这东西简直像魔法一样——把程序里的变量变成数学符号,然后让求解器去算。后来自己踩了不少坑,才慢慢摸清楚门道。

3.1 BitVector:符号世界的基本单元

先聊聊BitVector。你想想看,CPU处理的数据本质上就是一串二进制位。angr里的BitVector,就是用来表示这些二进制位的符号化版本。

我个人习惯把BitVector理解成“带长度的符号容器”。它不像Python的int那样有固定精度,而是明确告诉你:我这个符号变量占多少位。

import angr

# 创建一个32位的符号变量
sym_var = claripy.BVS('my_var', 32)
print(sym_var)  # <BV32 my_var>

这里BVS就是BitVector Symbolic的缩写。第一个参数是名字,方便调试时识别;第二个参数是位宽。我在分析一个勒索软件时,遇到过它用64位整数做密钥运算,当时我创建的就是64位的BitVector。

小技巧:命名符号变量时,尽量用有意义的名称。比如分析解密函数时,我会用key_byte_0plaintext_word这样的名字。不然调试时满屏的var_0var_1,你根本分不清谁是谁。

除了BVS,还有BVV——BitVector Value,用来表示具体的常量值:

# 创建一个32位的常量值
const_val = claripy.BVV(0x12345678, 32)
print(const_val)  # <BV32 0x12345678>

嗯,这里要注意:BVV的第一个参数可以是整数,也可以是字符串。如果是字符串,你得指定进制。我刚开始用的时候,经常忘记给位宽参数,结果报错半天找不出原因。

3.2 符号变量的创建与操作

创建符号变量只是第一步。真正有意思的是,你可以像操作普通整数一样操作这些符号变量——加减乘除、异或、移位,全都支持。

# 创建两个符号变量
a = claripy.BVS('a', 32)
b = claripy.BVS('b', 32)

# 符号运算
c = a + b
d = a ^ b
e = (a << 2) | (b >> 1)

print(c)  # <BV32 a + b>
print(d)  # <BV32 a ^ b>

看到没?这些运算结果仍然是符号表达式,而不是具体数值。angr会构建一棵抽象语法树(AST),记录下所有操作。这棵树就是后面约束求解的基础。

我曾经分析过一个恶意样本,它用了一个复杂的多项式来解密字符串。我当时就是把整个解密过程用符号变量表示出来,然后让求解器去反推原始数据。要是用手工逆向,估计得算到猴年马月去。

核心概念:符号执行不是真的“执行”代码,而是构建路径约束的AST。每个分支条件、每个运算,都会变成这棵树上的一片叶子。

3.3 约束添加与求解器

光有符号变量还不够,你得告诉求解器:我想要什么样的结果。这就是约束的作用。

angr使用claripy作为约束求解的接口。你可以把约束想象成“过滤器”——从所有可能的取值中,筛选出满足条件的那些。

# 创建求解器
solver = claripy.Solver()

# 添加约束
x = claripy.BVS('x', 32)
solver.add(x > 10)
solver.add(x < 20)
solver.add(x % 2 == 0)

# 求解
result = solver.eval(x, 1)
print(result)  # 输出一个满足条件的值,比如12

这里eval的第一个参数是要求解的符号变量,第二个参数是想要几个解。我一般用1,除非我需要检查多个可能的值。

还有一种情况:你想知道有没有解。这时候用satisfiable()

if solver.satisfiable():
    print("有解!")
    result = solver.eval(x, 1)
else:
    print("约束矛盾,无解")

我在分析一个CrackMe时,遇到过约束矛盾的情况。当时我加了两个互相冲突的条件——既要变量等于5,又要它不等于5。求解器直接告诉我无解。嗯,这其实是个好现象,说明你的约束写错了,而不是求解器有问题。

避坑指南:我曾经在添加约束时,不小心把==写成了=。Python里不会报错,但约束变成了赋值操作,结果求解器永远返回True。调试了整整一个下午才发现。所以,写约束时一定要检查运算符。

3.4 Z3后端交互

angr背后用的是Z3求解器。Z3是微软研究院开发的定理证明器,在符号执行领域几乎是标配。你不需要直接调用Z3的API,但了解一些底层机制会很有帮助。

实际上,claripy把Z3封装得很好。你创建的BVSBVV,最终都会转换成Z3的表达式。你可以通过claripy.backends来查看后端信息:

# 查看当前使用的后端
print(claripy.backends._backends)
# 输出类似:{'z3': <claripy.backends.backend_z3.BackendZ3 object>}

如果你需要直接操作Z3的表达式,可以用_model_concrete方法:

# 获取Z3的模型
solver = claripy.Solver()
x = claripy.BVS('x', 32)
solver.add(x == 42)

if solver.satisfiable():
    # 获取Z3底层模型
    z3_model = solver._model_concrete()
    print(z3_model)
    # 输出:x = 42

不过说实话,我很少直接碰Z3的底层接口。除非遇到一些奇怪的bug,需要确认是不是Z3本身的问题。大多数情况下,claripy提供的接口已经够用了。

这里有个性能相关的点:Z3求解复杂约束时,可能会很慢。我遇到过最极端的情况,一个包含1000多个变量的约束系统,求解花了将近10分钟。这时候你可以尝试简化约束,或者使用timeout参数:

# 设置求解超时(毫秒)
solver = claripy.Solver()
solver.set_timeout(5000)  # 5秒超时
经验之谈:约束越少,求解越快。我写约束时,总是尽量用最少的条件来表达需求。比如要限制变量在0-255之间,用x < 256就够了,不需要同时加x >= 0x < 256——因为符号变量默认是无符号的。

3.5 实战:解密一个简单的异或加密

光说不练假把式。咱们来个小例子:假设恶意代码里有一段异或加密的数据,密钥未知,但你知道加密后的数据和解密后的数据应该满足某些特征。

# 加密后的数据(假设)
encrypted = [0xAB, 0xCD, 0xEF, 0x12]

# 创建符号密钥
key = claripy.BVS('key', 8)

# 创建求解器
solver = claripy.Solver()

# 假设解密后的数据应该是可打印的ASCII字符
for byte in encrypted:
    decrypted_byte = byte ^ key
    # 约束:解密后的字节在0x20到0x7E之间
    solver.add(decrypted_byte >= 0x20)
    solver.add(decrypted_byte <= 0x7E)

# 求解
if solver.satisfiable():
    key_value = solver.eval(key, 1)
    print(f"找到密钥: 0x{key_value[0]:02X}")
    
    # 验证
    decrypted = [b ^ key_value[0] for b in encrypted]
    print(f"解密结果: {''.join(chr(b) for b in decrypted)}")
else:
    print("无解,可能约束条件太严格")

这个例子虽然简单,但体现了符号执行的核心思想:把未知量符号化,用约束描述已知条件,让求解器自动找出答案。我在分析一个真正的恶意软件时,就是用类似的方法破解了它的字符串解密算法——只不过那个算法更复杂,用了多轮异或和置换。

3.6 知识体系总览

为了让你更直观地理解本章的知识结构,我画了一张图:

符号变量与约束求解知识体系 BitVector BVS: 符号变量创建 BVV: 常量值创建 位宽: 8/16/32/64 约束求解 Solver: 求解器创建 add(): 添加约束 eval(): 求解取值 satisfiable(): 可满足性 Z3后端交互 后端自动转换 _model_concrete() set_timeout() 核心流程: 创建符号变量 → 构建约束 → 调用求解器 → 获取结果

这张图把本章的三个核心模块串起来了。从左到右,就是一次完整的符号求解流程。你写代码时,也按照这个顺序来,基本不会出错。

好了,这一章的内容就到这里。符号变量和约束求解是angr的基石,后面的路径探索、漏洞挖掘全都建立在这之上。多写几个小例子练练手,很快就能上手。


专注资料整理