原文链接: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

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

[https://github.com/danielkrupinski/VAC](