好久没碰某二次元开放世界冒险游戏了,听说新升级了反作弊,故来一探究竟,并尝试实现一些简单的功能。
这种级别的游戏首先不考虑静态分析,直接跑起来。不出意外肯定不能直接内存读写,想附加调试器也是附加不上的,所以选择先从驱动入手,游戏加载时会加载驱动。
先尝试简单的拦截,方法很多:注册 LoadImage 回调拦截,改驱动名等等等。后者比较好实现,但是运行游戏一段时间会弹窗强制退出。

而如果说让保护加载,自己起一个句柄提权的驱动,则会被弹窗退出。

尝试过在虚拟机里直接启动游戏,不出意外也是弹窗。

使用启动时注入的方式,手动 Create 进程挂起,再远线程注入,可以将 DLL 注入,因为游戏刚运行的时候是没有驱动保护的,自然可以获得正常的游戏句柄。
DLL 直接用 imgui 做 hook 就行,网上框架巨多,先浅浅尝试一下改锁帧的功能,由于这个游戏锁 60 帧,因此玩的很难受,尝试找一下这个值。

反复修改反复找可以找到四个值,地址较小的那个是真实值

imgui里面直接用这个值绑定滑动条,实现帧率解锁。

面临的难点主要是反调试和反虚拟机。
先说结论:R3程序使用了多种类型的反虚拟机技术,大部分通过hook api 的形式可以直接过掉。
- 虚拟机设备检测——Hook CreateFileA 和 CreateFileW 拦截常见的虚拟设备
- 虚拟机系统文件检测(sys和dll)——Hook CreateFileA 和 CreateFileW 虚拟机的 sys 和 dll 文件
- 进程检测——Hook ProcessNextW 跳过虚拟机中才会存在的进程
- 驱动目录检测——Hook NtQueryDirectory 拦截虚拟机中的驱动服务,改成其它任意名字即可
- 计时器检测——Hook GetTickCount 在监测点修改返回值降低时间间隔
- MAC地址检测——Hook GetAdaptersInfo 将MAC地址的厂商号替换为非虚拟机厂商的厂商号
- 注册表检测——暂时是配合 sys 文件一起做检测的,可以不用拦截,实际上也可以 Hook OpenKey 之类的注册表函数
- 模块检测——Hook ModuleNextW 跳过虚拟机相关模块
虚拟设备检测
Hook CreateFileW
和 CreateFileA
这两个 API,可以看出在尝试打开如下的设备和文件
1
2
3
4
|
\\.\vmmemctl
C:\Windows\system32\DRIVERS\vm3dmp.sys
C:\Windows\system32\drivers\vm3dmp_loader.sys
...
|
不用想,游戏打开这些文件肯定是在检测虚拟机,这里将文件添加到一个 set 中,每次打开遍历一遍,遇到它检测的文件就直接返回无效句柄。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
HANDLE gh_CreateFileW(...) {
for ( auto it : DeviceFileBlacklist) {
if (CaseInsensitiveContains(lpFileName, it)) {
DBG_PRINT( "black device \"%ws\" not allowed to open\n" , lpFileName);
return INVALID_HANDLE_VALUE;
}
}
HANDLE hFile = CreateFileW(...);
bool flag = true ;
for ( auto it:FileBlacklist){
if (CaseInsensitiveContains(lpFileName,it)) {
flag = false ;
break ;
}
}
DBG_PRINT( "CreateFileW called with %ws return value %p\n" , lpFileName, hFile);
return hFile;
}
|
只需要对 yxxxshen.exe
和 mxxxbase.dll
两个模块做 IAT hook 即可。下面是拦截成功的一些日志,实际上还有更多的设备,这里不一一展示:
1
2
3
4
|
[Debug Info]black device "\\.\vmmemctl" not allowed to open
[Debug Info]black device "C:\Windows\system32\DRIVERS\vm3dmp.sys" not allowed to open
[Debug Info]black device "C:\Windows\system32\drivers\vm3dmp_loader.sys" not allowed to open
...
|
进程检测
运行过程中会有一段调用了进程遍历的关键函数 Process32NextW
,应该是检测虚拟机的相关进程,这里直接匹配当前虚拟机存在的一些虚拟机特有的进程不让它返回即可。
1
2
3
4
5
6
7
8
9
10
11
12
|
BOOL gh_ProcessNextW( HANDLE hSnapshot, LPPROCESSENTRY32W lppe) {
BOOL ret = Process32NextW(hSnapshot, lppe);
WCHAR *szExeFile = lppe->szExeFile;
while (CaseInsensitiveContains(szExeFile, L "vm" )||CaseInsensitiveContains(szExeFile,L "VGAuthService" ) && ret) {
DBG_PRINT( "Found Vm in Process name %ws,try to execute again\n" , szExeFile);
ret = Process32NextW(hSnapshot, lppe);
szExeFile = lppe->szExeFile;
DBG_PRINT( "new Process Name %ws pid=%d ret=%d\n" , lppe->szExeFile, lppe->th32ProcessID, ret);
}
DBG_PRINT( "ProcessNextW called with %ws pid=%d ret=%d\n" , lppe->szExeFile,lppe->th32ProcessID ,ret);
return ret;
}
|
如果找到 vm 相关进程则持续调用,直到进程名不包含 vm 或者为 VGAuthService
即可。下面是一些拦截成功的日志:
1
2
3
4
5
|
[Debug Info]Found Vm in Process name vm3dservice.exe, try to execute again
[Debug Info] new Process Name vmtoolsd.exe pid=3916 ret=1
[Debug Info]Found Vm in Process name vmtoolsd.exe, try to execute again
[Debug Info] new Process Name svchost.exe pid=3928 ret=1
[Debug Info]ProcessNextW called with svchost.exe pid=3928 ret=1
|
驱动目录检测
游戏调用了 NtOpenDirectoryObject
和 NtQueryDirectoryObject
两个 API,经过测试发现它打开了 \Device
路径,也就是开始遍历了驱动对象。
这两个 api 可以先hook打印,但是单纯绕过检测 hook 后者即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
NTSTATUS gh_NtQueryDirectoryObject(...) {
auto ret = NtQueryDirectoryObject(...);
auto info = (POBJECT_DIRECTORY_INFORMATION)Buffer;
for ( auto it:DeviceBlackList){
if (CaseInsensitiveEqual(info->Name.Buffer,it)){
DBG_PRINT( "NtQueryDirectoryObject name=\"%wZ\" return %d Deny to open!\n" ,
info->Name, info->TypeName, ret);
info->Name = DeniedDevice;
return 0;
}
}
DBG_PRINT( "NtQueryDirectoryObject name=\"%wZ\",Type=\"%wZ\" return %d\n" ,
info->Name, info->TypeName, ret);
return ret;
}
|
这里也给出一些拦截成功的日志
1
2
3
|
[Debug Info]NtQueryDirectoryObject name= "gpuenergydrv" ,Type= "Device" return 0
[Debug Info]NtQueryDirectoryObject name= "VMCIHostDev" return 697297488 Deny to open!
[Debug Info]NtQueryDirectoryObject name= "00000068" ,Type= "Device" return 0
|
计时器检测
注意到 mxxxbase.dll 的一个函数

GetTickCount64 获取系统启动以来经过的毫秒数。
它做了 10 次测试,每次测试 10000 条 cpuid 指令运行所需的时间,在虚拟机里,它很大,物理机中几乎每次都为 0。
那么便可以:
强制将两次运行的 cpuid 的时间设为一致。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
ULONGLONG st=40000;
ULONGLONG gh_GetTickCount64() {
auto ret = GetTickCount64();
if (st == 0) {
DBG_PRINT( "GetTickCount64 called %lld\n" , ret);
st = ret;
}
else {
DBG_PRINT( "GetTickCount64 called change %lld to %lld\n" , ret, st);
ret = st;
st = 0;
}
return ret;
}
|
下面是日志
1
2
3
4
|
[Debug Info]GetTickCount64 called 4117687
[Debug Info]GetTickCount64 called change 4117718 to 4117687
[Debug Info]GetTickCount64 called 4117734
[Debug Info]GetTickCount64 called change 4117812 to 4117734
|
可以对比得到,hook 前和 hook 后的差距大概是有几十毫秒的,这里会被检测到,通常物理机的间隔都是 0。
MAC地址检测
该函数调用了,但是没进行检测,提前写好以免后面加这个检测,检测的方式通常是检查 MAC 地址前三字节的信息看厂商是否为 Vmware 之类的。
1
2
3
4
5
6
7
8
9
|
ULONG gh_GetAdaptersInfo(...) {
auto ret = GetAdaptersInfo(AdapterInfo, SizePointer);
DBG_PRINT( "GetAdaptersInfo called with %p %p return %d\n" ,...);
AdapterInfo->Address[0] = 0x60;
AdapterInfo->Address[1] = 0x45;
AdapterInfo->Address[2] = 0x2E;
return ret;
}
|
注册表检测
hook 注册表相关的 api,拦截对应 open 的 key 的名字,实际上也是有调用没检测。
这里输出了一些相关log
1
2
3
|
[Debug Info]RegOpenKeyExA called with FFFFFFFF80000002 "SYSTEM\CurrentControlSet\services\vm3dmp_loader" 0 131353 000000702B0FF5B0 return 0
[Debug Info]CreateFileW called with C:\Program Files (x86)\mihoyo\games\Genshin Impact Game\yuanshen_Data\Persistent\base_res_version_hash return value 0000000000000CDC
[Debug Info]black device "C:\Windows\system32\drivers\vm3dmp_loader.sys" not allowed to open
|
但是预计可能是两个一起检测的,即:注册表判断服务是否存在,再判断驱动文件是否存在,有一样不成立就不认为检测到了虚拟机。
最终效果
过完这些虚拟机检测之后,也是成功可以在虚拟机中启动 yxxxshen.exe 了。

R3的反调试相对比较简单,除了众所周知的 IsDebuggerPresent
之外,早期的版本似乎 hook
了 DbgBreakPoint
和 DbgUiRemoteBreakin
两个 API 来防止调试器附加,现在仍有 hook
,不过只 hook
了 DbgBreak
,并且同样也有 ThreadHideFromDebugger
检测。
-
IsDebuggerPresent
:hook 返回 0 即可。
-
ThreadHideFromDebugger
:需要根据参数和调用的时机合理地选择返回,稍有不慎就会crash,具体看下文分析。
-
API hook
:目前无须绕过。
ThreadHideFromDebugger
NtSetInformationThread
这个 API 本意是设置线程优先级的,其中有一个参数 ThreadInformationClass
,这是一个 THREADINFOCLASS
的枚举类型。
1
2
3
4
5
6
7
8
9
10
|
typedef enum _THREADINFOCLASS {
ThreadBasicInformation = 0,
ThreadPriorityBoost = 14,
ThreadSetTlsArrayAddress = 15,
ThreadIsIoPending = 16,
ThreadHideFromDebugger = 17,
MaxThreadInfoClass = 51,
} THREADINFOCLASS;
|
其中注意到 0x11 即为 ThreadHideFromDebugger
,字面意思也不难理解,就是从调试器中隐藏该线程,据看雪一篇文章的分析,该函数关于 ThreadHideFromDebugger
的实现如下
1
2
3
4
5
6
7
8
9
10
11
12
|
case ThreadHideFromDebugger:
if (ThreadInformationLength != 0) {
return STATUS_INFO_LENGTH_MISMATCH;
}
st = ObReferenceObjectByHandle (...);
if (!NT_SUCCESS (st)) {
return st;
}
PS_SET_BITS (&Thread->CrossThreadFlags, PS_CROSS_THREAD_FLAGS_HIDEFROMDBG);
ObDereferenceObject (Thread);
return st;
break ;
|
可以看出当 class
为 ThreadHideFromDebugger
时,若 ThreadInformationLength
不为 0 则返回一个错误。因此过这个反调试不能无脑拦截 class
为 ThreadHideFromDebugger
的调用,而应注意这里的 Length 是否为 0。根据拦截 yxxxshen.exe 的调用可以看出。
1
2
|
[Debug Info]NtSetInformationThread called with handle fffffffe 17 at ... length 1 return c0000004
[Debug Info]NtSetInformationThread called with handle fffffffe 17 at ... length 0 return 0
|
它连续调用了两次,第一次估计设置 Length
为 1,看是否调用失败,第二次才是真正的反调试,因此需要辨别出这一点。
似乎也不难写出它的 hook 函数?
1
2
3
4
5
6
7
8
9
10
|
UINT64 gh_NtSetInformationThread(...) {
if (ThreadInformationClass==0x11 && ThreadInformationLength==0){
DBG_PRINT( "Try to set ThreadHideFromDebugger,Stop it\n" );
return 0;
}
auto ret = NtSetInformationThread(...);
DBG_PRINT( "lasterror=%d\n" , GetLastError());
DBG_PRINT( "NtSetInformationThread called with handle %x %d at %p length %d return %x\n" ,...);
return ret;
}
|
但是很不幸的是,你会得到一个闪退。

思路似乎中断了,于是考虑看看与之相近的 API,也就是 NtQueryInformationThread
。
1
2
3
4
5
6
|
[Debug Info]NtQueryInformationThread called with handle fffffffe 17 at ... length 4 return c0000004
[Debug Info]NtQueryInformationThread called with handle fffffffe 17 at ... length 1 return 0
[Debug Info]NtSetInformationThread called with handle fffffffe 17 at ... length 1 return c0000004
[Debug Info]NtSetInformationThread called with handle fffffffe 17 at ... length 0 return 0
[Debug Info]NtQueryInformationThread called with handle fffffffe 17 at ... length 4 return c0000004
[Debug Info]NtQueryInformationThread called with handle fffffffe 17 at ... length 1 return 0
|
可以看到在前后各成功调用一次 NtQueryInformationThread
,并且将 class 设为了 ThreadHideFromDebugger
。
这不对吧,query 它能干什么呢,对了,查询信息,可能是需要查询跟隐藏线程调试器相关的字段,那么会不会是因为成功 set 了和没成功 set 了情况不太一样呢?
这里 hook 掉看看前后查询的数据的区别。
1
2
3
4
5
6
7
|
[Debug Info]past information=34
[Debug Info]after information=00
[Debug Info]NtQueryInformationThread called with handle fffffffe 17 at ... length 1 return 0
[Debug Info]NtSetInformationThread called with handle fffffffe 17 at ... length 0 return 0
[Debug Info]past information=95
[Debug Info]after information=01
[Debug Info]NtQueryInformationThread called with handle fffffffe 17 at ... length 1 return 0
|
这里我保留关键的 LOG,也可以看出来,它在 set 前后分别查询了一次,第一次查询得知的结果是 0,而成功调用 set 之后得到的结果会是 1,如果仅仅 hook set 不让它调用则会在第二次查询也得到 0 的结果,这便是之前闪退的原因了。
因此对于这个反调试,需要同时 hook NtQueryInformationThread
和 NtSetInformationThread
,严格判断参数,并合理过滤掉一些检测反调试和反-反-反调试的东西。
IsDebuggerPresent
这个已经被玩烂了的 API 相信是第一个被考虑到的,hook它永远返回 0 就行了。
最终效果

可以在虚拟机中,附加调试器的情况下运行该二次元开放世界冒险游戏且不报错。
主要尝试分析检测逻辑,尽可能地在不影响功能的情况下过掉检测。
先给结论:反调试主要由驱动创建的一个线程实现,入口点在 0x2f0c0
,重复顺序执行以下逻辑:
- 读取
KdDebuggerEnabled
标志位,如果置 1
则清零。
- 找到寻找
kdcom.dll
,使用 MDL 的方式将 kdcom.dll
的 data
段清零。
- 读取
KdDebuggerNotPresent
标志位。
- 读取
KdDebuggerEnabled
标志位。
- 读取
KdDebuggerNotPresent
标志位。
下面给出笔者的分析步骤和对应的解决方案。
动态调试
R0 层的反调试其实反而没那么难,因为 API 就那么几个,HxxxKProtect.sys 的反调试具体表现为,在双机调试的情况下成功加载之后会导致调试器无响应。
根R0调试相关的API找一下即可,通过 IDA 直接搜索导入表或者字符串,得到以下几个跟调试器相关的
- KdDebuggerNotPresent
- KdDebuggerEnabled
根据查阅 MSDN 可知,这两个是内核中的标志位,尝试 hook 将它修改到其它位置。运行之后发现调试器依旧被剥离,但是虚拟机似乎也卡死,并没有蓝屏,在游戏终端中发现了上传日志。

路径中可以看到上传了由于驱动导致的蓝屏(dmp),和自己的信息文件。
info.txt 包含了操作系统的信息,硬件信息和uid信息。
1
2
3
4
5
6
7
8
9
10
11
12
|
version: 5.2_rel CNRELWin5. 2.0_28336591_29063028_28887986_28772242_28351161
deviceName:DESKTOP - DLBRLIS
time: 2024 - 12 - 28 15.08 . 15.9001
deviceModel:VMware20, 1 (VMware, Inc.)
operatingSystem:Windows 10 ( 10.0 . 19045 ) 64bit Microsoft Windows NT 10.0 . 19045.0
uid: 14xxxxxx3
memoryInfo: 695
cpuInfo:Intel(R) Core(TM) i9 - 14900HX
gpuInfo:VMware SVGA 3D
clientIp:fe80:: 374b : 96c4 : 2526 :ec61
isRelease: 1
type :Windows Crash Release
|
这些信息大概率都是注册表或者一个 API GetSystemFirmwareTable
读出来的,这里为了防止被上传,最好把注册表处理干净,所有跟 Vmware 相关的全部替换掉。
其它特征去除直接用大表哥的 vmloader(大表哥nb),不知道这里怎么访问这两个标志的,所以先尝试 Hook MmGetSystemRoutineAddress,再去导入表替换两个标志位。
创建一个线程,持续输出两个标志位
1
2
3
4
5
6
7
8
|
VOID Routine() {
while (TRUE) {
DBG_PRINT( "%d %d\n" , *KdDebuggerEnabled, *KdDebuggerNotPresent);
LARGE_INTEGER interval;
interval.QuadPart = -10ll * 1000 * 1000;
KeDelayExecutionThread(KernelMode, FALSE, &interval);
}
}
|
附加调试器的情况下,输出应当是 1 0
。
加载游戏之后,会发现标志位变为了 0 1
,而 KdDebuggerEnabled
标志位一旦被复位,windbg 会直接被剥离,因此需要阻止。
这里本想尝试加载驱动后,设置硬件断点在 KdDebuggerEnabled 字符串和对应的标志位中,但是似乎会有检测,如果设置了硬断驱动则会加载失败。

通过动调,还是找到了关键的指令。
1
2
|
.upx0: 000000014034794F mov rsi, [rcx]
.upx0: 0000000140347952 mov [r11], rsi
|
[招生]科锐逆向工程师培训(2025年3月11日实地,远程教学同时开班, 第52期)!
最后于 2025-2-14 12:17
被xi@0ji233编辑
,原因: