
像一个侦探一样,不放过任何蛛丝马迹,去逼近事物的真相。
不知道各位 80、90 后,最近是否看《驱魔龙族马小玲》这部电影,真的是贩卖青春啥的。很多电影电视剧都在翻拍,唯独《僵约》没有被翻拍过,估计是即使拍了也知道无法超越吧。但是出来这么个电影,真是拉胯。看情况好像还有后续。
月底还有《寻秦记》的电影版,这怎么都集中在今年出呢?《寻秦记》被翻拍过一次,第一版评分 8、9 分,翻拍才 2、3 分!不知道这个电影如何。
上面是废话,下面是正文!
提到 VM,我们可能想到的是类似 VMWare 或者 Vbox 这样可以安装操作系统的虚拟机!这两种软件是日常中很多电脑用户都会用到的,或者是经常听说的。不过内容写的不是它。
如果作为一个程序员的话,也可能联想到的是类似 JVM 可以运行字节码的的虚拟机。JVM 的话已经和我们要讨论的比较接近了,当然了,这篇文章的虚拟机没有 JVM 那么复杂。
但是,我们这里提到的 VM 是一种软件保护的机制。如果稍有了解的话,可能听过VMP,它就是一种代码虚拟化的软件保护系统。早期用在二进制中,现在被保护的应用面更广泛了,比如现在还有 JSVMP 等。不过,本篇文章介绍的是二进制相关的 VM。
那么这种 VM 的软件保护机制是什么呢?其实,这种软件保护机制的 VM 执行的也是字节码,它通过将二进制代码转换成自定义的字节码从而保护软件,到软件运行时,由嵌入到可执行文件中的虚拟机来解释这些字节码。
上面就是一些简单的介绍,因为是第一次写这方面的内容,多说几句。
0x01:初识某二进制文件
拿到的样本是前几年比赛的一个二进制文件。这种题目一般就是要求输入一个正确的 “值”,这个 “值” 被称作 “Flag”。
先来看下它是什么情况,如下图所示:

可以看到,是一个 ELF32 的文件,没有外壳。那么就直接分析吧。
0x02:查看源码
打开该文件,然后定位到 main 函数的地址,然后 F5 来查看源码,源码如下:
int main()
{
int v1; // [esp+18h] [ebp-10h]
sub_80486BB();
v1 = sub_8048F45();
*(_DWORD *)(v1 + 32) = &unk_804B0C0;
sub_80491C8();
((void (__cdecl *)(int))loc_80487A8)(v1);
return 0;
}来逐个查看一下相关函数,毕竟这样一样看去有 4 个函数,还是看的过来的。
其中 sub_80486BB 这个函数没有什么作用,就是初始化 I/O 的。
看第 6 行和第 7 行,第 6 行调用函数后的返回值放入了 v1,第 7 行通过 v1 加偏移进行了赋值。
看一下 sub_8048F45 这个函数,代码如下:
_DWORD *sub_8048F45()
{
_DWORD *v1; // [esp+8h] [ebp-10h]
v1 = malloc(0x3Cu);
*v1 = 0;
v1[1] = 0;
v1[2] = 0;
v1[3] = 0;
v1[4] = 0;
v1[5] = 0;
v1[9] = 0;
v1[10] = calloc(4u, 0x50u);
v1[6] = v1[10] + 316;
v1[7] = v1[10] + 316;
v1[8] = 0;
return v1;
}是个初始化的工作,但是从 main 函数的第 7 行看到偏移,这里就处理下吧。把 v1 定义成为一个结构体,修改后的代码如下:
VM *VmInit()
{
VM *v1; // [esp+8h] [ebp-10h]
v1 = (VM *)malloc(60u);
v1->r0 = 0;
v1->r1 = 0;
v1->r2 = 0;
v1->r3 = 0;
v1->r4 = 0;
v1->r5 = 0;
v1->r9 = 0;
v1->r10 = calloc(4u, 80u);
v1->r6 = v1->r10 + 316;
v1->r7 = v1->r10 + 316;
v1->ip = 0;
return v1;
}可以看到,把数组初始化变成了结构体成员的初始化,注意,这里没有 r8,因为把 r8 改名为 ip,因为通过 main 函数的第 7 行看,偏移 32 的作用就是用来指向 opcode 的位置的,作用类似 ip 寄存器,这里就命名为 ip 即可。其他的不知道是干什么的,就暂且命名为 r0、r1 这样的名字。
继续看 main 函数调用的 sub_80491C8 这个函数,代码如下:
unsigned int sub_80491C8()
{
_DWORD v1[4]; // [esp+Ch] [ebp-1Ch] BYREF
unsigned int v2; // [esp+1Ch] [ebp-Ch]
v2 = __readgsdword(0x14u);
v1[0] = 17;
v1[1] = 34;
v1[2] = 51;
v1[3] = 68;
mprotect(&dword_8048000, 0x2000u, 7);
sub_8049008(&loc_80487A8, 487, v1);
return __readgsdword(0x14u) ^ v2;
}上面代码的流程比较简单,修改内存属性,并调用 sub_8049008 函数。继续看 sub_8049008 函数,该函数有三个参数,第一个是内存地址,第二个是长度,第三个是一个数组。重命名变量后的代码如下:
unsigned int __cdecl sub_8049008(unsigned int *pMem, int iLength, int key)
{
unsigned int v4; // [esp+14h] [ebp-34h]
unsigned int v5; // [esp+18h] [ebp-30h]
int i; // [esp+1Ch] [ebp-2Ch]
int v7; // [esp+20h] [ebp-28h]
int v8; // [esp+24h] [ebp-24h]
unsigned int v9; // [esp+2Ch] [ebp-1Ch]
v9 = __readgsdword(0x14u);
v7 = 34 / iLength + 9;
v5 = 1316499440 * v7;
v4 = *pMem;
do
{
v8 = (v5 >> 2) & 3;
for ( i = iLength - 1; i; --i )
{
pMem[i] -= (((2 * v4) ^ (pMem[i - 1] >> 4)) + ((v4 >> 3) ^ (32 * pMem[i - 1])) + 40)
^ ((v4 ^ v5 ^ 0x77) + (pMem[i - 1] ^ *(_DWORD *)(4 * (v8 ^ i & 3) + key)) - 15);
v4 = pMem[i];
}
*pMem -= (((2 * v4) ^ (pMem[iLength - 1] >> 4)) + ((v4 >> 3) ^ (32 * pMem[iLength - 1])) + 40)
^ ((v4 ^ v5 ^ 0x77) + (pMem[iLength - 1] ^ *(_DWORD *)(4 * v8 + key)) - 15);
v4 = *pMem;
v5 -= 1316499440;
--v7;
}
while ( v7 );
return __readgsdword(0x14u) ^ v9;
}看到了嵌套循环,还有一堆数学运算,问问 chatgpt 看看它见过没有。
chatgpt 告我这是非标准 XXTEA 解密算法。好的,了解了!
结合 XXTEA 解密算法和 main 函数的第 8 行代码中的 loc_80487A8 这个地址可以了解到,是对这块内存进行解密。从 main 的第 8 行代码可以看出 loc_80487A8 是个函数,看 XXTEA 解密的地址我们就知道了,loc_80487A8 这个函数被加密了,需要在内存中还原。
也就是说 sub_80491C8 函数是一个 smc。
0x03:解密 loc_80487A8
解密它有两种方法,第一种方法是动态调试,当这块内存被解密以后,我们把解密的代码拿过来进行分析;第二种方法是写脚本让它执行帮我们还原。
动态调试要么在 Linux 下,要么就要双机调试了,反正怎么都需要启动一个 Linux 系统,算了!
第二种方法的话,先请 chatgpt 给我们写吧,它写不了再自己写,自己写不了再动态调试。
看下解密前的部分代码:
.text:080487A8 loc_80487A8: ; CODE XREF: main+4B↓p
.text:080487A8 ; DATA XREF: smc+11↓o
.text:080487A8 ; __unwind {
.text:080487A8 xchg cl, [ecx+ecx*2-0B81C2A0h]
.text:080487AF in eax, 1 ; DMA controller, 8237A-5.
.text:080487AF ; channel 0 current word count
.text:080487B1 outsb
.text:080487B2 mov edi, 8CAB9D50h
.text:080487B7 and al, 0D9h
.text:080487B9 mov edi, 0AD3B3BB8h
.text:080487BE pop eax
.text:080487BF setz dh
.text:080487C2 mov word ptr [ebp-35h], gs
.text:080487C5 xrelease xchg eax, edx
.text:080487C7 adc esp, edx
.text:080487C9 outsb
.text:080487CA in eax, dx
.text:080487CB xchg eax, ebp
.text:080487CC rcl dword ptr [ecx+39h], cl
.text:080487CF xchg eax, esi
.text:080487D0 enter 0FFFF99ACh, 9Ch
.text:080487D4 jns short loc_80487D8
.text:080487D6 pop ebp
.text:080487D8
.text:080487D8 loc_80487D8: ; CODE XREF: .text:080487D4↑j
.text:080487D8 cmp edi, [esp-6143B921h]
.text:080487DF fbstp tbyte ptr [esi]
.text:080487E1 jge short loc_8048811
.text:080487E3 loopne near ptr loc_8048777+2
.text:080487E5 push eax
.text:080487E6 lahf
.text:080487E6 ; ---------------------------------------------------------------------------
.text:080487E7 db 0C6h
.text:080487E8 dd 0FF7249ADh, 0D82BCA7Dh, 870F38A2h, 0EE79FC2Dh, 0B86D3157h
.text:080487FC dd 84E039E1h, 4CA9A96Fh, 762DE590h, 0E0C4A1D2h, 8CDE380h
.text:08048810 db 6Eh可以看到入口不像常规函数的入口。至少编译器生成的函数入口函数不是这样。
让 chatgpt 给我们写一个解密的脚本,代码如下:
import ida_bytes
import idaapi
import struct
# ===============================
# 参数(按你的代码来)
# ===============================
START_ADDR = 0x80487A8 # loc_80487A8
DWORD_COUNT = 487
KEY = [17, 34, 51, 68]
DELTA = 1316499440 # 0x4E67C6F0
# ===============================
# 读取 DWORD 数组
# ===============================
def read_dwords(addr, count):
data = []
for i in range(count):
b = ida_bytes.get_bytes(addr + i * 4, 4)
if not b:
raise RuntimeError("Read failed at 0x%X" % (addr + i * 4))
data.append(struct.unpack("<I", b)[0])
return data
# ===============================
# 写回 DWORD 数组
# ===============================
def write_dwords(addr, data):
for i, v in enumerate(data):
ida_bytes.patch_dword(addr + i * 4, v & 0xFFFFFFFF)
# ===============================
# sub_8049008 解密逻辑
# ===============================
def decrypt_xxtea_variant(buf, key):
n = len(buf)
v7 = 34 // n + 9
v5 = (DELTA * v7) & 0xFFFFFFFF
v4 = buf[0]
while v7 > 0:
v8 = (v5 >> 2) & 3
for i in range(n - 1, 0, -1):
buf[i] = (buf[i] - (
(((2 * v4) ^ (buf[i - 1] >> 4)) +
((v4 >> 3) ^ (32 * buf[i - 1])) + 40)
^
((v4 ^ v5 ^ 0x77) +
(buf[i - 1] ^ key[(v8 ^ (i & 3)) & 3]) - 15)
)) & 0xFFFFFFFF
v4 = buf[i]
buf[0] = (buf[0] - (
(((2 * v4) ^ (buf[n - 1] >> 4)) +
((v4 >> 3) ^ (32 * buf[n - 1])) + 40)
^
((v4 ^ v5 ^ 0x77) +
(buf[n - 1] ^ key[v8 & 3]) - 15)
)) & 0xFFFFFFFF
v4 = buf[0]
v5 = (v5 - DELTA) & 0xFFFFFFFF
v7 -= 1
# ===============================
# 主流程
# ===============================
def main():
print("[*] Reading encrypted data...")
data = read_dwords(START_ADDR, DWORD_COUNT)
print("[*] Decrypting...")
decrypt_xxtea_variant(data, KEY)
print("[*] Patching decrypted data back...")
write_dwords(START_ADDR, data)
idaapi.auto_wait()
print("[+] Done! SMC restored at 0x%X" % START_ADDR)
main()
解密后的代码如下:
.text:080487A8 ; __unwind {
.text:080487A8 push ebp
.text:080487A9 mov ebp, esp
.text:080487AB push ebx
.text:080487AC sub esp, 34h
.text:080487AF mov eax, [ebp+arg_0]
.text:080487B2 mov [ebp+var_2C], eax
.text:080487B5 mov eax, large gs:14h
.text:080487BB mov [ebp+var_C], eax
.text:080487BE xor eax, eax
.text:080487C0
.text:080487C0 loc_80487C0: ; CODE XREF: RunVm+743↓j
.text:080487C0 ; RunVm+780↓j
.text:080487C0 mov eax, [ebp+var_2C]
.text:080487C3 mov eax, [eax+20h]
.text:080487C6 movzx eax, byte ptr [eax]
.text:080487C9 cmp al, 71h ; 'q'
.text:080487CB jnz short loc_80487FC
.text:080487CD mov eax, [ebp+var_2C]
.text:080487D0 mov eax, [eax+18h]
.text:080487D3 lea edx, [eax-4]
.text:080487D6 mov eax, [ebp+var_2C]
.text:080487D9 mov [eax+18h], edx
.text:080487DC mov eax, [ebp+var_2C]
.text:080487DF mov eax, [eax+18h]
.text:080487E2 mov edx, [ebp+var_2C]
.text:080487E5 mov edx, [edx+20h]
.text:080487E8 mov edx, [edx+1]
.text:080487EB mov [eax], edx
.text:080487ED mov eax, [ebp+var_2C]解密后的代码入口看起来就是一个函数了,在入口处创建一个函数名。
既然解密了,就省下自己写脚本或者动态调试了。
0x04:整理后的 main 函数
通过上面的代码,已经整理完了 main 函数,来看一下 main 函数吧。代码如下:
int main()
{
VM *pVm; // [esp+18h] [ebp-10h]
InitIO();
pVm = (VM *)VmInit();
pVm->ip = &opcode;
smc();
RunVm(pVm);
return 0;
}对于我们来说,关键就是要分析 RunVm 了,只有分析它,才能得到我们想要的值。
未完待续……!
涉及文章的资源:https://pan.quark.cn/s/fd8e76c9f4ab