[机翻] [ABD] 10_等价性检查

10. 等价性检查(Equivalence Checking)

https://github.com/malrev/ABD

⚠️ API 版本说明:本文档中的 Miasm API 代码示例已于 2026-07 更新至 Miasm 0.1.5+ 版本。课程原始材料使用的是 0.1.3 版本(2020年),部分 API 已发生 breaking change。更新处以 【更新】 标注,旧版用法以 【旧版 v0.1.3】 标注。

概述

在混淆代码分析中,等价性检查(Equivalence Checking) 是一种用于识别语义相同的基本块(Basic Block)的技术,其目标是去重和简化控制流。本节以 VipasanaAsprox 恶意软件为案例,演示如何利用 Miasm 框架检测语义等价的基本块。

背景与动机

Vipasana 恶意软件大量使用 不透明谓词(Opaque Predicate) 来制造虚假控制流。攻击者通过插入"始终为真"或"始终为假"的条件分支,将原本单一的执行路径拆分成多条分支。在这些分支中,存在大量语义相同的基本块——它们虽然指令序列不同,但执行后产生的效果(对寄存器、内存的影响)完全一致。

这些冗余的等价基本块带来两个问题:

  1. 代码膨胀:相同的逻辑被复制多份,增加分析负担。
  2. 控制流复杂化:虚假分支使控制流图(CFG)变得错综复杂,干扰人工和自动化分析。

等价性检查正是解决上述问题的关键步骤。通过识别并合并这些语义相同的冗余基本块,可以显著还原原始控制流结构。这也是 opaque predicate + CFF(控制流平坦化)去混淆 流程中的核心环节。

核心思路

由于"两条指令序列不同但语义等价"这一判定本质上是一个不可判定问题(归约到程序等价性),实际工程中采用分层比较策略:从廉价的语法比对开始快速过滤,再到昂贵的符号执行精确验证,逐层深入。


三层比较策略

本方案采用三层比较策略,由粗到细地判定两个基本块 blk0blk1 是否语义等价。

1. 语法比较 syntax_compare(blk0, blk1)

最轻量级的比较,用于快速过滤掉明显不等价的块对,避免后续昂贵的符号执行开销。

比较规则

  • 指令数量:两块的指令数必须相同,否则直接判定不等价。
  • 逐条指令的字符串表示:依次比较每条指令的字符串形式。
  • 跳转指令特殊处理:对于以 J 开头的跳转指令(如 JMPJZJNZ 等),仅比较助记符(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),再调用符号执行进行深度比对。

关键步骤

  1. 为每个块单独构建 IRCFG:调用 ir_arch.add_asmblock_to_ircfg()(【旧版 v0.1.3】),将汇编基本块转换为 IR 表达。【更新 v0.1.5+】改为 lifter.add_asmblock_to_ircfg()ir_archlifter
  2. 捕获 NotImplementedError:某些指令可能尚未被 Miasm 的 IR 翻译器支持,遇到此类指令时捕获异常,将该块标记为"不可比较"。
  3. 调用 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.splifter.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 步:遍历所有寄存器和内存位置,逐一比较符号值

对每个寄存器和每个被访问的内存位置,比较两个块执行后的符号值 v0v1

  • 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 脚本后,语义等价的基本块将显示为相同颜色,分析者可一目了然地识别冗余块并进行合并去重。


技术要点总结

  1. 这是 opaque predicate + CFF 去混淆的关键步骤:不透明谓词产生的虚假分支中包含大量语义相同的冗余基本块,等价性检查正是识别并合并它们的核心手段。

  2. 分层比较平衡了精度与性能

    • 语法比较(syntax_compare):O(n) 复杂度,快速过滤。
    • 语义比较(semantic_compare):构建 IRCFG,中等开销。
    • 符号执行验证(execute_symbolic_execution):Z3 求解,最精确但最昂贵,仅对候选对执行。
  3. Z3 反例求解是等价性证明的核心:通过"证明不等式不可满足"来证明恒等,能处理指令替换、常量编码等复杂等价形式,远超纯结构比对的能力。

  4. 通过等价性检查识别并合并语义相同的冗余基本块:最终通过 networkx 连通分量聚类和 IDC 着色,将分析结果可视化并辅助后续的人工/自动化去重。

  5. 注意事项

    • 符号执行需正确处理内存模型(清除栈以上临时内存)。
    • 需跳过 EIP/IRDst 等控制流寄存器的比较,聚焦数据副作用。
    • NotImplementedError 的捕获确保流程对不完整指令集支持具有鲁棒性。
posted @ 2026-08-05 15:56  DirWangK  阅读(4)  评论(0)    收藏  举报