DEV Community

MR storm
MR storm

Posted on

Spoofing WiFi MAC Addresses with a Windows Kernel Driver

1. What a MAC address is

A MAC address is six bytes. Convention writes them as hex pairs separated by colons:

A4:5E:60:C3:1B:07
Enter fullscreen mode Exit fullscreen mode

The first three bytes are the OUI — the Organizationally Unique Identifier — assigned by the IEEE to the manufacturer. The last three bytes are assigned by the manufacturer to the specific unit.

The OS uses the MAC address to:

  • Filter incoming frames (the NIC drops frames not addressed to it)
  • Identify itself on the local network segment
  • Report to routers, switches, and access points
  • Provide a stable identifier for DHCP lease assignment

Spoofing doesn't modify the hardware. It intercepts the OS at some layer between the NIC driver and the network stack and substitutes a different value.


2. Why userspace can't reliably set it

The registry approach

Windows network adapter drivers read the MAC address from a registry value called NetworkAddress during initialization. Setting that value and restarting the adapter works for some drivers.

It fails for others because:

  • Some drivers ignore NetworkAddress entirely
  • Some read it only at boot, not on adapter restart
  • Some read it but report the physical address to the network stack regardless
  • Some validate the value against a format whitelist
  • Some cache the physical address elsewhere and prefer the cache

From userspace there's no way to know whether the write succeeded, because the adapter can report one address to getmac while using a different one on the wire.

The driver IOCTL approach

Some drivers expose a proprietary IOCTL that changes the MAC. Intel PROSet and Realtek's utility use this. Most drivers don't support it, or support it only through undocumented interfaces, or require the manufacturer's signing certificate to call.

The NDIS OID approach

NDIS defines OID_802_3_CURRENT_ADDRESS for setting the current MAC. Userspace can send OIDs via DeviceIoControl, but on modern Windows the write permission for this OID is restricted to kernel-mode callers. Userspace can query the OID but not set it.

The remaining option

A kernel driver that sits between userspace and the NIC driver and performs the registry write from kernel mode, where the access checks don't apply.


3. The three driver models

WDM — Windows Driver Model

The base model. You create a DRIVER_OBJECT, register dispatch routines for IRP major functions (IRP_MJ_CREATE, IRP_MJ_DEVICE_CONTROL, etc.), create a device object with IoCreateDevice, and handle raw IRPs.

You manage the device object lifecycle, reference counting, I/O queueing, and cleanup manually.

KMDF — Kernel-Mode Driver Framework

A framework layered on WDM. You work with WDFDEVICE, WDFQUEUE, and event callbacks instead of raw IRPs. The framework handles reference counting, PnP, power management, I/O queueing, and cleanup on unload.

NDIS — Network Driver Interface Specification

For network drivers specifically. An NDIS filter driver registers with the NDIS stack, sits between protocol drivers (TCP/IP) and miniport drivers (the NIC), and can intercept every OID and every packet.

Filter drivers have strict requirements around IRQL, packet lifetime, and reentrancy.


4. KMDF implementation

Device extension

Every driver instance has a device extension — a structure attached to the device object holding per-instance state.

typedef struct _DEVICE_EXTENSION {
    PDEVICE_OBJECT DeviceObject;
    UNICODE_STRING DeviceName;
    UNICODE_STRING SymbolicLink;
    UCHAR TargetMAC[6];
    BOOLEAN IsActive;
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;
Enter fullscreen mode Exit fullscreen mode

TargetMAC holds the value set by the last IOCTL. IsActive flags whether the last set succeeded.

IOCTL codes

#define IOCTL_SET_MAC_ADDRESS CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_GET_MAC_ADDRESS CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)
Enter fullscreen mode Exit fullscreen mode

CTL_CODE packs four fields into a 32-bit value:

  • Device type — FILE_DEVICE_UNKNOWN (0x22). A generic device class.
  • Function code — 0x800 and 0x801. Values >= 0x800 avoid Microsoft-reserved codes.
  • Method — METHOD_BUFFERED. The I/O manager copies user buffers into kernel memory before invoking the driver. Irp->AssociatedIrp.SystemBuffer points at the kernel copy.
  • Access — FILE_ANY_ACCESS. Any user who can open the device can send the IOCTL.

Driver entry

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    WDF_DRIVER_CONFIG config;
    WDF_DRIVER_CONFIG_INIT(&config, WiFiMacSpoofEvtDeviceAdd);
    return WdfDriverCreate(DriverObject, RegistryPath, WDF_NO_OBJECT_ATTRIBUTES, &config, WDF_NO_HANDLE);
}
Enter fullscreen mode Exit fullscreen mode

WdfDriverCreate registers WiFiMacSpoofEvtDeviceAdd as the device-add callback. The framework calls it when it's ready for the driver to create its device object.

Device add

NTSTATUS WiFiMacSpoofEvtDeviceAdd(WDFDRIVER Driver, PWDFDEVICE_INIT DeviceInit) {
    WdfDeviceInitSetDeviceType(DeviceInit, FILE_DEVICE_UNKNOWN);
    WdfDeviceInitSetExclusive(DeviceInit, FALSE);
    WdfDeviceInitSetIoType(DeviceInit, WdfDeviceIoBuffered);

    WDF_OBJECT_ATTRIBUTES attributes;
    WDF_OBJECT_ATTRIBUTES_INIT(&attributes);
    attributes.DeviceObjectExtensionType = sizeof(DEVICE_EXTENSION);

    WDFDEVICE device;
    NTSTATUS status = WdfDeviceCreate(&DeviceInit, &attributes, &device);
    if (!NT_SUCCESS(status)) return status;

    PDEVICE_EXTENSION deviceExtension = WdfObjectGet_DEVICE_EXTENSION(device);
    RtlZeroMemory(deviceExtension, sizeof(DEVICE_EXTENSION));
    deviceExtension->DeviceObject = WdfDeviceWdmGetDeviceObject(device);

    WDF_IO_QUEUE_CONFIG queueConfig;
    WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queueConfig, WdfIoQueueDispatchParallel);
    queueConfig.EvtIoDeviceControl = WiFiMacSpoofEvtIoDeviceControl;
    queueConfig.EvtIoStop = WiFiMacSpoofEvtIoStop;

    status = WdfIoQueueCreate(device, &queueConfig, WDF_NO_OBJECT_ATTRIBUTES, NULL);
    if (!NT_SUCCESS(status)) return status;

    static const GUID WiFiMacSpoofGuid = {0x12345678, 0x1234, 0x1234, {0x12, 0x34, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC}};
    status = WdfDeviceCreateDeviceInterface(device, &WiFiMacSpoofGuid, NULL);
    if (!NT_SUCCESS(status)) return status;

    return STATUS_SUCCESS;
}
Enter fullscreen mode Exit fullscreen mode

WdfDeviceInitSetIoType(DeviceInit, WdfDeviceIoBuffered) sets the buffering mode for all I/O on this device.

WdfIoQueueDispatchParallel allows multiple IOCTLs to run concurrently. Sequential would also work; parallel is only needed for handlers that can overlap safely.

WdfDeviceCreateDeviceInterface registers a device interface GUID. Userspace finds the device by calling SetupDiGetClassDevs with that GUID, or by opening the symbolic link if one is created.

Device control handler

VOID WiFiMacSpoofEvtIoDeviceControl(
    _In_ WDFQUEUE Queue,
    _In_ WDFREQUEST Request,
    _In_ size_t OutputBufferLength,
    _In_ size_t InputBufferLength,
    _In_ ULONG IoControlCode
) {
    NTSTATUS status = STATUS_SUCCESS;
    WDFDEVICE device = WdfIoQueueGetDevice(Queue);
    PDEVICE_EXTENSION deviceExtension = WdfObjectGet_DEVICE_EXTENSION(device);
    PVOID inputBuffer = NULL;
    PVOID outputBuffer = NULL;
    size_t bytesReturned = 0;

    switch (IoControlCode) {
        case IOCTL_SET_MAC_ADDRESS:
            if (InputBufferLength < 6) { status = STATUS_BUFFER_TOO_SMALL; break; }
            status = WdfRequestRetrieveInputBuffer(Request, 6, &inputBuffer, NULL);
            if (!NT_SUCCESS(status)) break;
            RtlCopyMemory(deviceExtension->TargetMAC, inputBuffer, 6);
            status = ChangeWiFiMAC(deviceExtension->TargetMAC);
            deviceExtension->IsActive = NT_SUCCESS(status);
            break;

        case IOCTL_GET_MAC_ADDRESS:
            if (OutputBufferLength < 6) { status = STATUS_BUFFER_TOO_SMALL; break; }
            status = WdfRequestRetrieveOutputBuffer(Request, 6, &outputBuffer, NULL);
            if (!NT_SUCCESS(status)) break;
            RtlCopyMemory(outputBuffer, deviceExtension->TargetMAC, 6);
            bytesReturned = 6;
            break;

        default:
            status = STATUS_INVALID_DEVICE_REQUEST;
            break;
    }

    WdfRequestCompleteWithInformation(Request, status, bytesReturned);
}
Enter fullscreen mode Exit fullscreen mode

WdfRequestRetrieveInputBuffer returns a pointer to the kernel copy of the user's input. WdfRequestRetrieveOutputBuffer returns a pointer to the kernel buffer that will be copied back to the user's output buffer after the handler returns.


5. WDM implementation

Driver entry

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    UNICODE_STRING deviceName;
    RtlInitUnicodeString(&deviceName, DEVICE_NAME);

    PDEVICE_OBJECT deviceObject = NULL;
    NTSTATUS status = IoCreateDevice(
        DriverObject,
        sizeof(DEVICE_EXTENSION),
        &deviceName,
        FILE_DEVICE_UNKNOWN,
        FILE_DEVICE_SECURE_OPEN,
        FALSE,
        &deviceObject
    );
    if (!NT_SUCCESS(status)) return status;

    PDEVICE_EXTENSION deviceExtension = (PDEVICE_EXTENSION)deviceObject->DeviceExtension;
    RtlZeroMemory(deviceExtension, sizeof(DEVICE_EXTENSION));
    deviceExtension->DeviceObject = deviceObject;

    UNICODE_STRING symbolicLink;
    RtlInitUnicodeString(&symbolicLink, SYMBOLIC_LINK);
    status = IoCreateSymbolicLink(&symbolicLink, &deviceName);
    if (!NT_SUCCESS(status)) {
        IoDeleteDevice(deviceObject);
        return status;
    }

    DriverObject->MajorFunction[IRP_MJ_CREATE] = CreateClose;
    DriverObject->MajorFunction[IRP_MJ_CLOSE] = CreateClose;
    DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DeviceControl;
    DriverObject->DriverUnload = UnloadDriver;

    deviceObject->Flags |= DO_BUFFERED_IO;
    deviceObject->Flags &= ~DO_DEVICE_INITIALIZING;

    return STATUS_SUCCESS;
}
Enter fullscreen mode Exit fullscreen mode

IoCreateDevice allocates the device object and the device extension in one block. The third parameter is the device name (\Device\WiFiMacSpoof).

FILE_DEVICE_SECURE_OPEN applies the device's security descriptor to all opens. Without an INF file that sets a specific descriptor, this falls back to defaults.

IoCreateSymbolicLink creates the \DosDevices\WiFiMacSpoof link that userspace CreateFile(L"\\\\.\\WiFiMacSpoof", ...) resolves to.

DO_BUFFERED_IO on the device object makes the I/O manager use METHOD_BUFFERED semantics for all IOCTLs, mirroring what KMDF does via WdfDeviceInitSetIoType.

Clearing DO_DEVICE_INITIALIZING tells the I/O manager the device is ready to receive I/O.

Device control

NTSTATUS DeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
    PDEVICE_EXTENSION deviceExtension = (PDEVICE_EXTENSION)DeviceObject->DeviceExtension;
    PIO_STACK_LOCATION ioStack = IoGetCurrentIrpStackLocation(Irp);

    ULONG inputLength = ioStack->Parameters.DeviceIoControl.InputBufferLength;
    ULONG outputLength = ioStack->Parameters.DeviceIoControl.OutputBufferLength;
    ULONG_PTR information = 0;
    NTSTATUS status = STATUS_SUCCESS;

    switch (ioStack->Parameters.DeviceIoControl.IoControlCode) {
        case IOCTL_SET_MAC_ADDRESS:
            if (inputLength < 6) { status = STATUS_BUFFER_TOO_SMALL; break; }
            PVOID inputBuffer = Irp->AssociatedIrp.SystemBuffer;
            if (!inputBuffer) { status = STATUS_INVALID_PARAMETER; break; }
            RtlCopyMemory(deviceExtension->TargetMAC, inputBuffer, 6);
            status = ChangeWiFiMAC(deviceExtension->TargetMAC);
            deviceExtension->IsActive = NT_SUCCESS(status);
            break;

        case IOCTL_GET_MAC_ADDRESS:
            if (outputLength < 6) { status = STATUS_BUFFER_TOO_SMALL; break; }
            PVOID outputBuffer = Irp->AssociatedIrp.SystemBuffer;
            if (!outputBuffer) { status = STATUS_INVALID_PARAMETER; break; }
            RtlCopyMemory(outputBuffer, deviceExtension->TargetMAC, 6);
            information = 6;
            break;

        default:
            status = STATUS_INVALID_DEVICE_REQUEST;
            break;
    }

    Irp->IoStatus.Status = status;
    Irp->IoStatus.Information = information;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    return status;
}
Enter fullscreen mode Exit fullscreen mode

IoGetCurrentIrpStackLocation returns the current stack location, which holds the parameters for this request.

Irp->AssociatedIrp.SystemBuffer points at the kernel buffer for buffered I/O — the same buffer the KMDF version receives via WdfRequestRetrieveInputBuffer/WdfRequestRetrieveOutputBuffer.

IoCompleteRequest finalizes the IRP and returns it to the I/O manager.

Exception handling

__try {
    status = ZwOpenKey(&keyHandle, KEY_READ | KEY_WRITE, &attributes);
    // ...
}
__except(EXCEPTION_EXECUTE_HANDLER) {
    if (keyInfo) ExFreePoolWithTag(keyInfo, 'MAC1');
    if (keyHandle) ZwClose(keyHandle);
    return GetExceptionCode();
}
Enter fullscreen mode Exit fullscreen mode

Kernel APIs can raise structured exceptions for conditions like an invalid registry path or an access violation. An unhandled exception in kernel mode triggers a bugcheck (blue screen). __try/__except catches the exception, allows cleanup, and returns an error to the caller.

Variables referenced inside the __except block must be declared outside the __try block, because the compiler scopes them separately.


6. NDIS implementation

Filter callbacks

NDIS_STATUS FilterAttach(NDIS_HANDLE FilterModuleContext, NDIS_HANDLE NdisFilterHandle, PNDIS_FILTER_ATTACH_PARAMETERS AttachParameters);
VOID FilterDetach(NDIS_HANDLE FilterModuleContext);
NDIS_STATUS FilterOidRequest(NDIS_HANDLE FilterModuleContext, PNDIS_OID_REQUEST OidRequest);
VOID FilterOidRequestComplete(NDIS_HANDLE FilterModuleContext, PNDIS_OID_REQUEST OidRequest, NDIS_STATUS Status);
Enter fullscreen mode Exit fullscreen mode

FilterAttach is called when the filter binds to a miniport. FilterDetach is called when the binding is torn down. FilterOidRequest is called for every OID passing through the filter. FilterOidRequestComplete is called when an OID request completes.

Filter context

typedef struct _FILTER_CONTEXT {
    UCHAR SpoofedMAC[6];
    BOOLEAN IsSpoofing;
} FILTER_CONTEXT, *PFILTER_CONTEXT;
Enter fullscreen mode Exit fullscreen mode

One instance per miniport binding. Holds the current spoofed MAC and a flag indicating whether spoofing is active.

What completing the filter requires

  1. Call NdisFRegisterFilterDriver in DriverEntry, passing FilterAttach and FilterDetach
  2. In FilterAttach, query OID_802_3_CURRENT_ADDRESS from the miniport and store it in FilterModuleContext
  3. In FilterOidRequest, intercept OID_802_3_CURRENT_ADDRESS and OID_802_3_PERMANENT_ADDRESS requests. Complete them locally with the spoofed value instead of forwarding to the miniport
  4. Handle OID set requests by updating the stored spoofed value

The registry path works without any of this. The filter path, if completed, would allow spoofing at the NDIS layer, which is invisible to software querying the adapter through standard OID paths.


7. The registry manipulation

The ChangeWiFiMAC function is the core of the driver.

Opening the class key

RtlInitUnicodeString(&registryPath, 
    L"\\Registry\\Machine\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e972-e325-11ce-bfc1-08002be10318}");

InitializeObjectAttributes(&attributes, &registryPath, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, NULL);

status = ZwOpenKey(&keyHandle, KEY_READ | KEY_WRITE, &attributes);
Enter fullscreen mode Exit fullscreen mode

The GUID {4d36e972-e325-11ce-bfc1-08002be10318} is the class GUID for network adapters. Under this key, subkeys named 0000, 0001, 0002, etc. correspond to individual installed network devices.

OBJ_KERNEL_HANDLE tells the Object Manager to create the handle in the kernel handle table, which prevents it from being inherited by userspace processes.

Enumerating subkeys

ULONG index = 0;
ULONG resultLength = 0;

while (TRUE) {
    status = ZwEnumerateKey(keyHandle, index, KeyBasicInformation, NULL, 0, &resultLength);
    if (status == STATUS_NO_MORE_ENTRIES) break;

    if (status == STATUS_BUFFER_TOO_SMALL) {
        keyInfo = ExAllocatePoolWithTag(NonPagedPool, resultLength, 'MAC1');
        if (!keyInfo) { status = STATUS_INSUFFICIENT_RESOURCES; break; }

        status = ZwEnumerateKey(keyHandle, index, KeyBasicInformation, keyInfo, resultLength, &resultLength);
        if (NT_SUCCESS(status)) {
            // process subkey
        }

        ExFreePoolWithTag(keyInfo, 'MAC1');
    }
    index++;
}
Enter fullscreen mode Exit fullscreen mode

ZwEnumerateKey with a NULL buffer returns STATUS_BUFFER_TOO_SMALL and sets resultLength to the required size. The code then allocates a buffer of that size and calls again.

The two-pass pattern is standard for kernel registry enumeration.

Reading DriverDesc

UNICODE_STRING valueName;
RtlInitUnicodeString(&valueName, L"DriverDesc");

PKEY_VALUE_PARTIAL_INFORMATION valueInfo = NULL;
ULONG valueLength = 0;

status = ZwQueryValueKey(subkeyHandle, &valueName, KeyValuePartialInformation, NULL, 0, &valueLength);

if (status == STATUS_BUFFER_TOO_SMALL) {
    valueInfo = ExAllocatePoolWithTag(NonPagedPool, valueLength, 'MAC2');
    if (valueInfo) {
        status = ZwQueryValueKey(subkeyHandle, &valueName, KeyValuePartialInformation, valueInfo, valueLength, &valueLength);
        // valueInfo->Data points at the string
    }
}
Enter fullscreen mode Exit fullscreen mode

Same two-pass pattern. KeyValuePartialInformation returns only the value data, not the name. The Data field is a PWCHAR for REG_SZ values.

Matching the WiFi adapter

PWCHAR driverDesc = (PWCHAR)valueInfo->Data;
if (wcsstr(driverDesc, L"Wireless") || 
    wcsstr(driverDesc, L"Wi-Fi") || 
    wcsstr(driverDesc, L"WLAN") ||
    wcsstr(driverDesc, L"802.11")) {
    // this is the WiFi adapter
}
Enter fullscreen mode Exit fullscreen mode

String matching against DriverDesc. Alternative fields that could be checked:

  • *PhysicalMediaType — a REG_DWORD that specifies the media type
  • MediaType — another REG_DWORD
  • Characteristics — a REG_DWORD with bit flags including NCF_PHYSICAL and NCF_VIRTUAL

Writing the MAC

WCHAR macString[13];
WCHAR hexChars[] = L"0123456789ABCDEF";

for (int i = 0; i < 6; i++) {
    macString[i * 2] = hexChars[(NewMAC[i] >> 4) & 0x0F];
    macString[i * 2 + 1] = hexChars[NewMAC[i] & 0x0F];
}
macString[12] = L'\0';

UNICODE_STRING macStringUnicode;
RtlInitUnicodeString(&macStringUnicode, macString);

UNICODE_STRING macValueName;
RtlInitUnicodeString(&macValueName, L"NetworkAddress");

ZwSetValueKey(subkeyHandle, &macValueName, 0, REG_SZ, 
    macStringUnicode.Buffer, macStringUnicode.Length + sizeof(WCHAR));
Enter fullscreen mode Exit fullscreen mode

MAC addresses are stored as 12-character hex strings without separators. NetworkAddress is the primary value.

Some drivers also read:

  • AlternateMACAddress
  • CurrentMACAddress
  • MACAddress
  • *NetworkAddress

Different drivers check different values. Writing all of them costs nothing and covers more drivers.

Why it works

When the adapter driver initializes, it reads NetworkAddress from its class key. If the value is present and passes the driver's validation, the driver uses it instead of the physical address.

The read happens at different times depending on the driver:

  • Some read at system boot
  • Some read when the adapter is restarted
  • Some read when the device is re-enumerated

Restarting the adapter after setting the value triggers the re-read for most drivers. Some require a full reboot.

Why it sometimes fails

  • Drivers that validate the format and reject values that don't match expected patterns
  • Drivers that cache the physical address and prefer the cache
  • Drivers that query the miniport for the "real" address and ignore the registry
  • Drivers that refuse the change when connected to a network

For these, the registry write has no effect, and the only path is an NDIS filter that intercepts the OID path.


8. The IOCTL interface

Two IOCTLs, both METHOD_BUFFERED.

METHOD_BUFFERED behavior

When userspace sends an IOCTL with METHOD_BUFFERED:

  1. The I/O manager allocates a kernel buffer of size max(InputBufferLength, OutputBufferLength)
  2. It copies the user's input into the kernel buffer
  3. It calls the driver with Irp->AssociatedIrp.SystemBuffer pointing at the kernel buffer
  4. After the driver returns, it copies the kernel buffer back into the user's output buffer

The driver never touches a user-mode pointer. Buffer validation and copying are handled by the I/O manager.

Access flags

FILE_ANY_ACCESS allows any user to send the IOCTL. Two alternatives:

#define IOCTL_SET_MAC_ADDRESS CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_WRITE_ACCESS)
#define IOCTL_GET_MAC_ADDRESS CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_READ_ACCESS)
Enter fullscreen mode Exit fullscreen mode

With these, the I/O manager checks the caller's access against the device's security descriptor before invoking the driver. A caller without the required access gets STATUS_ACCESS_DENIED without the driver seeing the request.

For administrator-only access, either:

  • Use FILE_WRITE_ACCESS and set a security descriptor in DriverEntry that grants write access only to administrators
  • Set a security descriptor on the device object that restricts all access to administrators

Validation

Even with METHOD_BUFFERED, the driver should validate:

  • Buffer length (InputBufferLength >= 6)
  • MAC address content: not all zeros, not all 0xFF, and the multicast bit not set unless intended

9. Building and signing

Prerequisites

  • Visual Studio with the C++ workload
  • Windows Driver Kit (WDK) matching the SDK version
  • Test signing enabled on the target machine

Enabling test signing

bcdedit /set testsigning on
Enter fullscreen mode Exit fullscreen mode

Reboot after running this.

Signing with a test certificate

makecert -r -pe -ss PrivateCertStore -n "CN=TestCert" TestCert.cer
signtool sign /v /s PrivateCertStore /n TestCert /t http://timestamp.digicert.com wifi_mac_driver.sys
Enter fullscreen mode Exit fullscreen mode

Or with New-SelfSignedCertificate and Export-PfxCertificate.

Windows loads kernel drivers signed by a certificate in the trusted root store when test signing is on.

Building

With WDK installed, open the Developer Command Prompt for VS:

cl /c /W3 /WX- /sdl /O2 /Oi /GL /D WIN32 /D NDEBUG /D _WINDLL /D _MBCS /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /Zc:inline /Gd /TP /errorReport:prompt wifi_mac_driver.c /Fo:wifi_mac_driver.obj

link /OUT:wifi_mac_driver.sys /NOLOGO /MACHINE:X64 /SUBSYSTEM:NATIVE /DRIVER /DEF:wifi_mac_driver.def wifi_mac_driver.obj
Enter fullscreen mode Exit fullscreen mode

/SUBSYSTEM:NATIVE sets the PE subsystem to NATIVE, which is required for kernel drivers. /DRIVER configures the linker for a driver build. /DEF:wifi_mac_driver.def uses a module definition file to export DriverEntry.

Module definition file

LIBRARY wifi_mac_driver
EXPORTS
    DriverEntry
Enter fullscreen mode Exit fullscreen mode

Without this file, the linker doesn't know DriverEntry is the export point.


10. Installing and testing

Using pnputil

If an INF file is present:

pnputil /add-driver wifi_mac_driver.inf /install
Enter fullscreen mode Exit fullscreen mode

This uses the standard Windows driver installation path. The INF specifies the driver files, hardware IDs, and service configuration.

Using sc.exe

For a driver loaded as a service:

sc create WiFiMacSpoof type= kernel binPath= "C:\path\to\wifi_mac_driver.sys"
sc start WiFiMacSpoof
Enter fullscreen mode Exit fullscreen mode

The spaces after type= and binPath= are required.

To stop and remove:

sc stop WiFiMacSpoof
sc delete WiFiMacSpoof
Enter fullscreen mode Exit fullscreen mode

Testing

After sending the IOCTL, restart the adapter:

netsh interface set interface "Wi-Fi" admin=disable
netsh interface set interface "Wi-Fi" admin=enable
Enter fullscreen mode Exit fullscreen mode

Then verify:

getmac /v
Enter fullscreen mode Exit fullscreen mode

If the value matches the spoofed MAC, the write took effect. If it shows the original, the driver either ignored the registry value or hasn't re-read it.

Kernel debugging

Two-machine setup: a target running the driver and a host running WinDbg.

On the target:

bcdedit /debug on
bcdedit /dbgsettings net hostip:192.168.1.100 port:50000 key:1.2.3.4
Enter fullscreen mode Exit fullscreen mode

Reboot, then connect WinDbg from the host:

windbg -k net:port=50000,key=1.2.3.4
Enter fullscreen mode Exit fullscreen mode

For non-crashing bugs, DbgPrint statements in the driver and DbgView on the target work without the debugger setup.


11. Userspace interface

C

HANDLE h = CreateFile(
    L"\\\\.\\WiFiMacSpoof",
    GENERIC_READ | GENERIC_WRITE,
    0,
    NULL,
    OPEN_EXISTING,
    0,
    NULL
);

if (h == INVALID_HANDLE_VALUE) {
    DWORD err = GetLastError();
    // ERROR_FILE_NOT_FOUND — driver not loaded
    // ERROR_ACCESS_DENIED — not running as admin
    return;
}

UCHAR newMac[6] = {0xA4, 0x5E, 0x60, 0xC3, 0x1B, 0x07};
DWORD bytesReturned;
BOOL ok = DeviceIoControl(
    h, IOCTL_SET_MAC_ADDRESS,
    newMac, 6,
    NULL, 0,
    &bytesReturned, NULL
);

CloseHandle(h);
Enter fullscreen mode Exit fullscreen mode

Python

import win32file, win32con

h = win32file.CreateFile(
    r"\\.\WiFiMacSpoof",
    win32con.GENERIC_READ | win32con.GENERIC_WRITE,
    0, None,
    win32con.OPEN_EXISTING,
    0, None
)

mac = bytes([0xA4, 0x5E, 0x60, 0xC3, 0x1B, 0x07])
IOCTL_SET_MAC = 0x00222003  # decode from CTL_CODE if needed
win32file.DeviceIoControl(h, IOCTL_SET_MAC, mac, None)
Enter fullscreen mode Exit fullscreen mode

The IOCTL code depends on the four fields passed to CTL_CODE. For FILE_DEVICE_UNKNOWN (0x22), function 0x800, METHOD_BUFFERED (0), FILE_ANY_ACCESS (0):

(0x22 << 16) | (0x800 << 2) | (0 << 14) | 0 = 0x00222000
Enter fullscreen mode Exit fullscreen mode

For FILE_WRITE_ACCESS (1), the low bits become 0x3:

(0x22 << 16) | (0x800 << 2) | (0 << 14) | 3 = 0x00222003
Enter fullscreen mode Exit fullscreen mode

12. Technical limitations

Registry path. Drivers that validate the NetworkAddress format or cache the physical address elsewhere will ignore the write. The only path for these is an NDIS filter that intercepts the OID responses.

Adapter restart required. Setting NetworkAddress has no effect until the driver re-reads it. For most drivers that means restarting the adapter; for some it means a full reboot.

No content validation in KMDF and WDM paths. A userspace caller can set an all-zero or all-0xFF MAC. The adapter's behavior with an invalid MAC is driver-dependent. Validation must be added if the driver is exposed to untrusted callers.

FILE_ANY_ACCESS on both IOCTLs. Any user who can open the device can change the MAC. FILE_WRITE_ACCESS combined with a security descriptor on the device object restricts this to administrators.

No synchronization on TargetMAC. Concurrent IOCTLs can interleave reads and writes. A KSPIN_LOCK around the update serializes access.

swprintf is not a kernel API. It happens to compile in some WDK configurations but is a userspace CRT function. The manual hex conversion is the correct approach.

Paged pool allocation at PASSIVE_LEVEL. ExAllocatePoolWithTag(PagedPool, ...) requires PASSIVE_LEVEL. Parallel dispatch can invoke handlers at DISPATCH_LEVEL if the queue is configured that way. The current code runs at PASSIVE_LEVEL in practice because ZwOpenKey requires it, but separating the allocation from the registry call would introduce a latent bug.

Exception handling. ZwOpenKey, ZwEnumerateKey, and ZwQueryValueKey can raise structured exceptions. Without __try/__except, an unhandled exception in kernel mode triggers a bugcheck.

NDIS filter. The registry path in an NDIS driver works identically to the WDM and KMDF versions. The filter path — intercepting OID requests — requires registering the filter with NdisFRegisterFilterDriver, implementing FilterAttach/FilterDetach, and completing OID requests locally within FilterOidRequest.

Top comments (0)