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.
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:
OpenProcess with PROCESS_VM_READ hits this callback first.
Faceit can strip those rights before the caller gets the handle back. This alone
kills the entire category of traditional memory-reading cheats.
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.
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.
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.
| Property | Value |
|---|---|
| Vendor | OpenText Corporation (formerly Guidance Software) |
| Product | EnCase Forensic / Enterprise |
| Signature | Valid Authenticode — Guidance Software, Inc. |
| LOLDrivers | Not present at time of research |
| Microsoft block list | Present |
| HVCI | Blocks this driver — not affected |
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 Code | Operation | Notes |
|---|---|---|
0x223058 | Version | Returns driver version (312) |
0x22305C | Set EPROCESS Offsets | Configures PID/FLINK/ImageName offsets |
0x223060 | SysProcInfo | Enumerate running process list |
0x223064 | PidName | Resolve process name from PID |
0x223068 | PidMemory | Read virtual memory from target PID — used in this research |
0x22306C | DriverName | Enumerate loaded driver names |
0x223070 | FileHandle | Enumerate file handles in target process |
0x223074 | FileHandleName | Resolve file handle paths in target process |
0x223078 | FileHandleCount | Count file handles in target process |
0x22307C | HideProc | Remove process from system process list |
0x223080 | GetDupPidHandle | Duplicate handle from another process |
0x223084 | GetPortNumber | Read TCP/IP port information |
0x223090 | Get Pid Dll Count | Count loaded DLLs in target process |
0x223094 | Get Pid Dlls | Enumerate loaded DLLs in target process |
0x223098 | PidTokenSid | Read process token SID |
0x22309C | Scan KMEM | Scan kernel memory |
0x2230A0 | GetVadList | Enumerate VAD tree of target process |
0x2230A4 | GetPageDataList | Read page data list |
0x2230A8 | OpenPhysicalMemory | Open \Device\PhysicalMemory section |
0x2230AC | ClosePhysicalMemory | Close physical memory handle |
0x2230B0 | ReadPhysicalMemory | Map physical memory via ZwMapViewOfSection |
0x2230B4 | KillProc | Terminate arbitrary process via ZwTerminateProcess |
0x2230B8 | UnhideProc | Restore hidden process |
0x2230BC | Get EPROCESS | Resolve EPROCESS pointer for PID |
0x2230C0 | DeleteFile | Delete file from kernel mode |
0x2230C4 | DeleteService | Remove 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.
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:
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.
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.
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 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 Layer | Mechanism | Result |
|---|---|---|
| Handle interception | ObRegisterCallbacks | Not triggered — no user-mode handle to game |
| Driver blocklist | Image load + hash check | Driver not on list at research time |
| Module scan | Enumerate game process modules | No foreign modules present |
| Memory integrity | Scan for RWX / signatures | Game process untouched |
| Process monitoring | PsSetCreateProcessNotifyRoutine | Attacking process appears benign |
The video below shows the read primitive working against a live Faceit session. Faceit AC is active throughout. No detection, no disconnect.
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++
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.
%TEMP% or user profile paths warrant
immediate investigation — legitimate drivers don't load from there.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
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.