目录

VMP壳分析(3):VMProtect3.9.4脱壳记录

定位到OEP、Dump PE

  1. 这步没啥好说的,如果OEP没有被虚拟化,其实还是好找的

/vmprotectdumping/Image/1-%E5%AE%9A%E4%BD%8DOEP.png

  1. 这里Dump PE,即可

识别被破坏的IAT Stub

  1. 下图是原始、未加壳的OEP代码,可以看到有2个正常的函数调用

/vmprotectdumping/Image/2-%E5%8E%9F%E5%A7%8BOEP%E4%BB%A3%E7%A0%81.png

  1. 我们点进去看看原始IAT Stub长什么样
    1. 可以发现是非常典型的FF 25 xxxx,即call [IatSlot]
    2. 我们找到导入表VA,确实保存了大量导入函数的VA

/vmprotectdumping/Image/3-%E5%8E%9F%E5%A7%8BIATStub%E4%B8%8E%E5%AF%BC%E5%85%A5%E8%A1%A8VA.png

  1. 我们来对比加壳后,设置了导入表保护后的代码

/vmprotectdumping/Image/4-%E5%8A%A0%E5%A3%B3%E5%90%8E%E5%AF%BC%E5%85%A5%E8%A1%A8%E4%BF%9D%E6%8A%A4%E4%BB%A3%E7%A0%81.png

  1. 选择一个双击跳进去看

/vmprotectdumping/Image/5-%E5%8A%A0%E5%A3%B3%E5%90%8EIATStub_E8%E8%B0%83%E7%94%A8.png

  1. 会发现原本的FF 25 xxxx是6字节,被修改为E8 call的调用方式,但是E8调用只有5字节,所以会在E8调用的前、后设置一个单字节的占位机器码

找出VMP解密方式

  1. 这些被VMP修改后的调用Stub,指向的是VmpStubEntry,这些StubEntry解密原始的导入函数非常简单,我们双击选一个StubEntry,如图所示:

/vmprotectdumping/Image/6-StubEntry%E5%8F%8D%E6%B1%87%E7%BC%96.png

  1. 将EIP设置到函数头部,之后一直F7步进运行代码,这里需要耐心,此时注意看堆栈,直到出现敏感函数,如图所示

/vmprotectdumping/Image/7-%E6%AD%A5%E8%BF%9B%E8%A7%82%E5%AF%9F%E5%A0%86%E6%A0%88%E6%95%8F%E6%84%9F%E5%87%BD%E6%95%B0.png

  1. 这里可以发现规则,一个StubEntry最后的retn 4时,栈顶会保存真实的返回地址
  2. 我们利用这个规则,选择另一个StubEntry,在头部设置EIP,并且利用x64Dbg的自动Trace功能:

/vmprotectdumping/Image/8-%E8%87%AA%E5%8A%A8Trace%E8%AE%BE%E7%BD%AE.png

  1. 成功解密出另外的导入函数

/vmprotectdumping/Image/9-%E8%A7%A3%E5%AF%86%E5%87%BA%E5%AF%BC%E5%85%A5%E5%87%BD%E6%95%B0.png

使用Unicorn模拟执行解密

  1. 我们将加壳程序断在OEP处,这里为了快速试验、演示效果,所以用的是手动保存主模块内存快照,正常自动化流程应该写脚本自动Dump快照
  2. 我们选中主模块的全局节区内存,右键创建转储文件

/vmprotectdumping/Image/10-%E5%88%9B%E5%BB%BA%E8%BD%AC%E5%82%A8%E6%96%87%E4%BB%B6.png

  1. 保存到文件后,就可以到Py_Unicorn中手动载入模块快照,进行模拟执行了
  2. 接下来我们需要提取出所有的VmpStubEntry函数地址,即从下图中,解析出所有的解密函数入口地址

/vmprotectdumping/Image/5-%E5%8A%A0%E5%A3%B3%E5%90%8EIATStub_E8%E8%B0%83%E7%94%A8.png

  1. 这里使用C/C++来实现,如下所示:
struct _IAT_STUB
{
	UCHAR Bytes[6];

	ULONG GetRel32()
	{
		if (!Bytes[0])
		{
			return 0;
		}

		ULONG LocalOffset = Bytes[0] == 0xE8 ? 1 : 2;

		return *(PULONG)(Bytes + LocalOffset);
	}

	ULONG GetNextInsn()
	{
		if (!Bytes[0])
		{
			return 0;
		}

		ULONG LocalOffset = Bytes[0] == 0xE8 ? 5 : 6;

		return LocalOffset;
	}
};

int main()
{
	cReadWriteMem ProcRw("test.vmp.394.exe");

	ULONG Addr = 0x00401394;
	ULONG Begin = Addr;

	while (true)
	{
		_IAT_STUB Stub = ProcRw.Read<_IAT_STUB>(Begin);
		if (Stub.Bytes[0] == 0)
		{
			break;
		}

		ULONG StubEntry = Begin + Stub.GetNextInsn() + Stub.GetRel32();

		printf("0x%X, \n", StubEntry);
		//printf("VmpStub:0x%X -> 0x%X \n", Begin, StubEntry);

		Begin += sizeof _IAT_STUB;
	}

	getchar();
	return 0;
}
  1. 运行上面的代码,输出结果如下,顺利得到所有StubEntry,将这些地址复制到Py中

/vmprotectdumping/Image/11-C%E4%BB%A3%E7%A0%81%E8%BE%93%E5%87%BAStubEntry%E5%88%97%E8%A1%A8.png

StubEntryList = [
    0x41CB8F, 0x49541A, 0x4F20DE, 0x4E91E0,
    0x67A4B3, 0x57FBFB, 0x53DAFE, 0x87BEDE,
    0x79983E, 0x798AD1, 0x5615AC, 0x62ED7B,
    0x4779D0, 0x41B3D1, 0x7A1EA7, 0x75BDD8,
    0x4B3F3E, 0x7AF8DD, 0x7FE904, 0x7EBBBD,
    0x41B2F3, 0x67A1C1, 0x40E76B,
]
  1. 接下来要做的是初始化Unicorn环境、载入内存快照、设置内存映射、循环模拟执行所有的StubEntry,代码如下所示:
from unicorn import *
from unicorn.x86_const import *
from capstone import *
import sys

DumpFile = "test.vmp.394_00400000.bin"

Data = open(DumpFile, "rb").read()

ImageBase = 0x400000

StackBase = 0x10000000
StackSize = 0x100000
Esp = StackBase + StackSize - 8

StepLimit = 200

StubEntryList = [
    0x41CB8F, 0x49541A, 0x4F20DE, 0x4E91E0,
    0x67A4B3, 0x57FBFB, 0x53DAFE, 0x87BEDE,
    0x79983E, 0x798AD1, 0x5615AC, 0x62ED7B,
    0x4779D0, 0x41B3D1, 0x7A1EA7, 0x75BDD8,
    0x4B3F3E, 0x7AF8DD, 0x7FE904, 0x7EBBBD,
    0x41B2F3, 0x67A1C1, 0x40E76B,
]

Count = 0
RetFound = None
LimitHit = False


def hook_code(emu, address, size, user_data):
    global Count, RetFound, LimitHit
    Count += 1

    if RetFound is not None:
        return

    if Count >= StepLimit:
        LimitHit = True
        print(f"[Limit] 达到步数上限 {StepLimit},EIP=0x{address:08X}")
        emu.emu_stop()
        return

    Code = bytes(emu.mem_read(address, size))
    IsRet = (Code[0] == 0xC3) or (Code[0] == 0xC2 and size >= 3)
    if not IsRet:
        return

    EspNow = emu.reg_read(UC_X86_REG_ESP)
    RetAddr = int.from_bytes(emu.mem_read(EspNow, 4), "little")
    RetFound = (address, EspNow, RetAddr)
    print(f"0x{RetAddr:08X}")
    # print(f"[Ret] EIP=0x{address:08X}  ESP=0x{EspNow:08X}  RetAddr=0x{RetAddr:08X}")
    emu.emu_stop()


if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")
    Cs = Cs(CS_ARCH_X86, CS_MODE_32)

    emu = Uc(UC_ARCH_X86, UC_MODE_32)
    emu.mem_map(0, 0x100000000, UC_PROT_ALL)
    emu.mem_write(ImageBase, Data)

    emu.hook_add(UC_HOOK_CODE, hook_code)

    print(f"ImageBase : 0x{ImageBase:08X}")
    print(f"DumpSize  : 0x{len(Data):08X}")
    print(f"Stack     : 0x{StackBase:08X} - 0x{StackBase + StackSize:08X}  ESP=0x{Esp:08X}")
    print(f"StepLimit : {StepLimit}")

    for Entry in StubEntryList:
        Count = 0
        RetFound = None
        LimitHit = False
        emu.mem_write(StackBase, b"\x00" * StackSize)
        emu.reg_write(UC_X86_REG_ESP, Esp)

        # print("-" * 60)
        # print(f"CurStub:0x{Entry:08X}")

        try:
            emu.emu_start(Entry, 0)
        except UcError as Ex:
            print(f"[UcError] {Ex}")
            continue

        # if RetFound is not None:
            print(f"[Done] ret after {Count} steps")
        # elif LimitHit:
            print(f"[Done] limit after {Count} steps")
  1. 运行Py模拟执行代码,输出结果如下所示,顺利得到原始函数地址
    1. 这里解密不完美:由于在VMP加壳设置中,我们勾选了资源保护,所以VMP对LoadResource、LockResource等资源相关函数有更深层保护,所以模拟执行出来得到的函数地址,仍然是VMP加密节区中的函数

/vmprotectdumping/Image/12-Py%E6%A8%A1%E6%8B%9F%E6%89%A7%E8%A1%8C%E8%BE%93%E5%87%BA.png

  1. 我们随便选一个正常的函数地址0x75D17F50,到调试器中查看,顺利解析出导入函数

/vmprotectdumping/Image/13-%E8%B0%83%E8%AF%95%E5%99%A8%E8%A7%A3%E6%9E%90%E5%AF%BC%E5%85%A5%E5%87%BD%E6%95%B0.png

将解密函数地址回写IAT Slot

  1. 上一小节中,我们得到了所有的原始函数地址,我们要将这些原始函数地址回写到IAT Slot中,正常步骤如下
    1. 解析加壳程序的PE头,在数据目录表中得到IAT的RVA、Size
    2. 计算得到IAT的Va,将原始函数地址,一个个回写
ULONG RawFunc[] =
{
	0x75D31500,
	// ...
};

int main()
{
	cReadWriteMem ProcRw("test.vmp.394.exe");

	// 正常情况下一个读PE头、读数据目录表中IAT的RVA、Size
	// 知道原理即可,由于测试程序没有随机基址,所以ImageBase = 0x400000
	// 并且手动查询得知IATRva = 0x49E000
	ULONG IAT_Addr = 0x0089e000;
	ULONG Begin = IAT_Addr;

	for (ULONG i = 0; i < _countof(RawFunc); i++, Begin += 4)
	{
		ProcRw.Write(Begin, RawFunc[i]);
	}

	getchar();
	return 0;
}
  1. 可以发现,哪怕是加壳后的VMP程序,初始导出表还是依赖了一些基础的函数,如图所示:

/vmprotectdumping/Image/14-%E5%8A%A0%E5%A3%B3%E7%A8%8B%E5%BA%8F%E4%BE%9D%E8%B5%96%E7%9A%84%E5%9F%BA%E7%A1%80%E5%87%BD%E6%95%B0.png

  1. 我们运行代码,将原始函数一个个回填,效果如图所示

/vmprotectdumping/Image/15-%E5%9B%9E%E5%A1%ABIAT%E5%90%8E%E6%95%88%E6%9E%9C.png

修复被破坏的IAT Stub

  1. 还记得VMP会将原始的FF 25 xxxx修改为E8 xxxx形式的函数调用吗?如图所示:

/vmprotectdumping/Image/5-%E5%8A%A0%E5%A3%B3%E5%90%8EIATStub_E8%E8%B0%83%E7%94%A8.png

  1. 这也是我们需要还原的,还原非常简单,步进为6字节开始,一个个还原,代码如下所示:
int main()
{
	cReadWriteMem ProcRw("test.vmp.394.exe");

	ULONG IATStubAddr = 0x00401394;
	ULONG CurStub = IATStubAddr;

	ULONG IAT_Addr = 0x0089e000;
	ULONG CurIATSlot = IAT_Addr;

	while (true)
	{
		if (ProcRw.Read<UCHAR>(CurStub) == 0)
		{
			break;
		}

		UCHAR StubCode[6] = { 0xFF, 0x25 };
		*(PULONG)(StubCode + 2) = CurIATSlot;
		ProcRw.ReadWriteMem(CurStub, StubCode, sizeof StubCode, FALSE);

		CurIATSlot += 4;

		// FF 25 xxxx
		CurStub += 6;
	}

	getchar();
	return 0;
}
  1. 运行代码,还原后效果如图所示:

/vmprotectdumping/Image/16-%E8%BF%98%E5%8E%9FIATStub%E5%90%8E%E6%95%88%E6%9E%9C.png

使用Scylla修复导入表

  1. 我们修复导入表,并不是说全部流程都用代码实现,通晓其中实现原理即可
  2. 我们打开调试器自带的Scylla,填入OEP、导入表地址、大小,点击GetImport按钮,解析完成后再Fix Dump即可
    1. 下图的解析失败,是因为资源相关导入函数没解密干净

/vmprotectdumping/Image/17-Scylla%E8%A7%A3%E6%9E%90%E5%AF%BC%E5%85%A5%E8%A1%A8.png

  1. 到了这里,VMProtect3.9.4基础脱壳就完成了,当然,内嵌的代码变异、代码虚拟化这种才是后续真正麻烦所在

脱壳效果

  1. 加壳后程序

/vmprotectdumping/Image/18-%E5%8A%A0%E5%A3%B3%E5%90%8E%E7%A8%8B%E5%BA%8F.png

  1. OEP处Dump PE

/vmprotectdumping/Image/19-OEP%E5%A4%84DumpPE.png

  1. OEP处Dump PE、再修复IAT,可以发现导入函数识别正常了

/vmprotectdumping/Image/20-%E4%BF%AE%E5%A4%8DIAT%E5%90%8E%E8%AF%86%E5%88%AB%E6%AD%A3%E5%B8%B8.png

VMProtect 脱壳 Unicorn 逆向工程