top of page

Windows Driver Fuzzing with WinAFL: Theory to Practice

9 hours ago
8 min read

 

Introduction

 

Windows kernel drivers run in ring 0. A bad length check on an IOCTL path can turn a user-mode buffer into a blue screen, or worse, a privilege-escalation primitive. Windows driver fuzzing is the practice of automatically mutating those IOCTL inputs until the kernel faults, then triaging the crash under a debugger.

 

This blog post is a lab walkthrough for practise, not a production hardening checklist. You will:

 

  1. Build a small WDM (not KMDF) test driver with an intentional stack overflow in the IOCTL handler.

  2. Call it from a user-mode harness that uses the same CTL_CODE the driver defines.

  3. Point WinAFL + DynamoRIO at that harness inside a disposable VM with test signing enabled.

  4. Open the resulting crash in WinDbg.

 

Do this only on machines you own or are authorized to break. Kernel fuzzing will crash guests; snapshot often.

 

Windows driver fuzzing with WinAFL

 

WinAFL instruments user-mode code with DynamoRIO. It does not sit inside the kernel. The usual pattern for driver work is:

 

WinAFL → mutates bytes → FuzzTarget() in harness.exe/dll
       → CreateFileA + DeviceIoControl(IOCTL, mutated buffer)
       → I/O Manager builds an IRP
       → your .sys DeviceControl handler runs in kernel

 

If the harness sends the wrong IOCTL value, the driver returns STATUS_INVALID_DEVICE_REQUEST and you never exercise the vulnerable copy. That mismatch is the most common reason “driver fuzzing” posts fail in practice.

 

Infographic on fuzz testing with four steps: test case generation, injection, monitor and analyze, crash analysis.

Lab prerequisites

 

Tool

Role

Visual Studio 2022 + Windows Driver Kit (WDK)

Build the .sys and harness

WinDbg Preview

Triage BSODs / breaks

DynamoRIO + WinAFL

Coverage-guided fuzzing of the harness

Hyper-V / VMware / VirtualBox guest

Crash containment

 

Enable test signing and (optionally) kernel debugging on the guest:

 

bcdedit /set testsigning on
bcdedit /set debug on
shutdown /r /t 0

 

Symbol path in WinDbg Preview (File → Symbol File Path):

 

srv*C:\Symbols*https://msdl.microsoft.com/download/symbols

 

Shared IOCTL definition

 

Define the control code once and reuse it in the driver and the harness. With FILE_DEVICE_UNKNOWN (0x22), function 0x800, METHOD_BUFFERED, and FILE_ANY_ACCESS, CTL_CODE evaluates to 0x00222000.

 

// shared.h: same CTL_CODE args in both builds
#pragma once
#ifdef _KERNEL_MODE
#include <ntddk.h>
#else
#include <windows.h>
#endif

#define FUZZDRV_DEVICE_TYPE   FILE_DEVICE_UNKNOWN
#define FUZZDRV_FUNCTION      0x800
#define IOCTL_FUZZ_OPERATION  CTL_CODE(FUZZDRV_DEVICE_TYPE, FUZZDRV_FUNCTION, METHOD_BUFFERED, FILE_ANY_ACCESS)
// IOCTL_FUZZ_OPERATION == 0x00222000

 

Hardcoding a different constant (for example 0x80002000) in the harness is a silent miss. Print or assert the value in both binaries during bring-up.

 

WDM test driver (intentional bug)

 

This is classic WDM: DriverEntry, IoCreateDevice, major-function table, and IRP_MJ_DEVICE_CONTROL with AssociatedIrp.SystemBuffer. It is not a KMDF sample. KMDF would use WdfDriverCreate / EvtIoDeviceControl and retrieve buffers through WdfRequestRetrieveInputBuffer instead of reading the IRP stack directly. Do not pick a KMDF-only Visual Studio template and expect this code to compile unchanged. Use an empty WDM / “Kernel Mode Driver” project that gives you DriverEntry, or paste into a minimal WDM skeleton that matches the listing below.

 

Device names

 

Kernel side:

 

#define DEVICE_NAME     L"\\Device\\FuzzDriver"
#define DOS_DEVICE_NAME L"\\DosDevices\\FuzzDriver"

 

User mode opens that same symlink as:

 

\\.\FuzzDriver

 

That text form is what you type in WinObj or pass conceptually to CreateFile. In a C string literal you must escape backslashes, so the harness uses "\\\\.\\FuzzDriver" (four backslashes before the dot in source). Do not paste the C literal, quotes included, into a shell or sc command.

 

Driver skeleton with intentional overflow

 

The handler copies InputBufferLength bytes into a 64-byte stack buffer. Under METHOD_BUFFERED, the I/O manager allocates one nonpaged AssociatedIrp.SystemBuffer sized to max(InputBufferLength, OutputBufferLength), copies the caller’s input into it, and reports that input length on the IRP stack. There is no fixed 1024-byte METHOD_BUFFERED ceiling. The lab harness can send more than 64 bytes (the sample caps reads at 0x10000), so an unbounded RtlCopyMemory overflows local. That is deliberate for the lab.

 

#include <ntddk.h>
#include "shared.h"  // IOCTL_FUZZ_OPERATION

#define DEVICE_NAME     L"\\Device\\FuzzDriver"
#define DOS_DEVICE_NAME L"\\DosDevices\\FuzzDriver"

DRIVER_UNLOAD UnloadDriver;
DRIVER_DISPATCH CreateCloseHandler;
DRIVER_DISPATCH DeviceControlHandler;

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
    UNREFERENCED_PARAMETER(RegistryPath);
    UNICODE_STRING deviceName = RTL_CONSTANT_STRING(DEVICE_NAME);
    UNICODE_STRING dosName = RTL_CONSTANT_STRING(DOS_DEVICE_NAME);
    PDEVICE_OBJECT deviceObject = NULL;
    NTSTATUS status = IoCreateDevice(
        DriverObject, 0, &deviceName,
        FILE_DEVICE_UNKNOWN, 0, FALSE, &deviceObject);
    if (!NT_SUCCESS(status)) return status;

    status = IoCreateSymbolicLink(&dosName, &deviceName);
    if (!NT_SUCCESS(status)) {
        IoDeleteDevice(deviceObject);
        return status;
    }

    DriverObject->MajorFunction[IRP_MJ_CREATE] =
    DriverObject->MajorFunction[IRP_MJ_CLOSE] = CreateCloseHandler;
    DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DeviceControlHandler;
    DriverObject->DriverUnload = UnloadDriver;
    return STATUS_SUCCESS;
}

VOID UnloadDriver(PDRIVER_OBJECT DriverObject)
{
    UNICODE_STRING dosName = RTL_CONSTANT_STRING(DOS_DEVICE_NAME);
    IoDeleteSymbolicLink(&dosName);
    IoDeleteDevice(DriverObject->DeviceObject);
}

NTSTATUS CreateCloseHandler(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    UNREFERENCED_PARAMETER(DeviceObject);
    Irp->IoStatus.Status = STATUS_SUCCESS;
    Irp->IoStatus.Information = 0;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    return STATUS_SUCCESS;
}

NTSTATUS DeviceControlHandler(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    UNREFERENCED_PARAMETER(DeviceObject);
    PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
    NTSTATUS status = STATUS_SUCCESS;

    if (stack->Parameters.DeviceIoControl.IoControlCode == IOCTL_FUZZ_OPERATION) {
        ULONG inputLength = stack->Parameters.DeviceIoControl.InputBufferLength;
        // LAB ONLY: intentional stack overflow when inputLength > 64
        UCHAR local[64];
        if (inputLength > 0 && Irp->AssociatedIrp.SystemBuffer) {
            RtlCopyMemory(local, Irp->AssociatedIrp.SystemBuffer, inputLength);
        }
        Irp->IoStatus.Information = 0;
    } else {
        status = STATUS_INVALID_DEVICE_REQUEST;
    }

    Irp->IoStatus.Status = status;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    return status;
}

 


Why this crashes:


METHOD_BUFFERED places caller input in "AssociatedIrp.SystemBuffer" (allocated as the larger of the input and output lengths). The buggy path trusts InputBufferLength and writes past local[64], corrupting the stack. A “safe” copy that clamps length to sizeof(local) will not fault under this harness; do not advertise that variant as a crash lab.

 

Install on the guest

 

Build FuzzDriver.sys, copy it somewhere stable (lab path is fine; System32\drivers is optional), then:

 

sc create FuzzDriver type= kernel start= demand binPath= C:\lab\FuzzDriver.sys
sc start FuzzDriver

 

Confirm the symlink exists (WinObj or a successful CreateFile from the harness).

 

User-mode harness (must match IOCTL)

 

WinAFL’s DynamoRIO persistent loop expects the target function to open the input path, use it, close handles, and return normally (not ExitProcess). Export that function and call it once from main so the first iteration matches later redirects.

 

#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "shared.h"  // IOCTL_FUZZ_OPERATION == 0x00222000

#define DEVICE_PATH "\\\\.\\FuzzDriver"  /* C escapes → \\.\FuzzDriver at runtime */

// Exported for WinAFL -target_method (opens @@ path, closes before return)
__declspec(dllexport) int FuzzTarget(char* filename)
{
    if (!filename) return 1;

    HANDLE f = CreateFileA(filename, GENERIC_READ, FILE_SHARE_READ,
                           NULL, OPEN_EXISTING, 0, NULL);
    if (f == INVALID_HANDLE_VALUE) return 1;

    DWORD size = GetFileSize(f, NULL);
    if (size == INVALID_FILE_SIZE || size == 0 || size > 0x10000) {
        CloseHandle(f);
        return 1;
    }

    char* buf = (char*)malloc(size);
    if (!buf) {
        CloseHandle(f);
        return 1;
    }
    DWORD read = 0;
    if (!ReadFile(f, buf, size, &read, NULL)) {
        free(buf);
        CloseHandle(f);
        return 1;
    }
    CloseHandle(f);

    HANDLE h = CreateFileA(
        DEVICE_PATH,
        GENERIC_READ | GENERIC_WRITE,
        0, NULL, OPEN_EXISTING,
        FILE_ATTRIBUTE_NORMAL, NULL);
    if (h == INVALID_HANDLE_VALUE) {
        free(buf);
        return 1;
    }

    DWORD returned = 0;
    DeviceIoControl(
        h,
        IOCTL_FUZZ_OPERATION,   // 0x00222000, not 0x80002000
        buf,
        read,
        NULL, 0,
        &returned,
        NULL);
    CloseHandle(h);
    free(buf);
    return 0;
}

int main(int argc, char** argv)
{
    if (argc < 2) {
        fprintf(stderr, "usage: %s <input.bin>\n", argv[0]);
        fprintf(stderr, "IOCTL=0x%08X\n", (unsigned)IOCTL_FUZZ_OPERATION);
        return 1;
    }
    return FuzzTarget(argv[1]);
}

 


Bring-up checklist:

 

  1. Print IOCTL_FUZZ_OPERATION and confirm it is 0x00222000 in both binaries.

  2. Write a 64-byte file and run harness.exe seed64.bin: should return without BSOD.

  3. Write a 128+ byte file and run again: guest should bugcheck (snapshot restore).

 

If step 2 cannot open \\.\FuzzDriver, fix sc start / the symlink before involving WinAFL.

 

WinAFL + DynamoRIO

 

Clone and build WinAFL per upstream docs (googleprojectzero/winafl). You need a matching DynamoRIO build and a 64-bit harness if the driver and OS are x64.

 

Seed corpus:

 

mkdir corpus out
echo AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA> corpus\overflow.txt

 

Dry-run with DynamoRIO first (confirms the export resolves and the function returns):

 

drrun.exe -c winafl.dll -debug -target_module harness.exe ^
  -target_method FuzzTarget -nargs 1 -fuzz_iterations 10 -- ^
  harness.exe corpus\overflow.txt

 

Then fuzz (adjust DynamoRIO / binary paths). @@ is the current input path; -nargs 1 matches FuzzTarget(char* filename):

 

afl-fuzz.exe -i corpus -o out -D C:\DynamoRIO\bin64 -t 20000 -- ^
  -coverage_module harness.exe ^
  -target_module harness.exe ^
  -target_method FuzzTarget ^
  -nargs 1 ^
  -fuzz_iterations 1000 -- harness.exe @@

 

Notes that usually decide success or failure:

 

  • -target_method needs an exported name or symbols; __declspec(dllexport) avoids PDB dependency.

  • The target must close the input file before return so WinAFL can rewrite @@ for the next iteration.

  • Kernel BSODs will not look like neat user-mode AFL crashes. Treat VM non-responsiveness plus WinDbg as the oracle, or attach a kernel debugger from the start.

  • If the export is awkward, use -target_offset per the WinAFL README.

 

Scaling WinAFL: parallel fuzzing


Like the standard AFL, WinAFL also supports multi-core parallel fuzzing to vastly increase execution speed. Because our driver’s DeviceControlHandler processes IRPs on their own stack frames (making our local[64] buffer thread-safe from race conditions), we can safely run multiple harnesses simultaneously against the same "\\.\FuzzDriver" symlink.


Open multiple command prompts and bind instances using -M (Master) and -S (Slave).


Terminal 1 (main):


afl-fuzz.exe -i corpus -o out -M Master -D C:\DynamoRIO\bin64 -t 20000 -- -coverage_module harness.exe -target_module harness.exe -target_method FuzzTarget -nargs 1 -fuzz_iterations 1000 -- harness.exe @@

Terminal 2 (Sub agent 1):



afl-fuzz.exe -i corpus -o out -S Slave1 -D C:\DynamoRIO\bin64 -t 20000 -- -coverage_module harness.exe -target_module harness.exe -target_method FuzzTarget -nargs 1 -fuzz_iterations 1000 -- harness.exe @@

Note: Ensure your VM has enough CPU cores allocated to handle the number of concurrent instances you spawn.


Triage with WinDbg

 

When the guest bugchecks (or breaks in the debugger):

 

!analyze -v
lm m FuzzDriver
kb

 

WDK builds usually enable /GS (buffer security check). Overwriting local[64] past the stack cookie commonly surfaces as Bug Check 0xF7 (DRIVER_OVERRAN_STACK_BUFFER). If the overflow takes a different path first, you may instead see 0x7E (SYSTEM_THREAD_EXCEPTION_NOT_HANDLED) or a related exception stop. Either way, confirm the faulting module is FuzzDriver and the frame sits in DeviceControlHandler / the copy with inputLength > 64.

 

Fix for production code is the obvious clamp:

 

ULONG n = (inputLength < sizeof(local)) ? inputLength : (ULONG)sizeof(local);
RtlCopyMemory(local, Irp->AssociatedIrp.SystemBuffer, n);

 

For authorized assessments against third-party drivers, replace this lab .sys with the vendor driver, enumerate IOCTLs (static reverse engineering, IRPMon-style tracing, or a custom user-mode probe), and keep the same harness discipline: one shared CTL_CODE, VM snapshots, kernel debugger.


Automating kernel cash triage


In user-mode fuzzing, AFL automatically collects and deduplicates crashes in the "out/crashes/" directory. However, a successful kernel exploit results in a BSOD, meaning WinAFL dies instantly and cannot log the crash natively.


To automate crash triage without manually attaching the WinDbg GUI to every single Blue Screen, configure your guest VM to generate Minidumps (sysdm.cpl -> Advanced -> Startup and Recovery -> Write debugging information: Small memory dump).


Windows Startup and Recovery dialog showing dump creation config

Once you have collected several .dmp files from "C:\Windows\Minidump\", you can batch-analyze them on your host machine using the command-line kernel debugger (kd.exe) to quickly extract the Bug Check codes and faulting driver frames:

$dumps = Get-ChildItem "C:\Windows\Minidump\*.dmp"
# Runs the debugger, executes !analyze -v, auto quits
foreach ($dump in $dumps) {
    Write-Host "Analyzing $($dump.Name)..." -ForegroundColor Green
    & "C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\kd.exe" -z $dump.FullName -c "!analyze -v; q" | Select-String -Pattern "BUGCHECK_CODE|FAULTING_MODULE"
}

This output provides a deduplicated summary of which inputs caused a "DRIVER_OVERRAN_STACK_BUFFER" versus an unhandled exception, matching the behavior of tools like afl-collect but designed strictly for ring 0 memory dumps.


What this lab does not prove

 

  • Finding crashes in a correctly bounded handler. Coverage without a bug still has value for plumbing checks, but do not promise buffer overflows from safe copies.

  • Bypass of Driver Signature Enforcement or PatchGuard. Test signing is a lab switch, not an exploit step.

  • That WinAFL alone is a kernel fuzzer. It is a user-mode coverage engine driving IOCTLs.

 

Conclusion

 

Driver fuzzing pays off when three things line up: a real IOCTL surface, a harness that speaks the same control codes, and a crash oracle you can triage (VM + WinDbg). Start with a WDM lab driver that contains an intentional length bug, prove the harness with fixed sizes, then let WinAFL mutate inputs. Once that loop is boringly reliable, point the same pattern at real targets under a written rules of engagement.

 

References

 

 

 

Register for instructor-led online courses today! https://www.darkrelay.com/courses

 

Check out our self-paced learning paths! https://www.darkrelay.com/learning-paths

 

Explore our bundled Pricing & Plans for cost-effective options! Buy a course subscription to learn more—hands-on labs and expert-led training included. https://www.darkrelay.com/plans-pricing

 

Contact us for custom pentesting needs at: info@darkrelay.com or WhatsApp.

 

Comments


bottom of page