<index> / <wazabiedr> / driver-skeleton
[ en | fr ]
┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
Part 1 — A KMDF Driver Skeleton in Rust
~ lululufr
CONTENTS
  0  the target state
  1  cargo.toml — the wdk crate split
  2  no_std, allocator, panic — and the test config
  3  driverentry — ordering and unwinding
  4  the irp_mj table
  5  device, symlink, do_buffered_io
  6  driverunload — atomic device pointer
  7  build, sign, load
  8  what's next

──[ 0. The Target State ]──

At the end of this post the driver loads, exposes `\.WazabiEDR` (a device path 
reachable from user mode), and does nothing else. No kernel callbacks are
registered. The four IRP dispatch routines (*IRP, I/O Request Packet*: the
structure the Windows I/O manager uses to convey a user-mode call —
`CreateFile`, `ReadFile`, `DeviceIoControl` and so on — to the driver) all
complete successfully without side effects. Opening the device from user mode
returns a usable handle, closing it succeeds. The point of stopping here is to isolate the kernel-mode plumbing — entry point,
dispatch table, device naming, unload — from the EDR-specific logic that
follows. With the plumbing wrong, the callbacks of Part 2 would fire into a
half-built driver, which is the most painful way to debug a kernel codebase.
With the plumbing correct, everything subsequent stays additive. A note on framework choice. KMDF (*Kernel-Mode Driver Framework*: Microsoft's
higher-level abstraction over the raw WDM driver model — *Windows Driver Model*
— that handles a sizeable chunk of object-lifetime, IRP cancellation and
power-management boilerplate for you) is what this driver targets. The
alternative, raw WDM, would give us slightly more control at the cost of
significant boilerplate; for a driver whose surface is "five callbacks plus one
IOCTL", KMDF's overhead pays itself back many times over.
──[ 1. Cargo.toml — the WDK Crate Split ]──
[package]
name    = "WazabiEDR_Driver"
version = "0.1.0"
edition = "2024"

[dependencies]
wdk        = "0.4.1"
wdk-alloc  = "0.4.1"
wdk-panic  = "0.4.1"
wdk-sys    = "0.5.1"

[build-dependencies]
wdk-build = "0.5.1"

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

[package.metadata.wdk.driver-model]
driver-type              = "KMDF"
kmdf-version-major       = 1
target-kmdf-version-minor = 33

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"
The four `wdk-*` runtime crates partition the WDK (*Windows Driver Kit*: 
Microsoft's headers, libraries and tools for kernel-mode driver development)
surface deliberately:
    wdk-sys     bindgen output over the WDK headers. Unsafe FFI, no
                wrapping. This is what you actually write against in
                a production driver, because the higher-level
                wrappers don't yet cover what an EDR needs (callback
                registration shapes, IRP completion idioms, OB
                callback envelopes).

    wdk         safe-ish wrappers over wdk-sys for the patterns
                Microsoft considered first-class. Useful for a USB
                or storage driver. Largely tangential here.

    wdk-alloc  `GlobalAlloc` impl backed by `ExAllocatePool2` — the
                modern (Windows 10 2004 and later) replacement for
                `ExAllocatePoolWithTag`. Pool type is configurable
                at the allocation site via flags.

    wdk-panic  `#[panic_handler]` that calls `KeBugCheckEx`. Pulled
                in via `extern crate` so the symbol exists, even
                though the rest of the code never references it.

`crate-type = ["cdylib"]` (*cdylib*: a dynamically-linked library with a C ABI — 
what `link.exe` needs to produce a `.sys` rather than a Rust rlib) combined with
the `package.metadata.wdk.driver-model` block is what makes `wdk-build` emit the
correct linker invocation: `/SUBSYSTEM:NATIVE`, `/DRIVER`, the KMDF library
imports, and the right entry point. Without the metadata block `cargo build`
produces a cdylib that links but won't load. `panic = "abort"` in both profiles is mandatory, not stylistic. The build
accepts `panic = "unwind"`, but `wdk-panic` doesn't provide an unwind
personality (the runtime code Rust would call into to actually walk and clean up
the stack), and even if it did, unwinding through kernel stack frames would
leave half-released spinlocks and dangling IRPs along the way. Aborting on panic
is the only safe choice; the compiler enforces it via these two lines.
──[ 2. no_std, Allocator, Panic — and the Test Config ]──
#![no_std]

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

#[cfg(not(test))]
use wdk_alloc::WdkAllocator;

#[cfg(not(test))]
#[global_allocator]
static GLOBAL_ALLOCATOR: WdkAllocator = WdkAllocator;
`no_std` (*no_std*: a Rust crate attribute that opts out of `std`, leaving only 
`core` — the portable subset — and `alloc` — heap-aware collections, once an
allocator is registered) is mandatory because the Rust standard library assumes
a user-mode environment we don't have: no `std::fs`, no `std::thread`, no
syscall surface. We work against `core` and `alloc` exclusively. The three `#[cfg(not(test))]` gates exist for one practical reason. `cargo test`
builds the crate for the host triple, the host already has its own
`#[global_allocator]` and `#[panic_handler]`, and registering ours
unconditionally produces a duplicate-symbol link error at test time. Gating them
out keeps test builds compatible with the host stdlib while production builds
get the kernel-side primitives. `WdkAllocator` defaults to non-paged pool. Recall that Windows splits kernel
memory into two pools: *paged* (can be evicted to disk under memory pressure)
and *non-paged* (locked in physical memory, mandatory for code that runs above
`PASSIVE_LEVEL` — see IRQL note below). Any allocation made via the global
allocator can therefore be touched at any IRQL. IRQL (*Interrupt Request Level*: a scheduling priority level enforced by the
kernel; code running at `DISPATCH_LEVEL` or higher cannot block or page-fault)
is the constraint that drives this choice. The ring buffer in Part 4 is written
by kernel callbacks whose IRQL we don't fully control — in particular the OB
pre-operation callback can run as high as `DISPATCH_LEVEL`. Anything those
callbacks touch must be in non-paged memory. Making non-paged the default for
the entire driver keeps the rule uniform and removes a class of bugs. The cost is that non-paged pool is a shared, finite resource. The ring buffer is
fixed-size (4096 slots) precisely so the driver's worst-case memory footprint is
statically known.
──[ 3. DriverEntry — Ordering and Unwinding ]──
#[unsafe(export_name = "DriverEntry")]
pub unsafe extern "system" fn driver_entry(
    driver: PDRIVER_OBJECT,
    _registry_path: PCUNICODE_STRING,
) -> NTSTATUS
`PDRIVER_OBJECT` is a pointer to the kernel-allocated `DRIVER_OBJECT` structure 
that represents this driver to the I/O manager. We mutate it in place — install
dispatch routines, the unload routine, the device list pointer — because there
is no return-an-initialised-object idiom at this layer; the kernel hands us a
partially-filled struct and expects us to fill in the rest. `PCUNICODE_STRING` is a pointer to a `UNICODE_STRING` (*UNICODE_STRING*: the
kernel's standard string descriptor — a pair of `(Length, MaximumLength,
Buffer)` over a UTF-16 buffer, with `Length` in bytes, no NUL terminator). The
`_registry_path` here points at the registry path of our service key under
`HKLMSYSTEMCurrentControlSetServices`. We ignore it deliberately: the driver has
no per-deployment configuration, all tunables live in the agent's `agent.json`
(Part 5). `NTSTATUS` is a signed 32-bit return code where the high bit signals failure.
The reason it's worth flagging is that `DriverEntry` failure has no kernel-side
recovery: if we return a negative status, the I/O manager will not call our
`DriverUnload`. Anything we acquired before failing — device objects, symlinks,
registered callbacks — has to be released inline in the same `DriverEntry` body.
Every fallible step in the function is therefore paired with an inline unwind of
every preceding step. The `lib.rs` source is repetitive for that reason, and the
repetition is load-bearing. The very first line of the body:
(*driver).DriverUnload = Some(driver_unload);
`DriverUnload` is wired before any other work because the inline unwind ladder 
only covers `DriverEntry` failure. Once we return `STATUS_SUCCESS` the kernel
takes over the cleanup path, and that path runs through this field. Wiring it
last would leave a window in which a fault between the last successful side
effect and the field assignment leaves the kernel with no way to call us back.
──[ 4. The IRP_MJ Table ]──
let mj = (*driver).MajorFunction.as_mut_ptr();
for i in 0..=IRP_MJ_MAXIMUM_FUNCTION as usize {
    *mj.add(i) = Some(dispatch_invalid);
}

*mj.add(IRP_MJ_CREATE as usize)         = Some(dispatch_create_close);
*mj.add(IRP_MJ_CLOSE as usize)          = Some(dispatch_create_close);
*mj.add(IRP_MJ_CLEANUP as usize)        = Some(dispatch_cleanup);
*mj.add(IRP_MJ_DEVICE_CONTROL as usize) = Some(dispatch_device_control);
`MajorFunction` is an array of function pointers, indexed by *major function 
code* (`IRP_MJ_CREATE`, `IRP_MJ_READ`, `IRP_MJ_DEVICE_CONTROL`, …). The I/O
manager picks the slot matching the incoming IRP's major code and calls the
function there. A `NULL` entry causes the system to bug-check (kernel-mode crash, the Blue
Screen) on the first IRP of that type. Some samples leave the array zeroed and
rely on never receiving an unexpected IRP — fine until something unexpected does
arrive. We install `dispatch_invalid` (returns `STATUS_INVALID_DEVICE_REQUEST`)
across the entire table and then override the four slots we actually service. The four overridden handlers are stubs at this stage:
    dispatch_create_close   — IRP_MJ_CREATE + IRP_MJ_CLOSE. Returns
                              STATUS_SUCCESS. Agent's CreateFile and
                              CloseHandle both succeed.

    dispatch_cleanup        — IRP_MJ_CLEANUP. Sent by the I/O
                              manager when the last handle on the
                              file object is closing. In the final
                              driver this handler must cancel any
                              IRP we have parked in PENDING_IRP
                              (Part 4); for now it returns
                              STATUS_SUCCESS.

    dispatch_device_control — IRP_MJ_DEVICE_CONTROL. Returns
                              STATUS_INVALID_DEVICE_REQUEST for any
                              IOCTL code, since IOCTL_WEDR_GET_EVENT
                              isn't wired yet. Part 4 replaces this
                              stub with the real handler.

The dispatch routines live in `src/ipc/dispatch.rs`. Keeping them in their own 
module from day one is free and makes Part 4's expansion drop in cleanly.
──[ 5. Device, Symlink, DO_BUFFERED_IO ]──
let mut device_name: UNICODE_STRING = core::mem::zeroed();
let device_name_buf: [u16; 18] = wstr16(b"\\Device\\WazabiEDR\0");
RtlInitUnicodeString(&mut device_name, device_name_buf.as_ptr());

let mut device: PDEVICE_OBJECT = ptr::null_mut();
let status = IoCreateDevice(
    driver,
    0,                       // DeviceExtensionSize
    &mut device_name,
    FILE_DEVICE_UNKNOWN,
    0,                       // DeviceCharacteristics
    0,                       // Exclusive = FALSE
    &mut device,
);
`DeviceWazabiEDR` is the device's name in the kernel object namespace. The 
kernel namespace is the global tree the Object Manager arbitrates — it's where
named kernel objects (devices, events, mutexes, sections) live. User mode can't
reach it through Win32 directly, which is why we follow up with a symbolic link. `Exclusive = FALSE` (no `FILE_DEVICE_SECURE_OPEN`, no characteristics flags)
because we serialise client access at a different layer. The agent is the only
intended client, and the single-pending-IRP slot in Part 4 enforces
single-client semantics from above; making the device exclusive would add a
redundant gate and complicate `IRP_MJ_CREATE` behaviour for test scenarios. `DeviceExtensionSize = 0` because we don't use the *device extension* (an
opaque, kernel-allocated buffer that a driver typically uses as
per-device-object state). Our global state lives in `state.rs` as `AtomicPtr`
and `SyncCell` statics, which is friendlier to Rust's borrow model than a
self-referential extension struct and removes per-device-object lifetime
bookkeeping. The symlink:
let mut symlink: UNICODE_STRING = core::mem::zeroed();
let symlink_buf: [u16; 22] = wstr16(b"\\DosDevices\\WazabiEDR\0");
RtlInitUnicodeString(&mut symlink, symlink_buf.as_ptr());
let status = IoCreateSymbolicLink(&mut symlink, &mut device_name);
`DosDevicesWazabiEDR` is the user-mode-visible path. The DOS device namespace is
 what Win32's `CreateFileW(L"\\.\WazabiEDR", …)` resolves through: the `\.` pref
ix tells Win32 to look up the rest under `DosDevices` (in modern Windows this is
 a session-scoped namespace, but a symlink created by a kernel driver lands in t
he global session, which is what we want).

Immediately after both calls succeed:
(*device).Flags |= DO_BUFFERED_IO;
CONTROL_DEVICE.store(device, Ordering::Release);
`DO_BUFFERED_IO` is one of three I/O methods Windows supports for moving data 
between user mode and a driver. Briefly:
    Buffered I/O — the I/O manager copies the user buffer into a
                   kernel-allocated SystemBuffer on the way in, and
                   copies it back on the way out. One copy per
                   transfer. Driver only ever touches kernel memory.

    Direct I/O   — the user buffer is locked into physical memory
                   and mapped into the kernel for the duration of
                   the IRP. No copy, but the driver must handle MDLs
                   (Memory Descriptor Lists).

    Neither      — the driver receives the user-mode pointer raw and
                   must validate and probe it itself. Fastest, most
                   dangerous, used by drivers that already do their
                   own page-locking.

For our IOCTL traffic — one event copied per call, peak event size around 1 KiB 
— the buffered-I/O copy is invisibly cheap and it removes the entire class of
issues around user-mode buffer revocation, page faults at elevated IRQL, and
concurrent user-mode modification mid-call. `CONTROL_DEVICE.store(…, Release)` publishes the device pointer to the rest of
the driver. `Release` pairs with the `Acquire` load in `DriverUnload` and in any
handler that walks back to the device. The atomic exists instead of a cached
local because dispatch routines need access to it and we'd rather not thread it
through every signature.
──[ 6. DriverUnload — Atomic Device Pointer ]──
unsafe extern "C" fn driver_unload(_driver: PDRIVER_OBJECT) {
    unsafe {
        // callbacks deregistration — Part 2
        // pending IRP cancellation, queue drain — Part 4

        let mut symlink: UNICODE_STRING = core::mem::zeroed();
        let symlink_buf: [u16; 22] = wstr16(b"\\DosDevices\\WazabiEDR\0");
        RtlInitUnicodeString(&mut symlink, symlink_buf.as_ptr());
        let _ = IoDeleteSymbolicLink(&mut symlink);

        let dev = CONTROL_DEVICE.swap(ptr::null_mut(), Ordering::AcqRel);
        if !dev.is_null() {
            IoDeleteDevice(dev);
        }
    }
}
Two ordering points.

**Symlink before device.** The symlink is the path user mode resolves through, 
so removing it first cuts off new `CreateFile` attempts immediately. Removing
the device first would leave a brief window during which the symlink points at a
torn-down device object — a thread resolving the path during that window would
see `STATUS_NO_SUCH_DEVICE` (object exists but is being torn down) from a path
that previously returned `STATUS_SUCCESS`. The reverse order makes the failure
mode `STATUS_OBJECT_NAME_NOT_FOUND` (path doesn't exist) from the start, which
is unambiguous. **`swap` instead of `load`-then-`store`.** Dispatch routines load
`CONTROL_DEVICE` under their own atomic. Using `swap` here is a single-step
"publish null and read the previous value" that's race-free against any
concurrent `load(Acquire)`. A `load` followed by `store(null)` would have a
window between the two during which a dispatch routine could pick up the
still-valid pointer and proceed past our `IoDeleteDevice`. `swap` collapses that
window to zero instructions. The two commented-out blocks are what Parts 2 and 4 fill in. The order they
impose in the final driver is non-trivial: callbacks must be deregistered first
(so no callback can fire after we start tearing down the queue), then the
pending IRP must be cancelled (so the agent's blocked `DeviceIoControl` call
returns), then the queue drained (releasing the pool allocations of
still-buffered events), then the symlink and device removed. Each registration
has its own `AtomicBool` flag in `state.rs` so we never double-deregister —
calling `PsRemoveCreateThreadNotifyRoutine` against a routine that was never
registered bug-checks the system, and the same pattern holds for every other
`*Remove*` / `*Unregister*` call.
──[ 7. Build, Sign, Load ]──

`cargo build --release` produces `target/release/WazabiEDR_Driver.sys`. The 
accompanying `.inf` and `wdk-build` post-build steps emit the catalog files an
actual production install would need, though for local loading via `sc.exe`
they're not consulted. Test-signing posture for local development:
bcdedit /set testsigning on
bcdedit /set hypervisorlaunchtype off   # if HVCI is active
Reboot, then:
sc.exe create WazabiEDR type= kernel binPath= C:\path\to\WazabiEDR_Driver.sys
sc.exe start  WazabiEDR
`testsigning` allows the kernel loader to accept drivers signed by a 
non-Microsoft, non-WHQL-cross-signed certificate (including self-signed,
including unsigned in some configurations). HVCI (*Hypervisor-Protected Code
Integrity*: a Windows security feature that runs Code Integrity checks inside a
hypervisor-isolated process) imposes stricter rules on top of standard Code
Integrity and must be off for test-signed kernel drivers to load. Common failure codes from `sc.exe start`:
    577  — driver isn't signed, TESTSIGNING off
    31   — DriverEntry returned negative NTSTATUS; check DbgView
           for the specific status
    1275 — blocked by Code Integrity (TESTSIGNING off, or HVCI on)
    1058 — service start type is wrong; type= kernel is required

`DbgView` (sysinternals utility that captures kernel-mode `DbgPrint` output) 
shows the driver's diagnostics. The instrumentation in `lib.rs` prints
`[WazabiEDR] DriverEntry called` on entry, then `[WazabiEDR] ready (…)` on
success, or one `[WazabiEDR] <step> failed` line identifying the first failure.
The order of `DbgPrint` calls in `DriverEntry` is informative — they let you
tell at a glance how far initialisation got before failing.
──[ 8. What's Next ]──

After this post:
    ✓ device exists at \.WazabiEDR
    ✓ dispatch table fully populated (4 real + 1 default handler)
    ✓ unload path can tear down what we've created
    ✗ no kernel callbacks registered
    ✗ no event flow
    ✗ no IPC contract with the agent

Part 2 registers the five kernel callbacks (`PsSetCreateProcessNotifyRoutineEx`, 
`PsSetLoadImageNotifyRoutine`, `CmRegisterCallback`,
`PsSetCreateThreadNotifyRoutine`, `ObRegisterCallbacks` against `PsProcessType`)
and walks through the per-callback shape and the pieces of telemetry each one
surfaces. The unwind ladder we set up here is exactly what makes adding five
fallible registrations to `DriverEntry` cleanly possible — each new step gets an
`AtomicBool.store(true)` on success and its own cleanup step prepended to the
unwind path.