我逆向了幻塔的反作弊驱动:一个从未加载的BYOVD工具包。
I reversed Tower of Fantasy's anti-cheat driver: a BYOVD toolkit never loaded

原始链接: https://vespalec.com/blog/tower-of-flaws/

## 幻塔驱动程序漏洞:摘要 一位研究人员尝试删除其幻塔账号,导致对游戏内核驱动程序的安全审计,揭示了重大漏洞。该驱动程序 `GameDriverX64.sys` 令人惊讶的是,即使作为安全关键组件,也缺乏混淆,这可能是由于与Windows的Hypervisor-Protected Code Integrity (HVCI)兼容所致。 该驱动程序的身份验证依赖于一个简单的硬编码“魔术数字”,允许攻击者利用两个主要缺陷:任意进程终止和任意进程保护。通过使用特定的IOCTL代码,攻击者可以杀死*任何*进程,甚至包括具有更高安全性的进程,如Protected Process Light (PPL),并限制对任何进程句柄的访问。这本质上允许一种“自带易受攻击驱动程序” (BYOVD)攻击,类似于过去其他游戏反作弊驱动程序中的事件。 讽刺的是,该驱动程序甚至没有被游戏本身主动加载,使其在玩家的系统中处于休眠状态。尽管如此,滥用的可能性仍然存在。一个概念验证已被创建,证明了这些漏洞,并且已提交了一个CVE (CVE-2025-61155)。该研究人员强调了编写不当的内核驱动程序的危险性以及即使启用HVCI,强大身份验证的重要性。

黑客新闻 新 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 我逆向了幻塔的反作弊驱动:一个从未加载的BYOVD工具包 (vespalec.com) 8 分,来自 svespalec 38 分钟前 | 隐藏 | 过去 | 收藏 | 2 条评论 bri3d 13 分钟前 [–] 这是一篇很棒的报告。看起来这个驱动也在被恶意软件积极使用:https://www.fortinet.com/blog/threat-research/interlock-rans... 回复svespalec 5 分钟前 | 父评论 [–] 谢谢!我不知道它已经在野外使用了。这是一个很好的案例,说明了发布带有暴露 IOCTL 和弱身份验证的签名驱动程序是多么具有风险,即使(尤其是)开发者从不费心加载它们。 回复 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请YC | 联系 搜索:
相关文章

原文

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.

Driver file in directory

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.

Driver file in directory

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.

Driver entry in IDA


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 CodePurpose
0x222000Check if debugger attached via debug port
0x222004Register a process as “protected”
0x222008Heartbeat/keepalive
0x222020Get list of protected processes
0x222040Terminate any process by PID
0x222044Strip handle access rights via ExEnumHandleTable
0x222048Validate 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;

Magic key check 1

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:

  1. DLL presence check - bypassed by loading any renamed DLL
  2. Process name whitelist - bypassed by renaming your executable
  3. PE checksum validation - bypassed by writing 4 bytes with a hex editor
  4. 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.

Terminate process handler in IDA


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:

  1. 0x222004 to register your PID as protected (blocks future handles via ObRegisterCallbacks)
  2. 0x222044 to strip all existing handles to your process (kills current EDR handles via ExEnumHandleTable)

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

联系我们 contact @ memedata.com