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_0、plaintext_word这样的名字。不然调试时满屏的var_0、var_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),记录下所有操作。这棵树就是后面约束求解的基础。
我曾经分析过一个恶意样本,它用了一个复杂的多项式来解密字符串。我当时就是把整个解密过程用符号变量表示出来,然后让求解器去反推原始数据。要是用手工逆向,估计得算到猴年马月去。
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封装得很好。你创建的BVS、BVV,最终都会转换成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秒超时
x < 256就够了,不需要同时加x >= 0和x < 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 知识体系总览
为了让你更直观地理解本章的知识结构,我画了一张图:
这张图把本章的三个核心模块串起来了。从左到右,就是一次完整的符号求解流程。你写代码时,也按照这个顺序来,基本不会出错。
好了,这一章的内容就到这里。符号变量和约束求解是angr的基石,后面的路径探索、漏洞挖掘全都建立在这之上。多写几个小例子练练手,很快就能上手。