【二进制漏洞-某FPS游戏反作弊分析】此文章归类为:二进制漏洞。
由于该FPS游戏版本变动较大,且 CVE-2025-45737 已经公开,遂公开之前分析。
<!--more-->上来先开驱动。
DriverEntry按顺序执行以下逻辑
由于 DriverEntry 加了混淆,遂尝试通过动态调试和模拟执行的方式尝试解出 DriverEntry 的逻辑。
先 IAT hook PsCreateSystemThread,发现创建了五个线程。
1 2 3 4 5 6 | [OwNeacSafe]ImageBase: 0xFFFFF80281560000 [OwNeacSafe]PsCreateSystemThread: start at 0xFFFFF80281780AAE [OwNeacSafe]PsCreateSystemThread: start at 0xFFFFF80281584A8D [OwNeacSafe]PsCreateSystemThread: start at 0xFFFFF8028157DAE2 [OwNeacSafe]PsCreateSystemThread: start at 0xFFFFF8028157D82C [OwNeacSafe]PsCreateSystemThread: start at 0xFFFFF8028157D4D5 |
据此可算出五个线程的偏移:
尝试模拟执行,观察第一个线程,前面仅仅是创建了一个 Log 文件,随后获取了 MmGetVirtualForPhysical 函数的指针,进行了数次的 RtlCompareMemory,最后驱动就返回失败了。
1 2 3 4 5 6 7 8 | [TID:00008dc8] Executing ntoskrnl.exe!RtlGetVersion[TID:00008dc8] 10.0.22631[TID:00008dc8] Executing ntoskrnl.exe!RtlInitUnicodeString[TID:00008dc8] Executing ntoskrnl.exe!MmGetSystemRoutineAddress[TID:00008dc8] Retrieving MmGetVirtualForPhysical ptr[TID:00008dc8] Executing ntoskrnl.exe!RtlCompareMemory[TID:00008dc8] Executing ntoskrnl.exe!RtlCompareMemory... |
找到对应的位置发现是特征码识别

这里的 1400885E54 对应的字节是 48 8B 04 D0 48 C1 E0 19 48 BA
那么来到 ntoskrnl.exe 搜索特征码,搜索得到如下结果

这个地方不仅可以反 hook,还能反模拟器,因为模拟器大概率是不能原样实现这样的函数的,所以需要想办法绕过,最好的办法自然是直接 hook RtlCompareMemory 直接让它 return length。
1 2 3 4 5 6 7 8 9 10 | [TID:0000ac54] Executing ntoskrnl.exe!RtlInitUnicodeString[TID:0000ac54] Executing ntoskrnl.exe!MmGetSystemRoutineAddress[TID:0000ac54] Retrieving MmGetVirtualForPhysical ptr[TID:0000ac54] Executing ntoskrnl.exe!RtlCompareMemory[TID:0000ac54] Executing ntoskrnl.exe!MmGetPhysicalMemoryRanges[TID:0000ac54] Executing ntoskrnl.exe!ExAllocatePoolWithTag[TID:0000ac54] Allocate with tag=466b5458 size=0x90[TID:0000ac54] Executing ntoskrnl.exe!ExFreePool[TID:0000ad28] Executing ntoskrnl.exe!KeDelayExecutionThread[TID:0000ad28] Executing ntoskrnl.exe!KeDelayExecutionThread |
随后模拟执行的结果是获取了物理内存的范围,然后开始检查一些东西,根据这个关键 API MmGetPhysicalMemoryRanges 交叉可以找到对应的位置。

游戏里面多次用到这个俯瞰天下的 tag,没想到大 Neac 有如此宽广的心胸抱负。
随后使用 MmGetSystemRoutineAddress 获取了一堆的 API,这一部分沿用动态调试的结果。
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 | [OwNeacSafe]MmGetSystemRoutineAddress: MmAllocateContiguousNodeMemory return FFFFF8025445FF30[OwNeacSafe]MmGetSystemRoutineAddress: KeAreAllApcsDisabled return FFFFF802544339C0[OwNeacSafe]MmGetSystemRoutineAddress: KeQueryActiveProcessorCount return FFFFF80254713980[OwNeacSafe]MmGetSystemRoutineAddress: KeQueryActiveProcessorCountEx return FFFFF80254419190[OwNeacSafe]MmGetSystemRoutineAddress: KeGetProcessorNumberFromIndex return FFFFF802544E55B0[OwNeacSafe]MmGetSystemRoutineAddress: KeSetSystemGroupAffinityThread return FFFFF8025453D060[OwNeacSafe]MmGetSystemRoutineAddress: KeRevertToUserGroupAffinityThread return FFFFF8025453CF00[OwNeacSafe]MmGetSystemRoutineAddress: KeSetTargetProcessorDpcEx return FFFFF8025452F690[OwNeacSafe]MmGetSystemRoutineAddress: KeGetCurrentProcessorNumberEx return FFFFF802544467C0[OwNeacSafe]MmGetSystemRoutineAddress: KeInvalidateAllCaches return FFFFF802545A4540[OwNeacSafe]MmGetSystemRoutineAddress: SeLocateProcessImageName return FFFFF8025484D5C0[OwNeacSafe]MmGetSystemRoutineAddress: PsReferenceProcessFilePointer return FFFFF8025488ECE0[OwNeacSafe]MmGetSystemRoutineAddress: ObGetObjectType return FFFFF802548CFA50[OwNeacSafe]MmGetSystemRoutineAddress: PsAcquireProcessExitSynchronization return FFFFF80254851630[OwNeacSafe]MmGetSystemRoutineAddress: PsReleaseProcessExitSynchronization return FFFFF802548C1760[OwNeacSafe]MmGetSystemRoutineAddress: ExfUnblockPushLock return FFFFF802545F7730[OwNeacSafe]MmGetSystemRoutineAddress: KeGenericCallDpc return FFFFF80254510FA0[OwNeacSafe]MmGetSystemRoutineAddress: KeSignalCallDpcDone return FFFFF8025452F290[OwNeacSafe]MmGetSystemRoutineAddress: KeSignalCallDpcSynchronize return FFFFF802547182A0[OwNeacSafe]MmGetSystemRoutineAddress: KeIpiGenericCall return FFFFF802545A4260[OwNeacSafe]MmGetSystemRoutineAddress: KeIsExecutingDpc return FFFFF8025450D8A0[OwNeacSafe]MmGetSystemRoutineAddress: KeRegisterProcessorChangeCallback return FFFFF802549C4540[OwNeacSafe]MmGetSystemRoutineAddress: KeDeregisterProcessorChangeCallback return FFFFF80254ABE2C0[OwNeacSafe]MmGetSystemRoutineAddress: PsGetProcessSessionId return FFFFF80254444CC0[OwNeacSafe]MmGetSystemRoutineAddress: NtOpenFile return FFFFF802547FBC80 |
随后线程退出主线程返回失败,这里为了查失败的原因看一下后半部分的模拟执行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | [TID:000065d4] Executing ntoskrnl.exe!ZwWriteFile[TID:000065d4] Executing ntoskrnl.exe!DbgPrintEx[TID:000065d4] Executing ntoskrnl.exe!ExReleaseResourceLite[TID:000065d4] INSIDE STUB, RETURNING 0[TID:000065d4] Executing ntoskrnl.exe!KeLeaveCriticalRegion[TID:000065d4] INSIDE STUB, RETURNING 0[TID:000065d4] Executing ntoskrnl.exe!ZwWriteFile[TID:000065d4] Executing ntoskrnl.exe!DbgPrintEx[TID:000065d4] Executing ntoskrnl.exe!ZwWaitForSingleObject[TID:0000b42c] Executing ntoskrnl.exe!PsTerminateSystemThread[TID:0000b42c] thread boom[TID:000065d4] Executing ntoskrnl.exe!ZwClose[TID:000065d4] Closing Kernel Handle : 1c0[TID:000065d4] Executing ntoskrnl.exe!ZwClose[TID:000065d4] Closing Kernel Handle : 1bc...[TID:000065d4] Main Thread Done! Return = c0000001 |
线程自杀了,找到线程的逻辑,顺便根据上下文推导这里的结构体。

这里的 StartContext 结构是存储在 .data 段上的。
同时也找到在 DriverEntry 中调用的逻辑

标准的退出收尸动作,交叉找一下位置。

最后发现,返回 c0000001 只有可能是 PsCreateSystemThread 返回的,而翻看了源码之后发现不太可能返回这个值,只能向下搜索看看值是从哪里出来的。
最后在模拟器栈回溯之后找到 +0x24C4F8。

抱歉,是 vm,再见。。。
不甘心,还是要找一下,于是想到这个 C0000001 应该是直接在某条指令写死的,在 IDA 里搜一下相关的指令。

只有 45 条,依次打上断点,然后附加调试器运行。

最终断在这里,往后跟发现的确是这个函数返回了 C0000001 错误码。

于是选择去双机调试中看一下,这个地方返回值是否不同。

果然在对应被赋值 0xC000001 的内存被赋值为 0 了,翻看它原本函数的定义
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | __int64 __fastcall sub_140020C52(__int64 a1, __int64 a2){ *(_OWORD *)&p_Event = 0LL; v4[0] = a1; v4[1] = a2; p_Event = (__int64)&Event; v6 = 0xC0000001; KeInitializeEvent(&Event, NotificationEvent, 0); WorkItem.WorkerRoutine = (PWORKER_THREAD_ROUTINE)sub_140020B4B; WorkItem.Parameter = v4; WorkItem.List.Flink = 0LL; ExQueueWorkItem(&WorkItem, DelayedWorkQueue); KeWaitForSingleObject(&Event, Executive, 0, 0, 0LL); result = v6; if ( ((unsigned __int64)&v3 ^ v9) != _security_cookie ) __debugbreak(); return result;} |
应当是,KeWaitForSingleObject 等待过程中被其它线程写入了 0 最终返回成功。所以我的解决方法是,断点修改大法,会两次命中这个函数,给它返回值改成 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 | [TID:0000d3c0] Executing ntoskrnl.exe!MmGetSystemRoutineAddress[TID:0000d3c0] Retrieving NtOpenFile ptr[TID:0000d3c0] Executing ntoskrnl.exe!ExAllocatePoolWithTag[TID:0000d3c0] Allocate with tag=53425458 size=0x40000[TID:0000d3c0] Executing ntoskrnl.exe!ZwQuerySystemInformation[TID:0000d3c0] Class 0000000b status : 00000000[TID:0000d3c0] Class 0000000b success[TID:0000d3c0] Patching ntoskrnl.exe base from fffff8056f000000 to 7ff7a9760000[TID:0000d3c0] Patching hal.dll base from fffff8056d960000 to 1c0000000[TID:0000d3c0] Patching kd.dll base from fffff8056d970000 to 528f0000[TID:0000d3c0] Patching FLTMGR.SYS base from fffff8056db70000 to 7ffcc7230000[TID:0000d3c0] Patching CI.dll base from fffff80573600000 to 7ffcbc880000[TID:0000d3c0] Patching cng.sys base from fffff8056e140000 to 7ffcbb5c0000[TID:0000d3c0] Patching WDFLDR.SYS base from fffff8056dcd0000 to 7ff68a2e0000[TID:0000d3c0] Base is : 7ff7a9760000[TID:0000d3c0] Executing ntoskrnl.exe!RtlImageNtHeader[TID:0000d3c0] Executing ntoskrnl.exe!RtlInitAnsiString[TID:0000d3c0] Executing ntoskrnl.exe!ExAllocatePoolWithTag[TID:0000d3c0] Allocate with tag=53425458 size=0x208[TID:0000d3c0] Executing ntoskrnl.exe!ExFreePool[TID:0000d3c0] Executing ntoskrnl.exe!ZwQuerySystemInformation[TID:0000d3c0] Class 000000c4 status : 00000000[TID:0000d3c0] Class 000000c4 success[TID:0000d3c0] Executing ntoskrnl.exe!RtlImageNtHeader[TID:0000d3c0] [Info] read Violation at ... execute in ...[TID:0000d3c0] Emulating read from ntoskrnl.exe:+00cfca50... |
遍历了一下模块列表,调用了 RtlImageNtHeader 去解析了 ntoskrnl.exe 模块,随后多次尝试读取 +00cfca50,看了对应版本的内核,是 KeServiceDescriptorTableShadow+0x10 的位置,也就是系统描述符表的服务个数字段。
随后异常在了一个内核地址,且非 _KUSER_SHARED_DATA,结合动态调试,发现异常函数。
1 2 3 4 5 6 7 | unsigned __int64 __fastcall sub_140020E16(unsigned __int8 a1){ __sidt(v3); result = ((unsigned __int64)*(unsigned int *)(v4 + 16 * (unsigned int)a1 + 8) << 32) | (*(unsigned __int16 *)(v4 + 16 * (unsigned int)a1 + 6) << 16) | *(unsigned __int16 *)(v4 + 16 * (unsigned int)a1); //... return result;} |
发现是读取了 IDT 表,经过动态调试。

读取了 idtr+0xE8 位置的值,这个位置是 KiPageFault 的处理例程入口,走到返回的确是返回了该函数的地址。

由于 kace 没有实现 IDT,因此手动获取并填充 IDT 给驱动使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | #pragma pack(push, 1)struct IDTR { uint16_t limit; uint64_t base;};struct IDTR idtr;__sidt((PVOID)&idtr);IDTRBase = idtr.base;IDTRLimit = idtr.limit;UserIDT= (uint64_t)VirtualAlloc(...);memset((void *)UserIDT, 0xFF, 0x1000);struct IDTEntry64 { ...};#pragma pack(pop)auto SetIDTEntry = [](uint64_t handler, struct IDTEntry64* entry) { ... return;};uint64_t PageFaultHandler = Provider::FindFuncImpl("KiPageFault");SetIDTEntry((uint64_t)PageFaultHandler,(struct IDTEntry64*)(UserIDT+0xe0));MemoryTracker::AddMapping(IDTRBase, IDTRLimit+1,UserIDT); |
然后输出了 2w 行的 log,最后驱动还是返回了 0xC0000205,不过即使 hook 绕过,也没有更多的逻辑了,所以接下来着重对模拟器的 log 进行分析。
话说读取到 IDT 的 KiPageFault 例程地址,之后循环读取该函数的字节码进行一些判断。
之后又调用了 RtlLookupFunctionEntry,奈何 MSDN 的翻译太过僵硬
RtlLookupFunctionEntry 函数 (winnt.h)
在活动函数表中搜索与指定电脑值对应的条目。
最后还是在看雪一篇帖子的评论中找到了比较正确的回答:
查找某个指令位置对应在哪一个模块的哪一个函数里面。
大概看明白了,MSDN官方翻译人员把 PC Value 翻译成了电脑值,真的给不出很多很好的评价。然后根据返回地址找了当前函数的调用函数,这里大概是有一个防 hook 的一个作用,也有可能是做日志记录使用的,目前没有发现什么特殊的。

之后读取了一系列的标志位。
1 2 3 4 5 6 7 8 9 10 11 | [TID:00002988] Executing ntoskrnl.exe!RtlLookupFunctionEntry[TID:00002988] [Info] read Violation at 7ff7c7bb0ad0 execute in 0x00007FFD51A30782[TID:00002988] Emulating read from ntoskrnl.exe:+00130ad0[TID:00002988] [Info] read Violation at 7ff7c7b7cd5c execute in 0x00007FFD51A307B1[TID:00002988] Emulating read from ntoskrnl.exe:+000fcd5c[TID:00002988] [Info] read Violation at 7ff7c7b62ea8 execute in 0x00007FFD51A307B1[TID:00002988] Emulating read from ntoskrnl.exe:+000e2ea8[TID:00002988] [Info] read Violation at 7ff7c7b55f48 execute in 0x00007FFD51A307B1[TID:00002988] Emulating read from ntoskrnl.exe:+000d5f48[TID:00002988] [Info] read Violation at 7ff7c7b4f798 execute in 0x00007FFD51A307B1... |
中间多次穿插类似获取线程 id,获取进程名,读取 KUSER_SHARED_DATA 获取时间,为了记录日志,因为模拟器中可能存在部分标志位值是异常的。

出现一些问题会 DbgPrint 打印并同步写入日志 OwNeacSafe.log。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | 13:38:33.156 ERR #0 4 8 System Found abnormal interrupt entry , analyzed from 000000014001A05013:38:33.168 ERR #0 4 8 System KiInterruptInitTable not found13:38:33.175 ERR #0 4 8 System KiKernelExit not found13:38:33.190 ERR #0 4 8 System ExpLargePoolTableLock not found13:38:33.193 ERR #0 4 8 System PoolBigPageTable not found13:38:33.197 ERR #0 4 8 System PoolBigPageTableSize not found13:38:33.250 ERR #0 4 8 System RtlpIsFrameInBoundsEx not found13:38:33.267 ERR #0 4 8 System PiDDBCacheTable not found13:38:33.272 ERR #0 4 8 System KeRemoveQueueApc not found13:38:38.113 ERR #0 4 8 System PspCidTable not found.13:38:38.399 ERR #0 4 8 System KiPageFault not found13:38:38.739 ERR #0 4 8 System CiOptions not found13:38:38.823 ERR #0 4 8 System failed to find MmSetPageProtection13:38:38.912 ERR #0 4 8 System failed to find pg entry13:38:39.009 INF #0 4 8 System Failed to initialize security manager context C0000205.13:38:40.067 INF #0 4 8 System Bye! |
随后注册一系列的回调
1 2 3 4 5 6 7 8 9 | [TID:0000f4b4] Executing ntoskrnl.exe!ObRegisterCallbacks[TID:0000f4b4] Executing ntoskrnl.exe!ObGetFilterVersion[TID:0000f4b4] Executing ntoskrnl.exe!RtlAnsiCharToUnicodeChar//...[TID:00002988] Executing ntoskrnl.exe!ObRegisterCallbacks[TID:00002988] Executing ntoskrnl.exe!PsSetLoadImageNotifyRoutine[TID:00002988] Executing ntoskrnl.exe!PsSetCreateProcessNotifyRoutine[TID:00002988] Executing ntoskrnl.exe!PsSetCreateProcessNotifyRoutineEx[TID:00002988] Executing ntoskrnl.exe!PsSetCreateThreadNotifyRoutine |
最后注册 minifilter 回调。
1 2 3 4 5 6 7 8 9 | [TID:00002988] Executing ntoskrnl.exe!MmGetSystemRoutineAddress[TID:00002988] Retrieving CmCallbackGetKeyObjectID ptr[TID:00002988] Executing FLTMGR.SYS!FltRegisterFilter[TID:00002988] Executing FLTMGR.SYS!FltBuildDefaultSecurityDescriptor[TID:00002988] Executing ntoskrnl.exe!RtlSetDaclSecurityDescriptor[TID:00002988] Executing ntoskrnl.exe!RtlInitUnicodeString[TID:00002988] Executing FLTMGR.SYS!FltCreateCommunicationPort[TID:00002988] Executing FLTMGR.SYS!FltFreeSecurityDescriptor[TID:00002988] Executing FLTMGR.SYS!FltStartFiltering |
由于笔者当时没有了解过相关回调,因此这里模拟器模拟并不成功,但是当 hook 并绕过的时候,DriverEntry 正常返回了,因此这里已经到了最后一步的逻辑。
最后就是创建了四个线程,分析的结论与之前动态调试一致。
可以先用 ARK 查一下基本的回调。

除此之外还有 minifilter 回调,据说 Neac 就是用这个做通信的,放到最后着重分析,先简单分析前面的回调,能够正常运行游戏读写游戏内存就算成功。
如法炮制,还是像分析某二次元游戏一样 hook,拦截,查,之前的代码基本可以复用的,但是看着调试器的 log 陷入了沉思

看了一下,导入表确实没 ObRegisterCallbacks,而且也没用 MmGetSystemRoutineAddress 获取函数地址,要不是之前模拟器跑到了这个函数,我真要相信其它什么函数也能注册这个句柄回调了,由于不是高频次调用的 API,可以直接下断点加载驱动看看。

果然直接命中断点,应该是用了其它方法获取 API 地址,结合之前解析 ntoskrnl.exe 模块也能大概想到是直接 ZwQueryInformation 查询模块然后手动解析的。
其实这里 traceback 也是可以找到位置的,毕竟这个混淆不是很 vm,找到对应的位置。

就是个简单的异或字符串加密,手动解一下。
1 2 3 4 | 0x84876DDD9EAF40B6^0xEDE0088FF0FA22F9=0x696765526e55624f0x640ABA616093A94D^0x0866DB2212F6DD3E=0x6c6c6143726574730x3BDAEC226A1093F9^0x3BDAEC510173F29B=0x000000736b6361620x3EB416FCB7EFF0B0^0x3EB416FCB7EFF0B0=0x0 |
转一下端序可以发现就是 ObUnRegistreCallbacks,看起来 sub_140224927 这个函数是根据模块基址和字符串名称去对应模块搜索导出函数的。
解这个混淆当然是容易的,全是立即数,直接模拟执行,匹配特征码 patch。
但是还有一种方法,直接做 inline hook 到 sub_140224927 函数,不仅可以解这个混淆,还可以顺带把获取的函数地址一起修改掉。为什么不直接 inline hook ObUnRegistreCallbacks?鉴定为:需要吃几次PG才能好。
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 | namespace inlinehook { PVOID TargetFunction = NULL; CHAR OriginalCode[12] = { 0 }; CHAR hookcode[12] = { //... }; VOID rehook() { bypass_memcpy(...); } VOID unhook() { bypass_memcpy(...); } PVOID gh_FindExportedFunctionAddress(...) { unhook(); DBG_PRINT("FindExportedFunctionAddress %p %s\n", ...); auto func = TargetFunction; auto ret = func(...); rehook(); return ret; } VOID DoHook(PVOID target) { //... }} |
得到以下结果:
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 37 38 39 40 41 42 43 44 | [OwNeacSafe]FindExportedFunctionAddress NtQuerySystemInformation[OwNeacSafe]FindExportedFunctionAddress ProbeForRead[OwNeacSafe]FindExportedFunctionAddress ProbeForWrite[OwNeacSafe]FindExportedFunctionAddress NtQueryInformationProcess[OwNeacSafe]FindExportedFunctionAddress PsSetLoadImageNotifyRoutine[OwNeacSafe]FindExportedFunctionAddress PsSetLoadImageNotifyRoutineEx[OwNeacSafe]FindExportedFunctionAddress PsRemoveLoadImageNotifyRoutine[OwNeacSafe]FindExportedFunctionAddress PsSetCreateThreadNotifyRoutine[OwNeacSafe]FindExportedFunctionAddress PsSetCreateProcessNotifyRoutine[OwNeacSafe]FindExportedFunctionAddress PsSetCreateProcessNotifyRoutineEx[OwNeacSafe]FindExportedFunctionAddress RtlCompareMemory[OwNeacSafe]FindExportedFunctionAddress RtlCaptureContext[OwNeacSafe]FindExportedFunctionAddress RtlRestoreContext[OwNeacSafe]FindExportedFunctionAddress KeBugCheckEx[OwNeacSafe]FindExportedFunctionAddress ExAcquireRundownProtection[OwNeacSafe]FindExportedFunctionAddress MmIsAddressValid[OwNeacSafe]FindExportedFunctionAddress ExGetPreviousMode[OwNeacSafe]FindExportedFunctionAddress ExQueueWorkItem[OwNeacSafe]FindExportedFunctionAddress KeSetTimer[OwNeacSafe]FindExportedFunctionAddress IoGetInitialStack[OwNeacSafe]FindExportedFunctionAddress KeDelayExecutionThread[OwNeacSafe]FindExportedFunctionAddress PsLookupProcessByProcessId[OwNeacSafe]FindExportedFunctionAddress IofCallDriver[OwNeacSafe]FindExportedFunctionAddress RtlIsGenericTableEmptyAvl[OwNeacSafe]FindExportedFunctionAddress KeSetEvent[OwNeacSafe]FindExportedFunctionAddress PsIsProtectedProcess[OwNeacSafe]FindExportedFunctionAddress PsReferenceProcessFilePointer[OwNeacSafe]FindExportedFunctionAddress ObFindHandleForObject[OwNeacSafe]FindExportedFunctionAddress PsSuspendProcess[OwNeacSafe]FindExportedFunctionAddress memmove[OwNeacSafe]FindExportedFunctionAddress MmGetPhysicalAddress[OwNeacSafe]FindExportedFunctionAddress MmProbeAndLockPages[OwNeacSafe]FindExportedFunctionAddress MmCopyVirtualMemory[OwNeacSafe]FindExportedFunctionAddress MmAdjustWorkingSetSize[OwNeacSafe]FindExportedFunctionAddress CmUnRegisterCallback[OwNeacSafe]FindExportedFunctionAddress MmMapViewInSystemSpace[OwNeacSafe]FindExportedFunctionAddress MmAllocateMappingAddress[OwNeacSafe]FindExportedFunctionAddress MmAllocateMappingAddressEx[OwNeacSafe]FindExportedFunctionAddress CiInitialize[OwNeacSafe]FindExportedFunctionAddress ObRegisterCallbacks[OwNeacSafe]FindExportedFunctionAddress ObUnRegisterCallbacks[OwNeacSafe]FindExportedFunctionAddress ObGetFilterVersion[OwNeacSafe]FindExportedFunctionAddress PsSetCreateProcessNotifyRoutineEx[OwNeacSafe]FindExportedFunctionAddress RtlLookupFunctionEntry |
在这些函数中,有些使用 MmGetSystemRoutineAddress 获取过,有些在导入表当中存在,同时也有两者都不存在的,类似 ObRegisterCallbacks 很好理解,应该就是去藏调用的,但是一般来说 ARK 工具是能精准定位的,因此确实不明白它藏调用的出发点。其它一些函数大概率有一个防 IAT 的替换和防 MmGetSystemRoutineAddress 的 hook 替代。
稍微改一下,去 hook ObRegisterCallbacks
1 2 3 4 5 6 7 8 9 10 11 12 13 | PVOID gh_FindExportedFunctionAddress(...){ unhook(); DBG_PRINT("FindExportedFunctionAddress %p %s\n", ...); auto func = (FindExportFunctionAddress)TargetFunction; auto ret = func(Base, FunctionName); if (!strcmp("ObRegisterCallbacks", FunctionName)) { DBG_PRINT("Found function %s\n", FunctionName); ret = gh_ObRegisterCallbacks; } rehook(); return ret;} |
看起来直接取消回调是不影响驱动加载的,并且回调函数没 vm,可以在创建回调的时候看到两个回调函数,它调用了两次,一次创建线程的两个回调,一次创建进程的两个回调,直接来分析线程的回调。

直接看这个 preOperation 回调

(说真的,第一次见到这么裸的回调函数)
大概就是判断打开的进程句柄是否为被保护的进程,取消的权限看起来只取消了
THREAD_SUSPEND_RESUME THREAD_SET_CONTEXT第二个回调函数是在获取权限之后的操作。

这个誓不投降 tag 看的我是热血沸腾,精神鼓舞,想不到 Neac 驱动的 tag 都这么富有深意。
回到函数里面,最外面判断一下被操作的进程是否为被保护进程,如果是,会创建一个结构,填充授予的权限,操作的进程线程 id,和被操作的进程线程id和一些其它信息,甚至还回溯了栈进行保存,不过后续并没有对权限进行任何操作,倒是调用了一个 KeSetEvent 函数,目前还没分析出有什么用。
从回调函数来看,也只取消了两个权限
其它的逻辑跟线程大差不差,在 ObRegisterCallbacks 的函数指针交叉引用就可以很快找到这几个回调。
其中对于进程的判断,依赖于一个全局变量,交叉之后发现,这个变量赋值是在 minifilter 的通信函数当中的,怪不得自己建的 Overwatch.exe 没有保护效果。。。
在这个全局变量中写入对应的 PID,再尝试获取句柄

发现可以成功过滤掉刚刚分析出的权限了,看来真就只降两个权限。。。
第一个创建的线程,会在主线程中填充 Context 结构,主线程退出后该线程持续运行,并打印相关日志,这里的结构体通信预计是和回调相关的。

找到创建回调的地方。

没错,非常恶心的混淆,一大堆的运算符和变量干扰,网上公开的D810插件对此束手无策,甚至能直接卡死 D810,于是选择用模拟执行的方法,跑出每一个分支,去掉这些运算符的混淆。
首先可以确定一点,这些指令膨胀来源于 call 调用,并且只有 call rax 和 call r10,据此写出模拟 + patch 脚本。
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 | def hook_mem_unmapped(uc, access, address, size, value, user_data): aligned_addr = address&0xFFFFFFFFFFFFF000 # 按页加载驱动的内存到 unicorn 环境 try: uc.mem_map(aligned_addr, PAGE_SIZE) data=idaapi.get_bytes(aligned_addr,PAGE_SIZE) uc.mem_write(aligned_addr,data) return True # 表示错误已处理,继续执行 except Exception as e: print(f"[-] 动态映射内存页失败: {e}") return False #def hook_code(uc, address, size, user_data): #处理 call rax 和 call r10 if uc.mem_read(address,2)==b'\xFF\xD0' or uc.mem_read(address,3)==b'\x41\xFF\xD2': pass # 无码mu = Uc(UC_ARCH_X86, UC_MODE_64)#初始化环境print(hex(mu.reg_read(UC_X86_REG_RIP)))try: mu.emu_start(fix_function_start,fix_function_end)except UcError as e: pass |
话不多说,直接放反混淆后的 F5 代码

这里的逻辑与之前动态调试,模拟执行得到的结论是一致的,据此直接在IDA中找到创建线程的代码。
线程2总体做了这几件事
Context 中的 vector 字段并使用中断方式尝试读取指定区域的内存并写入分配的内存中,读取内存字节数被统计计入 Context 的 Value 字段用于日志输出。vector 不为空,则读取并存入 Context 的 vector。直接分析 StartRoutine2 例程,直接 F5 十分地绝望,3000 多行,还有一堆的膨胀指令,和平坦化,和间接 call 混淆。流程图可以看出有平坦化还有巨量的指令膨胀

同样使用模拟执行+手动patch去除混淆,这里需要注意的是,call 指令有三种 call rax, call r8, call r10,模拟分支执行的时候,大约一半的分支是需要提供 r13 的值的,由于当时决定梭哈,赌了一下 r13<0x100 去遍历,最后也去除了大部分的混淆,只有一个地方的 call 没有解出来。
最后的平坦化混淆直接上 D810 插件,反编译大概 20 多分钟,最后得到一份清晰的代码,这是代码部分截图

又一次看到了誓不投降,这不正是研究游戏安全所需的决心嘛,哪怕面对再丧心病狂的混淆,也迎难而上,誓不投降。
下面是线程逻辑的具体分析:

线程开头获取了当前逻辑核心个数,获取了当前进程结构,初始化三个 Event,随后进入循环,每次循环等待 Event (全局变量,由DriverUnload设置)十秒,如果正常返回,则直接调用 PsTerminateThread 结束线程。

随后检查了两个标志位,经过交叉查找发现一个是在驱动通信时获取的 ProcessId,另一个标志位也在其中设置,应该是判断游戏是否跟驱动通信上了,如果没通信上则重新循环。

可以看出,这一部分也陷入了循环,如果没通信上则重新等待 10 秒等 KeWaitForSingleObject 超时。
然后后面似乎分析不动了,因为引用了大量全局变量,栈也很大,很难分析出来,所以选择找关键点,例如一开始展示的创建线程。不管是游戏正常运行还是模拟,还是直接裸驱动加载,这个线程都没有创建过,因此条件应该比较苛刻,分析这里的 StartRoutine,最终分析出以下结构体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | struct vector{ data *first; data *last; data *end;};struct data6{ char p1[48]; __int64 value3; char p5[8]; vector vec1; vector vec2; FAST_MUTEX mutex; PVOID Event[3]; struct _KPROCESS *Process; signed __int64 value; char p2[8]; union _LARGE_INTEGER Time; PVOID function; int unknown[4]; mem Memory;}; |
p开头的和 unknown 字段都是填充的暂时没发现什么其它用处。

可以发现,之前看上去莫名其妙的赋值现在看得非常合理了,中间的小循环是进行了一大堆的赋值运算,还有 vector 的初始化和一些操作,顺便手动识别了一些与 vector 相关的函数。

看到线程2创建的例程,上面分析识别了符号之后,结果如下,还是比较清晰的。

如果外面传进来的线程不为 PsInitialSystemProcess,那么挂靠外面传进来的进程,这里通常执行不到,外面的 Context 传进来了三个 Event,Event[0~1] 是线程创建的,Event[2] 是全局变量,外面线程赋值的。
主要看这里 vector 的处理,如果 vector 不为空,则取出最后一个值,vector 的元素类型是 24 字节大小的对象,随后将 second-first 的值加到 context 的 value 中,这个 value 会在线程 2 中使用 DbgPrint 打印出来,这里因为同时创建了很多线程,因此访问 vector 加了锁。
最后使用一个函数加判断,这个函数在线程例程 2 中被赋初值。两个 event 任意一个正确被 set 为信号态,则退出,中间执行了一个 function 函数在线程例程 2 中被赋值,来观察这里的逻辑,深入了解之后断定该线程逻辑是去检查由其它地方上报的虚拟内存地址和范围,最后去调用对应例程。
data 结构体如下,暂且还不清楚第三个字段的作用
1 2 3 4 5 6 | struct __unaligned __declspec(align(4)) data{ __int64 startAddress; __int64 endAddress; __int32 third;}; |
直接看到 function 函数。

第一个判断就是判断 check 的内存是否和之前分配的内存重合,如果重合则不进行 check,猜测在检查的时候应该要对这个内存读写,如果 check 本身内存可能出现一些不可预计的错误。
随后检查一些基本参数正确性调用另一个函数操作,传参了之前分配的内存和检查的内存基址和大小。

根据 Windows 的版本选择合适的方式去调用例程,低版本使用 DPC 方式去调用该例程。

而高版本则使用了 IPI 的方式去调用该例程。

调用的对应例程如下:

这个地方不难看出,其实就保证两点:
来看看 IPI 参数的 function,在最开始判断windows版本地方赋值的,其中多次用到了参数聚合和参数分离,建议还原结构体,看的更清晰。

sub_14003E9D3 就是一个类似 memcpy,第四个参数返回成功写入的字节。

分析到这里之后,线程6的逻辑就结束了,随后把 vector 中的数据 size 加到 Context->value 中做一个统计,读取到的内存也会在这里释放掉,没有后续操作。

线程2后续只有一个对该 value 的一个输出,没有其它操作了,vector 的数据来自于全局变量,全局变量在通信中被引用。
驱动和三环使用 minifilter 的端口通信,输入数据使用了两次简单的异或加密,输出数据并未加密,该驱动有 72 个功能函数。
因为创建通信端口的代码已经明了,IDA 已经可以识别出回调函数。

根据连接回调函数,可以写出连接的应用程序代码。
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 | struct ConnectionContext { DWORD magic; // 必须等于多字符字面量 'FUCK' DWORD number; // 必须等于 8 BYTE key1[16]; // 16 字节密钥数据 BYTE key2[16]; // 16 字节密钥数据};NTSTATUS status;HANDLE hPort = NULL;// 构造连接上下文(总共 40 字节)ConnectionContext context = { 0 };context.magic = 'FUCK'; // 多字符字面量,保证和驱动端检查相符context.number = 8;memset(context.key1, 0x11, sizeof(context.key1));memset(context.key2, 0x22, sizeof(context.key2));// 连接到驱动创建的通信端口,端口名称需与驱动侧使用的匹配(\\OWNeacSafePort)status = FilterConnectCommunicationPort( L"\\OWNeacSafePort", // 通信端口的名称 NULL, &context, // 指向连接上下文数据的指针 sizeof(context), // 上下文数据的大小(40字节) NULL, // 保留参数,一般使用 NULL &hPort // 返回的端口句柄);if (!NT_SUCCESS(status)) { printf("连接通信端口失败, NTSTATUS: 0x%08X\n", status); return 1;}printf("成功连接到通信端口!\n"); |
通信过程中存在两个动态密钥和两个常量密钥,看到通信过程的回调。

这里要求了输入长度必须是 16 的倍数,经历了两次解密,第一个是常量密钥的解密。

全部使用 XMM 浮点指令实现,IDA比较难识别,这里通过模拟执行观察输入输出以及查阅 x86 手册使用 C 语言还原了这段代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | void decrypt(char* in, char* out, int idx) { for (int i = 0; i < 4; i++) { UINT32 value = *(UINT32*)&out[i << 2]; UINT32 value2 = 0; for (int j = 0; j < 4; j++) { value2 = value2 << 8; value2 |= key2[j]; } UINT32 value3 = 0; for (int j = 0xC - i; j < 0xC - i + 4; j++) { value3 = value3 << 8; value3 |= (unsigned char)(key1[j]^idx); } *(UINT32*)&out[i << 2] = value ^ (value2 | value3); }} |
第二个动态密钥的解密很简单,就是一个 16 字节的异或。
加密和解密可以用同一个函数,虽然或运算是理论不可逆的,但是或运算的两个量在已知 idx 的情况下都是常量。

随后解密的数据的第一个字节,会作为调用号,调用该驱动的函数表,总共有 72 个功能函数,函数例程为
1 2 3 | NTSTATUS function(char *DecryptData,size_t len,char *OutputBuffer,size_t outputlen,size_t *ReturnOutputBufferLength){ ;} |
分析顺序不按号码顺序。
跨进程读数据在 9 号功能表中。

根据示例函数构建数据
1 2 3 4 5 6 7 | void* Function9Item(DWORD ProcessId,void *SourceAddress,DWORD bytes,size_t *outlength) { auto request = { 9,ProcessId,SourceAddress,bytes }; auto mem=VirtualAlloc(NULL, 0x1000,...); memcpy(mem, &request, sizeof(MEMORY_READ_REQUEST)); *outlength = sizeof(MEMORY_READ_REQUEST); return mem;} |
随后加密数据与 minifilter 通信,尝试读取记事本中的一串内存,看看能否成功。
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 | size_t len;BYTE *mem=(BYTE*)Function9Item(0x3C, (void*)0x2C54D86A440, 16, &len);printf("源字节:");for (int i = 0; i < len; i++) { printf("%02X ", mem[i]);}putchar(10);for (int i = 0; i < len; i += 16) { decrypt(mem + i, mem + i, i / 16); for (int j = 0; j < 16; j++) { mem[i + j] =~ mem[i + j] ^ key3[j]; }}printf("目标字节:");for (int i = 0; i < len; i++) { printf("%02X ", mem[i]);}putchar(10);fflush(stdout);const ULONG outBufferLength = 256;BYTE outBuffer[outBufferLength];ULONG bytesReturned = 0;status = FilterSendMessage( hPort, mem, len, outBuffer,outBufferLength, &bytesReturned );if (!NT_SUCCESS(status)) { printf("FilterSendMessage 调用失败, NTSTATUS: 0x%08X\n", status);}else { printf("FilterSendMessage 成功, 返回字节数: %lu\n", bytesReturned); for(int i=0;i<bytesReturned;i++){ printf("%02X ",outBuffer[i]); }} |
输出结果:

查询进程信息在 8 号功能表中。

输入就是一个pid,构建好数据即可,可供返回的信息有:
BreakOnTermination 标志输出结构体为:
1 2 3 4 5 6 7 8 9 10 11 | struct __unaligned FunctionItem8_OutBuffer{ DWORD InheritedFromProcessId; ULONGLONG CreateTime; ULONGLONG KernelTime; ULONGLONG UserTime; DWORD SessionId; BOOLEAN IsWow64; BOOLEAN BreakOnTermination; ULONGLONG DebugPort;}; |
如法炮制,照样解析,还是以记事本为例,由于创建时间这种字段比较难以验证,可以验证的字段有:是否为32位程序,父进程id,调试端口和sessionid。

验证发现结果是正确的。
是直接去寻找 KPROCESS 结构对应的信息。

输入也是一个 pid,构造好输入直接发送,可以在输出缓冲区拿到 WCHAR 的映像路径,这个路径似乎反映了当前进程的工作目录(PWD)。

选个 cmd 验证一下,发现果然如此。

输入同样只有一个 pid,输出是一个 WCHAR 数组,里面包含了该进程的启动命令。

输入同样只有一个 pid,输出是一个 WCHAR 数组,里面包进程的映像路径。

输入为 pid 和一个虚拟内存地址,返回如下结构:
1 2 3 4 5 6 | struct FunctionItem4_OutBuffer{ __int64 BaseAddress; __int32 Offset; char IsWow64;}; |
查询的内存属性一定得是映射的内存(0x1000000)。

随后解析 PE 头获取入口点的文件偏移(FOV),并获取是否为 32 位的PE文件。

这个跟4号功能结合起来可以达到在任意地址中寻找对应的模块路径。

没有参数,使用内核API直接返回完成

该功能提供两个 char 类型,去写 qword_140216AA8 指向的某个结构体中的指定的偏移字节中写上指定的字节(具体功能需要更深入地分析)。

没有参数,固定返回 16 字节的GUID。

可以使用 bcdedit.exe 命令验证

经过测试,甚至不能读写 R0 的数据,只能本进程实现类似 memcpy 函数的功能,输入依次为

这里编写程序去测试,新建两个内存段赋上不同的值。
1 2 3 4 5 6 7 8 | PVOID test1, test2;test1=VirtualAlloc(NULL, 0x1000, ...);test2=VirtualAlloc(NULL, 0x1000, ...);memset(test1, 0xFF, 0x1000);memset(test2, 0xDD, 0x1000);BYTE* mem = (BYTE*)Function70Args(test1,test2,0x100,&len); |
结构构造
1 2 3 4 5 6 7 | void* Function70Args(PVOID dest, PVOID src, SIZE_T len, size_t* outlength) { ARGS request = { 70,dest,src,len }; PARGS mem = (PARGS)VirtualAlloc(NULL,0x1000,...); memcpy(mem, &request, sizeof(ARGS)); *outlength = sizeof(ARGS); return mem;} |
最后输出两段内存
1 2 3 4 5 6 7 8 9 10 11 12 13 | if (!NT_SUCCESS(status)) { printf("FilterSendMessage 调用失败, NTSTATUS: 0x%08X\n", status);}else { printf("FilterSendMessage 成功, 返回字节数: %lu\n", bytesReturned); Function70OutbufferProcess(outBuffer, bytesReturned); for (int i = 0; i < 0x10; i++) { printf("%02X ",((unsigned char*)test1)[i]); }putchar(10); for (int i = 0; i < 0x10; i++) { printf("%02X ", ((unsigned char*)test2)[i]); }putchar(10);} |
测试结果:

最后也是研究了一下为什么不能读取内核数据,在内部实现的过程中,获取了线程的 PreviouseMode,而调用这个函数的 PreviousMode 通常是用户层,在实现锁页时调用了 MmProbeAndLockPages(PoolWithQuotaTag, AccessMode, Operation);,导致了访问违例。但是注意到对于拷贝的目的地址只进行了基本的 MmIsAddressValid 去检查,因此这里可以给一个内核地址达到写内核内存的目的,但是只能去写可写的内存,如果去写只读内存可能导致蓝屏。
没有参数,且本地无法成功调用,暂时无法确定具体用途。

输入为
目前未观察到任何返回的数据,仅仅在找不到指定进程时返回错误,其余情况均返回 0 且返回数据长度也为 0。

可以见得,不论 FindPhysicalMemory 返回何值,最终返回都为 0,且不会有任何数据返回用户层,但是特殊的情况下,内核层会调用 DbgPrint 打印虚拟地址的解析信息。

初步推断为方便调试的功能。
参数只有一个 pid。

根据进程 id 去附加该进程得到页目录基址,随后分配新的虚拟页将该页的物理地址设置为对应的 cr3。

测试结果:

HalPrivateDispatchTable 及其附近的指针是否有效(66)没有参数,输出值仅为一个 BOOLEAN 值。

跟前面的跨进程读数据简直一模一样,只不过把两个 Process 顺序给交换了,达成跨进程写的目的。

不过这里写入的字节是通过 outbuffer 传递的,需要注意一下,效果:

可以发现,目前实现的一些功能都有一定的限制,无法直接做到更深层次的利用,比如能够任意的读写内核数据,但是内核中有个标志位可以临时将驱动强制签名关闭,那样便可以达到加载无签名驱动的目的,而关键的判断则是 ci.dll 中的某个全局变量

当 g_CiOptions==0 时,可以加载任何签名,g_CiOptions==6 时,驱动必须强制签名,g_CiOptions==8 时,可以使用测试签名,所以这里在应用层用 NtQuerySystemInformation 获取 ci.dll 模块的基址,再根据偏移将该值写入 0 达到绕过驱动强制签名的限制。理论上,实时读写 system32 下对应的 ci.dll 然后下载 pdb 解析可以实现通杀。
1 2 3 | /*无码*/ |
成功利用的截图:

经本地测试,该值的修改似乎不会被 PG 蓝屏,而且即使会蓝屏,那么PG也是存在一定时间差的,修改完成加载自己的驱动后可以及时改回去。
本篇文章于一年前完成,相关组件版本均已更新较多,遂公开本篇分析报告供学习,若有侵权请联系删除。
更多【二进制漏洞-某FPS游戏反作弊分析】相关视频教程:www.yxfzedu.com