好久没碰某二次元开放世界冒险游戏了,听说新升级了反作弊,故来一探究竟,并尝试实现一些简单的功能。

基本保护分析

这种级别的游戏首先不考虑静态分析,直接跑起来。不出意外肯定不能直接内存读写,想附加调试器也是附加不上的,所以选择先从驱动入手,游戏加载时会加载驱动。

先尝试简单的拦截,方法很多:注册 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 CreateFileWCreateFileA 这两个 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.exemxxxbase.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

驱动目录检测

游戏调用了 NtOpenDirectoryObjectNtQueryDirectoryObject 两个 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",...);
//换成intel的MAC地址60:45:2E
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 之外,早期的版本似乎 hookDbgBreakPointDbgUiRemoteBreakin 两个 API 来防止调试器附加,现在仍有 hook,不过只 hookDbgBreak,并且同样也有 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, // Obsolete
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;

可以看出当 classThreadHideFromDebugger 时,若 ThreadInformationLength 不为 0 则返回一个错误。因此过这个反调试不能无脑拦截 classThreadHideFromDebugger 的调用,而应注意这里的 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 NtQueryInformationThreadNtSetInformationThread,严格判断参数,并合理过滤掉一些检测反调试和反-反-反调试的东西。

IsDebuggerPresent

这个已经被玩烂了的 API 相信是第一个被考虑到的,hook它永远返回 0 就行了。

最终效果

可以在虚拟机中,附加调试器的情况下运行该二次元开放世界冒险游戏且不报错。

R0分析

主要尝试分析检测逻辑,尽可能地在不影响功能的情况下过掉检测。

反调试

先给结论:反调试主要由驱动创建的一个线程实现,入口点在 0x2f0c0,重复顺序执行以下逻辑:

  • 读取 KdDebuggerEnabled 标志位,如果置 1 则清零。
  • 找到寻找 kdcom.dll,使用 MDL 的方式将 kdcom.dlldata 段清零。
  • 读取 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

这一步 RCX 读取了自身驱动导入表的那个指针,存到了 r11 指向的内存

经过多次调试,最终确定写入的指令为

1
2
.upx0:00000001402BD371                 lock xchg r11d, [rax]
.upx0:00000001402BD375 bt r10w, r10w

如下所示(本次调试截图与上面截图不是同一次调试)

这里再次确认一下:

  • 确认该指令确实修改了 KdDebuggerEnabled 标志位
  • 确认该指令只被用于写 KdDebuggerEnabled 标志位

第一点很好判断,直接软件断点走过来,观察这条指令前后标志位的变化,从下图来看,基本可以确认了,虽然 KdDebuggerEnabled 和 RAX 指向的地址不同,但是它们一定是映射了同一个物理页。

第二点经过确认,至少第一次触发该指令就是用于修改这个标志位的,把这个指令 Patch 掉之后,会被蓝屏,蓝屏模块为 kdcom.dll,蓝屏代码为 IRQL_NOT_LESS_OR_EQUAL

毫无疑问,调试出问题了,看起来内核调试不仅仅是靠这一个标志位决定的(之前一直不知道)。于是想着去分析一下官方的 API,来看看剥离内核调试器需要做什么样的步骤。分析了一会发现想的都是错的,应该是用了一些我们平时想不到的操作去做的。

模拟执行

也是此时,猛然回首,感觉自己可能正往错误的方向行进,vmp 调起来太累,又不会还原。这时候想到了模拟执行,在队里师傅的帮助下,找到 KACE 这个模拟执行的工具,但是编译什么的都很有问题,且需要自己装 zydis 库,而最新的版本又没适配最新的 zydis,甚至很多结构都改变了,遂尝试自己修一下,下面给出我修好的版本 https://github.com/xia0ji233/KACE。

模拟执行最关键的一点就是驱动要能独立加载,比如能直接使用 monitor 或者 sc 这种简单的工具加载,很不幸这个二次元开放世界冒险游戏并不能。分析其 R3 的行为,发现游戏运行的时候会往注册表写下一个 ConfigData,加载驱动之后会立刻删除,由于中间延时还挺高,所以可以捕获这一过程。

分析这串数据时间成本过高,所以可以选择,先运行 yxxxshen.exe,等到写上去之后再马上运行模拟器,就可以成功在模拟器中跑起来


综合分析

下面分析 DriverEntry 的执行步骤。

  • 读取注册表中的 ConfigData,判断游戏是否正确启动。
  • 创建设备 \Device\HXXXProtect
  • 读取页目录基址
  • 调用 PsLookupProcessByProcessId 获取 system 进程的 EProcess
  • 随后又使用了 MmGetSystemRoutineAddress 获取了一遍 PsLookupProcessByProcessId 地址
  • 读取 MSR_LSTAR 获得 syscall 的入口点

直到这里,模拟执行已经跑不出什么更加细节的东西了,转动态调试。

调试器中手动 rdmsr 拿到返回的地址下硬件读断点,截取到驱动的读取操作,驱动拿到了 MSR 返回的 syscall 地址之后,先判断了该地址是否合法,再尝试读取其中的四字节数,并且有一个循环,循环每次加1,从图中可以清晰看到。

随后将拿到的四字节整数进行了 &0x00F0FFFF 的操作,最后和 0x00108D4C 判断是否相等,转小端序来看,它需要找到类似这样的特征码 4C 8D 1? ??,这里不管直接在条件满足的分支下断点,在 jne 下方下断点再过来看看它找到了什么位置。

也就是说拿到了 KiSystemServiceRepeat 的地址,通过搜索找到了一篇文章,里面提到了通过 MSR 寻找得到未导出的 KeServiceDescriptorTableKeServiceDescriptorTableShadow,文章里面使用了特征偏移的方法寻找这两个表,当然不同的系统版本这个值必然也不同,因此该驱动使用了兼容性更好的特征 KiSystemServiceRepeat 函数头的方式去寻找这个偏移,拿到对应的两个表的地址。

随后必然读了两个表,之前模拟执行也得到过该结论

1
2
3
4
5
6
7
8
[TID:0000625c]  Emulating read from ntoskrnl.exe:+00e018d0
[TID:0000625c] Executing ntoskrnl.exe!MmIsAddressValid
[TID:0000625c] Checking if address is valid : 7ff716feca40
[TID:0000625c] Getting data @ ntoskrnl.exe!KeServiceDescriptorTable
[TID:0000625c] Getting data @ ntoskrnl.exe!KeServiceDescriptorTableShadow
[TID:0000625c] Emulating read from ntoskrnl.exe:+00e018d0
[TID:0000625c] Executing ntoskrnl.exe!MmIsAddressValid
[TID:0000625c] Checking if address is valid : 0

下两个硬件断点发现仅仅是判断了一下两张表的表头内容是否一致

然后读走了熟悉的 0x1d8(表中系统服务的数量)。

随后调用了 MmIsAddressValid,在模拟器中可以看到,模拟调用了一个0,因为模拟器的局限性,不太可能还原真实的内核情况,那来看看实际上它取的 0 来自哪里。

FFFFF8052FCC79F0,也就是 SSDT 存的 KiServiceTable,也许很大概率是发现 SSDT 存的 KiServiceTable 为 0 了,所以模拟器加载驱动才会失败。

在模拟器中简单实现这两个结构之后,驱动就照着走后续加载的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
[TID:00002b88]  Getting data @ ntoskrnl.exe!KeServiceDescriptorTable
[TID:00002b88] Getting data @ ntoskrnl.exe!KeServiceDescriptorTableShadow
[TID:00002b88] Emulating read from ntoskrnl.exe:+00e018d0
[TID:00002b88] Executing ntoskrnl.exe!MmIsAddressValid
[TID:00002b88] Checking if address is valid : 7ff7a2c402c0
[TID:00002b88] Executing ntoskrnl.exe!MmIsAddressValid
[TID:00002b88] Checking if address is valid : 7ff7170f18c0
[TID:00002b88] Executing ntoskrnl.exe!KeInitializeEvent
[TID:00002b88] Event object : 7ff6375c7710
[TID:00002b88] Executing ntoskrnl.exe!ExAllocatePoolWithTag
[TID:00002b88] Executing ntoskrnl.exe!KeInitializeEvent
[TID:00002b88] Event object : 22326561050
[TID:00002b88] Executing ntoskrnl.exe!ExAllocatePoolWithTag
[TID:00002b88] Executing ntoskrnl.exe!ExAllocatePoolWithTag
[TID:00002b88] Executing ntoskrnl.exe!ExInitializeResourceLite
[TID:00002b88] INSIDE STUB, RETURNING 0
[TID:00002b88] Executing ntoskrnl.exe!ExAllocatePoolWithTag
[TID:00002b88] Executing ntoskrnl.exe!ExInitializeResourceLite
[TID:00002b88] INSIDE STUB, RETURNING 0
[TID:00002b88] Reading CR8
[TID:00002b88] Reading CR8
[TID:00002b88] Executing ntoskrnl.exe!RtlInitUnicodeString
[TID:00002b88] Executing ntoskrnl.exe!ZwCreateFile
[TID:00002b88] Creating file : \SystemRoot\System32\csrss.exe
[TID:00002b88] Return : 00000000
[TID:00002b88] Getting data @ ntoskrnl.exe!IoFileObjectType
[TID:00002b88] Exported Data ntoskrnl.exe!IoFileObjectType is not implemented
[TID:00002b88] Emulating read from ntoskrnl.exe:+00cfc448
[TID:00002b88] Executing ntoskrnl.exe!ObReferenceObjectByHandle
[TID:00002b88] Executing ntoskrnl.exe!ZwClose
[TID:00002b88] Closing Kernel Handle : 1c4
[TID:00002b88] Executing ntoskrnl.exe!ExAllocatePoolWithTag
[TID:00002b88] Reading CR8

通过模拟器打印对应的指令去trace,最终在驱动文件找到了对应的调用函数,幸运的是这个函数没被v,看一下大致逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void __fastcall sub_140028B70(struct _OBJECT_NAME_INFORMATION **a1)
{
_DWORD v2[15]; // [rsp+20h] [rbp-E0h] BYREF
//...
//some definition
v2[0] = 0x53005C;
//...
//Imm assgin

v11 = 0;
sub_140029100((__int64)a1, (const WCHAR *)v2, a1 + 16);
sub_140029100((__int64)a1, (const WCHAR *)v4, a1 + 17);
sub_140029100((__int64)a1, (const WCHAR *)v12, a1 + 18);
sub_140029100((__int64)a1, (const WCHAR *)v6, a1 + 19);
sub_140029100((__int64)a1, (const WCHAR *)v8, a1 + 20);
sub_140029100((__int64)a1, (const WCHAR *)v13, a1 + 21);
sub_140029100((__int64)a1, (const WCHAR *)v15, a1 + 22);
sub_140029100((__int64)a1, (const WCHAR *)v10, a1 + 23);
}

其中,sub_140029100 是它封装的打开文件的函数,下面给出相关的反编译结果,这里的调用链路也很符合模拟器跑的结果,既然没 v,那就直接拦截,断点,调试一气呵成。

最终发现对以下 8 个文件进行了打开操作。

1
2
3
4
5
6
7
8
\SystemRoot\System32\csrss.exe
\SystemRoot\System32\lsass.exe
\SystemRoot\System32\svchost.exe
\SystemRoot\SysWow64\svchost.exe
\SystemRoot\System32\audiodg.exe
\SystemRoot\System32\services.exe
\SystemRoot\System32\wbem\wmiprvse.exe
\SystemRoot\SysWow64\wbem\wmiprvse.exe

并且获取了它们的完整名称,后续进行的操作都 v 了,不过看后面有类似验 hash 的操作,感觉可能是检查这些进程的签名,如果是白签名那么不限制获取游戏的句柄。

(以上分析皆来自 5.2 版本,后续分析使用了 5.3 版本)

随后使用模拟器跑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
[TID:00005e28]  Executing ntoskrnl.exe!PsSetCreateProcessNotifyRoutineEx
[TID:00005e28] Executing ntoskrnl.exe!ExInitializePushLock
[TID:00005e28] INSIDE STUB, RETURNING 0
[TID:00005e28] Executing ntoskrnl.exe!PsSetLoadImageNotifyRoutine
[TID:00005e28] Executing ntoskrnl.exe!ExInitializePushLock
[TID:00005e28] INSIDE STUB, RETURNING 0
[TID:00005e28] Executing ntoskrnl.exe!PsSetCreateThreadNotifyRoutine
[TID:00005e28] Executing ntoskrnl.exe!ExInitializeResourceLite
[TID:00005e28] INSIDE STUB, RETURNING 0
[TID:00005e28] Executing ntoskrnl.exe!ExAllocatePoolWithTag
[TID:00005e28] Executing ntoskrnl.exe!ExInitializePushLock
[TID:00005e28] INSIDE STUB, RETURNING 0
[TID:00005e28] Executing ntoskrnl.exe!ExCreateCallback
[TID:00005e28] Callback object : 299ce937f90
[TID:00005e28] *Callback object : 0
[TID:00005e28] Callback name : \Callback\PowerState
[TID:00005e28] Executing ntoskrnl.exe!ExRegisterCallback
[TID:00005e28] Executing ntoskrnl.exe!KeAreApcsDisabled
[TID:00005e28] Executing ntoskrnl.exe!ExEnterCriticalRegionAndAcquireResourceExclusive
[TID:00005e28] INSIDE STUB, RETURNING 0
[TID:00005e28] Executing ntoskrnl.exe!ExAllocatePoolWithTag
[TID:00005e28] Executing ntoskrnl.exe!ExReleaseResourceAndLeaveCriticalRegion
[TID:00005e28] INSIDE STUB, RETURNING 0
[TID:00005e28] Executing ntoskrnl.exe!PsCreateSystemThread
[TID:00009cb8] Thread Initialized, starting...
[TID:00005e28] Thread created at 299ce91f0c0
[TID:00009cb8] Executing ntoskrnl.exe!PsGetCurrentThreadId
[TID:00005e28] Executing ntoskrnl.exe!ZwClose
[TID:00005e28] Closing Kernel Handle : 1c4
[TID:00009cb8] Executing ntoskrnl.exe!KeSetPriorityThread
[TID:00005e28] Executing ntoskrnl.exe!KeQueryTimeIncrement

无非就是注册回调,创建线程,后续主线程退出,因为没有写 wdf 对应的框架的 API,所以模拟器里面不会正确返回 0,但是DriverEntry 的逻辑确实是跑完了。可见反调试并不在 DriverEntry 当中,应该在创建的线程中,创建线程的地址在 +0x2f0c0 的地址上,这个地址虽然是 .text 段,但是 v 了,不太好静态分析,选择模拟器分析。

针对标志位的反调试

直接把 DriverEntry 设置为线程入口点试试看跑的结果,关键反调试的 Log 如下:

1
2
3
4
5
6
7
8
9
[TID:0000a3dc]  Driver Base at 7ff7bb200000
[TID:0000b058] [Warning] Read with execute at 00007FF7BB571B7A
[TID:0000b058] Getting data @ ntoskrnl.exe!KdDebuggerEnabled
[TID:0000b058] [Warning] Read with execute at 00007FF7BB553B1C
[TID:0000b058] Getting data @ ntoskrnl.exe!KdDebuggerNotPresent
[TID:0000b058] [Warning] Read with execute at 00007FF7BB53E5C9
[TID:0000b058] Getting data @ ntoskrnl.exe!KdDebuggerEnabled
[TID:0000b058] [Warning] Read with execute at 00007FF7BB56B94C
[TID:0000b058] Getting data @ ntoskrnl.exe!KdDebuggerNotPresent

在四处地方都有读取调试标志位的操作,计算得到偏移如下

1
2
3
4
0x371b7a
0x353b1c
0x33e5c9
0x36b94c

模拟器中没跑出写 KdDebuggerEnabled 的操作,大概率因为在模拟器中该标志为0才不会执行写的操作,这里在读的 case 这里判断一下将 KdDebuggerEnabled 读取的数值修改为 1。

执行结果如下:

1
2
3
4
5
6
7
[TID:00005aac]  Driver Base at 7ff7bb200000
[TID:0000a8c8] [Warning] Read with execute at 00007FF7BB571B7A
[TID:0000a8c8] Getting data @ ntoskrnl.exe!KdDebuggerEnabled
[TID:0000a8c8] [Warning] change KdDebuggerEnabled flags 00007FF7BB571B7A
[TID:0000a8c8] [Info] Write Violation at 00007FF7BB481D20
[TID:0000a8c8] Getting data @ ntoskrnl.exe!KdDebuggerEnabled
[TID:0000a8c8] Unhandled Mnemonic.

得到写入 KdDebuggerEnabled 的指令偏移为 0x281d20

1
.upx0:0000000140281D20 F0 45 87 19                    lock xchg r11d, [r9]

将它改为 mov r11d, [r9] 指令,再来看看会不会被剥离调试器,经测试发现 kdcom 还是蓝屏,那么尝试在读取的指令入手,经测试,0x371b7a 若读到了 1 则会写,因此尝试把这里的

1
.upx0:0000000140371B7A 66 41 0F B6 12                 movzx   dx, byte ptr [r10]

改为 xor dx,dx,同理改掉四个读取的位置,让它们读的值分别为未调试状态读取的值,但是会导致虚拟机被卡死,同时调试器也是未响应的状态,似乎没什么思路了,于是再次考虑动态调试。

针对kdcom的反调试

因为当时会蓝屏,所以修改系统设置获取完整的 Memory.dmp,一通分析发现 data 段被清零了。

最后通过动态调试发现了一个神秘的函数

看到这个函数基本可以验证刚才的猜想了,那就直接把这个函数覆盖 0xC3,观察是否蓝屏。

发现已经完全不会蓝屏,调试器也可以正常工作,那就意味着后面可以正常调试游戏驱动保护了。

尝试分析这个函数,研究它是怎么干掉调试器的。

通过 ZwQuerySystemInformation 获取 kdcom.dll 的基址和模块大小。

随后通过解析 PE 文件找到 .data 段的基址和大小。

随后获取到该段的物理地址,使用 MDL 映射该内存(虽然+0x39240函数被 v 了,但可以合理怀疑这个函数就是申请 MDL 使用的,后续通过动态调试也能得到这个结论)。

进入这个 memset 函数,可以发现将 kdcom.dllmagic 清零之后 kdcom 工作将不正常,其中 fffff8033e455000 为模块本身的虚拟内存,RCX 指向了 MDL 分配的虚拟内存,此时汇编代码通过 RCX 写入 0,在现在这种情况下,单步调试会导致调试器直接断开。

绕过的思路也很简单,抹 PE 头是最简单粗暴 & 安全的方式,因为在获取 NT 头的时候会进行 magic 判断,判断不成功自然不会去搞 kdcom 了。

但是都 inline hook 了那么多,再多这一个又有什么所谓呢

不是,这这这这是是是是谁把 C3 放到我游戏驱动的反调试函数头了,这这这是谁不成心……

嗯,一定是太阳黑子射到了内存,把它改成C3了,总不能是一个黑签名驱动调用了 PsSetLoadImageNotifyRoutine 注册了加载模块回调,然后识别 HxxxYOKProtect.sys 再用 MDL 把这个内存给改了吧。

回调分析

注册了如下回调

  • 创建线程/进程回调
  • 获取/复制线程/进程句柄回调:屏蔽了大部分危险的权限
  • 模块加载回调

yxxxshen 以前是有主动的句柄降权的,但是这个版本测试下来没有开,只能等开了再分析,并且其它回调通过 hook 和模拟执行等手段并没有发现做了什么操作。

进程、线程句柄回调

不管是直接拦还是 ARK 工具看都很方便,这里还是选择hook,拿到句柄之后直接取消这个回调,看看 CE 能否直接读写内存。

用 ARK 工具可以看到,这里驱动正常加载,且正常注册了进程/线程创建回调和模块加载回调,去特征 CE 不加载 DBK 可直接读取游戏内存,且驱动不会降权句柄。

再来具体分析一下降了哪些权限,虽然回调函数被 v 了,但是不妨碍可以做 hook,只要比较一下打开 PROCESS_ALL_ACCESS 的游戏句柄,看看最终得到的权限就行了,这里选择hook注册回调的函数,在注册回调的时候拦截,注册上自己的回调,自己的回调再调用真正的回调函数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
PreOperationCallback = NULL;
PostOperationCallback = NULL;
POB_PRE_OPERATION_CALLBACK gh_ObPreOperationCallback(...)
{
if (PreOperationCallback &&!OperationInformation->KernelHandle) {
DBG_PRINT("handle access(pre)=%x\n",...);
PreOperationCallback(RegistrationContext, OperationInformation);
DBG_PRINT("handle access(after)=%x\n",...);
}
return 0;
}

NTSTATUS gh_ObPostOperationCallback(...)
{
if (PostOperationCallback && !OperationInformation->KernelHandle) {
DBG_PRINT("handle access(pre)=%x\n",...);
PostOperationCallback(RegistrationContext, OperationInformation);
DBG_PRINT("handle access(after)=%x\n",...);
}
return 0;
}

NTSTATUS gh_ObRegisterCallbacks(...)
{
DBG_PRINT("ObRegisterCallbacks operations=%d,preOperation=%p,PostOperation=%p\n",...);
auto ret = ObRegisterCallbacks(CallbackRegistration, RegistrationHandle);
ObUnRegisterCallbacks(*RegistrationHandle);
*RegistrationHandle = 0;
PVOID gs_HandleCallback;
OB_CALLBACK_REGISTRATION obl_callback_reg = { 0 };
OB_OPERATION_REGISTRATION ob2_operation = *CallbackRegistration->OperationRegistration;
//...
auto ret2=ObRegisterCallbacks(&obl_callback_reg, &gs_HandleCallback);//自己注册一个回调
DBG_PRINT("status:%p\n", ret2);
return 0;
}

自己写的驱动记得加 bypass check sign,不然会返回 0xC0000022

最终打开游戏进程得到以下 LOG

跟宏定义比对一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define PROCESS_TERMINATE                  (0x0001)  
#define PROCESS_CREATE_THREAD (0x0002)
#define PROCESS_SET_SESSIONID (0x0004)
#define PROCESS_VM_OPERATION (0x0008)
#define PROCESS_VM_READ (0x0010)
#define PROCESS_VM_WRITE (0x0020)
#define PROCESS_DUP_HANDLE (0x0040)
#define PROCESS_CREATE_PROCESS (0x0080)
#define PROCESS_SET_QUOTA (0x0100)
#define PROCESS_SET_INFORMATION (0x0200)
#define PROCESS_QUERY_INFORMATION (0x0400)
#define PROCESS_SUSPEND_RESUME (0x0800)
#define PROCESS_QUERY_LIMITED_INFORMATION (0x1000)
#define PROCESS_SET_LIMITED_INFORMATION (0x2000)

可以发现降了如下的权限:

  • PROCESS_CREATE_THREAD:创建线程
  • PROCESS_VM_OPERATION:虚拟内存操作
  • PROCESS_VM_READ:读内存
  • PROCESS_VM_WRITE:写内存
  • PROCESS_DUP_HANDLE:复制句柄
  • PROCESS_SET_INFORMATION:设置线程优先级
  • PROCESS_SUSPEND_RESUME:挂起和恢复

进程创建回调

没研究出这个回调干了什么,一般情况下应该是会拦截一些黑工具的使用的,比如 CE 和 xdbg 之类的,没想到没拦,为了保证安全把回调直接取消了也可以。

线程,模块回调

同样也是注册了但是貌似并没有使用,经测试,只把句柄回调去掉之后可以直接远线程注入游戏

最后

感谢 @Qfrost@上学困难户 在分析过程中提供技术支持,本篇报告发布的时候,游戏已经升级到 5.4 版本。

参考文献