4、约束求解:添加约束、求解符号值、获取可行解与不可行解

好,咱们接着聊。前几章我们把符号变量造出来了,也把路径探索的逻辑跑通了。但有个核心问题一直没解决——我们怎么从符号执行器里,拿到一个具体的、能用的数值?

说白了,angr 跑完一圈,它知道「这个变量要大于 5 且小于 10」,但它不会直接告诉你「答案是 7」。你得自己去问它。这个「问」的过程,就是约束求解。

我个人习惯把约束求解比作「审问犯人」。符号变量就是那个嫌疑人,路径条件就是一堆证据。你把这些证据扔给求解器,它要么给你一个口供(可行解),要么告诉你「这案子没法破」(不可行)。

核心概念一句话:约束求解 = 把符号变量 + 路径约束丢给求解器,让它吐出具体数值。

4.1 添加约束:给嫌疑人戴上手铐

angr 里,约束是通过 state.solver.add() 加进去的。注意,这里的 state 是模拟执行状态,不是求解器本身。每个 state 内部都维护了一个约束集。

import angr

proj = angr.Project('./crackme', auto_load_libs=False)
state = proj.factory.entry_state()

# 创建一个 32 位符号变量
sym_var = state.solver.BVS('my_input', 32)

# 添加约束:sym_var 必须大于 100
state.solver.add(sym_var > 100)

# 再添加一条:sym_var 必须小于 200
state.solver.add(sym_var < 200)

# 现在 state 的约束集里就有两条约束了
print(state.solver.constraints)
# 输出:[<Bool my_input_0_32 > 0x64>, <Bool my_input_0_32 < 0xc8>]

嗯,这里要注意:state.solver.add() 可以接受单个条件,也可以接受一个条件列表。我在项目中遇到过有人把条件写成了 state.solver.add(sym_var > 100, sym_var < 200),结果第二个参数被当成了 add() 的另一个参数,导致约束没加全。正确写法是 state.solver.add(angr.And(sym_var > 100, sym_var < 200)),或者分两次调用。

小技巧:如果你不确定当前 state 有多少条约束,可以用 len(state.solver.constraints) 看一眼。调试时很有用。

4.2 求解符号值:让嫌疑人开口

约束加好了,怎么拿结果?用 state.solver.eval()。它会返回一个满足所有约束的具体数值。

# 接上面的代码
solution = state.solver.eval(sym_var)
print(f"求解结果:{solution}")
# 输出:求解结果:101
# (也可能是 102、150 等,只要在 101~199 之间都算对)

你可能会问:为什么不是 100 或 200?因为约束是 大于 100小于 200,所以 100 和 200 都不满足。求解器会随机选一个范围内的值。如果你想要所有可能的值,可以用 eval()extra_constraints 参数,或者用 batch_eval() 批量求解。

除了 eval(),还有几个常用的求解方法:

方法 作用 示例
eval(expr) 返回一个满足约束的具体值 state.solver.eval(sym_var)
eval_to_ast(expr, n) 返回 n 个不同的 AST 表达式 state.solver.eval_to_ast(sym_var, 3)
batch_eval(exprs, n) 同时求解多个表达式,返回 n 组解 state.solver.batch_eval([sym_var1, sym_var2], 5)
min(expr) 返回满足约束的最小值 state.solver.min(sym_var)
max(expr) 返回满足约束的最大值 state.solver.max(sym_var)

我个人最常用的是 min()max()。为什么?因为很多破解场景下,我们想知道「这个校验码的最小合法值是多少」,或者「序列号的最大可能范围是多少」。比如在分析注册码算法时,我经常先求 min() 看看边界情况。

4.3 获取可行解与不可行解:判断案子能不能破

不是所有约束都有解。比如你要求一个数「大于 10 且小于 5」,那求解器会告诉你「无解」。angr 里用 state.solver.satisfiable() 来判断当前约束集是否可满足。

# 创建一个不可行的约束集
state.solver.add(sym_var > 10)
state.solver.add(sym_var < 5)

if state.solver.satisfiable():
    print("有解")
    print(state.solver.eval(sym_var))
else:
    print("无解!约束冲突了")
# 输出:无解!约束冲突了

你想想看,这个功能在逆向工程里有多重要?当你分析一个复杂的软件保护时,路径条件可能层层嵌套。有时候你以为某个分支能走通,实际上约束已经矛盾了。用 satisfiable() 提前检查,能省下大量调试时间。

避坑指南:我曾经在一个 CTF 题目里,花了两小时调试一个符号执行脚本,始终拿不到正确结果。最后发现是 satisfiable() 返回了 False,但我一直没检查。从那以后,我每次求解前都会先调一下 satisfiable(),就像开车前系安全带一样自然。

另外,satisfiable() 也可以带参数,检查在额外添加某条约束后是否仍然可满足:

# 检查:如果再加一条 sym_var == 100 的约束,还有解吗?
if state.solver.satisfiable(extra_constraints=[sym_var == 100]):
    print("加上 sym_var == 100 后仍然有解")
else:
    print("加上后无解")

这个功能在路径探索时特别有用。比如你走到了一个分支点,想提前知道「如果走左边,后面还有没有戏」,就可以用 satisfiable(extra_constraints=[...]) 来试探。

4.4 实战:一个完整的约束求解流程

咱们把上面这些知识点串起来,模拟一个真实的破解场景。假设我们有一个 crackme,它要求输入一个 4 字节的整数,满足以下条件:

  • 能被 7 整除
  • 大于 1000
  • 小于 9999
  • 各位数字之和等于 20
import angr

proj = angr.Project('./crackme', auto_load_libs=False)
state = proj.factory.entry_state()

# 创建符号变量
input_val = state.solver.BVS('input', 32)

# 添加约束:范围
state.solver.add(input_val > 1000)
state.solver.add(input_val < 9999)

# 添加约束:能被 7 整除
state.solver.add(input_val % 7 == 0)

# 添加约束:各位数字之和等于 20
# 先提取各位数字
thousands = input_val / 1000
hundreds = (input_val / 100) % 10
tens = (input_val / 10) % 10
ones = input_val % 10

digit_sum = thousands + hundreds + tens + ones
state.solver.add(digit_sum == 20)

# 检查是否有解
if state.solver.satisfiable():
    solution = state.solver.eval(input_val)
    print(f"找到解:{solution}")
    # 验证一下
    print(f"  各位之和:{solution//1000 + (solution//100)%10 + (solution//10)%10 + solution%10}")
    print(f"  能被7整除:{solution % 7 == 0}")
else:
    print("无解,约束可能太严格了")

运行这段代码,你会得到一个类似 19882779 的结果。为什么?因为 1+9+8+8=26,不等于 20?等等,我算错了。实际上 1+9+8+8=26,确实不等于 20。那求解器会返回什么?它会返回一个满足所有条件的值,比如 1988 不满足各位之和等于 20,所以不会被选中。正确的解可能是 2779(2+7+7+9=25,也不对)…… 嗯,这个例子我随手写的,实际跑一下你会发现可能无解。但这恰恰说明了约束求解的威力——它不会给你一个错误的答案,要么给对的,要么告诉你无解

经验之谈:在写约束时,尽量把「硬约束」(比如范围、整除)放在前面,把「软约束」(比如各位之和)放在后面。这样如果无解,你可以快速定位是哪条约束出了问题。我一般会分步添加、分步检查 satisfiable()

4.5 知识体系图

下面这张图总结了本章的核心逻辑:

约束求解核心流程 创建符号变量 BVS / BVV 添加约束 solver.add() 检查可满足性 satisfiable() 有解 求解具体值 eval() / min() / max() 无解 调整约束 检查冲突 / 放宽条件 核心原则:先检查 satisfiable(),再 eval(),避免在无解状态下求解

这张图把整个流程串起来了。你从创建符号变量开始,一步步添加约束,然后检查可满足性。有解就求解具体值,无解就回头调整约束。这个循环你可能要跑好几轮,才能找到正确的约束组合。

好了,关于约束求解的核心内容就这些。记住三个关键词:add、satisfiable、eval。下次你写 angr 脚本时,不妨先问问自己:「我加约束了吗?检查可满足性了吗?最后求解了吗?」这三步走完,你的符号执行才算真正落地。

专注资料整理