从零开始分析VAC

一、资料收集

国内:

CSGO辅助制作思路与VAC保护分析 - -Qfrost-

【CSGO游戏分析】VAC反作弊系统分析 - 哔哩哔哩 (bilibili.com)

国外:

How To Bypass VAC Valve Anti Cheat Info (guidedhacking.com)

代码:

danielkrupinski/VAC: Source code of Valve Anti-Cheat obtained from disassembly of compiled modules (github.com)

二、分析笔记

1
实验目标:CSGO

2.1 ARK分析

image-20230104095003980

根据Inline钩子扫描猜测当前检测功能有:

  • 禁止第三方DLL注入

    LdrLoadDll、LoadLibraryA、LoadLibraryExA、LoadLibraryExW、LoadLibraryW、NtOpenFile

  • 申请内存、修改内存属性、写内存

    VirtualAlloc、VirtualProtect、VirtualAllocEx、VirtualProtectEx

经过火绒剑分析,发现除了NtOpenFileLoadLibraryExW被跳转到csgo.exe外,其他的所有的钩子全部跳转到gameoverlayrenderer.dll模块中,将对应模块dump后,ida分析。

2.1.1 gameoverlayrenderer.dll分析

LdrLoadDll

首先会进行一个判断,猜测可能是反作弊或者是hook初始化之类的。

image-20230103181242412

其中会执行一个函数sub_7ADBDE50,该函数主要是收集模块信息,其中疑似有白名单检测。

image-20230103181826494

this[0xF126]和this[0xF124]都为byte类型,作用为开启模块收集。

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
78
79
80
81
82
83
84
85
86
87
88
char __thiscall RecordModuleInfos_7ADBEE50(int this, int moduleName, DWORD arg_4)
{
HANDLE CurrentThread; // edi
DWORD CurrentThreadId; // esi
NTSTATUS (__stdcall *NtQueryInformationThread)(HANDLE, THREADINFOCLASS, PVOID, ULONG, PULONG); // eax
HMODULE ModuleHandleA; // eax
HANDLE CurrentProcess; // eax
int v9; // ecx
volatile signed __int32 *v10; // edx
HMODULE hModule; // edi
int v12; // ecx
volatile signed __int32 *v13; // eax
int v14; // eax
LPCWSTR threadEntryAddr_1; // ecx
DWORD *v16; // eax
HMODULE v18; // [esp-Ch] [ebp-12Ch]
char curtModuleName[264]; // [esp+Ch] [ebp-114h] BYREF
LPCWSTR v20; // [esp+114h] [ebp-Ch] BYREF
LPCWSTR threadEntryAddr; // [esp+118h] [ebp-8h] BYREF
HMODULE phModule; // [esp+11Ch] [ebp-4h] BYREF

CurrentThread = GetCurrentThread();
CurrentThreadId = GetCurrentThreadId();
NtQueryInformationThread = (NTSTATUS (__stdcall *)(HANDLE, THREADINFOCLASS, PVOID, ULONG, PULONG))NtQueryInformationThread_7AE57AE4;
if ( !NtQueryInformationThread_7AE57AE4 )
{
ModuleHandleA = GetModuleHandleA("ntdll.dll");
if ( ModuleHandleA )
{
NtQueryInformationThread = (NTSTATUS (__stdcall *)(HANDLE, THREADINFOCLASS, PVOID, ULONG, PULONG))GetProcAddress(ModuleHandleA, "NtQueryInformationThread");
NtQueryInformationThread_7AE57AE4 = (int)NtQueryInformationThread;
}
else
{
NtQueryInformationThread = (NTSTATUS (__stdcall *)(HANDLE, THREADINFOCLASS, PVOID, ULONG, PULONG))NtQueryInformationThread_7AE57AE4;
}
}
phModule = 0;
threadEntryAddr = 0;
curtModuleName[0] = 0;
if ( NtQueryInformationThread )
{
v20 = 0;
NtQueryInformationThread(CurrentThread, (THREADINFOCLASS)9, &threadEntryAddr, 4, (PULONG)&v20);// ThreadQuerySetWin32StartAddress
// 获取当前线程的入口地址
if ( GetModuleHandleExW(6u, threadEntryAddr, &phModule) )// GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS(0x4) | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT(0x2)
// 通过入口地址获取所在的模块句柄
{
v18 = phModule;
CurrentProcess = GetCurrentProcess();
GetModuleBaseNameA((int)CurrentProcess, (int)v18, (int)curtModuleName, 260);// 通过模块句柄获取模块名
}
}
v9 = *(_DWORD *)(this + 0xEAD4); // 获取ModuleInfos
v10 = (volatile signed __int32 *)(this + 0xEAD4);
hModule = phModule;
v20 = threadEntryAddr;
if ( v9 < 100 ) // 结构头部为Module数量
{
v12 = v9 - 1;
if ( v12 < 0 ) // 如果当前Infos为空
{
insert_new_node:
v14 = _InterlockedIncrement(v10);
if ( v14 <= 100 )
{
threadEntryAddr_1 = v20;
v16 = (DWORD *)&v10[4 * v14]; // 插入一个模块信息
*v16 = CurrentThreadId;
v16[1] = (DWORD)hModule;
v16[2] = (DWORD)threadEntryAddr_1;
v16[3] = arg_4;
}
_InterlockedIncrement((volatile signed __int32 *)(this + 60120));
}
else
{
v13 = &v10[4 * v12 + 4];
while ( *v13 != CurrentThreadId ) // 数组中的ThreadId与curtThreadId不相同
{
v13 -= 4; // ModuleInfon-4,实际上是回到上一个指针
if ( --v12 < 0 )
goto insert_new_node; // 然后将当前模块信息重新插入infos
}
}
}
return 1;
}

收集的模块结构如下:

1
2
3
4
5
6
struct ModuleInfo{
DWORD threadId; //模块所处的线程ID
DWORD hModule; //模块句柄
DWORD threadEntryAddr; //模块所在的线程入口地址
DWORD unkown;
}

image-20230103182210961

除此之外,游戏还会以另一种方式收集模块信息。在调用原LdrLoadDll后会将拉起的模块句柄、函数返回值、上下文返回的上层地址进行保存在另一个数组中。

image-20230103182412330

image-20230103182522049

image-20230103182506075

LoadLibrary系列

挂钩代码基本上与LdrLoadDll相同,但是LoadLibrayA中,存在对d3d9.dllOPENGL32的判断,用来进行对应hook来实现steam面板的绘制。

image-20230103182812719

image-20230103182821484

另外还会判断下图中的模块是否加载完毕,然后给全局变量赋值1,可能为反作弊的核心模块。

image-20230103182844729

VirtualAlloc检测

首先会判断申请的内存类型是否为PAGE_EXECUTE_READWRITE。

image-20230103183019935

然后进行记录到AllocMemInfos中。

image-20230103183052568

image-20230103183118283

记录的内存块有如下结构:

1
2
3
4
5
6
7
8
9
10
11
12
struct AllocMemInfo
{
DWORD lpNewMem; //申请的内存地址
DWORD dwLastError; //调用Alloc函数的错误码
DWORD dwRetAddr; //调用Alloc函数的返回到地址
DWORD pad_0;
DWORD pad_1;
DWORD lpAddress; //以下都为调用参数的记录
DWORD dwSize;
DWORD flProtect;
DWORD flAllocationType;
}

VirtualProtect检测

首先会判断欲修改内存类型是否为PAGE_EXECUTE_READWRITE。

image-20230103183451098

然后进行记录到ProtectInfos中。

image-20230103183511023

image-20230103183518345

记录的内存块有如下结构:

1
2
3
4
5
6
7
8
9
10
11
12
struct ProtectInfo
{
DWORD FunRet; //Protect函数执行的返回值
DWORD dwLastError; //调用Protect函数的错误码
DWORD dwRetAddr; //调用Protect函数的返回到地址
DWORD pad_0;
DWORD pad_1;
DWORD lpAddress; //以下都为调用参数的记录
DWORD dwSize;
DWORD flProtect;
DWORD flAllocationType;
}

总结

游戏会进行如下记录:

1、记录游戏加载的模块(模块入口、模块所属的线程ID、模块句柄、加载时返回到的地址)。

2、记录申请内存类型为PAGE_EXECUTE_READWRITE的内存。

3、记录修改内存类型为PAGE_EXECUTE_READWRITE的内存。

4、记录后作用未知,猜测在某些地方会进行扫描这些表来寻找游戏作弊。

2.1.2 csgo.exe分析

NtOpenFile

image-20230104112000697

从ObjAttr拿到文件名后判断文件名是否存在白名单,如果不存在白名单则返回错误码0xC0000034,存在则正常打开文件。

LoadLibraryExW

同样会首先判读是否存在白名单。

image-20230104112122129

存在则正常加载DLL。

image-20230104112130903

如果Load的DLL不在白名单内,则进行记录到表中,但记录的方式有坑。记录前会去查找一个句柄表,表中存放着一些dll的句柄、dll名、dll文件大小。游戏通过判断文件大小和文件名来查找句柄表是否存放对应的dll句柄,如果存在则返回该句柄。

image-20230104112352425

image-20230104112252111

不存在则进行记录。

image-20230104112519644

PeekMessage

根据CE查看得知该函数是跳转到模块GameOverlayrenderer.dll中,猜测可能是与steam面板有关,故不分析。

image-20230104112648387

initHook

hkNtOpenFile函数继续交叉引用,可找到初始化HOOK的位置。

image-20230104113203202

可以看到分别对三个函数进行了HOOK,其中Hook NtOpenFile中有一个中转函数sub_4E5980.

函数首先是判断当前的获取到的NtOpenFile函数地址是否合法。

image-20230104113954875

image-20230104113537280

hookTable为一个map表,里边存放着NtOpenFile、LoadLibraryExW、PeekMessageA的原始地址,该表在较早时期进行初始化,每次HOOK都会判断GetProcAddress得到的地址与表中是否一致,防止一些IAT类型的HOOK。之后检查函数头是否存在int3、int2、nop等指令覆盖,如果没覆盖则进行Hook。

image-20230104113308336

总结

1、hook了NtOpenFile和LoadLibraryExW进行白名单判断,并且在LoadLirary中,如果dll不为白名单中则尝试查找句柄表中是否有该dll句柄,然后返回;否则就插入一个记录节点,记录该dll的信息。

2、其中游戏初期会初始化一个hookTable表,里边存放着三个函数的地址;在HOOK时会重新判断地址是否一致,然后进行HOOK,防止一些IAT HOOK。

3、对于记录了白名单外的dll,猜测可能是会有遍历文件,然后后台上传。

2.2 API分析

2.2.1 tier0.dll分析

IsDebuggerPresent

将CE调成VEH调试器后再IsDebuggerPresent函数下断.

image-20230104123118650

断下后F8单步走出。

image-20230104123201020

发现模块返回到了tier0.dll

image-20230104123258746

通过火绒剑查看进程模块发现有两个,由于是返回到tier0.dll中,所有这里先dump下该模块后进行分析下。

IsDebuggerPresent交叉引用后得到的结果基本都是判断在调试,如果调试就抛出一个int3

image-20230104144627714

Dr寄存器清空

通过导入表发现该模块还调用了GetThreadContextSetThreadContext,疑似为清空Dr寄存器。

image-20230104144841073

image-20230104144857872

但在游戏中对这两个函数下断并为断下。

2.2.2 steamclient.dll分析

检测顶层窗口

在对CreateToolhelp32Snapshot下断点时游戏断下,查看堆栈信息来自steamclient.dll

image-20230104145405713

将该模块dump后,跳到返回到地址进行分析。

模块首先会枚举每一个进程的PID。

image-20230104152201577

然后会遍历枚举到的进程ID,获取他们的文件信息保存到数组中。

image-20230104164837789

image-20230104164934590

image-20230104164943770

接着通过遍历进程,其中会寻找csgo.exe的PID。

image-20230104153441268

通过对遍历进程的函数进行交叉引用后,发现会获取最顶层窗口并且检查是否为csgo的窗口。

image-20230104153534264

并且会根据顶层窗口是否为csgo.exe来对时间数据赋值。

image-20230104154110565

image-20230104154102544

猜测是检测是否有窗口一直覆盖再游戏上。

总结

1、会通过IsDebuggerPresent检测是否在调试,如果调试则抛出int3异常。

2、可能会清空Dr寄存器。

3、会检测顶层窗口

4、会枚举电脑上所有的进程,并收集进程文件的信息。