初探Android_Linker(思维导图版)
0x0 前言
本文主要参考肉丝姐的俩篇介绍Linker文章,具体地址会放到文末参考链接中,我主要讲文字整合成思维导图方便回忆。
本文主要参考肉丝姐的俩篇介绍Linker文章,具体地址会放到文末参考链接中,我主要讲文字整合成思维导图方便回忆。
太久没发文章了,把存货发出来了,当作存档了<@_@>,仅供学习交流
mhy这个驱动漏洞也很有意思:https://bbs.pediy.com/thread-272873.htm
对于IL2CPP的Unity游戏,它的核心游戏逻辑一般都是放在 *Assembly.dll这个DLL里面的,我们global-metadata.dat解析出对应的符号也是作用于这个DLL的。正常游戏都是放在游戏主程序根目录下,原神做了一个目录迁移,用ProcessHacker可以找到真实路径。
游戏版本: genshin-impact-2.4
观察global-metadata.dat文件,发现被加密了
原神的Unity版本为2017.4.30,下载il2cpp的源码进行对照逆向
https://github.com/4ch12dy/il2cpp/tree/master/unity_2017_x
il2cpp 加载的关键函数和结构
void MetadataCache::Initialize()
void* MetadataLoader::LoadMetadataFile(const char* fileName)
struct Il2CppGlobalMetadataHeader
通过源码和IDA逆向对比可以得出对应关键函数的位置以及一些重要全局变量,我们修改IDA中伪代码的名称,在LoadMetadataFile函数中发现疑似对global-metadata.dat进行解密的函数操作。
对这个函数交叉引用可以发现是在il2cpp_init_security这个函数中进行初始化的,在结合x64dbg动态调试可以发现在这里初始化的三个函数都是在UnityPlayer.dll中的。
并且这三个函数还加了代码混淆和反调试,静态看不是很清晰。
基本上就是靠IDA静态分析以及结合源码进行判断,分别为以下三个函数
其中对GetStringLiteralFromIndex的判断如下,通过对比可以猜测原神对这个函数做了其他操作(字面量二次解密之类的)
对GetStringFromIndex的判断
然后在IDA的Local Types界面把Il2CppGlobalMetadataHeader结构导入,观察源码和IDA中伪代码的解析可以发现原神对这个结构做了改动。
通过进一步的分析发现原神修改了如Il2CppGlobalMetadataHeader,Il2CppTypeDefinition,Il2CppMethodDefinition,Il2CppFieldDefinition和Il2CppPropertyDefinition等诸多IL2CPP加载过程中要使用的结构。
手动调用从UnityPlayer.dll获取的DecryptMetadata函数对global-metadata.dat进行解密,发现只进行了部分位置解密,而且没有出现dat文件的特征头。
手动把mhyprot2.sys卸载后,迅速将内存中的global-metadata.dat Dump下来然后和我们手动调用解密函数后的文件进行比对,发现global-metadata.dat是存在二次解密的,并且是有规律的不同,间隔的字节数和文件大小有关系。
观察和查找资料后可用发现是每隔(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;
}
我们可以用Il2CppInspector这个工具编写插件来自动化操作
得到符号信息,可以发现mhy对非关键函数做了源码层面的哈希操作。
将CE源码中CE相关字符串替换掉成系统相关进程名(如:svchost.exe)重新编译,或者直接修改"cheatengine-x86_64.exe" 进程名为 "svchost.exe"。
global-metadata文件经历了两次解密,原神dump下来的符号文件意义不大,非unity的关键函数都做了源码层面的混淆。CE进程检测较为草率了,不知道有没有其他交叉检测。
目前Unity的游戏主要分为Mono和IL2CPP这两种,最主要的区别就是看游戏目录,如果有Assembly-CSharp.dll这个Dll则该游戏使用的是Mono虚拟机,否则就是使用IL2CPP。
steam游戏的本体可以在 xxx\steamapps\common\游戏名字\
找到。
使用Mono虚拟机的unity游戏,它的核心逻辑都是写在Assembly-CSharp.dll这个Dll里,所以我们可以使用(dnspy)[https://github.com/dnSpy/dnSpy/releases/tag/v6.1.8]进行反编译,并且找到SteamManager这个类。
然后修改Awake这个函数最方便的就是修改方法,如果不行就直接修改IL指令和后续的IL2CPP修改汇编类似。
替换游戏目录下的Assembly-CSharp.dll(记得备份原来的)即可。
使用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
复制到同一目录。
执行命令行Il2CppDumper.exe ,得到几个json和il2cpp.h。
使用IDA打开GameAssembly.dll执行ida_with_struct_py3.py(在Il2CppDumer目录下)脚本,分别载入以下json和h文件,即可获得该DLL的符号信息(感觉类似pdb的作用)。
同样找到SteamManager.Awake这个函数,直接Hex修改函数头使其直接返回,即可绕过验证。
最后保存并替换原始的GameAssembly.dll(记得备份)。
以上内容仅为Unity游戏逆向和Il2CppDumper的学习笔记,仅供学习参考,切勿用于违法违规行为。
本文应该算作个人学习VMP保护的笔记,所以内容较为空泛。详细的VMP虚拟保护壳的学习可以参考文末的参考资料。
使用VMProtect保护后的程序添加了两个新节区
壳代码也是虚拟化过的
寻找OEP的方法
修复IAT表
虽然能看到程序使用了哪些API,却不能通过交叉引用来静态分析,因为VMP保护后的程序导入地址是运行时动态计算的
参考源哥用Unicorn还原IAT表的文章
mov ebx, offset byte_407DD1
mov ebx, [ebx+198694h]
lea ebx, [ebx+44C25846h]
xchg ebx, [esp+0]
retn
[407DD1 + 198694] + 44C25846 = IAT(MessageBoxW)
代码混淆引起所使用的指令都是不常见的指令,我们可以一眼就识别出来比如 rcr,bt,btc,sbb,lahf等。
这一部分比较复杂,我主要参考的是这篇文章 ,以x86的VMP保护为讲解例子,如果启动了VMP加外层壳VMP的handle和混淆变异的代码会在.vmp1这个节区里,否则都在.vmp0节区。
进入虚拟机的标志是push uint32 加上 call function 跳转到.vmp1的节区进行操作,在大多数情况下这个call是不会返回的,更像是一个跳转。
其中这个push的32位数是虚拟opcode表起始位置加密后的值。
call进去后就开始依次执行每一个handle了,在每个handle里面都存在的大量的代码混淆阻碍逆向分析。
原文中的例子
======================================================================
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
VIP和VSP是存储在一个随机的寄存器中(register base),下图为VMP 上下文的包含关系图。
一条指令由两部分组成,一个加密的handle偏移量和它加密后的参数(操作码和操作数)
某段VMP例程以一个VENTER指令开始,以一个VEXIT指令结束,以下为常见的VM指令。
VENTER, VEXIT, VADDU*, VNANDU*, VNORU*, VPUSHV, VPOPR, VPOPVSP, VPUSHVSP, VPUSHI*
VFETCH*, VJUMP_*, VMOV*, VSHLU*, VSHRU*, VMULU*, VDIVU*, ....
原文链接:https://whereisr0da.github.io/blog/posts/2021-03-10-quick-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