Reversing VAC

June 3, 2026 · View on GitHub

Platform Target License

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

+--[ 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

MsgDirContentWhen
158C→SModule tree CRC32 (two passes) + name/path entriesOnce at connect
159C→S40+ fields: loaded DLLs, PE timestamps, BSecureAllowed, IsDebuggerPresent, CPUID, cmdlineOnce at connect
161C→SPE hashes + engine VMT frequency map + 112 interface CRCs4× batched
162S→CServer-directed: DR dump / memory read / export probe / code execOn demand
163C→SDR registers + VEH chain (10 handlers) + exception ring buffer (64×2944B stack dumps)~5s + on demand
164C→SField monitor: unknown-module retaddrs, data hash mismatchesPer event
385C→SCounter-strafe telemetryPer 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_STEPDr6 & 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:

  1. VirtualAlloc copy.
  2. Apply 0xA000 (DIR64) relocations only.
  3. Zero DataDirectory[0] (EAT) and DataDirectory[12] (IAT).
  4. Skip sections where Characteristics & 0x80000000 (MEM_WRITE).
  5. CRC32 + SHA1 over the rest.

Section coverage in client.dll:

SectionCharacteristicsHashed
.text0x60000020yes
.rdata0x40000040yes (IAT zeroed)
.data0xC0000040no — MEM_WRITE
.pdata0x40000040yes
.reloc0x42000040yes — 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: TimeDateStamp of 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 string
  • IsDebuggerPresent(), CPUID, full cmdline

Server-Directed Diagnostics (msg 162 → 163)

VAC_HandleDiagnosticRequest_Dispatcher:

CaseAction
23Call stored function pointer — 4 conventions. With Case 30: arbitrary CFG-valid execution inside VAC.
26OpenThread + SuspendThread + GetThreadContext(CONTEXT_ALL) → DR0–DR3, DR7, RSP, RIP for server-specified TID.
27Reset exception ring buffer (xchg g_VAC_ExceptionSlotCount, 0).
28Read up to 1024B from any address the server provides.
29GetModuleHandleA + GetProcAddress + read N bytes of the export.
30Write 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.

HandleReads (5 min)Size
13,649312B
23,649312B
33,650312B
44,000312B

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 + LoadLibraryExW first 8B snapshotted on suspicious threads, nothing else
  • no VirtualQuery region walk — one call total, per-frame stack RA check only
  • no thread enumeration — no TH32CS_SNAPTHREAD, no NtQuerySystemInformation(5)
  • no EAT integrity — DataDirectory[0] zeroed before hashing, no separate check
  • no .data integrity — 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

FunctionSignature
VAC_InventoryResponse_Dispatcher48 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_Dispatcher89 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_CRC40 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 ?
BuildTelemetry44 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_SendDllStatus48 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

FunctionSignature
VACExceptionHandler41 56 48 81 EC ? ? ? ? 4C 8B F1 48 85 C9 0F 84 ? ? ? ? 48 8B 09 44 8B 01 41 81 F8 ? ? ? ?
VAC_RegisterVEHHandler48 83 EC ? 48 83 3D ? ? ? ? ? 75 ? 48 8D 15 ? ? ? ? B9 ? ? ? ? FF 15 ? ? ? ?
VAC_CollectThreadDiagnostics48 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_DumpVEHChain44 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_SerializeStackFrames48 83 EC ? 8B 05 ? ? ? ? 83 F8 ? 76 ? B8 ? ? ? ? EB ? 85 C0 0F 8E ? ? ? ? 48 89 6C 24 68
VAC_SerializeDebugMonitor48 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_16448 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

FunctionSignature
CollectThreadInfo48 89 4C 24 08 48 81 EC ? ? ? ? 48 C7 44 24 38 ? ? ? ? 48 C7 44 24 30 ? ? ? ? FF 15 ? ? ? ?
CDllVerificationMonitor_Init48 89 4C 24 08 48 83 EC ? 48 83 3D ? ? ? ? ? 75 ? E8 ? ? ? ? 90 48 8B 4C 24 40 E8 ? ? ? ?
VAC_ModuleRangeCache_Check48 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_MonitorFieldAccess40 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_RateLimitedSend48 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