【软件逆向-某美国大片题材游戏反作弊分析】此文章归类为:软件逆向。
该反作弊使用VMP进行保护,故此使用了很多时间进行对抗。
本人第一次做反作弊分析,如有不当还望各位大佬帮我指正。
先看整体内核反作弊功能简化图
在R3上,其使用该dll以及与R0驱动的协作来达到反作弊的目的,其功能总结如下:
由于我只在虚拟机外面挂了windbg来调试,所以只需要稍微绕过一下即可。这里总结一下,只需要绕过NtQuerySystemInformation的查询和单步调试的陷阱就可以了。
众所周知,在r3下也可以检测r0是否挂载调试器是通过NtQuerySystemInformation这个函数来实现的,其中当第一个参数SystemKernelDebuggerInformation(0x23) 时则会返回一个结构,结构如下。
1 2 3 4 5 | typedef struct _SYSTEM_KERNEL_DEBUGGER_INFORMATION { BOOLEAN DebuggerEnabled; BOOLEAN DebuggerNotPresent; } SYSTEM_KERNEL_DEBUGGER_INFORMATION; |
其中第一个成员为1,第二个成员为0的时候则是挂了调试器。我们只需要对此函数下条件断点,修改返回值即可。
当绕过查询时,windbg会单步断到一个nop指令的地方,不过我并没有任何调试行为,这里即为一个陷阱检测点。
调整一下windbg的配置即可。
然后,然后就得到了经典vmp的虚拟机检测
vmp的虚拟机检测大致使用cpuid指令与查询枚举系统FirmwareTables来检测的。
关于cpuid的检测直接在vmx中加入hypervisor.cpuid.v0 = "FALSE" 即可。
有关枚举FirmwareTables,我们可以得到2个关键api,EnumSystemFirmwareTables、 GetSystemFirmwareTable。
于是进而逆向一下这俩玩意,结果如下。
可以发现都是调用NtQuerySystemInformation,但是其结构SYSTEM_FIRMWARE_TABLE_INFORMATION的Action不一样罢了,于是我们直接下SSDTHook拦截这个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 26 27 28 29 30 31 | NTSTATUS MyNtQuerySystemInformation( ULONG InfoClass, PVOID Buffer, ULONG Length, PULONG ReturnLength ) { NTSTATUS retV = pNtQuerySystemInfomation(InfoClass, Buffer, Length, ReturnLength); if (InfoClass == 0x23) { *( char *)Buffer = 0; *(( char *)Buffer + 1) = 1; DBG_PRINT( "拦截一次查询调试器\n" ); } if (InfoClass == 0x4c && (retV == 0)) { if (((SYSTEM_FIRMWARE_TABLE_INFORMATION*)Buffer)->TableBufferLength > 0x32) { if ( memcmp ((( char *)Buffer) + 0x32, "VM" , 2) == 0) { DBG_PRINT( "拦截一次查询虚拟机,大小为:%d\n" , ((SYSTEM_FIRMWARE_TABLE_INFORMATION*)Buffer)->TableBufferLength); for ( size_t i = 0; i < ((SYSTEM_FIRMWARE_TABLE_INFORMATION*)Buffer)->TableBufferLength; i++) { char * data = (( char *)Buffer) + i; if ( data[0] == 'V' &&data[1]== 'M' &&data[2]== 'w' &&data[3]== 'a' &&data[4]== 'r' &&data[5]== 'e' ) { memcpy (data, "moshui" , 6); } } } } } return retV; } |
于是我们得到了
太棒了忘记装DX了
其中首先判断了驱动是否已经加载,没有加载的话就先获取驱动名字(由于驱动名字是随机的,但是这里被vm了),然后就是正常的用服务加载驱动,并把符号链接名字设置为一个环境变量(NEP_SVC_NAME),然后删除文件,删除服务
IsKernelLoaded、GetNepKernelName、DeleteKernelFile是我自定义的函数名字。
这玩意在另一个函数里,我命名为nep_IsKernelLoad
这个函数会使用IsKernelLoaded函数先判断是否驱动已加载,没有加载的话会创建服务。
其中使用CreateFileW来尝试创建驱动的FileHandle来判断是否加载
重量级函数,这玩意被vm了分析不了下一个,但是为什么是这个函数呢,看IsKernelLoaded分析猜测一下,就能发现这个是获取驱动的符号链接名字。
这里面漏了创建到tempfile文件夹底下的驱动的文件名字的生成函数,其中使用winapi GetUserNameA获取了用户名与一个常量作为生成驱动文件名字的生成子,生成了一个文件名,然后用这个文件名去正常的DeleteFileW去删文件。
生成函数如下
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 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 | unsigned __int64 __fastcall GenerateKernelName(unsigned __int8 *a1, unsigned __int64 a2, __int64 a3) { unsigned __int8 *v3; // r9 unsigned __int64 v4; // r10 unsigned __int8 *v5; // r11 __int64 v6; // rcx unsigned __int64 v7; // rcx __int64 v8; // rdx __int64 v9; // rdx __int64 v10; // rdx __int64 v11; // rdx __int64 v12; // rdx __int64 v13; // rdx unsigned __int64 v14; // rcx v3 = a1; v4 = a3 ^ (0x880355F21E6D1965ui64 * a2); v5 = &a1[8 * (a2 >> 3)]; if ( a1 != v5 ) { do { v6 = *v3 ^ (*v3 >> 23); v3 += 8; v4 = 0x880355F21E6D1965ui64 * ((0x2127599BF4325C37i64 * v6) ^ ((0x2127599BF4325C37i64 * v6) >> 47) ^ v4); } while ( v3 != v5 ); } v7 = 0i64; v8 = (a2 & 7) - 1; if ( !v8 ) goto LABEL_16; v9 = v8 - 1; if ( !v9 ) { LABEL_15: v7 ^= v3[1] << 8; LABEL_16: v14 = 0x2127599BF4325C37i64 * (v7 ^ *v3 ^ ((v7 ^ *v3) >> 23)); v4 = 0x880355F21E6D1965ui64 * (v14 ^ (v14 >> 47) ^ v4); return (0x2127599BF4325C37i64 * (v4 ^ (v4 >> 23))) ^ ((0x2127599BF4325C37i64 * (v4 ^ (v4 >> 23))) >> 47); } v10 = v9 - 1; if ( !v10 ) { LABEL_14: v7 ^= v3[2] << 16; goto LABEL_15; } v11 = v10 - 1; if ( !v11 ) { LABEL_13: v7 ^= v3[3] << 24; goto LABEL_14; } v12 = v11 - 1; if ( !v12 ) { LABEL_12: v7 ^= v3[4] << 32; goto LABEL_13; } v13 = v12 - 1; if ( !v13 ) { LABEL_11: v7 ^= v3[5] << 40; goto LABEL_12; } if ( v13 == 1 ) { v7 = v3[6] << 48; goto LABEL_11; } return (0x2127599BF4325C37i64 * (v4 ^ (v4 >> 23))) ^ ((0x2127599BF4325C37i64 * (v4 ^ (v4 >> 23))) >> 47); } |
使用winapi函数QueryServiceStatus来查询驱动服务的状态
使用winapi函数ControlService来停止驱动服务
先说结论,他会以一个线程的信息作为最小单位进行信息收集,同时会关注csrss.exe
进程的pid信息。
其使用拍摄快照的方式来枚举所有线程信息,并获取线程上下文(CONTEXT),根据线程信息来获取他们的进程信息与线程起始地址相对模块的偏移,并加入到一个集合中(std::set)。
其监控保存数据的结构体如下
1 2 3 4 5 6 7 8 9 10 11 12 | struct ThreadInfo { DWORD threadID; DWORD ownProcessID; QWORD Dr0; QWORD Dr1; QWORD Dr2; QWORD Dr3; QWORD Dr7; PVOID threadStartAddrOffset; std::string *procInfo; } |
其关键函数图如下(已经逆向修过结构体和合理标注名称)
其中值得注意的就是GetThreadStartAddr
与GetProcessInfo
函数。这个是我自己重命名的。
就是使用NtQueryInformationThread来获取线程的起始地址。
通过线程获取到的pid进而获取进程句柄,通过获取的句柄来枚举进程内的全部模块,通过获取每个模块的详细信息来暴力扫描一个线程起始地址处在模块中的偏移与该模块名称。
其会拍摄进程的快照,并且在确认是csrss后,把pid加入到一个数组中。
使用进程名称与进程权限来判断是否为真正的csrss进程,其使用OpenProcess函数来尝试打开,如果获取不到句柄则判断为真正的csrss进程。
其获取csrss的进程的作用其实是为了给他白名单,让他可以正常打开被保护进程的句柄进行操作,也就是不对该进程获得的句柄进行降权。
其中会使用EnumProcessModules函数来枚举自己的模块,然后把模块名字放到一个数组中。
其中会检测一个文件的证书合法性和获取证书信息。
其中核心函数便是CheckAndGetSignInfo,其中会使用winapi WinVerifyTrust 来验证证书的有效性,使用GetCertInfo函数(自定义)获取证书的详细信息,在此函数中使用winapi CryptQueryObject。
转至GetCertInfo函数,其中会先使用属性值CMSG_SIGNER_COUNT_PARAM来获取证书的数量,然后使用属性值CMSG_SIGNER_INFO_PARAM获取证书的信息,存在一个自定义的结构中。
结构如下:
1 2 3 4 5 6 | struct nep_SignerInfo { HCERTSTORE certStore; CMSG_SIGNER_INFO *signInfo; DWORD signInfoSize; }; |
然后使用一个函数解析证书的详细信息,并存在一个pair中。
此函数会在导出函数l11ll11l11l1中被调用
此时转至l11ll11l11l1中进行分析,此函数中有部分函数被vm了,但是从上下文关系来分析,可以关注到\\?\GLOBALROOT这样一个特殊的字符串和 StrRChrA、lstrcmpi、MultiByteToWideChar等api,可以猜测到其中使用Windows 对象命名空间的一个根目录的方式来寻找文件,并检测文件的证书。
使用WMI实现对电脑信息的搜集,其中搜集了操作系统信息
WQL_SELECT就是一个获取WMI对象的函数,通过把传入的wstring拼接上SELECT * FROM 来到查询语句的目的。
R0其实内容比较少,但是对抗vmp是比较恶心的。
R0的功能总结如下:
由于该驱动被vmp保护了,所以要想分析就得对抗一下。总的来说呢要想搞vmp基本上就得用模拟器,这里用KACE来完成模拟。
驱动这里没有开虚拟机检测、调试器检测。所以这里只说一下怎么dump
要想对被VMP过的驱动dump,我们肯定是要了解一下vmp的压缩保护的释放过程。
这里只针对R0的进行讲述。
那么如果我们想要进行dump,无疑是在最后KeQueryActiveProcessors处进行dump,因为其只会被调用一次,而且是在解压修复完成后,调用入口点前。
知道了此事我们就可以通过修改模拟器中的API实现函数来达到dump驱动的效果了。
首先值得说的是MmGetSystemAddressForMdlSafe函数并不是一个内核导出函数,而是一个定义在wdm.h中的函数。
其定义如下(有修改):
1 2 3 4 5 6 7 8 | __forceinline void * MmGetSystemAddressForMdlSafe (PMDL Mdl, ULONG Priority){ if (Mdl->MdlFlags & (MDL_MAPPED_TO_SYSTEM_VA | MDL_SOURCE_IS_NONPAGED_POOL)) { return Mdl->MappedSystemVa; } else { return MmMapLockedPagesSpecifyCache(Mdl, KernelMode, MmCached, NULL, FALSE, Priority); } } |
可以看出其实是调用了内核导出函数MmMapLockedPagesSpecifyCache来进行映射的。
由于笔者是在模拟器当中运行,原始模拟器并没有提供有关实现,由此需要自行实现该功能。
在实现时,笔者一开始单纯的分配了一块内存作为返回值,结果获得了一个异常。
这时观察一下汇编,便可以捋出来一串汇编。
观察一下就可以发现,这玩意是CRC32校验。
如下是网上找到的CRC32校验算法,对比汇编简直一模一样。
1 2 3 4 5 | Crc = 0xffffffff; for (Index = 0, Ptr = Data; Index < DataSize; Index++, Ptr++) { Crc = (Crc >> 8) ^ mCrcTable[(UINT8) Crc ^ *Ptr]; } *CrcOut = Crc ^ 0xffffffff; |
此时笔者怀疑是没有正确解压出源程序导致的CRC校验异常,于是去分配的地址看了一下。
令人以外的是,竟然正确解压出了源程序。故此可以推断出应该是在解压后还有校验。
不过依然无法解释为什么当时的异常是读取到了0,后面经过查询与试验,发现MmMapLockedPagesSpecifyCache函数的返回值是使用MDL映射出来的内存,败在了映射二字。
其意为把MDL中内存的物理地址再分配一个虚拟地址,也就是其实与MDL传入的StartVa是共享同一个物理页的,当修改了一个另一个也会改,壳子改了映射的虚拟内存上的值,使用StartVa的地址来CRC校验。使用需要将2个内存的值同步才可以模拟。
在DriverEntry中调用了KdDisableDebugger函数来剥离调试器
其中会在上文提到的qsec.dll中,使用IRP包进行通信,利用DeviceIoControl来进行调用。
其中所有的包都具有一个通用的通信包头结构,结构如下:
1 2 3 4 5 | struct Nep_IRP_PacketHeader { DWORD unknown_1; DWORD tag; //'_NEP' } |
其在游戏开始后,并无与驱动的常态通信,其通信主要集中在游戏的启动阶段。
使用的通信码如下,其中有很多功能是并无使用到的。
在创建完设备后,第一次将使用此通信码传输4字节0,具体作用未知。
在0x22640调用完毕后,使用该通信码给不进行句柄降权的白名单添加pid。会多次调用。
其结构如下:
1 2 3 4 | struct Nep_IRP_ReportWhitePid:Nep_IRP_PacketHeader { DWORD pid; } |
在多次调用0x22642C后,使用该通信码把pid加入到保护进程的列表中。
其结构如下:
1 2 3 4 | struct Nep_IRP_ReportSelfPid:Nep_IRP_PacketHeader { DWORD pid; } |
该回调函数会调用Nep_ReportProtectPid2ProtectList函数,加入到保护进程的链表中。
在上报完需要保护的pid后,使用该通信码获取当前电脑中的进程列表。
其结构如下(该结构将在后续内容中被详细说明):
1 2 3 4 5 6 | struct Nep_IRP_EnumProcessList:Nep_IRP_PacketHeader { DWORD procNum; DWORD len; NepProcInfo *pBuff; } |
其中两次使用该通信码,首先获得所有进程数量,然后分配缓冲区后,再次调用获得具体信息。
其中通过回调来进行,该驱动使用PsSetCreateProcessNotifyRoutineEx与PsSetLoadImageNotifyRoutineEx,注册了进程与加载模块的回调函数,通过遍历全局保护链表,匹配pid,分析行为来修改全局保护链表。
所以总结一下:全局保护链表中的进程是由IOCTL传递进来的和父进程创建子进程继承而来的。
该回调首先判断其是否是创建新进程,当是创建新进程时,会获得其父pid并在保护进程的链表中寻找,如果在其中,则会调用一个函数(被vm)笔者猜测为把新进程的pid加入保护进程链表的函数。
当其中为终止进程的时候,如果其在保护进程的链表中,则把他删去并释放内存。如果保护进程的链表为空,则会使用WorkItem来自卸载。
其中进程链表tag为’PROC',其结构如下:
自卸载其实使用的就是ZwUnloadDriver
其中Nep_SetLoadPropInProtectProcList函数就是设置结构中isLoad字段为1
其中会在加载模块的回调中进行检测,使用CiValidateFileObject 函数来返回其签名的信息。
首先通过文件名打开本地的文件,获取文件对象,使用CiValidateFileObject函数获取签名信息,返回并回收引用的文件对象关闭文件句柄。
首先依然是从签名入手,第一个传入的模块名字,第二个则是返回的信息数组。
其结构如下。
值得一说的是第一个成员表示的是签名的算法类型,比如0x800c 是SHA256, 0x8004 是SHA1。
关键代码如下,不过这里的变量有点问题,大致逻辑是对的。
就是首先打开文件,获得文件句柄,通过文件句柄获得文件对象,通过文件对象使用函数CiValidateFileObject直接获得签名信息,然后释放资源(后面有关闭句柄和解除引用的部分只是没截到图中)
上文中对于签名的获取CiValidateFileObject是个位于CI.dll中的函数,这里其使用ZwQuerySystemInformation函数获取CI.dll的地址,并使用Nep_GetExportFuncAddr函数根据名字获取函数地址,保存到全局变量中。
我们按照代码来看,很明显通过ZwQuerySystemInformation先获得大小再获得模块信息,后面则是遍历模块。
其中在模块名字为CI.dll时跳出循环。
后面则是调用Nep_GetExportFuncAddr函数获得函数的地址,并保存。
其中就是解析PE的导出表通过对比函数名字的方式获得函数地址。
其在驱动加载后使用ObRegisterCallbacks注册了进程句柄(PsProcessType) 和 线程句柄(PsThreadType) 来实现对于句柄的降权。
我们可以通过ARK工具来看到其降下了哪些权限。
不难发现,其中没有的权限为:
我们给CE升一下权限就可以正常使用了,而且被提升的句柄权限是永久的。
然后其降权回调是受保护的,保护方式非常的暴力,通过一个线程循环注册回调,这波啊是有循环在身上的。
其中有个白名单链表,在白名单中的进程获取被保护进程的句柄时,不会被降权。(内存tag为‘WPRO’)
其结构大致为:
1 2 3 4 5 6 | struct Nep_WhiteList { HANDLE pid; __int64 unknown; LIST_ENTRY entry; }; |
当然的有白名单,就有从白名单删去进程的函数,不难发现其中应该是通过Irp进行的,除了Nep_IRP_PacketHeader则为pid。
但是这里有个问题,释放的时候与分配的内存tag不一样啊!
值得注意的是此函数并未被调用过。
qsec.dll通过IOCTL来调用 NepEnumProcessListImp,在其中作为一个分发函数,决定是返回缓冲区大小还是返回真正的信息,不过无论如何都是调用NepEnumProcessList函数完成获取。
其中IRP通信的原始包结构逆向分析结果如下:
1 2 3 4 5 6 | struct Nep_IRP_EnumProcessList:Nep_IRP_PacketHeader { DWORD procNum; DWORD len; NepProcInfo *pBuff; } |
其中很明显,当包文中缓冲区长度字段为0时,将会获取所需缓冲区长度。而长度字段和缓冲区字段都符合要求时通过NepEnumProcessList函数获取具体信息。
该函数使用ZwQuerySystemInformation的SystemProcessInformation来获取当前所有进程,并遍历所有进程,获取PID、EPROCESS并调用NepGetFileDosNameByEProcess获取具体信息。
从函数签名入手
除了第一个其他参数都清晰明了。第一个参数是用来返回所有进程信息的,结构是通过逆向得出的。结构如下
1 2 3 4 5 6 | struct Nep_IRP_EnumProcessList:Nep_IRP_PacketHeader { DWORD procNum; DWORD len; NepProcInfo *pBuff; } |
附上一张动调的真实图
函数一开始是很标准的2步ZwQuerySystemInformation,先获取了结构大小,然后获得了SYSTEM_PROCESS_INFORMATION结构。
非常谨慎的计算了缓冲区大小是否够用。
然后进行分配了进程名字的缓冲区用于获得进程名字。接着就是核心函数,代码如下
其中使用pid获取了进程的EPROCESS,进而使用EPROCESS去获取FileDosName,并且把他由UNICODE编码转为Ascii编码(大小为2:1的关系),保存到NepProcInfo中,这里还存了SYSTEM_PROCESS_INFORMATION结构下的Reserved2(具体用途未知),与EPROCESS。并释放了由于NepGetFileDosNameByEProcess对于EPROCESS Silo 的引用。
该函数为NepEnumProcessList的配套函数,其使用Eprocess获取文件名(含路径)。
由于ida看着比较乱我整理了一下。
看起来还是通俗易懂的,基本上就是用了IoQueryFileDosDeviceName来获取文件名。
通过调用NepEnumKernelModuleListImp来获取内核模块信息。首先通过调用NepGetNumberOfModules获得模块数量,然后通过调用NepGetKernelModules_v(被VM保护)函数获得具体信息,放置到缓冲区中返回。
该功能应由某IoControlCode调用,其结构如下:
1 2 3 4 5 6 | struct Nep_IRP_EnumKernelModuleList:Nep_IRP_PacketHeader { DWORD moduleNum; DWORD buffLen; void *pBuff; } |
这个函数是调用了Aux库中的函数:AuxKlibQueryModuleInformation通过枚举出模块来计算数量。浅逆一下AuxKlibQueryModuleInformation吧。
先看函数签名
值得一提的是这里着重说明一下第二个和第三个参数。
第二个参数 援引微软文档:QueryInfo 指向的数组的每个元素的大小(以字节为单位)。 此值必须 sizeof(AUX_MODULE_BASIC_INFO)或 sizeof(AUX_MODULE_EXTENDED_INFO)。AUX_MODULE_BASIC_INFO结构仅获得模块首地址。
NepModuleInfo这个结构即为AUX_MODULE_EXTENDED_INFO,该信息保存了内核模块的部分重要信息。通过逆向标记的结构如下
aux_klib.h中的定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 | #define AUX_KLIB_MODULE_PATH_LEN 256 typedef struct _AUX_MODULE_BASIC_INFO { PVOID ImageBase; } AUX_MODULE_BASIC_INFO, *PAUX_MODULE_BASIC_INFO; typedef struct _AUX_MODULE_EXTENDED_INFO { AUX_MODULE_BASIC_INFO BasicInfo; ULONG ImageSize; USHORT FileNameOffset; UCHAR FullPathName [AUX_KLIB_MODULE_PATH_LEN]; } AUX_MODULE_EXTENDED_INFO, *PAUX_MODULE_EXTENDED_INFO; |
了解了结构后,来详细说明一下函数实现。
首先会判断Aux库是否加载好,如果正常的话会直接使用AuxKlibInitialize初始化好的RtlQueryModuleInformation来获取信息。
贴个AuxKlibInitialize代码吧
如果没有的情况下会使用函数ZwQuerySystemInformation搭配SystemModuleInformation来实现枚举,不过这次直接用SYSTEM_MODULE_INFORMATION的大小作为size查询了,只使用了一次ZwQuerySystemInformation。
后面呢则是遍历查询出来的数组,把模块基地址、模块大小、路径大小、和名字赋值给了缓冲区。值得注意的是当该函数的第二个参数nepModuleInfoLen的值为8的时候则是只获取模块基地址,272时才是获得NepModuleInfo这个结构。
该功能应由某IoControlCode调用,其结构如下:
1 2 3 4 5 6 | __declspec (align(4)) struct Nep_IRP_LoadImageList:Nep_IRP_PacketHeader { DWORD moduleNum; DWORD buffLen; void *pBuff; } |
其主要调用NepGetLoadImageList_v(被vm)的函数进行获取,鉴于未使用则不做分析。
超级感谢@Qfrost与@xi@0ji233的支持和指导!
更多【软件逆向-某美国大片题材游戏反作弊分析】相关视频教程:www.yxfzedu.com