Vanguard Reversed
scroll to descend

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.

01 · Suite map

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.

RING 3 · USER RING 0 · KERNEL uninstall.exe stub · plain .text vgrl.dll packed shell · .stub1 entry ordinals 1337 - 1346 vgtray.exe D3D11 tray app · plain VBS / HVCI Pre-Check vgc.exe User-mode Service session 0 · SCM owner vgk.sys kernel driver · .grfn1 Egg / Egg2 · no IOCTL vgm.exe worker · protected job object child log-uploader.exe plain · curl + OpenSSL logs.vg.ac.pvp.net /logs endpoint PUUID + HWID attach loads ordinal 1337 · SCM Egg_0 / Egg2_0 MDL ring job object named pipe + SCM PUUID + HWID uploads

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

uninstall.exe runs the vgrl.dll stub paththe "uninstaller" is also the bootstrap shape: plain .text at rva 0x42b8, loads vgrl and hands over
vgrl.dll ordinal 1337 spawns vgc.exeCreateProcessW bootstrap, then service lifecycle through SCM; ordinal 1339 covers the CreateProcessAsUserW variant for session crossings
vgc authenticates files before loadWinVerifyTrust on the driver file, revocation and CA policy apply at this boundary
vgc creates the vgk service and starts itlocked-down DACLs on the service object, full SCM control held by vgc
vgk DriverEntry runs the protector first.riot1 runtime unpacks and verifies before any detection logic executes
vgc spawns vgm.exe into the job, and the UI into the user sessionvgm as worker child; vgtray via WTS token + CreateProcessAsUserW

1.2Communication matrix

PathMechanismNotes
vgc → vgkdirect export calls Egg_0 / Egg2_0base located via ZwQuerySystemInformation(SystemModuleInformation); a private kernel API with no handles and no ACLs in the way
vgk → vgcace/vgtrace telemetry ringring 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 ↔ vgtraynamed pipe + SCMNotifyServiceStatusChange for lifecycle, pipe for payload
vgc → vgmparent/child job objectvgm holds no independent channel upward
all → Riotlibcurl + OpenSSLstatically linked in vgc, vgm, vgtray, log-uploader

1.3Kernel enforcement flow

1 · Detection battery in .grfn132,410 flattened functions: loaded-module scans against embedded cheat-driver signatures (WinIo, MAPMEM.SYS, NAL, MsIo, matched on in-image strings across three independent signature tables); cross-process memory inspection (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)
2 · Verdictterminate the offending process (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)
3 · Usermode reachAPC injection via KeInitializeApc / KeInsertQueueApc into target processes for hooks and reporting
4 · Handshake authenticationin-kernel BCrypt verifier at 0x1400a3c78: SHA-1/256/384/512 by OID against a pinned key table at 0x140105260, stride 0x258; no wintrust dependency in the kernel
5 · Machine-wide state syncKeIpiGenericCall, 63 sites
Identity chain: kernel and userland collection (SMBIOS via GetSystemFirmwareTable 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.
02 · Inventory

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.

BinaryRaw sizeProtectionKey sectionsImports
vgk.sys69.4 MBfull stack.grfn1 62.1 MB payload · .stub0 unpacker · .riot0 directory · .riot1 runtime127
vgc.exe83.8 MBfull stack.grfn1 77.2 MB · .riot0 · .riot1 · .toir config358
vgm.exe27.8 MBfull stack.grfn1 14.8 MB · .riot0 · .riot1 · .riot2224
vgtray.exe10.4 MBnoneplain .text, entry at rva 0x309fcc; D3D11 Pre-Check app329
vgrl.dll3.2 MBpacker onlyeverything empty on disk except .stub0/.stub1; entry rva 0x2fb38f inside the stub19
log-uploader.exe4.3 MBlightplain .text + .toir (dwords 01 00 01 00 02 01 00 00)279
uninstall.exe57 KBnonestub, entry rva 0x42b890

2.1Protection classes

  • Full stack (vgk, vgc, vgm): clean .text reduced to thin thunks whose real bodies JUMPOUT into .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 .toir dwords: byte 2 is the per-binary variant (07 vgc, 0b vgm, 01 log-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, vgc 0x513d13c, vgm 0x140713c, each resolving to .riot1+0x3313C.

2.2Observations worth keeping

  • One runtime blob serves three binaries: .riot1 raw sizes vgk 0x660600 / vgc 0x666091 / vgm 0x666000, 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.
  • .fptable appears 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.png inside vgc's .rdata, an image URL with no loader reference. Treated as a developer artifact, not a signal path.
  • _ENTROPY in vgk holds packer entropy metadata; its raw size (0x200) is far below its mapped size (0x1000).
03 · Protector

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.

payload L1 packer L2 flattening L3 MBA L4 runtime

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:

StructureCountShareMeaning
flattened functions32,41064.5%dispatcher machines in .grfn1
clean thunks9391.9%readable .text stubs, JUMPOUT bridges
runtime fragments10,44020.8%.riot1 marshalling and crypto bodies
directory triples30,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 usedFormWhere it surfaces
choiceCh(x,y,z) = (x&y) ^ (~x&z)expands to 5-7 term MBA chains in round functions
parityPar(x,y,z) = x ^ y ^ zappears as a 3-way xor hidden inside and/xor runs
majorityMaj(x,y,z) = (x&y) | (x&z) | (y&z)split across fragments to break pattern rules
shift mixingROTLn composed from shifts and orsrotation 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.

RegionSizeContent
vgk .riot00x7a000header + 30,521 triples + 11-slot manifest (vgk only)
vgk .riot10x661000shared runtime core: RSA, SHA-256, CRC32, marshalling
vgc .riot10x66a000same core, per-binary tail
vgm .riot10x666000same core, per-binary tail
entry offset+0x3313Cidentical 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

BinaryL1 packerL2 CFFL3 MBAL4 runtime
vgk.sys.stub032,410 fnsdense in round logic.riot0 manifest + .riot1
vgc.exe.stub0presentpresent.riot0 (no manifest) + .riot1
vgm.exe.stub0presentpresent.riot0 (no manifest) + .riot1
vgrl.dll.stub0 + .stub1 totalunpacked at loadunpacked at load.fptable cache
vgtray / uploader / uninstallnonenonenonenone
04 · vgk.sys

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

SectionVAVSizeRole
.text0x1400010000xf4000939 clean stubs, thunks, sig-verify, boot glue
sect_10x1400f50000x1000plain anti-VM helpers: CR4.PGE-toggle TLB flush bracketing raw CPUID(1), bit-protocol over a pointer array, pause-spinlock at 0x1400f507e
_ENTROPY0x1400f60000x1000packer entropy metadata, all-zero on disk
.rdata0x1400f70000xe000strings, WPP control blocks, key table pointers
.data0x1401050000x38000globals incl. pinned key table @ 0x140105260
.pdata / .NT / .edata / INIT0x14013d000+0x9000exception table, boot tables, export dir (Egg/Egg2), INIT freed after load
.stub00x1401480000x40000unpacker stub, 24 plain helper functions
.riot2 / .rsrc0x1401880000x7000protector config marker, resources
.grfn10x14018f0000x3bae00062.1 MB CFF + MBA payload: 32,410 flattened functions, the detection battery
.riot00x143d3d0000x7a000RW function directory: 30,521 triples + 11-slot integrity manifest (4.3)
.riot10x143db70000x661000shared protected runtime, PE entry at offset 0x3313C
.reloc0x1444180000x1000relocations
sect_1, decoded: 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 ebx load zero-extends over cb before add rax,rbx, so the target is slot + 0x52F90, not slot + cb + 0x52F90. Emulation-proven: slot 0 yields rax = 0x52F90 exactly.
  • The armed slot value is cb itself (0x143dea0c0): target = cb + 0x52F90 = 0x143E3D050, the CFF continuation.
  • The registrar 0x143dea190 stores cb into 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: VGNOINIT materializes through an MBA imul-chain (first materialization rva 0x4179d36, spilled to 24 stack sites, final load rva 0x3e864fb). Only 2 immediate sites exist image-wide: a never-executed .grfn1 stub and the DriverEntry compare.

Arming protocol (emulation-proven)

1 · Runtime sets bit0 of the dword at .riot0+0x8init-armed flag, 0 on disk. Reader at 0x143e65fc5: movsxd r9,[rdx+0x28] (same -0xAD0C0 constant), mov eax,[rdx+r9+8], test al,1
2 · Registrar 0x143dea190 stores cb into .riot0+0x10the tail-jmp slot
3 · Next DriverEntry call passes the compareeax != VGNOINIT, jmp cb+0x52F90 lands at 0x143E3D050 and the real init runs
What emulation shows: with bit0 patched to 1, sub_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.

.grfn1 · 19,300 .riot1 · 10,267 .text · 575 .stub0 · 379

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 sectionTriplesrva rangeDescriptor records land in
.text5750x1000 - 0xf5000.rdata descriptors, ~20 clusters in 0x101728 - 0x104c34
.stub03790x148000 - 0x188000.stub0 itself
.grfn119,3000x18f000 - 0x3c9d000.grfn1
.riot110,2670x3db7000 - 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.

#Regionrva + sizeNote
1sect_1 whole0xf5000 + 0x1000anti-VM helpers under digest
2_ENTROPY0xf6040 + 0x1c0entropy metadata window
3.rdata0xf75a0 + 0x9d20strings + WPP blocks
4runtime slot0x10145c + 0x24all-zero region, digest nonzero
5runtime slot0x1016f8 + 0x28all-zero region, digest nonzero
6.rdata0x101724 + 0x36dcdescriptor-record clusters
7.data0x105000 + 0x238globals head
8.data0x105240 + 0x41c0pinned key table 0x140105260 inside
9.pdata whole0x13d000 + 0x3000exception table
10.stub0 whole0x148000 + 0x40000unpacker
11.grfn1 whole0x18f000 + 0x3bada00the entire payload
  • Total covered: 0x3c02800 bytes = everything except .text, .riot1 and 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 0x100000001B3 appears 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 at 0x143d967fc, table-driven with 30,803 xrefs into .riot1.

4.4The Egg interface: two exports, nothing else

  • No device object exists. vgk imports no IoCreateDevice (only IoCreateFileEx), so there is no IOCTL surface at all. The two exports are the only entry.
  • The export directory points into .riot1: AddressOfFunctions entries 0x1443eeed0 / 0x1443eeee0 sit 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 → body 0x140271833 → resolver pattern call sub_1401F818B; jmp rax. Egg2_0: thunk 0x1400dd8180x140213c89call sub_140317F12; jmp rax. Both bodies JUMPOUT into .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; jmp through 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 its ZwQuerySystemInformation import has zero direct xrefs through XOR-indirection).

Primitive surface reachable from Egg_0 (fragment-BFS)

ClassAPIs
cryptoBCryptOpenAlgorithmProvider / CreateHash / HashData / FinishHash / GetProperty / ImportKeyPair / VerifySignature / CloseAlgorithmProvider / DestroyHash
threadsPsCreateSystemThread, PsTerminateSystemThread, KeInitializeApc, KeInsertQueueApc (APC injection), KeDelayExecutionThread
memoryMmAllocatePagesForMdl, MmBuildMdlForNonPagedPool, MmMapLockedPagesSpecifyCache, IoAllocateMdl, IoFreeMdl
processIoGetCurrentProcess, KeStackAttachProcess / KeUnstackDetachProcess, ZwQuerySystemInformation
killZwTerminateProcess x2, KeBugCheckEx
filesZwReadFile / ZwWriteFile / ZwFlushBuffersFile, ZwOpenDirectoryObject / ZwQueryDirectoryObject
syncrundown protection (cache-aware alloc), guarded mutexes, work items, timers, DPCs, KeIpiGenericCall, KeRegisterBugCheckReasonCallback
State gate on the on-disk path: emulating the thunk → body → resolver chain on the on-disk image returns 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

Scanner fragments produce an NTSTATUS verdict variabledetection logic in .grfn1, verdict as data
MBA dispatcher hubs propagate itthrough the CFF web, no fixed channel
Terminal wrapper sub_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 dispatch
ZwTerminateProcess 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 site

The 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.

ImportSitesSemantic user
ExFreePoolWithTag90replicated resolvers
KeIpiGenericCall63all-processor synchronized state checks
KeQueryPerformanceCounter47 + 6timing baselines
IoQueryFileInformation51on-disk vs in-memory image comparison
RtlPcToFileHeader42 + 1 + 1module identity of captured return addresses
ZwWriteFile / ZwFlushBuffersFile44 / 36kernel-side log writer
KeBugCheckEx46panic-on-tamper path
ZwTerminateProcess37kill path (4.5)
KeStackAttachProcess36 + 4cross-process memory inspection
MmMapLockedPagesSpecifyCache35 + 2physical memory views for inspection
BCryptVerifySignature39 + 1signature verifier (05); cng imports have exactly ONE .text caller each
KeInitializeApc / KeInsertQueueApc40 + 2 / 33 + 2APC 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 stringTarget 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.

05 · Verification

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

OIDAlgorithm
1.2.840.113549.1.1.5sha1WithRSA
1.2.840.113549.1.1.11sha256WithRSA
1.2.840.113549.1.1.12sha384WithRSA
1.2.840.113549.1.1.13sha512WithRSA

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

Parse the DER certificatein-function, no external parser
BCryptOpenAlgorithmProviderBCryptGetProperty(BCRYPT_OBJECT_LENGTH)BCryptCreateHashhash object sized by property query
BCryptHashDataBCryptFinishHash20-byte digest: SHA-1 in the observed path
BCryptImportKeyPair materializes the pinned modulusonce per table entry, from the key table at 0x140105260
BCryptVerifySignature 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.

pinned CA entry table entry digest sweep

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.

06 · vgrl.dll

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

TierRangeContent
tier 1.stub1 0x0 - 0x3f000entry stub, jump-spaghetti decryptor, key schedule; entropy 7.763 on 0x316c00 ciphertext bytes
tier 2.stub0 0x3f000 - 0x1ab000obfuscated dynamic-import layer: 39 computed-continuation sites, all 31 APIs named by inverting the wine module layout
tier 3.text 0x1000 - 0x27000the 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 siteResolved APISeed function
0x3f2f6 / 0x3f324 / 0x3f34cGetSystemTimeAsFileTime / QueryPerformanceCounter / GetCurrentProcessIdstartup cluster
0x9c5dd / 0x9c606 / 0x9c62dGetCurrentThreadId / GetModuleHandleW / GetProcAddressloader cluster
0xb1ad0 - 0xb1c81NtQueryAttributesFile, NtCreateFile, NtReadFile, NtClosefile primitive cluster
0xd86a4 - 0xd88b9VirtualAlloc / VirtualFree / VirtualProtectmemory cluster
0x12e1d6 - 0x12e449CreateProcessW / CreateProcessAsUserW / WaitForSingleObject / CloseHandlespawn cluster
0x152768 - 0x1529b0OpenSCManagerW / CreateServiceW / StartServiceW / QueryServiceConfigWservice cluster
0x1760a4 - 0x1762fdGetTokenInformation / DuplicateTokenEx / CreateProcessAsUserWtoken cluster
0x19c433 - 0x19c7d8RtlInitUnicodeString / NtQueryDirectoryFile / RtlFreeUnicodeStringdirectory 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.

registry slot install wave replaced NT fn

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

OrdinalRole
1337bootstrap spawn: CreateProcessW on the target with job + affinity pinning
1338spawn control: priority, CPU set, working set trim
1339session-crossing spawn: CreateProcessAsUserW with a duplicated token (WTS)
1340service starter: OpenSCManagerW / CreateServiceW / StartServiceW, vgk timeouts 180000 / 30000 ms, vgc 30000 ms
1341service config query: QueryServiceConfigW mirror of the advapi32 import
1342window-station introspection (user32 station queries)
1343probe runner: executes the bitmask battery of 6.5, returns the posture word
1344logger hookup: formats and emits the spawn/probe audit trail
1345gate control: sets and reads the state of 6.3
1346teardown: 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.

BitProbe
0x1KUSD (shared user data) consistency
0x2tbs.dll loadability: TPM Base Services present
0x4Hv CPUID leaf: hypervisor present
0x8speculation mitigation info (class 103)
0x10ACPI 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. .fptable is 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: teamfighttactics and league_of_legends image names. Everything else gets the fake-success path.
07 · Userland

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 direct Egg_0 / Egg2_0 calls
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 classRole
Riot::ServiceHWIDSourcehardware identity collection, service side
Riot::ServicePUUIDSourceplayer identity attachment
Riot::VanguardServiceservice core
Riot::VanguardModulevgm-side worker
Riot::VanguardTraytray 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.

08 · tbs.dll

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.

IOCTLCodeMeaning
submit0x22C00Csend one TPM command buffer, wait for the response
get info0x22C01C16-byte device info struct: version, spec level, interface type
nonblocking0x22C194async 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.

09 · Detection map

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.

SurfaceWhat is readReaderReaction
TPMpresence, interface type 1-5 (4 = fTPM), WindowsAIKHash identity, MeasuredBoot logs (WBCL/WBCLDrtm/WBCLTrustPoint) via tbs.dll: IOCTL 0x22C01C on \??\TPMvgrl probe bit 0x2no TPM (0x8028400F) lands in the posture word; interface type separates real / fTPM / vTPM
VT-d / IOMMUDMAR ACPI table via GetSystemFirmwareTable('ACPI','DMAR')vgrl probebit in the same posture word as TPM
CPU virtualizationCPUID leaf 1 ECX bit 31, Hv-CPUID leaves (0x40000000 MaxLeaf, Ms-Hv signature), KUSER_SHARED_DATA fieldsvgrl probe bitmaskposture word; the launcher decides before any spawn
Speculation posturemitigation state probevgrl probeposture word bit
VBS / HVCI / IOMMUmachine posture check on startupvgtray Pre-Checkabsence degrades to always-on mode, not a hard block
Timing874 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 probesvgk install gateC1/C3/C2 class timing verdicts gate the install; this is the practical ceiling of the whole suite
Perf countersKeQueryPerformanceCounter baseline sites (47)vgkbehavioral verdicts in the detection battery
Blacklisted driversloaded-module scans against three independent signature tables: WinIo, MAPMEM.SYS, NAL, MsIo, matched on in-image stringsvgk .grfn1kill: offending process terminated, driver machine-panics on kernel paths
Image integrityon-disk vs in-memory image comparison (IoQueryFileInformation, 51 sites), .riot0 11-slot integrity manifest with FNV-family digestsvgktamper verdict on mismatch
Foreign memorycross-process inspection (KeStackAttachProcess, MDL mapping), Nt-side mirrors in vgc; vgrl fake-succeeds foreign NtAllocate/NtWriteVirtualMemory as a firewallvgk + vgc + vgrlinjection attempts answered with silent fake success, then verdicts
Thread contextusermode hook engine hijacks SetThreadContext-adjacent spawn paths; arm window only during CreateProcess-family callsvgrl hooksanti-tamper on the spawn pipeline itself
Namespace and modulesobject namespace walks (ZwOpen/QueryDirectoryObject), module identity of captured return addresses (RtlPcToFileHeader)vgkhidden-driver and caller-identity verdicts
Machine identitySMBIOS/DMI via GetSystemFirmwareTable in vgc, disk and TPM surfaces, Riot::ServiceHWIDSource / ServicePUUIDSource recordsvgc + log-uploaderattached to every upload to logs.vg.ac.pvp.net/logs; correlation, not an instant ban
Probe passhardware posture: TPM, DMAR, CPUID, speculation, KUSD
Posture wordone bitmask per machine in the launcher
Timing gatevgk install decision, C1/C3/C2 class
Verdict pathsZwTerminateProcess (37 sites), KeBugCheckEx (46 sites), verdict NTSTATUS travels through CFF dispatch only

What 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.

10 · Telemetry

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.

ring banks events consumer (vgc)

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

Kernel and userland collectorsSMBIOS via GetSystemFirmwareTable in vgc, service-side sources
Riot::ServiceHWIDSource / ServicePUUIDSourcenormalized identity records
log-uploader attaches them to every uploadlogs.vg.ac.pvp.net/logs, curl + static OpenSSL

10.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.