写在最前
该反作弊使用VMP进行保护,故此使用了很多时间进行对抗。
本人第一次做反作弊分析,如有不当还望各位大佬帮我指正。
先看整体内核反作弊功能简化图
Ring3——qsec.dll
在R3上,其使用该dll以及与R0驱动的协作来达到反作弊的目的,其功能总结如下:
- NtQuerySystemInformation通过查询SystemKernelDebuggerInformation以及使用陷阱(无效句柄、设置TF标志位)来反调试
- 使用cpuid指令来查询hypervisor状态以及CPU名字的方式来反虚拟机
- 使用枚举FirmwareTables的方式来反虚拟机
- 使用快照来进行线程信息收集
- 使用EnumProcessModules来进行枚举自身模块
- 使用WMI来进行电脑信息搜集
VMP对抗
反调试器
由于我只在虚拟机外面挂了windbg来调试,所以只需要稍微绕过一下即可。这里总结一下,只需要绕过NtQuerySystemInformation的查询和单步调试的陷阱就可以了。
详细分析
众所周知,在r3下也可以检测r0是否挂载调试器是通过NtQuerySystemInformation这个函数来实现的,其中当第一个参数SystemKernelDebuggerInformation(0x23) 时则会返回一个结构,结构如下。
1 2 3 4 5 |
|
其中第一个成员为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 |
|
于是我们得到了
太棒了忘记装DX了
驱动的加载
其中首先判断了驱动是否已经加载,没有加载的话就先获取驱动名字(由于驱动名字是随机的,但是这里被vm了),然后就是正常的用服务加载驱动,并把符号链接名字设置为一个环境变量(NEP_SVC_NAME),然后删除文件,删除服务
IsKernelLoaded、GetNepKernelName、DeleteKernelFile是我自定义的函数名字。
驱动服务创建
这玩意在另一个函数里,我命名为nep_IsKernelLoad
这个函数会使用IsKernelLoaded函数先判断是否驱动已加载,没有加载的话会创建服务。
IsKernelLoaded
其中使用CreateFileW来尝试创建驱动的FileHandle来判断是否加载
GetNepKernelName
重量级函数,这玩意被vm了分析不了下一个,但是为什么是这个函数呢,看IsKernelLoaded分析猜测一下,就能发现这个是获取驱动的符号链接名字。
DeleteKernelFile
这里面漏了创建到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 |
|
驱动服务的查询
使用winapi函数QueryServiceStatus来查询驱动服务的状态
驱动服务的停止
使用winapi函数ControlService来停止驱动服务
R3下对其他程序的监控
先说结论,他会以一个线程的信息作为最小单位进行信息收集,同时会关注csrss.exe
进程的pid信息。
对线程信息收集的分析
其使用拍摄快照的方式来枚举所有线程信息,并获取线程上下文(CONTEXT),根据线程信息来获取他们的进程信息与线程起始地址相对模块的偏移,并加入到一个集合中(std::set)。
其监控保存数据的结构体如下
1 2 3 4 5 6 7 8 9 10 11 12 |
|
其关键函数图如下(已经逆向修过结构体和合理标注名称)
其中值得注意的就是GetThreadStartAddr
与GetProcessInfo
函数。这个是我自己重命名的。
GetThreadStartAddr
就是使用NtQueryInformationThread来获取线程的起始地址。
通过线程获取到的pid进而获取进程句柄,通过获取的句柄来枚举进程内的全部模块,通过获取每个模块的详细信息来暴力扫描一个线程起始地址处在模块中的偏移与该模块名称。
csrss的关注
其会拍摄进程的快照,并且在确认是csrss后,把pid加入到一个数组中。
使用进程名称与进程权限来判断是否为真正的csrss进程,其使用OpenProcess函数来尝试打开,如果获取不到句柄则判断为真正的csrss进程。
其获取csrss的进程的作用其实是为了给他白名单,让他可以正常打开被保护进程的句柄进行操作,也就是不对该进程获得的句柄进行降权。
对自己的监控
其中会使用EnumProcessModules函数来枚举自己的模块,然后把模块名字放到一个数组中。
文件信息
其中会检测一个文件的证书合法性和获取证书信息。
CheckAndGetSignInfo
其中核心函数便是CheckAndGetSignInfo,其中会使用winapi WinVerifyTrust 来验证证书的有效性,使用GetCertInfo函数(自定义)获取证书的详细信息,在此函数中使用winapi CryptQueryObject。
转至GetCertInfo函数,其中会先使用属性值CMSG_SIGNER_COUNT_PARAM来获取证书的数量,然后使用属性值CMSG_SIGNER_INFO_PARAM获取证书的信息,存在一个自定义的结构中。
结构如下:
1 2 3 4 5 6 |
|
然后使用一个函数解析证书的详细信息,并存在一个pair中。
此函数会在导出函数l11ll11l11l1中被调用
此时转至l11ll11l11l1中进行分析,此函数中有部分函数被vm了,但是从上下文关系来分析,可以关注到\\?\GLOBALROOT这样一个特殊的字符串和 StrRChrA、lstrcmpi、MultiByteToWideChar等api,可以猜测到其中使用Windows 对象命名空间的一个根目录的方式来寻找文件,并检测文件的证书。
电脑信息的搜集
使用WMI实现对电脑信息的搜集,其中搜集了操作系统信息
WQL_SELECT就是一个获取WMI对象的函数,通过把传入的wstring拼接上SELECT * FROM 来到查询语句的目的。
- 对Win32_VideoController的搜集有Caption、PNPDeviceID
- 对Win32_DiskDrive的搜集有Caption、DeviceID、SerialNumber
- 对Win32_BaseBoard的搜集有Manufacturer、Product、Version、SerialNumber
- 对Win32_BIOS的搜集有ReleaseDate、Manufacturer、SerialNumber、Version
- 对Win32_PhysicalMemory的搜集有Manufacturer、SerialNumber
- 对Win32_Processor的搜集有Name、Description、Manufacturer、NumberOfCores、ThreadCount、MaxClockSpee
- 对Win32_NetworkAdapterConfiguration的搜集有MACAddress、Caption、Description、SettingID
- 对Win32_IDEController的搜集有Caption、PNPDeviceID
- 对Win32_LogicalDisk的搜集有Caption、VolumeName、VolumeSerialNumber、DriveType
- 对Win32_SCSIController的搜集有Caption、PNPDeviceID
- 对Win32_SoundDevice的搜集有Caption、PNPDeviceID、Manufacturer、ProductName
Ring0——Sys
R0其实内容比较少,但是对抗vmp是比较恶心的。
R0的功能总结如下:
- 使用KdDisableDebugger来反调试
- 使用线程加载回调与CiValidateFileObject来进行加载的模块信息收集
- 使用对象回调函数来进行句柄降权
- 使用额外的线程循环注册对象回调来保护执行句柄降权的回调
- 使用ZwQuerySystemInformation查询SystemProcessInformation来进行进程信息搜集
- 使用Aux库中AuxKlibQueryModuleInformation函数来进行内核模块信息搜集(未使用)
VMP对抗——Dump
由于该驱动被vmp保护了,所以要想分析就得对抗一下。总的来说呢要想搞vmp基本上就得用模拟器,这里用KACE来完成模拟。
驱动这里没有开虚拟机检测、调试器检测。所以这里只说一下怎么dump
要想对被VMP过的驱动dump,我们肯定是要了解一下vmp的压缩保护的释放过程。
这里只针对R0的进行讲述。
- 提供使用NtQuerySystemInformation函数获取ntoskrnl模块地址。
- 对自身进行一个内存的CRC校验。
- 使用MDL的方式进行内存映射锁页,通过MmGetSystemAddressForMdlSafe获取需要将要解压到的地址。
- 进行解压,然后一顿修。
- 再次检测CRC。
- 使用KeQueryActiveProcessors计算出CPU核心数量,再次使用KeSetSystemAffinityThread与KeRevertToUserAffinityThread配合CPUID指令计算出每个CPU核心的哈希值。
- 设置sessionkey,并调用解压后的入口点。
- 解锁页面,释放MDL。
那么如果我们想要进行dump,无疑是在最后KeQueryActiveProcessors处进行dump,因为其只会被调用一次,而且是在解压修复完成后,调用入口点前。
知道了此事我们就可以通过修改模拟器中的API实现函数来达到dump驱动的效果了。
踩过的坑
首先值得说的是MmGetSystemAddressForMdlSafe函数并不是一个内核导出函数,而是一个定义在wdm.h中的函数。
其定义如下(有修改):
1 2 3 4 5 6 7 8 |
|
可以看出其实是调用了内核导出函数MmMapLockedPagesSpecifyCache来进行映射的。
由于笔者是在模拟器当中运行,原始模拟器并没有提供有关实现,由此需要自行实现该功能。
在实现时,笔者一开始单纯的分配了一块内存作为返回值,结果获得了一个异常。
这时观察一下汇编,便可以捋出来一串汇编。
观察一下就可以发现,这玩意是CRC32校验。
如下是网上找到的CRC32校验算法,对比汇编简直一模一样。
1 2 3 4 5 |
|
此时笔者怀疑是没有正确解压出源程序导致的CRC校验异常,于是去分配的地址看了一下。
令人以外的是,竟然正确解压出了源程序。故此可以推断出应该是在解压后还有校验。
不过依然无法解释为什么当时的异常是读取到了0,后面经过查询与试验,发现MmMapLockedPagesSpecifyCache函数的返回值是使用MDL映射出来的内存,败在了映射二字。
其意为把MDL中内存的物理地址再分配一个虚拟地址,也就是其实与MDL传入的StartVa是共享同一个物理页的,当修改了一个另一个也会改,壳子改了映射的虚拟内存上的值,使用StartVa的地址来CRC校验。使用需要将2个内存的值同步才可以模拟。
反调试
在DriverEntry中调用了KdDisableDebugger函数来剥离调试器
通信
其中会在上文提到的qsec.dll中,使用IRP包进行通信,利用DeviceIoControl来进行调用。
其中所有的包都具有一个通用的通信包头结构,结构如下:
1 2 3 4 5 |
|
其在游戏开始后,并无与驱动的常态通信,其通信主要集中在游戏的启动阶段。
使用的通信码如下,其中有很多功能是并无使用到的。
0x226400——初始化
在创建完设备后,第一次将使用此通信码传输4字节0,具体作用未知。
0x22642C——添加白名单
在0x22640调用完毕后,使用该通信码给不进行句柄降权的白名单添加pid。会多次调用。
其结构如下:
1 2 3 4 |
|
0x226414——添加被保护的进程
在多次调用0x22642C后,使用该通信码把pid加入到保护进程的列表中。
其结构如下:
1 2 3 4 |
|
该回调函数会调用Nep_ReportProtectPid2ProtectList函数,加入到保护进程的链表中。
0x226420——查询进程信息
在上报完需要保护的pid后,使用该通信码获取当前电脑中的进程列表。
其结构如下(该结构将在后续内容中被详细说明):
1 2 3 4 5 6 |
|
其中两次使用该通信码,首先获得所有进程数量,然后分配缓冲区后,再次调用获得具体信息。
进程保护
其中通过回调来进行,该驱动使用PsSetCreateProcessNotifyRoutineEx与PsSetLoadImageNotifyRoutineEx,注册了进程与加载模块的回调函数,通过遍历全局保护链表,匹配pid,分析行为来修改全局保护链表。
所以总结一下:全局保护链表中的进程是由IOCTL传递进来的和父进程创建子进程继承而来的。
进程回调Nep_ProcessNotify
该回调首先判断其是否是创建新进程,当是创建新进程时,会获得其父pid并在保护进程的链表中寻找,如果在其中,则会调用一个函数(被vm)笔者猜测为把新进程的pid加入保护进程链表的函数。
当其中为终止进程的时候,如果其在保护进程的链表中,则把他删去并释放内存。如果保护进程的链表为空,则会使用WorkItem来自卸载。
其中进程链表tag为’PROC',其结构如下:
自卸载其实使用的就是ZwUnloadDriver
模块加载回调Nep_LoadImageCallback
其中Nep_SetLoadPropInProtectProcList函数就是设置结构中isLoad字段为1
DLL模块检测
其中会在加载模块的回调中进行检测,使用CiValidateFileObject 函数来返回其签名的信息。
Nep_ValidateDLL
首先通过文件名打开本地的文件,获取文件对象,使用CiValidateFileObject函数获取签名信息,返回并回收引用的文件对象关闭文件句柄。
首先依然是从签名入手,第一个传入的模块名字,第二个则是返回的信息数组。
其结构如下。
值得一说的是第一个成员表示的是签名的算法类型,比如0x800c 是SHA256, 0x8004 是SHA1。
关键代码如下,不过这里的变量有点问题,大致逻辑是对的。
就是首先打开文件,获得文件句柄,通过文件句柄获得文件对象,通过文件对象使用函数CiValidateFileObject直接获得签名信息,然后释放资源(后面有关闭句柄和解除引用的部分只是没截到图中)
NepInitializeCIFunc
上文中对于签名的获取CiValidateFileObject是个位于CI.dll中的函数,这里其使用ZwQuerySystemInformation函数获取CI.dll的地址,并使用Nep_GetExportFuncAddr函数根据名字获取函数地址,保存到全局变量中。
我们按照代码来看,很明显通过ZwQuerySystemInformation先获得大小再获得模块信息,后面则是遍历模块。
其中在模块名字为CI.dll时跳出循环。
后面则是调用Nep_GetExportFuncAddr函数获得函数的地址,并保存。
Nep_GetExportFuncAddr
其中就是解析PE的导出表通过对比函数名字的方式获得函数地址。
句柄降权
其在驱动加载后使用ObRegisterCallbacks注册了进程句柄(PsProcessType) 和 线程句柄(PsThreadType) 来实现对于句柄的降权。
我们可以通过ARK工具来看到其降下了哪些权限。
不难发现,其中没有的权限为:
- PROCESS_CREATE_THREAD
- PROCESS_VM_OPERATION
- PROCESS_VM_READ
- PROCESS_VM_WRITE
- PROCESS_DUP_HANDLE
- PROCESS_CREATE_PROCESS
- PROCESS_SET_QUOTA
- PROCESS_SET_INFORMATION
- PROCESS_SUSPEND_RESUME
我们给CE升一下权限就可以正常使用了,而且被提升的句柄权限是永久的。
Nep_GuardProcProtectCallBack
然后其降权回调是受保护的,保护方式非常的暴力,通过一个线程循环注册回调,这波啊是有循环在身上的。
Nep_QueryPidFromWhiteList
其中有个白名单链表,在白名单中的进程获取被保护进程的句柄时,不会被降权。(内存tag为‘WPRO’)
其结构大致为:
1 2 3 4 5 6 |
|
Nep_DeleteWhiteListProcessFromPid
当然的有白名单,就有从白名单删去进程的函数,不难发现其中应该是通过Irp进行的,除了Nep_IRP_PacketHeader则为pid。
但是这里有个问题,释放的时候与分配的内存tag不一样啊!
值得注意的是此函数并未被调用过。
进程信息收集
qsec.dll通过IOCTL来调用 NepEnumProcessListImp,在其中作为一个分发函数,决定是返回缓冲区大小还是返回真正的信息,不过无论如何都是调用NepEnumProcessList函数完成获取。
其中IRP通信的原始包结构逆向分析结果如下:
1 2 3 4 5 6 |
|
NepEnumProcessListImp
其中很明显,当包文中缓冲区长度字段为0时,将会获取所需缓冲区长度。而长度字段和缓冲区字段都符合要求时通过NepEnumProcessList函数获取具体信息。
NepEnumProcessList
该函数使用ZwQuerySystemInformation的SystemProcessInformation来获取当前所有进程,并遍历所有进程,获取PID、EPROCESS并调用NepGetFileDosNameByEProcess获取具体信息。
从函数签名入手
除了第一个其他参数都清晰明了。第一个参数是用来返回所有进程信息的,结构是通过逆向得出的。结构如下
1 2 3 4 5 6 |
|
附上一张动调的真实图
函数一开始是很标准的2步ZwQuerySystemInformation,先获取了结构大小,然后获得了SYSTEM_PROCESS_INFORMATION结构。
非常谨慎的计算了缓冲区大小是否够用。
然后进行分配了进程名字的缓冲区用于获得进程名字。接着就是核心函数,代码如下
其中使用pid获取了进程的EPROCESS,进而使用EPROCESS去获取FileDosName,并且把他由UNICODE编码转为Ascii编码(大小为2:1的关系),保存到NepProcInfo中,这里还存了SYSTEM_PROCESS_INFORMATION结构下的Reserved2(具体用途未知),与EPROCESS。并释放了由于NepGetFileDosNameByEProcess对于EPROCESS Silo 的引用。
NepGetFileDosNameByEProcess
该函数为NepEnumProcessList的配套函数,其使用Eprocess获取文件名(含路径)。
由于ida看着比较乱我整理了一下。
看起来还是通俗易懂的,基本上就是用了IoQueryFileDosDeviceName来获取文件名。
内核模块信息收集(未使用)
通过调用NepEnumKernelModuleListImp来获取内核模块信息。首先通过调用NepGetNumberOfModules获得模块数量,然后通过调用NepGetKernelModules_v(被VM保护)函数获得具体信息,放置到缓冲区中返回。
NepEnumKernelModuleListImp
该功能应由某IoControlCode调用,其结构如下:
1 2 3 4 5 6 |
|
NepGetNumberOfModules
这个函数是调用了Aux库中的函数:AuxKlibQueryModuleInformation通过枚举出模块来计算数量。浅逆一下AuxKlibQueryModuleInformation吧。
Nep_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 |
|
了解了结构后,来详细说明一下函数实现。
首先会判断Aux库是否加载好,如果正常的话会直接使用AuxKlibInitialize初始化好的RtlQueryModuleInformation来获取信息。
贴个AuxKlibInitialize代码吧
如果没有的情况下会使用函数ZwQuerySystemInformation搭配SystemModuleInformation来实现枚举,不过这次直接用SYSTEM_MODULE_INFORMATION的大小作为size查询了,只使用了一次ZwQuerySystemInformation。
后面呢则是遍历查询出来的数组,把模块基地址、模块大小、路径大小、和名字赋值给了缓冲区。值得注意的是当该函数的第二个参数nepModuleInfoLen的值为8的时候则是只获取模块基地址,272时才是获得NepModuleInfo这个结构。
加载的模块信息收集(未使用)
NepGetLoadImageListImp
该功能应由某IoControlCode调用,其结构如下:
1 2 3 4 5 6 |
|
其主要调用NepGetLoadImageList_v(被vm)的函数进行获取,鉴于未使用则不做分析。