vm1.exe实现了一个简单的8位虚拟机(VM)来尝试和阻止逆向工程师检索攻击标记。VM的RAM包含加密的标志和一些字节码来解密它。你能弄清楚虚拟机如何工作并编写自己的虚拟机来解密标志吗? ram.bin中提供了VM RAM的副本,该副本与执行前恶意软件VM的RAM内容相同,包含自定义汇编代码和加密标志。
规则是:
1. 你不需要运行vm1.exe,此挑战仅是静态分析。
2. 不要使用调试器或转储程序从内存中检索解密的标志,这是作弊行为。
3. 可以使用免费版本的IDA Pro(无需调试器)进行分析。
在这种情况下,我将使用IDA Pro分析二进制文件,尽管诸如Cutter或Ghidra之类的工具也可以正常工作。我还将使用Python3编写脚本。查看下载的ZIP文件,我们有2个文件: vm1.exe和ram.bin,其中ram.bin包含字节码和加密标志。
对于那些不知道在恶意软件/软件打包程序的情况下虚拟机实际上是什么的人,让我简要地总结一下;样本中的虚拟机是一种混淆实际恶意软件或打包程序正在执行的操作的方法,例如,样本中将包含大量字节码,这些字节码不是有效的程序集,因此无法自行进行分析。此时,样本中将内置一个解释器,负责获取并执行字节码。该字节码可能用在切换情况下,在这种情况下,有大量没有交叉引用的功能,并且通过字节码进行调用,这意味着需要一个解释器来了解示例的工作方式。虚拟机实现的一个极为常见的示例是Python,执行时,脚本将转换为字节码,而python解释器是唯一可以理解并执行该字节码的东西。如果你有兴趣,这是一篇有关用Python编写Python解释器的精彩文章!
在IDA中打开vm1.exe,我们可以看到一些API调用,一些与MD5相关的函数调用以及对sub_4022E0的调用。首先,我对memcpy()调用很感兴趣,因为它将数据从unk_404040复制到新分配的内存区域dword_40423C,然后将其作为参数传递给MD5::DigestString()。因此,让我们看一下unk_404040,看看我们在那里能看到什么。
查看IDA中的数据,在30个空字节之后似乎有25个字节的数据,然后是更多的空字节,然后到达0x40413F处的更多数据,似乎每3个字节具有类似的结构,地址0x40413F依次为0x01、0x1D和0xBD。在这前3个字节之后,分别是0x01、0x05、0x53和0x01。在多次浏览数据后,似乎第一个字节是0x01、0x02、0x03或0x04,其余数据似乎是随机的。此时,我假设unk_404040上的数据是字节码,当我们将其与ram.bin中的数据进行比较时,这一点变得很清楚,它们是相同的。
现在知道了字节码的位置,让我们看一下函数sub_4022E0()。这个函数看起来很复杂,但实际上很简单。首先,将var_1设置为0,并移动到ecx中。指向VM字节码的指针被移动到edx中,然后[edx+ecx+0xFF]的一个字节被移动到eax中。再往下看,我们可以看到var_1在每个循环中增加了3,所以在本例中,var_1显然用作计数器。因此,[edx+ecx+0xFF]基本上是字节码[counter+255],这意味着实际的VM代码从偏移255开始。无论如何,第一个字节被移动到eax中,然后eax又被移动到var_10中。然后var_1增加1,并移动到edx中。VM字节码的地址被移动到eax中,并使用类似的[eax+edx+0xFF],现在edx不但是计数器,更是数据。字节存储在ecx中,ecx被移动到var_C中。以上过程重复一次,计数器var_1增加1,并执行类似的操作,下一个字节存储在var_8中。在循环中最后一次增加var_1,然后将var_8、var_C和var_10作为参数推入sub_402270()。测试它的返回值,然后函数返回或循环。通过分析,我们已经可以为这个函数创建伪代码。
counter = 0 while result: byte_1 = bytecode[counter + 255] counter += 1 byte_2 = bytecode[counter + 255] counter += 1 byte_3 = bytecode[counter + 255] counter += 1 decode_and_execute(byte_1, byte_2, byte_3)
我已经将sub_402270() decode_and_execute()命名为sub_402270(),这是基于将字节码传递给sub_402270()并且没有其他函数处理这个字节码这一事实,所以让我们来看看它是如何工作的。首先,它将把byte_1移动到var_4中,var_4在一个值为1、2、3和4的switch语句中使用。此时,我们可以将byte_1定义为操作码,因为它决定了程序的执行,而byte_2和byte_3是操作数(byte_2 = operand1, byte_3 = operand2)。
如果var_4等于1,则将vm_bytecode的地址移入ecx,然后将其添加到byte_2的值中。将byte_3移到dl中,然后将其写入ecx指向的存储区域。然后函数返回,此函数的伪代码非常简单:
bytecode[operand1] = operand2
如果var_4等于2,则将vm_bytecode的地址移入eax,然后将其添加到byte_2中的值。该地址指向的字节被移入cl,然后被移入byte_404240。然后函数返回。同样,此函数的伪代码很简单:
byte_404240 = bytecode[operand1]
最后,如果var_4等于3,则将byte_404240中的值移入edx,并将vm_bytecode的地址移入eax,然后将其加到byte_2中的值。指向该地址的值将移入ecx,并与edx中的值进行异或。然后将其移至vm_bytecode [byte_2]。然后,该函数将返回。该块的伪代码如下:
vm_bytecode[operand1] ^= byte_404240
如果var_4等于其他任何值,则在返回之前将al设置为0。对于所有其他块,将al设置为1。
因此,现在我们首先知道字节码(opcode | operand1 | operand2)的布局以及可以执行的函数(mov,store,xor,exit),因此现在就可以开始编写脚本了!
脚本解释器
查看解析器功能与基于字节码执行的功能之间的区别,可以很清楚地看到ram.bin文件中有2个部分;数据部分([:255])和代码部分([255:])。因此,我将把文件分成2个部分,以防止任何可能的覆盖问题。我还将使用类来包含代码,所以这些都将保存在VM()类中。首先,我们希望创建_init__()函数,因此让我们使用它来初始化vm_data和vm_instructions变量。另外,我们知道每个字节码指令都是3字节长,所以让我们将数据[255:]分割成3字节长的字符串并将其存储在一个列表中。我们还可以将vm_data设置为字节数组,以简化执行过程。我们还将变量xor_byte初始化为0,即我们在IDA中看到的byte_404240。
接下来,我们需要创建基于字节码执行的实际函数,因此让我们采用前面的伪代码并将其放入脚本中。
调用就绪后,现在只需创建一个函数来解析字节码并调用对应于操作码的函数。虽然我们不能创建一个确切的switch语句,但是我们可以使用字典,遍历每个指令,使用opcode来确定执行哪个函数,并确保在调用VM函数之前设置了self.operand1和self.operand2。
最后,让我们添加一个main()函数,该函数将打开ram.bin,读取它,并将数据传递到VM类,然后在它输出标记之前(使用分隔符 “}” 分割它,并以一种非常符合python风格的方式将它添加回字符串)。在添加了所有内容之后,我们就得到了最后的脚本。执行脚本将输出后,把这个结果输入到标记检查器,我们就得到相应的结果。
我们有正确的标志!但是,目前我们正在从内存转储中获取标志。如果我们没有给定的内存转储,而必须解析主可执行文件以首先找到字节码的地址,然后转储并解析,该怎么办?好吧,让我们看看是否可以做到!
首先,我们需要编写一个YARA规则来检测和定位将指向VM字节码的memcpy()调用。如果这是一个真正的恶意软件样本,这个方法会很不切实际的,因为它依赖于存在只有一个二进制memcpy()调用,因为YARA只是用来检测恶意软件样本,而不查找地址。
现在,让我们开始编写规则mwt_vm_rule.yar。 YARA根据给定的规则在样本中搜索字符串,十六进制模式等。我们需要找到对memcpy()的调用的十六进制字节,并使用该字节创建规则,因此让我们快速跳回IDA,并查看我们正在处理的内容。
因此,查看这个函数,将值0x1FB推入,将偏移量推入,将dword推入,然后调用memcpy()。我们希望在本例中获得偏移量,但是我们不能简单地搜索push offset 0x404040,因为重新编译可能会改变这个偏移量,所以不是0x404040,而是0x404010,这将导致YARA在检测模式方面出现问题。这对于被推送的dword和对memcpy()地址的调用是相同的。因此,我们必须在规则中使用几个通配符。通配符表示为??,表示该位置可能有值。由于通配符相当多,因此增加了误报的机会。因此,我们将使用大小0x1FB作为常量来保持低误报。
因此,我们的YARA规则(基于上面的十六进制视图)如下所示:
{A3 ?? ?? ?? ?? 68 FB 01 00 00 68 ?? ?? ?? ?? A1 ?? ?? ?? ?? 50 E8 ?? ?? ?? ??}
将其放入.yar文件中,就形成了完整的规则:
在终端上使用yara mwt_vm_rule.yar vm1.exe运行此命令,将返回mwt_vm_rule vm1.exe,这意味着规则有效,并且字节模式位于二进制文件中!现在,我们只需要将此端口移植到Python3,从全字节模式解析地址,将虚拟地址转换为文件偏移量,然后提取数据即可!现在开始吧。
同样,我们将使用类,这次是类YARA()。对于_init__()函数,我们将接受文件名作为参数,并使用yara.compile()初始化规则。
这样,我们可以创建一个find_rule()函数,该函数使用YARA的.match()函数在二进制文件中搜索字节模式。然后,我们解析实际字节匹配返回的内容(它将包含我们需要的地址)并将其返回,我将其放在try语句中,因为该匹配可能在相似的示例中不存在,因此我们可以解决此问题。
到目前为止,执行此代码(在返回值上使用binascii.hexlify())将返回此值字符串,该字符串已由|分割。显示重要字节:
A33c42400068fb010000 | 6840404000 | a13c42400050e84a000000
0x68是用于推送的操作码,而404040是VM字节码的地址。但是,它是字节码的虚拟地址,这意味着它只有在程序被映射到内存时才有用。我们在这里处理静态二进制文件,因此我们需要将此值转换为文件偏移量。为此,我们从字节码的地址中减去该节的虚拟地址,然后将指向原始数据的指针添加到该值。因此,“等式”如下所示:
Address - VirtualAddress + PointerToRawData
这非常简单,我们将使用pefile进行计算。首先,我们从VM字节码地址中减去图像的基本地址,剩下的是0x4040。然后我们循环遍历每个可执行部分,直到找到一个虚拟地址大于字节码地址的部分。一旦找到它,我们就从上一节获取虚拟地址和PointerToRawData并从循环中断开。把这些放在一起,我们就得到文件偏移量0x1E40,然后返回它。
在此基础上,让我们使用一个“main”函数get_address()将所有内容绑定在一起。这将调用每个函数,并最终将文件偏移量返回给调用函数。
我们还可以稍微修改main()函数,所以现在我们调用YARA(filename).get_address(data),然后在打开的文件中查找偏移量,读取字节码,然后将其传递给VM()类。就这样!现在,我们可以获取二进制文件,从中解析字节码,然后对其进行解释!尽管YARA并不是很实用,但我的主要目标是展示如何使用它以及如何基于函数实际编写基本规则,所以希望你从中学到一些东西!
本文翻译自:https://0ffset.net/reverse-engineering/solving-a-vm-based-crackme/如若转载,请注明原文地址: