Step-by-step development of a kernel driver to read and write process memory on Windows
In this article we will walk, like a code review between a senior engineer and a junior developer, through the development of a C driver for Windows that:
- Searches processes by name and obtains their PID.
- Finds the base address of a DLL in that process.
- Reads memory from that process.
- Writes memory into that process.
First you will see the full code and then we will analyze it section by section, explaining why it is written that way and what each part does.
Complete driver code
#include <ntddk.h>
#include <wdm.h>
#include <string.h>
// ============================================================================
// DEFINITIONS AND CONSTANTS
// ============================================================================
#define POOL_TAG 'MPRC' // Tag for pool allocations
#define MAX_PROCESS_NAME 260 // Max process name length
// System process info structure (partial)
typedef struct _SYSTEM_PROCESS_INFO {
ULONG NextEntryOffset;
ULONG NumberOfThreads;
LARGE_INTEGER Reserved[3];
LARGE_INTEGER CreateTime;
LARGE_INTEGER UserTime;
LARGE_INTEGER KernelTime;
UNICODE_STRING ImageName;
KPRIORITY BasePriority;
HANDLE ProcessId;
HANDLE InheritedFromProcessId;
} SYSTEM_PROCESS_INFO, *PSYSTEM_PROCESS_INFO;
// LDR data table entry (module info)
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;
// ============================================================================
// HELPERS
// ============================================================================
/**
* Validate a user pointer is accessible
*/
BOOLEAN IsAddressValid(PVOID Address, SIZE_T Size, LOCK_OPERATION Mode) {
if (!Address || Size == 0) {
return FALSE;
}
__try {
ProbeForRead(Address, Size, sizeof(UCHAR));
if (Mode == IoWriteAccess) {
ProbeForWrite(Address, Size, sizeof(UCHAR));
}
return TRUE;
}
__except (EXCEPTION_EXECUTE_HANDLER) {
return FALSE;
}
}
/**
* Case-insensitive wide string compare safe in kernel
*/
INT SafeWcsicmp(PCWSTR Str1, PCWSTR Str2) {
if (!Str1 || !Str2) {
return -1;
}
__try {
return _wcsicmp(Str1, Str2);
}
__except (EXCEPTION_EXECUTE_HANDLER) {
return -1;
}
}
// ============================================================================
// MAIN FUNCTIONS
// ============================================================================
/**
* Get PID by process name
*/
NTSTATUS GetPidByName(PCWSTR TargetProcessName, HANDLE* Pid) {
NTSTATUS status;
PSYSTEM_PROCESS_INFO processInfo = NULL;
PSYSTEM_PROCESS_INFO current = NULL;
ULONG bufferSize = 0;
BOOLEAN found = FALSE;
if (!TargetProcessName || !Pid) {
DbgPrint("[MemDriver] Error: Invalid parameters in GetPidByName\n");
return STATUS_INVALID_PARAMETER;
}
status = ZwQuerySystemInformation(
SystemProcessInformation,
NULL,
0,
&bufferSize
);
if (status != STATUS_INFO_LENGTH_MISMATCH) {
DbgPrint("[MemDriver] Error querying size: 0x%X\n", status);
return status;
}
bufferSize += 0x1000;
processInfo = (PSYSTEM_PROCESS_INFO)ExAllocatePoolWithTag(
NonPagedPool,
bufferSize,
POOL_TAG
);
if (!processInfo) {
DbgPrint("[MemDriver] Error: Could not allocate memory\n");
return STATUS_INSUFFICIENT_RESOURCES;
}
status = ZwQuerySystemInformation(
SystemProcessInformation,
processInfo,
bufferSize,
NULL
);
if (!NT_SUCCESS(status)) {
DbgPrint("[MemDriver] Error getting process info: 0x%X\n", status);
ExFreePoolWithTag(processInfo, POOL_TAG);
return status;
}
current = processInfo;
while (TRUE) {
if (current->ImageName.Buffer && current->ImageName.Length > 0) {
if (SafeWcsicmp(current->ImageName.Buffer, TargetProcessName) == 0) {
*Pid = current->ProcessId;
found = TRUE;
DbgPrint("[MemDriver] Found process: %ws (PID: %d)\n",
TargetProcessName, (ULONG)(ULONG_PTR)current->ProcessId);
break;
}
}
if (current->NextEntryOffset == 0) {
break;
}
current = (PSYSTEM_PROCESS_INFO)((PUCHAR)current + current->NextEntryOffset);
}
ExFreePoolWithTag(processInfo, POOL_TAG);
if (!found) {
DbgPrint("[MemDriver] Process not found: %ws\n", TargetProcessName);
return STATUS_NOT_FOUND;
}
return STATUS_SUCCESS;
}
/**
* Get base address of a DLL inside a process
*/
NTSTATUS GetDllBaseAddress(HANDLE Pid, PCWSTR DllName, PVOID* BaseAddress) {
PEPROCESS targetProcess = NULL;
NTSTATUS status;
KAPC_STATE apcState;
PPEB peb = NULL;
PPEB_LDR_DATA ldr = NULL;
PLIST_ENTRY listHead = NULL;
PLIST_ENTRY listEntry = NULL;
BOOLEAN found = FALSE;
if (!Pid || !DllName || !BaseAddress) {
DbgPrint("[MemDriver] Error: Invalid parameters in GetDllBaseAddress\n");
return STATUS_INVALID_PARAMETER;
}
status = PsLookupProcessByProcessId(Pid, &targetProcess);
if (!NT_SUCCESS(status)) {
DbgPrint("[MemDriver] Error finding process (PID: %d): 0x%X\n",
(ULONG)(ULONG_PTR)Pid, status);
return status;
}
if (PsGetProcessExitStatus(targetProcess) != STATUS_PENDING) {
DbgPrint("[MemDriver] Error: Process is terminating or has terminated\n");
ObDereferenceObject(targetProcess);
return STATUS_PROCESS_IS_TERMINATING;
}
KeStackAttachProcess(targetProcess, &apcState);
__try {
peb = PsGetProcessPeb(targetProcess);
if (!peb) {
DbgPrint("[MemDriver] Error: Could not get PEB\n");
status = STATUS_UNSUCCESSFUL;
__leave;
}
ldr = peb->Ldr;
if (!ldr) {
DbgPrint("[MemDriver] Error: Could not get Ldr\n");
status = STATUS_UNSUCCESSFUL;
__leave;
}
listHead = &ldr->InMemoryOrderModuleList;
listEntry = listHead->Flink;
while (listEntry != listHead) {
PLDR_DATA_TABLE_ENTRY module = CONTAINING_RECORD(
listEntry,
LDR_DATA_TABLE_ENTRY,
InMemoryOrderLinks
);
if (module->BaseDllName.Buffer && module->BaseDllName.Length > 0) {
if (SafeWcsicmp(module->BaseDllName.Buffer, DllName) == 0) {
*BaseAddress = module->DllBase;
found = TRUE;
DbgPrint("[MemDriver] DLL found: %ws (Base: 0x%p, Size: 0x%X)\n",
DllName, module->DllBase, module->SizeOfImage);
status = STATUS_SUCCESS;
break;
}
}
listEntry = listEntry->Flink;
if (listEntry == NULL) {
break;
}
}
if (!found) {
DbgPrint("[MemDriver] DLL not found: %ws\n", DllName);
status = STATUS_NOT_FOUND;
}
}
__except (EXCEPTION_EXECUTE_HANDLER) {
DbgPrint("[MemDriver] Exception searching DLL: 0x%X\n", GetExceptionCode());
status = STATUS_ACCESS_VIOLATION;
}
KeUnstackDetachProcess(&apcState);
ObDereferenceObject(targetProcess);
return status;
}
/**
* Read memory from a target process
*/
NTSTATUS ReadProcessMemory(HANDLE Pid, PVOID Address, PVOID Buffer, SIZE_T Size) {
PEPROCESS targetProcess = NULL;
NTSTATUS status;
SIZE_T bytesRead = 0;
if (!Pid || !Address || !Buffer || Size == 0) {
DbgPrint("[MemDriver] Error: Invalid parameters in ReadProcessMemory\n");
return STATUS_INVALID_PARAMETER;
}
if (!IsAddressValid(Buffer, Size, IoWriteAccess)) {
DbgPrint("[MemDriver] Error: Invalid destination buffer\n");
return STATUS_ACCESS_VIOLATION;
}
status = PsLookupProcessByProcessId(Pid, &targetProcess);
if (!NT_SUCCESS(status)) {
DbgPrint("[MemDriver] Error finding process for read: 0x%X\n", status);
return status;
}
if (PsGetProcessExitStatus(targetProcess) != STATUS_PENDING) {
DbgPrint("[MemDriver] Error: Process not active\n");
ObDereferenceObject(targetProcess);
return STATUS_PROCESS_IS_TERMINATING;
}
__try {
status = MmCopyVirtualMemory(
targetProcess,
Address,
PsGetCurrentProcess(),
Buffer,
Size,
KernelMode,
&bytesRead
);
if (NT_SUCCESS(status) && bytesRead == Size) {
DbgPrint("[MemDriver] Read successful: 0x%p (%zu bytes)\n", Address, Size);
status = STATUS_SUCCESS;
} else if (bytesRead < Size) {
DbgPrint("[MemDriver] Warning: Partial read (%zu/%zu bytes)\n",
bytesRead, Size);
status = STATUS_PARTIAL_COPY;
} else {
DbgPrint("[MemDriver] Read error: 0x%X\n", status);
}
}
__except (EXCEPTION_EXECUTE_HANDLER) {
DbgPrint("[MemDriver] Exception reading memory: 0x%X\n", GetExceptionCode());
status = STATUS_ACCESS_VIOLATION;
}
ObDereferenceObject(targetProcess);
return status;
}
/**
* Write data into a target process memory
*/
NTSTATUS WriteProcessMemory(HANDLE Pid, PVOID Address, PVOID Buffer, SIZE_T Size) {
PEPROCESS targetProcess = NULL;
NTSTATUS status;
SIZE_T bytesWritten = 0;
if (!Pid || !Address || !Buffer || Size == 0) {
DbgPrint("[MemDriver] Error: Invalid parameters in WriteProcessMemory\n");
return STATUS_INVALID_PARAMETER;
}
if (!IsAddressValid(Buffer, Size, IoReadAccess)) {
DbgPrint("[MemDriver] Error: Invalid source buffer\n");
return STATUS_ACCESS_VIOLATION;
}
status = PsLookupProcessByProcessId(Pid, &targetProcess);
if (!NT_SUCCESS(status)) {
DbgPrint("[MemDriver] Error finding process for write: 0x%X\n", status);
return status;
}
if (PsGetProcessExitStatus(targetProcess) != STATUS_PENDING) {
DbgPrint("[MemDriver] Error: Process not active\n");
ObDereferenceObject(targetProcess);
return STATUS_PROCESS_IS_TERMINATING;
}
__try {
status = MmCopyVirtualMemory(
PsGetCurrentProcess(),
Buffer,
targetProcess,
Address,
Size,
KernelMode,
&bytesWritten
);
if (NT_SUCCESS(status) && bytesWritten == Size) {
DbgPrint("[MemDriver] Write successful: 0x%p (%zu bytes)\n", Address, Size);
status = STATUS_SUCCESS;
} else if (bytesWritten < Size) {
DbgPrint("[MemDriver] Warning: Partial write (%zu/%zu bytes)\n",
bytesWritten, Size);
status = STATUS_PARTIAL_COPY;
} else {
DbgPrint("[MemDriver] Write error: 0x%X\n", status);
}
}
__except (EXCEPTION_EXECUTE_HANDLER) {
DbgPrint("[MemDriver] Exception writing memory: 0x%X\n", GetExceptionCode());
status = STATUS_ACCESS_VIOLATION;
}
ObDereferenceObject(targetProcess);
return status;
}
// ============================================================================
// USAGE EXAMPLE
// ============================================================================
NTSTATUS ExampleUsage() {
NTSTATUS status;
HANDLE pid = NULL;
PVOID dllBase = NULL;
PVOID finalAddress = NULL;
INT32 valueToWrite = 100;
INT32 currentValue = 0;
ULONG_PTR offset = 0x1234; // example offset
DbgPrint("[MemDriver] ===== Starting usage example =====\n");
status = GetPidByName(L"example.exe", &pid);
if (!NT_SUCCESS(status)) {
DbgPrint("[MemDriver] Could not find process\n");
return status;
}
status = GetDllBaseAddress(pid, L"mydll.dll", &dllBase);
if (!NT_SUCCESS(status)) {
DbgPrint("[MemDriver] Could not find DLL\n");
return status;
}
finalAddress = (PVOID)((ULONG_PTR)dllBase + offset);
DbgPrint("[MemDriver] Computed address: 0x%p (Base: 0x%p + Offset: 0x%lX)\n",
finalAddress, dllBase, offset);
status = ReadProcessMemory(pid, finalAddress, ¤tValue, sizeof(INT32));
if (NT_SUCCESS(status)) {
DbgPrint("[MemDriver] Current value at 0x%p: %d\n", finalAddress, currentValue);
} else {
DbgPrint("[MemDriver] Warning: Could not read memory\n");
}
status = WriteProcessMemory(pid, finalAddress, &valueToWrite, sizeof(INT32));
if (NT_SUCCESS(status)) {
DbgPrint("[MemDriver] New value written: %d\n", valueToWrite);
INT32 verifyValue = 0;
if (NT_SUCCESS(ReadProcessMemory(pid, finalAddress, &verifyValue, sizeof(INT32)))) {
DbgPrint("[MemDriver] Verification: current value is %d\n", verifyValue);
}
} else {
DbgPrint("[MemDriver] Error writing memory\n");
}
DbgPrint("[MemDriver] ===== Example finished =====\n");
return status;
}
// ============================================================================
// DRIVER ENTRYPOINT
// ============================================================================
VOID DriverUnload(PDRIVER_OBJECT DriverObject) {
UNREFERENCED_PARAMETER(DriverObject);
DbgPrint("[MemDriver] Driver unloaded successfully\n");
}
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
UNREFERENCED_PARAMETER(RegistryPath);
DbgPrint("[MemDriver] Driver loaded successfully\n");
DriverObject->DriverUnload = DriverUnload;
// ExampleUsage(); // commented out in production
return STATUS_SUCCESS;
}
1. Overall driver design
Before diving into each function, it's useful to understand what problems we want to solve from kernel space and how that reflects in the code structure:
- We need a way to enumerate processes and find one by name →
GetPidByName. - Once we have the process, we want to find a specific DLL inside it →
GetDllBaseAddress. - With PID and addresses, we need to read memory →
ReadProcessMemory. - And also write memory →
WriteProcessMemory. - Finally, an example that shows the whole flow →
ExampleUsage, and the standard driver entry/unload routines.
Everything else (structures, helpers, validations) exists so those four operations are as safe and clear as possible.
2. Definitions, constants and internal structures
2.1 POOL_TAG and constants
#define POOL_TAG 'MPRC' defines a tag for all pool allocations made by this driver.
It helps debugging and allows the kernel to associate memory blocks with your module.
MAX_PROCESS_NAME sets a reasonable limit for process name buffers when static buffers are used.
2.2 SYSTEM_PROCESS_INFO
SYSTEM_PROCESS_INFO is the format returned by
ZwQuerySystemInformation(SystemProcessInformation, ...). We don't use every field,
but we rely on:
NextEntryOffset: offset to the next element in the buffer.ImageName: the executable name (UNICODE_STRING).ProcessId: the PID we are looking for.
2.3 LDR_DATA_TABLE_ENTRY
This structure models each module loaded in a process (EXE or DLL). The process loader keeps doubly linked lists of these entries.
DllBase: base address of the image in memory.BaseDllName: short DLL name likekernel32.dll.InMemoryOrderLinks: linked-list node used to iterate modules.
3. Helper functions: sanitizing input
3.1 User pointer validation: IsAddressValid
IsAddressValid ensures, from kernel, that a pointer passed from user-mode is accessible before use. Basic logic:
- If the address is
NULLor size is 0, fail. - Inside
__try / __exceptcallProbeForRead. If writing, callProbeForWriteas well. - If any call raises, catch in
__exceptand returnFALSE.
This prevents an invalid user-mode pointer from crashing the whole system.
3.2 Safe wide-string compare: SafeWcsicmp
SafeWcsicmp wraps _wcsicmp with __try / __except.
If either string is invalid, return -1. Used both for process and DLL name comparisons.
4. Finding a process PID: GetPidByName
This function walks the system process list and returns the PID matching the provided name.
4.1 Reserving the correct buffer
- Validate
TargetProcessNameandPidare notNULL. - Call
ZwQuerySystemInformationwith a NULL buffer to get requiredbufferSize. - Add a margin (+0x1000) to mitigate list growth between calls.
- Allocate
NonPagedPoolmemory of that size.
4.2 Iterating the process list
After filling the buffer, iterate using current:
- If
ImageNameexists, compare withTargetProcessNameusingSafeWcsicmp. - On match store
ProcessId, setfound = TRUEand exit. - If
NextEntryOffset == 0reach the end. - Otherwise advance via pointer arithmetic.
Free buffer with ExFreePoolWithTag. Return STATUS_NOT_FOUND if not found.
5. Finding a DLL base: GetDllBaseAddress
Given a PID, locate the base of a specific DLL inside the process by attaching to its address space and iterating the loader lists.
5.1 Getting EPROCESS and attaching
- Validate inputs.
- Obtain
PEPROCESSviaPsLookupProcessByProcessId. - Check process is not terminating (
PsGetProcessExitStatus). - Attach to the process with
KeStackAttachProcess.
5.2 PEB, Ldr and module list
- Get PEB with
PsGetProcessPeb. - From PEB get
peb->Ldrand theInMemoryOrderModuleList. - Iterate
Flinkand useCONTAINING_RECORDto getLDR_DATA_TABLE_ENTRY. - Compare
BaseDllNamewith target usingSafeWcsicmp. On match storeDllBase.
Wrap this block in __try / __except. Always call:
KeUnstackDetachProcess(&apcState);ObDereferenceObject(targetProcess);
6. Read process memory: ReadProcessMemory
Copies memory from the target process into a buffer in the driver's context using MmCopyVirtualMemory.
6.1 Validations and obtaining process
- Verify
Pid,Address,BufferandSize. - Validate destination buffer with
IsAddressValid(..., IoWriteAccess). - Lookup process and verify state.
6.2 Copy using MmCopyVirtualMemory
status = MmCopyVirtualMemory(
targetProcess, // source process
Address, // source address
PsGetCurrentProcess(), // destination (this driver)
Buffer, // destination buffer
Size, // size
KernelMode, // access mode
&bytesRead // bytes read
);
If bytesRead == Size and status is success, the read is successful. Partial reads return STATUS_PARTIAL_COPY.
7. Write memory to a process: WriteProcessMemory
Symmetric logic to reading: source buffer is the driver, destination is the process.
7.1 Input validations
- Ensure parameters are non-null.
- Validate source buffer with
IsAddressValid(..., IoReadAccess). - Find process and check its state.
7.2 Copy into target process
status = MmCopyVirtualMemory(
PsGetCurrentProcess(), // source (this driver)
Buffer, // source buffer
targetProcess, // destination process
Address, // destination address
Size, // size
KernelMode, // access mode
&bytesWritten // bytes written
);
If bytesWritten == Size and status is success, return STATUS_SUCCESS. Partial writes return STATUS_PARTIAL_COPY.
8. Bringing it together: ExampleUsage
ExampleUsage demonstrates the full flow:
- Find process "example.exe" via
GetPidByName. - Get base of "mydll.dll" via
GetDllBaseAddress. - Compute final address (base + fake offset).
- Read an
INT32usingReadProcessMemory. - Write the value
100withWriteProcessMemory. - Verify by reading back.
This is the basic recipe to inspect and modify a process memory from kernel mode.
9. DriverEntry and DriverUnload
Finally, the driver entry point and unload routine.
9.1 DriverUnload
DriverUnload logs unload. In a real driver you should free global resources, unregister callbacks and stop threads here.
9.2 DriverEntry
DriverEntry is the driver "main":
- Ignore
RegistryPathif unused. - Log successful load.
- Assign
DriverUnload. - Keep
ExampleUsage()commented out for production.
10. Conclusion
The driver analyzed shows a complete pipeline for process memory manipulation from kernel mode: locate the process, find modules, compute addresses, and read/write memory using kernel APIs in a controlled way.
Engineering takeaways:
- Always validate pointers coming from user-mode.
- Wrap accesses to internal structures (PEB, module lists) in
__try / __except. - Use kernel primitives (
ZwQuerySystemInformation,PsLookupProcessByProcessId,MmCopyVirtualMemory) instead of reinventing low-level logic. - Always clean up resources: pool memory and process references.
From this base you can add IOCTLs to expose these operations to a user-mode program in a controlled way, or integrate advanced analysis and monitoring logic.