[机翻] [ABD] 10_等价性检查
10. 等价性检查(Equivalence Checking)
⚠️ API 版本说明:本文档中的 Miasm API 代码示例已于 2026-07 更新至 Miasm 0.1.5+ 版本。课程原始材料使用的是 0.1.3 版本(2020年),部分 API 已发生 breaking change。更新处以
【更新】标注,旧版用法以【旧版 v0.1.3】标注。
概述
在混淆代码分析中,等价性检查(Equivalence Checking) 是一种用于识别语义相同的基本块(Basic Block)的技术,其目标是去重和简化控制流。本节以 Vipasana 和 Asprox 恶意软件为案例,演示如何利用 Miasm 框架检测语义等价的基本块。
背景与动机
Vipasana 恶意软件大量使用 不透明谓词(Opaque Predicate) 来制造虚假控制流。攻击者通过插入"始终为真"或"始终为假"的条件分支,将原本单一的执行路径拆分成多条分支。在这些分支中,存在大量语义相同的基本块——它们虽然指令序列不同,但执行后产生的效果(对寄存器、内存的影响)完全一致。
这些冗余的等价基本块带来两个问题:
- 代码膨胀:相同的逻辑被复制多份,增加分析负担。
- 控制流复杂化:虚假分支使控制流图(CFG)变得错综复杂,干扰人工和自动化分析。
等价性检查正是解决上述问题的关键步骤。通过识别并合并这些语义相同的冗余基本块,可以显著还原原始控制流结构。这也是 opaque predicate + CFF(控制流平坦化)去混淆 流程中的核心环节。
核心思路
由于"两条指令序列不同但语义等价"这一判定本质上是一个不可判定问题(归约到程序等价性),实际工程中采用分层比较策略:从廉价的语法比对开始快速过滤,再到昂贵的符号执行精确验证,逐层深入。
三层比较策略
本方案采用三层比较策略,由粗到细地判定两个基本块 blk0 和 blk1 是否语义等价。
1. 语法比较 syntax_compare(blk0, blk1)
最轻量级的比较,用于快速过滤掉明显不等价的块对,避免后续昂贵的符号执行开销。
比较规则
- 指令数量:两块的指令数必须相同,否则直接判定不等价。
- 逐条指令的字符串表示:依次比较每条指令的字符串形式。
- 跳转指令特殊处理:对于以
J开头的跳转指令(如JMP、JZ、JNZ等),仅比较助记符(mnemonic),不比较目标地址。原因是等价块在二进制中位置不同,跳转目标地址必然不同,但这不影响语义等价性。
代码示例
def syntax_compare(blk0, blk1):
"""语法层比较两个基本块是否可能等价。
快速过滤策略:指令数量与助记符/操作数字符串匹配。
对跳转指令(J开头)仅比较助记符,忽略目标地址差异。
返回 True 表示语法层面可能等价(需进一步验证),
返回 False 表示一定不等价。
"""
# 指令数量必须一致
if len(blk0.lines) != len(blk1.lines):
return False
for line0, line1 in zip(blk0.lines, blk1.lines):
str0 = str(line0)
str1 = str(line1)
# 对跳转指令仅比较助记符(目标地址可能不同)
if str0.startswith('J'):
mnemonic0 = str0.split()[0]
mnemonic1 = str1.split()[0]
if mnemonic0 != mnemonic1:
return False
else:
# 非跳转指令:完整字符串匹配
if str0 != str1:
return False
return True
用途与局限
- 用途:作为预过滤器,在所有块对中迅速剔除绝大多数不等价对,将真正需要符号执行的候选对压缩到可接受规模。
- 局限:语法相同的块未必语义等价(例如常量编码、指令替换后的块语法不同但语义相同会在此层被遗漏,需进入下一层)。注意:语法比较返回
True只是"可能等价",并非最终结论。
2. 语义比较 semantic_compare(blk0, blk1, ...)
在语法比较通过后,进入语义层比较。该层为每个基本块单独构建 IR 控制流图(IRCFG),再调用符号执行进行深度比对。
关键步骤
- 为每个块单独构建 IRCFG:调用
ir_arch.add_asmblock_to_ircfg()(【旧版 v0.1.3】),将汇编基本块转换为 IR 表达。【更新 v0.1.5+】改为lifter.add_asmblock_to_ircfg(),ir_arch→lifter。 - 捕获
NotImplementedError:某些指令可能尚未被 Miasm 的 IR 翻译器支持,遇到此类指令时捕获异常,将该块标记为"不可比较"。 - 调用
execute_symbolic_execution():进入核心的符号执行等价性验证(见下一节)。
代码示例
# 【旧版 v0.1.3】def semantic_compare(ir_arch, blk0, blk1, ...):
# 【更新 v0.1.5+】参数 ir_arch → lifter
def semantic_compare(lifter, blk0, blk1, ...):
"""语义层比较:为每个块构建 IRCFG 并执行符号执行等价性验证。"""
# 【旧版 v0.1.3】ircfg0 = ir_arch.new_ircfg()
# 【更新 v0.1.5+】ir_arch.new_ircfg() → lifter.new_ircfg()
ircfg0 = lifter.new_ircfg()
ircfg1 = lifter.new_ircfg()
try:
# 分别为两个块构建 IR 控制流图
# 【旧版 v0.1.3】ir_arch.add_asmblock_to_ircfg(blk0, ircfg0)
# 【更新 v0.1.5+】ir_arch.add_asmblock_to_ircfg() → lifter.add_asmblock_to_ircfg()
lifter.add_asmblock_to_ircfg(blk0, ircfg0)
lifter.add_asmblock_to_ircfg(blk1, ircfg1)
except NotImplementedError:
# 存在不支持的指令,无法比较
return False
# 调用核心符号执行等价性验证
# 【旧版 v0.1.3】return execute_symbolic_execution(ir_arch, ircfg0, ircfg1, ...)
# 【更新 v0.1.5+】ir_arch → lifter
return execute_symbolic_execution(lifter, ircfg0, ircfg1, ...)
说明:实际实现中,
semantic_compare还会传入寄存器集合、内存模型等上下文参数。这里为突出主干逻辑做了简化。
3. 符号执行等价性验证 execute_symbolic_execution()(核心)
这是整个等价性检查的核心算法。其基本思想是:如果两个基本块在相同的初始符号状态下,执行后所有寄存器和内存的符号值都等价,则两块语义等价。
算法流程
第 1 步:初始化所有寄存器为符号变量
将 CPU 所有寄存器初始化为独立的符号变量,确保比较不受具体值约束,覆盖所有可能的输入空间。
from miasm.arch.x86.regs import all_regs
from miasm.expression.expression import ExprId
# 为每个寄存器创建符号变量,初始状态完全符号化
symbols_init = {}
for reg in all_regs:
symbols_init[reg] = ExprId(reg.name + "_init", reg.size)
第 2 步:分别对 src/dst 块执行符号执行
对两个块分别执行符号执行,跳过 EIP(x86 程序计数器)和 IRDst(Miasm IR 的目标地址寄存器)的更新。因为基本块等价性关注的是数据副作用(寄存器/内存),而非控制流目标。
# 对 blk0 (src) 执行符号执行
# 【旧版 v0.1.3】sym_exec0 = SymbolicExecutionEngine(ir_arch, symbols_init)
# 【更新 v0.1.5+】第一个参数 ir_arch→lifter,不再传 symbols_init
sym_exec0 = SymbolicExecutionEngine(lifter)
sym_exec0.run_at(ircfg0, blk0_addr, ...)
# 对 blk1 (dst) 执行符号执行
# 【旧版 v0.1.3】sym_exec1 = SymbolicExecutionEngine(ir_arch, symbols_init)
# 【更新 v0.1.5+】第一个参数 ir_arch→lifter,不再传 symbols_init
sym_exec1 = SymbolicExecutionEngine(lifter)
sym_exec1.run_at(ircfg1, blk1_addr, ...)
第 3 步:清除栈以上内存
执行 sb.del_mem_above_stack(ir_arch.sp)(【旧版 v0.1.3】),清除栈指针以上的内存读写。【更新 v0.1.5+】改为 sb.del_mem_above_stack(lifter.sp),ir_arch.sp→lifter.sp。这些内存区域通常是临时栈帧,不属于基本块语义的有效副作用范围,需排除以避免误判。
# 【旧版 v0.1.3】sb.del_mem_above_stack(ir_arch.sp)
# 【更新 v0.1.5+】ir_arch.sp → lifter.sp
sb.del_mem_above_stack(lifter.sp)
第 4 步:遍历所有寄存器和内存位置,逐一比较符号值
对每个寄存器和每个被访问的内存位置,比较两个块执行后的符号值 v0 和 v1:
- 若
v0 == v1(结构相同):两个表达式在语法树结构上完全一致,直接判定该位置等价。 - 否则用 Z3 求解器检查:将两个表达式翻译为 Z3 表达式,构造约束"两者不相等",检查该约束是否可满足:
- 若
solver.check() == z3.unsat→ 证明不存在使两者不等的输入,即两表达式恒等(无反例),该位置等价。 - 若可满足 → 存在反例输入使两者不等,该位置不等价,两块语义不等价。
- 若
import z3
from miasm.ir.translators import Translator
z3_trans = Translator.to_language("z3")
for reg in all_regs:
v0 = sym_exec0.symbols[reg]
v1 = sym_exec1.symbols[reg]
# 结构相同:直接等价
if v0 == v1:
continue
# 结构不同:用 Z3 验证是否恒等
solver = z3.Solver()
z3_r_cond = z3_trans.from_expr(v0)
z3_l_cond = z3_trans.from_expr(v1)
# 添加约束:两者不相等
solver.add(z3.Not(z3_r_cond == z3_l_cond))
if solver.check() == z3.unsat:
# 无反例 → 恒等
continue
else:
# 存在反例 → 不等价
return False
# 所有寄存器和内存位置均等价
return True
Z3 约束构造的直觉解释
Z3 检查的核心约束是:
solver.add(z3.Not(z3_r_cond == z3_l_cond))
这相当于问 Z3:"是否存在某个输入,使得 v0 != v1?"
unsat(不可满足):找不到这样的输入,即对所有输入v0 == v1恒成立 → 两表达式语义等价。sat(可满足):Z3 给出的模型就是反例输入 → 两表达式不等价。
这种"通过求反例不可满足性来证明等价"的方式,是程序等价性检查的标准技术,比直接结构比对能处理更复杂的等价形式(如指令替换、常量编码后的等价表达式)。
图聚类与 IDC 生成
在完成所有基本块对的等价性判定后,需要将等价的块聚类为等价类,并在 IDA 中可视化。
构建等价图
使用 networkx 构建无向图:每个基本块是一个节点,若两个块判定为等价,则在它们之间连一条边。
import networkx as nx
G = nx.Graph()
# 添加所有基本块为节点
for blk in all_blocks:
G.add_node(blk.label.offset)
# 对每对块进行等价性检查,等价则连边
for i, blk0 in enumerate(all_blocks):
for blk1 in all_blocks[i+1:]:
# 先语法过滤,再语义验证
if syntax_compare(blk0, blk1) and semantic_compare(blk0, blk1, ...):
G.add_edge(blk0.label.offset, blk1.label.offset)
找到等价类(连通分量)
等价关系具有传递性:若 A 等价 B,B 等价 C,则 A 等价 C。在图中,这对应于连通分量(Connected Component)。使用 nx.connected_components(G) 即可找到所有等价类。
equivalence_classes = list(nx.connected_components(G))
print("发现 %d 个等价类" % len(equivalence_classes))
for cls in equivalence_classes:
print(" 等价类: %s" % [hex(addr) for addr in cls])
生成 IDC 脚本在 IDA 中着色
为每个等价类分配随机颜色,生成 IDC 脚本(IDA 的内置脚本语言),在 IDA 中对相应地址的基本块进行着色,便于人工审查。
import random
def gen_idc_color_script(equivalence_classes):
"""生成 IDC 脚本,为每个等价类分配随机颜色并着色。"""
idc_lines = []
idc_lines.append('# Auto-generated IDC script: equivalence class coloring')
idc_lines.append('static main() {')
for cls in equivalence_classes:
# 为每个等价类分配随机 RGB 颜色
r = random.randint(0, 0xFF)
g = random.randint(0, 0xFF)
b = random.randint(0, 0xFF)
# IDA 颜色值: 0xBBGGRR
color = (b << 16) | (g << 8) | r
for addr in cls:
idc_lines.append(' SetColor(0x%x, CIC_FUNC, 0x%x);' % (addr, color))
idc_lines.append('}')
return '\n'.join(idc_lines)
idc_script = gen_idc_color_script(equivalence_classes)
with open('equivalence_colors.idc', 'w') as f:
f.write(idc_script)
在 IDA 中执行该 IDC 脚本后,语义等价的基本块将显示为相同颜色,分析者可一目了然地识别冗余块并进行合并去重。
技术要点总结
-
这是 opaque predicate + CFF 去混淆的关键步骤:不透明谓词产生的虚假分支中包含大量语义相同的冗余基本块,等价性检查正是识别并合并它们的核心手段。
-
分层比较平衡了精度与性能:
- 语法比较(
syntax_compare):O(n) 复杂度,快速过滤。 - 语义比较(
semantic_compare):构建 IRCFG,中等开销。 - 符号执行验证(
execute_symbolic_execution):Z3 求解,最精确但最昂贵,仅对候选对执行。
- 语法比较(
-
Z3 反例求解是等价性证明的核心:通过"证明不等式不可满足"来证明恒等,能处理指令替换、常量编码等复杂等价形式,远超纯结构比对的能力。
-
通过等价性检查识别并合并语义相同的冗余基本块:最终通过 networkx 连通分量聚类和 IDC 着色,将分析结果可视化并辅助后续的人工/自动化去重。
-
注意事项:
- 符号执行需正确处理内存模型(清除栈以上临时内存)。
- 需跳过
EIP/IRDst等控制流寄存器的比较,聚焦数据副作用。 NotImplementedError的捕获确保流程对不完整指令集支持具有鲁棒性。

浙公网安备 33010602011771号