写在最前
该反作弊使用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
|
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函数先判断是否驱动已加载,没有加载的话会创建服务。

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
|
unsigned __int64 __fastcall GenerateKernelName(unsigned __int8 *a1, unsigned __int64 a2, __int64 a3)
{
unsigned __int8 *v3;
unsigned __int64 v4;
unsigned __int8 *v5;
__int64 v6;
unsigned __int64 v7;
__int64 v8;
__int64 v9;
__int64 v10;
__int64 v11;
__int64 v12;
__int64 v13;
unsigned __int64 v14;
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来停止驱动服务

R3下对其他程序的监控
先说结论,他会以一个线程的信息作为最小单位进行信息收集,同时会关注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
函数。这个是我自己重命名的。
GetThreadStartAddr
就是使用NtQueryInformationThread来获取线程的起始地址。

通过线程获取到的pid进而获取进程句柄,通过获取的句柄来枚举进程内的全部模块,通过获取每个模块的详细信息来暴力扫描一个线程起始地址处在模块中的偏移与该模块名称。

其会拍摄进程的快照,并且在确认是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
|
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 来到查询语句的目的。

- 对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
|
__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函数来剥离调试器
[招生]科锐逆向工程师培训(2025年3月11日实地,远程教学同时开班, 第52期)!
最后于 4天前
被moshuiD编辑
,原因: