Architecting CS2 GSIS

This report provides an analysis of the architectural methodologies utilized in unauthorized game state interrogation system (commonly referred to as "cheat engines"). It traces the evolution of memory manipulation from standard user-mode (Ring 3) API exploitation to advanced kernel-mode (Ring 0) subversion. The analysis details the inherent structural limitations of user-mode implementations against modern security solutions, the mechanics of Driver Signature Enforcement (DSE) bypasses via Bring Your Own Vulnerable Driver (BYOVD) vectors, and the low-level implementation of unbacked memory manual mapping.


Part I: The Mechanics of User-Mode Memory Interrogation (Ring 3)

The fundamental objective of any state interrogation tool is to read, and selectively write, the virtual memory space of a target process. In a standard Windows environment, processes operate in isolated virtual address spaces. A standard user-mode executable cannot arbitrarily access the memory of another process without explicit authorization from the Windows OS Kernel.

To demonstrate these concepts, we will analyze the architecture of a custom user-mode interrogation engine built in Rust, codenamed "The Jonkler Engine," designed to target the process cs2.exe (Counter-Strike 2).

fig1

Fig 1: Windows NT Architecture Diagram

1.1 The NTAPI Vector and Undocumented Structures

Initial implementations of memory scanners rely heavily on the documented Windows API (Win32), specifically functions like OpenProcess and ReadProcessMemory. However, clinical implementations often bypass the standard Win32 wrappers in kernel32.dll and interface directly with the Native API (NTAPI) exported by ntdll.dll.

This approach reduces the footprint of the application and interacts closer to the system call boundary. Because NTAPI functions are largely undocumented, the engine must manually define the required C-style structures using Rust's #[repr(C)] directive to ensure exact memory layout parity with the OS.

// UNDOCUMENTED NTAPI STRUCTURES 
#[repr(C)]
#[allow(non_snake_case)]
pub struct UNICODE_STRING {
    pub Length: u16,
    pub MaximumLength: u16,
    pub Buffer: *mut u16,
}

#[repr(C)]
#[allow(non_snake_case)]
pub struct OBJECT_ATTRIBUTES {
    pub Length: u32,
    pub RootDirectory: HANDLE,
    pub ObjectName: *mut UNICODE_STRING,
    pub Attributes: u32,
    pub SecurityDescriptor: *mut c_void,
    pub SecurityQualityOfService: *mut c_void,
}

#[repr(C)]
#[allow(non_snake_case)]
pub struct CLIENT_ID {
    pub UniqueProcess: HANDLE,
    pub UniqueThread: HANDLE,
}

With the structures defined, the engine dynamically links the target functions from ntdll.dll.

// NTDLL IMPORTS 
#[link(name = "ntdll")]
unsafe extern "system" {
    fn NtOpenProcess(
        ProcessHandle: *mut HANDLE,
        DesiredAccess: u32,
        ObjectAttributes: *mut OBJECT_ATTRIBUTES,
        ClientId: *mut CLIENT_ID,
    ) -> i32;

    fn NtReadVirtualMemory(
        ProcessHandle: HANDLE,
        BaseAddress: *mut c_void,
        Buffer: *mut c_void,
        BufferSize: usize,
        NumberOfBytesRead: *mut usize,
    ) -> i32;
}

1.2 Process Enumeration and Handle Acquisition

Before memory can be read, the engine must acquire a highly privileged handle to the target process.

  1. Process Identification: The engine utilizes the ToolHelp32 API (CreateToolhelp32Snapshot, Process32First, Process32Next) to iterate through the system's active process list, matching the target executable name (cs2.exe) to extract its Process Identifier (PID).
  2. Handle Acquisition via NtOpenProcess: Armed with the PID, the engine populates a CLIENT_ID structure and invokes NtOpenProcess. It requests a specific access mask: 0x0010, which corresponds to PROCESS_VM_READ. If successful, the kernel grants a handle that authorizes read operations against the isolated virtual memory space of cs2.exe.
    pub fn new(process_name: &str) -> Option<Self> {
        let pid = Self::get_process_id(process_name)?;
        unsafe {
            let mut handle = HANDLE::default();

            let mut client_id = CLIENT_ID {
                UniqueProcess: HANDLE(pid as isize),
                UniqueThread: HANDLE(0),
            };

            let mut obj_attr = OBJECT_ATTRIBUTES {
                Length: size_of::<OBJECT_ATTRIBUTES>() as u32,
                RootDirectory: HANDLE(0),
                ObjectName: std::ptr::null_mut(),
                Attributes: 0,
                SecurityDescriptor: std::ptr::null_mut(),
                SecurityQualityOfService: std::ptr::null_mut(),
            };

            // 0x0010 is the specific bitmask for PROCESS_VM_READ
            let status = NtOpenProcess(
                &mut handle,
                0x0010,
                &mut obj_attr,
                &mut client_id,
            );

            // NTSTATUS >= 0 means NT_SUCCESS
            if status >= 0 {
                Some(Self { handle })
            } else {
                None
            }
        }
    }

1.3 Navigating ASLR: Dynamic Module Resolution

Modern applications employ Address Space Layout Randomization (ASLR). The OS pseudo-randomly arranges the memory positions of key data areas, including the base of the executable and the positions of libraries (DLLs). Hardcoded memory addresses are therefore obsolete.

The Jonkler engine must dynamically resolve the base address of client.dll—the module containing the primary game state—at runtime. It again utilizes the ToolHelp32 API, this time with the TH32CS_SNAPMODULE flag, to iterate through the modules loaded by cs2.exe until a string match is found.

    pub fn get_module_base(&self, process_name: &str, module_name: &str) -> Option<usize> {
        let pid = Self::get_process_id(process_name)?;
        unsafe {
            let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, pid).ok()?;
            if snapshot == INVALID_HANDLE_VALUE { return None; }

            let mut me32: MODULEENTRY32 = zeroed();
            me32.dwSize = size_of::<MODULEENTRY32>() as u32;

            if Module32First(snapshot, &mut me32).is_ok() {
                loop {
                    let current_module = CStr::from_ptr(me32.szModule.as_ptr() as *const i8).to_str().unwrap_or("");
                    if current_module.eq_ignore_ascii_case(module_name) {
                        CloseHandle(snapshot).ok();
                        return Some(me32.modBaseAddr as usize);
                    }
                    if Module32Next(snapshot, &mut me32).is_err() { break; }
                }
            }
            CloseHandle(snapshot).ok();
        }
        None
    }

1.4 The Engine Core: Interrogation and The Hot-Loop

With a valid handle and the dynamic base address of client.dll resolved, the engine establishes a multi-threaded "hot-loop."

  1. Offset Resolution: The engine adds static, pre-calculated offsets (e.g., dwEntityList, dwViewMatrix) to the dynamic client_base address to locate dynamic pointers.
  2. Memory Reading: The background thread continuously invokes the custom read<T> wrapper, which utilizes NtReadVirtualMemory to extract the game state (player coordinates, health, team data) from cs2.exe.
  3. Data Synchronization: The parsed data is locked into an Arc<Mutex<Vec<EspPlayer>>> to be safely shared with the render thread.
    pub fn read<T: Copy>(&self, address: usize) -> Option<T> {
        unsafe {
            let mut buffer: T = zeroed();
            let mut bytes_read = 0;

            let status = NtReadVirtualMemory(
                self.handle,
                address as *mut c_void,
                &mut buffer as *mut T as *mut c_void,
                size_of::<T>(),
                &mut bytes_read,
            );

            if status >= 0 && bytes_read == size_of::<T>() {
                Some(buffer)
            } else {
                None
            }
        }
    }

fig2

Fig 2: The Jonkler Engine User Mode Boot Sequence

The engine's boot sequence demonstrates this orchestration:

    let mem = loop {
        if let Some(m) = Memory::new("cs2.exe") {
            println!("[+] Target Process Hooked: cs2.exe");
            break m;
        }
        thread::sleep(Duration::from_secs(1));
    };

    let client_base = loop {
        if let Some(base) = mem.get_module_base("cs2.exe", "client.dll") {
            println!("[+] Module Resolved [client.dll]: 0x{:X}", base);
            break base;
        }
        thread::sleep(Duration::from_secs(1));
    };

    let entity_list = loop {
        let ptr = mem.read::<usize>(client_base + base_offsets::dwEntityList).unwrap_or(0);
        if ptr != 0 {
            println!("[+] Pointer Resolved [dwEntityList]: 0x{:X}", ptr);
            break ptr;
        }
        thread::sleep(Duration::from_secs(2));
    };

Part II: The Downfall of User-Mode and Anti-Cheat Mitigations

The Jonkler Engine, as constructed above, is functionally sound but operationally doomed against an adversary with kernel-level privileges. Because the interrogator operates in Ring 3, it relies on the OS kernel to facilitate memory access. When an Anti-Cheat (AC) driver is loaded into Ring 0, it interposes itself in this communication pathway.

2.1 Handle Stripping via ObRegisterCallbacks

The primary mitigation against NtOpenProcess relies on the Windows Object Manager. Microsoft provides legitimate drivers with the ObRegisterCallbacks API.

When the Jonkler Engine calls NtOpenProcess, the request enters the kernel. Before the handle is granted, the AC driver's registered pre-operation callback is triggered. The AC examines the requesting process (Jonkler.exe) and the target process (cs2.exe). Recognizing an unauthorized access attempt against a protected process, the AC alters the requested access mask, stripping PROCESS_VM_READ from the request.

The kernel returns a handle to the engine, but the handle lacks the necessary permissions. Subsequent calls to NtReadVirtualMemory fail with STATUS_ACCESS_DENIED (0xC0000022). The user-mode engine is effectively blinded.

2.2 API Hooking and Heuristic Detection

Even if a handle is obtained (e.g., prior to the AC fully initializing), user-mode execution is heavily monitored:

  • Inline Hooking: Older AC architectures injected monitoring DLLs into all running processes, altering the prologue of NtReadVirtualMemory to redirect execution to analysis routines.
  • Handle Enumeration: ACs routinely scan the system's active handle tables using NtQuerySystemInformation. The presence of a high-privilege handle targeting the game client originating from an untrusted process is grounds for immediate termination.
  • Topmost Window Detection: The Jonkler Engine utilizes a transparent overlay to visualize data:
        let mut options = eframe::NativeOptions {
            viewport: egui::ViewportBuilder::default()
                .with_decorations(false)
                .with_transparent(true)
                .with_always_on_top()
                .with_mouse_passthrough(true)
    ACs iterate through the system's window hierarchy via EnumWindows. Windows matching this exact profile (Topmost, Layered, Transparent) that are not digitally signed by trusted vendors (e.g., Discord, NVIDIA) trigger immediate heuristics.

The conclusion is absolute: Ring 3 memory manipulation cannot survive against a proactive Ring 0 adversary. Escalation of privileges is mandatory to subvert Object Manager callbacks and handle monitoring.

Part III: Escalation to Kernel Mode (Ring 0)

To bypass Object Manager callbacks and handle monitoring entirely, the interrogation engine must operate at the same privilege level as the security solution. However, since the introduction of 64-bit Windows, Microsoft has enforced Driver Signature Enforcement (DSE). The kernel will refuse to load a .sys file unless it is cryptographically signed by Microsoft's Windows Hardware Developer Center Dashboard portal.

3.1 The BYOVD Vector and Manual Mapping

Threat actors bypass DSE utilizing the BYOVD (Bring Your Own Vulnerable Driver) methodology. By loading a legitimate, digitally signed driver containing an arbitrary read/write vulnerability (e.g., iqvw64e.sys), an attacker gains foundational execution in Ring 0.

However, running a continuous interrogation loop through a vulnerable OEM driver is highly anomalous. Instead, clinical architectures use the vulnerable driver strictly as a bootstrap mechanism to "manually map" a custom, unsigned payload into the kernel, utilizing tools such as kdmapper.

  1. Memory Allocation: The tool exploits the signed driver to allocate a block of Non-Paged Pool memory.
  2. Section Mapping: The custom driver (jonkler_sys) is manually loaded into this pool, resolving its own relocations and IAT (Import Address Table).
  3. Execution & Unlinking: The tool hijacks an execution thread to call the unsigned driver's DriverEntry, then instructs the vulnerable driver to wipe the MZ/PE headers of the mapped payload and unloads the vulnerable driver.

The result is a Ghost Engine executing in Ring 0, residing in an unbacked memory pool, completely invisible to the Service Control Manager (SCM), and unlisted in the system's PsLoadedModuleList.

fig3

Fig 3: Manual Mapping jonkler_sys to unbacked memory pool

3.2 Synthesizing the Driver Object

Because the payload was not loaded by the Windows OS Loader, it lacks a standard DRIVER_OBJECT. To establish an I/O Control (IOCTL) bridge with the user-mode client, the engine must synthesize an object from nothing using the undocumented IoCreateDriver NTAPI function.

In the Jonkler kernel implementation, the hijacked entry point explicitly ignores the execution context provided by the mapper and spins up a pristine, unregistered object:

#[unsafe(export_name = "DriverEntry")]
pub unsafe extern "system" fn driver_entry(_hijacked_driver: PDRIVER_OBJECT, _reg: PCUNICODE_STRING) -> NTSTATUS {
    let mut ustr_driver = get_static_unicode(DRV_NAME);
    // Ignore the hijacked object, create a synthetic unregistered driver
    IoCreateDriver(&mut ustr_driver, Some(real_driver_entry)); 
    0 // Return success so kdmapper unlinks the bootstrap cleanly
}

The real_driver_entry then initializes a DEVICE_OBJECT (\Device\jonkler_sys) and a symbolic link (\DosDevices\jonkler_sys), effectively opening a shadow communication channel in the Windows Object Manager.


Part IV: The IOCTL Communication Bridge

With the kernel payload established, the user-mode client (Jonkler.exe) must abandon NtOpenProcess and route all state interrogation requests through the custom device symbolic link.

4.1 The User-Mode Handshake

The user-mode application interfaces with the Ghost Engine exactly as it would a legitimate hardware driver, utilizing CreateFileA to obtain a handle to the symbolic link, and DeviceIoControl to transmit instructions.

    pub fn new(pid: u32) -> Option<Self> {
        let driver_name = PCSTR(b"\\\\.\\jonkler_sys\0".as_ptr());
        let driver_handle = unsafe {
            CreateFileA(driver_name, 0xC0000000, FILE_SHARE_MODE(0), None, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None)
        }.unwrap_or(INVALID_HANDLE_VALUE);

        if driver_handle == INVALID_HANDLE_VALUE { return None; }
        Some(Self { driver_handle, pid })
    }

fig4

Fig 4: IOCTL Communication Bridge Showing User-mode DeviceIoControl Interacting With Kernel-mode IRP Dispatcher

<image of IOCTL communication bridge showing user-mode DeviceIoControl interacting with kernel-mode IRP dispatcher>

4.2 Subverting Handle Checks via MmCopyVirtualMemory

The primary operational advantage of the Ghost Engine is its reliance on MmCopyVirtualMemory. When the user-mode client dispatches an IOCTL_READ_MEMORY command, the I/O Request Packet (IRP) is intercepted by the kernel driver's dispatch_ioctl routine.

    if control_code == IOCTL_READ_MEMORY {
        let system_buffer = (*irp).AssociatedIrp.SystemBuffer;
        if !system_buffer.is_null() {
            let request = *(system_buffer as *const KernelReadRequest);
            let mut target_process: PEPROCESS = core::ptr::null_mut();

            // Locate the target process context entirely in Ring 0
            if nt_success(PsLookupProcessByProcessId(request.target_pid as usize as *mut _, &mut target_process)) {
                let mut return_size = 0;
                
                // Directly copy memory between VADs, bypassing handle generation
                if nt_success(MmCopyVirtualMemory(
                    target_process,
                    request.target_address as *mut _,
                    PsGetCurrentProcess(),
                    system_buffer, // Route data directly to the IO Manager's buffer
                    request.size as usize, 
                    0,
                    &mut return_size
                )) {
                    status = 0;
                    bytes_returned = return_size;
                }
                ObfDereferenceObject(target_process as *mut _);
            }
        }
    }

MmCopyVirtualMemory operates below the Object Manager layer. It directly commands the memory manager to copy data between the Virtual Address Descriptors (VADs) of cs2.exe and Jonkler.exe. Because no formal access handle is ever instantiated, ObRegisterCallbacks is completely bypassed. To a Ring 0 Anti-Cheat monitoring the system handle table, the Jonkler user-mode application appears as a benign process with zero interaction with the game client.


Part V: Architectural Hazards: Alignment and The Clobber Anomaly

Developing across the Ring 3 to Ring 0 boundary introduces severe structural hazards. A single unhandled page fault or misaligned memory mapping will trigger a KMODE_EXCEPTION_NOT_HANDLED bug check (Blue Screen of Death). The Jonkler architecture demonstrates two critical mitigations for these hazards.

5.1 The 32/64-Bit Struct Alignment Crisis

The integrity of the IOCTL bridge demands strict byte-for-byte parity between the user-mode request structure and the kernel-mode interpretation. If the user-mode app defaults to a 32-bit compilation target while the kernel executes in 64-bit space, the C-compiler will implicitly insert padding to align u64 variables on 8-byte boundaries.

Without explicit instruction, the kernel reads the user-mode target_address shifted by 4 bytes, resulting in an immediate memory violation. The clinical solution is explicit manual padding enforced by the #[repr(C)] macro, ensuring immutable alignment across environments:

// Immutable Memory Layout
#[repr(C)]
#[derive(Copy, Clone)]
pub struct KernelReadRequest {
    pub target_pid: u32,
    pub pad: u32,          // Explicit 4-byte padding forces strict 8-byte alignment
    pub target_address: u64,
    pub size: u64,         // Explicit 64-bit type overrides architecture-dependent usize
}

5.2 The METHOD_BUFFERED System Clobber

When utilizing METHOD_BUFFERED IOCTLs (the standard for DeviceIoControl), the Windows I/O Manager allocates a single SystemBuffer in the Non-Paged Pool. This buffer serves a dual purpose: it carries the input request from user-mode to kernel-mode, and subsequently carries the output data back.

A common implementation error involves the kernel driver utilizing MmCopyVirtualMemory to write game data directly into a user-mode pointer provided inside the request struct. While the read succeeds, upon completion of the IRP, the Windows I/O Manager helpfully copies the original input contents of SystemBuffer back to the user, overwriting the legitimately retrieved game data with the request metadata—a silent corruption anomaly.

The Jonkler implementation properly circumvents this by overwriting the SystemBuffer itself while still in Ring 0:

// Overwriting the I/O Manager's buffer directly
MmCopyVirtualMemory(..., system_buffer, request.size as usize, ...);

By placing the extracted game state directly into system_buffer, the driver allows the I/O Manager to natively transfer the target data back to the user-mode client's memory space upon returning IofCompleteRequest(irp, 0);.


Part VI: The Hot-Loop and Render Decoupling

With the kernel bridge finalized, the user-mode execution degrades into an ultra-fast telemetry processor. The main.rs implementation separates the memory interrogation loop from the Graphical User Interface (GUI) rendering pipeline to maximize system performance.

    // Engine Hot-Loop Thread
    thread::spawn(move || {
        // ... (Entity list resolution)
        loop {
            let vm: [f32; 16] = mem.read(client_base + base_offsets::dwViewMatrix).unwrap_or([0.0; 16]);
            let frame = scan_targets(&mem, client_base, entity_list, &vm, screen_w, screen_h);

            if let Ok(mut p) = thread_players.lock() {
                *p = frame; // Synchronize parsed state
            }
            thread::sleep(Duration::from_millis(1)); // 1000Hz polling rate
        }
    });

Because the memory reads traverse the optimized IOCTL path, the background thread can sustain a polling rate of 1000Hz (1ms). The resulting target data is serialized into an Arc<Mutex<Vec<EspPlayer>>> and passed to the egui presentation layer, which runs independently, locked to the monitor's native refresh rate.

fig5

Fig 4: Multi Threaded Application using Decoupled Memory Polling and UI Rendering Threads

DISCLAIMER: THE SCREENSHOT ABOVE IS DONE IN A CONTROLLED, NON-COMPETITIVE MODE AND ONLY STANDS FOR EDDUCATIONAL PURPOSES ONLY, WE ARE NOT RESPONSIBLE FOR ANY ACTIONS AND CONSEQUENCES THAT WILL HAPPEN WITH THE MISUSE OF THIS SHARED KNOWLEDGE

fig5

Fig 5: Undetectable Process Handle Using MmCopyVirtualMemory

Conclusion: The Horizon of Heuristic Detection

The manually mapped Ring 0 engine represents a mathematically profound subversion of Windows operating system security. By existing in unbacked memory and transiting data exclusively via direct VAD manipulation, the Jonkler architecture fundamentally blinds standard user-mode and kernel-mode signature scanners.

However, against top-tier Ring 0 hypervisors (such as Vanguard or FACEIT), this architecture leaves heuristic footprints:

  1. NMI Callbacks (Non-Maskable Interrupts): Advanced ACs use hardware performance counters to trigger NMIs, freezing CPU cores. If an instruction pointer (RIP) is caught executing inside the kdmapper allocated pool—a region without a verified backing file on disk—the system detects the anomaly.
  2. IOCTL Profiling: The continuous 1000Hz barrage of DeviceIoControl calls from a user-mode process to an unregistered device object (\DosDevices\jonkler_sys) forms a highly anomalous statistical pattern.

The perimeter of state interrogation is a perpetual arms race. While manual mapping represents the apex of software-based subversion, the inevitability of hardware-backed NMI profiling dictates the next evolutionary leap: migrating the read operations out of the host OS entirely via Direct Memory Access (DMA) PCIe hardware, or intercepting the AC itself utilizing customized Intel VT-x hypervisors.


References