使用 Binary Ninja 逆向 C++ 虚拟函数调用

本文详细介绍了如何使用 Binary Ninja 的 Python API 和低级中间语言(LLIL)逆向分析 C++ 中的虚拟函数调用机制,包括虚拟表指针的识别、寄存器状态的数据流分析以及虚拟函数偏移量的计算。

使用 Binary Ninja 逆向 C++ 虚拟函数调用

在我的第一篇博客文章中,我介绍了 Binary Ninja 低级中间语言(LLIL)的基本结构,以及如何使用 Python API 遍历和操作它。现在,我们将做一些更有趣的事情。

逆向工程从面向对象语言编译的二进制文件可能具有挑战性,尤其是在处理虚拟函数时。在 C++ 中,调用虚拟函数涉及在虚拟表(vtable)中查找函数指针,然后进行间接调用。在反汇编中,您只能看到类似 mov rax, [rcx+0x18]; call rax 的指令。如果您想知道对于给定的类对象将调用哪个函数,您必须找到虚拟表,然后确定该偏移处的函数指针。

或者,您可以使用这个插件!

示例插件:导航到虚拟函数

vtable-navigator.py 是一个示例插件,可以从调用指令导航到给定类的虚拟函数。首先,插件使用 LLIL 在构造函数中引用时识别指定类的虚拟表。接下来,它将预处理调用指令的基本块,以跟踪寄存器分配及其对应的 LLIL 表达式。最后,插件将处理调用的 LowLevelILInstruction 对象,并通过递归访问寄存器分配表达式来计算要调用的函数的偏移量。

发现虚拟表指针

图 1:两个类继承自一个基虚拟类。每个类的虚拟表指向其各自的虚拟函数实现。

在最简单的形式中,类构造函数将其虚拟表的指针存储在内存中的对象结构中。存储虚拟表指针的两种最常见方式是直接引用虚拟表指针的硬编码值,或将虚拟表指针存储在寄存器中,然后将该寄存器的值复制到内存中。因此,如果我们查找从寄存器写入内存地址且没有偏移的情况,那么它很可能是虚拟表。

一个示例构造函数。高亮显示的指令将虚拟表存储在对象结构中。

我们可以通过查找构造函数 LowLevelILFunction 对象中的 LLIL 指令(如第 1 部分所述)来检测第一种虚拟表指针分配,该指令将常量值存储到寄存器中包含的内存地址。

根据 API,LLIL_STORE 指令有两个操作数:dest 和 src。两者都是 LLIL 表达式。对于这种情况,我们寻找由寄存器提供的目标值,因此 dest 应该是 LLIL_REG 表达式。要存储的值是常量,因此 src 应该是 LLIL_CONST 表达式。如果我们匹配此模式,则假设该常量是虚拟表指针,读取该常量指向的值(即 il.src.value),并再次检查该处是否有函数指针,以确保它确实是虚拟表。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 如果不是内存存储,则不是虚拟表。
if il.operation != LowLevelILOperation.LLIL_STORE:
    continue

# 直接引用虚拟表
if (il.dest.operation == LowLevelILOperation.LLIL_REG and
        il.src.operation == LowLevelILOperation.LLIL_CONST):
    fp = read_value(bv, il.src.value, bv.address_size)

    if not bv.is_offset_executable(fp):
        continue

    return il.src.value

相当直接,但让我们看看第二种情况,即值首先存储在寄存器中。

对于这种情况,我们搜索 LLIL_STORE 的 dest 和 src 操作数都是 LLIL_REG 表达式的指令。现在我们需要仅基于寄存器确定虚拟表的位置。

这就是事情变得酷的地方。这种情况不仅展示了 LLIL 的使用,还展示了在 LLIL 上执行的数据流分析有多么强大。如果没有数据流分析,我们将不得不解析此 LLIL_STORE 指令,找出被引用的寄存器,然后向后步进以找到分配给该寄存器的最后一个值。通过数据流分析,寄存器的当前值可以通过单次调用 get_reg_value_at_low_level_il_instruction 轻松获得。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 虚拟表首先加载到寄存器,然后存储
if (il.dest.operation == LowLevelILOperation.LLIL_REG and
        il.src.operation == LowLevelILOperation.LLIL_REG):
    reg_value = src_func.get_reg_value_at_low_level_il_instruction(
        il.instr_index, il.src.src
    )

    if reg_value.type == RegisterValueType.ConstantValue:
        fp = read_value(bv, reg_value.value, bv.address_size)

        if not bv.is_offset_executable(fp):
            continue

    return reg_value.value

寄存器分配的传播

现在我们知道虚拟表的位置,让我们找出调用的偏移量。要确定此值,我们需要从调用指令回溯程序状态到从内存检索虚拟表指针的时刻,计算虚拟表中的偏移量,并发现正在调用的函数。我们通过实现一个基本的数据流分析来完成此回溯,该分析预处理包含调用指令的基本块。此预处理步骤将让我们查询基本块中任何点的寄存器状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def preprocess_basic_block(bb):
    defs = {}
    current_defs = {}

    for instr in bb:
        defs[instr.instr_index] = copy(current_defs)

        if instr.operation == LowLevelILOperation.LLIL_SET_REG:
            current_defs[instr.dest] = instr

        elif instr.operation == LowLevelILOperation.LLIL_CALL:
            # 清除先前的定义,因为我们无法保证调用未修改寄存器。
            current_defs.clear()

    return defs

在基本块的每条指令处,我们维护一个寄存器状态表。当我们遍历每个 LowLevelILInstruction 时,遇到 LLIL_SET_REG 操作时会更新此表。对于每个跟踪的寄存器,我们存储负责更改其值的 LowLevelILInstruction。稍后,我们可以查询此寄存器的状态并检索 LowLevelILInstruction,并递归查询 src 操作数的值,即寄存器当前表示的表达式。

此外,如果遇到 LLIL_CALL 操作,则从该点开始清除寄存器状态。被调用的函数可能会修改寄存器,因此最安全的假设是调用后的所有寄存器都具有未知值。

现在我们拥有所有需要的数据来模拟虚拟表指针解引用并计算虚拟函数偏移量。

计算虚拟函数偏移量

在深入计算偏移量的任务之前,让我们考虑如何模拟行为。回顾图 1,分发虚拟函数可以概括为四个步骤:

  1. 从内存中的对象结构读取指向虚拟表的指针(LLIL_LOAD)。
  2. 如果要分发的函数不是第一个函数,则向指针值添加偏移量(LLIL_ADD)。
  3. 在计算的偏移量处读取函数指针(LLIL_LOAD)。
  4. 调用函数(LLIL_CALL)。

因此,分发虚拟函数可以通过评估 LLIL_CALL 指令的 src 操作数表达式来模拟,递归访问每个表达式。当遇到步骤 1 的 LLIL_LOAD 指令时,达到递归的基本情况。该 LLIL_LOAD 的值是指定的虚拟表指针。虚拟表指针值返回并通过先前的递归迭代传播回来,用于这些迭代的评估。

让我们逐步评估一个示例,看看模型如何工作以及如何在 Python 中实现。以以下 x86 中的虚拟函数分发为例。

1
2
mov eax, [ecx] ; 检索虚拟表指针
call [eax+4]   ; 调用虚拟表偏移 4 处的函数指针

此汇编将被翻译为以下 LLIL。

1
2
0: eax = [ecx].d
1: call ([eax + 4].d)

为这两个 LLIL 指令构建树结构产生以下结构。

图 2:示例虚拟表分发汇编的 LLIL 树结构。

LLIL_CALL 的 src 操作数是 LLIL_LOAD 表达式。我们基于其操作为 LLIL_CALL 的 src 操作数评估处理程序。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 这让我们以更通用的方式处理表达式。
# 操作处理程序接受以下参数:
#   vtable (int): 内存中类虚拟表的地址
#   bv (BinaryView): 传递给插件回调的 BinaryView
#   expr (LowLevelILInstruction): 要处理的表达式
#   current_defs (dict): 当前寄存器定义状态
#   defs (dict): 所有指令的寄存器状态表
#   load_count (int): 遇到的 LLIL_LOAD 操作数量
operation_handler = defaultdict(lambda: (lambda *args: None))
operation_handler[LowLevelILOperation.LLIL_ADD] = handle_add
operation_handler[LowLevelILOperation.LLIL_REG] = handle_reg
operation_handler[LowLevelILOperation.LLIL_LOAD] = handle_load
operation_handler[LowLevelILOperation.LLIL_CONST] = handle_const

因此,我们递归评估此虚拟函数分发的第一次迭代是调用 handle_load

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def handle_load(vtable, bv, expr, current_defs, defs, load_count):
    load_count += 1

    if load_count == 2:
        return vtable

    addr = operation_handler[expr.src.operation](
        vtable, bv, expr.src, current_defs, defs, load_count
    )
    if addr is None:
        return

    # 读取指定地址的值。
    return read_value(bv, addr, expr.size)

handle_load 首先增加遇到的 LLIL_LOAD 表达式计数。回想一下,我们解引用虚拟表的模型期望两个 LLIL_LOAD 指令:虚拟表指针,然后是我们想要的函数指针。向后追踪程序状态意味着我们将首先遇到函数指针的加载,然后是虚拟表指针的加载。此时计数为 1,因此递归不应终止。相反,LLIL_LOAD 的 src 操作数由 src 表达式的处理程序函数递归评估。当此处理程序调用完成时,addr 应包含指向要分发的函数指针的地址。在这种情况下,src 是 LLIL_ADD,因此调用 handle_add

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def handle_add(vtable, bv, expr, current_defs, defs, load_count):
    left = expr.left
    right = expr.right

    left_value = operation_handler[left.operation](
        vtable, bv, left, current_defs, defs, load_count
    )

    right_value = operation_handler[right.operation](
        vtable, bv, right, current_defs, defs, load_count
    )

    if None in (left_value, right_value):
        return None

    return left_value + right_value

handle_add 递归评估 LLIL_ADD 表达式的左侧和右侧,并将这些值的总和返回给其调用者。在我们的示例中,左操作数是 LLIL_REG 表达式,因此调用 handle_reg

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def handle_reg(vtable, bv, expr, current_defs, defs, load_count):
    # 检索此寄存器当前表示的 LLIL 表达式。
    set_reg = current_defs.get(expr.src, None)
    if set_reg is None:
        return None

    new_defs = defs.get(set_reg.instr_index, {})

    return operation_handler[set_reg.src.operation](
        vtable, bv, set_reg.src, new_defs, defs, load_count
    )

这就是我们的数据流分析发挥作用的地方。使用当前寄存器状态(由 current_defs 描述),我们识别表示此 LLIL_REG 表达式当前值的 LLIL 表达式。基于上面的示例,current_defs[‘eax’] 将是表达式 [ecx].d。这是另一个 LLIL_LOAD 表达式,因此再次调用 handle_load。这次,load_count 增加到 2,满足基本情况。如果我们假设在我们的示例中用户选择的类的构造函数位于 0x1000,则 handle_load 将返回值 0x1000。

左操作数评估完成后,现在轮到 handle_add 评估右操作数。此表达式是 LLIL_CONST,非常容易评估;我们只需返回表达式的值操作数。左右操作数都评估完成后,handle_add 返回表达式的和,即 0x1004。handle_load 接收从 handle_add 返回的值,然后从 BinaryView 读取位于该地址的函数指针。然后,我们可以通过调用 BinaryView 对象中的 bv.file.navigate(bv.file.view, function_pointer) 来更改当前显示的函数。

回到之前的 LLIL 树结构,我们可以注释结构以可视化递归和具体数据传播的发生方式。

图 3:示例虚拟表分发汇编的 LLIL 树结构,注释了处理程序调用和具体值沿调用链传播。

示例:矩形和三角形

对于真实世界的示例,我使用了此 C++ 教程的稍作修改版本,您可以在此处找到。以下是插件实际运行的演示:

如果您为 x86-64 和 ARM 编译 virtual-test.cpp,并在安装了插件的 Binary Ninja 中打开二进制文件,您会发现它可以在两种架构上工作,而无需任何架构特定代码。这就是中间表示的美丽之处!

前进并分析

Binary Ninja 的 LLIL 是一个强大的功能,使跨平台程序分析易于开发。正如我们所看到的,其结构简单,但允许表示甚至最复杂的指令。Python API 是一个高质量的接口,我们可以有效地使用它来遍历指令并轻松处理操作和操作数。更重要的是,我们已经看到了简单的示例,说明由 LLIL 启用的数据流分析如何允许我们开发跨平台插件来执行程序分析,而无需实现复杂的启发式方法来计算程序值。您还在等什么?拿起一份 Binary Ninja,开始使用 LLIL 编写您自己的二进制分析,别忘了参加 Sophia 在 INFILTRATE 2017 上使用 Binary Ninja LLIL 进行的“Next-level Static Analysis for Vulnerability Research”演示。

如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News

页面内容 示例插件:导航到虚拟函数 发现虚拟表指针 寄存器分配的传播 计算虚拟函数偏移量 示例:矩形和三角形 前进并分析 最近文章 构建安全消息传递很难:对 Bitchat 安全辩论的细致看法 使用 Deptective 调查您的依赖项 系好安全带,Buttercup,AIxCC 的评分回合正在进行中! 使您的智能合约超越私钥风险 Go 解析器中意外的安全陷阱 © 2025 Trail of Bits。 使用 Hugo 和 Mainroad 主题生成。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计