Medusa: A Rust Kernel Driver for Cross-Process Memory Access

This post covers Medusa, a proof-of-concept Windows kernel driver written in Rust that provides arbitrary read and write access to any process’s virtual memory. Medusa exposes a device at \\.\Medusa and uses the undocumented MmCopyVirtualMemory API to copy memory across process boundaries, bypassing usermode protections like PAGE_GUARD pages and anti-cheat hooks. The driver exists as an educational reference for kernel-level game cheating techniques and for writing WDM drivers in Rust.

The Core Primitive: MmCopyVirtualMemory

Everything Medusa does depends on a single undocumented NT kernel function: MmCopyVirtualMemory. This API copies bytes between two process address spaces, and it accepts a previous_mode parameter that determines whether access checks are enforced. In util.rs, the extern declaration looks like this:

// From: Medusa/src/util.rs

#[repr(C)]
#[allow(non_camel_case_types)]
pub enum KPROCESSOR_MODE {
    /// Kernel mode: no access checks.
    KernelMode = 0,
    /// User mode: standard access checks apply.
    UserMode = 1,
}

extern "system" {
    /// Undocumented NT kernel API for copying memory between processes.
    pub fn MmCopyVirtualMemory(
        from_process: *mut c_void,
        from_address: *const c_void,
        to_process: *mut c_void,
        to_address: *mut c_void,
        buffer_size: usize,
        previous_mode: KPROCESSOR_MODE,
        return_size: *mut usize,
    ) -> NTSTATUS;
}

The critical detail is previous_mode. When a usermode application calls NtReadVirtualMemory or NtWriteVirtualMemory, the kernel sets PreviousMode to UserMode, which triggers page-level access checks. Pages marked with PAGE_GUARD or PAGE_NOACCESS will cause the operation to fail [1]. When previous_mode is set to KernelMode (value 0), these checks are skipped entirely. The memory manager treats the request as originating from a trusted kernel component and copies the bytes unconditionally [2].

This is why kernel drivers are the standard approach for game cheating tools that need to bypass memory protection. Usermode APIs like ReadProcessMemory and WriteProcessMemory ultimately call into the same kernel path, but they run with UserMode as the previous mode, meaning anti-cheat software can set up guard pages, VAD-based protections, or hook the syscall to detect and block the access [1]. A kernel driver calling MmCopyVirtualMemory directly with KernelMode sidesteps all of that.

Driver Initialization in Rust with the WDK Crate Ecosystem

Medusa is built on Microsoft’s windows-drivers-rs crate ecosystem, which provides Rust bindings for the Windows Driver Kit. The Cargo.toml declares these dependencies:

# From: Medusa/Cargo.toml

[package.metadata.wdk.driver-model]
driver-type = "WDM"

[lib]
crate-type = ["cdylib"]

[dependencies]
wdk-alloc = { path = "../../crates/wdk-alloc", version = "0.3.1" }
wdk = { path = "../../crates/wdk", version = "0.3.1" }
wdk-panic = { path = "../../crates/wdk-panic", version = "0.3.1" }
wdk-sys = { path = "../../crates/wdk-sys", version = "0.4.0" }

Four crates serve distinct roles: wdk-sys provides raw FFI bindings to the WDK C headers, wdk provides higher-level Rust abstractions (including the println! macro for DbgPrint output), wdk-alloc supplies a global allocator backed by kernel pool memory, and wdk-panic provides a panic handler suitable for #![no_std] kernel environments. The driver is compiled as a cdylib, which produces the .sys file that Windows loads.

The entry point in lib.rs follows the standard WDM pattern: create a device, create a symbolic link, and register IRP dispatch handlers:

// From: Medusa/src/lib.rs

#![no_std]
extern crate alloc;

#[cfg(not(test))]
extern crate wdk_panic;

#[cfg(not(test))]
#[global_allocator]
static GLOBAL_ALLOCATOR: WdkAllocator = WdkAllocator;

#[export_name = "DriverEntry"]
pub unsafe extern "system" fn driver_entry(
    driver: &mut DRIVER_OBJECT,
    _registry_path: PCUNICODE_STRING,
) -> NTSTATUS {
    println!("[medusa] [i] Driver loading...");
    driver.DriverUnload = Some(driver_exit);

    let status = unsafe { coms::setup_device(driver) };
    if status != STATUS_SUCCESS {
        println!("[medusa] [-] setup_device failed: {status:#x}");
        return status;
    }

    driver.MajorFunction[wdk_sys::IRP_MJ_CREATE as usize] = Some(coms::dispatch_create_close);
    driver.MajorFunction[wdk_sys::IRP_MJ_CLOSE as usize] = Some(coms::dispatch_create_close);
    driver.MajorFunction[wdk_sys::IRP_MJ_READ as usize] = Some(coms::dispatch_read);
    driver.MajorFunction[wdk_sys::IRP_MJ_WRITE as usize] = Some(coms::dispatch_write);

    println!("[medusa] [+] Driver loaded successfully");
    STATUS_SUCCESS
}

The #[export_name = "DriverEntry"] attribute ensures the Rust function matches the symbol name the Windows loader expects [3]. The #![no_std] declaration is mandatory for kernel code, since the Rust standard library relies on OS abstractions (threads, heap allocation via the system allocator, I/O) that do not exist in kernel mode. The wdk_panic crate and WdkAllocator fill in the two features that #![no_std] requires: a panic handler and a global allocator.

Device Creation: \Device\Medusa and \DosDevices\Medusa

The setup_device function in coms.rs creates the device object and its symbolic link:

// From: Medusa/src/coms.rs

pub unsafe fn setup_device(driver: &mut DRIVER_OBJECT) -> NTSTATUS {
    let mut dos_name = "\\DosDevices\\Medusa"
        .to_u16_vec()
        .to_windows_unicode_string()
        .expect("[medusa] [-] Unable to encode DOS name.");

    let mut nt_name = "\\Device\\Medusa"
        .to_u16_vec()
        .to_windows_unicode_string()
        .expect("[medusa] [-] Unable to encode NT name.");

    let mut device_object: PDEVICE_OBJECT = null_mut();

    let res = unsafe {
        IoCreateDevice(
            driver,
            0,
            &mut nt_name,
            FILE_DEVICE_UNKNOWN,
            FILE_DEVICE_SECURE_OPEN,
            0,
            &mut device_object,
        )
    };

    if !nt_success(res) {
        println!("[medusa] [-] IoCreateDevice failed: {res:#x}");
        return res;
    }

    (*driver).DeviceObject = device_object;
    unsafe {
        (*device_object).Flags |= DO_BUFFERED_IO;
    }

    let res = unsafe { IoCreateSymbolicLink(&mut dos_name, &mut nt_name) };
    if !nt_success(res) {
        println!("[medusa] [-] IoCreateSymbolicLink failed: {res:#x}");
        unsafe {
            IoDeleteDevice(device_object);
        }
        return STATUS_UNSUCCESSFUL;
    }

    STATUS_SUCCESS
}

Two names are necessary because the Windows object namespace has separate trees for kernel objects and Win32 objects. IoCreateDevice places the device at \Device\Medusa in the kernel namespace [4]. IoCreateSymbolicLink creates a \DosDevices\Medusa entry that maps to the kernel object, which is what usermode code accesses as \\.\Medusa via CreateFile [5].

The DO_BUFFERED_IO flag tells the I/O manager to allocate a system buffer and copy data between user space and kernel space for each IRP [6]. This is the simplest (though not the most performant) approach to transferring data between a usermode client and a kernel driver. The alternative would be DO_DIRECT_IO with MDLs, which avoids the copy but adds complexity.

The Wire Protocol: Binary Commands Over WriteFile

Rather than using DeviceIoControl with IOCTL codes, Medusa accepts commands as raw bytes written via WriteFile and returns results via ReadFile. The binary format is defined by constants in coms.rs:

// From: Medusa/src/coms.rs

/// Maximum buffer size for IRP data exchange.
const BUFFER_SIZE: usize = 4096;

/// Minimum command size: 8 (address) + 5 (tag) + 4 (PID) = 17 bytes.
const MIN_CMD_SIZE: usize = 17;

/// Command tag for write operations.
const CMD_WRITE: &[u8] = b"write";

/// Command tag for read operations (null-padded to 5 bytes).
const CMD_READ: &[u8] = b"read\0";

Every command follows this layout:

OffsetSizeField
08 bytesTarget virtual address (u64, little-endian)
85 bytesCommand tag: write or read\0
134 bytesTarget PID (u32, little-endian)
17N bytesPayload (data for writes, read size for reads)

The 5-byte command tag is compared directly against byte literals. The read tag is null-padded to read\0 to match the fixed 5-byte width. This is a straightforward design: no headers, no versioning, no TLV encoding. It works because the protocol has exactly two commands and a single client.

Parsing and Dispatching Commands in dispatch_write

The dispatch_write handler receives each WriteFile call as an IRP, copies the data from the system buffer, and routes it based on the command tag:

// From: Medusa/src/coms.rs

pub unsafe extern "C" fn dispatch_write(
    _dev: PDEVICE_OBJECT,
    irp: *mut IRP,
) -> NTSTATUS {
    unsafe {
        let stack = io_get_current_irp_stack_location(irp);
        let sys_buf = (*irp).AssociatedIrp.SystemBuffer as *mut u8;

        if sys_buf.is_null() {
            (*irp).IoStatus.__bindgen_anon_1.Status = STATUS_INVALID_PARAMETER;
            (*irp).IoStatus.Information = 0;
            IofCompleteRequest(irp, IO_NO_INCREMENT as _);
            return STATUS_INVALID_PARAMETER;
        }

        let wlen = ((*stack).Parameters.Write.Length as usize).min(BUFFER_SIZE);
        core::ptr::copy_nonoverlapping(sys_buf, BUFFER.as_mut_ptr(), wlen);
        BUFFER_LEN = wlen;

        // Parse command if we have enough bytes
        if wlen >= MIN_CMD_SIZE {
            let cmd_tag = &BUFFER[8..13];

            // Parse address (bytes 0..8, little-endian u64)
            let user_address = usize::from_le_bytes([
                BUFFER[0], BUFFER[1], BUFFER[2], BUFFER[3],
                BUFFER[4], BUFFER[5], BUFFER[6], BUFFER[7],
            ]) as *mut core::ffi::c_void;

            // Parse PID (bytes 13..17, little-endian u32)
            let target_pid = u32::from_le_bytes([
                BUFFER[13], BUFFER[14], BUFFER[15], BUFFER[16],
            ]);

            // ...command routing follows...
        }
        // ...
    }
}

For write commands, the payload bytes after offset 17 are the data to write into the target process. For read commands, bytes 17 through 20 contain the desired read size as a u32. The response is stored in a global RESP buffer and retrieved by the client with a subsequent ReadFile call. Successful responses are prefixed with ok (2 bytes), and failures with fail (4 bytes).

Reading and Writing Target Process Memory

The actual cross-process memory transfer happens in copy_usermode_memory in util.rs. This function resolves a PID to a PEPROCESS pointer, then calls MmCopyVirtualMemory:

// From: Medusa/src/util.rs

pub fn copy_usermode_memory(
    target_pid: u32,
    user_address: *mut c_void,
    buffer: &mut [u8],
    is_write: bool,
) -> Result<usize, NTSTATUS> {
    if user_address.is_null() {
        return Err(wdk_sys::STATUS_ACCESS_VIOLATION);
    }

    if buffer.is_empty() {
        return Err(wdk_sys::STATUS_INVALID_PARAMETER);
    }

    unsafe {
        let mut target_process: *mut c_void = ptr::null_mut();

        let status = PsLookupProcessByProcessId(
            target_pid as _,
            &mut target_process as *mut _ as *mut _,
        );

        if status != 0 {
            return Err(status);
        }

        let this_process = IoGetCurrentProcess();
        let mut transferred: usize = 0;

        let (from_process, from_address, to_process, to_address) = if is_write {
            (
                this_process as *mut c_void,
                buffer.as_ptr() as *mut c_void,
                target_process,
                user_address,
            )
        } else {
            (
                target_process,
                user_address,
                this_process as *mut c_void,
                buffer.as_mut_ptr() as *mut c_void,
            )
        };

        let status = MmCopyVirtualMemory(
            from_process as _,
            from_address,
            to_process as _,
            to_address,
            buffer.len(),
            KPROCESSOR_MODE::KernelMode,
            &mut transferred,
        );

        ObfDereferenceObject(target_process);

        if NT_SUCCESS(status) {
            Ok(transferred)
        } else {
            Err(status)
        }
    }
}

The flow has three steps:

  1. PsLookupProcessByProcessId converts the target PID into a PEPROCESS pointer and increments its reference count [7]. This is necessary because process IDs are volatile; the actual kernel object could be freed between the time the client sends the PID and the time the driver uses it.

  2. MmCopyVirtualMemory performs the transfer. The is_write flag determines the direction: for writes, the source is the driver’s local buffer (IoGetCurrentProcess()) and the destination is the target process. For reads, it is reversed. The key is KPROCESSOR_MODE::KernelMode as the previous_mode parameter, which bypasses all page-level access checks.

  3. ObfDereferenceObject decrements the reference count that PsLookupProcessByProcessId acquired [8]. Failing to call this would leak the EPROCESS object, preventing the target process from being fully cleaned up on exit.

IRP Dispatch: Raw Read/Write Instead of IOCTL

Most Windows kernel drivers that communicate with usermode use IRP_MJ_DEVICE_CONTROL (the handler for DeviceIoControl), which provides structured IOCTL codes with defined input/output buffer sizes [6]. Medusa takes a different approach: it uses IRP_MJ_WRITE to receive commands and IRP_MJ_READ to return results.

The read handler copies the response buffer back to the client:

// From: Medusa/src/coms.rs

pub unsafe extern "C" fn dispatch_read(
    _dev: PDEVICE_OBJECT,
    irp: *mut IRP,
) -> NTSTATUS {
    unsafe {
        let stack = io_get_current_irp_stack_location(irp);
        let sys_buf = (*irp).AssociatedIrp.SystemBuffer as *mut u8;

        if sys_buf.is_null() {
            (*irp).IoStatus.__bindgen_anon_1.Status = STATUS_INVALID_PARAMETER;
            (*irp).IoStatus.Information = 0;
            IofCompleteRequest(irp, IO_NO_INCREMENT as _);
            return STATUS_INVALID_PARAMETER;
        }

        let rlen = ((*stack).Parameters.Read.Length as usize).min(RESP_LEN);
        core::ptr::copy_nonoverlapping(RESP.as_ptr(), sys_buf, rlen);

        (*irp).IoStatus.__bindgen_anon_1.Status = STATUS_SUCCESS;
        (*irp).IoStatus.Information = rlen as u64;
        IofCompleteRequest(irp, IO_NO_INCREMENT as _);
    }
    STATUS_SUCCESS
}

The communication state lives in two global static arrays:

// From: Medusa/src/coms.rs

static mut BUFFER: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];
static mut BUFFER_LEN: usize = 0;

static mut RESP: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];
static mut RESP_LEN: usize = 0;

This design is simpler than IOCTL dispatch (no control code constants, no switch statement) and the client only needs CreateFile, WriteFile, and ReadFile. The trade-off is that the response is decoupled from the command: the client writes a command, then must issue a separate read to retrieve the result. The global buffers also mean this protocol is strictly single-client; concurrent access from two processes would corrupt shared state.

String Handling in no_std Kernel Rust

Windows kernel APIs expect UNICODE_STRING structures, not C-style null-terminated wide strings [9]. The string_stuff.rs module provides two traits to convert Rust &str values into this format:

// From: Medusa/src/string_stuff.rs

pub trait ToUnicodeString {
    fn to_u16_vec(&self) -> Vec<u16>;
}

impl ToUnicodeString for &str {
    fn to_u16_vec(&self) -> Vec<u16> {
        let mut buf = Vec::with_capacity(self.len() + 1);
        for c in self.chars() {
            let mut c_buf = [0; 2];
            let encoded = c.encode_utf16(&mut c_buf);
            buf.extend_from_slice(encoded);
        }
        buf.push(0); // null terminator
        buf
    }
}

pub trait ToWindowsUnicodeString {
    fn to_windows_unicode_string(&self) -> Option<UNICODE_STRING>;
}

impl ToWindowsUnicodeString for Vec<u16> {
    fn to_windows_unicode_string(&self) -> Option<UNICODE_STRING> {
        create_unicode_string(self)
    }
}

pub fn create_unicode_string(s: &[u16]) -> Option<UNICODE_STRING> {
    if s.is_empty() {
        return None;
    }

    let len = s.len();
    let len_checked = if len > 0 && s[len - 1] == 0 {
        len - 1
    } else {
        len
    };

    Some(UNICODE_STRING {
        Length: (len_checked * 2) as u16,
        MaximumLength: (len * 2) as u16,
        Buffer: s.as_ptr() as *mut u16,
    })
}

The conversion is a two-step chain: "\\Device\\Medusa".to_u16_vec().to_windows_unicode_string(). The first step encodes UTF-8 to UTF-16 with a null terminator. The second step builds the UNICODE_STRING struct, where Length is the byte count excluding the null terminator and MaximumLength includes it [9]. This is a common pattern in Rust kernel drivers because the standard library’s OsString and OsStr types are not available in #![no_std].

One subtlety: the returned UNICODE_STRING borrows the Vec<u16> data. The caller must ensure the vector outlives the UNICODE_STRING, which it does in setup_device because both are stack-local to the same function.

Building and Signing with cargo make and Self-Signed Certificates

Medusa builds with cargo make, which orchestrates the WDK build toolchain. The compiled output is target\debug\medusa.sys (or target\release\medusa.sys). Because Windows requires all kernel drivers to be signed [10], the repository includes sign.ps1, a PowerShell script that generates a self-signed certificate and signs the driver:

# From: Medusa/sign.ps1

$cert = New-SelfSignedCertificate `
    -Subject $certSubject `
    -Type CodeSigning `
    -CertStoreLocation "Cert:\LocalMachine\My" `
    -KeyExportPolicy Exportable `
    -KeySpec Signature `
    -HashAlgorithm SHA256

# ...export, import, then sign:

& "$signtoolPath" sign `
    /fd SHA256 `
    /td SHA256 `
    /tr http://timestamp.digicert.com `
    /sha1 $thumb `
    "$sysFile"

Self-signed certificates are not trusted by default. The target machine must have test signing enabled via bcdedit /set testsigning on, and the certificate must be manually installed into both the Trusted Root Certification Authorities and Trusted Publishers stores [10]. This is standard for driver development but means Medusa cannot run on a machine with Secure Boot enforced, which requires Microsoft-signed or WHQL-certified drivers [11].

The driver is installed using the INF file (medusa.inx) via pnputil.exe /add-driver medusa.inx /install, followed by devgen.exe /add /hardwareid "root\SAMPLE_WDM_HW_ID" to create the device node.

Limitations and Honest Assessment

Medusa is explicitly a proof-of-concept. Several design choices reflect this:

No synchronization. The BUFFER and RESP arrays are global static mut variables with no locking. If two threads or processes send commands simultaneously, they will corrupt each other’s data. The source code comments acknowledge this: “POC only, not safe for concurrent access.” A production driver would use a KMUTEX or FAST_MUTEX to serialize access [12].

No authentication on the device interface. Any process that can call CreateFile on \\.\Medusa can read or write memory in any other process. There is no access control, no client validation, no signature check. Any malware on the system could use the device as a privilege escalation primitive.

Hardcoded 4096-byte buffer. The BUFFER_SIZE constant limits all transfers to 4 KB. Reading a large memory region requires multiple round trips. This is sufficient for reading game values (health, ammo, coordinates) but impractical for dumping entire modules.

Single-client only. Because the global response buffer is shared, a second client reading before the first would receive stale or incorrect data.

No IOCTL interface. The raw read/write approach works for this POC, but it makes the protocol harder to extend. Adding a new command type means changing the binary format. IOCTLs provide natural multiplexing via distinct control codes [6].

x64 only. The 8-byte address field in the wire protocol assumes 64-bit pointers. This is a reasonable constraint for modern Windows, but it is worth noting.

These are all reasonable trade-offs for a proof-of-concept whose purpose is to demonstrate the core technique: kernel-level memory access via MmCopyVirtualMemory with KernelMode privileges.


This post was generated by an LLM based on code from Medusa. All code snippets are from the actual repository. Claims about Windows internals are sourced from Microsoft documentation.

References

[1] Microsoft, “Memory Protection Constants,” Windows Dev Center. https://learn.microsoft.com/en-us/windows/win32/memory/memory-protection-constants

[2] ReactOS, “MmCopyVirtualMemory implementation,” ReactOS source. https://doxygen.reactos.org/d5/d83/ntoskrnl_2mm_2virtual_8c.html (The function is undocumented by Microsoft; ReactOS provides the closest public reference for its behavior.)

[3] Microsoft, “DriverEntry for WDM Drivers,” Windows Hardware Dev Center. https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/writing-a-driverentry-routine

[4] Microsoft, “IoCreateDevice function,” Windows Hardware Dev Center. https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-iocreatedevice

[5] Microsoft, “IoCreateSymbolicLink function,” Windows Hardware Dev Center. https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-iocreatesymboliclink

[6] Microsoft, “Buffered I/O,” Windows Hardware Dev Center. https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/using-buffered-i-o

[7] Microsoft, “PsLookupProcessByProcessId function,” Windows Hardware Dev Center. https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-pslookupprocessbyprocessid

[8] Microsoft, “ObDereferenceObject macro,” Windows Hardware Dev Center. https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-obdereferenceobject

[9] Microsoft, “UNICODE_STRING structure,” Windows Hardware Dev Center. https://learn.microsoft.com/en-us/windows/win32/api/ntdef/ns-ntdef-_unicode_string

[10] Microsoft, “Driver Signing,” Windows Hardware Dev Center. https://learn.microsoft.com/en-us/windows-hardware/drivers/install/driver-signing

[11] Microsoft, “Secure Boot,” Windows Hardware Dev Center. https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-secure-boot

[12] Microsoft, “Fast Mutexes and Guarded Mutexes,” Windows Hardware Dev Center. https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/fast-mutexes-and-guarded-mutexes