对于逆向工程师来说,直接从分析的二进制代码中调用函数的能力是一种捷径,可以省去很多麻烦。虽然在某些情况下,理解函数逻辑并在高级语言中重新实现它是可能的,但这并不总是可行的,而且原始函数的逻辑越脆弱和复杂,这种方法就越不可行。在处理自定义哈希和加密时,这是一个特别棘手的问题,如果计算中的某个地方出现一个错误,就会导致最终输出完全不同,而且调试起来非常麻烦。
我们将在本文中,介绍3 种实现此“捷径”的不同方法,并直接从汇编中调用函数。我们首先介绍IDA Pro原生支持的IDA Appcall功能,可以直接通过idpython使用。然后我们演示如何使用Dumpulator实现同样的行为,最后,我们将展示如何使用Unicorn Engine模拟得到该结果。本文中使用的实际示例基于MiniDuke恶意软件示例实现的“经过调整的”SHA1哈希算法。
MiniDuke示例中经过修改的SHA1算法用于为恶意软件配置创建每个系统的加密密钥。要哈希的缓冲区包含与所有接口描述的 DWORD 连接的当前计算机名称,例如 'DESKTOP-ROAC4IJ\x00MicrWAN WAN MicrWAN MicrWAN InteWAN InteWAN Inte'。此函数 (SHA1Hash) 在初始摘要和中间阶段使用与原始 SHA1 相同的常量,但产生不同的输出。
MiniDuke SHA1Hash 函数常量
由于在原始和修改后的 SHA1 中使用的常量都相同,因此差异必须出现在函数的 1241 条汇编指令中。我们不能说这种调整是否是故意引入的,但事实仍然是恶意软件开发者越来越喜欢插入这样的“惊喜”,而处理它们的责任就落在了分析师身上。为此,我们必须首先了解函数期望其输入并产生其输出的形式。
事实证明,Duke-SHA1汇编使用自定义调用约定,其中要哈希的缓冲区长度在ecx寄存器中传递,而缓冲区本身的地址在edi中传递。从技术上讲,在 eax 中也传递了一个值,但是每当可执行文件调用该函数时,该值都是相同的 0xffffffff,因此我们可以将其视为常量。有趣的是,恶意软件还在每次调用该函数时将缓冲区长度(ecx)设置为0x40,仅对缓冲区的前 0x40 个字节进行有效地哈希处理。
SHA1Hash 函数参数
由此产生的160位SHA1哈希值在寄存器中以5个dword的形式返回(从高到低: eax, edx, ebx, ecx, esi)。例如,缓冲DESKTOP-ROAC4IJ\x00MicrWAN WAN MicrWAN MicrWAN InteWAN InteWAN Inte的Duke-SHA1值为1851fff77f0957d1d690a32f31df2c32a1a84af7,返回为EAX:0x1851fff7 EDX:0x7f0957d1 EBX:0xd690a32f ECX:0x31df2c32 ESI:0xa1a84af7。
生成的SHA1缓冲区哈希示例
如上所述,查找SHA1和Duke-SHA1的逻辑发生分歧的确切位置,然后在Python中重新实现Duke-SHA1,不过这个方法非常浪费时间。接下来,我们将使用几种方法来“插入”函数的调用约定并直接调用它。
Appcall是IDA Pro的一个功能,它允许IDA Python脚本在调试程序中调用函数,就像它们是内置函数一样。这是非常方便的,但它也不是通用的,即当用例变得有些不寻常或复杂时,应用难度会急剧上升。虽然在 ecx 中传递缓冲区长度和在 edi 中传递缓冲区是正常的,但在5个寄存器中分割160位的返回值并不是典型的函数输出形式,Appcall 需要一些创意来解决这个问题。
接下来,我们创建了一个自定义结构struc_SHA1HASH,它保存了5个寄存器的值,并用作函数原型的返回类型:
IDA 结构窗口——“struc_SHA1HASH”
现在有了结构定义, Appcall 就可以与这个函数原型交互,如下面的 PROTO 值所示。
由于IDA Appcall依赖于调试器,为了调用这个逻辑,我们首先需要编写一个脚本来启动调试器,对堆栈进行必要的调整,并执行其他必要的管理工作。
IDA视图——堆栈调整
使用Appcall是最后一步,有几种方法可以利用它来调用函数。我们可以在不指定原型的情况下直接调用函数,但这高度依赖于 IDA 的 IDB 中正确类型的函数。第二种方法是根据函数名和定义的原型创建一个可调用对象。通过这种方式,我们可以调用带有特定原型的函数,无论在IDB中设置了什么类型,如下所示:
使用 Appcall 调用 Duke-SHA1 的完整脚本如下所示。
还有一些示例输出:
脚本执行——“IDA Appcall”产生与 MiniDuke 样本相同的 SHA1 哈希值
如果我们只是想将被调用的函数用作黑盒,那么上面的方法是可以的,但有时我们可能希望在执行的特定状态下访问注册表值,并且像上面那样指定原型是一件繁琐的事情。令人高兴的是,这两个缺点都可以被优化。
由于 IDA Appcall 依赖于调试器并且可以直接从 IDAPython 调用,因此我们可以从调试器调用 Appcall 并对其执行进行更精细的控制。例如,我们可以通过为 Appcall 设置一个特殊选项——APPCALL_MANUAL 来让 Appcall 在执行过程中将控制权交还给调试器。
通过这种方式,我们可以使用Appcall来准备参数,分配一个缓冲区,然后恢复之前的执行上下文。我们也可以避免为返回值指定结构类型(将其输入为void),因为这将由调试器处理。有更多的方法来获取函数的返回值,因此当控制调试器,就可以使用条件断点在特定的执行状态(例如在返回时)打印所需的值。
我们可以通过调用cleanup_appcall()在任何需要的执行时刻恢复之前的状态(在 Appcall 调用之前)。在我们的例子中,正好在遇到条件断点之后。
完整的脚本如下:
Dumpulator是一个python库,它帮助在minidump文件中进行代码模拟。dumator的核心模拟引擎基于Unicorn engine,但在同类工具中有一个比较独特的特点,那就是可以获得整个过程的内存。这带来了性能改进(在不离开 Unicorn 的情况下模拟大部分已分析的二进制文件),如果我们可以在调用函数所需的程序上下文(堆栈等)已经就位的时候计算内存转储的时间,那么就更方便了。此外,只有模拟系统调用才能提供真实的Windows环境(因为实际上一切都是合法的进程环境)。
可以使用许多工具(x64dbg - MiniDumpPlugin, process Explorer, process Hacker, Task Manager)或Windows API (MiniDumpWriteDump)捕获所需进程的一个minidump。我们可以使用x64dbg - MiniDumpPlugin在几乎所有进程都已经设置为SHA1哈希创建的状态下创建一个minidump,就在SHA1Hash函数调用之前。注意,没有必要以这种方式对转储进行计时,因为在进行转储后,可以在转储器中手动设置环境,这只是为了方便而已。
使用“x64dbg - MiniDumpPlugin”创建minidump
Dumpulator不仅可以访问整个转储的进程内存,还可以分配额外的内存、读取内存、写入内存、读取注册表值和写入注册表值。换句话说,模拟器可以做的任何事情。也有可能实现系统调用,因此可以模拟使用它们的代码。
要通过Dumpulator调用Duke-SHA1,我们需要指定将在minidump中调用的函数的地址及其参数。在本例中,SHA1Hash的地址为0x407108。
在 IDA 中打开生成的minidump
因为我们不希望在minidump的当前状态中使用已经设置的值,所以我们为函数定义自己的参数值。我们甚至可以分配一个新的缓冲区,用作哈希的缓冲区。完成此任务的代码如下所示。
执行此脚本将生成正确的Duke-SHA1值
脚本执行——“Dumpulator”产生与 MiniDuke 样本相同的 SHA1 哈希值
对于模拟方法,我们可以使用任何类型的CPU模拟器(例如Qiling、Speakeasy等),它们能够模拟x86汇编,并具有针对Python语言的绑定。因为我们不需要任何更高的抽象级别(系统调用,API函数),我们可以使用其他大多数引擎的基础设施——Unicorn Engine。
Unicorn是一个轻量级、多平台、多体系结构的CPU模拟器框架,基于QEMU,它是用纯C语言实现的,并绑定了许多其他语言。我们将使用Python绑定。我们的目标是创建一个独立的函数SHA1Hash,它可以像Python中的任何其他普通函数一样被调用,产生与MiniDuke中原始函数相同的SHA1哈希。我们使用的实现背后的想法非常简单——我们只需提取函数的操作码字节并通过 CPU 模拟使用它们。
提取原始函数操作码的所有字节可以简单地通过idpython或使用IDA→Edit→Export Data来完成。
使用IDA“Export data”对话框导出SHA1Hash函数的操作码字节
与前面的方法一样,我们需要设置执行上下文。在本文示例中,这意味着为函数准备参数,并为提取的操作码和输入缓冲区设置地址。
请注意,应从提取的操作码列表中删除最后一条 retn 指令,以免将执行转移回堆栈上的返回地址,并且应通过指定 ebp 和 esp 的值手动设置堆栈帧。所有这些都显示在下面的最终 Python 脚本中。
脚本输出如下所示:
脚本执行——“Unicorn Engine”产生与MiniDuke示例相同的SHA1哈希值
上述所有直接调用汇编的方法都各有优缺点。给我们留下特别深刻印象的是简易的Dumpulator,它是免费的,执行起来很快,而且非常有效。它非常适合编写通用字符串解密器、配置提取器和其他上下文,在这些上下文中,必须依次调用许多不同的逻辑片段,同时保留难以设置的上下文。
当我们希望直接使用调用特定函数产生的结果来丰富IDA数据库时,IDA Appcall功能是最好的解决方案之一。系统调用可以是Appcall在实际执行环境中使用的函数的一部分——使用调试器。Appcall最大的优点之一就是快速而简单的上下文恢复。由于Appcall依赖调试器,可以与idpython脚本一起使用,理论上它甚至可以作为模糊器的基础,向函数提供随机输入以发现意外行为(即错误),但这种方法的消耗太大。
通过 Unicorn Engine 使用纯仿真是独立实现特定功能的通用解决方案。使用这种方法,可以按原样获取部分代码并在不连接到原始示例的情况下使用它。此方法不依赖于可运行的示例,并且仅适用于部分代码重新实现功能。对于不是连续的、易于转储的代码块的函数,这种方法可能更难实现。对于发生 API 或系统调用的部分代码,或者难以设置执行上下文的代码,前面提到的方法通常是更好的选择。
参考及来源:https://research.checkpoint.com/2022/native-function-and-assembly-code-invocation/