This all started because I wanted to delete my Tower of Fantasy account from over 4 years ago.
For the life of me, I couldn’t find a way to do it without having the game installed. There was no web portal and no obvious support route. Eventually I gave up and decided to just download it.
Tower of Fantasy is over 100 GB so it would be a long install. I already knew the game shipped with an anti-cheat driver from past experience, so while the download crawled along I started poking around the launcher directory. That’s when I noticed GameDriverX64.sys.

Kernel drivers run with the highest privileges on your machine. Anti-cheat drivers use this power to protect games from cheaters, but when they’re poorly written, attackers can abuse that same power against you.
I opened the driver in IDA expecting a wall of virtualized code, probably VMProtect. Instead I got clean, readable functions with no obfuscation or virtualization at all.

By now, the install was at 9%. I had time to dig in.
There’s a lot of noise online about kernel anti-cheats being “spyware” or inherently privacy-invasive. Most of it misidentifies the actual risk. A usermode game client can already steal your browser cookies, log keystrokes, and exfiltrate files without ever touching the kernel. The real concern with kernel anti-cheats isn’t surveillance, it’s that they are security-critical code running at the highest privilege level. When they’re poorly written, they become attack surface, and when they fail, they can take your entire system down with them (e.g the CrowdStrike incident). For a thorough, level-headed breakdown of the privacy and security tradeoffs, I’d recommend this post by Bevan Philip.
Why Isn’t This Obfuscated?
The previous version of this driver (KSophon_x64.sys) was VMProtect’d to hell, so I was curious why they’d strip protection from a security-critical kernel component. The reason is due to HVCI.
HVCI (Hypervisor-Protected Code Integrity) is a Windows security feature that uses Hyper-V to enforce code integrity above the NT kernel, enabled by default on clean Windows 11 installs. The key constraint: W^X (Write XOR Execute) enforcement means code pages can’t be both writable and executable. VMProtect’s packing and import protection both violate this, so the driver fails integrity checks on HVCI-enabled systems.
VMProtect can still work under HVCI if you stick to mutation and virtualization macros while avoiding the features that break W^X. They could still protect their imports manually and virtualize the bulk of their code. For some reason, they did neither.
Even with VMProtect, these vulnerabilities would still exist. The IOCTLs still do what they do and the authentication is essentially nonexistent. Obfuscation makes reversing harder, not impossible.

What the Driver Actually Does
It registers the following device:
Device Name: \Device\HtAntiCheatDriver
Symbolic Link: \\.\HtAntiCheatDriver
Device Access Control
The IRP_MJ_CREATE handler checks whether the calling process has loaded one of three specific DLLs:
NTSTATUS CreateHandler(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
NTSTATUS Status = STATUS_UNSUCCESSFUL;
if (VerifyRequiredModules())
Irp->IoStatus.Status = Status;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
BOOLEAN VerifyRequiredModules() {
UNICODE_STRING QmGUI4, QmGUI, GameUI{};
RtlInitUnicodeString(&QmGUI4, L"QmGUI4.dll");
RtlInitUnicodeString(&QmGUI, L"QmGUI.dll");
RtlInitUnicodeString(&GameUI, L"gameuirender.dll");
// Get the PEB of the calling process
PEPROCESS Process = IoGetCurrentProcess();
PPEB Peb = PsGetProcessPeb(Process);
PPEB_LDR_DATA Ldr = Peb->Ldr;
PLIST_ENTRY Head = &Ldr->InLoadOrderModuleList;
PLIST_ENTRY Entry = Head->Flink;
// Walk the loaded module list and check each DLL name
PLDR_DATA_TABLE_ENTRY Module = CONTAINING_RECORD(Entry, LDR_DATA_TABLE_ENTRY, InLoadOrderLinks);
if (RtlCompareUnicodeString(&Module->BaseDllName, &QmGUI4, TRUE) == 0 ||
RtlCompareUnicodeString(&Module->BaseDllName, &QmGUI, TRUE) == 0 ||
RtlCompareUnicodeString(&Module->BaseDllName, &GameUI, TRUE) == 0) {
In practice, you don’t even need the real DLLs. The check only looks at module names in your PEB, so you can rename literally any DLL to QmGUI.dll for example and load it. My PoC just copies version.dll from System32 and renames it.
Handle Protection
The driver registers ObRegisterCallbacks to intercept handle operations on protected processes and strip dangerous access rights. The callbacks look like this:
NTSTATUS ProcessPreCallback(PVOID RegistrationContext, POB_PRE_OPERATION_INFO OperationInfo) {
PEPROCESS TargetProcess = OperationInfo->Object;
PEPROCESS CurrentProcess = IoGetCurrentProcess();
if (!IsProtectedProcess(TargetProcess))
if (TargetProcess == CurrentProcess)
if (IsProtectedProcess(CurrentProcess) || IsWhitelistedProcess(CurrentProcess))
// Strips: VM_READ, VM_WRITE, VM_OPERATION, DUP_HANDLE, SET_INFORMATION, SUSPEND_RESUME
OperationInfo->Parameters->CreateHandleInformation.DesiredAccess &= 0xFFFFF587;
NTSTATUS ThreadPreCallback(PVOID RegistrationContext, POB_PRE_OPERATION_INFO OperationInfo) {
PETHREAD TargetThread = OperationInfo->Object;
PEPROCESS TargetProcess = IoThreadToProcess(TargetThread);
PEPROCESS CurrentProcess = IoGetCurrentProcess();
if (!IsProtectedProcess(TargetProcess))
// Same whitelist checks...
// Strips: TERMINATE, SUSPEND_RESUME, SET_CONTEXT
OperationInfo->Parameters->CreateHandleInformation.DesiredAccess &= 0xFFFFFFEC;
Notably, PROCESS_TERMINATE is not stripped from process handles, so external processes can still kill the game.
Before stripping, the callback checks a whitelist. One entry is hardcoded for "CrashCapture.e" (their crash handler) with zero integrity verification. The .e extension isn’t a typo by the way, PsGetProcessImageFileName returns from EPROCESS.ImageFileName, a 15-byte array, so “CrashCapture.exe” (16 chars) gets truncated to 14 chars + null. The check itself is just a filename comparison:
bool IsWhitelistedProcess(PEPROCESS Process) {
char* ProcessName = PsGetProcessImageFileName(Process);
// Hardcoded whitelist entry, no integrity check
if (strnicmp(ProcessName, "CrashCapture.e", strlen(ProcessName)) == 0)
// Dynamically whitelisted entries
for (int i = 0; i < WhitelistCount; i++) {
if (strnicmp(ProcessName, Whitelist[i].Name, strlen(ProcessName)) == 0) {
// "Validation" that compare PE checksum from PEB
PPEB Peb = PsGetProcessPeb(Process);
PIMAGE_NT_HEADERS NtHeaders = RtlImageNtHeader(Peb->ImageBaseAddress);
if (NtHeaders->OptionalHeader.CheckSum == Whitelist[i].ExpectedChecksum)
There’s also a subtle bug here: the strnicmp calls use strlen(ProcessName) as the comparison length, not the length of the constant string. This means the comparison is a prefix match. Any process whose name is a prefix of the whitelist entry will pass. A process named "Crash" or even "C" would match "CrashCapture.e". The same bug applies to every dynamic whitelist entry.
Dynamic whitelist entries do one more check: comparing the caller’s OptionalHeader.CheckSum against a stored value. PE checksums aren’t cryptographic though, so anyone can set them to any value with a hex editor.
There’s also a background thread that runs anti-debug and integrity checks in a loop:
void MonitoringThread() {
KdChangeOption(KD_OPTION_SET_BLOCK_ENABLE, 1, ...);
// Self-integrity: CRC32 check on driver memory regions
if (!VerifyIntegrity(...))
Plus a process creation callback that kills all protected processes if it sees GPUView.exe or xperf.exe launch, neither of which are reverse engineering tools. My best guess is this was cargo-culted from another anti-cheat driver’s blocklist.
The IOCTL Interface
The driver exposes 10 IOCTL codes, 7 of which are interesting:
| IOCTL Code | Purpose |
|---|---|
0x222000 | Check if debugger attached via debug port |
0x222004 | Register a process as “protected” |
0x222008 | Heartbeat/keepalive |
0x222020 | Get list of protected processes |
0x222040 | Terminate any process by PID |
0x222044 | Strip handle access rights via ExEnumHandleTable |
0x222048 | Validate memory checksum |
The remaining three (0x222080, 0x222084, 0x222088) expose kernel memory scanning and module enumeration. Not the main finding here, but worth noting they exist behind the same weak authentication.
The Authentication Mechanism (Or Lack Thereof)
Every IOCTL is “protected” by this:
NTSTATUS DispatchDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
if (InputBuffer->Magic != 0xFA123456)
return STATUS_ACCESS_DENIED;

A hardcoded 32-bit magic value. No cryptographic verification, no challenge-response, no signature validation. The driver is unobfuscated, so anyone can just read the value out of the binary and call whatever IOCTL they want.
Four Layers, None of Them Do Anything
The full “authentication” stack:
- DLL presence check - bypassed by loading any renamed DLL
- Process name whitelist - bypassed by renaming your executable
- PE checksum validation - bypassed by writing 4 bytes with a hex editor
- Hardcoded magic number - bypassed by reading the binary
Vulnerability #1: Arbitrary Process Termination
IOCTL 0x222040 terminates any process on the system:
struct TERMINATE_REQUEST {
DWORD Magic; // 0xFA123456
DWORD ProcessId; // PID to kill
The IOCTL dispatch routine validates the magic field, then passes just the PID to an internal TerminateProcess function. That function calls ZwOpenProcess with GENERIC_ALL (0x10000000), then ZwTerminateProcess. The Zw prefix is important here: Zw* functions set PreviousMode to KernelMode before entering the system service, which tells the object manager to skip security descriptor checks on the target process entirely. If they’d used the Nt* variants instead, the call would inherit the original caller’s previous mode and could actually be denied. But with Zw*, no access check will block the open. GENERIC_ALL maps to every access right, so the returned handle can do anything. Antivirus, EDR agents, system services, even PPL (Protected Process Light) processes are not safe. ZwTerminateProcess from kernel mode bypasses PPL protection entirely, which means even processes that Windows itself is supposed to shield from tampering are killable through this driver.

Vulnerability #2: Arbitrary Process Protection
IOCTL 0x222004 registers any process as “protected.” The same ObRegisterCallbacks that strip handle access rights for the game now apply to whatever PID you pass in.
DWORD Magic; // 0xFA123456
DWORD ProcessId; // PID to protect
DWORD GroupId; // Protection group identifier
This doesn’t make your process invisible on its own. EDR agents register kernel callbacks (PsSetCreateProcessNotifyRoutine, PsSetLoadImageNotifyRoutine) to inject their monitoring DLLs during early process creation, before your entry point or even TLS callbacks run. By the time your code can call this IOCTL, the EDR’s hooks are already in your process.
What this does block is future handle operations. ObRegisterCallbacks intercepts both OB_OPERATION_HANDLE_CREATE and OB_OPERATION_HANDLE_DUPLICATE, so it covers NtOpenProcess and NtDuplicateObject. Important detail: the callback can’t outright deny the open. It strips access bits from the DesiredAccess mask, so the call still succeeds but returns a handle with reduced permissions (no PROCESS_VM_READ, no PROCESS_VM_WRITE, etc). Functionally useless for the caller.
That leaves existing handles. Any handles the EDR already holds from before the IOCTL remain valid with their original access rights. But the driver has a solution for that too: IOCTL 0x222044 calls ExEnumHandleTable to walk the system handle table and retroactively strip access rights from existing handles pointing at a target process. So the full protection chain doesn’t even require killing anything:
0x222004to register your PID as protected (blocks future handles viaObRegisterCallbacks)0x222044to strip all existing handles to your process (kills current EDR handles viaExEnumHandleTable)
New handles get stripped on creation, existing handles get stripped after the fact. Combine that with Vulnerability #1 to kill the EDR service entirely and you have the complete BYOVD toolkit. If you remember mhyprot2 (the Genshin Impact driver that got weaponized by ransomware groups), this driver has the same kill-then-shield capability exposed through a comparably weak authentication mechanism.
They Don’t Even Use It
After documenting all of this, I actually launched the game to see the driver in action.
It wasn’t running. I checked for a loaded driver, checked for associated services, tried deleting the file to see if anything held a handle. Nothing. The driver just sits in the game directory and never gets loaded.
The game process isn’t protected either. No ObRegisterCallbacks stripping handle access. You can freely open a handle and read/write its memory.
They ship a kernel driver with hardcoded authentication and full BYOVD capabilities, and they don’t even load it. It just sits on every player’s machine. Good to know.
Proof of Concept
I wrote a PoC demonstrating both vulnerabilities. It bypasses the DLL check by loading one of the required DLLs, opens a handle to the driver, and first registers notepad.exe as a protected process via IOCTL 0x222004. Once protected, it waits for you to press Delete, then terminates it via IOCTL 0x222040 with the magic value.
Full source on GitHub: TowerOfFlaws
Disclosure
Someone already filed a CVE for this driver (CVE-2025-61155) before I started this work, but the entry has no writeup, no PoC, and no technical details. As far as I can tell, this is the first public documentation of how these vulnerabilities actually work. The driver isn’t actively loaded by the game (reducing immediate attack surface), the techniques involved are straightforward enough that any motivated attacker would find them independently, and if nothing else it’s a good reference for other anti-cheat vendors and driver developers on what not to do.

Takeaways
mhyprot2 already proved that anti-cheat drivers make high-value BYOVD targets. This driver has the same capabilities behind weaker authentication, and it shipped anyway. HVCI doesn’t kill obfuscation entirely, but it does break major features like packing and import protection that protectors like VMProtect offer. It’s possible some vendors just see their protected driver fail on HVCI systems and strip it entirely without understanding what specifically broke. Code obfuscation is still possible under HVCI, just more constrained, and if your security model depended on it, you were already in trouble.
If you’re shipping a kernel driver and want to make sure it doesn’t end up as someone’s next blog post, feel free to reach out.
Thanks for reading!