【翻译】快速了解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模块的方法之一(有很多方法,但这个似乎是最常用的)。
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加载到内存中的。
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]是加密节区的偏移。
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公钥被存储在可执行文件中。
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;
}
}
最后使用结构:
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部分的大小进行散列。
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