_._     _,-'""`-._
(,-.`._,'(       |\`-/|
    `-.-' \ )-`( , o o)
          `-    \`_`"'-

full house beats ACE: dumping Tencent's anti-cheat kernel drivers, and the CVE patch that was never really there

2026-08-20 · windowskernelvmprotectanti-cheattencentphysical-memorybyovdcwe-284reverse-engineering

His watchmen are blind; They are all unaware; They are all dumb dogs; They cannot bark; Sleeping, lying down; Liking to slumber.

Isaiah 56:10 (KJV)

Anti-cheat products are weird. At face value, anti-cheat is just a kernel driver written by a game company that is monitoring all processes, module loads, and handle usage on your machine. Tencent's Anti-Cheat Expert (ACE) is a particularly large anti-cheat solution. it claims to be protecting the play of over 700 million players. ACE is pre-installed on a large number of games that are played on PC with a mobile controller, including PUBG Mobile, Delta Force, Dark and Darker, Ring of Elysium, and Arena Breakout, to name a few. each of these games load 3 kernel drivers (WHQL signed) when a protected game is launched.

this is the story of how I pulled the three binaries out of kernel memory by using someone else's signed drivers, stripped half a GByte of VMProtect from them and found out that the fix for the 2024 CVE that granted arbitrary physical memory read/write was in functional terms a decoration. below I will describe in detail how I did this on my Win11 box with the current driver line (1.0.2603.17210).

the rules of the game

the constraint i set myself: no custom driver, no test mode, no stolen cert, nothing that trips driver signature enforcement. everything that touches kernel memory has to arrive on the box as a legitimate, WHQL-signed driver that some vendor legitimately installed. this is the usual BYOVD posture, and the supply of candidates is honestly embarrassing. three signed drivers ended up in the story: one for addresses, one for proof, one for the actual work.

BlueStacks comes with BstkDrv_nxt.sys in the package. it has several bad properties. it lists all kernel modules, which immediately breaks KASLR. hand it nothing, it will give you the load address of ntoskrnl.exe and everything after it. first step: knowing where things live.

G.SKILL, a RAM company, ships RGB control software that contains a driver from Creative Technology Innovation called CtiIo64.sys, WHQL signed, no DACL on its device object. its IOCTL surface is a small SMBus toolkit, and one entry in that toolkit, 0x80502164, SMBUS_PROCESS_CALL, takes a user pointer and hands it to kernel code without ever calling ProbeForRead or ProbeForWrite:

// FUN_00012dd0, ich5_smb_process_call, decompiled
sVar3 = *param_4;              // kernel READ through caller's ptr
... SMBus traffic ...
*param_4 = result;             // kernel WRITE through caller's ptr

the input buffer is 12 bytes and the target address rides in it little endian:

// input layout for IOCTL 0x80502164
[0]     param1 = 0              // required, selects the bug path
[1]     param2 = SMBus command
[2]     param3 = read/write flag
[3..10] target kernel pointer, LE  // 64 bits of wherever you want
[11]    unused

the above method was tested with the following preconditions: SMBusType set to 1 (a different IOCTL on the same device) and the hardware actually answering the query. the above method returns an arbitrary 2-byte kernel read and write (two bytes at a time is enough) and is slow, rude, but works. as a proof of the species it is perfect. for actually moving megabytes it is a hobby, not a pipeline, so the heavy lifting went to a third signed driver:

KKYUM.sys, 26 KB of kernel driver that ships with gaming peripheral tooling, imports MmCopyVirtualMemory three times, puts a device object down with no DACL, and speaks a compact little protocol: an ioctl carrying a pid, a count, and an array of (remote, local, size) triples. pid 4, count 1, four kilobytes a shot. that is the read primitive every tool in the dumper zip below actually uses, the one that pulled 6.5 MB of ACE-BASE out of kernel memory in the live run. CtiIo is the writeup. KKYUM is the truck.

what ACE actually ships

with addresses and a read primitive, the next job is finding the drivers. they load after ntoskrnl, page aligned, inside about 256 MB of it, so the scan is: walk that range in 64 KB steps, read the first page of each, look for MZ, then look at the section table for .tvm0. that section name is VMProtect's thumbprint. ACE-BASE is easy to pick out of the crowd because it is huge:

  ACE-BASE.sys   4.1 MB   .tvm0  2.9 MB   entropy 6.84
  ace-game.sys   2.0 MB   .tvm0  1.7 MB   entropy 6.80
  SGuard64.exe   7.6 MB   .tvm0  3.1 MB   entropy 6.79   // userland orchestrator

about 7.9 MB of the three binaries is VM bytecode, compressed or encrypted, entropy around 6.8. on disk these files are noise. but on disk is not where they run. VMProtect has to decrypt its own code to execute it, so the running copy in kernel memory is the real thing, and a running copy is something i can already read. dump, fix the PE headers so the section sizes and checksum line up with what memory actually looks like, zero the .tvm0 section, and you are holding the driver VMP was protecting.

this corresponds to the userland half of SGuard64.exe, so we need to apply the same logic from the other side of the boundary: OpenProcess, EnumProcessModulesEx, ReadProcessMemory, and align RawAddress to the correct VirtualAddress for each section. Ring 3 stuff.

unwrapping the VM

a dumped but still-virtualized driver is a bag of jmp stubs. each protected function in .text is a 5 byte jmp rel32 stub that jumps into .tvm0, and then a bytecode interpreter eats the handlers. so, go find all the rel32 jumps from .text to VMP in the VMP section. there are 519 entry points in total for these three binaries.

then each entry gets emulated. Unicorn, the whole mapped image, hooks that log every instruction. a single virtualized function traces out thousands of native instructions, and most of them are costume. VMP pads everything with the same few shapes, push and movabs and pushfq and popfq and a jmp that goes nowhere meaningful, and those lift out clean:

// shapes that get deleted outright during lifting
push reg; movabs; pushfq; add/xor; popfq; jmp; pop   // opaque predicate
lea; lea; jmp                                       // address arithmetic theater
pushfq / popfq                                      // flag jitter

what survives that gets assembled back into x86-64 and written out as pseudocode. final tally for the corpus:

  VM entries devirtualized:    519
  obfuscation patterns killed: 50,413
  VMP bytecode zeroed:         7.9 MB
  pseudocode emitted:          261,385 lines
  handler mix (ace-game):      mov 22%, alu 19%, branch 17%, stack 28%

the three binaries below in the downloads section are the output of that pipeline. they drop straight into IDA or Ghidra with the import table intact, ImageBase 0x140000000, and decompile like ordinary, if badly written, drivers. which brings us to what they decompile to.

the 0-day: CVE-2024-22830, patched in press release only

I started by going through the disassembly of February 2024's CVE-2024-22830, ACE-BASE.sys 1.0.2202.6217 vulnerability in \Device\PhysicalMemory code, which allowed local user to gain SYSTEM privileges by reading and writing arbitrary physical memory. in the end, a fix was produced by tencent and the current version line of 1.0.2603 of ACE-BASE.sys contains 4 different mitigations for this vulnerability in the code path. here I intend to explain all 4 of the fixes in detail together because collectively they form a masterclass in patch writing when no one believes that an attacker would even read the disassembly.

mitigation 1, a size cap. transfers are limited to 100 MB per request, param_3 < 0x6400001. a physical memory primitive with a size limit is a physical memory primitive. nobody needs 100 MB to read a token.

mitigation 2, address validation. a helper, FUN_140025cc0, checks the address:

// FUN_140025cc0, the "kernel address check"
return (param_1 > 0x7FFFFFFFFFFF);   // rejects nothing that matters

all of this merely enforces that the caller's buffer is in usermode space. it is a sanity check, dressing up as a security check. the target physical address is not a usermode pointer and never has been.

mitigation 3, a buffer probe. FUN_1400767fc wraps ProbeForWrite over the user buffer before use. correct, and completely orthogonal to the bug. the bug was never about the caller's buffer being invalid. it is about what the driver does with a valid one.

mitigation 4, process authorization. this is my favorite. a tracking list of calling processes, so the driver can verify callers before servicing them. here is the function that maintains it, FUN_1400a5b6c reached through FUN_140014750:

// the authorization gate
if (plVar2 == NULL) {
    _Dst = ExAllocatePoolWithTag(0, 0x140, 'WKOU');
    *_Dst = param_1;           // store the caller's PID
    _Dst[2] = DAT_140384470;   // link it into the list
    DAT_140384470 = _Dst;
    plVar2 = _Dst;
}
return plVar2;                 // always non-NULL

read it slowly. there is no whitelist. there is no check. the first thing this function does with an unknown caller is to add it to the authorized list and return success. it is a bouncer whose entire procedure is to stamp hands. and then, past all four mitigations, the destination:

// FUN_140077c14, the physical memory R/W, still present
local_48 = L"\\device\\physicalmemory";
local_84 = FUN_14007f820(&local_60, 4, local_40);   // ZwOpenSection

do {
    local_70 = min(remaining, 0x1000 - offset);
    FUN_140076fa4(handle, physAddr, userBuf, size, flags);  // map + memcpy
} while (remaining > 0);

a primitive that is not changed by a patch is a primitive that can be used after a patch. as an example, you open \\.\ACE-BASE and then send IOCTLs for 0x222440 and 0x222448 with a physical address. PhysicalMemory is then mapped and a copy is made from there. after that, as before, a EPROCESS is leaked with NtQuerySystemInformation(SystemHandleInformationEx), page tables are walked to physical, the SYSTEM token is read, and then it is written over your own SYSTEM token. and as SYSTEM, you can go through the anti-cheat.

for the record, the IOCTL surface on the current build:

  0x221c24   0x24c buf    primary channel, wants an auth handshake
  0x221c34   0x78/0x30    encrypted command
  0x222440   0x1008 buf   physical memory read
  0x222444   -            physical memory write
  0x222448   0x10 buf     memory op
  0x22244c   -            memory op
  0x222450   8            status

Oh and a related physical memory IOCTLs check via an init SGuard64 handshake (for now the auth buffer reversal isn't complete, FUN_140408776 etc function lookups reside in 0x221c24 handler etc of SGuard64 restored binary though end-to-end test for current build not yet completed due to missing handshake). Note that the 2024 CVE would have been reachable without this decorator anyway (hence likely the basic path decoration was considered sufficient to patch).

what else was in there

the devirtualized corpus keeps paying rent. quick tour.

string crypto. every interesting string is XORed with a single byte key loaded as an immediate, and the keys are just sitting in the restored code. 0x3A for ntdll, 0x61 for LdrLoadDll. after devirtualization you can read the driver like a book.

real crypto. SGuard64 embeds a complete OpenSSL 3.x: AES S-box at 0x3CE2B0, inverse at 0x36D3D0, SHA-256 init at 0x3A3658, TLS 1.3 with X25519 and ChaCha20-Poly1305, even SM2 for the domestic market. stock tables, no custom primitives, which means the MITM story writes itself if you patch the verify calls, and the corpus includes a full intercept kit that does exactly that: hosts redirect, self-signed cert, and a patcher for the pinning.

the telemetry. endpoints anticheatexpert.com and down.anticheatexpert.com, JSON over TLS for control, a compact binary framing for data, [len:4][cmd:4][seq:4][payload]. heartbeat every few seconds, scan reports on demand, HWID is an MD5 over hardware identifiers. game id 366666 in the strings is Dark and Darker.

build discipline. the drivers are stripped, but SGuard64 leaked its PDB path: C:\data\landun\workspace\CommonComponent\ACE-Guard\.... Landun is tencent's internal CI. the same binary that ships seven megs of VMProtect to stop you from reading it also ships the name of the build robot.

the dumps

all three restored binaries, VMProtect stripped, sections zeroed, headers rebuilt, imports intact. ImageBase 0x140000000, no rebasing needed, load straight into IDA or Ghidra:

# ACE-BASE.sys, the core driver, 4.1 MB
https://afflicted.sh/files/fullhouse/ace_unpacked_deob_restored.sys

# ace-game.sys, per-game driver, 2.0 MB
https://afflicted.sh/files/fullhouse/ace-game-unpacked_deob_restored.sys

# SGuard64.exe, userland orchestrator, 7.6 MB
https://afflicted.sh/files/fullhouse/SGuard64_unpacked_deob_restored.exe

# the dumper itself, full source, tested against 1.0.2603
https://afflicted.sh/files/fullhouse/fullhouse_dumper.zip

a word on the base, because an earlier draft of this section had it wrong and the live test proved it. the base you need is ntoskrnl, the real one, and on current win11 the documented ways to get it are dead: EnumDeviceDrivers and NtQuerySystemInformation(11) hand back zeroes for kernel module bases, elevated or not, SeDebug or not. scrubbed. i watched the fallback path in my own dumper print ACE-BASE.sys @ 0x0 and die on a bad PE header before i stopped trusting it.

what works is the BlueStacks driver doing what it does: its query_funcs ioctl returns 312 exports with full kernel addresses in the output buffer, no read primitive needed, no elevation needed. one export minus its RVA gives the driver's own base, and one IAT slot inside that driver (drv + 0x3C038) names an ntoskrnl import: that value minus 0x269A20 is the kernel base, page aligned, MZ on top. from there PsLoadedModuleList is an exported symbol, walk it with your read primitive and every loaded driver hands you its exact base. that is the whole address problem, solved by the vendor's own signed driver:

# how the base actually resolves on win11 26200, from the live run
EnumDeviceDrivers()               # 0x0, scrubbed. dead end
NtQuerySystemInformation(11)      # 0x0, same scrub. dead end

# the working chain, unelevated:
BstkDrv_nxt query_funcs           # 312 exports, kernel addrs in the clear
SUPR0LockMem - 0x1BD0             # = driver base
read(drv + 0x3C038) - 0x269A20    # = ntoskrnl base, MZ checked

# live run, for the record:
#   ntoskrnl     0xFFFFF8077D600000
#   PsLoadedModuleList -> 230 modules
#   ACE-BASE.sys 0xFFFFF80713C50000 (6656 KB)

# and the one command that does all of it:
python ace_ultimate.py full ace-base    # dump, deob, lift, clean

also in the zip, ace_smart_dump.c, the C version for people who would rather not have python anywhere near kernel memory. it is the polite one of the family: no blind scanning, it looks the module up by name, validates the PE header before it reads a single chunk, walks in 0x800 byte steps with error recovery, and tells you outright when the .tvm0 it just pulled is the unpacked running copy. the name lookup modes (list, find, dump) predate the base scrubbing and still work where SystemModuleInformation returns real addresses; on the current build use dumpaddr with the base from the BlueStacks chain above:

# the surgical route, base supplied by hand from the chain
ace_smart_dump.exe find ace                        # name lookup, where it still works
ace_smart_dump.exe dumpaddr 0xFFFFF80713C50000 0x680000   # the reliable route
# output: memory dump, .tvm0 UNPACKED, section map, VMP verdict

closing

there's a genre of security talk about how anti-cheat needs to be a kernel-mode driver, and how that's a necessary evil. but a driver that opens up \Device\PhysicalMemory with SECTION_ALL_ACCESS, then issues IOCTLs to it at will, and then tries to "fix" the resulting CVE by e.g. adding a size limit and a PID list to stamp every hand that reaches for it, is an unnecessary evil. it's security through a paid watchman and an unlocked door.

as always this research is done on my own machines, no players were harmed, and nothing was executed on live infrastructure. the HWID sent by the emulator was an MD5 hash of garbage. next step is to disclose this to tencent, given the history of the related CVEs I should not hold my breath for a fix any time soon.

full house beats ACE.