Vanguard Reversed.
Structural documentation of the Vanguard anti-cheat suite, 1.18.5.x: the seven binaries, the protector stack around them, the kernel interface, every enforcement path from boot handshake to telemetry. Every address, constant, and stride below is read from the binaries. The model behind this text is the driver's own section map, to scale.
One protector, seven binaries
The suite is a single protection scheme applied per target. Three binaries carry the full stack (vgk.sys, vgc.exe, vgm.exe), one ships as a packed shell (vgrl.dll), three are plain (vgtray.exe, log-uploader.exe, uninstall.exe). Version 1.18.5.x, (C) 2019-2026 Riot Games, source tree vanguard-client, Jenkins release builds, DigiCert G4 chain.
Interface facts: vgc never opens a handle to the driver. It locates the vgk base via ZwQuerySystemInformation(SystemModuleInformation) and calls the export thunks Egg_0 (0x1400dd554) and Egg2_0 (0x1400dd818) directly. vgk holds no device object: zero IoCreateDevice imports, zero IOCTL surface. The NalDeviceControl / MAPMEM / MsIo / WinIo strings are blacklist scanner signatures for third-party drivers, not vgk handlers (4.7).
1.1Boot chain, install to kernel
1.2Communication matrix
| Path | Mechanism | Notes |
|---|---|---|
| vgc → vgk | direct export calls Egg_0 / Egg2_0 | base located via ZwQuerySystemInformation(SystemModuleInformation); a private kernel API with no handles and no ACLs in the way |
| vgk → vgc | ace/vgtrace telemetry ring | ring buffer built over an MDL (BuildConsumer kva=%p uva=%p mdl=%p), consumed by vgc via NtMapViewOfSection; an event stream with no per-event IOCTLs |
| vgc ↔ vgtray | named pipe + SCM | NotifyServiceStatusChange for lifecycle, pipe for payload |
| vgc → vgm | parent/child job object | vgm holds no independent channel upward |
| all → Riot | libcurl + OpenSSL | statically linked in vgc, vgm, vgtray, log-uploader |
1.3Kernel enforcement flow
KeStackAttachProcess, MDL mapping, Nt-side mirrors in vgc); object namespace walks (ZwOpen/QueryDirectoryObject); timing baselines (KeQueryPerformanceCounter, 47 sites); on-disk vs in-memory image comparison (IoQueryFileInformation, 51 sites); module identity of captured return addresses (RtlPcToFileHeader)ZwTerminateProcess, 37 sites; terminal wrapper sub_1404428AD, zero static xrefs: the verdict NTSTATUS travels through the CFF dispatch only, no magic exit constants anywhere) or panic the machine (KeBugCheckEx, 46 sites)KeInitializeApc / KeInsertQueueApc into target processes for hooks and reporting0x1400a3c78: SHA-1/256/384/512 by OID against a pinned key table at 0x140105260, stride 0x258; no wintrust dependency in the kernelKeIpiGenericCall, 63 sitesGetSystemFirmwareTable in vgc, service-side sources) feed Riot::ServiceHWIDSource / Riot::ServicePUUIDSource; log-uploader attaches that identity to every upload. Pre-Check texts confirm Riot treats VBS / HVCI / IOMMU as baseline expectations: absence only degrades to "always-on" mode.Seven binaries, measured
Section layout and import counts read straight off the PE headers. Raw sizes are on-disk bytes; vsize is the mapped size. The three .riot1 sections are near-identical copies of one shared runtime blob.
| Binary | Raw size | Protection | Key sections | Imports |
|---|---|---|---|---|
| vgk.sys | 69.4 MB | full stack | .grfn1 62.1 MB payload · .stub0 unpacker · .riot0 directory · .riot1 runtime | 127 |
| vgc.exe | 83.8 MB | full stack | .grfn1 77.2 MB · .riot0 · .riot1 · .toir config | 358 |
| vgm.exe | 27.8 MB | full stack | .grfn1 14.8 MB · .riot0 · .riot1 · .riot2 | 224 |
| vgtray.exe | 10.4 MB | none | plain .text, entry at rva 0x309fcc; D3D11 Pre-Check app | 329 |
| vgrl.dll | 3.2 MB | packer only | everything empty on disk except .stub0/.stub1; entry rva 0x2fb38f inside the stub | 19 |
| log-uploader.exe | 4.3 MB | light | plain .text + .toir (dwords 01 00 01 00 02 01 00 00) | 279 |
| uninstall.exe | 57 KB | none | stub, entry rva 0x42b8 | 90 |
2.1Protection classes
- Full stack (vgk, vgc, vgm): clean
.textreduced to thin thunks whose real bodiesJUMPOUTinto.grfn1; unpacker stage in.stub0; function directory in.riot0; shared runtime in.riot1. - Packer-only vgrl.dll is the extreme case: on disk, every section except the stubs is zero-filled. The entire original image is reconstructed at load time.
- Unprotected (vgtray, log-uploader, uninstall) carry no detection logic of their own, so shipping them plain costs nothing.
- Config lives in
.toirdwords: byte 2 is the per-binary variant (07vgc,0bvgm,01log-uploader); bytes 4-7 differ between protected (22 01 02 00) and lightly protected (02 01 00 00) builds. - All three protected binaries enter the shared runtime at the same PE entry offset: vgk rva
0x3dea13c, vgc0x513d13c, vgm0x140713c, each resolving to.riot1+0x3313C.
2.2Observations worth keeping
- One runtime blob serves three binaries:
.riot1raw sizes vgk0x660600/ vgc0x666091/ vgm0x666000, a common core plus a per-binary tail. - Import counts are meaningless as a difficulty signal: the protector replicates import resolution across 30-90 call sites per API inside
.grfn1, so caller counts carry no semantic signal. .fptableappears in every userland binary; in vgrl it is the plain function-pointer cache left by the packer.- vgtray's
.rdata(3.4 MB) is larger than its code: the Pre-Check strings and D3D11 shader cache dominate. - An analysis-time marker was found embedded in one build: the string
https://i.imgur.com/KOiWz9K.pnginside vgc's.rdata, an image URL with no loader reference. Treated as a developer artifact, not a signal path. _ENTROPYin vgk holds packer entropy metadata; its raw size (0x200) is far below its mapped size (0x1000).
Four layers between you and the code
One protector, applied per target. The layers are independent: the packer hides the image, control-flow flattening destroys structure, mixed boolean arithmetic hides predicates, and the runtime holds the keys to all of it. All four are broken in this analysis.
The code as a cube in four nested cages. One cage expands and drops out every few seconds, in layer order: packer, control-flow flattening, mixed boolean arithmetic, runtime.
L1Packer.stub0 / .stub1 · on-disk encryption
On disk, protected binaries carry zero-filled payload sections; .stub0 and .stub1 hold the unpacker stages. vgrl.dll is the extreme: only the stubs contain data, everything else is reconstructed at load. The stage-2 stub resolves imports through an obfuscated dynamic-import layer whose 31 computed-continuation APIs were all recovered by inverting the wine loader layout (section 06).
Key schedule of the vgrl stage-2 stub, recovered from the unpack loop (stream cipher over the image, keystream derived from the seed 0x3F15A7 and the rolling offset):
; .stub1+0x1C40 · key schedule, one round per 4-byte block mov eax, [rsi] ; ciphertext block xor eax, 0x3F15A7 ; seed constant rol eax, 7 imul eax, 0x6F4D9B1 xor eax, edx ; rolling state mov [rdi], eax ; plaintext block add edx, 0x10 ; offset advance
The IAT itself sits inside the ciphertext region. qiling dies on that by design; the working unpack path is a wine loader with ASLR flags cleared, the same loader path that unpacked vgrl in 06.
L2Control-flow flattening.grfn1 · 32,410 functions
Every real function is compiled into a state machine: a dispatcher loop selects the next basic block through a state variable, and the original CFG survives only as arithmetic on that variable. vgk's .grfn1 holds 32,410 such functions in 62.1 MB. Clean .text keeps 939 readable stubs that JUMPOUT into the flattened bodies.
Structure census across the vgk .text + .grfn1 corpus:
| Structure | Count | Share | Meaning |
|---|---|---|---|
| flattened functions | 32,410 | 64.5% | dispatcher machines in .grfn1 |
| clean thunks | 939 | 1.9% | readable .text stubs, JUMPOUT bridges |
| runtime fragments | 10,440 | 20.8% | .riot1 marshalling and crypto bodies |
| directory triples | 30,521 | .riot0 index of every protected function |
Breadth sample of 3,241 functions for the secondary obfuscation layers: JUMPOUT splitting 32.7%, opaque flag predicates 20.2%, fragment splitting 12.5%, dense MBA 7.8%, junk instruction runs 6.1%, split-Ch constructions 0.4%.
L3Mixed boolean arithmetic.grfn1 · predicates
Predicates and data transforms are rewritten into long chains of bitwise identities that no decompiler simplifies at these depths. The break came from lifting the constants: SHA-1 round constants leaked through the arithmetic, which pinned the underlying construction. Each identity was then proven bit-exhaustively over {0,1}3 and checked against 100,000 random 32-bit vectors; the rebuilt transform reproduces all 10 SHA-1 digests bit-exact against OpenSSL.
| Identity used | Form | Where it surfaces |
|---|---|---|
| choice | Ch(x,y,z) = (x&y) ^ (~x&z) | expands to 5-7 term MBA chains in round functions |
| parity | Par(x,y,z) = x ^ y ^ z | appears as a 3-way xor hidden inside and/xor runs |
| majority | Maj(x,y,z) = (x&y) | (x&z) | (y&z) | split across fragments to break pattern rules |
| shift mixing | ROTLn composed from shifts and ors | rotation constants 5 / 20 / 15 / 25 match SHA-1 K rounds |
Proof artifacts: SHA-1 H initial state and all four K round constants recovered from the lifted equations; the identity set above reconstructs every round.
L4Runtime.riot0 directory + .riot1 shared blob
The protector's own machinery ships as data plus one runtime. .riot0 opens with a header (magic 0x5647020b, directory at +0x128) and then the function directory: 30,521 twelve-byte triples (start RVA, end RVA, descriptor-record RVA) running to +0x59734 exactly. The third field is a pointer, not a hash, verified by dereference. vgk alone appends an 11-slot integrity manifest at +0x59e2c covering 62.1 MB of image (section 4.3).
.riot1 is one shared runtime: 95.7% byte-identical across vgk / vgc / vgm, common prefix 0x330C8, shared PE entry at +0x3313C. It contains the RSA keygen (sub_143DB70D7), SHA-256 (0x6A09E667 initial state) and CRC32 (0xEDB88320 polynomial) cores, and 5,313 marshalling fragments (83.5% of its bodies) that can move up to 26 arguments into place, plus string-builder fragments that construct strings like \System32\ one byte per fragment so no contiguous string ever exists in the image.
| Region | Size | Content |
|---|---|---|
| vgk .riot0 | 0x7a000 | header + 30,521 triples + 11-slot manifest (vgk only) |
| vgk .riot1 | 0x661000 | shared runtime core: RSA, SHA-256, CRC32, marshalling |
| vgc .riot1 | 0x66a000 | same core, per-binary tail |
| vgm .riot1 | 0x666000 | same core, per-binary tail |
| entry offset | +0x3313C | identical PE entry point inside the runtime for all three |
Raw-byte crosscheck of the directory: fragment sizes run median 88 bytes, p90 765, max 59,722; 47.3% of triples sit in .grfn1, 32.7% in .text, 16.2% in .riot1, 3.9% elsewhere. The largest bodies are runtime infrastructure, not detection logic.
3.1Layer map per binary
| Binary | L1 packer | L2 CFF | L3 MBA | L4 runtime |
|---|---|---|---|---|
| vgk.sys | .stub0 | 32,410 fns | dense in round logic | .riot0 manifest + .riot1 |
| vgc.exe | .stub0 | present | present | .riot0 (no manifest) + .riot1 |
| vgm.exe | .stub0 | present | present | .riot0 (no manifest) + .riot1 |
| vgrl.dll | .stub0 + .stub1 total | unpacked at load | unpacked at load | .fptable cache |
| vgtray / uploader / uninstall | none | none | none | none |
The kernel driver, laid open
71 MB on disk, 127 imports, 2 exports. The driver boots through its own protector runtime before any logic runs, exposes exactly one interface (two export functions, no device, no IOCTL), and every verdict it produces travels through computed dispatch that static call-graph analysis cannot see. All of it decoded below.
4.1Section layout
| Section | VA | VSize | Role |
|---|---|---|---|
| .text | 0x140001000 | 0xf4000 | 939 clean stubs, thunks, sig-verify, boot glue |
| sect_1 | 0x1400f5000 | 0x1000 | plain anti-VM helpers: CR4.PGE-toggle TLB flush bracketing raw CPUID(1), bit-protocol over a pointer array, pause-spinlock at 0x1400f507e |
| _ENTROPY | 0x1400f6000 | 0x1000 | packer entropy metadata, all-zero on disk |
| .rdata | 0x1400f7000 | 0xe000 | strings, WPP control blocks, key table pointers |
| .data | 0x140105000 | 0x38000 | globals incl. pinned key table @ 0x140105260 |
| .pdata / .NT / .edata / INIT | 0x14013d000+ | 0x9000 | exception table, boot tables, export dir (Egg/Egg2), INIT freed after load |
| .stub0 | 0x140148000 | 0x40000 | unpacker stub, 24 plain helper functions |
| .riot2 / .rsrc | 0x140188000 | 0x7000 | protector config marker, resources |
| .grfn1 | 0x14018f000 | 0x3bae000 | 62.1 MB CFF + MBA payload: 32,410 flattened functions, the detection battery |
| .riot0 | 0x143d3d000 | 0x7a000 | RW function directory: 30,521 triples + 11-slot integrity manifest (4.3) |
| .riot1 | 0x143db7000 | 0x661000 | shared protected runtime, PE entry at offset 0x3313C |
| .reloc | 0x144418000 | 0x1000 | relocations |
0x1400f5000 toggles CR4.PGE (cr4 &= ~0x80; cr4 |= 0x80) for a locked TLB flush, brackets a raw CPUID(1) with a bit protocol over a pointer array (count at ctx+4, array at ctx+8): clears bit0 of each qword, executes leaf 1, lock bts ctx+0xF, restores bit0, flushes again, lock btr ctx+0. CPUID-side-channel and serialization infrastructure with no direct xrefs, reached only through computed dispatch. INIT holds a boot table at 0x14014700c referencing the literal 'cng.sys': a dependency preload list for the kernel crypto provider consumed by the signature verifier in section 05.4.2Boot chain and the arming gate
DriverEntry is not in .text: the PE entry 0x143dea13c sits in .riot1 at the shared offset +0x3313C. The entry computes a status and either returns quietly or tail-jumps into a hidden second entry whose target is stored in a runtime slot that is zero on disk.
; PE entry 0x143dea13c (.riot1+0x3313C) - exact decode push rbx/rcx/rdx/r8/r9; sub rsp,0x20 lea rbx,[rip-0x8e] ; cb = 0x143dea0c0 (callback block) call sub_143DE1E27 ; runtime init thunk test eax,eax je plain_ret ; eax == 0 cmp eax,0xD4494E49 je plain_ret ; "VGNOINIT" (0xD4494E494F4E4756) ; else: hidden second entry rax = movsxd dword[cb+0x28] ; -0xAD0C0 -> .riot0 base rax = qword[rax+0x10] ; slot = *(.riot0+0x10), ZERO on disk ebx = dword[cb+0x5C] ; 0x52F90 (verified on disk) jmp rax ; target = slot + 0x52F90
- The
mov ebxload zero-extends overcbbeforeadd rax,rbx, so the target isslot + 0x52F90, notslot + cb + 0x52F90. Emulation-proven: slot 0 yieldsrax = 0x52F90exactly. - The armed slot value is
cbitself (0x143dea0c0): target= cb + 0x52F90 = 0x143E3D050, the CFF continuation. - The registrar
0x143dea190storescbinto the slot:lea r8,[rip-0xdd]; call 0x143de1faf; if (rax) *(qword*)rax = r8. Arming the slot means storing cb. The registrar has zero static xrefs; it is reached only through computed directory dispatch. - The magic is computed, not immediate:
VGNOINITmaterializes through an MBA imul-chain (first materialization rva0x4179d36, spilled to 24 stack sites, final load rva0x3e864fb). Only 2 immediate sites exist image-wide: a never-executed.grfn1stub and the DriverEntry compare.
Arming protocol (emulation-proven)
0x143e65fc5: movsxd r9,[rdx+0x28] (same -0xAD0C0 constant), mov eax,[rdx+r9+8], test al,1jmp cb+0x52F90 lands at 0x143E3D050 and the real init runssub_143DE1E27 stops returning VGNOINIT and the hidden tail-jmp executes (slot read observed at 0x143dea173; the run widens from 650k to 5.2M instructions; one runtime write of 0 to .riot0+0x120, a consume-once field read at 0x144085cdf and zeroed by 0x144360784). The unpatched on-disk boot touches .riot0 exactly twice, both on the header page: directory and manifest are not read before arming. Fully-armed emulation runs until the first unresolvable computed indirect call escapes to junk after ~400 faulted pages: the real-API boundary. The on-disk image contains no self-arming path; a header sanity check compares the dword at .riot0+0x4 (the 0x128 directory offset) against an expected value at 0x14407ef20.4.3.riot0: function directory and integrity manifest
The section opens with a header (magic 0x5647020b, directory offset 0x128, config qword 0x6a72aebc at +0x20) and then the protector's own index of every protected function: 30,521 twelve-byte triples, perfectly monotonic, running from rva 0x1310 to 0x43eeebf. The third dword is a pointer, not a hash: for all 30,521 entries it lands inside a mapped section, and consecutive entries map to consecutive variable-length descriptor records that open with a 01 00 xx 00 header and carry a token stream of small integers. The runtime walks these records, hashing them with FNV-1a-64 to drive the CFF dispatch.
The directory as a skyline over rva: slab lengths are the real section spans (x axis 0x1000 to 0x43ef000), heights scale with the per-section triple count, points sample the 30,521 triples inside their slabs, and the bright plane sweeping the axis is the runtime walker hashing descriptor records with FNV-1a-64. Slabs flash as the walker crosses them.
| Indexed section | Triples | rva range | Descriptor records land in |
|---|---|---|---|
| .text | 575 | 0x1000 - 0xf5000 | .rdata descriptors, ~20 clusters in 0x101728 - 0x104c34 |
| .stub0 | 379 | 0x148000 - 0x188000 | .stub0 itself |
| .grfn1 | 19,300 | 0x18f000 - 0x3c9d000 | .grfn1 |
| .riot1 | 10,267 | 0x3db7000 - 0x43ef000 | .riot1 (sample: entries 20254-20256 map to records of 8, 0x18 and 0x10 bytes) |
These counts are the protector's authoritative view and differ from IDA framing (which counts 32,410 .grfn1 functions): the directory merges fragments that IDA frames separately. At +0x59e2c, directly after the directory, vgk alone carries an integrity manifest: 11 fixed 0x78-byte slots, absent from vgc and vgm. Slot layout: +0x00 tag 0x10, +0x08 24-byte digest, +0x20 region rva, +0x24 region size, +0x34 tag 0x2005, +0x38 16-byte aux blob.
| # | Region | rva + size | Note |
|---|---|---|---|
| 1 | sect_1 whole | 0xf5000 + 0x1000 | anti-VM helpers under digest |
| 2 | _ENTROPY | 0xf6040 + 0x1c0 | entropy metadata window |
| 3 | .rdata | 0xf75a0 + 0x9d20 | strings + WPP blocks |
| 4 | runtime slot | 0x10145c + 0x24 | all-zero region, digest nonzero |
| 5 | runtime slot | 0x1016f8 + 0x28 | all-zero region, digest nonzero |
| 6 | .rdata | 0x101724 + 0x36dc | descriptor-record clusters |
| 7 | .data | 0x105000 + 0x238 | globals head |
| 8 | .data | 0x105240 + 0x41c0 | pinned key table 0x140105260 inside |
| 9 | .pdata whole | 0x13d000 + 0x3000 | exception table |
| 10 | .stub0 whole | 0x148000 + 0x40000 | unpacker |
| 11 | .grfn1 whole | 0x18f000 + 0x3bada00 | the entire payload |
- Total covered:
0x3c02800bytes = everything except.text,.riot1and INIT. - The 24-byte digests are not on-disk content hashes: the two all-zero regions carry nonzero digests, and sha1/256/512, md5, blake2s/b, sha3, crc32, adler32, fnv, xor/sum batteries all miss. Expected values of runtime-initialized content or per-region key material.
- No plaintext reference to the manifest offset exists anywhere in the image; the reader is computed dispatch. The hub decompiles point at the family: FNV-1a-64 prime
0x100000001B3appears 93-129 times per hub, with strength-reduced multiples (2x 0x20000000366,3x 0x30000000519,4x 0x400000006CC), so the digests and dispatch keys are FNV-family fingerprints. - A
'vgk.sys'string sits at0x143d967fc, table-driven with 30,803 xrefs into.riot1.
4.4The Egg interface: two exports, nothing else
- No device object exists. vgk imports no
IoCreateDevice(onlyIoCreateFileEx), so there is no IOCTL surface at all. The two exports are the only entry. - The export directory points into .riot1: AddressOfFunctions entries
0x1443eeed0/0x1443eeee0sit 0x11 bytes past the end of the on-disk function directory. The exported entry points are runtime-resolved slots, not static code addresses. - Egg_0: thunk
0x1400dd554→ body0x140271833→ resolver patterncall sub_1401F818B; jmp rax. Egg2_0: thunk0x1400dd818→0x140213c89→call sub_140317F12; jmp rax. Both bodiesJUMPOUTinto.grfn1; the resolvers are themselves CFF. - There is no static command table. The Egg_0 body is a CFF fragment web of 420 reachable fragments; dispatch lives in MBA dispatcher hubs that refuse decompilation (fragments over 9,000 chars). The resolver decodes as
push rax; lea/lea; jmpthrough junk-obfuscated constants: the resolver is the dispatcher entry, continuation = f(registers, runtime state). Opcode encoding is recoverable only dynamically. - Entry is unauthenticated in shape: any kernel-mode caller that finds the vgk base can invoke the exports. vgc locates the base via
ZwQuerySystemInformation(SystemModuleInformation); notably vgc itself carries no plaintext Egg-RVA constants (a dword scan for 0xdd554/0xdd818 finds zero hits, the marshalling lives in its own CFF web, and itsZwQuerySystemInformationimport has zero direct xrefs through XOR-indirection).
Primitive surface reachable from Egg_0 (fragment-BFS)
| Class | APIs |
|---|---|
| crypto | BCryptOpenAlgorithmProvider / CreateHash / HashData / FinishHash / GetProperty / ImportKeyPair / VerifySignature / CloseAlgorithmProvider / DestroyHash |
| threads | PsCreateSystemThread, PsTerminateSystemThread, KeInitializeApc, KeInsertQueueApc (APC injection), KeDelayExecutionThread |
| memory | MmAllocatePagesForMdl, MmBuildMdlForNonPagedPool, MmMapLockedPagesSpecifyCache, IoAllocateMdl, IoFreeMdl |
| process | IoGetCurrentProcess, KeStackAttachProcess / KeUnstackDetachProcess, ZwQuerySystemInformation |
| kill | ZwTerminateProcess x2, KeBugCheckEx |
| files | ZwReadFile / ZwWriteFile / ZwFlushBuffersFile, ZwOpenDirectoryObject / ZwQueryDirectoryObject |
| sync | rundown protection (cache-aware alloc), guarded mutexes, work items, timers, DPCs, KeIpiGenericCall, KeRegisterBugCheckReasonCallback |
rax = 0 after 11,043 instructions, argument-independent for RCX in {0, 1, 2, 3, 4, 0x40, pointer, pointer+contents}: an early exit through the body epilogue 0x27185a. The emulator's memory-read log shows the gate: the dword at .data 0x12bf14 must equal 0x27E81DFE, a cookie written by clean .text scanner init sub_14000E6C8. Egg_0 dispatches only after that scanner-side init ran. With cookie and flags patched, the path widens to 3,027 fragments / 28,461 blocks and still returns 0; the next blockers are XOR import-pointer table pairs in .data (0x1363f8/0x136400+0x136408, 0x1366a0/0x1366a8+0x1366b0) and .riot0 scratch, all zero on disk. Riding Egg_0 gives arbitrary-process APC / kill / memory-view primitives, but the opcode → target mapping only materializes in runtime-armed state.4.5Kill path: verdict encoding
.grfn1, verdict as datasub_1404428AD__noreturn, fragment-marshalled ~26-arg shape, real args ProcessHandle / ExitStatus (ExitStatus at param slot 16), zero static code xrefs: entered only through computed CFF dispatchZwTerminateProcess firesthunk 0x1400f7120, 37 call sites in .grfn1, 17 distinct caller shapes, all passing variable NTSTATUS values: no magic exit-status constants exist at any call siteThe verdict channel is invisible to static call-graph analysis and there is no constants table to patch. Adjacent to the kill sites sit the tamper panics: KeBugCheckEx at 46 sites.
4.6Import census
125 of 127 imports referenced. Caller distribution is uniform, almost every import at 30-90 .grfn1 call sites: an artifact of the flattener replicating resolver stubs, so caller counts do not rank importance.
| Import | Sites | Semantic user |
|---|---|---|
| ExFreePoolWithTag | 90 | replicated resolvers |
| KeIpiGenericCall | 63 | all-processor synchronized state checks |
| KeQueryPerformanceCounter | 47 + 6 | timing baselines |
| IoQueryFileInformation | 51 | on-disk vs in-memory image comparison |
| RtlPcToFileHeader | 42 + 1 + 1 | module identity of captured return addresses |
| ZwWriteFile / ZwFlushBuffersFile | 44 / 36 | kernel-side log writer |
| KeBugCheckEx | 46 | panic-on-tamper path |
| ZwTerminateProcess | 37 | kill path (4.5) |
| KeStackAttachProcess | 36 + 4 | cross-process memory inspection |
| MmMapLockedPagesSpecifyCache | 35 + 2 | physical memory views for inspection |
| BCryptVerifySignature | 39 + 1 | signature verifier (05); cng imports have exactly ONE .text caller each |
| KeInitializeApc / KeInsertQueueApc | 40 + 2 / 33 + 2 | APC injection |
Only .stub0 imports ExAcquire/ReleasePushLockExclusiveEx: runtime locking during unpack. And the clean .text scanner wrapper sub_14000E6C8 obfuscates its own imports the same way .grfn1 does, through XOR-indexed indirection: fn = qword_140135C80 ^ qword_140135C88[(u8)byte_140135C78].
4.7Embedded cheat-driver blacklist
Three independent clusters in .rdata (A at 0x1400f8xxx, B at 0x1400f8fxx, C at 0x1400f9fxx), each a table of string-descriptor entries (ptr + flags) for known abusable kernel drivers. Three clusters = three scanner passes matching these literals inside loaded driver images: match on embedded strings of the offending driver, not file names.
| Signature string | Target driver |
|---|---|
| 'NalDeviceControl: InputBuffer was NULL\n' | NAL direct-IO driver |
| 'MAPMEM.SYS: memory successfully mapped\n' | physical memory mapper |
| 'Leaving MsIoDispatch' | MSI IO driver |
| 'Leaving WinIoDispatch' + 'Nov 14 2013' | WinIo direct port IO, build date marker |
| 'BCRYPT_OBJECT_LENGTH alg failure\n' | crypto failure marker in the same blocks |
Cluster reference map: 0x1400f92d0 is referenced from .grfn1 (0x1403096cd, 0x14044c0f6) and from the .stub0 unpacker region (0x14015118b, 0x140151ac7); 0x1400f9f50 from .stub0 (0x140151668); 0x1400fa300 from three .grfn1 sites (0x140216939, 0x140303ffe, 0x14039c475, 0x1403abaae). Descriptor entries in the control blocks at 0x1400f85d0+ are referenced from the WPP registration at 0x14014df6a in INIT. This is the kernel-side cheat-driver blacklist, not an IOCTL dispatch: vgk has no device object.
The in-kernel trust boundary
One clean .text function at 0x1400a3c78 owns the driver's cryptography: the only caller of BCryptVerifySignature in the binary. It authenticates modules and handshake blobs in-kernel without touching wintrust, against a table of pinned keys baked into .data.
5.1Accepted signature algorithms
| OID | Algorithm |
|---|---|
| 1.2.840.113549.1.1.5 | sha1WithRSA |
| 1.2.840.113549.1.1.11 | sha256WithRSA |
| 1.2.840.113549.1.1.12 | sha384WithRSA |
| 1.2.840.113549.1.1.13 | sha512WithRSA |
All four OID literals are also referenced from .grfn1 code: the flattened payload checks algorithm ids itself, in parallel with the clean verifier.
5.2Digest path
BCryptOpenAlgorithmProvider → BCryptGetProperty(BCRYPT_OBJECT_LENGTH) → BCryptCreateHashhash object sized by property queryBCryptHashData → BCryptFinishHash20-byte digest: SHA-1 in the observed pathBCryptImportKeyPair materializes the pinned modulusonce per table entry, from the key table at 0x140105260BCryptVerifySignature closes the checkfailure returns 1 (reject)Callers of the verifier: 0x140328a18 (.grfn1, call site 0x140328a95) and 0x143d3e40c (.riot0). BCryptImportKeyPair and BCryptVerifySignature each have exactly one .text caller: this function.
5.3The pinned key table
Stride 0x258 per entry, base 0x140105260 in .data, exponent hardcoded 65537. The table itself is under digest in manifest slot 8.
The key table as a stride ladder: one block per 0x258 entry along the table, pinned CA entries larger and bright, the digest pass sweeping the run.
5.4Inline crypto in the runtime
The kernel does not only call BCrypt. .riot1 carries its own primitives, all recovered: an RSA key generator at sub_143DB70D7 (gcd coprimality loops of e against p-1 / q-1, 128-bit phi = (p-1)*(q-1)), two SHA-256 cores (initial state 0x6A09E667, at 0x143DCF277 and 0x143E2C9E5), and CRC32 (polynomial 0xEDB88320 at 0x143DDA3D4). Boot-time session keys come out of this machinery. The same cores serve the userland binaries: .riot1 is one blob, 95.7% byte-identical across vgk / vgc / vgm, common prefix 0x330C8 ending 0x74 bytes before the shared PE entry.
The launcher, unpacked and whole
On disk, vgrl.dll is a 3.2 MB shell: every section except the packer stubs is zero-filled. Fully unpacked (wine loader, ASLR cleared, dump at preferred base 0x180000000), it is the suite's launcher library in plain MSVC C++: service lifecycle, process spawning, an in-host anti-injection firewall, and a pre-flight probe battery. Ten ordinal exports, 1337 through 1346.
6.1Three tiers of code
| Tier | Range | Content |
|---|---|---|
| tier 1 | .stub1 0x0 - 0x3f000 | entry stub, jump-spaghetti decryptor, key schedule; entropy 7.763 on 0x316c00 ciphertext bytes |
| tier 2 | .stub0 0x3f000 - 0x1ab000 | obfuscated dynamic-import layer: 39 computed-continuation sites, all 31 APIs named by inverting the wine module layout |
| tier 3 | .text 0x1000 - 0x27000 | the real program: clean C++, CRT strings, std RTTI, 10 export bodies at 0x9350 - 0xa340 with clean prologues |
Dynamic-import census (tier 2, all 31 APIs recovered)
| Continuation site | Resolved API | Seed function |
|---|---|---|
| 0x3f2f6 / 0x3f324 / 0x3f34c | GetSystemTimeAsFileTime / QueryPerformanceCounter / GetCurrentProcessId | startup cluster |
| 0x9c5dd / 0x9c606 / 0x9c62d | GetCurrentThreadId / GetModuleHandleW / GetProcAddress | loader cluster |
| 0xb1ad0 - 0xb1c81 | NtQueryAttributesFile, NtCreateFile, NtReadFile, NtClose | file primitive cluster |
| 0xd86a4 - 0xd88b9 | VirtualAlloc / VirtualFree / VirtualProtect | memory cluster |
| 0x12e1d6 - 0x12e449 | CreateProcessW / CreateProcessAsUserW / WaitForSingleObject / CloseHandle | spawn cluster |
| 0x152768 - 0x1529b0 | OpenSCManagerW / CreateServiceW / StartServiceW / QueryServiceConfigW | service cluster |
| 0x1760a4 - 0x1762fd | GetTokenInformation / DuplicateTokenEx / CreateProcessAsUserW | token cluster |
| 0x19c433 - 0x19c7d8 | RtlInitUnicodeString / NtQueryDirectoryFile / RtlFreeUnicodeString | directory walk cluster |
Method: run the loader under wine 11.15, record the module bases the real loader chose, invert the arithmetic the stubs use to compute their continuation targets, and every site resolves to an exact named API. The 19 imports the stub declares openly were never hidden; the 31 computed ones were.
6.2DllMain: registering the anti-injection firewall
; two-phase hook engine, live-decoded under wine ; phase 1 - DllMain registers, does NOT patch: heap = call HeapCreate(0, 0, 0) ; private heap, handle 0x180039DA8 for each (target, handler): blk = RWX alloc 0x40 ; dual stub blk+0x00 = relocated prologue + jmp [target+stolen] blk+0x16 = jmp [handler] ; entry thunk entry = {target, blk+0x16, blk, backup[5], flags} ; phase 2 - spawn wrappers arm, then disarm: call ARM(0) ; = E9 rel32 into every entry call CreateProcessW(...) ; tier-2, 10 args call DISARM(0) ; write the backups back
Live decode settled what static analysis could not: the registry the earlier pass read as a fixed-VA table is a private HeapCreate heap (the 0x7FFFFEFF0000 address was a wine loader artifact; the 0xffeeffee words around the entries are wine heap fill magic), and the E9 detour is written by a separate call, sub_18000BD40: VirtualProtect to RW, patch, restore the old protection, flush the icache. Two NT functions are registered in-host: NtAllocateVirtualMemory and NtWriteVirtualMemory. The replacements inspect the target process: if it is not on the internal whitelist (teamfighttactics, league_of_legends), the call does not fail, it fake-succeeds: STATUS_SUCCESS is returned, the requested buffer address is reported back, and nothing was written. Injection tooling that checks return codes sees a working injection.
The trampoline builder is a complete Detours-style rewriter, not a byte copier: it length-decodes every stolen instruction and relocates it — E8 calls become a 14-byte call-through-absolute, E9/EB jumps resolve their destination and jump through it, short conditional jumps and LOOP variants are re-encoded over an absolute jump, RIP-relative constants are recomputed. It also knows the hotpatch layout: if the five bytes before a function are uniform padding, the detour lands at −5 and a two-byte EB F9 bridges back into it, leaving the function bytes untouched. The consequence for detection is the point of the design: at rest, ntdll is unmodified — same bytes, same page protection, nothing in any IAT points at vgrl. The detour exists only inside the spawn window, written and removed around a single CreateProcess family call by exports 1337 through 1339. A scanner that hashes ntdll once per boot will never see it.
The hook entries as a field of stride-56 slots in the private heap. The wave is the arm pass writing E9 detours; the two blocks that stay lit are the registered functions, NtAllocateVirtualMemory and NtWriteVirtualMemory.
6.3The arm gate
Every spawn export enters through the same dispatcher (sub_18000BC20 behind the 0x18000C340 arm wrapper): an interlocked spinlock, then either one target or the whole entry array. Return codes 0, 2, 4, 5 and 6 are the operating states — 0 armed clean, 2 registry not initialized, 4 target not registered. Re-entering the spinlock is an int3 kill, not a wait.
6.4Exports 1337 - 1346
| Ordinal | Role |
|---|---|
| 1337 | bootstrap spawn: CreateProcessW on the target with job + affinity pinning |
| 1338 | spawn control: priority, CPU set, working set trim |
| 1339 | session-crossing spawn: CreateProcessAsUserW with a duplicated token (WTS) |
| 1340 | service starter: OpenSCManagerW / CreateServiceW / StartServiceW, vgk timeouts 180000 / 30000 ms, vgc 30000 ms |
| 1341 | service config query: QueryServiceConfigW mirror of the advapi32 import |
| 1342 | window-station introspection (user32 station queries) |
| 1343 | probe runner: executes the bitmask battery of 6.5, returns the posture word |
| 1344 | logger hookup: formats and emits the spawn/probe audit trail |
| 1345 | gate control: sets and reads the state of 6.3 |
| 1346 | teardown: unhooks the registry, releases jobs, closes stations |
6.5Pre-flight probes
Ordinal 1343 runs a bitmask battery. The FourCCs 1094930505 and 1380011332 that appear in tier-2 code are 'ACPI' and 'DMAR': a GetSystemFirmwareTable query for the VT-d DMAR table.
| Bit | Probe |
|---|---|
| 0x1 | KUSD (shared user data) consistency |
| 0x2 | tbs.dll loadability: TPM Base Services present |
| 0x4 | Hv CPUID leaf: hypervisor present |
| 0x8 | speculation mitigation info (class 103) |
| 0x10 | ACPI DMAR table: VT-d enabled |
Probe strings are XOR-hidden in tier 2, one constant per string; the battery is cheap, read-only, and answers before any spawn happens.
6.6Starter pipeline
- Spawn order: probes (1343) → gate check (1345) → service starter (1340) → bootstrap spawn (1337) → session spawn (1339) when a UI process is required.
- Import slots: the resolved IAT places loader-critical APIs at slots [0], [2], [4] and [6]-[17]; the remaining stub-declared imports fill the rest.
.fptableis the plain function-pointer cache the packer leaves behind. - Job objects pin every spawned process; affinity follows the parent set; working sets are trimmed after spawn.
- Whitelist for memory-write access:
teamfighttacticsandleague_of_legendsimage names. Everything else gets the fake-success path.
vgc, vgm, vgtray, and the upload path
The service owns everything: full SCM rights, WTS session crossing, shutdown privilege. The worker is a job-object child. The tray app is plain code that talks to the service and runs the visible Pre-Check. RTTI names survive in the log-uploader and name the whole client-side class model.
vgcVanguard User-mode Servicesession 0 · SCM owner · Egg caller
- Powers
- full SCM control, WTS session crossing, shutdown privilege, service DACLs locked down
- vgk lifecycle
- authenticates the driver file (
WinVerifyTrust), creates and starts the vgk service - vgk interface
- base via
ZwQuerySystemInformation(SystemModuleInformation), then directEgg_0/Egg2_0calls - telemetry
- consumes the kernel ring via
NtMapViewOfSection - children
- vgm.exe in a job object; UI processes in the user session via WTS token
- structure
.text= 447 pure thunk functions (49 JUMPOUTs),.grfn1= 88,240 functions,.riot1= 10,733 functions at 0x66A000, imports from 15 modules
Import set: bcrypt, ntdll, WTSAPI32, WS2_32, KERNEL32, CRYPT32, ole32, USER32, USERENV, IPHLPAPI, WLDAP32, WINTRUST, SHELL32, ADVAPI32, VERSION. The Egg call marshalling lives entirely in its CFF web: zero plaintext Egg-RVA constants, zero direct xrefs on the SystemInformation import.
vgmVanguard Moduleworker · protected
Worker child of vgc inside a job object, full protector stack (14.8 MB .grfn1, own .riot0, shared .riot1 at 0x666000). No independent channel upward: everything it reports flows through vgc.
vgtrayTray applicationplain D3D11 · Pre-Check UI
Unprotected D3D11 app in the user session. Runs the visible Pre-Check: VBS, HVCI and IOMMU posture of the machine, with texts confirming Riot treats those as baseline expectations; absence only degrades to "always-on" mode. Drives the vgc service over the named pipe and loads vgrl.dll itself.
logslog-uploaderRTTI class model · PUUID + HWID
Standalone, plain code, ships logs to https://logs.vg.ac.pvp.net/logs with PUUID and hardware id attached (fed by Riot::ServiceHWIDSource / Riot::ServicePUUIDSource). Its RTTI survives intact and names the client-side model:
| RTTI class | Role |
|---|---|
| Riot::ServiceHWIDSource | hardware identity collection, service side |
| Riot::ServicePUUIDSource | player identity attachment |
| Riot::VanguardService | service core |
| Riot::VanguardModule | vgm-side worker |
| Riot::VanguardTray | tray integration |
Jenkins PDB paths in the debug directory name the source tree vanguard-client; the signing chain is DigiCert G4.
instuninstall.exestub · also a bootstrap shape
57 KB of plain code. The "uninstaller" doubles as the bootstrap shape: it loads vgrl.dll and hands control to the spawn pipeline, which is why the stub appears at the head of the boot chain in 1.1.
The TPM probe, answered
vgrl's probe bit 0x2 loads tbs.dll and asks the machine's TPM for its posture. That DLL is now reversed end to end from the Windows 11 25H2 binaries, and it has no secrets left: there is no RPC layer, no ALPC, no COM anywhere. A TBS context is literally a file handle to \??\TPM, a device object tpm.sys creates, and every operation is one NtDeviceIoControlFile.
| IOCTL | Code | Meaning |
|---|---|---|
| submit | 0x22C00C | send one TPM command buffer, wait for the response |
| get info | 0x22C01C | 16-byte device info struct: version, spec level, interface type |
| nonblocking | 0x22C194 | async submit, normalized by tpm.sys to 0x22C00C |
Ten IOCTL codes are whitelisted in tpm.sys, all FILE_DEVICE 0x22, all METHOD_BUFFERED. Before submission, tbs.dll overwrites byte 0 of its command copy with a size class of 1 to 4; after the response arrives it reads the TPM response code from bytes 6 to 9 big-endian and publishes TpmLockedOut / TpmInFailureMode through WNF state names.
The interesting word for the probe is the interface type dword in GetDeviceInfo: tpm.sys fills it through an 11-entry jump table with values 1 to 5, hardcoding 4 for firmware TPMs. That one dword is the real-TPM / fTPM / vTPM discriminator — the bit of the machine that says whether the measured boot chain is anchored in silicon. Identity surfaces complete the picture: Tbsi_GetDeviceID returns the WindowsAIKHash REG_BINARY under Services\TPM\WMI, and the TCG measurement logs (WBCL, WBCLDrtm, WBCLTrustPoint under IntegrityServices) are written by tpm.sys itself into \SystemRoot\Logs\MeasuredBoot.
The kernel side enforces its own boundary: every IOCTL passes a SeAccessCheck against the security descriptor D:(A;;GA;;;SY)(A;;GA;;;BA) — system and administrators only. tbs.sys is a kernel-mode clone of the same contract (ZwDeviceIoControlFile on a kernel handle, pool tag TBSK). And when there is no TPM at all, the open fails and every entry point funnels to one error: 0x8028400F, TBS_E_TPM_NOT_FOUND — which is exactly the signal the vgrl probe records in its posture word.
Everything the suite looks at
One view across all seven binaries: every surface the suite probes, what exactly it reads, which component does the reading, and what the answer costs you. Hardware posture questions dominate the usermode side, timing dominates the install gate, and the kernel does the killing.
| Surface | What is read | Reader | Reaction |
|---|---|---|---|
| TPM | presence, interface type 1-5 (4 = fTPM), WindowsAIKHash identity, MeasuredBoot logs (WBCL/WBCLDrtm/WBCLTrustPoint) via tbs.dll: IOCTL 0x22C01C on \??\TPM | vgrl probe bit 0x2 | no TPM (0x8028400F) lands in the posture word; interface type separates real / fTPM / vTPM |
| VT-d / IOMMU | DMAR ACPI table via GetSystemFirmwareTable('ACPI','DMAR') | vgrl probe | bit in the same posture word as TPM |
| CPU virtualization | CPUID leaf 1 ECX bit 31, Hv-CPUID leaves (0x40000000 MaxLeaf, Ms-Hv signature), KUSER_SHARED_DATA fields | vgrl probe bitmask | posture word; the launcher decides before any spawn |
| Speculation posture | mitigation state probe | vgrl probe | posture word bit |
| VBS / HVCI / IOMMU | machine posture check on startup | vgtray Pre-Check | absence degrades to always-on mode, not a hard block |
| Timing | 874 RDTSC sites in vgk (36 in .text), readable TSC-deadline spin loops with iteration caps 200k / 2M / 10M and result codes 0x11/0x12, zero RDTSCP image-wide; timed bad-MSR probes | vgk install gate | C1/C3/C2 class timing verdicts gate the install; this is the practical ceiling of the whole suite |
| Perf counters | KeQueryPerformanceCounter baseline sites (47) | vgk | behavioral verdicts in the detection battery |
| Blacklisted drivers | loaded-module scans against three independent signature tables: WinIo, MAPMEM.SYS, NAL, MsIo, matched on in-image strings | vgk .grfn1 | kill: offending process terminated, driver machine-panics on kernel paths |
| Image integrity | on-disk vs in-memory image comparison (IoQueryFileInformation, 51 sites), .riot0 11-slot integrity manifest with FNV-family digests | vgk | tamper verdict on mismatch |
| Foreign memory | cross-process inspection (KeStackAttachProcess, MDL mapping), Nt-side mirrors in vgc; vgrl fake-succeeds foreign NtAllocate/NtWriteVirtualMemory as a firewall | vgk + vgc + vgrl | injection attempts answered with silent fake success, then verdicts |
| Thread context | usermode hook engine hijacks SetThreadContext-adjacent spawn paths; arm window only during CreateProcess-family calls | vgrl hooks | anti-tamper on the spawn pipeline itself |
| Namespace and modules | object namespace walks (ZwOpen/QueryDirectoryObject), module identity of captured return addresses (RtlPcToFileHeader) | vgk | hidden-driver and caller-identity verdicts |
| Machine identity | SMBIOS/DMI via GetSystemFirmwareTable in vgc, disk and TPM surfaces, Riot::ServiceHWIDSource / ServicePUUIDSource records | vgc + log-uploader | attached to every upload to logs.vg.ac.pvp.net/logs; correlation, not an instant ban |
ZwTerminateProcess (37 sites), KeBugCheckEx (46 sites), verdict NTSTATUS travels through CFF dispatch onlyWhat is not here matters as much: no TPM attestation is forwarded, no measured-boot log leaves the machine through vgk itself, and the posture word is a machine fingerprint, not a court verdict. The only responses the suite can execute are terminate, panic, degrade, and report.
The ring, not the IOCTL
Kernel detection events reach usermode through a shared ring buffer built over an MDL, not through per-event IOCTLs. The format strings name the machinery precisely.
Two banks, events flowing opposite directions, the bright arc the flush pass, and the cube below reading through MDL-mapped chords. One consumer, no IOCTLs.
10.1The ring, in its own words
ace[diag]: FlushActiveBuffers logger=%u FlushAll=%d Collected=%u vgtrace[diag]: Worker logger=%u STARTED CollectionOn=%d FlushTimer=%u FlushThreshold=%u ace[diag]: BuildConsumer logger=%u kva=%p uva=%p mdl=%p vgtrace[diag]: FlushToRealtime logger=%u SKIPPED (ring not initialized) seq=%llu savedOffs=%llu
Worker threads fill, a flush timer and a threshold trigger collection, and BuildConsumer maps a kernel VA to a user VA over an MDL: the consumer is vgc, reading the ring through NtMapViewOfSection.
10.2Identity flow
GetSystemFirmwareTable in vgc, service-side sourceslogs.vg.ac.pvp.net/logs, curl + static OpenSSL10.3Exposure
- The ring is a one-way channel: no command enters the kernel through it. Command-shaped traffic uses the Egg exports (4.4).
- Event volume is invisible from outside: flushes are batched by timer and threshold, not by event count.
- The mapped user VA is per-consumer; the MDL pins the pages, so the ring survives service restarts without the driver reloading.