应用安全 --- win安全 之 VMP初体验
VMP是一种软件加固方法
Virtual Machine Protect. 虚拟机保护 ,可以将汇编指令转化为自定义指令集,虚拟指令涉及上百万条汇编指令,极大增强pj难度。
由win版本的和linux,安卓版本的。他们的软件实现方法和厂家都不一样,但是原理相同。
win具体的软件由pmvrotect2.x 3.x 3.5。vmp加密时只是对之前的so进行新增加密节区,不修改节区。
pmvrotect2软件加密的手段有很多最著名的是vmp技术。包含 指令虚拟化,导入表加密,反调试,反dump,指令和数据压缩
vmpdumper可以脱出汇编代码但是不能修复vmp节区和导出表,加密字符串。需要手动修复。导入表(IAT)不能修复
代码压缩 (Packing)
原理:压缩代码段
原始PE结构:
├─ .text (500KB) - 可执行代码
├─ .data (100KB) - 数据
└─ .rsrc (200KB) - 资源
↓ 压缩后 ↓
├─ .vmp0 (150KB) - 压缩的代码+解压stub
├─ .vmp1 (80KB) - 压缩的数据
└─ .rsrc (200KB)
运行时:
1. 解压.vmp0到内存
2. 修复重定位
3. 跳转执行
压缩算法:LZMA/自定义
强度:★★
主要用途:减小体积(附带混淆效果)
代码虚拟化 (Virtualization) ⭐最强
原理:将x86指令转换为自定义虚拟机指令
转换示例:
; 原始代码
mov eax, 5
add eax, 3
ret
; ↓ 虚拟化后 ↓
push offset vm_bytecode ; VM字节码
call vm_interpreter ; 调用VM解释器
ret
; vm_bytecode (自定义指令):
; 0x45 0x05 0x00 0x00 0x00 ; VM_MOV eax, 5
; 0x12 0x03 0x00 0x00 0x00 ; VM_ADD eax, 3
; 0xFF ; VM_RET
C代码示例:
// 原始
int add(int a, int b) {
return a + b;
}
// SDK标记
#include "VMProtectSDK.h"
int add(int a, int b) {
VMProtectBeginVirtualization("Add");
return a + b;
VMProtectEnd();
}
强度:★★★★★
性能损耗:5-20倍
代码变异 (Mutation/Obfuscation)
原理:用等价但复杂的指令替换原指令
转换示例:
; 原始
mov eax, 5
; ↓ 变异后 ↓
push 2
push 3
pop ecx
pop edx
lea eax, [ecx+edx] ; 实际还是5
; 或者
xor eax, eax
add eax, 3
inc eax
inc eax ; 仍是5,但复杂化
典型变异技术:
; 1. 指令替换
mov eax, 0 → xor eax, eax
; 2. 花指令插入
nop
jmp $+1
db 0xE8 ; 无效字节
add eax, ebx
; 3. 寄存器替换
mov eax, 5 → mov ebx, 5
mov eax, ebx
; 4. 常量加密
mov eax, 100 → mov eax, 0x12345678
xor eax, 0x123456DC ; = 100
强度:★★★★
性能损耗:1.5-3倍
导入表加密
导入表保护 (Import Protection)
原理:隐藏真实的API调用
转换示例:
; 原始导入表
IMPORT TABLE:
kernel32.dll
- CreateFileA
- ReadFile
; ↓ 保护后 ↓
IMPORT TABLE:
kernel32.dll
- LoadLibraryA ; 只保留最基础的
; 运行时动态获取
char* encrypted = "\x3F\x12\x7A..."; // 加密的 "CreateFileA"
decrypt(encrypted);
pCreateFile = GetProcAddress(LoadLibrary("kernel32"), decrypted);
pCreateFile(...); // 调用
代码对比:
// 原始
MessageBoxA(NULL, "Hello", "Test", 0);
// 保护后(伪代码)
typedef int (WINAPI *pMsgBox)(HWND, LPCSTR, LPCSTR, UINT);
pMsgBox MyMsgBox = vmp_get_api(0x1A3F); // 内部索引
MyMsgBox(NULL, vmp_str(0x2B4C), vmp_str(0x3D5E), 0);
强度:★★★★
绕过难度:中等(可API Hook监控)
call后面是加密的iat表

字符串加密 (String Encryption)
原理:加密所有字符串常量
示例:
// 原始
const char* key = "MySecretKey123";
if (strcmp(input, key) == 0) { ... }
// ↓ 加密后 ↓
// 数据段存储
.data
encrypted_str db 0xA3,0x7F,0x2B,0x9C,... ; 加密的字符串
// 代码中
char* key = vmp_decrypt_string(0x004050A0);
if (strcmp(input, key) == 0) {
vmp_free_string(key); // 用完立即清除
}
反调试 (Anti-Debug)
技术清单:
// 1. IsDebuggerPresent if (IsDebuggerPresent()) { ExitProcess(0); } // 2. PEB检测 bool CheckDebug() { __asm { mov eax, fs:[0x30] // PEB movzx eax, byte ptr [eax+2] // BeingDebugged test eax, eax } } // 3. NtQueryInformationProcess BOOL isDebug = FALSE; NtQueryInformationProcess(GetCurrentProcess(), ProcessDebugPort, &isDebug, sizeof(BOOL), NULL); // 4. 时间检测 DWORD t1 = GetTickCount(); // 一些代码 DWORD t2 = GetTickCount(); if (t2 - t1 > 1000) { /* 被调试 */ } // 5. 硬件断点检测 CONTEXT ctx = {0}; ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS; GetThreadContext(GetCurrentThread(), &ctx); if (ctx.Dr0 || ctx.Dr1 || ctx.Dr2 || ctx.Dr3) { /* 检测到硬件断点 */ } // 6. INT 3检测 __try { __asm int 3 } __except(EXCEPTION_EXECUTE_HANDLER) { // 正常程序会走这里 } // 7. 检测调试器窗口 if (FindWindow("OLLYDBG", NULL) || FindWindow("WinDbgFrameClass", NULL)) { ExitProcess(0); }
反虚拟机 (Anti-VM)
检测技术:
// 1. CPUID检测
void CheckVM() {
int cpuInfo[4];
__cpuid(cpuInfo, 1);
if (cpuInfo[2] & (1 << 31)) {
// Hypervisor bit set
ExitProcess(0);
}
}
// 2. 特征文件检测
if (PathFileExists("C:\\Windows\\System32\\drivers\\vmmouse.sys") ||
PathFileExists("C:\\Windows\\System32\\drivers\\vmhgfs.sys")) {
// VMware检测
}
// 3. 注册表检测
HKEY hKey;
if (RegOpenKey(HKEY_LOCAL_MACHINE,
"SOFTWARE\\VMware, Inc.\\VMware Tools", &hKey) == ERROR_SUCCESS) {
// VMware
}
// 4. MAC地址检测
// VMware前缀: 00:05:69, 00:0C:29, 00:50:56
// VirtualBox前缀: 08:00:27
// 5. 指令时间检测
DWORD t1 = __rdtsc();
// 执行一些指令
DWORD t2 = __rdtsc();
if (t2 - t1 > threshold) { /* VM环境 */ }
强度:★★★
绕过:修改VM配置
内存保护 (Memory Protection)
原理:检测内存完整性
// 1. CRC校验 DWORD original_crc = 0x12345678; DWORD current_crc = calc_crc32(code_section, size); if (current_crc != original_crc) { ExitProcess(0); // 代码被修改 } // 2. 定期校验 void __stdcall CheckThread(LPVOID param) { while (true) { Sleep(1000); if (!verify_memory()) { TerminateProcess(GetCurrentProcess(), 0); } } } // 3. 页面保护 VirtualProtect(code_section, size, PAGE_EXECUTE_READ, &old); // 任何写入会触发异常
资源加密 (Resource Encryption)
原理:加密PE资源段
// 原始
HRSRC hRes = FindResource(NULL, MAKEINTRESOURCE(101), RT_BITMAP);
HGLOBAL hData = LoadResource(NULL, hRes);
void* pData = LockResource(hData);
// ↓ 保护后 ↓
// 资源段全部加密
.rsrc section: [encrypted data]
// 运行时
HRSRC hRes = FindResource(...);
// VMProtect拦截LoadResource
HGLOBAL hData = LoadResource(...); // 内部解密
void* pData = LockResource(hData); // 返回解密数据
强度:★★★
效果:防止资源提取工具直接读取
应用VMProtect后(简化示意)
// 编译后的保护代码(逆向视角)
.text:00401000 ; VMP Entry
.text:00401000 push ebp
.text:00401001 call vmp_init_2FA3C
.text:00401006 jmp vm_dispatcher_1
// 原始的main函数代码已经不存在
// 被转换为:
.vmp0:00501000 db 0x4A, 0x8F, 0x23, ... ; VM字节码
.vmp0:00501100 db 0x7C, 0x12, 0xAB, ...
.vmp1:00502000 ; VM解释器
.vmp1:00502000 vm_handler_0:
.vmp1:00502000 mov al, [esi]
.vmp1:00502002 inc esi
.vmp1:00502003 movzx eax, al
.vmp1:00502006 jmp [vm_table + eax*4]
// 字符串也被加密
.data:00601000 encrypted_str1 db 0xA4,0x7F,0x9C,...
.data:00601020 encrypted_str2 db 0x3E,0x12,0x88,...
// 导入表被隐藏
.idata:00701000 ; 只剩下
.idata:00701000 dd offset LoadLibraryA
.idata:00701004 dd offset GetProcAddress
保护强度对比表
| 方法 | 反静态分析 | 反动态分析 | 性能影响 | 推荐场景 |
|---|---|---|---|---|
| Virtualization | ★★★★★ | ★★★★★ | -90% | 核心算法 |
| Mutation | ★★★★ | ★★★ | -30% | 一般函数 |
| Import Protection | ★★★★ | ★★ | -5% | 全局开启 |
| String Encryption | ★★★ | ★ | -5% | 敏感字符串 |
| Anti-Debug | ★ | ★★★★ | -1% | 全局开启 |
| Anti-VM | ★ | ★★★ | -1% | 可选 |
| Memory Protection | ★★ | ★★★★ | -10% | 全局开启 |
| Packing | ★★ | ★ | +5% | 减小体积 |
虚拟指令、虚拟栈、虚拟寄存器。

msdn。开发文档
<windows程序设计>
<windows核心编程>
<win32汇编语言程序设计>
PE 文件结构图例
+-----------------------------------+ <-- 文件开始 (Offset 0) | IMAGE_DOS_HEADER | |-----------------------------------| | - e_magic: "MZ" (0x5A4D) | <- DOS 可执行文件签名 | - e_lfanew: 指向 NT 头部的偏移量 | <- 这是通往 PE 头的关键指针! +-----------------------------------+ | DOS Stub Program | <- 一小段 DOS 程序,通常显示 "This program cannot be run in DOS mode." +-----------------------------------+ <-- e_lfanew 指向的位置 | IMAGE_NT_HEADERS | | +-----------------------------+ | | | Signature | | | | - "PE\0\0" (0x00004550) | | <- PE 文件签名 | +-----------------------------+ | | | IMAGE_FILE_HEADER | | | |-----------------------------| | | | - Machine: (e.g., 0x8664) | | <- 目标 CPU 架构 (如 x64) | | - NumberOfSections: | | <- 后面跟着的节区数量 | | - TimeDateStamp: | | <- 链接时间戳 | | - SizeOfOptionalHeader: | | <- 下一个头的大小 | | - Characteristics: | | <- 文件属性 (如 DLL, Executable) | +-----------------------------+ | | | IMAGE_OPTIONAL_HEADER | | <- 对于操作系统加载器至关重要 | |-----------------------------| | | | - Magic: (e.g., 0x20B) | | <- 标识 PE32(0x10B) 或 PE32+(0x20B) | | - AddressOfEntryPoint: | | <- 程序执行入口 RVA | | - ImageBase: | | <- 映像的首选加载地址 | | - SectionAlignment: | | <- 内存中节区的对齐方式 | | - FileAlignment: | | <- 文件中节区的对齐方式 | | - SizeOfImage: | | <- 内存中整个映像的大小 | | - SizeOfHeaders: | | <- 所有头部的总大小 | | - Subsystem: (e.g., GUI/CUI) | | <- 要求子系统 (如 Windows GUI) | | - NumberOfRvaAndSizes: | | <- 数据目录项的数量 | |-----------------------------| | | | IMAGE_DATA_DIRECTORY | | <- 一个结构体数组 | | [0] Export Directory | | | | [1] Import Directory | | <- 指向导入函数信息 | | [2] Resource Directory | | <- 指向资源 (图标、字符串等) | | [3] Exception Directory | | | | ... (共 16 个) ... | | | +-----------------------------+ | +-----------------------------------+ | IMAGE_SECTION_HEADER[0] | <- 第一个节区头 (如 .text) | - Name: ".text\0\0\0" | <- 节区名称 | - VirtualAddress: | <- 内存中的 RVA | - SizeOfRawData: | <- 在文件中的大小 | - PointerToRawData: | <- 在文件中的偏移 | - Characteristics: | <- 节区属性 (如 可执行、可读) +-----------------------------------+ | IMAGE_SECTION_HEADER[1] | <- 第二个节区头 (如 .data) | - Name: ".data\0\0\0" | | - ... | +-----------------------------------+ | ... | <- 其他节区头 (共 NumberOfSections 个) +-----------------------------------+ <-- SizeOfHeaders 标记的头部结束位置 | 节区数据开始 | | | | .text 节区原始数据 | <- 文件偏移由 PointerToRawData 指定 | (存放代码) | | | | .data 节区原始数据 | <- 文件偏移由 PointerToRawData 指定 | (存放全局变量) | | | | .rsrc 节区原始数据 | | (存放资源) | | | | ... | +-----------------------------------+ <-- 文件结束
各组成部分的简明解释
-
IMAGE_DOS_HEADER (DOS 头)
-
目的:为了保持与古老 DOS 系统的兼容性。
-
关键成员:
e_lfanew字段,它包含了指向真正的 PE 头 (IMAGE_NT_HEADERS) 的文件偏移量。
-
-
IMAGE_NT_HEADERS (NT 头)
-
目的:PE 文件的正式入口和核心描述符。
-
包含三部分:
-
Signature:一个 "PE\0\0" 的签名,标识这是一个 PE 文件。
-
IMAGE_FILE_HEADER (文件头):描述了文件的全局属性,如目标机器类型、节区数量、创建时间等。
-
IMAGE_OPTIONAL_HEADER (可选头):虽然叫"可选",但对于可执行文件是必需的。它包含了程序加载和运行所需的关键信息。
-
-
-
IMAGE_OPTIONAL_HEADER (可选头)
-
目的:为操作系统加载器提供如何准备和执行程序的信息。
-
关键成员:入口点地址、映像基址、内存/文件对齐值、子系统等。
-
它末尾的 IMAGE_DATA_DIRECTORY (数据目录) 是一个非常重要的表格,它指出了其他重要数据结构(如导入表、导出表、资源表)在文件中的位置和大小。
-
-
IMAGE_DATA_DIRECTORY (数据目录)
-
目的:作为指向其他重要数据的“目录”或“索引”。
-
结构:一个由16个相同结构组成的数组。每个结构包含一个 RVA(相对虚拟地址) 和 Size。
-
例如:第二个条目(索引1)是 Import Directory,加载器通过它找到所有需要从其他DLL导入的函数列表。
-
-
IMAGE_SECTION_HEADER (节区头)
-
目的:描述文件中的各个“节区”。节区是实际存储代码、数据、资源等内容的部分。
-
数量:由
IMAGE_FILE_HEADER中的NumberOfSections指定。 -
关键成员:
-
Name:节区名称(如.text,.data,.rdata)。 -
VirtualAddress:该节区加载到内存后的 RVA。 -
PointerToRawData:该节区在磁盘文件中的原始数据偏移。 -
Characteristics:节区属性(如可读、可写、可执行)。
-
-
总结与流程
操作系统加载一个 PE 文件的简化流程如下:
-
读取
IMAGE_DOS_HEADER,找到e_lfanew。 -
跳到
e_lfanew位置,验证 "PE" 签名,读取IMAGE_NT_HEADERS。 -
从
IMAGE_FILE_HEADER知道有多少个节区。 -
从
IMAGE_OPTIONAL_HEADER获取关键信息(如入口点、映像大小、数据目录)。 -
遍历
IMAGE_SECTION_HEADER数组,了解每个节区在文件和内存中的映射关系。 -
根据节区头的信息,将文件的各个节区(代码、数据等)映射到内存的相应位置。
-
通过
IMAGE_DATA_DIRECTORY找到导入表,解析并填充所有需要的外部函数地址。 -
最后,跳转到
AddressOfEntryPoint指向的地址,程序开始执行。
这个结构确保了 PE 文件既能在磁盘上高效存储,又能在内存中正确加载和执行。
PE文件头结构图解 + 白话文秒懂
📊 完整结构总览
┌─────────────────────────────────────────────────────────┐
│ DOS Header (64字节) │ ← 开头的"MZ"标记
│ "这是个老式DOS程序" 的伪装外壳 │
├─────────────────────────────────────────────────────────┤
│ DOS Stub (可变) │
│ "This program cannot be run in DOS mode" │ ← 在DOS下运行会看到的提示
├─────────────────────────────────────────────────────────┤
│ PE Signature (4字节) │
│ "PE\0\0" │ ← 真正的PE文件标记
├─────────────────────────────────────────────────────────┤
│ File Header (20字节) │
│ 记录机器类型、节数量、时间戳等基本信息 │
├─────────────────────────────────────────────────────────┤
│ Optional Header (224/240字节) │
│ 记录程序入口点、内存布局、导入导出等关键信息 │
├─────────────────────────────────────────────────────────┤
│ Section Table (每节40字节) │
│ .text .data .rdata .rsrc 等节的"目录" │
├─────────────────────────────────────────────────────────┤
│ │
│ Section 1 (.text) │ ← 代码区
│ 你写的代码在这里 │
│ │
├─────────────────────────────────────────────────────────┤
│ Section 2 (.data) │ ← 数据区
│ 全局变量在这里 │
├─────────────────────────────────────────────────────────┤
│ Section 3 (.rsrc) │ ← 资源区
│ 图标、对话框、字符串在这里 │
└─────────────────────────────────────────────────────────┘
🔍 详细结构拆解
1️⃣ DOS Header (IMAGE_DOS_HEADER)
偏移 大小 字段名 白话文解释
+0x00 2字节 e_magic "MZ" 标记(0x5A4D)—— 所有PE文件必须以这两个字母开头
+0x02 58字节 [其他DOS字段] 基本没用,为了兼容古董DOS系统
+0x3C 4字节 e_lfanew **超重要!** 指向真正的PE头在哪里
白话:
这是个"假门面",为了让Windows程序能在老DOS系统上显示错误提示,而不是直接崩溃。
最重要的是最后那个e_lfanew,它告诉系统:"真正的PE头在文件偏移XXX处"。
2️⃣ PE Signature (4字节)
+0x00 4字节 Signature "PE\0\0" (0x50450000)
白话:
就像盖了个"认证章",证明"我是正宗的Windows程序"。
3️⃣ File Header (IMAGE_FILE_HEADER - 20字节)
偏移 大小 字段名 白话文解释
+0x00 2字节 Machine CPU类型(0x14C=x86, 0x8664=x64)
+0x02 2字节 NumberOfSections 这个程序有几个"节"(通常3-6个)
+0x04 4字节 TimeDateStamp 程序编译的时间戳
+0x08 4字节 PointerToSymbolTable 调试符号表位置(发布版通常是0)
+0x0C 4字节 NumberOfSymbols 符号数量
+0x10 2字节 SizeOfOptionalHeader 下一个头的大小(32位=224, 64位=240)
+0x12 2字节 Characteristics 文件属性标志
白话:
这是"身份证"部分:
- 告诉系统这是32位还是64位程序
- 有几个代码/数据分区
- 什么时候编译的
- 是个EXE还是DLL(Characteristics字段)
Characteristics 常见标志:
0x0002 IMAGE_FILE_EXECUTABLE_IMAGE 可执行文件(不是obj)
0x0100 IMAGE_FILE_32BIT_MACHINE 32位程序
0x2000 IMAGE_FILE_DLL 这是个DLL文件
4️⃣ Optional Header (IMAGE_OPTIONAL_HEADER - 最重要!)
标准字段部分
偏移 大小 字段名 白话文解释
+0x00 2字节 Magic 0x10B(32位) / 0x20B(64位)
+0x02 1字节 MajorLinkerVersion 编译器版本
+0x03 1字节 MinorLinkerVersion
+0x04 4字节 SizeOfCode 代码段总大小
+0x08 4字节 SizeOfInitializedData 已初始化数据大小
+0x0C 4字节 SizeOfUninitializedData未初始化数据大小
+0x10 4字节 AddressOfEntryPoint **入口点!程序从这里开始执行**
+0x14 4字节 BaseOfCode 代码段起始地址
+0x18 4字节 BaseOfData 数据段起始地址(仅32位)
Windows专用字段部分
偏移 大小 字段名 白话文解释
+0x1C 4/8字节 ImageBase 程序希望加载到内存的哪个地址
+0x20 4字节 SectionAlignment 节在内存中的对齐单位(通常0x1000=4KB)
+0x24 4字节 FileAlignment 节在文件中的对齐单位(通常0x200=512字节)
+0x28 8字节 [操作系统版本号]
+0x30 8字节 [程序版本号]
+0x38 8字节 [子系统版本号]
+0x40 4字节 Win32VersionValue 保留(总是0)
+0x44 4字节 SizeOfImage 程序加载到内存后的总大小
+0x48 4字节 SizeOfHeaders 所有头的总大小
+0x4C 4字节 CheckSum 校验和(驱动必须正确,普通程序可为0)
+0x50 2字节 Subsystem 子系统类型
+0x52 2字节 DllCharacteristics DLL特性标志
+0x54 16字节 [栈/堆大小设置]
+0x64 4字节 NumberOfRvaAndSizes 数据目录数量(通常是16)
Subsystem 子系统:
1 = Native(驱动程序)
2 = GUI(窗口程序)
3 = CUI(控制台程序,黑框框)
DllCharacteristics 重要标志:
0x0040 DYNAMIC_BASE 支持ASLR(地址随机化)
0x0100 NX_COMPAT 支持DEP(数据执行保护)
0x0400 NO_SEH 不使用SEH异常处理
0x8000 TERMINAL_SERVER_AWARE 终端服务器感知
5️⃣ 数据目录表 (Data Directory - 16个条目)
索引 名称 作用
[0] Export Table 导出表(DLL导出的函数列表)
[1] Import Table 导入表(程序要用哪些DLL的哪些函数)
[2] Resource Table 资源表(图标、字符串、对话框)
[3] Exception Table 异常处理表
[4] Certificate Table 数字签名
[5] Base Relocation Table 重定位表(修正地址用)
[6] Debug 调试信息
[7] Architecture 架构特定数据
[8] Global Ptr 全局指针寄存器
[9] TLS Table 线程局部存储
[10] Load Config Table 加载配置
[11] Bound Import 绑定导入
[12] IAT 导入地址表(最常被破解者关注!)
[13] Delay Import Descriptor 延迟导入
[14] CLR Runtime Header .NET程序专用
[15] Reserved 保留
每个条目结构:
+0x00 4字节 VirtualAddress 数据在内存中的RVA
+0x04 4字节 Size 数据的大小
6️⃣ 节表 (Section Table - 每节40字节)
偏移 大小 字段名 白话文解释
+0x00 8字节 Name 节名称(如".text"、".data")
+0x08 4字节 VirtualSize 在内存中的实际大小
+0x0C 4字节 VirtualAddress 在内存中的起始地址(RVA)
+0x10 4字节 SizeOfRawData 在文件中的大小
+0x14 4字节 PointerToRawData 在文件中的偏移
+0x18 12字节 [重定位/行号信息] 通常为0
+0x24 4字节 Characteristics 节的属性(可读/可写/可执行)
常见节名称:
.text 代码段(你的程序逻辑) 可读+可执行
.data 已初始化数据(全局变量) 可读+可写
.rdata 只读数据(常量字符串) 只读
.bss 未初始化数据 可读+可写
.rsrc 资源(图标、菜单、字符串) 只读
.reloc 重定位信息 只读
.idata 导入表(需要的DLL函数) 可读+可写
.edata 导出表(DLL导出的函数) 只读
Characteristics 节属性:
0x00000020 CODE 包含代码
0x00000040 INITIALIZED_DATA 包含已初始化数据
0x00000080 UNINITIALIZED_DATA 包含未初始化数据
0x20000000 EXECUTE 可执行
0x40000000 READ 可读
0x80000000 WRITE 可写
🎯 关键概念白话解释
RVA (Relative Virtual Address) - 相对虚拟地址
假设程序被加载到内存地址 0x00400000
某个函数的RVA是 0x1000
那么这个函数的实际内存地址 = 0x00400000 + 0x1000 = 0x00401000
文件偏移 vs 内存地址
文件偏移:在硬盘上的.exe文件中的位置
内存地址:程序运行时在内存中的位置
需要通过节表来转换!
对齐 (Alignment)
FileAlignment = 0x200 (512字节)
→ 文件中每个节的起始位置必须是512的倍数
SectionAlignment = 0x1000 (4096字节)
→ 内存中每个节的起始位置必须是4KB的倍数
🛠️ 实战:用十六进制编辑器看PE头
偏移 十六进制 解释
00000000: 4D 5A 90 00 03 00 00 00... MZ = DOS头开始
0000003C: E0 00 00 00 e_lfanew = 0xE0
000000E0: 50 45 00 00 PE签名
000000E4: 4C 01 06 00 Machine=0x014C(x86), Sections=6
000000F8: 0B 01 Magic=0x10B(32位)
00000100: 00 10 00 00 AddressOfEntryPoint=0x1000
📝 总结:PE头三句话秒懂
- DOS头:"我伪装成DOS程序,但真正的内容在后面"
- PE头:"我是Windows程序,32位/64位,从地址XXX开始执行"
- 节表:"我的代码在.text节,数据在.data节,资源在.rsrc节"
🔥 最关心的地方
✅ AddressOfEntryPoint → 程序从哪里开始跑
✅ Import Table (IAT) → 调用了哪些关键API(MessageBox? RegCreateKey?)
✅ .text 节 → 注册验证代码藏在这里
✅ Characteristics → 能不能改这个节的数据
✅ Resource Table → 修改图标、字符串、对话框
希望这个图解能帮你快速理解PE结构!如果要深入学习,推荐工具:
- CFF Explorer (查看PE结构最直观)
- PE-bear
- 010 Editor (配合PE模板)
- windowspe 权威指南
了解所有编译器和反汇编特征
了解vmp入口,区段,反汇编特征
看特征的方法主要有两个,一个是入口代码的在节区中的位置,入口代码中包含的指令和调用的外部接口
vc6特征和易语言








64-ia-32-architectures-software-developer-manualintel开发手册
<加密与解密>
揭秘><C++反汇编与逆向分析技术

Stud PE
exeinfoExeinfo PE是一款专业的程序查壳工具
LordPE
PE Tools 1.9
WinHex
UltraEdit
Beyond Compare数据比较工具。
iat修复工具
ImportREC
Scylla
Universal lmport Fixer
Imports Fixer
vmp指令转化器软件
将汇编转vmp自定义指令,在转化为真实指令集
好的,我们来详细说明如何尝试使用 VMPDump 来处理一个被 VMProtect 2 加密的程序。
但在开始之前,我必须先强调一个至关重要的事实,这能为你节省大量的时间和精力:
🛑 第一部分:必读的现实情况与重要提醒
-
VMPDump 并非万能神药:VMPDump 是一个比较早期的、针对特定旧版本 VMProtect 的自动化分析工具。它无法真正地“还原所有代码”。它的主要功能是尝试自动完成“寻找OEP”和“Dump内存”这两个初级阶段的工作。
-
它不能“去虚拟化”:VMPDump 绝对无法将 VMProtect 的核心——虚拟化字节码(Bytecode)——翻译回原始的 x86/x64 汇编代码。被虚拟化的关键代码(如注册算法)在 VMPDump 处理后,依然是虚拟化的,你用IDA打开看仍然是一团乱麻。
-
成功率极低:对于现代版本的 VMProtect 2 (例如 2.13 及以后) 或者即使是旧版本但开启了高强度保护选项的程序,VMPDump 的成功率趋近于零。它很可能会失败、卡死、或者生成一个完全无法运行的垃圾文件。
-
它的真正作用:在极少数情况下(例如面对非常古老的、保护设置很弱的VMP版本),它可能可以帮你快速脱掉外层的壳,省去你手动寻找OEP和Dump的几分钟时间。请把它看作一个“快速尝试的彩票”,而不是一个“解决方案”。
结论: 不要期望 VMPDump 能帮你“一键还原”所有代码。它只是一个辅助性的、成功率很低的入门级工具。
第二部分:准备工作
在你开始之前,请确保你已经准备好以下环境和工具:
- 虚拟机 (VM):强烈建议在 VMware 或 VirtualBox 等虚拟机中进行所有操作。这可以防止目标程序或工具损坏你的物理机系统。
- 目标程序:你想要分析的那个被VMP2加密的
.exe文件。 - VMPDump 工具:你需要从一些逆向工程论坛或网站下载这个工具。请注意,这些工具可能被杀毒软件报毒,这是因为它们的行为(如注入进程、读取内存)与恶意软件相似。请在虚拟机中谨慎使用。
- 调试器:x64dbg (强烈推荐)。VMPDump 经常需要附加到一个正在运行的进程上,所以你需要先运行目标程序。
- PE 编辑器:CFF Explorer 或 010 Editor。用于后续检查生成的文件是否结构正常。
第三部分:使用 VMPDump 的详细步骤
假设你已经找到了一个可以运行的 VMPDump 版本,并且你的目标程序是一个32位的EXE(VMPDump主要支持32位)。
步骤 1:运行目标程序
首先,双击运行你要分析的那个 .exe 文件。让它正常运行起来,比如显示出主窗口或对话框。
步骤 2:以管理员身份运行 VMPDump
右键点击 VMPDump.exe,选择 “以管理员身份运行”。这能确保它有足够的权限去读取其他进程的内存。
步骤 3:附加到目标进程
- 在 VMPDump 的主界面上,你会看到一个 “...” 按钮,旁边是 "Process" 或 "PID" 字段。
- 点击这个 “...” 按钮,会弹出一个当前正在运行的进程列表。
- 在列表中找到你的目标程序进程(例如
target.exe),选中它,然后点击 “OK” 或 “Select”。
步骤 4:智能扫描 (Smart Scan)
这是 VMPDump 尝试分析虚拟机的关键一步。
- 在界面上找到一个名为 “Smart Scan of Handlers” 的复选框,勾选它。
- 这一步会让 VMPDump 尝试在程序的内存空间中搜索它认为是 VMProtect 虚拟机处理器(VM Handlers)的代码块。这是它最容易失败的地方。
步骤 5:设置 OEP (Original Entry Point)
VMPDump 会尝试自动检测 OEP。
- 自动检测:通常你不需要手动设置 OEP 字段。VMPDump 会利用一些内置的特征码去定位。
- 手动设置:如果自动检测失败,你需要自己通过其他方法(如 x64dbg 的 ESP 定律)找到 OEP 的相对虚拟地址(RVA),然后手动填入 OEP 字段。对于初学者来说,如果 VMPDump 无法自动找到 OEP,基本可以宣告失败了。
步骤 6:执行 Dump(转储)
- 在 VMPDump 界面上,点击 “Unpack” 或 “Dump” 按钮。
- VMPDump 会开始它的工作流程:
- 寻找 OEP。
- 扫描 VM Handlers。
- 转储内存中的所有节区(Sections)。
- 尝试重建导入地址表(IAT)。
- 如果一切顺利(概率很小),它会提示你保存文件。通常会默认保存为
[原文件名]_unpacked.exe。
步骤 7:验证生成的文件
这是最重要的一步,用来判断 VMPDump 是否真的起作用了。
-
尝试运行:双击运行生成的
_unpacked.exe文件。- 直接崩溃? -> 失败。很可能是 IAT 修复失败或代码段Dump不完整。
- 可以运行但功能异常? -> 部分失败。可能某些保护代码没有被移除。
- 可以正常运行? -> 恭喜,你完成了最基础的“脱壳”。但这不代表代码被还原了。
-
使用 PE 工具检查:用 CFF Explorer 打开
_unpacked.exe文件。- 检查 “Import Directory”(导入目录),看看函数列表是否看起来正常,有没有大量的无效或乱码条目。
- 检查节表(Section Headers),看看
.text,.data等节的大小和权限是否合理。
-
使用 IDA Pro 分析:这是最终的检验。用 IDA Pro 打开
_unpacked.exe文件。- 找到你关心的关键功能(比如注册按钮的点击事件)。
- 按 F5 尝试反编译。
- 结果是什么?
- 如果依然是无法反编译、跳转关系混乱、充满了奇怪计算的代码 -> 这就证明了 VMPDump 没有去虚拟化。它只是把内存里的东西原封不动地Dump了出来。关键逻辑依然被VM保护着。
- 如果能看到清晰的C代码逻辑 -> 这种情况几乎不可能发生,除非你的目标程序用的不是VMP的虚拟化保护。
第四部分:当 VMPDump 失败时(99% 的情况)
你很可能会遇到以下情况,这都意味着 VMPDump 对你的目标无效:
- VMPDump 无法找到 OEP 或 Handlers:直接报错,提示分析失败。
- VMPDump 在扫描过程中卡死或崩溃:说明 VMP 的反分析机制触发了。
- 生成的 Dump 文件无法运行:最常见的结果,说明 Dump 或修复过程出错了。
- 生成的 Dump 文件虽然能运行,但关键功能(如注册)依然被保护:证明了核心逻辑仍是虚拟化的。
当 VMPDump 失败后,你必须回到正统的、手动的逆向工程流程上来:
- 手动寻找 OEP:使用 x64dbg 和 ESP 定律。
- 手动 Dump:使用 Scylla 插件,在 OEP 处进行内存转储。
- 手动修复 IAT:使用 Scylla 的 IAT 搜索和修复功能。
- 手动去虚拟化:这才是真正的挑战。在 IDA 和 x64dbg 中,花费数天、数周甚至数月的时间去:
- 分析 VM Entry 和 Dispatcher。
- 逐个逆向 VM Handlers 的功能。
- 编写脚本翻译字节码。
- 找到关键逻辑并进行 Patch。
总结
使用 VMPDump 是一个“死马当活马医”的尝试。你可以按照上述步骤操作一遍,体验一下自动化工具的工作流程。但这趟旅程的终点,大概率是让你明白:没有捷径可走,对抗高强度的软件保护,唯有扎实的逆向工程基础和巨大的耐心。
好的,我们来非常详细地拆解和说明 VMProtect 2 的加密功能和脱壳流程。这是一个极其复杂但非常有趣的技术话题。
⚠️ 郑重声明:本内容仅用于安全研究、技术学习和防御策略探讨。对商业软件进行逆向工程、破解和传播是违法行为,可能导致严重的法律后果。请在法律允许的范围内使用这些知识,并尊重软件开发者的劳动成果。
Part 1: VMProtect 2 的核心加密功能(它做了什么?)
VMProtect (VMP) 不是一个简单的“壳”,它是一个深度代码混淆和虚拟化系统。其核心思想是让你写的代码,在你的电脑上都看不懂。
1. 虚拟化保护 (Virtualization) - VMP的王牌
这是VMP最强大、最核心的功能。它能将你指定的代码片段从标准的 x86/x64 汇编指令,转换成一种自定义的、每次编译都不一样的字节码(Bytecode)。
工作原理图解
你的原始代码 (x86汇编):
┌──────────────────────────────┐
│ MOV EAX, [EBP+8] ; 取一个参数 │
│ ADD EAX, 10 ; 加上 10 │
│ CMP EAX, 1234 ; 和 1234 比较 │
│ JNE loc_failed ; 不相等就跳走 │
│ CALL CheckLicense ; 相等就调用授权 │
└──────────────────────────────┘
↓ 经过 VMProtect 处理后...
┌──────────────────────────────────────────────────────┐
│ VM Entry(虚拟机入口) │
│ 功能: 保存当前CPU的真实寄存器状态 (PUSHAD/PUSHFD), │
│ 初始化虚拟机的环境(虚拟寄存器、虚拟栈指针等)。 │
├──────────────────────────────────────────────────────┤
│ Bytecode(自定义的字节码) │
│ 内容: 0x47 0x12 0x89 0xAA 0x3F 0x91 0x05 ... │
│ 特点: 这串字节码不是x86指令,只有VMP自己生成的虚拟机能看懂。│
├──────────────────────────────────────────────────────┤
│ VM Dispatcher(调度器 / 指令分发器) │
│ 功能: 这是一个循环,不断地读取下一个字节码,然后根据这个 │
│ 字节码的值,跳转到对应的处理器(Handler)去执行。 │
├──────────────────────────────────────────────────────┤
│ VM Handlers(处理器集合) │
│ 功能: 每一个Handler都是一小段真实的x86代码,负责实现 │
│ 一个字节码的功能。例如: │
│ - Handler_0x47: 实现“虚拟加法”功能。 │
│ - Handler_0x12: 实现“虚拟数据加载”功能。 │
│ - Handler_0x89: 实现“虚拟比较”功能。 │
│ 特点: 这些Handler之间互相穿插,充满了垃圾指令,让你难以分析。│
├──────────────────────────────────────────────────────┤
│ VM Exit(虚拟机退出) │
│ 功能: 执行完虚拟化代码后,从虚拟机环境退出,恢复之前保存的│
│ 真实CPU寄存器状态,然后返回到正常的x86代码继续执行。│
└──────────────────────────────────────────────────────┘
为什么这让破解变得极其困难?
- 独一无二的指令集:每个被VMP保护的程序,其内部的虚拟机、字节码、Handler都是随机生成的。你在A程序上分析得到的经验,完全无法用于B程序。
- 分析成本极高:你无法再用IDA Pro的F5功能直接看到C代码。你必须先逆向分析出这套虚拟机的几十甚至上百个Handler的功能,才能像翻译密码一样,一点点地把字节码“翻译”回原始逻辑。这个过程可能需要数周甚至数月。
- 高度混淆:一个简单的
ADD EAX, 10指令,在VM里可能被分解成V_PUSH 10->V_PUSH EAX->V_ADD->V_POP EAX等多条虚拟指令,而每个虚拟指令的Handler本身又是高度混淆的真实x86代码。
2. 变异混淆 (Mutation)
即使是不被虚拟化的代码,VMP也会对其进行“变异”,让代码变得臃肿、难以阅读。
- 等价指令替换:
ADD EAX, 1会被替换成SUB EAX, -1或LEA EAX, [EAX+1]。 - 指令膨胀:
MOV EAX, EBX会变成PUSH EBX; POP EAX。 - 插入垃圾代码:在有效指令之间插入大量无用的、迷惑性的指令,这些指令不影响程序执行结果,但会严重干扰静态分析。
- 控制流平坦化:将正常的
if-else或switch-case结构,变成一个巨大的while循环和状态机,每次都通过一个调度器来决定下一步执行哪个代码块,打乱原始的逻辑顺序。
3. 加壳与压缩 (Packing)
这是VMP的基础功能。它会将程序的原始代码段(.text)、数据段(.data)等进行加密和压缩,然后包裹在一个“加载器”(Loader)外壳里。
- 运行时解密:程序启动时,首先执行的是VMP的Loader。它会在内存中解密、解压原始的代码和数据。
- 入口点修改:程序的入口点(OEP, Original Entry Point)被隐藏起来,指向了VMP的Loader。
- 导入表保护:程序调用的Windows API(如
MessageBoxA)地址不再静态存储在导入地址表(IAT)中,而是在运行时动态获取,这使得分析者很难直接看到程序调用了哪些关键函数。
4. 反调试与反分析 (Anti-Debug)
VMP内置了海量的反调试技术,像地雷一样遍布在代码中,一旦发现自己被调试器(如x64dbg, IDA Pro)附加,就会改变行为。
- 调试器检测:通过
IsDebuggerPresent,CheckRemoteDebuggerPresent等API检测。 - 时间检测:使用
RDTSC指令或GetTickCountAPI,通过计算两段代码执行的时间差来判断是否在单步调试(单步执行会花费更长的时间)。 - 硬件断点检测:检查
DR0-DR7调试寄存器。 - 异常处理技巧:故意触发一个异常,然后用
SEH(结构化异常处理)来捕获并执行正常代码。如果调试器干预了异常处理流程,程序就会崩溃。 - 完整性检查:在运行时计算自身代码的校验和(CRC),如果发现代码被修改(比如被打了补丁),就会退出。
Part 2: VMProtect 2 的脱壳流程(如何应对?)
“脱壳”对于VMP来说是一个非常宽泛的概念。完整的流程极其复杂,通常分为以下几个阶段。
阶段一:准备工作与环境配置
这是所有工作的基础。你需要一个“干净”且“武装到牙齿”的分析环境。
-
工具清单:
- 调试器: x64dbg (主流选择,插件丰富)
- 反汇编器: IDA Pro 7.x (静态分析的王者)
- 反反调试插件: ScyllaHide (x64dbg插件,用于对抗各种反调试技术)
- Dump工具: Scylla (集成在ScyllaHide中,用于dump内存和修复IAT)
- PE工具: CFF Explorer 或 010 Editor (用于查看和编辑PE文件结构)
-
环境配置:
- 虚拟机:强烈建议在VMware或VirtualBox中进行分析,防止搞垮物理机。
- 配置ScyllaHide:在x64dbg中加载ScyllaHide插件,并尽可能多地勾选对抗选项,如
NtQueryInformationProcess,GetTickCount等。
阶段二:寻找OEP (Original Entry Point - 真实入口点)
由于VMP加了壳,第一步就是找到VMP的Loader执行完毕,即将跳转到程序原始代码的那个点(OEP)。
常用方法:ESP定律
- 用x64dbg加载目标程序,程序会停在系统断点。
- 在x64dbg的命令行输入
bp GetModuleHandleA,然后按F9运行。程序会在加载系统DLL时断下。 - 找到ESP寄存器的值,右键 -> 在内存窗口中转到。
- 在内存窗口中,选中ESP指向的前4个字节,右键 -> 断点 -> 硬件, 访问 (Dword)。
- 按F9继续运行。程序会在VMP的壳代码即将
RET或JMP到OEP之前,访问这个栈地址时断下。 - 此时,单步执行(F7/F8),很大概率就会跳转到OEP。
如何判断找到了OEP?
- 代码看起来像是正常的函数开头(如
PUSH EBP; MOV EBP, ESP)。 - 可以看到清晰的API调用。
- IDA Pro能够很好地分析这部分代码。
阶段三:Dump内存镜像
在OEP处,程序的代码和数据已经在内存中解密,此时需要将它们从内存中“倒”出来,存成一个新的EXE文件。
- 在x64dbg中,停在OEP处。
- 打开插件菜单 -> Scylla。
- 在Scylla窗口中,确保进程已附加,OEP地址已自动填好(如果不对,手动修改为OEP的RVA)。
- 点击 "IAT Autosearch" 按钮,Scylla会自动寻找并定位导入地址表。
- 点击 "Get Imports" 获取所有导入函数。
- 点击 "Dump" 将内存转储到文件(例如
dumped.exe)。 - 点击 "Fix Dump",选择刚才的
dumped.exe,Scylla会尝试修复导入表,并生成一个新文件(例如dumped_SCY.exe)。
阶段四:去虚拟化 (De-virtualization) - 最核心、最艰难的一步
仅仅Dump出文件是远远不够的,因为关键逻辑(如注册码验证)仍然是被虚拟化的字节码。去虚拟化的目标就是理解虚拟机的设计并还原原始逻辑。
-
识别VM Entry:在dump出的文件中,用IDA Pro打开。找到调用虚拟化代码的地方。通常这个地方会有一系列
PUSH指令(保存环境),然后是一个JMP到一个非常复杂混乱的区域,这个区域就是VM Entry。 -
分析VM架构:这是纯粹的逆向工程体力活。
- 定位Dispatcher:VM Entry之后,你会找到一个核心的指令分发器。它通常是一个循环,根据字节码的值跳转到不同的Handler。
- 分析Handlers:以Dispatcher为中心,逐个分析它跳转到的每一个Handler。
- 在x64dbg中对某个Handler下断点,观察它执行前后对虚拟环境(通常在栈上分配的一块内存)做了什么修改。
- 例如,你发现某个Handler总是把虚拟栈顶的两个值相加,那么你就可以把它标记为
V_ADD。 - 另一个Handler可能是从某个地方加载一个常量,你可以标记为
V_LOAD_CONST。
- 重复此过程:你需要分析出尽可能多的Handler的功能,为它们命名。这个过程极其枯燥,可能需要分析几十到上百个Handlers。
-
提取并反编译字节码:
- 当你大致搞清楚了虚拟机的指令集后,就可以在x64dbg中dump出被虚拟化的那段字节码。
- 编写一个简单的脚本(如Python),根据你分析出的Handler功能,将字节码“翻译”成可读的伪代码。
Python# 伪代码示例 bytecode = [0x10, 0x04, 0x12, 0x34, 0x56, 0x78, 0x25, 0x30, 0x01] # 翻译后可能得到 # V_LOAD_CONST 0x12345678 (指令0x10) # V_CMP_WITH_INPUT (指令0x25) # V_JNE_FAIL (指令0x30) -
还原逻辑并Patch:
- 从翻译出的伪代码中,你就能看懂原始的验证逻辑了。
- 暴力破解:找到关键的跳转,例如
V_JNE_FAIL,然后在实现这个虚拟跳转的真实x64代码处,将其修改(如JNE改成JMP或NOP),从而绕过验证。这通常是最高效的方法。 - 算法还原:如果你的目标是写注册机,那就需要完全逆向整个算法,这难度极高。
阶段五:修复与清理
- 修复其他VMP“陷阱”:如完整性检查(CRC Check)。你需要找到计算CRC的地方,然后将其NOP掉,或者在Patch后重新计算正确的CRC值并填回去。
- 测试:运行你最终patch过的文件,确保所有功能正常,并且破解有效。
总结
| 步骤 | 目标 | 难度 | 常用工具 | 关键技巧 |
|---|---|---|---|---|
| 阶段一: 准备 | 搭建稳定的分析环境 | ★☆☆☆☆ | VMware, x64dbg, ScyllaHide | 反反调试配置 |
| 阶段二: 寻OEP | 找到壳执行完后的真实入口 | ★★★☆☆ | x64dbg | ESP定律、硬件断点 |
| 阶段三: Dump | 将内存中的解密代码存盘 | ★★☆☆☆ | Scylla, PE-sieve | 修复IAT |
| 阶段四: 去虚拟化 | 分析VM,还原原始逻辑 | ★★★★★ | IDA Pro, x64dbg | 耐心、毅力、逆向思维 |
| 阶段五: 修复 | 移除其他保护,完成破解 | ★★★☆☆ | x64dbg, 010 Editor | 定位CRC校验 |
结论:对VMP2进行所谓的“暴力破解”几乎是不可能的。真正的“破解”是一个极其漫长且复杂的逆向工程过程,其核心是对 VMProtect的虚拟机进行“去虚拟化”分析。这需要深厚的汇编、操作系统底层知识,以及超乎常人的耐心和毅力。对于大多数情况,分析者会选择最简单的路径,即只分析到能绕过验证的关键跳转,然后进行Patch,而不会尝试去完整地还原所有被虚拟化的代码。
VMProtect 2 加密功能与脱壳流程详解
⚠️ 法律声明:以下内容仅供安全研究和学习用途。未经授权对软件进行逆向工程可能违反法律和软件许可协议。
📌 Part 1: VMProtect 2 的核心加密功能
1.1 三大核心保护技术
┌─────────────────────────────────────────────────────┐
│ VMProtect 2 保护技术架构图 │
├─────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────┐ │
│ │ Virtualization│ │ Mutation │ │ Packing │ │
│ │ 虚拟化 │ │ 变异混淆 │ │ 加壳 │ │
│ └─────────────┘ └──────────────┘ └───────────┘ │
│ ↓ ↓ ↓ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Anti-Debug & Anti-Dump │ │
│ │ 反调试 & 反转储 │ │
│ └─────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
1.2 虚拟化保护 (Virtualization) - 最强大的功能
原理图解
原始代码(x86汇编):
┌──────────────────────────────┐
│ MOV EAX, [EBP+8] │
│ ADD EAX, 10 │
│ CMP EAX, 64 │
│ JNE loc_failed │
│ CALL CheckLicense │
└──────────────────────────────┘
↓ VMProtect 处理
┌──────────────────────────────────────────────────────┐
│ VM Entry(虚拟机入口) │
│ • 保存真实寄存器状态 │
│ • 初始化虚拟寄存器 │
│ • 加载字节码指针 │
├──────────────────────────────────────────────────────┤
│ Bytecode(自定义字节码) │
│ 0x47 0x12 0x89 0xAA 0x3F 0x91 0x05 ... │
│ (每次编译都不同!) │
├──────────────────────────────────────────────────────┤
│ VM Dispatcher(调度器) │
│ while (true) { │
│ opcode = *bytecode_ptr++; │
│ jump handlers[opcode]; // 跳转到处理器 │
│ } │
├──────────────────────────────────────────────────────┤
│ VM Handlers(处理器集合) │
│ Handler_0x47: // 可能是 PUSH │
│ vm_stack[++sp] = vm_regs[...]; │
│ 混淆代码... │
│ return to dispatcher; │
│ │
│ Handler_0x12: // 可能是 ADD │
│ vm_regs[a] = vm_regs[b] + decrypt(...); │
│ 垃圾代码... │
│ return to dispatcher; │
│ ...(可能有 50-200+ 个 handlers) │
├──────────────────────────────────────────────────────┤
│ VM Exit(虚拟机退出) │
│ • 恢复真实寄存器 │
│ • 返回到未保护代码 │
└──────────────────────────────────────────────────────┘
虚拟化的"恐怖"之处
// 原始代码:1条指令
if (serial == 0x12345678) return true;
// 虚拟化后:可能变成 100+ 条等价操作
vm_reg[0] = decrypt_constant(bytecode[0]); // 获取 serial
vm_reg[1] = 0x12;
vm_reg[1] = (vm_reg[1] << 8) | 0x34;
vm_reg[1] = (vm_reg[1] << 8) | 0x56;
vm_reg[1] = (vm_reg[1] << 8) | 0x78;
vm_reg[2] = vm_reg[0] ^ vm_reg[1];
vm_reg[3] = ~vm_reg[2];
vm_reg[3] = vm_reg[3] + 1;
if (vm_reg[3] == 0) vm_flag = 1;
// ... 还有几十行垃圾指令
1.3 变异混淆 (Mutation)
代码膨胀技术
原始代码:
ADD EAX, 5
变异后(可能变成以下任意一种):
方式1: 等价替换
SUB EAX, -5
方式2: 多步骤分解
ADD EAX, 3
ADD EAX, 2
方式3: 加入垃圾指令
PUSH EBX
MOV EBX, 5
ADD EAX, EBX
POP EBX
方式4: 使用复杂运算
LEA EAX, [EAX + 5]
方式5: 插入无效代码
JMP $+5
DB 0xE8, 0xFF, 0xFF // 看起来像CALL,但跳过了
ADD EAX, 5
控制流平坦化 (Control Flow Flattening)
原始代码:
A → B → C → D
平坦化后:
┌→ Dispatcher ←┐
│ ↓ │
│ switch(state) {
│ case 1: A; state=3; break;
│ case 2: C; state=4; break;
│ case 3: B; state=2; break;
│ case 4: D; state=0; break;
│ } │
└─────────────┘
1.4 加壳与压缩 (Packing)
原始PE文件结构:
┌────────────────┐
│ PE Headers │
├────────────────┤
│ .text (100KB) │ ← 代码段
├────────────────┤
│ .data (50KB) │ ← 数据段
├────────────────┤
│ .rsrc (200KB) │ ← 资源
└────────────────┘
↓ VMP 加壳处理
加壳后的文件:
┌──────────────────────┐
│ Original PE Headers │ ← 保留原始头(部分修改)
├──────────────────────┤
│ VMP Loader │ ← 解密解压代码
├──────────────────────┤
│ Encrypted .text │ ← 加密的代码段
│ (压缩 + 加密) │
├──────────────────────┤
│ Encrypted .data │
├──────────────────────┤
│ VM Core Engine │ ← 虚拟机引擎代码
├──────────────────────┤
│ Encrypted Bytecode │ ← 虚拟化后的字节码
├──────────────────────┤
│ Anti-Debug Code │ ← 反调试模块
├──────────────────────┤
│ Import Protection │ ← 导入表保护
└──────────────────────┘
1.5 反调试与反分析
多层反调试技术清单
╔══════════════════════════════════════════════════════════╗
║ VMProtect 2 反调试技术大全 ║
╠══════════════════════════════════════════════════════════╣
║ [硬件层] ║
║ ✓ 硬件断点检测 (DR0-DR7寄存器) ║
║ ✓ 单步检测 (Trap Flag) ║
║ ✓ 性能计数器检测 ║
╠══════════════════════════════════════════════════════════╣
║ [API层] ║
║ ✓ IsDebuggerPresent ║
║ ✓ CheckRemoteDebuggerPresent ║
║ ✓ NtQueryInformationProcess (ProcessDebugPort) ║
║ ✓ NtSetInformationThread (HideFromDebugger) ║
║ ✓ OutputDebugString 技巧 ║
╠══════════════════════════════════════════════════════════╣
║ [时间检测] ║
║ ✓ RDTSC 指令(检测时间差) ║
║ ✓ GetTickCount / QueryPerformanceCounter ║
║ ✓ 两次执行间隔异常 → 判定为单步调试 ║
╠══════════════════════════════════════════════════════════╣
║ [进程/线程检测] ║
║ ✓ 检测调试器进程名(OllyDbg.exe, x64dbg.exe等) ║
║ ✓ 检测调试器窗口类名 ║
║ ✓ 父进程检测(是否被调试器启动) ║
╠══════════════════════════════════════════════════════════╣
║ [内存检测] ║
║ ✓ PEB.BeingDebugged 标志检测 ║
║ ✓ PEB.NtGlobalFlag 检测 ║
║ ✓ Heap Flags 检测 ║
║ ✓ 校验和检测(代码被修改) ║
╠══════════════════════════════════════════════════════════╣
║ [异常处理] ║
║ ✓ SEH 反调试(异常处理链) ║
║ ✓ VEH 反调试(向量化异常) ║
║ ✓ INT 3 / INT 2D 检测 ║
║ ✓ 故意触发异常,检查处理流程 ║
╠══════════════════════════════════════════════════════════╣
║ [虚拟机检测] ║
║ ✓ VMware / VirtualBox 检测 ║
║ ✓ CPUID 指令检测 ║
║ ✓ 虚拟化环境特征检测 ║
╚══════════════════════════════════════════════════════════╝
📌 Part 2: VMProtect 2 脱壳流程详解
2.1 脱壳总体流程图
┌─────────────────────────────────────────────────────────────┐
│ VMProtect 2 脱壳流程 │
└─────────────────────────────────────────────────────────────┘
阶段一:环境准备
├─ 安装必要工具
├─ 配置反反调试环境
└─ 样本初步分析
↓
阶段二:定位 OEP (Original Entry Point)
├─ 方法1: ESP定律
├─ 方法2: 内存断点
├─ 方法3: 最后一次异常
└─ 方法4: SFX自解压特征
↓
阶段三:Dump内存镜像
├─ 使用 Scylla / OllyDumpEx
├─ 完整dump进程内存
└─ 修正 ImageSize
↓
阶段四:修复导入表 (IAT)
├─ 自动搜索IAT
├─ 修复Thunk
└─ 重建导入表
↓
阶段五:去虚拟化 (最难!)
├─ 识别VM Entry
├─ 分析VM架构
├─ 提取Handler
├─ 反编译字节码
└─ 重建原始逻辑
↓
阶段六:修复与测试
├─ 修复重定位表
├─ 修复资源
├─ 删除反调试代码
└─ 测试运行
2.2 阶段一:环境准备与工具配置
必备工具清单
┌──────────────────────────────────────────────────────────┐
│ 工具类型 工具名称 用途 │
├──────────────────────────────────────────────────────────┤
│ 调试器 x64dbg 主力调试 │
│ OllyDbg 2.01 32位程序备用 │
│ WinDbg 内核态调试 │
├──────────────────────────────────────────────────────────┤
│ 反汇编器 IDA Pro 7.x 静态分析 │
│ Ghidra 开源替代品 │
├──────────────────────────────────────────────────────────┤
│ 反反调试 ScyllaHide 对抗反调试 │
│ TitanHide 驱动级隐藏 │
│ HideOD 隐藏OllyDbg │
├──────────────────────────────────────────────────────────┤
│ Dump工具 Scylla dump+IAT修复 │
│ OllyDumpEx Olly插件 │
│ PE-sieve 内存扫描 │
├──────────────────────────────────────────────────────────┤
│ PE工具 CFF Explorer PE结构查看 │
│ LordPE PE编辑 │
│ StudPE PE分析 │
├──────────────────────────────────────────────────────────┤
│ 去虚拟化 VMPDump (部分有效) 自动化工具 │
│ Code Virtualizer Tools 辅助分析 │
│ 自写脚本 IDAPython等 │
├──────────────────────────────────────────────────────────┤
│ 监控工具 Process Monitor 文件/注册表监控 │
│ API Monitor API调用监控 │
│ WinAPIOverride API Hook │
└──────────────────────────────────────────────────────────┘
x64dbg 配置示例
1. 安装 ScyllaHide 插件
- 下载 ScyllaHide.zip
- 解压到 x64dbg\plugins\
- 重启 x64dbg
- 插件 → ScyllaHide → Options
- 勾选所有反调试对抗选项:
✓ NtQueryInformationProcess
✓ NtQuerySystemInformation
✓ NtSetInformationThread
✓ NtClose
✓ OutputDebugString
✓ GetTickCount
✓ ...
2. 配置选项
- 选项 → 首选项 → 异常
✓ 忽略所有异常(第一轮)
- 选项 → 首选项 → 引擎
✓ 禁用调试特权
✓ 保存数据库
3. 安装 Scylla
- 插件 → Scylla → Hide from PEB
2.3 阶段二:定位 OEP (真实入口点)
方法1: ESP 定律(最常用)
原理:
VMP的壳代码执行完后,会用类似 RETN 的指令跳转到OEP
此时ESP指向的栈位置存放着OEP地址
步骤:
1. x64dbg 加载程序(暂停在系统断点)
2. 命令行执行:
bp GetProcAddress // 或者 bp LoadLibraryA
F9 运行
3. 断下后,在命令行:
hr esp // 对ESP指向的内存地址下硬件访问断点
4. F9 继续运行
5. 程序会在访问这个栈地址时断下
观察此时的指令,通常是:
PUSH xxxxxxxx
RETN ← 断在这里
6. 单步执行 (F7),会跳转到一个新地址
此时查看代码,如果看到:
- 正常的函数序言(PUSH EBP; MOV EBP,ESP)
- 或者开始调用API
- 代码段名称变成 .text
→ 这就是 OEP!
7. 记录当前地址,例如:00401000
方法2: 内存断点法
步骤:
1. 运行程序,在 x64dbg 中打开内存映射 (Alt+M)
2. 找到 .text 节(代码段)
- 名称:.text
- 权限:ER-(可执行,可读)
- 大小:通常最大的可执行段
3. 右键 → 在反汇编中转到 → 地址
记录起始地址,例如:00401000
4. 在命令行设置内存访问断点:
bpm 00401000, 1, x // 在00401000地址,1字节,执行时中断
5. F9 运行,当第一次执行 .text 段代码时会断下
→ 这通常就是 OEP 附近
注意:VMP可能会在OEP前多次访问.text段进行完整性检查
方法3: 异常断点法
原理:VMP使用大量异常作为混淆手段,最后一个异常通常在OEP附近
步骤:
1. x64dbg → 选项 → 首选项 → 异常
- 取消"忽略所有异常"
- 只保留"忽略INT 3"
2. F9 运行,每次异常断下时:
- 查看当前位置是否在 .text 段
- 如果不是,按 Shift+F9 传递异常
3. 重复多次后,最终会停在接近OEP的地方
4. 配合反汇编判断是否为OEP
如何确认找到的是真正的 OEP?
特征检查清单:
✓ 代码段名称是 .text 或 CODE
✓ 可以看到清晰的函数调用(CALL API)
✓ 有正常的函数序言:
PUSH EBP
MOV EBP, ESP
SUB ESP, xxx
✓ 或者是 WinMain 的标准开头:
PUSH EBX
PUSH ESI
PUSH EDI
✓ 附近有字符串引用
✓ IDA Pro F5能正常反编译
✓ 地址接近PE头中的 AddressOfEntryPoint(但不完全相同)
2.4 阶段三:Dump 内存镜像
使用 Scylla 进行 Dump
步骤:
1. 在 OEP 处暂停程序(上一步已找到)
2. x64dbg → 插件 → Scylla
3. Scylla 窗口设置:
[*] Attach to: [选择目标进程]
[*] OEP: 00001000 ← 输入相对于ImageBase的偏移
[*] IAT AutoSearch ← 自动搜索导入表
4. 点击 "IAT Autosearch"
- 如果成功,会显示:
Found IAT: 00402000 (Size: 0x400)
5. 点击 "Get Imports"
- Scylla会分析IAT,列出所有导入函数
- 检查是否有 "invalid" 标记的项
6. 如果有invalid项:
- 右键 → Cut Thunk
- 或手动修复
7. 点击 "Dump"
- 选择保存位置,例如:dumped.exe
8. 点击 "Fix Dump"
- 选择刚才保存的 dumped.exe
- Scylla会修复IAT并生成 dumped_SCY.exe
手动 Dump (OllyDumpEx)
在 OllyDbg 中:
1. 插件 → OllyDumpEx
2. 设置参数:
Base Address: 00400000 ← 从内存窗口查看
Entry Point: 00001000 ← OEP的RVA
Size: 00050000 ← 从PE头SizeOfImage获取
3. 勾选选项:
✓ Rebuild Import
✓ Fix Dump
4. 点击 "Dump" 保存
注意:手动dump可能需要后续修复重定位表
2.5 阶段四:修复导入表 (IAT Fix)
IAT修复原理
正常程序的IAT:
┌──────────────────────────┐
│ .idata 节 │
├──────────────────────────┤
│ Import Directory │
│ DLL: kernel32.dll │
│ Thunk Array: │
│ → CreateFileA │
│ → ReadFile │
│ → CloseHandle │
├──────────────────────────┤
│ IAT (导入地址表) │
│ 0x402000: 76A81234 ← CreateFileA的真实地址
│ 0x402004: 76A85678 ← ReadFile的真实地址
│ 0x402008: 76A89ABC ← CloseHandle的真实地址
└──────────────────────────┘
VMP加密后:
┌──────────────────────────┐
│ Import Directory 被破坏 │
│ 或指向假数据 │
├──────────────────────────┤
│ IAT 被加密/动态生成 │
│ 0x402000: 00000000 │
│ 运行时才填充真实地址 │
└──────────────────────────┘
修复目标:
重建 Import Directory 和 IAT,使dump出的文件能独立运行
Scylla 自动修复
1. "IAT Autosearch" 原理:
- 在OEP附近扫描内存
- 查找连续的、指向DLL代码段的指针
- 识别为IAT
2. "Get Imports" 原理:
- 读取IAT中的地址
- 通过地址找到对应的DLL模块
- 在DLL的导出表中查找函数名
- 重建导入表
3. 常见问题及解决:
问题1: "Found invalid imports"
原因:某些地址不是真正的API地址
解决:右键 → Cut Thunk (删除)
问题2: "IAT not found"
原因:IAT被严重混淆
解决:手动搜索
- 在OEP处下断点
- 运行程序,观察哪些API被调用
- 在内存中搜索这些API的地址
- 找到IAT的大致范围后,手动指定
手动修复 IAT(高级)
工具:IDA Pro + IDAPython
脚本示例:
import idaapi
import idc
def rebuild_iat(start, end):
"""重建IAT"""
addr = start
imports = []
while addr < end:
# 读取指针
ptr = idc.get_qword(addr) if idaapi.get_inf_structure().is_64bit() \
else idc.get_wide_dword(addr)
# 检查是否指向DLL代码
seg = idaapi.getseg(ptr)
if seg and seg.type == idaapi.SEG_XTRN:
# 获取函数名
name = idc.get_name(ptr)
if name:
imports.append((addr, name))
print(f"{hex(addr)}: {name}")
addr += 4 if not idaapi.get_inf_structure().is_64bit() else 8
return imports
# 使用
iat_start = 0x00402000 # 手动确定的IAT起始
iat_end = 0x00402400 # 手动确定的IAT结束
rebuild_iat(iat_start, iat_end)
2.6 阶段五:去虚拟化 (De-virtualization) - 核心难点
5.1 识别 VM Entry
特征码搜索法:
在IDA Pro中搜索以下特征:
特征1: 大量 PUSHFD / PUSHAD (保存寄存器)
60 PUSHAD
9C PUSHFD
...
大量MOV/PUSH操作
...
特征2: 跳转到动态计算的地址
MOV EAX, [EBP+var_XX]
JMP [EAX*4+table_base] ← VM Dispatcher
特征3: 大量的RET链
RETN
RETN
RETN ← 每个Handler结束都用RETN返回Dispatcher
手动识别:
1. 从OEP开始F5查看
2. 如果看到极其复杂的代码,无法理解的嵌套
3. 大量单字节变量操作
4. 频繁的函数调用但没有明显逻辑
→ 这些都是VM化的代码
5.2 分析 VM 架构
目标:理解这个虚拟机的"指令集"
┌─────────────────────────────────────────┐
│ VM 架构分析清单 │
├─────────────────────────────────────────┤
│ 1. 虚拟寄存器 (Virtual Registers) │
│ - 在哪里存储?(栈上/全局内存) │
│ - 有多少个?(通常8-16个) │
│ - 如何访问? │
├─────────────────────────────────────────┤
│ 2. 虚拟栈 (Virtual Stack) │
│ - Stack Pointer 在哪? │
│ - PUSH/POP 如何实现? │
├─────────────────────────────────────────┤
│ 3. 字节码 (Bytecode) │
│ - 存储位置 │
│ - 读取方式 │
│ - 格式:定长?变长? │
├─────────────────────────────────────────┤
│ 4. Dispatcher (调度器) │
│ - 入口地址 │
│ - 解码逻辑 │
│ - 跳转表结构 │
├─────────────────────────────────────────┤
│ 5. Handlers (处理器) │
│ - 数量统计 │
│ - 功能分类 │
│ - 参数传递方式 │
└─────────────────────────────────────────┘
5.3 提取 VM Handlers
方法:动态跟踪 + 静态分析结合
x64dbg 脚本示例:
--------------------
// 在 Dispatcher 下断点
bp 00401234 // Dispatcher地址
// 记录每个Handler
var handler_list
log "Opcode, Handler Address"
loop:
run
mov eax, [esp] // 假设栈顶是返回地址
mov ebx, [ebp+opcode] // 假设EBP+offset存放opcode
log "{ebx}, {eax}"
esti // 执行直到返回
jmp loop
--------------------
输出示例:
Opcode, Handler Address
0x01, 0x00401500 ← Handler_01
0x02, 0x00401650 ← Handler_02
0x03, 0x004017A0 ← Handler_03
...
然后在IDA中逐个分析这些地址的代码
5.4 反编译字节码
步骤一:提取字节码
-----------------
x64dbg内存dump:
1. 运行到VM Entry后断下
2. 在dump窗口,找到字节码起始地址(通常从某个寄存器得到)
例如:EBP+0x100 指向字节码
3. 右键 → Dump Memory to File
4. 保存为 bytecode.bin
步骤二:编写反编译器
-------------------
Python示例:
class VMDecompiler:
def __init__(self, bytecode_file):
with open(bytecode_file, 'rb') as f:
self.bytecode = f.read()
self.pc = 0 # Program Counter
# Handler映射表(需手动填充)
self.handlers = {
0x01: self.vm_push,
0x02: self.vm_pop,
0x03: self.vm_add,
0x04: self.vm_sub,
0x05: self.vm_mov,
0x10: self.vm_cmp,
0x11: self.vm_jne,
# ... 更多
}
def read_byte(self):
val = self.bytecode[self.pc]
self.pc += 1
return val
def read_dword(self):
val = int.from_bytes(self.bytecode[self.pc:self.pc+4], 'little')
self.pc += 4
return val
def vm_push(self):
val = self.read_dword()
print(f"PUSH {hex(val)}")
def vm_add(self):
print("ADD vreg0, vreg1")
def vm_jne(self):
offset = self.read_dword()
print(f"JNE {hex(offset)}")
# ... 实现所有handler
def decompile(self):
while self.pc < len(self.bytecode):
opcode = self.read_byte()
if opcode in self.handlers:
self.handlers[opcode]()
else:
print(f"Unknown opcode: {hex(opcode)}")
break
# 使用
dec = VMDecompiler('bytecode.bin')
dec.decompile()
5.5 重建原始逻辑(最终目标)
输入:反编译的字节码
PUSH 0x12345678
PUSH [ebp+8]
CALL vm_hash
CMP vreg0, vreg1
JNE fail_label
PUSH 1
RET
输出:还原的C代码
int check_serial(char* input) {
int hashed = vm_hash(input);
if (hashed == 0x12345678) {
return 1;
}
return 0;
}
然后可以:
1. 用nop掉验证逻辑
2. 或者写注册机(基于还原的算法)
3. 或者直接patch跳转
2.7 阶段六:修复与测试
6.1 删除反调试代码(暴力法)
在IDA Pro中搜索并NOP以下代码:
1. IsDebuggerPresent
搜索:CALL IsDebuggerPresent
替换:NOP x 5
2. 时间检测
搜索:RDTSC / CPUID
替换:XOR EAX,EAX (清零结果)
3. 检查BeingDebugged
搜索:MOV AL, [FS:0x30] + 0x02
替换:MOV AL, 0
4. 异常反调试
搜索:INT 3 / INT 2D
替换:NOP
6.2 修复重定位表
使用 CFF Explorer:
1. 打开 dump_fixed.exe
2. Relocation Editor
3. 如果显示 "No relocations":
- 可能需要手动重建
- 或者将ImageBase固定为原始值
手动fix (如果必要):
使用 LordPE:
1. PE Editor
2. Section Headers → .reloc
3. 如果损坏,从原始文件复制.reloc节
6.3 测试流程
1. 静态测试
- CFF Explorer 打开,检查PE结构完整性
- ImportREC 验证导入表
2. 沙箱测试
- 虚拟机中运行
- 检查是否崩溃
- Process Monitor 监控行为
3. 功能测试
- 主要功能是否正常
- 是否还有反调试触发
- 是否有完整性检查
4. 对比测试
- 与原始程序对比API调用序列
- 对比关键算法输出
📌 Part 3: 实战案例分析
案例:破解一个简单的VMP2保护程序
目标程序:CrackMe_VMP2.exe
保护方式:VMProtect 2.08
目标:绕过序列号验证
=======================================
步骤记录:
=======================================
1. 查壳
► PEID: VMProtect 2.0x
► DIE: VMProtect 2.08
2. 初步运行
► 弹出对话框:"Enter Serial"
► 输入错误显示:"Wrong Serial!"
► 推测:验证逻辑被VM保护
3. x64dbg加载 + ScyllaHide
► 所有反调试选项全开
► F9运行,程序正常弹窗
4. 定位验证函数
方法:API断点
► bp GetDlgItemTextA // 获取输入的API
► F9运行,在输入框输入"test",点确定
► 断下!
5. 回溯查找验证逻辑
► 单步返回(Ctrl+F9)到调用者
► 发现代码段:
CALL GetDlgItemTextA
LEA EAX, [EBP-0x100]
PUSH EAX
CALL 00401234 ← 可疑的验证函数
TEST EAX, EAX
JZ fail_label
► 00401234 就是验证函数入口
6. 分析验证函数
► 跟入 CALL 00401234
► 发现大量混淆代码:
PUSH ECX
PUSH EDX
MOV EAX, [ESP+8]
JMP 00405678 ← 跳转到VM Entry!
7. 识别VM Entry
地址:00405678
特征:
PUSHAD
PUSHFD
MOV EBP, ESP
SUB ESP, 0x200
MOV ESI, [大量初始化]
...
JMP [EAX*4+0x00406000] ← Dispatcher!
8. 简化破解法:爆破跳转
► 不去虚拟化了,直接改跳转
► 在第5步的代码处:
TEST EAX, EAX
JZ fail_label ← 改成 JNZ
► 使用x64dbg修改:
选中 JZ 指令
空格 → 编辑
改成:JNZ fail_label
右键 → Patch → Patch File
保存为 CrackMe_VMP2_cracked.exe
9. 测试
► 运行 CrackMe_VMP2_cracked.exe
► 输入任意序列号
► 成功弹出:"Correct!"
=======================================
总结:
对于简单目标,不需要完全去虚拟化
找到关键跳转直接patch即可
但对于复杂的License系统,可能需要完整分析
=======================================
📌 Part 4: 进阶技术
4.1 自动化去虚拟化工具
工具1: VMPAttack
- GitHub开源项目
- 支持部分VMP版本
- 能自动识别Handler
使用:
python vmpattack.py target.exe --trace --devirt
工具2: Kryptonite
- IDA Pro插件
- 符号执行 + 污点分析
- 半自动去虚拟化
工具3: 自己用Unicorn引擎写模拟器
from unicorn import *
from unicorn.x86_const import *
# 模拟VM执行
uc = Uc(UC_ARCH_X86, UC_MODE_32)
uc.mem_map(0x00400000, 2 * 1024 * 1024)
uc.mem_write(0x00400000, code)
uc.emu_start(0x00401000, 0x00401234)
4.2 对抗完整性检查
VMP的CRC校验:
1. 在运行时计算代码段的校验和
2. 与内置值比较
3. 不匹配则退出
对策:
方法1:Hook校验函数
bp [校验函数地址]
修改返回值为"正确"
方法2:修复校验值
计算patch后的新CRC
在内存中搜索旧CRC值
替换为新值
方法3:NOP掉校验代码
找到CRC计算和比较代码
全部NOP
总结
VMProtect 2 的脱壳是逆向工程中的终极挑战之一:
难度评级: ⭐⭐⭐⭐⭐ (5/5)
时间成本:
- 简单patch:1-3小时
- 完整dump:1-2天
- 去虚拟化:1周-数月
成功率:
- 基本脱壳:90%
- IAT修复:70%
- 完全去虚拟化:<20%
关键建议:
- 从简单目标开始练习
- 理论+实践结合
- 善用自动化工具
- 专注于关键逻辑,不要试图还原全部
- 考虑替代方案(如内存patch而不是文件patch)
最后强调:将知识用于合法的安全研究和学习,尊重软件版权!
这是一个非常好的问题,也是无数逆向工程爱好者和安全研究人员梦寐以求的“圣杯”。
答案非常明确且残酷:不存在,也几乎不可能存在一个能够自动化、通用、完美修复VMProtect加密程序的“万能工具”。
下面我将详细解释为什么这在技术上是一个近乎无解的难题,以及为什么你看到的所谓“工具”都只是辅助性的。
第一部分:为什么“万能工具”是一个幻想?
要理解这一点,我们必须区分**“加壳器 (Packer)”** 和 “虚拟化保护器 (Virtualizer)” 的根本区别。
1. 简单的加壳器 (如 UPX)
- 工作原理:像一个
zip压缩程序。它有一套固定的算法来压缩原始代码,然后在程序入口点放一小段固定的解压代码。 - 脱壳原理:因为解压算法是固定的,所以可以写一个固定的“解压”工具(脱壳机)。
UPX -d就是这样一个万能脱壳工具,因为它完全了解UPX的压缩算法。
2. VMProtect (虚拟化保护器)
- 工作原理:它不是一个
zip程序,而是一个**“语言发明工厂”**。- 当你用VMProtect保护你的程序时,VMP会为你程序的关键代码发明一种全新的、独一无二的、随机的编程语言(字节码)。
- 同时,它还会为你**生成一个唯一的解释器(虚拟机VM)**来执行这种新语言。
- 核心特性:无限变种 (Infinite Variation)
- 不同的程序,不同的VM:用VMP保护
A.exe和B.exe,会得到两个完全不同的虚拟机。A.exe里的VM不认识B.exe的字节码,反之亦然。 - 同一程序,每次编译都不同:你用完全相同的设置,两次保护同一个
A.exe,得到的A_v1.exe和A_v2.exe,它们内部的虚拟机和字节码也是不一样的。
- 不同的程序,不同的VM:用VMP保护
把这个概念具象化:
想象一下,你想写一个“万能翻译器”。
- 对于
UPX,这很简单,因为你只需要翻译一种公开的语言(比如英语翻译成中文),算法是固定的。- 对于
VMProtect,这相当于要求你的“万能翻译器”能够自动翻译地球上从未出现过的、由外星人刚刚随机发明的、且每一个外星人说的都不一样的神秘语言。你会发现,你根本无法制造这样一个“万能翻译器”。你只能逐个去接触每一个外星人,花费大量时间学习并破解他那套独有的语言体系。
第二部分:三大技术壁垒,让“万能工具”无法实现
1. 虚拟机架构的无限随机性
一个自动化工具需要依赖固定的模式(Pattern)来识别和处理代码。但VMP的设计哲学就是消灭一切固定模式。
每次保护,以下所有元素都是随机生成的:
- 虚拟指令集 (Bytecode):
0x01在A程序里可能是“加法”,在B程序里可能是“跳转”。 - 虚拟机处理器 (VM Handlers):执行“加法”的真实x86代码,在A和B程序里完全不同,充满了各种垃圾指令和混淆。
- 虚拟寄存器:虚拟机的寄存器数量、存储位置(在栈上还是在特定内存区域)都是随机的。
- 调度器 (Dispatcher):分发指令的核心逻辑也是千变万化。
一个工具无法通过静态分析找到任何可依赖的“签名”或“特征码”来自动化还原这个过程。
2. 代码变异与深度混淆 (Mutation & Obfuscation)
即使是虚拟机本身的代码(那些VM Handlers),也是经过VMP深度混淆的。一个简单的 ADD EAX, EBX 会被变成几十条甚至上百条等价但难以理解的指令。自动化工具无法分辨哪些是有效代码,哪些是垃圾代码,更无法将其还原成最简形式。
3. 无处不在的反分析陷阱 (Anti-Analysis Traps)
VMP会在代码中埋下无数“地雷”,专门用来对抗自动化分析工具。
- 完整性检查 (CRC Check):自动化工具在分析和修改代码时,会改变文件的校验和。VMP的自校验代码一旦发现文件被修改,就会导致程序崩溃或行为异常。一个“万能工具”除非能智能地定位并修复所有校验点,否则生成的程序就是个废品。
- 反调试和时序攻击:自动化工具的某些分析技术(如符号执行)可能会被VMP的时钟检测、API行为检测等机制发现,从而触发对抗。
第三部分:那市面上的“VMP工具”是做什么的?
你可能听过或用过一些名字里带“VMP”的工具,比如VMPDump、VMP Unpacker by VT,或者一些开源脚本。它们都不是“万能工具”,而是辅助分析的脚本或小程序,作用非常有限。
-
自动化Dump工具 (如VMPDump)
- 能做什么:尝试自动完成“寻找OEP”和“内存转储”这两个最最基础的步骤。
- 不能做什么:完全不能去虚拟化。它Dump出来的文件,关键代码依然是VMP的字节码。
- 成功率:对非常古老、保护选项弱的VMP版本可能有效。对现代VMP版本基本100%失败。
-
分析辅助脚本 (如IDAPython脚本)
- 能做什么:帮助人类分析师提高效率。例如,一个脚本可以自动地:
- 追踪VM Dispatcher,并列出所有VM Handler的地址。
- 为每个Handler打上标签,方便你逐个分析。
- 尝试对字节码进行初步的染色或分组。
- 不能做什么:它不能告诉你某个Handler是“加法”还是“减法”。这个最关键的、需要智力判断的工作,仍然需要你手动完成。
- 能做什么:帮助人类分析师提高效率。例如,一个脚本可以自动地:
-
学术研究型工具 (基于符号执行、污点分析)
- 能做什么:这是最前沿的尝试。通过模拟执行每一条指令,并追踪数据的流动(污点分析),理论上有可能推断出部分代码的原始逻辑。
- 不能做什么:
- 极度缓慢:分析一小段VMP代码可能需要几个小时甚至几天。
- 路径爆炸:遇到复杂的分支,需要探索的路径呈指数级增长,很快就会耗尽所有计算资源。
- 使用复杂:需要博士级别的专业知识来配置和使用,绝非“一键运行”。
结论
放弃寻找“自动化脱壳VMP的万能工具”的想法。
对抗VMProtect的唯一途径,是将其视为一场人与机器的智力对抗。真正的“工具”是:
- 你的大脑:你的逆向工程知识、逻辑推理能力和分析经验。
- 强大的调试器和反汇编器:如
x64dbg和IDA Pro,它们是你的眼睛和手术刀。 - 无尽的耐心:去虚拟化是一个极其枯燥和漫长的过程,以周或月为单位计算。
如果你真的想攻克它,正确的道路是学习扎实的逆向工程基础,而不是在网上寻找那个不存在的“魔法按钮”。
加密程序的执行流程
解密流程
1.脱壳流程
进入程序的真实入口
dump内存文件
修复pe数据、修复iat
重定位及其他检查

这里壳的作用就是解压和解密代码段和数据段和资源段。入口点变为了虚假的壳入口,而不是代码段的mian函数。
什么是修复
目标是使ida可以正常工作,可以静态分析。
保证pe结构关键数据的完整及正确。
保证对数据的引用正确,全局变量涉及重定位。
保证跳转指令的跳转地址正确,cal、jmp指令涉及重定位。
保证api的调用正确,涉及iat的修复


到OEP如何
特征法:根据原程序入口特征查找些编译器编译程序的入口特征

同时我们知道OEP的位置在dump中的ida程序中是没有建立函数识别的,因为没有任何一段函数会引用这段函数,基本可以定位到这个OEP位置
壳分析法:根据壳代码查找这里需要我们熟悉壳代码的流程,知道壳代码将控制权转交给OEP的位置。
API方法;


我识别是什么程序我们可以dump内存获取特征
这里我用
https://www.unpac.me/results/556bdbc0-32f7-467f-ad28-42d5cf9112be
上传样本自动分析流程发现特折


vmp检测软件和硬件断点就无法设置oep处的断点


1.执行后第一次会自动断点到系统库nt停止,点击执行后第二次第三次,执行是gdi库,第四此就进入到了软件的vmp段,也就是正式进入了壳中。

2.ctrl+g 输入 vs2017的入口函数特征 GetSystemTimeAsFileTime,点击定位后双击进入这个api,

双击进入这个api,在第二个汇编双击下断点。因为vmp会检测第一句代码。 然后运行
3.继续运行,我们在堆栈返回函数地址,检查段区域,发现在vmp中,说明vmp中也用了这个api,我们跳过这个
4.继续运行,我们在堆栈返回函数地址,检查段区域,发现在text段中,我们右键在反汇编展示这个段汇编定位到返回地址,也就是说调用这个api的入口函数就在这里附近。

我们在真实入口OEP下断即可,并记录oep地址,取消api断点,重启这个程序并重新执行到这个断点处,也就是text的用f4执行到GetSystemTimeAsFileTime的下一条指令f4。我们也就完成了vmp段的执行将text段的代码解压也就完成了。这里为什么我们要下断后重启程序,因为我们下断的oep位置已经执行完毕了代码,数据已经发生了变化,为了保持原始数据,我们必须在oep处下断重启执行。

我们查看上一个栈的返回值,我们右键同步反汇编试图发现这里就是真正的oep位置
因此我们清楚了调用流程是
vmp到 txt的oep 再到 txt的api,我们现在在api下。
为了验证猜想,我们下断回退代码。
我们在txt的api下一个返回地址下端,执行,单步执行,发现返回到了oep,同样的操作就返回的了壳子的调用oep的位置。这里就不演示了。
dump文件整数据保存在文件中。相当于模块内存的将加载到内存中的模块的完一个拷贝。
查看dump文件需要16进制编辑器,如winhex
因为加了壳的程序,原代码和数据是以压缩或者加密的形式保存在文件中。只有程序运行到OEP时,内存中才会有完整的原代码和原数据。所以我们需要在OEP处dump模块。
思考一个问题:是不是OEP以后的位置都可以dump?
答案:不是的,需要在处理数据之前,我们还要保证数据段是初始的状态。
当EIP在EP时原代码段是没有数据的,所以这里dump它,是没有意义的。
当EIP在OEP时,其实OEP就在text段说明代码段已经有了数据。
我们已经找到了OEP的位置我们直接到这里,通过比较框架代码,OEP的指令是"call,jmp我们通过栈回溯到OEPMVIO OA/S
第二部修复ida

因为vmp保护的程序,它的导入表是壳的导入表,不是原程序的导入表。我们dump下来后,代码和数据有了,但是代码中的API调用是错误的。脱壳后,壳的导入表就失去作用,可以去掉它,


修复vmcall,就是修复代码中的call调用
xx vm iat x32插件发,主要用于修复vmp保护的API调用。
我们在od里操作因为插件在x64dbg有问题,打开软件运行目标程序。
ctrl+g 输入api名称GetSystemTimeAsFileTime,下一个汇编处下断点运行
发现在vmp段里,再次运行发现在text段内
在栈返回地址右键同步汇编代码,在汇编试图右键取消分析显示汇编代码,我们在8eb处下断点
开始修复call



在按钮区域点击I,查看日志,显示成功

我们点击窗口标题后完成所有修复

dump内存
https://down.52pojie.cn/Tools/PEtools/?amp%3BO=A
这有两种方法,一个插件,一个可执行,
我们用右键管理员执行可执行的32或64位exe,具体位数取决于你的目标程序位数,打开选择这个程序,并填入oep虚拟地址地址,点击dump

修复pe

这里的模块基地址和内存的一致。因为重定位要固定模块地址,不在修复模块偏移。

将40改为00,去除dll属性重定位。

我们看到DlCharacteristics是0x8140,有模块重定位标志0x40,我们去掉它,改成详情我们在第6节0x8100,讲解。
修复IAT
uifUniversal lmport Fixer,在内存中重建导入地址表if,

修复完成后不要关闭
修复IT
lmports Fixer,重建dump文件的导入表


注意上面是oep是偏移
点击修复dump,选这上次的dump出的文件,修复即可生成if.dump文件





我们上一节中,修复完iat,程序就已经可以运行了,看起来不用修复重定位了,其实我们是使用了比较简便的办法。我们回想一下ageBase是0x00400000,并且我我们修复PE数据时,看到Im的模块重定位标志,dump文件时们去掉了DlCharacteristics中内存中模块地址是0x00400000,而dump文件的寻址指令中的立000对应的,所以我们只要让dump即数都是和模块地址0x00400文件加载到0x00400000这个也址,所有的寻址指令就都对上了,就可以正常运行了。
指令中API的调用我们也是因为固定了模块地址。
壳代码隐藏了原程序的重定位数据,我们看不到重定位表
想要找到原程序的重定位表会非常困难,学会了虚拟代码还原之后我们就可以做到。
对于exe,我们可以使用固定模块地址的方法,d则不可以。
重定表的结构是一样的。前面我vmp修改了的重定位的数据定义,们看到正常的重定位数据是16位,高4位为标志,低12位为页偏移vmp自己的重定位数据,高12位为页偏移,低4位为标志
资源检查
例子中没有使用vmp资源加密。关于资源的方面的知识点,可以查看相关文档资料进行学习。













破解VMProtect v.1.6x – 1.7壳
1.附加进程
2.输入要跟随的表达式:VirtualProtect
3.找到Call VirtualProtectEx下端点
4.运行,至堆栈窗口显示ReadOnly。(即代码释放完毕)
5.打开内存窗口,在第一个代码段设置内存访问断点
6.运行后停下来,右键断点–删除内存短点
7.找到寄存器的ESP,数据窗口中跟随
8.从最低找,找到第一个“返回Kernel32…”,在其上一行设置硬件写入断点(Byte)
9.运行,打开内存窗口,第一个代码段设置内存访问断点。
10.继续运行,到达OEP。
11.可以使用od自带插件,也可用LordPE脱壳,再用ImportREC修复转存。
浙公网安备 33010602011771号