这篇是为了回应:驱动obcallback反附加的一次尝试【Second_BC の BloG】 的最新的解决方法;
隔了一年没更新是因为忙事情去了(
之前我们提到过,使用OB回调的方法进行进程打开时的修改操作的这种方法,非常不稳定,会掉线,附加会脱落;
目前的版本也不支持这种姿势绕过了,一进入游戏进程就会直接被检测到异常,弹窗报错然后直接游戏进程死掉;
同时之前的劫持驱动文件的方法非常的不优雅,而且目前版本也不支持这种低级绕过姿势;
针对这些个问题有了今天的这个补充篇;
环境和时间铺垫:
最新测试结果可行,时间节点为
2026.7.30以前,往后没有进行过测试,不保证该方法仍然有效;测试平台为 win10 旗舰版,win11肯定干不起,除非你能在win11上把过期签名的驱动跑起来,或者其他手段能给win11签名绕过或者正版签名;
最好不要使用虚拟机,现在的版本对虚拟机有检测,除非你有信息能绕过虚拟机检测;
由于过去了一段时间我才开始写这篇内容,所以有些实验结论我就直接给了,实验作证截图确实是找不到了(
初步实验猜想
第一处疑点
之前的篇子里提到过,保护驱动主要做的事情进行降权,降权是什么意思呢,如何降的呢?
降权的本质是在保护驱动开启的OB回调里面,检测当每次打开的进程是目标游戏进程的时候,就会让打开句柄的权限降低;
主要就是把可写的权限位给抹除了;
举个例子,当你编写程序使用 OpenProcess API 的时候,第一个参数一般我们会填入 PROCESS_ALL_ACCESS ,意思是以可读可写的方式打开这个文件或者进程句柄;
但经过我的测试,使用R3编程的控制台程序,直接打开这个游戏的进程句柄,返回的句柄通过 NtQueryObject 查询句柄访问权限的掩码,可以发现读写的权限都被抹除了;
这就是为什么使用CE打开进程后,地址出现问号,进程图标加载不出来的原因,因为CE也是使用 OpenProcess 这个API打开进程句柄,并通过后续的一系列API对句柄进行使用以读取内存地址,查询地址空间等操作,比如:ReadProcessMemory,WriteProcessMemory,等;
之前那个篇子中使用的思路是我们自己也写一组OB回调,分别放在OB回调的开始和结束(用优先级设定),针对目标游戏进程句柄的权限进行恢复操作,在这样的一个恢复过程后,CE确实能够看到进程图标,并且能够访问内存空间,这本身没有问题;但保护驱动做了什么措施,导致这样的姿势绕过以后会被检测到,导致游戏死掉;
我推测是保护驱动或者是游戏模块内某块区域持续检查(或者称呼为管理)目标进程被打开的句柄;检查这些被打开的句柄的权限是否异常,如果异常,就杀掉游戏进程,所以我们不能想着去改动打开的游戏进程句柄的权限了;(目前的版本就算使用SYSTEM级权限的CE也会报错闪退,或者根本打不开)
第二处疑点
我使用 PChunter 这玩意儿对游戏进程进行模块查看,发现能够正常返回游戏的每一个模块的基址,我就很好奇,为什么 PChunter 能看到被限制权限的游戏进程的模块基址,而CE不行;
同时,用 PChunter 访问了游戏进程的模块基址后,游戏并没有出现异常或者报错,或者闪退,非常稳定;
于是我开始思考他们两者的差异,即:
PChunter 会挂载驱动,它应该是通过驱动内的手段进行的内核级别的内存扫描;
而CE这东西,直接就是用的R3下的OpenProcess和其他API来操作的,这肯定会受限制;
思路
所以有了想法,即不改变CE打开游戏进程句柄的逻辑,但是改变CE读内存和写内存的逻辑;
至于调试器会不会受影响,我的答案是肯定会,但是我们仍然有CE的dbvm可以用,dbvm调试模式也是走的驱动层面,这条路可行;
由于我们只改变了内存读写逻辑,而没给操作的进程句柄提权;所以线程级操作肯定干不起,比如调试中的线程断点是没有效果的,但是仍然可以选择使用dbvm调试,打断点到对应位置,依旧会正常停下;
结论
这种思路打开了局面,经过我的测试,虽然CE的某些功能受到了限制,但是我们的主要操作已经绕过了,大部分的逆向操作都能直接进行工作,比如:扫描内存,扫描指针,查找是什么访问/改写了这个地址,打断点,单步调试,dump内存等等;这些操作不会触发反调试(因为使用了dbvm),也不会触发异常报错(没有动核心检测点:句柄);
经过我的测试我可以得出一个很明显的结论:这款游戏的保护模块根本没有强检测CE这个程序(但是检测了虚拟机),它并不像腾讯的一些驱动保护会查CE进程的特征;它也没有设置很多内核钩子去监控比如驱动链表,异常驱动模块,驱动回调,你建的异常OB回调等等;有反调试,但对于dbvm的CE来说相当于没有;主要还是针对于自身模块和句柄的权限维持;
接下来就是我写的源代码了,总共有三个模块:
一个是SYS驱动模块,主要是实现内核级内存查询,内存写,内存读;
第二个是DLL模块,用于进行和驱动设备的交互,接口的作用,把CE函数调用约定给到驱动,再由驱动返回CE函数应该得到的返回结果;并在附加到CE进程后对CE的关键函数进行HOOK,替换为这些接口函数,使CE每次调用关键函数走驱动实现;
第三个就是注入器了,把编译好的DLL注入到CE进程,改变CE的执行逻辑,使其绕过检测,正常工作;
值得说明的是,如果游戏的保护驱动检测力度更狠,比如检测到了我上面提到的一些内核级的内容;那么就要考虑其他绕过姿势了,详见:驱动设备引入-Windows驱动编程二【Second_BC の BloG】 ;
三个模块是让 AI 辅助去写的,风格不像之前一样保留大多数注释,但是思路就是上述提到的三个模块的设计思路;
虽然AI时代,AI确实能够代替老时代的残党进行大量的脑力工作和枯燥的逆向过程和漏洞挖掘过程,极大优化分析时间(CTF已死);但是这些绕过思路,对抗思路,长时间内,是AI不可能告诉你的,也是风控严格的AI不会去做的;除非你很吊,非常有刀,自己训练一个属于自己的还算聪明的大模型语言,那我没话说了,你随意;或者使用 agent 阵列组或者设计 harness 控制一些风控较低的但是笨的AI,让高级别的更聪明的AI给他们布置任务和写提示词来实现思路的产生或是exp的编写,也行;
所以往后的学习道路,Agent 是肯定躲不过去的,必须学的内容;老时代的课程也得看,还是得让自己变得更有思路才行;
最后附有上述仅凭外部测试通过的源码;
接下来是一些关于保护驱动的逆向结果,为什么之前没逆向?之前谁能手逆VMP我叫他逆向爷爷,现在 AI 是我爹,都能直接把 VMP 还原成idb可识别的伪代码,太伟大了;
逆向分析结果很有价值,解释了几个问题,也说明了虚拟化检测的一种途径;
针对目前的逆向分析结果还没什么有想法的思路进行绕过,只能说学的东西还多,记的东西也还多;
内部驱动模块逆向分析
这次分析的驱动模块的版本的对应的时间就是这篇内容更新的时间,各位可以参考;
接下来我会选择放 AI 识别的伪代码,因为IDA里全是VM混淆的内容;
初始化入口
在上篇其实我们已经看过驱动入口的样子了,里面被我改名为 true_entry的函数,实际上是一个受保护的初始化函数,新名称我叫他为:VMPcodeEntry1 ,AI 爹还原的伪代码如下所示:
1 | NTSTATUS __fastcall VMPcodeEntry1( |
里面做了一些大量的初始化,但实际上有意义的地方是 三个驱动回调,和next_init函数;
next_init函数后续被确定为业务初始化函数,在这个函数里就注册了我们之前在PChunter里发现的4个OB回调,3个系统回调,还创建了一个系统线程;
三个驱动回调
驱动回调还原的伪代码框架如下所示,需要提一嘴的是,创建和关闭回调没什么值得注意的;
但是控制回调,他做了一些事情:
- 添加受保护线程和进程到对应管理表,后续OB模块和其他模块可能使用到;
- 打开目标进程并终止(很可能检测到异常后对目标游戏进程杀死)
- 遍历指定进程的句柄,对指向受保护进程及其线程的句柄降权(和我们之前外部的推测相匹配,算是之前绕过姿势的一个补丁)
- 还会统计鼠标频率
- 拥有虚拟机检测功能(和外部实验相呼应)
1 | // 0x14003B6F0 |
next_init函数我就不放出来了,它实际上就是在初始化上述提到的7个回调,同时在最后还开启了一个系统线程;
虚拟化检测
我对它的虚拟化检测技术很好奇,进行了AI逆向,得出了如下的表;
检测手段中不存在匹配 VMware 等其他厂商字符串的手段;
其中,控制命令有三类,代表三种入口,可以不用理会,输出位也可以不用理会,主要是查看它的虚拟化检测手段:
| 控制命令 | 输出位/结果 | 检测手段 | 具体判定逻辑 |
|---|---|---|---|
0x80002034 |
0x1 |
CPUID Hypervisor 标志 | CPUID(1).ECX 第 31 位为 1,则命中。 |
0x80002034 |
0x2 |
特殊 CPUID 叶比较 | CPUID(0x13371337) 与 CPUID(0x40000000) 的 EAX、EBX、ECX、EDX 任意不同,则命中;不匹配厂商名称。 |
0x80002034 |
0x4 |
虚拟化 MSR 范围探测 | 遍历读取 MSR 0x40000000~0x400000FF,任意一次读取不触发异常,则命中。 |
0x80002034 |
0x8 |
CPU 能力与实际行为一致性检查 | CPUID(6).ECX[0] 为 0,但 MSR 0xE7/0xE8 至少一个可读,则命中。 |
0x80002034 |
0x20 |
CR4.VMXE 检查 | CR4 第 13 位为 1,则命中;只能说明 VMX 使能状态,不能独立证明处于虚拟机中。 |
0x80002034 |
0x40 |
TSC 测量 CPUID 耗时 | 关中断,以 LFENCE/RDTSC 测量 CPUID(0) 共 10 次;出现负差值,或最小差值大于 500,则命中。 |
0x80002034 |
0x80 |
APERF 测量 CPUID 耗时 | 要求 CPUID(6).ECX[0] 为 1;用 MSR 0xE8 测量 10 次,出现负差值或最小值不在 [11,500],则命中;异常提前结束后仍比较已保存的最小值。 |
0x80002034 |
0x100000 |
固定性能计数器测量 | 配置 MSR 0x38D/0x38F,用 MSR 0x30B 测量 10 次;负差值或最小值大于 500,则命中;异常返回 0。 |
0x80002034 |
0x200000 |
非法 XCR 编号测试 | 读取 XCR0 后尝试 XSETBV(0x45, 原XCR0值);不触发异常,则命中。 |
0x80002034 |
0x400000 |
不支持的 XCR0 位测试 | 根据 CPUID(0xD,0),逐个尝试设置不支持的 XCR0 位;设置成功,或异常后发现 XCR0 被改变,则命中并尝试恢复。 |
0x80002034 |
0x10、0x100、0x200 |
占位检测 | 对应函数直接返回 0,没有实质检测。 (意思是这条不用管) |
0x80002035 |
类别 1 |
单步检测入口执行去向 | 对 NtWaitForDebugEvent、NtDebugActiveProcess 临时接管 #DB 并设置 TF,获取第一条指令执行后的 RIP;其低 32 位与“入口低 32 位+指令长度”不同,则命中。受配置及 Driver Verifier 门控影响,可能跳过。 |
0x80002035 |
类别 2 |
修改代码后验证执行结果 | 对上述两个目标保存入口 8 字节,把前 6 字节改成 mov eax,77777777h; ret,调用后恢复;返回值不等于 0x77777777,则命中。用于检查可读写代码与实际执行是否一致,不能单独确定为 EPT 隐藏钩子。 |
0x80002035 |
结果控制 | 两种代码一致性检测的组合 | Mask bit0 启用单步检测,bit1 启用改码检测;类别 1 命中即结束,类别 2 不再执行。结果不是 OR 位图;接口解析失败不报告阳性。核心机制已识别,部分受保护边界及失败路径尚未完全核验。 |
0x80002036 |
状态 0/1/2/3 |
VMCALL 可执行性探测 | 先尝试参数 0x10F2C00,成功记 2;异常后尝试 0x4D3,成功记 3;均异常保持 0。最后尝试 0xFFFFFFFF,成功则把已有非零状态归一为 1,异常保留原状态;不校验响应内容或识别厂商。 |
下面我们给出七个系统回调;
七个系统回调
七个回调这里只给出框架,具体业务逻辑省略,不过我会口头叙述它们的业务逻辑;
1 | OB_PREOP_CALLBACK_STATUS __fastcall OBProcessFirst( |
OB进程回调-上篇姿势失效的本质
先说两个OB进程回调吧,第一个First做的核心功能就是削弱降权,核心步骤为:
假设进程 A 要打开进程 B:
- 先检查 B 是否受保护(是否位于驱动管理的保护进程表中)。 不受保护,就不削减权限。
- 检查 A 是否符合内置系统进程规则。 名称匹配六种系统进程(比如lsass.exe),并通过账户判断,就豁免。
- 如果上一步没有放行,再查 A 的名称哈希是否在可配置白名单中。 命中也豁免,白名单可以包含非系统进程。
- 两层都没放行,且过滤开关开启,才削减 A 这次申请的句柄权限。
First和Last的OB回调高度都是 310520,它和Last回调还不一样,Last是Post类型,First是Pre类型;
Last的核心功能是:
1 | if (GrantedAccess & 0xA7A) |
检查最终的GrantedAccess是否真的被降权了,如果被恢复了权力,那么就会动一个标志位,或上4,这里就是也许为什么恢复句柄权限后,会被检测到的一个核心点;这个标志位在这个驱动模块内部,是个全局变量;
这里确实涉及到我的知识盲区了,还没深入学习系统回调,不知道OB回调有Pre和Post的区分;
所以这里解释了为什么之前写的那种绕过姿势不太行,之前虽然设置了一上一下的两个OB回调进行恢复权的操作,但都是Pre类型,确实恢复了句柄权限,但是被Post回调检测到了,而我们改Pre的DesiredAccess是真实改变句柄权限,在Post里面改GrantedAccess是没用的,GrantedAccess只是个通知字段,并不有效;
同时First的保护逻辑也有问题,如果把CE改名为csrss.exe或者lsass.exe这种它判断逻辑的白名单进程名,并以SYSTEM权限启动获取对应的SID,也能直接通过正常读写权限的句柄打开;但是目前这种方法貌似也存在判断点,以这种方法打开也会被检测到,这种方法的检测点还尚不清楚;
OB线程回调
线程的First也进行了权限削弱,判断条件分两路,一路是跨进程打开线程句柄,另一路是同进程打开线程句柄;两路都会遭到削权,削弱的幅度不一样;这也就解释了为什么CE里面线程调试功能失效的原因;
不过目前版本的线程Last并没有做和进程一样的处理,即设置异常标志位,用于后续操作,所以理论上,如果不存在其他检测点,那么线程绕过姿势还是可以通过上篇的方式绕过;
全局进程回调
它作用于每个进程创建和销毁时;
销毁时就是把OB设置的保护给撤销了,会操作同一个标志变量,和其他一些垃圾回收,资源清理工作;
创建时会记录创建进程的信息,比如PID和对应父进程PID,以及完整路径等等信息;把记录的信息放入一个共享内存区域;外部模块可以访问,但是这个驱动本身不会对这些记录的进程信息有任何行为;
至于线程回调的逻辑和进程回调的逻辑类似,加载镜像回调也是进行上报无其他特殊业务逻辑,就先不进一步跟踪了;
系统线程
它主要的功能:
- 看有没有其他驱动碰它关注的进程内存,比如,它关注进程 A,发现某段内核代码访问了 A 的内存,就尝试记下:“哪个线程干的、来自哪个驱动、碰了哪个地址。”
这里查到的是记录线索,没有看到它在这个部分直接杀掉对方。它也不是每次内存访问都能发现;- 它保护的线程是不是被暂停了,尝试让线程继续运行。比如某个受保护线程被挂起,它会尝试恢复,并记录这件事;
- 看被检查的驱动有没有被别人改过,例如:代码内容变没变;原本应该执行的函数,有没有被换成其他地址;有没有其他驱动接到它的设备上。发现不一致就记录下来;
- 还会做一些超时和鼠标统计检查;
这个线程主要是监控、记录、通知,维护,以及干预受保护线程,不会主动出击妨碍其他外部进程和驱动模块,也不会去进行杀死保护进程的操作;
下面就是7月份的时候仅通过外部实验,测试出的当时可行的版本;
外部实验通过代码
SYS模块
myMem.c
1 |
|
DLL模块
这个DLL是注入CE进程的,不是目标游戏进程,目的是为了改变CE的功能;
dllmain.cpp
1 |
|
Driver.h
1 |
|
Driver.cpp
1 |
|
Hook.h
1 |
|
Hook.cpp
1 |
|
注入模块
直接用的以前的远程线程注入姿势;
main.cpp
1 |
|