小白对这几天VMP的反调试学习结合vmpprotect-3.5.1的源码总结一下。

定位VMP反调试源码

VMP检测到被调试时,会弹窗报错信息

通过这条报错信息,我们可以找到

然后通过VMP的消息传递逻辑,我们可以发现mtDebuggerFound和函数LoaderMessage()

mtDebuggerFoundVMP反调试检测的唯一语义化标识,LoaderMessage()VMP所有报错的统一处理入口,LoaderMessage(mtDebuggerFound)VMP所有反调试检测逻辑的统一报错触发语句。
所以我们可以通过查找LoaderMessage(mtDebuggerFound)的引用去定位到VMP的各处反调试相关源码

os_build_number的获取

一共有两种获取方式:
PEB直接读取(最快、最底层):绕过所有系统API,直接从进程环境块(PEB)读取OSBuildNumber
ntdll资源解析(兜底、最可靠):直接解析系统核心模块ntdll.dllPE资源,也是为了方便取syscall所使用的系统调用号。

PEB->OSBuildNumber读取的构建号,不满足IS_KNOWN_WINDOWS_BUILD(如测试版Win1126000+、未收录的预览版);
兜底调用LoaderParseOSBuildBumber解析原始ntdll资源,仍无法获取合法 / 适配的构建号;
VMP判定当前系统为未适配版本 / 异常环境,放弃使用进程中已加载的ntdll,转而映射新副本。拿到无篡改、无Hook、与当前系统完全匹配的原生ntdll

vmp反调试手段

用户态(R3)调试器检测(如 x64dbg/OllyDbg)

间接检测


正常环境下,VMP能轻松获取os_build_number(系统构建号)等基础系统信息;
若系统构建号获取失败(os_build_number=0)且加载器开启「调试器检测」开关,就会弹出错误信息。
VMP将这种系统基础信息获取失败判定为调试器干扰的典型特征,进而触发反调试报错。

PEB->BeingDebugged(最快速的基础判定)


Windows进程的PEB结构体中BeingDebugged是布尔标记,BeingDubgged=1的时候就是被调试的标志。

绕过方法:
VMP检测前,将PEB->BeingDebugged的值从1改回0

  1. 在调试器中手动修改,32位程序:fs:[0x30]+0x02,64位程序:gs:[0x60]+0x02
  2. 利用工具:
    x64dbg:安装ScyllaHide插件,启用「Hide Debugger -> PEB -> BeingDebugged」选项;
    OllyDbg:安装OllyAdvanced插件,勾选「Patch PEB BeingDebugged」

关闭InstrumentationCallback


针对Win10 + InstrumentationCallback—— 该回调是调试器实现Inline Hook拦截的核心手段,调试器会通过该回调注入拦截逻辑,vmp将回调函数置为NULL,直接关闭调试器的插桩拦截能力,让后续的NT函数调用 / 系统调用不被调试器拦截。

绕过方法:
R0层进行内核级Hook

NtQueryInformationProcess 双特征检测


通过查询进程调试端口和调试对象句柄两个特征,彻底判定调试器是否附加。
ProcessDebugPort(7):进程的调试端口,正常进程为0,调试器附加后会被设置为非0的端口句柄,是调试器附加的核心特征;
ProcessDebugObjectHandle(31):进程的调试对象句柄,调试器附加时Windows内核会为进程创建调试对象,该句柄非0表示存在调试器,比调试端口检测更底层、更难篡改。
绕过方法:
在内核中拦截NtQueryInformationProcess系统调用;
当检测到参数ProcessInformationClass= ProcessDebugPort= 0x7查询调试端口号或参数ProcessInformationClass= ProcessDebugObjectHandle = 0x1E查询调试对象句柄时,将返回值改为0

主动反制 —— 线程隐藏调试器


调用NtSetInformationThread并传入ThreadHideFromDebugger(17),会让Windows内核将当前线程从调试器的监控列表中移除,调试器无法再捕获该线程的执行、断点、异常等信息,即便后续附加调试器,也无法调试该线程,主动切断调试器与当前线程的关联,简单说就是不让你的调试器调试VMP保护的代码区块。

绕过方法:
在内核层Hook NtSetInformationThread系统调用,当检测到调用参数为ThreadHideFromDebugger (17) 时,将参数0x110即可,让该调用变成 “无效操作”。

检测0xCC断点

0xCC断点
调试器(如x64dbg/OllyDbg)对某个函数下软件断点时,会将该函数入口地址的首字节从原机器码替换为0xCC(单字节的INT3指令);
当程序执行到0xCC时,会触发INT3中断,CPU会主动将执行权交给调试器,调试器即可捕获程序执行、查看寄存器 / 内存;
调试器会在自身内存中保存被替换的原字节,当移除断点时,再将0xCC恢复为原机器码;
由于0xCC是断点的唯一且固定的机器码,VMP只需扫描关键函数入口的首字节,即可精准判定是否被下了断点。

绕过方法:
使用硬件断点去替代。

主动触发异常,引蛇出洞


无调试器(正常程序)
调用CloseHandle(0xDEADC0DE),因句柄无效,Windows会触发STATUS_INVALID_HANDLE异常;
该异常会被系统默认的异常处理机制捕获,直接终止CloseHandle调用,不会进入程序自身的__except块,也不会返回TRUE
最终结果:函数调用返回FALSE__try块内的判断不成立,__except块也不会执行,检测通过。
有调试器(附加并劫持异常)
调试器附加后,会成为进程的 异常端口,拥有最高的异常处理优先级,分两种情况:
第一种情况:调试器拦截并处理了异常(修改返回结果),让CloseHandle调用虚假返回TRUE→进入__try块内的判断,直接判定检测到调试器;
第二种情况:调试器拦截异常后,将异常抛回程序→程序自身的__except块会捕获到异常,进入__except分支,同样判定检测到调试器。
正常程序中,该操作既不会返回TRUE,也不会进入自身__except块,只要触发任一分支,就说明有调试器附加并劫持了异常处理链

绕过方法:

  1. 使用ScyllaHide这类成熟的反反调试插件,它们内置了对这类异常检测的自动绕过逻辑:
    插件会在VMP调用CloseHandle(0xDEADC0DE) 时,自动拦截并返回FALSE
    同时,插件会隐藏调试器对异常的捕获,让VMP认为异常没有被处理。
  2. 在调试器中手动操作:
    VMP调用CloseHandle(0xDEADC0DE)前下断点。
    当断点触发时,手动修改返回值寄存器(如rax)为0(表示调用失败)。

检测硬件断点(DR0~DR3)

x86/x64 硬件调试核心机制
EFLAGSTF陷阱标志(0x100):TF位被置位后,CPU会进入单步执行模式—— 每执行一条指令,就会触发STATUS_SINGLE_STEP单步异常,这是硬件级特性,无法通过软件屏蔽;
硬件调试寄存器(DR0~DR7):CPU提供6个调试寄存器,其中DR0~DR3用于存储硬件断点的地址(最多4个硬件断点),DR6/DR7用于控制硬件断点;若调试器设置了硬件断点,DR0~DR3中至少有一个非0,且该值会被记录在CPU上下文中;
CONTEXT_DEBUG_REGISTERSWindowsCONTEXT结构体标志位,若置位,说明上下文包含 调试寄存器(DR0~DR7) 的数值,可直接读取。

无调试器,无硬件断点(正常程序)
__writeeflags置位TF标志,CPU 进入单步执行模式;
执行下一条指令__rdtsc()CPU立即触发STATUS_SINGLE_STEP单步异常;
因无调试器,异常被程序自身的__except块捕获,执行__except后的复合操作:
获取异常时的CPU上下文ctx
检查ctx->ContextFlags是否包含调试寄存器,计算DR0~DR3的按位或结果drx(无硬件断点则drx=0);
指定异常处理策略为EXCEPTION_EXECUTE_HANDLER
进入__except块,判断drx是否为0→是,检测通过,继续执行后续流程。
调试器设置了硬件断点(核心检测目标)
调试器设置硬件断点后,DR0~DR3中至少有一个非0,且调试器会劫持异常处理,分两种子情况,最终都会被检测到:
第一种情况:调试器拦截了单步异常,并屏蔽了TF位的触发效果→程序未触发异常,直接执行完__rdtsc()__nop(),进入__try块后的分支1,判定检测到调试器;
第二种情况:调试器未屏蔽异常,将其抛回程序→程序自身__except块捕获异常,读取上下文后计算drx(硬件断点存在则drx≠0),进入__except块后判断drx≠0,判定检测到调试器。

绕过方法:
使用ScyllaHide这类成熟的反反调试插件,它们内置了对硬件断点检测的自动绕过逻辑:
插件会在VMP触发单步异常时,临时清空DR0-DR3寄存器的值,让VMP检测不到硬件断点。
异常处理完成后,再恢复寄存器的原始值,不影响你的调试。

内核态(R0)调试器(如 WinDbg/SoftICE/Syser)

查询内核调试器原生状态(Windows 内核底层标记)



这是检测WinDbg内核调试的最底层依据,SYSTEM_KERNEL_DEBUGGER_INFORMATION的两个布尔值由Windows内核直接维护,无法通过用户态操作篡改,核心判定逻辑:
info.DebuggerEnabled:系统内核调试开关是否被启用(通过bcdedit /debug on开启,内核启动时加载调试相关代码);
!info.DebuggerNotPresent:内核调试器是否实际附加(避免 “开启调试开关但未实际连接调试器” 的误判);
二者同时为真 → 判定系统存在活动的内核调试器。

绕过方法:

  1. 拦截内核态的NtQuerySystemInformation,当检测到查询SystemKernelDebuggerInformation时,修改返回的结构体内容,将DebuggerEnabled设为FALSE或修改 DebuggerNotPresentTRUE
  2. 直接修改内核中存储调试器状态的全局变量(如KdDebuggerEnabled),让所有查询都返回 “无内核调试器” 的结果。

扫描系统已加载内核模块(检测第三方内核调试驱动)


通过加密 + 全局表注册的方式隐藏起来,彻底杜绝通过静态扫描二进制文件、搜索明文字符串快速定位内核调试检测逻辑的可能。

所有第三方内核调试器(SoftICE/Syser)都需要加载内核驱动才能挂钩Windows内核,这些驱动是工具运行的核心,无法隐藏。

绕过方法:
在内核中拦截NtQuerySystemInformation,直接修改返回的模块列表,隐藏调试驱动的存在。

VMP通过自定义SYSCALL/SYSENTER指令直接执行系统调用

Ring3→Ring0 系统调用

Windows是分层权限架构,分为用户态(Ring3)和内核态(Ring0):
Ring3:应用程序、普通DLLntdll.dll、kernel32.dll、user32.dll等)运行的权限层,权限低,无法直接访问硬件、修改内核数据;
Ring0:内核、驱动程序运行的权限层,权限最高,所有系统资源的操作最终都要在这一层执行。

所有Win32 API/NT API的底层,最终都是系统调用—— 应用程序想完成 “打开文件、修改内存、创建进程” 等核心操作,必须从Ring3切换到Ring0,而ntdll.dllWindows官方的Ring3 系统调用入口,正常的NT函数调用流程是这样的:

1
应用程序 → kernel32.dll(Win32 API,如CreateFile) → ntdll.dll(NT API,如NtCreateFile) → ntdll中的系统调用桩函数 → INT 2E/SYSCALL → 内核态系统服务表 → 内核实际函数

其中ntdll的系统调用桩函数是关键,以NtProtectVirtualMemory为例,它的汇编代码(x64)非常简单:

1
2
3
4
5
NtProtectVirtualMemory:
mov r10, rcx ; 传递参数(x64快速调用约定)
mov eax, 0x101 ; 加载该函数的系统服务号(Syscall Number)
syscall ; 执行SYSCALL指令,从Ring3切到Ring0
ret ; 切回Ring3后返回

简单来说:ntdll.dllNT函数,本质就是加载服务号 + 执行 SYSCALL 指令的封装桩函数,它是Windows官方提供的、唯一的Ring3系统调用入口。

Ring3层Hook NT函数

我们平时在Ring3Hook ntdllNT函数(比如Inline Hook、IAT Hook),能拦截应用程序的系统调用,核心原因是应用程序默认会通过ntdll的桩函数执行系统调用:
比如Inline Hook NtProtectVirtualMemory,就是把ntdll中该函数的首字节改成0xCC(断点)或跳转指令,指向我们的自定义函数;
当应用程序调用NtProtectVirtualMemory时,会先执行我们的自定义Hook函数,我们可以修改参数、屏蔽调用、篡改返回值;
之后再跳回原函数的执行流程,完成Hook拦截。
核心前提:应用程序必须通过ntdllNT函数桩函数执行系统调用,Ring3Hook才能生效 —— 如果应用程序绕开ntdll,直接自己执行加载服务号 + SYSCALL,那么Ring3的所有Hook都会失效。
VMP正是抓住了上述Hook 的前提漏洞,放弃调用ntdll中的NT函数桩函数,而是自己在代码中实现加载系统服务号 + 执行 SYSCALL/SYSENTER 指令的完整流程。

绕开R3层Hook

VMP的系统调用流程

1
加壳应用程序 → VMP 虚拟执行层 / 脱壳代码段 → VMP 硬编码服务号 + 参数构造 → VMP 内联执行 SYSCALL(x64)/SYSENTER(x86) → 内核态系统服务表(SSDT/Shadow SSDT) → 内核实际函数(ntoskrnl.exe)

Ring3Hook的核心是拦截ntdll.dllNT函数桩函数,而VMP通过提前提取系统服务号,自己实现加载服务号 + 执行 SYSCALL/SYSENTER的流程,全程绕开ntdll.dll,让Ring3 Hook没有作业位置;同时SYSCALL是硬件级特权指令,Ring3层无法干预,最终实现对Ring3 Hook的完全规避。
VMP的反调试检测依赖的NT函数调用,都通过自实现SYSCALL执行,Ring3层无法Hook / 篡改这些调用的参数和返回值,只能获取到内核的原始真实数据,绕过反调试的难度从Ring3层直接升级到Ring0层。

补充:syscall 和 sysenter 的差异

VMP会根据系统架构选择对应的特权指令,两者是Windows不同架构的系统调用门,核心功能一致(触发用户态→内核态切换),差异仅在于适用平台和指令细节:
syscallx64Windows专属(Win7及以上),是AMDx64架构设计的特权指令,Intel x64 CPU也兼容;
寄存器约定:服务号存在rax,参数通过rcx/rdx/r8/r9传递,内核态返回后通过rax传返回值;
sysenterx86Windows专属(WinXP/Win7 x86),是Intelx86架构设计的快速系统调用指令,替代了早期的int 0x2e(软中断,速度慢);
寄存器约定:服务号存在eax,参数通过栈传递,需要借助MSR(模型特定寄存器)保存内核态入口地址。
注意:x64Windows已经彻底抛弃sysenterint 0x2e,只使用syscall,所以分析x64VMP加壳程序,只需要关注syscall指令即可。

文章参考

https://bbs.kanxue.com/thread-282244.htm
https://developer.aliyun.com/article/1221429
https://www.52pojie.cn/thread-1825316-1-1.html