Valve Anti Cheat - Part 1 : Module loading
I already taked about VAC and how useless it is. And recently I decided to take a closer look to it, so this is my quick analysis. My goal will be to understand how VAC execute its modules, and in a Part 2, understand those modules.
NOTE : I’m not a game hacker in the first place, so if something is not accurate, feel free to tell it.
What is it ?
VAC (Valve Anti Cheat) is an userland Anti Cheat made to scan and detect external cheats (other processes), or internal cheats (in game process).
Long story short, userland means that it has not acess to advenced features and TRUE system monitoring (kernel-land hypervisors). This is the major issue about VAC, and it’s one of the main reasons VAC protected games can be defeated easily. As it’s userland, it’s core is based in an running executable (or service) in an accessible space regarding the system. It means that we can possibly inject / execute code easily then kernel-land stuff (signed driver, secure boot, ..). By advenced features, I mean that it can only monitor processes like any other userland process does, which doesn’t give a complete scope of investigation. So VAC can’t detect hardware cheats (unless it’s visible userland of course), it also can’t properly deal with kernel-land cheats (only enumerate driver list, check names and signatures, and low the trust factor if you allow unsigned drivers on your computer).
Considering that, we can already code a VAC bypass with a driver that could block each steam process interactions with an external process that modify the game memory. We can also write a kernel-land cheat that will directly write stuff in the game memory. We can sign this driver with a leaked private certificat trusted by microsoft to make it more legitimate. Or we can even modify the Windows boot loader to allocate our driver on the bottom of a legitimate driver. Or even abuse of another software by hidding the cheat in it, in an Anti-virus for example as they write in other processes frequently. The only limit is your creativity.
I will stop here for the technical problems of userland anti-cheats, but if you want to take a closer look to it, there are a lot of ducumentation on forums like unknowncheats.
About what we are doing here, know that there are two different things to consider, VAC the Anti-Cheat made by Valve which can be applied to a lot of games. And custom VAC modules that can be applied for specific games like CSGO or Call of Duty. Those two are delivered as modules (executables), and each time you launch a VAC protected game, those modules are downloaded and executed to check specific “cheat” patterns.
NOTE : the case of CSGO is different because CSGO itself implements Anti-Cheat behaviors. Everyone consider them as part of VAC because it’s the same developpers.
Let’s see
So after documenting on what was already done by good game hackers, we know that VAC core is stored in steamservice.dll
which could be used in SteamService.exe
, or in steam.exe
if Steam is executed with administrator rights.
Here are some interesting routines I’ve reversed :
This is one of the routine that execute VAC modules (there are a lot of them, but it seems like it’s the most used).
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;
}
Here is the module load routine, as you can see, there are 2 types of module. One which is wrote in temp directory, one which is loaded in memory using 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;
}
}
}
This is the RunPE used by valve :
The tricks here is that for each section, pSectionHeader->Name[0]
is used as the real section size, pSectionHeader->Name[4]
is the offset of the crypted section.
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;
}
This is the decryption routine of a module.
Valve uses the DOS header to store informations, Those informations are located right after the end of the DOS header, through the e_lfanew
value of the DOS header as an offset. Those informations are section’s decryption keys, and CRC. Valve uses RSA to cipher them, as always, the RSA public key is stored in the executable.
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;
}
}
Finaly the struct used :
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;
};
Bypass
Well, people already covered this, it’s simple. You can disable the execution of module, and pretend that everything is fine.
You can hook GetVacModuleEntrypoint to load the module without executing it, and unload it right after. I reality you have to patch the return value (VacModuleResult_t
) to make this work.
Sadly, some modules are necessary to play some games like CSGO. So you have to filter what module should be patched.
NOTE : CRC are somehow unique per Steam ID. So you have to take another detection vector, like hashing .text
section size like people did.
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;
}
Conclusion
As you can see, the bypass is pretty doable considering that there is no other security regarding VAC itself (no integrity check, no obfuscation, usermode anticheat ..).
If VAC has a bad reputation in the first place, it’s because its directors don’t want to invest in it. But it’s more related to Valve itself. In 2016, devs said that each public cheat (source code on github) will be flagged, and people that will try to use it will be banned. Of course, nothing of this is true (at least for the most part). A lot of people already published bypasses using hooks in 2018, and there are still working today without any banning issues.
But you don’t even need to touch to VAC itself. You can just deal with it by staying under the radar using tricks like code mutation, “hidden” hooks and DLL hijacking. I’m doing it since 2018, and as soon as you don’t copy public cheat source codes, you don’t get banned at all.
In the next part, I will reverse some modules to see what they actualy check.