Chapter 3. The Windows Kernel
Reverse engineering Windows drivers is roughly 90% knowing how Windows works and 10% reading assembly. Because drivers interact with the OS through well-defined interfaces, the analytical task decomposes into: understanding core OS components, the structure of a driver, the user–driver and driver–OS interfaces, how driver constructs appear in binary form, and applying that knowledge to a target. Unless noted, examples are taken from Windows 8 RTM.
Memory Layout
Windows splits the virtual address space into user and kernel halves. Running processes see only their user space; kernel-mode code sees both. The kernel half is largely identical across all process contexts (session and hyperspace ranges vary). User and kernel pages are distinguished by a bit in the page table entry.
| Architecture | User space | Kernel space | First usable kernel address |
|---|---|---|---|
| x86 / ARM | 0 – 0x7fffffff (lower 2GB) |
0x80000000+ (upper 2GB) |
0x80000000 |
| x64 | 0 – 0x000007ff'ffffffff |
0xffff0800'00000000+ |
0xffff8000'00000000 |
MmSystemRangeStartandMmHighestUserAddresshold the kernel start and user end; both are viewable in a kernel debugger.- On x86/ARM a 64KB no-access region sits between the two halves so the kernel cannot accidentally cross into user memory.
- On x64 the separator
0xffff0800'00000000is a non-canonical (unusable) address; real kernel memory begins at0xffff8000'00000000. - Per-process translation uses the page directory base register: CR3 on x86/x64, TTBR on ARM. It is reloaded on context switch so each process sees its own user space.
/3GB switch
The boot option /3GB enlarges user space to 3GB and shrinks the kernel to 1GB.
Processor Initialization (PCR and PRCB)
At boot the kernel performs per-processor initialization built around two undocumented structures.
| Structure | Scope | Holds |
|---|---|---|
PCR (_KPCR) |
Per-processor | IDT base (x86), current IRQL, self-pointer, pointer to the PRCB |
PRCB (_KPRCB) |
Per-processor | CPU type/model/speed, current thread, next thread, DPC queue, timer table |
The PCR is always reachable from kernel mode through a segment or coprocessor register.
| Architecture | PCR access register |
|---|---|
| x86 | FS |
| x64 | GS |
| ARM | system coprocessor |
On x64 the PRCB begins at offset 0x180 of the PCR, and CurrentThread is at +0x8 of the PRCB — hence gs:[0x188].
PsGetCurrentThread:
mov rax, gs:188h ; PCR(gs:0) + PRCB(0x180) + CurrentThread(0x8)
ret
PsGetCurrentProcess:
mov rax, gs:188h ; current ETHREAD
mov rax, [rax+0B8h] ; ETHREAD.ApcState.Process
ret
System Calls
A system call is a kernel function that services an I/O request from user mode; it runs in the kernel because only privileged code can manage the underlying resources. User-mode APIs ultimately decompose into one or more system calls.
Service Tables
Windows describes syscalls with a service table descriptor plus an array of function pointers or offsets. A syscall number is an index into that array.
typedef struct _KSERVICE_TABLE_DESCRIPTOR {
PULONG Base; // array of addresses (x86) or encoded offsets (x64/ARM)
PULONG Count;
ULONG Limit; // number of entries
PUCHAR Number;
...
} KSERVICE_TABLE_DESCRIPTOR, *PKSERVICE_TABLE_DESCRIPTOR;
| Global descriptor | Contains |
|---|---|
KeServiceDescriptorTable |
Native (non-GUI) syscall table only |
KeServiceDescriptorTableShadow |
Native table and the GUI (win32k) table |
| Global table pointer | Target |
|---|---|
KiServiceTable |
Non-GUI syscall array |
W32pServiceTable |
GUI (win32k) syscall array |
- x86:
Baseis a plain array of function pointers. - x64 / ARM:
Baseis an array of 32-bit integers. The top 20 bits encode the offset fromKiServiceTable; the bottom 4 bits encode the count of stack arguments.
real_address = KiServiceTable + (KiServiceTable[index] >> 4)
stack_args = KiServiceTable[index] & 0xF
Resolving NtCreateFile on x64
NtCreateFile uses syscall number 0x53 and takes 11 arguments (4 in registers, 7 on the stack, so the low nibble is 7). Its encoded entry KiServiceTable[0x53] is 0x03ea2c07; the address is KiServiceTable + (0x03ea2c07 >> 4).
Dispatch Mechanisms
The concept is uniform; the transition instruction and dispatcher differ by platform.
| Platform | Trigger | Dispatcher entry | Notes |
|---|---|---|---|
| x86 (pre-P2) | INT 2Eh |
KiSystemService |
IDT vector 0x2e. Legacy path. |
| x86 | SYSENTER |
KiFastCallEntry |
Entry from MSR 0x176. |
| x64 | SYSCALL |
KiSystemCall64 |
Entry from IA32_LSTAR MSR (0xC0000082). |
| ARM | SVC (SWI) |
KiSWIException |
Reached through the exception vector table. |
- INT 2E path: the stub reads
KUSER_SHARED_DATA.SystemCall(at0x7ffe0300) and calls it.KUSER_SHARED_DATAis mapped at0x7ffe0000in every process. The IDT entry0x2epoints atKiSystemService. - SYSCALL (x64): the stub sets
R10 = RCX(SYSCALL overwritesRCXwith the return address) andEAX = syscall number, then executesSYSCALL.KiSystemCall64swaps to the kernel stack, saves user context, copies stack arguments, indexesKiServiceTable/W32pServiceTable, invokes the call, and returns viaSYSRET(which restoresRIPfromRCX). - SYSENTER (x86): unlike SYSCALL,
SYSENTERsaves no return address in a register. The return address is already on the stack; the stub stores the stack pointer inEDXbefore entering.SYSEXITsetsEIP = EDX(ntdll!KiSystemCallRet) andESP = ECX. - SVC (ARM): the syscall number is placed in
R12.KiSWIExceptionbuilds a_KTRAP_FRAME, saves the return address (already inLR) and syscall number in the thread, then branches toKiSystemService; return is viaKiSystemServiceExit.
Faults, Traps, and Interrupts
| Event | Source | Timing | Resumes at |
|---|---|---|---|
| Hardware interrupt | Device | Asynchronous | Instruction after the interrupted one |
| Fault | Instruction | Synchronous | The same instruction (re-executed after correction) |
| Trap | Instruction | Synchronous | The instruction after the trap |
A fault is a correctable exception (e.g., a page fault: save state, page in the data, re-execute). A trap is caused by a special instruction (e.g., SYSCALL). The defining difference is where execution resumes. System calls are implemented through traps or dedicated exceptions.
The IDT
The Intel architecture defines a 256-entry interrupt descriptor table; its base lives in IDTR. An interrupt number indexes the table.
| Vector | Meaning |
|---|---|
0x0 |
Divide error |
0x3 |
Software breakpoint |
0xe |
Page fault |
32–255 |
User-defined |
// x86 _KIDTENTRY (8 bytes) — handler address is split across two fields
+0x000 Offset : Uint2B
+0x002 Selector : Uint2B
+0x004 Access : Uint2B
+0x006 ExtendedOffset : Uint2B // handler = (ExtendedOffset << 16) | Offset
On x64 the handler address is divided across three members (_KIDTENTRY64), and IDTR is 48 bits wide (base + limit). In WinDbg, !idt 0x2e shows which routine services a vector.
Interrupt Request Level (IRQL)
IRQL is a per-processor number (type KIRQL, a UCHAR) that orders interrupt handling. An interrupt at IRQL X masks every interrupt below X. IRQL is per-processor, not per-thread; different processors may run at different IRQLs simultaneously. It is unrelated to thread priority.
| IRQL | Value | Runs |
|---|---|---|
PASSIVE_LEVEL |
0 | All user-mode and most kernel code |
APC_LEVEL |
1 | Asynchronous procedure calls |
DISPATCH_LEVEL |
2 | Thread dispatcher and DPCs; code here cannot wait |
| (hardware / IPI) | >2 | Device interrupts; IPI_LEVEL for inter-processor signals |
IRQL maps onto hardware. On x86/x64 the LAPIC exposes a writable task priority register (TPR) and a read-only processor priority register (PPR); the CPU delivers only interrupts whose priority exceeds the PPR. KeRaiseIrql/KeLowerIrql program the TPR. On x64, CR8 is a shadow of the LAPIC TPR:
KzRaiseIrql: mov rax, cr8 ; return old IRQL
movzx ecx, cl
mov cr8, rcx
ret
KzLowerIrql: movzx eax, cl
mov cr8, rax
ret
Code at a high IRQL cannot be preempted by code at a lower IRQL.
Pool Memory
Pool memory is the kernel's run-time heap, allocated and freed with the ExAllocatePool* / ExFreePool* families.
| Pool type | Pageable | Consequence |
|---|---|---|
| Paged pool | Yes | Access may fault and page in from disk |
| Non-paged pool | No | Access never faults; safe at DISPATCH_LEVEL+ |
Code at DISPATCH_LEVEL must reside in and touch only non-paged memory: a page fault would invoke the fault handler, which needs the dispatcher (itself running at DISPATCH_LEVEL), producing a bugcheck.
| Pool | x86 | x64 | ARM |
|---|---|---|---|
NonPagedPool |
RWX | RWX | NX |
NonPagedPoolNX (Win 8) |
RW | RW | RW |
| Paged pool | RWX | NX | NX |
Memory Descriptor Lists (MDLs)
An MDL describes the set of physical pages backing a virtual address range. Each entry describes one contiguous buffer; entries can be chained. To use one: allocate (IoAllocateMdl), probe and lock the pages (MmProbeAndLockPages), then map them (MmMapLockedPagesSpecifyCache).
Two common uses:
- Map kernel memory into a process's user space (or vice versa).
- Remap read-only pages (e.g., a code section) at a second virtual address with write permission — a kernel-mode equivalent of
VirtualProtect.
Processes and Threads
A thread is defined by two structures; likewise a process. In each pair the K-structure (scheduling data) is embedded at the start of the E-structure (housekeeping data). All are opaque, and field offsets change between releases.
| Object | E-structure (housekeeping) |
K-structure (scheduling) |
|---|---|---|
| Thread | ETHREAD — thread id, owning process, debug state |
KTHREAD (Tcb at +0) — stack, target processor, alertable state |
| Process | EPROCESS — pid, token, thread list, ActiveProcessLinks |
KPROCESS (Pcb at +0) — directory table base, ideal processor, times |
The scheduler operates on threads. User-mode analogues store per-process/per-thread data: the PEB (ntdll!_PEB: base load address, loaded modules, heaps) and the TEB (ntdll!_TEB: thread scheduling data and process pointers).
| Access | x86 | x64 | ARM |
|---|---|---|---|
| Current thread (kernel) | fs:124h |
gs:188h |
coprocessor 15 (c13) |
| TEB (user) | fs:18h |
gs:30h |
coprocessor 15 (c13) |
Rootkit relevance
Rootkits modify undocumented fields directly — e.g., unlinking a process from EPROCESS.ActiveProcessLinks to hide it. Because the structures are opaque, hardcoded offsets break across builds.
Execution Context
Every thread runs in an execution context: an address space, security token, and other thread properties. In kernel mode the context determines which address space and privileges apply.
| Context | Meaning |
|---|---|
| Thread | A specific thread (usually the requesting user thread) |
| System | A thread in the System process |
| Arbitrary | Whatever thread happened to be running |
| Situation | Context |
|---|---|
DriverEntry |
System |
| IOCTL handler | Thread (requestor) |
| APC | Thread (where queued) |
| DPC / timer | Arbitrary |
| Work item | System |
System thread (ProcessHandle NULL) |
System |
To run in another process's address space, a driver calls KeStackAttachProcess (useful for reading/writing that process's memory).
Kernel Synchronization Primitives
| Primitive | Structure | Init | Notes |
|---|---|---|---|
| Event | KEVENT |
KeInitializeEvent |
Signaled / non-signaled; waited on with KeWaitForSingleObject |
| Timer | KTIMER |
KeInitializeTimer[Ex] |
Optional DPC on expiry; per-processor TimerTable in the PRCB |
| Mutex | FAST_MUTEX / GUARDED_MUTEX |
ExInitialize{Fast,Guarded}Mutex |
Exclusive access; guarded mutexes are faster (Windows 2003+) |
| Spin lock | KSPIN_LOCK |
KeInitializeSpinLock |
For resources touched at DISPATCH_LEVEL+; holder and its memory must be resident |
Linked Lists
Linked lists underlie most dynamic kernel data structures (loaded-module list, active-process list, wait queues, DPC/timer queues). The WDK exposes three list types; all share the same source-level usage.
| List type | Structure | Property |
|---|---|---|
| Singly-linked | Next |
One forward pointer |
| Sequenced singly-linked | Next |
Singly-linked with atomic operations |
| Circular doubly-linked | LIST_ENTRY |
Forward (Flink) and backward (Blink) — the common case |
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink; // +0x0
struct _LIST_ENTRY *Blink; // +0x8 (x64) / +0x4 (x86)
} LIST_ENTRY, *PLIST_ENTRY;
A LIST_ENTRY acts as either a list head (stores no payload) or a list entry (embedded inside a larger structure). List routines operate only on the LIST_ENTRY; to reach the enclosing record use CONTAINING_RECORD.
List routines are always inlined
InitializeListHead, InsertHeadList, InsertTailList, RemoveHeadList, RemoveTailList, RemoveEntryList, and IsListEmpty are inlined by the compiler. They never appear as a call/branch target, so they must be recognized by their code pattern.
InitializeListHead sets both pointers to the head — recognizable as two write-only stores at +0 and +Blink using one register:
CONTAINING_RECORD recovers the base of the enclosing structure from a pointer to one of its fields:
#define CONTAINING_RECORD(address, type, field) \
((type *)((PCHAR)(address) - (ULONG_PTR)(&((type *)0)->field)))
Walk-through: locating PsLoadedModuleList (Windows XP)
A rootkit reads the PCR at the hardcoded address 0xFFDFF000, follows KdVersionBlock at +0x34, and reads +0x70 to obtain the head of PsLoadedModuleList. It then walks the list (entries of type KLDR_DATA_TABLE_ENTRY, whose first field is a LIST_ENTRY) matching FullDllName against the substring "krnl" to find the NT kernel image.
Fragilities of this approach: the PCR/KdVersionBlock offsets are XP-specific; KdVersionBlock is valid only for the first processor's PCR (crashes if rescheduled elsewhere); the KLDR_DATA_TABLE_ENTRY layout is undocumented; the offsets differ on x64; and the unlocked walk can hit a unlinked/stale module. The documented AuxKlibQueryModuleInformation achieves the same goal safely.
Asynchronous and Ad-Hoc Execution
Mechanisms a driver uses to run code later, elsewhere, or in response to events.
System Threads
Created with PsCreateSystemThread. A NULL ProcessHandle creates the thread in the System process with the supplied start routine. Used for background work or waiting on events.
Work Items
A lighter alternative to a dedicated thread: no thread object is created. A work item is an entry on a queue drained by a pool of pre-existing system threads (ExpWorkerThread).
0: kd> dt nt!_IO_WORKITEM
+0x000 WorkItem : _WORK_QUEUE_ITEM // list entry + routine + parameter
+0x020 Routine : Ptr64 void
+0x028 IoObject : Ptr64 Void
+0x030 Context : Ptr64 Void
- Allocate with
IoAllocateWorkItem(non-paged pool), queue withIoQueueWorkItem. - Queues hang off each processor's node:
KPRCB.ParentNode→KNODE→ENODE.ExWorkerQueues[7](_EX_WORK_QUEUE). - Work items run in the System process context at
PASSIVE_LEVEL(becauseExpWorkerThreaddoes). - Common pattern: queue a work item from inside a DPC (to escape
DISPATCH_LEVEL).
Asynchronous Procedure Calls (APCs)
APCs are functions executed in a particular thread context; they are always associated with an ETHREAD. Undocumented from a kernel perspective.
| APC kind | IRQL | Mode | Executes when |
|---|---|---|---|
| Kernel, normal | PASSIVE_LEVEL |
Kernel | Scheduled |
| Kernel, special | APC_LEVEL |
Kernel | Scheduled |
| User | PASSIVE_LEVEL |
User | Thread enters an alertable state |
An APC is a KAPC, initialized by KeInitializeApc and queued with KeInsertQueueApc. The Environment parameter (OriginalApcEnvironment, AttachedApcEnvironment, …) selects the process context. The two queues live in KTHREAD.ApcState (_KAPC_STATE.ApcListHead[2]: kernel and user).
- Thread suspension is implemented with an APC:
KTHREAD.SchedulerApc, initialized inKeInitThreadwithKiSchedulerApcas its normal routine, waits on the thread'sSuspendEvent;KeResumeThreadreleases it. - Rootkits queue user-mode APCs to inject code into user mode from the kernel.
Deferred Procedure Calls (DPCs)
DPCs run at DISPATCH_LEVEL, in arbitrary thread context, on a specific processor. The canonical use: an ISR runs at high IRQL, queues a DPC, and returns immediately so the system can service other interrupts; the DPC then does the deferred work (often by queuing a work item).
0: kd> dt nt!_KDPC
+0x000 Type : UChar // normal or threaded
+0x001 Importance : UChar // position in the queue (KeSetImportanceDpc)
+0x002 Number : Uint2B // target processor (KeSetTargetProcessorDpc)
+0x008 DpcListEntry : _LIST_ENTRY
+0x018 DeferredRoutine : Ptr64 void // runs at DISPATCH_LEVEL
+0x020 DeferredContext : Ptr64 Void
+0x028 SystemArgument1 : Ptr64 Void
+0x030 SystemArgument2 : Ptr64 Void
+0x038 DpcData : Ptr64 Void // -> _KDPC_DATA
- Init with
KeInitializeDpc, queue withKeInsertQueueDpc. Each core keeps its queues inKPRCB.DpcData[2]([0]normal,[1]threaded); eachKDPC_DATAtracksDpcListHead,DpcLock,DpcQueueDepth,DpcCount. - Three processing paths: the idle loop (
KiIdleLoop→KiRetireDpcList); on IRQL drop toDISPATCH_LEVEL(KiDispatchInterrupt); and a per-processor DPC thread created byKiStartDpcThread(KiExecuteDpc). - A DPC cannot wait and cannot take a page fault:
KeWaitForSingleObjectandKeDelayExecutionThreadare forbidden, since the dispatcher itself runs atDISPATCH_LEVEL.
DPC watchdog
A DPC that runs too long bugchecks with DPC_WATCHDOG_VIOLATION (0x133). Query the timer with KeQueryDpcWatchdogInformation.
Timers
A timer signals that an interval has elapsed, once or periodically, optionally firing a DPC on expiry.
0: kd> dt nt!_KTIMER
+0x000 Header : _DISPATCHER_HEADER
+0x018 DueTime : _ULARGE_INTEGER
+0x020 TimerListEntry: _LIST_ENTRY
+0x030 Dpc : Ptr64 _KDPC
+0x038 Processor : Uint4B
+0x03c Period : Uint4B
KeInitializeTimer, thenKeSetTimer(one-shot) orKeSetTimerEx(recurring). Cancel withKeCancelTimer.- Timers queue into the PRCB's
TimerTable(_KTIMER_TABLE). On each clock interrupt the system checks for expiring entries and requests a DPC interrupt to process them — so timers are serviced atDISPATCH_LEVEL. - OS examples:
ExpTimeRefreshDpcRoutine(time sync / license check),ExpCenturyDpcRoutine(century rollover).
Process, Thread, and Image Callbacks
Drivers register notifications through documented APIs: PsSetCreateProcessNotifyRoutine, PsSetCreateThreadNotifyRoutine, PsSetLoadImageNotifyRoutine.
PspInitializeCallbacksbuilds three global arrays (PspCreateProcessNotifyRoutine,PspCreateThreadNotifyRoutine,PspLoadImageNotifyRoutine); registrations land here.- A global flag
PspNotifyEnableMaskgates which notifications are live; it is checked on the thread creation/termination paths (PspInsertThread,PspExitThread). - The object and configuration managers expose their own callbacks via
ObRegisterCallbacksandCmRegisterCallback. - Antivirus products use these to monitor the system; rootkits pair them with APCs to inject into new processes.
Completion Routines
Notify a driver that an IRP completed, was cancelled, or failed. Run in arbitrary context and set with IoSetCompletionRoutine[Ex].
IoSetCompletionRoutine is forced-inline — it never appears in an import table or as a call. Recognize it by a write to the CompletionRoutine field of an IO_STACK_LOCATION. Its three boolean parameters map to Control flags SL_INVOKE_ON_SUCCESS, SL_INVOKE_ON_ERROR, SL_INVOKE_ON_CANCEL. The I/O manager invokes the routine from IopfCompleteRequest.
I/O Request Packets (IRPs)
An IRP describes an I/O request and is the unit of communication between layered devices. It has two parts:
| Part | Contents |
|---|---|
| Static | The IRP header: requestor mode, thread, status block, user buffer |
| Dynamic | An array of IO_STACK_LOCATION structures, one per device in the stack |
0: kd> dt nt!_IO_STACK_LOCATION
+0x000 MajorFunction : UChar
+0x001 MinorFunction : UChar
+0x002 Flags : UChar
+0x003 Control : UChar
+0x008 Parameters : <union — depends on major/minor function>
+0x028 DeviceObject : Ptr64 _DEVICE_OBJECT
+0x030 FileObject : Ptr64 _FILE_OBJECT
+0x038 CompletionRoutine: Ptr64 long
+0x040 Context : Ptr64 Void
The 'next' stack location is above the current one
The next IO_STACK_LOCATION is the array element immediately above (lower address than) the current one. IoGetCurrentIrpStackLocation, IoGetNextIrpStackLocation, and IoSkipCurrentIrpStackLocation are pointer arithmetic over this array. On x64, sizeof(_IO_STACK_LOCATION) == 0x48.
A driver can also build an IRP from scratch (IoAllocateIrp), fill in the major/minor code and stack location, and send it downstream with IoCallDriver — a technique rootkits use to reach the file-system driver directly and bypass syscall hooks.
Structure of a Driver
A driver is code loaded into kernel space that runs at kernel privilege. It has no main thread; it is a collection of routines the kernel calls under defined circumstances — much like a DLL.
| Driver type | Role |
|---|---|
| Legacy software driver | Ring-0 code interfacing the kernel; most rootkits and security drivers |
| Legacy filter driver | Attaches to another driver to modify its input |
| File system minifilter driver | Intercepts file I/O (AV scanning, on-disk encryption) |
The standard model is WDM (Windows Driver Model), defining required interfaces and rules since Windows 2000. WDF is a library layer on top of WDM: KMDF (kernel-mode) and UMDF (user-mode). Analyzed drivers are WDM-based.
Entry Point and DRIVER_OBJECT
Every driver has an entry point (conventionally DriverEntry) called by the I/O manager after the image is mapped and a DRIVER_OBJECT is created.
The primary job of DriverEntry is to initialize driver state and register IRP dispatch routines in MajorFunction.
DRIVER_OBJECT field |
x86 offset | Meaning |
|---|---|---|
DriverUnload |
0x34 |
Unload routine (absent → driver stays until reboot) |
MajorFunction[28] |
0x38 |
Array of IRP dispatch handlers, indexed by IRP_MJ_* |
Uninitialized MajorFunction slots default to IopInvalidDeviceRequest, which returns an error. RegistryPath is the driver's registry key. Many rootkits omit an unload routine.
Driver and Device Objects
A driver may create one or more device objects (IoCreateDevice, usually in DriverEntry). Without a device object, nothing can send it requests.
typedef struct _DEVICE_OBJECT {
...
struct _DRIVER_OBJECT *DriverObject; // owning driver
struct _DEVICE_OBJECT *NextDevice; // next device on this driver
struct _DEVICE_OBJECT *AttachedDevice; // device we are attached to
...
PVOID DeviceExtension; // driver-specific data (non-paged pool)
...
} DEVICE_OBJECT, *PDEVICE_OBJECT;
DeviceExtensionis a driver-defined structure whose size is passed toIoCreateDevice; recovering its layout is a top analysis priority.- A device can attach to another (
IoAttachDevicefamily) so IRPs bound for the target are routed to the attacher first — the basis of filter drivers.
IRP Handling
Dispatch routines share one prototype:
A handler reads its IO_STACK_LOCATION (IoGetCurrentIrpStackLocation), inspects the major function and parameters, and then either completes the request (IoCompleteRequest), returns an error, forwards the IRP (IoCallDriver), or pends it. To forward the same IRP it must either copy the current parameters to the next location (IoCopyCurrentIrpStackLocationToNext) or skip (IoSkipCurrentIrpStackLocation).
STATUS_PENDING (0x103)
A driver returns STATUS_PENDING (0x103) when the operation is incomplete and awaiting another driver. Recognize this constant when analyzing dispatch routines.
User–Kernel Communication (IOCTL)
Several channels exist (shared memory, events, custom interrupt handlers), but the standard interface is device I/O control over IRP_MJ_DEVICE_CONTROL, invoked from user mode via DeviceIoControl.
- The driver defines an IOCTL code per operation.
- Each code specifies a buffering method for accessing user data.
- The
IRP_MJ_DEVICE_CONTROLhandler reads the code from itsIO_STACK_LOCATIONand processes the data accordingly.
Buffering Methods
| Method | Constant | Kernel access to the buffer |
|---|---|---|
| Buffered I/O | METHOD_BUFFERED |
Kernel validates, allocates non-paged pool, copies in/out via Irp->AssociatedIrp.SystemBuffer |
| Direct I/O | METHOD_IN_DIRECT / METHOD_OUT_DIRECT |
I/O manager builds and locks an MDL; driver reads Irp->MdlAddress |
| Neither | METHOD_NEITHER |
No validation; raw pointer in Parameters.DeviceIoControl.Type3InputBuffer |
Buffered I/O is the common choice for software drivers (simple and reasonably safe). Direct I/O suits large hardware transfers. METHOD_NEITHER leaves all validation to the developer and is the most vulnerable to kernel memory corruption or disclosure.
I/O Control Codes
An IOCTL is a 32-bit value encoding device type, function, buffering method, and access rights.
#define CTL_CODE(DeviceType, Function, Method, Access) ( \
((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method) )
| Field | Convention |
|---|---|
DeviceType |
A FILE_DEVICE_* constant; third-party drivers use >0x8000 |
Function |
Driver-specific; recommended >0x800 |
Method |
One of the buffering methods |
Access |
FILE_ANY_ACCESS, FILE_READ_ACCESS, FILE_WRITE_ACCESS |
Decode with DEVICE_TYPE_FROM_CTL_CODE (>>16 & 0xffff) and METHOD_FROM_CTL_CODE (& 3).
Miscellaneous Mechanisms
CR0 and the WP Bit
Kernel code is mapped read-only; patching it faults. On x86/x64 this is enforced by the WP (write-protect) bit, bit 16 of CR0, which — when set (the default) — forbids writes to read-only pages even in ring 0. Rootkits toggle it to hook kernel code:
mov eax, cr0
mov [esp+...], eax ; save original
and eax, 0FFFEFFFFh ; clear bit 16 (WP)
mov cr0, eax
Alternatives that avoid touching CR0 use MDLs to remap the target pages writable.
PatchGuard
On x64 and ARM, Kernel Patch Protection (PatchGuard) detects modifications to security-critical structures and bugchecks. Hooking therefore persists mainly on x86.
Accessing the Service Table
KiServiceTable is not exported, so hooking rootkits reach it through the exported KeServiceDescriptorTable (a KSERVICE_TABLE_DESCRIPTOR whose Base is the table). On x64 this symbol is not exported either. To locate a specific call, rootkits either hardcode the syscall index or disassemble the Nt*/Zw* stub to recover it — both brittle across service packs.
Reading a syscall number from a stub (x86, Win 7)
The first instruction of ZwQuerySystemInformation is b8 05010000 (mov eax, 105h). 0xb8 is the MOV EAX opcode; the following dword is the syscall number, so reading the byte at offset +1 yields the index used to overwrite the corresponding KiServiceTable entry.
Sections
A section object describes memory backed by storage.
| Backing | Behavior of modifications |
|---|---|
| File-backed | Written through to the file on disk |
| Page-file-backed | Discarded when the section is closed |
Create with ZwCreateSection, map a view (a virtual range onto the section) with ZwMapViewOfSection. One section can have multiple views.
Walk-Through: x86 Syscall-Table Rootkit (Sample A)
Demonstrates the standard software-driver skeleton plus a service-table overwrite.
DriverEntrycreates a device\Device\fsodhfn2mwith a symbolic link\DosDevices\fsodhfn2m, registers one handler forIRP_MJ_READ,IRP_MJ_CLOSE, andIRP_MJ_DEVICE_CONTROL, and sets an unload routine (delete link, delete device).- The IOCTL handler accepts code
0x22c004, which decodes toFILE_DEVICE_UNKNOWN, function1,METHOD_BUFFERED,FILE_READ_DATA | FILE_WRITE_DATA. Input arrives inIrp->AssociatedIrp.SystemBuffer. - The operation: raise IRQL to
DISPATCH_LEVEL, build an MDL overKiServiceTableand map it writable at a second address (IoAllocateMdl→MmProbeAndLockPages→MmMapLockedPagesSpecifyCache), then overwrite entries from a user-supplied array laid out as[count][addr₁][addr₂]…. Finally unmap/unlock/free the MDL and lower IRQL.
The net effect is an IOCTL that lets user mode replace arbitrary native syscall pointers — trivially exploitable for ring-0 code execution, and unsafe on multiprocessor systems.
Walk-Through: x64 File-Deletion Rootkit (Sample B)
Demonstrates callbacks, MDL cleanup, a hand-built IRP, and reading optimized x64 code.
- A process-termination callback (registered via the documented notify API) fires only when
Create == FALSE, running in the dying process's context. It walks aLIST_ENTRYof tracked (injected) processes; on a name match it unmaps/unlocks/frees the MDL stored in the entry and unlinks it withRemoveEntryList. (Injected pages must be released before the process dies or the system bugchecks.) - A separate routine deletes a file without
ZwDeleteFileby crafting an IRP directly:IoGetRelatedDeviceObject→IoAllocateIrp(sized by the device'sStackSize) → fill the nextIO_STACK_LOCATIONwithIRP_MJ_SET_INFORMATION,FileInformationClass = FileDispositionInformation, and a one-byte buffer of1→ set a completion routine →IoCallDriver. If the driver returnsSTATUS_PENDING, it waits on an event the completion routine signals. This bypasses security software that hooks the deletion syscall.
x64 optimizer: negative offsets for the next stack location
Because IO_STACK_LOCATION is 0x48 bytes and the next element sits at a lower address, the compiler emits accesses like [rcx-48h], [rcx-20h] off the current location instead of computing a fresh base pointer. Recognize this pattern as IoGetNextIrpStackLocation followed by field writes.
Analysis Checklist for a Driver
- Identify
DriverEntryand its IRP dispatch handlers. - Determine whether the driver attaches to another device (and which).
- If it creates a device object, recover its name and
DeviceExtensionsize. - Recover the
DeviceExtensionlayout from field usage. - If it supports IOCTL, enumerate the codes, their functionality, and buffering methods.
- Identify DPCs, work items, APCs, timers, completion routines, callbacks, and system threads.
- Reconstruct how the pieces fit together.