分类 信息安全 下的文章

太久没发文章了,把存货发出来了,当作存档了<@_@>,仅供学习交流
mhy这个驱动漏洞也很有意思:https://bbs.pediy.com/thread-272873.htm

0x01 隐藏关键DLL

对于IL2CPP的Unity游戏,它的核心游戏逻辑一般都是放在 *Assembly.dll这个DLL里面的,我们global-metadata.dat解析出对应的符号也是作用于这个DLL的。正常游戏都是放在游戏主程序根目录下,原神做了一个目录迁移,用ProcessHacker可以找到真实路径。

image-20220202131912060

0x02 加密global-metadata.dat

游戏版本: genshin-impact-2.4

观察global-metadata.dat文件,发现被加密了

image-20220129122257777

原神的Unity版本为2017.4.30,下载il2cpp的源码进行对照逆向

https://github.com/4ch12dy/il2cpp/tree/master/unity_2017_x

image-20220129122804003

il2cpp 加载的关键函数和结构

void MetadataCache::Initialize()
void* MetadataLoader::LoadMetadataFile(const char* fileName)
struct Il2CppGlobalMetadataHeader

image-20220129125252155

通过源码和IDA逆向对比可以得出对应关键函数的位置以及一些重要全局变量,我们修改IDA中伪代码的名称,在LoadMetadataFile函数中发现疑似对global-metadata.dat进行解密的函数操作。

image-20220203184021700

对这个函数交叉引用可以发现是在il2cpp_init_security这个函数中进行初始化的,在结合x64dbg动态调试可以发现在这里初始化的三个函数都是在UnityPlayer.dll中的。

image-20220203184737050

image-20220130162206800

并且这三个函数还加了代码混淆和反调试,静态看不是很清晰。

image-20220130162230041

基本上就是靠IDA静态分析以及结合源码进行判断,分别为以下三个函数

image-20220203190904086

其中对GetStringLiteralFromIndex的判断如下,通过对比可以猜测原神对这个函数做了其他操作(字面量二次解密之类的)

image-20220203190748093

image-20220203190836374

对GetStringFromIndex的判断

image-20220203191108870

然后在IDA的Local Types界面把Il2CppGlobalMetadataHeader结构导入,观察源码和IDA中伪代码的解析可以发现原神对这个结构做了改动。

image-20220203185122907

通过进一步的分析发现原神修改了如Il2CppGlobalMetadataHeader,Il2CppTypeDefinition,Il2CppMethodDefinition,Il2CppFieldDefinition和Il2CppPropertyDefinition等诸多IL2CPP加载过程中要使用的结构。

手动调用从UnityPlayer.dll获取的DecryptMetadata函数对global-metadata.dat进行解密,发现只进行了部分位置解密,而且没有出现dat文件的特征头。

image-20220130224649298

手动把mhyprot2.sys卸载后,迅速将内存中的global-metadata.dat Dump下来然后和我们手动调用解密函数后的文件进行比对,发现global-metadata.dat是存在二次解密的,并且是有规律的不同,间隔的字节数和文件大小有关系。

image-20220202124718937

观察和查找资料后可用发现是每隔(dwFileSize >> 14) << 6 和一个数组异或。在UserAssembly.dll中找了一圈没有找到这个解密的地方,猜测是放到GetStringLiteralFromIndex或者类似UnityPlayer.dll中的函数中调用的。

截下来就是Dump出符号信息,先写脚本手动解密验证猜想

#include <windows.h>

#include <iostream>

typedef byte* (* pDecryptMetadata)(byte[], int);

int main()
{
    HMODULE hModuleBase = LoadLibrary(L"UnityPlayer.dll");
    pDecryptMetadata pfnDecryptMetadata = (pDecryptMetadata)((PBYTE)hModuleBase + 0x16F840);
    /*
    //DecryptMetadata                  0x16F840
    //GetStringFromIndex             0x12DC50
    //GetStringLiteralFromIndex      0x12DF60
    */

    HANDLE hFile = CreateFile(L"global-metadata.dat", 
                              GENERIC_READ, 
                              FILE_SHARE_READ,
                              NULL,
                              OPEN_EXISTING, 
                              FILE_ATTRIBUTE_NORMAL, 
                              NULL);
    DWORD dwFileSize = 0;
    dwFileSize = GetFileSize(hFile, NULL);
    PBYTE bBuffer;
    bBuffer = (PBYTE)malloc(dwFileSize);
    DWORD dwReadNumber = 0;
    if (!ReadFile(hFile, bBuffer, dwFileSize, &dwReadNumber, NULL))
    {
        printf("ReadFile Error\n");
        return 0;
    }

    pfnDecryptMetadata(bBuffer, dwFileSize);

    byte key[] = { 0xAD, 0x2F, 0x42, 0x30, 0x67, 0x04, 0xB0, 0x9C, 0x9D, 0x2A, 0xC0, 0xBA, 0x0E, 0xBF, 0xA5, 0x68 };

    // The step is based on the file size
    UINT32 step = (UINT32)((dwFileSize >> 14) << 6);

    for (DWORD pos = 0; pos < (dwFileSize - step); pos += step)
        for (byte b = 0; b < 0x10; b++)
            bBuffer[pos + b] ^= key[b];

    HANDLE hFILE = CreateFile(L"global-metadata-decrytpo.dat", 
                              GENERIC_WRITE | GENERIC_READ,
                              FILE_SHARE_READ,
                              NULL,
                              CREATE_ALWAYS,
                              FILE_ATTRIBUTE_READONLY,
                              NULL);
    DWORD dwWrite;
    if (!WriteFile(hFILE, bBuffer, dwFileSize, &dwWrite, NULL))
    {
        printf("WriteFile Error\n");
        return 0;
    }
    FreeLibrary(hModuleBase);
    printf("Done!\n");
    return 0;
}

image-20220202130433983

我们可以用Il2CppInspector这个工具编写插件来自动化操作

image-20220202130700608

得到符号信息,可以发现mhy对非关键函数做了源码层面的哈希操作。

image-20220202131607648image-20220202131508518

0x03 CE进程检测绕过

将CE源码中CE相关字符串替换掉成系统相关进程名(如:svchost.exe)重新编译,或者直接修改"cheatengine-x86_64.exe" 进程名为 "svchost.exe"。

image-20220207162712040

0x04 总结

global-metadata文件经历了两次解密,原神dump下来的符号文件意义不大,非unity的关键函数都做了源码层面的混淆。CE进程检测较为草率了,不知道有没有其他交叉检测。

目前Unity的游戏主要分为Mono和IL2CPP这两种,最主要的区别就是看游戏目录,如果有Assembly-CSharp.dll这个Dll则该游戏使用的是Mono虚拟机,否则就是使用IL2CPP。

image-20220115215515860

steam游戏的本体可以在 xxx\steamapps\common\游戏名字\ 找到。

Mono

使用Mono虚拟机的unity游戏,它的核心逻辑都是写在Assembly-CSharp.dll这个Dll里,所以我们可以使用(dnspy)[https://github.com/dnSpy/dnSpy/releases/tag/v6.1.8]进行反编译,并且找到SteamManager这个类。

image-20220115234334490

然后修改Awake这个函数最方便的就是修改方法,如果不行就直接修改IL指令和后续的IL2CPP修改汇编类似。

image-20220115234927623

image-20220115234956156

image-20220115234722473

替换游戏目录下的Assembly-CSharp.dll(记得备份原来的)即可。

image-20220115234820221

IL2CPP

使用IL2CPP作为跨平台虚拟机就比Mono稍微麻烦一点,我主要使用到了(IL2CPPDumper)[https://github.com/Perfare/Il2CppDumper/releases/tag/v6.7.6]和IDA,先将游戏目录下的GameAssembly.dll(安卓的为libil2cpp.so)和游戏_data\il2cpp_data\Metadata\global-metadata.dat 复制到同一目录。

image-20220115224249794

执行命令行Il2CppDumper.exe ,得到几个json和il2cpp.h。

image-20220116234624063

使用IDA打开GameAssembly.dll执行ida_with_struct_py3.py(在Il2CppDumer目录下)脚本,分别载入以下json和h文件,即可获得该DLL的符号信息(感觉类似pdb的作用)。

image-20220115224315481

image-20220115230450065

同样找到SteamManager.Awake这个函数,直接Hex修改函数头使其直接返回,即可绕过验证。

image-20220115232106653

最后保存并替换原始的GameAssembly.dll(记得备份)。

image-20220115232205460

image-20220115232541421

声明

以上内容仅为Unity游戏逆向和Il2CppDumper的学习笔记,仅供学习参考,切勿用于违法违规行为

本文应该算作个人学习VMP保护的笔记,所以内容较为空泛。详细的VMP虚拟保护壳的学习可以参考文末的参考资料。

0x01 外层壳保护

使用VMProtect保护后的程序添加了两个新节区

壳代码也是虚拟化过的

image-20211230185633948

寻找OEP的方法

  1. 通过对ZwProtectVirtualMemory下断,观察栈顶
  2. 对代码节下硬件执行断点(感觉略鸡肋,得知道原始函数在大致哪个范围
  3. 对mainCRTstartup内使用的一些API下断点(IsProcessorFeaturePresent, GetSystemTimeAsFileTime),然后回溯找到OEP

修复IAT表

虽然能看到程序使用了哪些API,却不能通过交叉引用来静态分析,因为VMP保护后的程序导入地址是运行时动态计算的

image-20211230211131287

参考源哥用Unicorn还原IAT表的文章

image-20211230213959428

mov     ebx, offset byte_407DD1
mov     ebx, [ebx+198694h]
lea     ebx, [ebx+44C25846h]
xchg    ebx, [esp+0]
retn
[407DD1 + 198694] + 44C25846 = IAT(MessageBoxW)

image-20211231104400935

0x02 代码混淆引擎

代码混淆引起所使用的指令都是不常见的指令,我们可以一眼就识别出来比如 rcr,bt,btc,sbb,lahf等。

image-20220102150822685

code2

block

0x03 虚拟化引擎

这一部分比较复杂,我主要参考的是这篇文章 ,以x86的VMP保护为讲解例子,如果启动了VMP加外层壳VMP的handle和混淆变异的代码会在.vmp1这个节区里,否则都在.vmp0节区。

进入虚拟机的标志是push uint32 加上 call function 跳转到.vmp1的节区进行操作,在大多数情况下这个call是不会返回的,更像是一个跳转。

img

其中这个push的32位数是虚拟opcode表起始位置加密后的值。

call进去后就开始依次执行每一个handle了,在每个handle里面都存在的大量的代码混淆阻碍逆向分析。

image-20220126212411013image-20220126213041291

原文中的例子
======================================================================
0x7ae901:    mov    ecx, dword ptr [esi]
0x7ae905:    lea    esi, [esi + 4]
0x7ae914:    movzx    eax, byte ptr [ebp]
0x83e3c2:    lea    ebp, [ebp + 1]
0x7d7bf8:    mov    dword ptr [esp + eax], ecx
...
0x8429bf:    add    edi, ecx
0x6d015e:    jmp    edi
======================================================================
0x755912:    mov    ecx, dword ptr [esi]
0x75591a:    lea    esi, [esi + 4]
0x755925:    movzx    eax, byte ptr [ebp]
0x6c94c6:    mov    dword ptr [esp + eax], ecx
0x6c94d7:    lea    ebp, [ebp + 4]
...
0x79cdbd:    add    edi, ecx
0x79cdbf:    push    edi
0x79cdc0:    ret    
======================================================================
0x7b821a:    mov    ecx, dword ptr [esi]
0x7b8222:    lea    esi, [esi + 4]
0x7b822b:    movzx    eax, byte ptr [ebp]
0x7b695c:    mov    dword ptr [esp + eax], ecx
0x7b6966:    lea    ebp, [ebp + 4]
...
0x7637cb:    add    edi, ecx
0x78cc6a:    jmp    edi

我们可以详细分析每个handle的实际作用,在这篇文章中较为详细的分析了VPUSH16 [VCTX + *] 这个handle具体实现方式

0x45bf82:    VUNKNOWN: (VIP = esi, VSP = ebp)

# update VIP to point on operand (current VIP is pointing on opcode offset)
0x45bf82:    lea    esi, [esi - 1]

# get the ciphered operand (1 byte)
0x45bf8c:    movzx    eax, byte ptr [esi]

# mutated operand decryption (keychain)
# NOTE : ebx contain the rolling key
0x45bf94:    xor    al, bl
0x45bf99:    ror    al, 1
0x40a4fa:    dec    al
0x40a505:    not    al
0x40a507:    dec    al
0x40a514:    xor    bl, al

# push a value into vm stack from vm context
# eax = 8; VCTX[8] -> [VSP-2] = VPUSH R8
0x40a51a:    movzx    dx, byte ptr [esp + eax]
0x40a51f:    sub    ebp, 2
0x40a529:    mov    word ptr [ebp], dx

# update VIP to the next ciphered opcode offset
0x40a531:    lea    esi, [esi - 4]

# get next ciphered opcode offset
0x40a537:    mov    ecx, dword ptr [esi]

# mutated next handle offset decryption routine (keychain)
# NOTE : ebx contain the rolling key
0x40a53e:    xor    ecx, ebx
0x438108:    sub    ecx, 0x5eac74dd
0x43810e:    cmc    
0x43810f:    not    ecx
0x41743d:    bswap    ecx
0x41743f:    rol    ecx, 1
0x4513d8:    neg    ecx
0x4513da:    stc    
0x4513db:    xor    ebx, ecx

# update absolute handle position with the next handle offset
0x4513e0:    add    edi, ecx

# reset the next rolling key operand
0x4752b4:    lea    ecx, [esp + 0x60]

# jump to the next handle
0x461417:    push    edi
0x461418:    ret    

VM的体系结构

VIP和VSP是存储在一个随机的寄存器中(register base),下图为VMP 上下文的包含关系图。

img

一条指令由两部分组成,一个加密的handle偏移量和它加密后的参数(操作码和操作数)

img

某段VMP例程以一个VENTER指令开始,以一个VEXIT指令结束,以下为常见的VM指令。

VENTER, VEXIT, VADDU*, VNANDU*, VNORU*, VPUSHV, VPOPR, VPOPVSP, VPUSHVSP, VPUSHI*
VFETCH*, VJUMP_*, VMOV*, VSHLU*, VSHRU*, VMULU*, VDIVU*, ....

0x04 参考资料

关于unicorn去搞VMP的iat那点事

Quick look around VMP 3.x

VMP导入表修复

(VMProtect 分析)跟着ida和WinDbg逛VirtualMachine

VM保护攻防

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