PROJECT · KERNEL / DRIVER
KM ↔ UM communication via shared memory
A personal systems-programming project: a kernel-mode driver (Ring 0) in C++ that communicates with a user-space process through a shared buffer, bypassing classic interfaces like IOCTL. The driver can read and write any process's memory, get module base addresses and resolve addresses dynamically.
Tech stack
C++Windows Kernel (WDK)Ring 0 / Ring 3Shared MemoryMmCopyVirtualMemoryDPC / Timer / WorkItem
Architecture · 01
Architecture overview
The system consists of two components: a kernel-mode driver (KM) and a user-mode application (UM). Both share a memory buffer without using DeviceIoControl. Instead, the UM process allocates a buffer with VirtualAlloc and publishes its address alongside a 'magic' value computed at runtime. The driver scans UM process memory looking for that magic, obtains the buffer pointer and from there both communicate by reading and writing fields in the shared structure.
- No IOCTL: communication relies entirely on a shared buffer in UM process memory.
- Dynamic magic: the rendezvous value is computed from the PID, so no recognisable constants appear in the binary.
- Timer + DPC + WorkItem: the driver polls the buffer periodically using a KTIMER and executes requests in a kernel thread-pool work item.
Architecture · 02
DriverEntry — entry point
The driver's entry point is minimal: it just calls SharedMemInit(), which creates a system thread to avoid blocking driver load.
DriverEntry.cpp
extern "C" NTSTATUS DriverEntry(PDRIVER_OBJECT driver_object,
PUNICODE_STRING reg_path)
{
UNREFERENCED_PARAMETER(driver_object);
UNREFERENCED_PARAMETER(reg_path);
NTSTATUS st = SharedMemInit();
if (!NT_SUCCESS(st))
return st;
return STATUS_SUCCESS;
}
Architecture · 03
Dynamic PID-based magic
The rendezvous value ('magic') is computed at runtime from the UM process PID. Both the driver and the UM application use the same formula, so they get the same result without needing a hardcoded constant in the binary. If someone dumps the binary and searches for known constants, they find nothing.
rendezvous.cpp
// Kernel-mode (y user-mode usa la misma fórmula)
ULONG64 ComputeMagic(ULONG pid)
{
return 0xC0FFEE0000000000ULL
^ ((ULONG64)pid * 0x9E3779B97F4A7C15ULL);
}
Architecture · 04
Rendezvous — finding the buffer
The driver attaches to the UM process address space (KeStackAttachProcess) and walks its committed, writable memory regions. It searches for the magic page by page; when it finds it, it reads the next 8 bytes, which contain the pointer to the shared buffer published by UM.
rendezvous.cpp
// UM publica magic + ptr en una estructura global
volatile struct {
uint64_t magic; // calculado en runtime
SHARED_BUFFER* ptr; // buffer alocado con VirtualAlloc
} g_rdv = { 0, nullptr };
// KM escanea la memoria UM buscando el magic
ULONG_PTR find_magic_in_process(PEPROCESS process,
ULONG64 magic)
{
KAPC_STATE apc;
KeStackAttachProcess(process, &apc);
// Recorre regiones MEM_COMMIT con permisos RW
// Compara cada ULONG64 con el magic
// Si coincide → devuelve la dirección del campo ptr
KeUnstackDetachProcess(&apc);
return result;
}
Architecture · 05
Shared buffer and request protocol
The shared structure has three atomic control fields (request_ready, response_ready, km_alive) and a data block (REQUEST_DATA). The flow is simple: UM fills the data block and sets request_ready = 1. The driver detects it, processes the request, writes the response and sets response_ready = 1. UM waits for response_ready to read the result.
shared_mem.h
typedef struct _SHARED_BUFFER {
volatile LONG request_ready; // UM → KM: "hay petición"
volatile LONG response_ready; // KM → UM: "respuesta lista"
volatile LONG km_alive; // KM → UM: "driver activo"
ULONG _pad;
REQUEST_DATA data; // bloque de datos
} SHARED_BUFFER;
typedef struct _REQUEST_DATA {
void* buffer_address;
UINT_PTR address;
ULONGLONG size;
ULONG pid;
BOOLEAN write;
BOOLEAN read;
BOOLEAN req_base;
UCHAR output[256];
char module_name[64];
ULONG64 base_address;
} REQUEST_DATA;
Architecture · 06
Timer, DPC and WorkItem — the driver loop
The driver does not use a while(true) loop. Instead, it creates a KTIMER that fires a DPC every ~100 µs. The DPC enqueues a work item that checks whether UM has written a request. If so, it processes it; if not, it returns. This way the driver never permanently blocks a kernel thread and keeps latency low.
shared_mem.cpp
static VOID TimerDpc(PKDPC, PVOID, PVOID, PVOID)
{
if (!InterlockedCompareExchange(&g_shutdown, 0, 0))
{
// Encolar work item (solo si no hay otro pendiente)
if (InterlockedCompareExchange(&g_workItemQueued, 1, 0) == 0)
ExQueueWorkItem(&g_workItem, DelayedWorkQueue);
// Re-armar el timer
LARGE_INTEGER due;
due.QuadPart = -1000LL; // ~100 µs
KeSetTimer(&g_timer, due, &g_timerDpc);
}
}
Architecture · 07
Request processing
When the work item detects request_ready == 1, it copies REQUEST_DATA from UM process memory using MmCopyVirtualMemory, processes the request and writes the result back. It supports three operations: getting a module base address (req_base), reading memory (read) and writing memory (write).
process_request.cpp
void process_request(SHARED_BUFFER* shared)
{
REQUEST_DATA* req = &shared->data;
if (req->req_base == TRUE)
{
// PsLookupProcessByProcessId → KeStackAttachProcess
// → BBGetUserModule → devuelve base_address
}
else if (req->read == TRUE)
{
// read_kernel_memory(pid, address, output, size)
}
else if (req->write == TRUE)
{
// write_kernel_memory(pid, address, buffer, size)
}
}
Architecture · 08
UM client — the user side
The user-mode application allocates the buffer with VirtualAlloc, computes the magic from its own PID and waits for the driver to signal km_alive = 1. From there it can send requests by filling in REQUEST_DATA and setting request_ready = 1, then waiting for the response in response_ready.
main.cpp (user-mode)
int main()
{
DWORD myPid = GetCurrentProcessId();
uint64_t magic = compute_magic(myPid);
SHARED_BUFFER* shared = (SHARED_BUFFER*)VirtualAlloc(
nullptr, sizeof(SHARED_BUFFER),
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// Publicar magic + ptr para que el driver lo encuentre
g_rdv.magic = magic;
g_rdv.ptr = shared;
// Esperar a que el driver señalice km_alive = 1
while (!shared->km_alive) Sleep(10);
// Enviar peticiones: rellenar shared->data,
// poner request_ready = 1, esperar response_ready
}