【翻译】快速了解VAC - 第一部分:模块加载

原文链接:https://whereisr0da.github.io/blog/posts/2021-03-10-quick-vac/

我们已经讨论过VAC以及它是多么的鸡肋。所以最近我决定仔细研究一下它,以下就是我分析的结果。我的目标是了解VAC如何执行其模块,并在第二部分深入解析这些模块。

注意:首先我本身不是一个专业的游戏黑客,所以如果有不准确的地方请随意指出。

VAC是什么?

VAC(Valve Anti Cheat)是一个用户层的反作弊系统,用于扫描和检测外部作弊程序(其他进程)或者内部作弊程序(游戏进程)。

长话短说,用户层意味着它无法获得高级的功能和真正的系统级监控(内核层的管理程序)。这是关于VAC的主要问题,也是VAC保护的游戏可以被轻易绕过的主要原因之一。由于它是用户态保护,它的本质是一个运行在用户空间的可执行文件(或服务)。这就意味着我们可以很容易地注入/执行代码,然后是加载内核区域的一些东西(签名的驱动程序,安全启动等等)。我所说的高级功能,是指它只能像其他用户态进程一样监控进程,这并不是一个很全面的监控。因此VAC不能检测硬件作弊(当然,除非它是用户态可见的),它也不能正确处理内核态的作弊程序(只能列举驱动程序列表,检查名称和签名,如果你允许你的计算机上有未签名的驱动程序,则降低信任系数)。

考虑到这一点,我们可以编写一个驱动来绕过VAC的保护,阻止每个steam进程与外部进程的互动,为了实现游戏内存修改。我们也可以写一个内核层的作弊器,直接在游戏内存中进行读写。我们可以使用过期签名对这个驱动进行签名使其更加合法化。或者我们甚至可以修改Windows系统启动加载器,将我们的驱动程序分配在签名驱动程序的底部。再或者可以通过利用其他进程来隐藏作弊器,例如在反病毒软件中它们经常在其他进程中写入。唯一的限制是你的创造力。

我将在这里停止对用户层面反作弊技术问题的讨论,但如果你想深入了解它,可以在unknowncheats等论坛上搜索。

关于我们在本文做的事情,两个不同的东西需要考虑,VAC是由Valve制作可以应用于很多游戏的反作弊系统。而定制的VAC模块则可以应用于特定的游戏,如CSGO或使命召唤。这两种东西是以模块(可执行文件)的形式提供的,每次你启动一个受VAC保护的游戏时,这些模块就会被下载并执行,以检测特定的 “作弊 “环境。

注意:CSGO的情况不同,因为CSGO本身就自带了反作弊系统。大家都认为它也是VAC的一部分,因为它是同一个开发者。

开始行动吧

根据优秀游戏黑客的经验,我们知道了VAC核心内容在steamservice.dll里面,如果Steam是以管理员权限执行的,它将在SteamService.exe和steam.exe中调用。

以下是我逆向得到的一些有趣的内容:

这是执行VAC模块的方法之一(有很多方法,但这个似乎是最常用的)。

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
VacModuleResult_t ExecVacModule(..., int iInjectionFlag, ...){

// take the module info from vector
struct VacModuleInfo_t* pModuleInfo = ....;

if (pModuleInfo != NULL){

if (unknown0 < 0x58)
return UKN0;

// setup module
if (!GetVacModuleEntrypoint(.., .., .., pModuleInfo, iInjectionFlag))
return pModuleInfo->m_nLastResult;

// I still don't know what they are deciphering here
DecryptUknw([ebp-0x78], 0, 0x50);

// call module exec function
pModuleInfo->m_nLastResult = pModuleInfo->m_pRunFunc(...);

UnknownSaveRoutine(pModuleInfo->pCallableUnkn11);

if (iInjectionFlag & 4)
UnknownCallback();

return pModuleInfo->m_nLastResult;
}

return ALREADY_LOADED;
}

这里是模块加载程序,你可以看到有两种类型的模块。一种是写在临时目录下的,一种是用RunPE加载到内存中的。

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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
int32_t GetVacModuleEntrypoint(..., VacModuleInfo_t* pModuleInfo, char iInjectionFlag){

if (!pModuleInfo->m_pRunFunc){

if (!pModuleInfo->m_pRawModule || (pModuleInfo->m_pRawModule && !pModuleInfo->m_nModuleSize)){

pModuleInfo->m_nLastResult = FAIL_MODULE_SIZE_NULL;
return 0;
}

if (pModuleInfo->m_pRawModule && pModuleInfo->m_nModuleSize){

if (pModuleInfo->m_pModule)
error("Assertion Failed");

.....
}

// decrypt sections using RSA
if (DecryptVacModule(pModuleInfo->m_pRawModule, pModuleInfo->m_nModuleSize, ...)){

UnloadVacModule(pModuleInfo);
pModuleInfo->m_nLastResult = FAIL_TO_DECRYPT_VAC_MODULE;
return 0;
}

// if VAC module should be on disk
if ((iInjectionFlag & 2) == 0){

auto tmp = SetupVacModuleInfo(pModuleInfo, 0, 0, 0);

pModuleInfo->m_nLastResult = NOT_SET;

// get temp path
if (!GetModuleTmpPath(tmp, ..., pModuleInfo)){

pModuleInfo->m_nLastResult = FAIL_GET_MODULE_TEMP_PATH;
sub_1007f2f0(FreeHandle(pModuleInfo));
UnloadVacModule(pModuleInfo);
return 0;
}

InitVacModule(pModuleInfo, pModuleInfo->m_pRawModule, pModuleInfo->m_nModuleSize, pModuleInfo->m_nModuleSize, 0);

// write module in temp
if(!WriteVacModule(pModuleInfo, ..., 0)){

pModuleInfo->m_nLastResult = FAIL_WRITE_MODULE;
sub_1007f2f0(FreeHandle(pModuleInfo));
UnloadVacModule(pModuleInfo);
return 0;
}

// check CRC32 + resolve imports from ".cpl" section + LoadLibraryW
HANDLE hModule = LoadVacModule(pModuleInfo, 0);

pModuleInfo->m_hModule = hModule;

if(!hModule){

pModuleInfo->m_nLastResult = FAIL_LOAD_MODULE;
sub_1007f2f0(FreeHandle(pModuleInfo));
UnloadVacModule(pModuleInfo);
return 0;
}

// get exec function from export
void* pRunFunc = GetProcAddress(hModule, "_runfunc@20");

pModuleInfo->m_pRunFunc = pRunFunc;

if (!pRunFunc)
pModuleInfo->m_nLastResult = FAIL_GET_EXPORT_RUNFUNC;

sub_1007f2f0(FreeHandle(hModule));

if (!pModuleInfo->m_pRunFunc){

UnloadVacModule(pModuleInfo);
return 0;
}

UnknownSaveRoutine(pModuleInfo->pCallableUnkn11);

return 1;
}
else{

// section decryption + RunPE the module + exec DllMain
VacModule_t* pModuleRaw = AllocVacModule(pModuleInfo->m_pRawModule, 0, 1);

pModuleInfo->m_pModule = pModuleRaw;

if (!pModuleRaw){
pModuleInfo->m_nLastResult = FAIL_LOAD_MODULE;
UnloadVacModule(pModuleInfo);
return 0;
}

// resolve exec function from new export table
void* pRunFunc = ResolveExportFromEAT(pModuleRaw, "_runfunc@20");

pModuleInfo->m_pRunFunc = pRunFunc;

if (!pRunFunc){
pModuleInfo->m_nLastResult = FAIL_GET_EXPORT_RUNFUNC_2;
UnloadVacModule(pModuleInfo);
return 0;
}

UnknownSaveRoutine(pModuleInfo->pCallableUnkn11);

return 1;
}
}
}

以下是valve使用的RunPE:

这里利用的技巧是对于每个节区,pSectionHeader->Name[0]被用作真正的节区大小,pSectionHeader->Name[4]是加密节区的偏移。

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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
VacModule_t* AllocVacModule(DOS_Header* pRawModule, uint32_t iImageBase, char arg3){

if (pRawModule->e_magic[0] != 'MZ')
return 0;

_IMAGE_NT_HEADERS* pNtHeader = pRawModule->e_lfanew + pRawModule;

if (pNtHeader->FileHeader.magic[0] != 'PE')
return 0;

if (iImageBase == 0)
iImageBase = pNtHeader->OptionalHeader.imageBase;

LPVOID pImageBase = VirtualAlloc(iImageBase, pNtHeader->OptionalHeader.sizeOfImage, MEM_RESERVE, 4);

if (!pImageBase)
pImageBase = VirtualAlloc(0, pNtHeader->OptionalHeader.sizeOfImage, MEM_RESERVE, 4);

if (!pImageBase)
return 0;

VacModule_t* pModule = HeapAlloc(GetProcessHeap(), 0, 0x14);

pModule->m_nRunFuncExportFunctionOrdinal = 0;
pModule->m_nRunFuncExportModuleOrdinal = 0;
pModule->m_pNTHeaders = nullptr;
pModule->m_nImportedLibrary = 0;
pModule->m_pIAT = 0;
pModule->m_pModuleBase = pImageBase;

pImageBase = VirtualAlloc(pImageBase, pNtHeader->OptionalHeader.sizeOfImage, MEM_COMMIT, 4);

DecryptUknw_1(pImageBase, pRawModule, pNtHeader->OptionalHeader.sizeOfHeaders + pRawModule->e_lfanew);

pNtHeader = pRawModule->e_lfanew + pImageBase;

pModule->m_pNTHeaders = pNtHeader;

pNtHeader->OptionalHeader.imageBase = pImageBase;

if (pNtHeader->FileHeader.numberOfSections <= 0)
return 0;

PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION(pNtHeader);

for (size_t i = 0; i < pNtHeader->FileHeader.NumberOfSections; i++){

DWORD iSectionNameSize = pSectionHeader->Name[0];
DWORD iStart = pSectionHeader->Name[4];

if(iSectionNameSize){

LPVOID pSection = VirtualAlloc(pSectionHeader->virtualAddress + pModuleBase_0, iSectionNameSize, MEM_COMMIT, 4);
pSectionHeader->virtualSize = pSection;
DecryptUknw_1(pSection, iStart + pRawModule, iSectionNameSize);
}
else{

DWORD iSectionAlignment = pNtHeader->OptionalHeader.sectionAlignment;

if (iSectionAlignment > 0){

LPVOID pSection = VirtualAlloc(pSectionHeader->virtualAddress + pModuleBase_0, iSectionAlignment, MEM_COMMIT, 4);
pSectionHeader->virtualSize = pSection;
DecryptUknw(pSection, 0, iSectionAlignment);
}
}

pSectionHeader++;
}

void* tmp = pImageBase - pNtHeader->OptionalHeader.imageBase;

if (pImageBase != pNtHeader->OptionalHeader.imageBase)
ResolveRelocation(pModule, tmp);

ResolveIAT(pModule);

SetPageFlagsVacModule(pModule);

uint32_t iEntryPointRva = pModule->m_pNTHeaders->OptionalHeader.addressOfEntryPoint;

if (!iEntryPointRva)
return pModule;

if (!pNtHeader)
return pModule;

void* pEntryPoint = iEntryPointRva + pImageBase;

if (iEntryPointRva != pImageBase){

auto result = pEntryPoint(pImageBase, 1, 0);

if (result){
pModule->m_nRunFuncExportFunctionOrdinal = 1;
return pModule;
}
}

if (pModule->m_nRunFuncExportFunctionOrdinal){

uint32_t pModuleBase = pModule->m_pModuleBase;
(pModule->m_pNTHeaders->OptionalHeader.addressOfEntryPoint + pModuleBase)(pModuleBase, 0, 0);
}

uint32_t pIAT = pModule->m_pIAT;

if (pIAT){

for(int i = 0; i < pModule->m_nImportedLibrary; i++){

pIAT = pModule->m_pIAT;
HMODULE hLibModule = *(pIAT + (i << 2));

if (hLibModule != 0xffffffff){

FreeLibrary(hLibModule);
pIAT = pModule->m_pIAT;
}
}
}

uint32_t pModuleBase = pModule->m_pModuleBase;

if (pModuleBase)
VirtualFree(pModuleBase, 0, 0x8000);

HeapFree(GetProcessHeap(), 0, pModule);

return 0;
}

这个是模块的解密程序:

Valve使用DOS头来存储信息,这些信息位于DOS头的尾部,通过DOS头的e_lfanew值作为一个偏移。这些信息是节区的解密密钥和CRC值。Valve使用RSA对它们进行加密,和往常一样RSA公钥被存储在可执行文件中。

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
struct VacModuleCustomDosHeader_t
{
struct _IMAGE_DOS_HEADER m_DosHeader;
DWORD m_ValveHeaderMagic; // "VLV"
DWORD m_nIsCrypted;
DWORD m_nCryptedDataSize;
DWORD unkn0;
BYTE m_CryptedRSASignature[0x80];
};

int32_t DecryptVacModule(VacModuleCustomDosHeader_t* pRawModule, int iModuleSize, DWORD** decodedData, int32_t arg5){

if (iModuleSize >= 0x200 && pRawModule->m_DosHeader.e_magic[0] == 'MZ'){

uint32_t pNtHeaderOffset = pRawModule->m_DosHeader.e_lfanew;

if (pNtHeaderOffset >= 0x40 && pNtHeaderOffset < iModuleSize + 8 && *(pNtHeaderOffset + pRawModule) == 'PE'){

if (pRawModule->m_ValveHeaderMagic != 'VLV')
return 2;

if (pRawModule->m_nIsCrypted != 1)
return 4;

if (iModuleSize >= pRawModule->m_nCryptedDataSize)
return 3;

....

void* pCryptedRSASignature = &pRawModule->m_CryptedRSASignature;

....

DecryptUknw(pCryptedRSASignature, 0, 0x80);

....

CCrypto::RSAVerifySignature(....., pRawModule, pRawModule->m_nCryptedDataSize, pubSignature, 0x80, rsaKey);

....
}
else{
return 6;
}
}
else{
return 6;
}
}

最后使用结构:

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
struct VacModule_t
{
WORD m_nRunFuncExportFunctionOrdinal;
WORD m_nRunFuncExportModuleOrdinal;
DWORD m_pModuleBase;
struct _IMAGE_NT_HEADERS* m_pNTHeaders;
DWORD m_nImportedLibraryCount;
DWORD m_pIAT;
};

enum VacModuleResult_t
{
NOT_SET = 0x0,
SUCCESS = 0x1,
ALREADY_LOADED = 0x2,
UKN0 = 0x5,
FAIL_TO_DECRYPT_VAC_MODULE = 0xb,
FAIL_MODULE_SIZE_NULL = 0xc,
UKN1 = 0xf,
FAIL_GET_MODULE_TEMP_PATH = 0x13,
FAIL_WRITE_MODULE = 0x15,
FAIL_LOAD_MODULE = 0x16,
FAIL_GET_EXPORT_RUNFUNC = 0x17,
FAIL_GET_EXPORT_RUNFUNC_2 = 0x19
};

struct VacModuleInfo_t
{
DWORD m_unCRC32;
DWORD m_hModule;
struct VacModule_t* m_pModule;
DWORD m_pRunFunc;
enum VacModuleResult_t m_nLastResult;
DWORD m_nModuleSize;
struct VacModuleCustomDosHeader_t* m_pRawModule;
WORD unkn08;
BYTE m_nUnknFlag_1;
BYTE m_nUnknFlag_0;
DWORD pCallableUnkn11;
DWORD pCallableUnkn12;
};

绕过保护

很多人已经讨论过这个,绕过VAC是个很简单的事情,你可以禁用模块的执行,并欺骗程序一切正常。

你可以hook GetVacModuleEntrypoint这个函数,在不执行模块的情况下加载模块,然后马上卸载它。我认为必须对返回值(VacModuleResult_t)进行修复使其发挥作用。

难受的是有些模块是玩某些游戏(如CSGO)所必需的。所以必须过滤哪些模块应该被patched。

注意:CRC在某种程度上是每个Steam ID唯一的。因此必须取另一个检测向量,比如像大家所做的那样对.text部分的大小进行散列。

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
bool __stdcall GetVacModuleEntrypointHook(struct VacModuleInfo_t* pModule, int iFlags) {

// call the original, load module
bool bOriginalReturn = ((GetVacModuleEntrypointPrototype)pOriginalGetVacModuleEntrypoint)(pModule, iFlags);

if (pModule->m_unCRC32) {

bool bFound = false;

for (DWORD iCrc : m_KnownCRC) {

if (pModule->m_unCRC32 == iCrc) {

PF("[+] GetVacModuleEntrypointHook : known module %p", pModule->m_unCRC32);
bFound = true;
break;
}
}

// dump it
DumpVacModule(pModule);

if (!bFound) {

PF("[-] GetVacModuleEntrypointHook : unknown module %p", pModule->m_unCRC32);
}
else {
// check that this module is not whitelisted
for (DWORD iCrc : m_WhiteListedCRC) {

// it's a needed module
if (pModule->m_unCRC32 == iCrc) {

PF("[+] GetVacModuleEntrypointHook : whitelisted module %p", pModule->m_unCRC32);
return bOriginalReturn;
}
}
}
}

if (pModule->m_pRunFunc) {
// null _runfunc@20
pModule->m_pRunFunc = NULL;
}

// unload the module
((UnloadVacModulePrototype)pUnloadVacModule)(pModule);

// patch the result
pModule->m_nLastResult = SUCCESS;

return true;
}

总结

正如你所看到的,考虑到VAC本身没有其他的安全性(没有完整性检查,没有混淆,用户态反作弊 …),这个绕过是相当可行的。

如果说VAC一开始就有不好的名声,那是因为它的开发商不想投资它,这与Valve本身更有关系。在2016年,开发者说每一个公开的外挂(Github上的源代码)都会被标记出来,封禁每一个试图使用它的账号。当然这一切都不是真的(至少在大多数情况下)。很多人早在2018年发布了使用钩子的绕过程序,而且今天仍在工作没有任何封号的问题。

你甚至不需要修改到VAC本身。可以通过使用代码混淆、”隐藏 “钩子和DLL劫持等技巧,在监控之下处理VAC。我从2018年开始就这样做了,只要你不直接复制公共外挂的源代码,你就根本不会被封号。

在下一部分中,我将逆向一些模块,看看它们实际检测的内容。

源码参考

https://github.com/danielkrupinski/VAC-Bypass-Loader

https://github.com/danielkrupinski/VAC-Bypass

https://github.com/danielkrupinski/VAC

逆向 信息安全

VAC