使用Binary Ninja逆向C++虚函数
在我的第一篇博客中,我介绍了Binary Ninja底层中间语言(LLIL)的基本结构,以及如何通过Python API遍历和操作它。现在,我们将做一些更有趣的事情。
逆向工程面向对象语言编译的二进制文件具有挑战性,特别是涉及虚函数时。在C++中,调用虚函数需要先在虚表(vtable)中查找函数指针,然后进行间接调用。在反汇编代码中,你只能看到类似mov rax, [rcx+0x18]; call rax
的指令。如果想了解特定类对象会调用哪个函数,就必须找到虚表并确定该偏移处的函数指针。
或者,你可以使用这个插件!
示例插件:导航到虚函数
vtable-navigator.py
是一个示例插件,可以从调用指令导航到指定类的虚函数。首先,插件使用LLIL在构造函数中识别指定类的虚表引用;接着预处理调用指令的基本块以追踪寄存器赋值及其对应的LLIL表达式;最后处理调用的LowLevelILInstruction对象,通过递归访问寄存器赋值表达式计算待调用函数的偏移量。
发现虚表指针
最简单的形式中,类构造函数将虚表指针存储在对象内存结构中。虚表指针存储的两种最常见方式是:直接引用硬编码的虚表指针值,或先将虚表指针存入寄存器再复制到内存。因此,如果我们发现从寄存器到内存地址的写入操作(无偏移),那很可能就是虚表。
我们可以通过查找构造函数LowLevelILFunction对象中的LLIL_STORE指令(如第一部分所述)来检测第一种虚表指针赋值,该指令将常量值存储到寄存器包含的内存地址。
根据API,LLIL_STORE指令有两个操作数:dest和src,都是LLIL表达式。对于这种情况,我们寻找由寄存器提供的目标值,因此dest应为LLIL_REG表达式。要存储的值是常量,因此src应为LLIL_CONST表达式。如果匹配此模式,则假定该常量是虚表指针,读取常量指向的值(即il.src.value),并双重检查那里是否有函数指针,以确保它确实是虚表。
|
|
相当直接了当,但让我们看看第二种情况,即值首先存储在寄存器中。
对于这种情况,我们搜索LLIL_STORE指令的dest和src操作数都是LLIL_REG表达式。现在我们需要仅基于寄存器确定虚表的位置。
这就是事情变得酷炫的地方。这种情况不仅展示了LLIL的用法,还展示了在LLIL上执行的数据流分析有多么强大。没有数据流分析,我们必须解析此LLIL_STORE指令,找出引用的寄存器,然后回溯查找分配给该寄存器的最后一个值。有了数据流分析,寄存器的当前值只需调用get_reg_value_at_low_level_il_instruction即可获得。
|
|
寄存器赋值的传播
现在我们知道虚表的位置了,让我们找出调用的偏移量。要确定这个值,我们需要从调用指令回溯程序状态到虚表指针从内存中检索的时刻,计算虚表中的偏移量,并发现正在调用的函数。我们通过实现一个基本的数据流分析来完成这种回溯,预处理包含调用指令的基本块。这个预处理步骤将让我们查询基本块中任意点的寄存器状态。
|
|
在基本块的每条指令处,我们维护一个寄存器状态表。当遇到LLIL_SET_REG操作时更新此表。对于每个跟踪的寄存器,我们存储负责更改其值的LowLevelILInstruction。之后,我们可以查询此寄存器的状态并检索LowLevelILInstruction,递归查询src操作数的值,即寄存器当前表示的表达式。
此外,如果遇到LLIL_CALL操作,则从该点开始清除寄存器状态。被调用函数可能修改寄存器,因此最安全的假设是调用后所有寄存器值未知。
现在我们拥有了建模虚表指针解引用和计算虚函数偏移所需的所有数据。
计算虚函数偏移量
在深入计算偏移量的任务前,让我们考虑如何建模此行为。回顾图1,虚函数的分发可以概括为四个步骤:
- 从对象内存结构中读取指向虚表的指针(LLIL_LOAD)
- 如果调用的函数不是第一个函数,则向指针值添加偏移量(LLIL_ADD)
- 读取计算偏移处的函数指针(LLIL_LOAD)
- 调用函数(LLIL_CALL)
因此,虚函数分发可以通过评估LLIL_CALL指令的src操作数表达式来建模,递归访问每个表达式。当遇到步骤1的LLIL_LOAD指令时达到递归的基本情况。该LLIL_LOAD的值是指定的虚表指针。虚表指针值被返回并通过递归的先前迭代传播回去,用于这些迭代的评估。
让我们通过一个示例的评估逐步了解模型的工作原理及其在Python中的实现。以下x86中的虚函数分发:
|
|
这段汇编会被翻译成以下LLIL:
|
|
为这两个LLIL指令构建树结构会得到以下结构。
LLIL_CALL的src操作数是LLIL_LOAD表达式。我们基于其操作为LLIL_CALL的src操作数评估一个处理程序。
|
|
因此,我们递归评估此虚函数分发的第一次迭代是调用handle_load。
|
|
handle_load首先增加遇到的LLIL_LOAD表达式计数。回想一下,我们的虚表解引用模型期望两个LLIL_LOAD指令:虚表指针,然后是我们想要的函数指针。反向追踪程序状态意味着我们将首先遇到函数指针的加载,然后是虚表指针的加载。此时计数为1,因此递归不应终止。相反,LLIL_LOAD的src操作数由src表达式的处理程序函数递归评估。当此处理程序调用完成时,addr应包含指向要分发的函数指针的地址。在这种情况下,src是LLIL_ADD,因此调用handle_add。
|
|
handle_add递归评估LLIL_ADD表达式的左右两侧,并将这些值的和返回给调用者。在我们的示例中,左操作数是LLIL_REG表达式,因此调用handle_reg。
|
|
这就是我们的数据流分析发挥作用的地方。使用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,非常容易评估;我们只需返回表达式的value操作数。左右操作数都评估后,handle_add返回表达式的和,即0x1004。handle_load接收来自handle_add的返回值,然后从BinaryView读取位于该地址的函数指针。然后我们可以通过调用bv.file.navigate(bv.file.view, function_pointer)在BinaryView对象中更改当前显示的函数。
回到之前的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