Reversing VAC
June 3, 2026 · View on GitHub
Static analysis of Valve's anti-cheat in CS2. Two separate components with different scanning strategies: client.dll runs inside the game process, steam.exe scans it from outside.
Scope note: this covers the standard module enumeration pipeline. Streamed modules (a separate loading mechanism that allowed VAC to pull scan code on demand) are currently disabled and not covered here.
Table of Contents
- Architecture
- Trusted Launch
- Boot Sequence
- In-Process Scanner — client.dll
- External Scanner — steam.exe
- Coverage Gaps
- Signatures
Architecture
+--[ steam.exe ]-----------------------------------------------+
| External scan engine |
| Syscalls: NtReadVirtualMemory + NtQueryVirtualMemory only |
| Pure usermode -- no kernel driver |
| 4 independent process handles -> cross-verification reads |
+------------------------------+-------------------------------+
| cross-process reads
v
+--[ cs2.exe ]-------------------------------------------------+
| +-- Trusted Launch (pre-engine: catalog check + NtOpenFile)|
| +-- engine2.dll -> client.dll |
| CDllVerificationMonitor |
| In-process scanner -- output via g_pNetworkChannel |
+--------------------------------------------------------------+
The external scanner is surprisingly minimal — confirmed syscalls over 5 minutes: NtReadVirtualMemory (192,006) + NtQueryVirtualMemory (2,347). Everything through KERNELBASE.dll. No ETW, no driver.
Trusted Launch — cs2.exe (pre-engine)
Runs before engine2.dll even loads, completely independent of client.dll. On init: loads 4 signature catalogs via CryptCatAdminAcquireContext2, hooks 3 APIs (including NtOpenFile), registers a VEH.
The NtOpenFile hook is the main gate — any open with DesiredAccess & 0x21 (READ + EXECUTE) triggers a catalog signature check. Unsigned files get STATUS_OBJECT_NAME_NOT_FOUND (0xC0000034).
Launch flags: -insecure skips the whole thing, -trusted requires full catalog verification.
Boot Sequence
T+0.0s cs2.exe created
T+0.1s System32 runtime
T+6.2s tier0.dll <- first Valve DLL
T+7.0s steam_api64.dll
T+7.1s steamclient64.dll <- VAC registration point
T+9~10s steam.exe begins VAS walk
T+17s client.dll <- in-process scanner starts
In-Process Scanner — client.dll
Everything client.dll sends goes through a single vtable slot on the game network channel as protobuf-encoded CSVCMsg_UserMessage.
Message Protocol
| Msg | Dir | Content | When |
|---|---|---|---|
| 158 | C→S | Module tree CRC32 (two passes) + name/path entries | Once at connect |
| 159 | C→S | 40+ fields: loaded DLLs, PE timestamps, BSecureAllowed, IsDebuggerPresent, CPUID, cmdline | Once at connect |
| 161 | C→S | PE hashes + engine VMT frequency map + 112 interface CRCs | 4× batched |
| 162 | S→C | Server-directed: DR dump / memory read / export probe / code exec | On demand |
| 163 | C→S | DR registers + VEH chain (10 handlers) + exception ring buffer (64×2944B stack dumps) | ~5s + on demand |
| 164 | C→S | Field monitor: unknown-module retaddrs, data hash mismatches | Per event |
| 385 | C→S | Counter-strafe telemetry | Per input |
Dispatch on UM_RequestDllStatus:
Server -> UM_RequestDllStatus
+-- VAC_IsConnectedToVACServer
+-- CDllVerificationMonitor_Init -> trust gate (see below)
+-- BuildTelemetry -> msg 159
+-- VAC_InventoryResponse_Dispatcher
+-- Scanner 3: interface CRC scan
+-- Scanner 2: engine VMT frequency map
+-- Scanner 1: registered module PE hashes
-> msg 161
CDllVerificationMonitor_Init
Called at scan start and each game frame:
bool CDllVerificationMonitor_Init(Context *ctx) {
if (!ctx->pfnGetTotal || !ctx->pfnNeedCheck || !ctx->pfnCompleted
|| !ctx->pfnBSecureAllowed || !ctx->pfnCountItems)
return false;
ctx->nCountCurrent = ctx->pfnCountItems();
if (!ctx->bInsecureFlag) {
// fails if any unsigned module is loaded
ctx->bInsecureFlag = (ctx->pfnBSecureAllowed(0, 0, 0) == 0);
if (ctx->bInsecureFlag) {
g_VAC_InsecureFlag = 1;
g_pVAC_NotifyInterface->vtable[1472](g_pVAC_NotifyInterface);
}
}
return ctx->nCountCurrent != ctx->nCountPrevious;
}
CollectThreadInfo
THREAD_ATTACH via ConcRT. Start address from NtQueryInformationThread(class 9):
HMODULE CollectThreadInfo(Funct_Structs *ctx) {
NtQueryInformationThread(GetCurrentThread(), 9, &threadStart, 8, 0);
VirtualQuery(threadStart, &mbi, sizeof(mbi));
GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, threadStart, &hMod);
bool suspicious = !hMod // no backing module
|| mbi.Protect == PAGE_EXECUTE_READWRITE // RWX
|| hMod == ctx->kernel32_dll
|| hMod == ctx->kernelbase_dll;
if (suspicious) {
ctx->phModule = hMod;
ctx->threadId = GetCurrentThreadId();
ctx->Protection = mbi.Protect;
ctx->retAddress = (uint64_t)__builtin_return_address(0);
memcpy(ctx->thread_start_page, PAGE_ALIGN(threadStart), 0x1000);
memcpy(ctx->NtOpenFile_prologue, ctx->pNtOpenFile, 8);
memcpy(ctx->LoadLibraryExW_prologue, ctx->pLoadLibraryExW, 8);
}
}
VEH Exception Handler
Priority 1. STATUS_ACCESS_VIOLATION + STATUS_SINGLE_STEP — Dr6 & 0xF filters out single-steps not caused by hardware BPs:
LONG CALLBACK VACExceptionHandler(EXCEPTION_POINTERS *ep) {
DWORD code = ep->ExceptionRecord->ExceptionCode;
if (code != STATUS_ACCESS_VIOLATION &&
(code != STATUS_SINGLE_STEP || (ep->ContextRecord->Dr6 & 0xF) == 0))
return EXCEPTION_CONTINUE_SEARCH;
// deduplicate: same addr+code+info+RSP+Dr6 -> skip
// ...
slot->ExceptionAddress = ep->ExceptionRecord->ExceptionAddress;
slot->ExceptionCode = code;
slot->ExInfo[0] = ep->ExceptionRecord->ExceptionInformation[0]; // R/W
slot->ExInfo[1] = ep->ExceptionRecord->ExceptionInformation[1]; // fault addr
slot->Rsp = ep->ContextRecord->Rsp;
slot->Dr6 = ep->ContextRecord->Dr6;
// raw stack dump, 8-byte aligned, capped at page boundary
size_t copyLen = min(2944u, 4096 - (RSP & 0xFFF)) & ~7u;
memcpy(stackDumpBuf + offset, (void*)RSP, copyLen);
slot->StackDumpPtr = stackDumpBuf + offset;
slot->StackDumpSize = copyLen;
// self-heal: AV inside client.dll .text -> restore PAGE_EXECUTE_READ
if (code == STATUS_ACCESS_VIOLATION && faultAddr <= client_text_end)
Plat_VirtualProtect(faultAddr, PAGE_EXECUTE_READ);
ep->ContextRecord->EFlags |= 0x10000; // TF -> SINGLE_STEP on resume
return EXCEPTION_CONTINUE_EXECUTION;
}
Ring buffer holds 64 entries, each with up to 2944B of raw stack from RSP. Gets flushed on every type 163 response.
Interface CRC Scanner (Scanner 3)
112 Source2 interfaces. Per interface:
void **vtable = *instance;
uintptr_t vfunc0 = vtable[0];
// stable across ASLR; breaks if vtable ptr is relocated or hooked
uintptr_t offset = (uintptr_t)vtable - vfunc0;
CRC32_Update(&accumCRC, &offset, 8);
detail.vtable_ptr = vtable;
detail.vtable_0 = vfunc0;
detail.deref_0 = *(uintptr_t*)vfunc0; // first instruction of vfunc[0]
// DJB2 variant -- seed 1171724434, multiplier 33
uint32_t h = 1171724434;
for (const char *p = interface_name; *p; p++)
h = *p + 33 * h;
detail.name_hash = h;
vtable[0] is the only slot that goes into the CRC. The rest are reported verbatim but not verified.
PE Module Hash Scanner (Scanner 1)
Plat_GetRegisteredModules(), private working copy, normalized before hash:
VirtualAlloccopy.- Apply
0xA000(DIR64) relocations only. - Zero
DataDirectory[0](EAT) andDataDirectory[12](IAT). - Skip sections where
Characteristics & 0x80000000(MEM_WRITE). - CRC32 + SHA1 over the rest.
Section coverage in client.dll:
| Section | Characteristics | Hashed |
|---|---|---|
.text | 0x60000020 | yes |
.rdata | 0x40000040 | yes (IAT zeroed) |
.data | 0xC0000040 | no — MEM_WRITE |
.pdata | 0x40000040 | yes |
.reloc | 0x42000040 | yes — DISCARDABLE alone doesn't exclude |
Worth noting: IAT exclusion comes from zeroing DataDirectory[12], not from any section name matching.
DLL Status Report (msg 159)
40+ fields sent once at connect:
- module list: name/base/size for every loaded DLL (
CModuleListSnapshot) - PE timestamps:
TimeDateStampof client.dll, kernel32, cs2.exe, ntdll, gameoverlayrenderer64 - main thread TID:
GetCurrentThreadId()twice (fields 25 and 34) — only TID the server ever receives BSecureAllowed(0,0,0)result + 100KB violation stringIsDebuggerPresent(), CPUID, full cmdline
Server-Directed Diagnostics (msg 162 → 163)
VAC_HandleDiagnosticRequest_Dispatcher:
| Case | Action |
|---|---|
| 23 | Call stored function pointer — 4 conventions. With Case 30: arbitrary CFG-valid execution inside VAC. |
| 26 | OpenThread + SuspendThread + GetThreadContext(CONTEXT_ALL) → DR0–DR3, DR7, RSP, RIP for server-specified TID. |
| 27 | Reset exception ring buffer (xchg g_VAC_ExceptionSlotCount, 0). |
| 28 | Read up to 1024B from any address the server provides. |
| 29 | GetModuleHandleA + GetProcAddress + read N bytes of the export. |
| 30 | Write two QWORDs used as function pointers by Case 23. |
External Scanner — steam.exe
NtReadVirtualMemory + NtQueryVirtualMemory only.
Phase 1 — VAS Walk (~2.5s at startup)
Walks every committed page, pulls MemoryBasicInformation + MemoryMappedFilenameInformation. Builds 153-entry module list in a dedicated heap range. 21 anonymous regions return STATUS_FILE_INVALID and get flagged. Second walk ~4.5 min later from thread pool.
Phase 2 — Polling Loop
Per module, continuous:
[1] Read 136B -- module descriptor struct
[2] Read 16B -- sub-field of descriptor
[3] Read 8B -- VAC's protected arena (stored snapshot)
[4] Read 8B -- live win32k UMI shared section address
[3] vs [4]: pointer integrity check. At init VAC reads a pointer from the win32k UMI shared section, stores it in a protected arena, then polls it against the live value. Resolves to descriptor[0]+0x10.
Phase 3 — Periodic Full Scan (~10–20s, thread pool)
Double-pass read of all 153 descriptors, writes a 20-byte result to a fixed address.
Phase 4 — Cross-Process Self-Verification
Opens 4 real handles to itself. Same 153 modules, 312B each.
| Handle | Reads (5 min) | Size |
|---|---|---|
| 1 | 3,649 | 312B |
| 2 | 3,649 | 312B |
| 3 | 3,650 | 312B |
| 4 | 4,000 | 312B |
Bytes 136–312 = per-section hash buffer pointers, separate arena.
Module Descriptor Layout (partial)
struct VAC_ModuleDescriptor { // 0x140 bytes
/* +0x00 */ QWORD heap_ptr;
/* +0x08 */ QWORD* umi_slot_1; // win32k UMI -> &descriptor+0x00
/* +0x10 */ QWORD heap_ptr_2;
/* +0x18 */ QWORD* umi_slot_2_watched; // continuously polled
/* +0x30 */ QWORD module_base; // cs2.exe base
/* +0x38 */ QWORD entry_point;
/* +0x40 */ QWORD module_size; // 0x432000 for cs2.exe
/* +0x60 */ QWORD* polled_sub_field; // 16B read in Phase 2 step [2]
/* +0x80 */ DWORD hash_candidate; // purpose unconfirmed
/* +0x98 */ QWORD* next_descriptor;
/* +0xC8 */ QWORD[] section_bufs[]; // per-section hash buf ptrs
};
Coverage Gaps
- no E9/FF25 hook scan —
NtOpenFile+LoadLibraryExWfirst 8B snapshotted on suspicious threads, nothing else - no
VirtualQueryregion walk — one call total, per-frame stack RA check only - no thread enumeration — no
TH32CS_SNAPTHREAD, noNtQuerySystemInformation(5) - no EAT integrity —
DataDirectory[0]zeroed before hashing, no separate check - no
.dataintegrity — MEM_WRITE flag causes the PE hasher to skip it - no signature/blacklist — no hardcoded names or patterns, collect → send → server decides
- no continuous DR polling — hardware BPs visible only if they fire (VEH) or server requests a dump (Case 26)
Signatures
Verified unique in client.dll. ? = relative offsets / absolute operands. Signatures may break on updates.
Core Scan Pipeline
| Function | Signature |
|---|---|
VAC_InventoryResponse_Dispatcher | 48 89 5C 24 18 48 89 7C 24 20 55 48 8D 6C 24 D0 48 81 EC ? ? ? ? 48 8B F9 33 D2 48 8B 0D ? ? ? ? |
VAC_HandleDiagnosticRequest_Dispatcher | 89 54 24 10 53 56 57 41 54 41 55 41 56 41 57 48 81 EC ? ? ? ? 48 8B D9 48 89 8C 24 38 01 00 00 |
VAC_ScanInterfacePointers_CRC | 40 53 41 55 41 57 48 83 EC ? 48 89 AC 24 80 00 00 00 4C 8B F9 48 89 74 24 48 0F 31 48 C1 E2 ? |
BuildTelemetry | 44 88 4C 24 20 4C 89 44 24 18 48 89 54 24 10 48 89 4C 24 08 B8 ? ? ? ? E8 ? ? ? ? 48 2B E0 |
VAC_SendDllStatus | 48 89 4C 24 08 48 81 EC ? ? ? ? 48 8D 8C 24 E0 00 00 00 E8 ? ? ? ? 45 33 C9 45 33 C0 33 D2 |
Diagnostic & Telemetry
| Function | Signature |
|---|---|
VACExceptionHandler | 41 56 48 81 EC ? ? ? ? 4C 8B F1 48 85 C9 0F 84 ? ? ? ? 48 8B 09 44 8B 01 41 81 F8 ? ? ? ? |
VAC_RegisterVEHHandler | 48 83 EC ? 48 83 3D ? ? ? ? ? 75 ? 48 8D 15 ? ? ? ? B9 ? ? ? ? FF 15 ? ? ? ? |
VAC_CollectThreadDiagnostics | 48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18 48 89 7C 24 20 41 56 48 81 EC ? ? ? ? 8B EA 48 8B F1 |
VAC_DumpVEHChain | 44 89 44 24 18 53 41 57 48 83 EC ? 33 DB 4C 8B FA 48 85 D2 74 ? 48 8B 5A 08 48 8B 03 48 39 43 08 |
VAC_SerializeStackFrames | 48 83 EC ? 8B 05 ? ? ? ? 83 F8 ? 76 ? B8 ? ? ? ? EB ? 85 C0 0F 8E ? ? ? ? 48 89 6C 24 68 |
VAC_SerializeDebugMonitor | 48 83 EC ? 48 89 5C 24 50 48 89 6C 24 60 48 89 74 24 68 4C 89 64 24 38 4C 89 6C 24 30 4C 8B E9 |
VAC_Send_ExtraUserData_164 | 48 89 5C 24 08 48 89 74 24 10 48 89 7C 24 18 4C 89 64 24 20 55 41 56 41 57 48 8D 6C 24 B9 48 81 EC ? ? ? ? 44 8B F9 49 63 D9 48 8B 0D ? ? ? ? |
Thread & Module Inspection
| Function | Signature |
|---|---|
CollectThreadInfo | 48 89 4C 24 08 48 81 EC ? ? ? ? 48 C7 44 24 38 ? ? ? ? 48 C7 44 24 30 ? ? ? ? FF 15 ? ? ? ? |
CDllVerificationMonitor_Init | 48 89 4C 24 08 48 83 EC ? 48 83 3D ? ? ? ? ? 75 ? E8 ? ? ? ? 90 48 8B 4C 24 40 E8 ? ? ? ? |
VAC_ModuleRangeCache_Check | 48 89 5C 24 10 48 89 74 24 18 57 41 56 41 57 48 83 EC ? 48 8B F9 33 DB 48 8B F2 8B CB 8B 97 00 05 00 00 |
VAC_MonitorFieldAccess | 40 53 41 57 48 83 EC ? 48 89 74 24 58 48 8B F1 48 89 7C 24 60 49 8B F8 4C 89 74 24 30 4C 63 F2 |
VAC_RetTracker_RateLimitedSend | 48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18 48 89 7C 24 20 41 56 48 83 EC ? 41 8B F1 48 63 F9 |