二次元开放世界冒险游戏反作弊分析报告,二次元开放世界冒险手游
好久没碰某二次元开放世界冒险游戏了,听说新升级了反作弊,故来一探究竟,并尝试实现一些简单的功能。
基本保护分析
这种级别的游戏首先不考虑静态分析,直接跑起来。不出意外肯定不能直接内存读写,想附加调试器也是附加不上的,所以选择先从驱动入手,游戏加载时会加载驱动。
先尝试简单的拦截,方法很多:注册LoadImage回调拦截,改驱动名等等等。后者比较好实现,但是运行游戏一段时间会弹窗强制退出。
而如果说让保护加载,自己起一个句柄提权的驱动,则会被弹窗退出。
尝试过在虚拟机里直接启动游戏,不出意外也是弹窗。
使用启动时注入的方式,手动Create进程挂起,再远线程注入,可以将DLL注入,因为游戏刚运行的时候是没有驱动保护的,自然可以获得正常的游戏句柄。
注入功能测试
DLL直接用imgui做hook就行,网上框架巨多,先浅浅尝试一下改锁帧的功能,由于这个游戏锁60帧,因此玩的很难受,尝试找一下这个值。
反复修改反复找可以找到四个值,地址较小的那个是真实值
imgui里面直接用这个值绑定滑动条,实现帧率解锁。
R3分析
面临的难点主要是反调试和反虚拟机。
反虚拟机
先说结论: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,可以看出在尝试打开如下的设备和文件
\\.\vmmemctlC:\Windows\system32\DRIVERS\vm3dmp.sysC:\Windows\system32\drivers\vm3dmp_loader.sys ...
不用想,游戏打开这些文件肯定是在检测虚拟机,这里将文件添加到一个set中,每次打开遍历一遍,遇到它检测的文件就直接返回无效句柄。
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即可。下面是拦截成功的一些日志,实际上还有更多的设备,这里不一一展示:
[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,应该是检测虚拟机的相关进程,这里直接匹配当前虚拟机存在的一些虚拟机特有的进程不让它返回即可。
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即可。下面是一些拦截成功的日志:
[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后者即可。
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; }
这里也给出一些拦截成功的日志
[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的时间设为一致。
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; }
下面是日志
[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之类的。
ULONG gh_GetAdaptersInfo(...) { auto ret = GetAdaptersInfo(AdapterInfo, SizePointer); DBG_PRINT("GetAdaptersInfo called with %p %p return %d\n",...); //换成intel的MAC地址60:45:2E AdapterInfo->Address[0] = 0x60; AdapterInfo->Address[1] = 0x45; AdapterInfo->Address[2] = 0x2E; return ret; }
注册表检测
hook注册表相关的api,拦截对应open的key的名字,实际上也是有调用没检测。
这里输出了一些相关log
[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的枚举类型。
typedef enum _THREADINFOCLASS { ThreadBasicInformation = 0, //... ThreadPriorityBoost = 14, ThreadSetTlsArrayAddress = 15, // Obsolete ThreadIsIoPending = 16, ThreadHideFromDebugger = 17, //... MaxThreadInfoClass = 51, } THREADINFOCLASS;
其中注意到0x11即为ThreadHideFromDebugger,字面意思也不难理解,就是从调试器中隐藏该线程,据看雪一篇文章的分析,该函数关于ThreadHideFromDebugger的实现如下
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的调用可以看出。
[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函数?
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。
[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掉看看前后查询的数据的区别。
[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就行了。
可以在虚拟机中,附加调试器的情况下运行该二次元开放世界冒险游戏且不报错。
R0分析
主要尝试分析检测逻辑,尽可能地在不影响功能的情况下过掉检测。
反调试
先给结论:反调试主要由驱动创建的一个线程实现,入口点在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信息。
version:5.2_rel CNRELWin5.2.0_28336591_29063028_28887986_28772242_28351161deviceName:DESKTOP-DLBRLIStime:2024-12-28 15.08.15.9001deviceModel:VMware20,1 (VMware, Inc.)operatingSystem:Windows 10 (10.0.19045) 64bit Microsoft Windows NT 10.0.19045.0uid:14xxxxxx3memoryInfo:695cpuInfo:Intel(R) Core(TM) i9-14900HXgpuInfo:VMware SVGA 3DclientIp:fe80::374b:96c4:2526:ec61isRelease:1type:Windows Crash Release
这些信息大概率都是注册表或者一个API GetSystemFirmwareTable读出来的,这里为了防止被上传,最好把注册表处理干净,所有跟Vmware相关的全部替换掉。
其它特征去除直接用大表哥的vmloader(大表哥nb),不知道这里怎么访问这两个标志的,所以先尝试Hook MmGetSystemRoutineAddress,再去导入表替换两个标志位。
创建一个线程,持续输出两个标志位
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字符串和对应的标志位中,但是似乎会有检测,如果设置了硬断驱动则会加载失败。
综合分析
下面分析DriverEntry的执行步骤。
读取注册表中的ConfigData,判断游戏是否正确启动。
创建设备DeviceHXXXProtect
读取页目录基址
调用PsLookupProcessByProcessId获取system进程的EProcess
随后又使用了MmGetSystemRoutineAddress获取了一遍PsLookupProcessByProcessId地址
读取MSR_LSTAR获得syscall的入口点
直到这里,模拟执行已经跑不出什么更加细节的东西了,转动态调试。
调试器中手动rdmsr拿到返回的地址下硬件读断点,截取到驱动的读取操作,驱动拿到了MSR返回的syscall地址之后,先判断了该地址是否合法,再尝试读取其中的四字节数,并且有一个循环,循环每次加1,从图中可以清晰看到。
文章从网络整理,文章内容不代表本站观点,转账请注明【蓑衣网】