Vulnerable Drivers – Catastrophic Security Risks

What is a kernel mode driver?

A kernel mode driver is a type of computer software that operates at the highest privilege level in an operating system. This means that it has direct access to the system’s hardware and can execute instructions without interference from other software programs running on the system.

Kernel mode drivers are typically used to control hardware devices, such as printers, network adapters, and disk drives. They are also used to provide system-level services, such as memory management and interrupt handling.

One of the main advantages of kernel mode drivers is their ability to execute instructions directly on the hardware. This allows them to perform low-level tasks that are critical to the operation of the system, such as reading and writing to memory or performing I/O operations.

Another advantage of kernel mode drivers is that they are isolated from other software programs running on the system. This isolation helps to prevent other programs from interfering with the driver’s operation and ensures that the driver has access to the necessary resources to perform its tasks.

In contrast to user mode drivers, which run at a lower privilege level and are subject to the same limitations as other programs, kernel mode drivers have unrestricted access to the system’s resources. This means that they can execute instructions that would be forbidden to user mode programs, such as directly accessing hardware registers or modifying memory locations.

Despite their powerful capabilities, kernel mode drivers must be carefully written and tested to ensure that they do not cause instability or security vulnerabilities in the system. Because they run at the highest privilege level, a bug or security flaw in a kernel mode driver can have serious consequences, such as crashing the system or allowing an attacker to gain unauthorized access.

To prevent these types of problems, kernel mode drivers are typically developed by experienced software engineers and are subject to strict quality assurance processes. Additionally, they must be digitally signed by the manufacturer to prove their authenticity and to allow the operating system to verify that they have not been tampered with.

Overall, kernel mode drivers are an essential component of modern operating systems, providing the low-level functionality needed to control hardware devices and perform critical system-level tasks.

What is an IOCTL?

An IOCTL (Input/Output Control) is a system call that allows a user program to communicate with a device driver. In the context of a driver, an IOCTL is a request made by an application to the driver to perform a specific operation, such as configuring a device or retrieving information about it. The driver processes the request and returns a response to the application. IOCTLs are used to provide a standardized way for applications to access the functionality of a device driver.

Execution of IOCTLs

IOCTLs are executed by the invoking thread, however, during the execution of the IOCTL handler, the thread will be in a privileged state.

More Information about IOCTLs

IOCTL Example Implementation using the KMDF (C++)
#include <ntddk.h>
#include <wdf.h>

#define IOCTL_EXAMPLE_CODE CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)

// Forward declarations
DRIVER_INITIALIZE DriverEntry;
EVT_WDF_DRIVER_DEVICE_ADD MyEvtDeviceAdd;
EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL MyEvtIoDeviceControl;

/*++

Routine Description:

    This is the entry point for the driver.

Arguments:

    DriverObject - pointer to the driver object
    RegistryPath - pointer to the driver's registry path

Return Value:

    NTSTATUS code that indicates the success or failure of the function

--*/
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath)
{
    WDF_DRIVER_CONFIG config;
    NTSTATUS status;

    // Initialize driver configuration to specify the entry point for the
    // driver's AddDevice routine
    WDF_DRIVER_CONFIG_INIT(&config, MyEvtDeviceAdd);

    // Create a WDF driver object with the driver configuration
    status = WdfDriverCreate(DriverObject, RegistryPath, WDF_NO_OBJECT_ATTRIBUTES, &config, WDF_NO_HANDLE);
    if (!NT_SUCCESS(status))
    {
        // Print an error message if the driver object could not be created
        KdPrint(("Error creating WDF driver object - 0x%X\n", status));
    }

    return status;
}

/*++

Routine Description:

    This routine is called by the framework when a device is added. It sets
    up the device's IO queue and any additional device parameters.

Arguments:

    Driver - a handle to the driver object
    DeviceInit - a pointer to a WDFDEVICE_INIT structure that contains
                 information about the device

Return Value:

    NTSTATUS code that indicates the success or failure of the function

--*/
NTSTATUS MyEvtDeviceAdd(_In_ WDFDRIVER Driver, _Inout_ PWDFDEVICE_INIT DeviceInit)
{
    WDF_IO_QUEUE_CONFIG ioQueueConfig;
    NTSTATUS status;

    UNREFERENCED_PARAMETER(Driver);

    // Initialize the IO queue configuration structure with default values
    // and the callback for device control requests
    WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&ioQueueConfig, WdfIoQueueDispatchSequential);
    ioQueueConfig.EvtIoDeviceControl = MyEvtIoDeviceControl;

    // Create the device's IO queue
    status = WdfIoQueueCreate(WDF_NO_HANDLE, &ioQueueConfig, WDF_NO_OBJECT_ATTRIBUTES, WDF_NO_HANDLE);
    if (!NT_SUCCESS(status))
    {
        // Print an error message if the IO queue could not be created
        KdPrint(("Error creating IO queue - 0x%X\n", status));
        return status;
    }

    // Set additional device parameters here, such as device attributes and callbacks

    // Create the device
    return WdfDeviceCreate(&DeviceInit, WDF_NO_OBJECT_ATTRIBUTES, WDF_NO_HANDLE);
}

/*++

Routine Description:

    This routine is called by the framework when a device control request
    is received. It processes the request and performs the requested
    operation, if possible.

Arguments:

    Queue - a handle to the queue that the request was received on
    Request - a handle to the request
    OutputBufferLength - the length of the output buffer, in bytes
    InputBufferLength - the length of the input buffer, in bytes
    IoControlCode - the IOCTL code of the request

Return Value:

    None

--*/
void MyEvtIoDeviceControl(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request, _In_ size_t OutputBufferLength, _In_ size_t InputBufferLength, _In_ ULONG IoControlCode)
{
    NTSTATUS status = STATUS_SUCCESS;
    WDFMEMORY inputMemory;
    WDFMEMORY outputMemory;

    UNREFERENCED_PARAMETER(Queue);
    UNREFERENCED_PARAMETER(OutputBufferLength);
    UNREFERENCED_PARAMETER(InputBufferLength);

    if (IoControlCode == IOCTL_EXAMPLE_CODE)
    {
        // Get the input and output buffer for the request
        status = WdfRequestRetrieveInputMemory(Request, &inputMemory);
        if (!NT_SUCCESS(status))
        {
            // Print an error message if the input memory could not be retrieved
            KdPrint(("Error retrieving input memory for request - 0x%X\n", status));
            WdfRequestComplete(Request, status);
            return;
        }
        
        status = WdfRequestRetrieveOutputMemory(Request, &outputMemory);
        if (!NT_SUCCESS(status))
        {
            // Print an error message if the output memory could not be retrieved
            KdPrint(("Error retrieving output memory for request - 0x%X\n", status));
            WdfRequestComplete(Request, status);
            return;
        }

        // Perform the requested operation here, using the input and output buffers
        // as necessary. For example, if the IOCTL is requesting device information,
        // the driver can copy the device information into the output buffer.
        
        // Complete the request with the specified status and size of the output buffer
        WdfRequestCompleteWithInformation(Request, status, WdfMemoryGetBufferSize(outputMemory));
    }
}

Why do hackers not create their own driver?

To publish a kernel mode driver for Windows, there are a wide range of hoops to jump through.

First, the driver must be built using the latest version of the Windows Driver Kit (WDK), which is currently WDK 10. The WDK provides the tools and resources needed to create, build, and test a driver. It also includes the Kernel-Mode Driver Framework (KMDF), which provides a set of libraries and frameworks that can help simplify the development process.

Second, the driver must be digitally signed with a valid certificate. This is a security measure that helps ensure that the driver comes from a trusted source and has not been tampered with. To get a valid certificate, you will need to obtain an EV Code Signing Certificate from a trusted certificate authority which often requires under-going an identity verification process.

Third, the driver must pass testing to ensure it is stable and does not cause any problems on the system. This includes running the Driver Verifier tool, which checks the driver for common problems and helps identify potential issues. Additionally, the driver must be tested on a variety of hardware and software configurations to ensure it is compatible with different systems.

Finally, once the driver has been built and tested, it must be submitted to the Windows Hardware Developer Center Dashboard for approval. This is a Microsoft-run website that allows developers to submit and manage their drivers. After the submission has been reviewed and approved, the driver will be published and available for users to download and install.

Overall, publishing a kernel mode driver for Windows involves meeting a number of technical requirements and undergoing a review process to ensure the driver is stable and secure.

Determining Vulnerability

IDA (Interactive Disassembler) is a powerful software program used by security researchers and developers to reverse engineer and analyze compiled code. It can be used to check the security of a driver’s IOCTLs (input/output control codes) by disassembling the driver’s code and examining its implementation of IOCTLs to identify potential vulnerabilities. The following steps can be used to check the security of a driver’s IOCTLs using IDA:

  1. Obtain the driver’s code and load it into IDA.
  2. Use IDA’s disassembly view to reverse engineer the driver’s code and identify its IOCTL handling functions.
  3. Use IDA’s graph view to visualize the flow of control within the IOCTL handling functions and better understand how they work.
  4. Use IDA’s debugging capabilities to run the driver’s code in a controlled environment and observe its behavior when handling IOCTLs.
  5. Use IDA’s various analysis tools, such as its string, cross-references, and functions views, to analyze the driver’s code and identify potential security vulnerabilities.
  6. Use IDA’s commenting and annotation features to document the findings of the security analysis and provide recommendations for addressing any identified vulnerabilities.

Overall, using IDA to check the security of a driver’s IOCTLs can help ensure that the driver is secure and free from vulnerabilities that could be exploited by attackers. By following the steps outlined above and being aware of common security vulnerabilities, security researchers and developers can use IDA to effectively identify and address potential security issues in a driver’s IOCTL implementation.

Common Vulnerabilities

If the IOCTL function is not properly validated, it can be exploited by a malicious actor to execute arbitrary code. This is typically done by passing a specially crafted argument to the IOCTL function that contains malicious code. This code is then executed at the kernel level, allowing the attacker to gain full control over the system.

Buffer Overflow

One common way to exploit a vulnerable IOCTL is to use a technique known as “buffer overflow.” This involves passing an overly large argument to the IOCTL function, which can cause the function to write data outside of the allocated memory buffer. This can allow the attacker to overwrite important data or even execute their own code.

Buffer Overflow Vulnerable IOCTL Handler Example (C++)
#include <ntddk.h>

#define IOCTL_EXAMPLE_BUFFER_OVERFLOW CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_NEITHER, FILE_ANY_ACCESS)

NTSTATUS ExampleIoctlHandler(IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp)
{
    PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
    ULONG ioControlCode = stack->Parameters.DeviceIoControl.IoControlCode;
    PVOID inputBuffer = stack->Parameters.DeviceIoControl.Type3InputBuffer;
    ULONG inputBufferLength = stack->Parameters.DeviceIoControl.InputBufferLength;

    if (ioControlCode == IOCTL_EXAMPLE_BUFFER_OVERFLOW)
    {
        // This IOCTL handler is vulnerable to a buffer overflow attack because it
        // does not properly validate the length of the input buffer before
        // copying the data into a fixed-size local buffer on the stack.
        // This can allow an attacker to overflow the local buffer and potentially
        // execute arbitrary code on the system.
        char localBuffer[1024];
        RtlCopyMemory(localBuffer, inputBuffer, inputBufferLength);
    }

    return STATUS_SUCCESS;
}
Buffer Overflow Secured IOCTL Handler Example (C++)
#include <ntddk.h>

#define IOCTL_EXAMPLE_BUFFER_OVERFLOW CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_NEITHER, FILE_ANY_ACCESS)

NTSTATUS ExampleIoctlHandler(IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp)
{
    PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
    ULONG ioControlCode = stack->Parameters.DeviceIoControl.IoControlCode;
    PVOID inputBuffer = stack->Parameters.DeviceIoControl.Type3InputBuffer;
    ULONG inputBufferLength = stack->Parameters.DeviceIoControl.InputBufferLength;

    if (ioControlCode == IOCTL_EXAMPLE_BUFFER_OVERFLOW)
    {
        // This IOCTL handler is now protected against buffer overflow attacks
        // because it dynamically allocates memory for the local buffer based
        // on the size of the input buffer.
        char *localBuffer = (char*)ExAllocatePoolWithTag(NonPagedPool, inputBufferLength, 'EXAM');
        if (localBuffer != NULL)
        {
            RtlCopyMemory(localBuffer, inputBuffer, inputBufferLength);
            ExFreePoolWithTag(localBuffer, 'EXAM');
        }
    }

    return STATUS_SUCCESS;
}

Use-after-free

Another way to exploit a vulnerable IOCTL is to use a technique known as “use-after-free.” This involves freeing up memory that is still being used by the IOCTL function, and then using the now-unused memory to execute malicious code.

Use-after-free Vulnerable IOCTL Handler Example (C++)
/*
This handler is vulnerable to a "use-after-free" attack because it frees the input and output buffers after setting the output buffer in the IRP, but the IRP may still be accessed and used by the caller after the handler returns. This can cause the caller to access freed memory, leading to undefined behavior and potentially allowing an attacker to execute arbitrary code.

To prevent this vulnerability, the input and output buffers should not be freed until after the IRP has been completed and is no longer in use. 
*/
NTSTATUS MyIoctlHandler(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    NTSTATUS status = STATUS_SUCCESS;
    PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
    ULONG ioctl = stack->Parameters.DeviceIoControl.IoControlCode;

    // Allocate memory for the input and output buffers.
    PVOID inputBuffer = ExAllocatePoolWithTag(NonPagedPool, stack->Parameters.DeviceIoControl.InputBufferLength, 'MyTag');
    PVOID outputBuffer = ExAllocatePoolWithTag(NonPagedPool, stack->Parameters.DeviceIoControl.OutputBufferLength, 'MyTag');

    // Copy the input buffer to the allocated memory.
    RtlCopyMemory(inputBuffer, Irp->AssociatedIrp.SystemBuffer, stack->Parameters.DeviceIoControl.InputBufferLength);

    switch (ioctl)
    {
        case IOCTL_MY_CODE:
            // Perform some operation on the input buffer and store the result in the output buffer.
            // ...

            // Free the input and output buffers.
            ExFreePoolWithTag(inputBuffer, 'MyTag');
            ExFreePoolWithTag(outputBuffer, 'MyTag');

            // Set the output buffer in the IRP.
            Irp->UserBuffer = outputBuffer;
            Irp->IoStatus.Information = stack->Parameters.DeviceIoControl.OutputBufferLength;
            break;

        default:
            status = STATUS_INVALID_DEVICE_REQUEST;
            break;
    }

    Irp->IoStatus.Status = status;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    return status;
}
Inadequate/Non-existant Access Checks

Another common vulnerability is the fact that some IOCTL handlers do not adequately verify that the application invoking the IOCTL has sufficient privileges. For example, we wouldn’t want to have an IOCTL that performs arbitrary reads/writes to memory without verifying that the address to read/write should be accessible by the invoking application.

Inadequate/Non-existant Access Checks Example (C++)
#include <ntddk.h>
#include <wdf.h>

#define IOCTL_MY_DEVICE_READ 0x800

NTSTATUS MyDeviceRead(IN WDFDEVICE Device, IN WDFREQUEST Request, IN size_t OutputBufferLength, OUT PVOID OutputBuffer, OUT size_t* pBytesReturned)
{
    UNREFERENCED_PARAMETER(Device);
    UNREFERENCED_PARAMETER(OutputBufferLength);

    // The requestor's access rights are not checked here.
    // This handler is vulnerable to attacks from malicious requestors.

    // Read data from device and write it to the output buffer.
    RtlCopyMemory(OutputBuffer, deviceData, deviceDataSize);
    *pBytesReturned = deviceDataSize;

    return STATUS_SUCCESS;
}

// This IOCTL handler is vulnerable because it does not check the requestor's
// access rights before allowing them to read data from the device.
NTSTATUS MyDeviceControl(IN WDFQUEUE Queue, IN WDFREQUEST Request, IN size_t OutputBufferLength, IN size_t InputBufferLength, IN ULONG IoControlCode)
{
    NTSTATUS status = STATUS_INVALID_DEVICE_REQUEST;

    UNREFERENCED_PARAMETER(Queue);
    UNREFERENCED_PARAMETER(OutputBufferLength);
    UNREFERENCED_PARAMETER(InputBufferLength);

    switch (IoControlCode)
    {
        case IOCTL_MY_DEVICE_READ:
        {
            // The requestor's access rights are not checked here.
            // This handler is vulnerable to attacks from malicious requestors.
            status = MyDeviceRead(WdfIoQueueGetDevice(Queue), Request, OutputBufferLength, OutputBuffer, &bytesReturned);
            break;
        }
        // Other IOCTL cases...
    }

    return status;
}

To prevent these types of attacks, device drivers should be carefully written and thoroughly tested to ensure that they properly validate and sanitize any arguments passed to their IOCTL functions. Additionally, the operating system should be configured to prevent the execution of unsigned code at the kernel level.

Effects

Unsigned shellcode is a type of code that is not signed with a digital signature, which is typically used to verify the authenticity and integrity of software. In the context of a vulnerable IOCTL handler, an attacker could potentially use unsigned shellcode to execute arbitrary code at the kernel level.

To do this, the attacker would first need to find a way to exploit the vulnerability in the IOCTL handler. This could involve crafting a malicious input that the IOCTL handler does not properly validate, allowing the attacker to gain control of the execution flow.

Once the attacker has gained control of the execution flow, they could use the IOCTL handler to allocate memory in the kernel space and write the unsigned shellcode to this memory. This would allow the attacker to execute the shellcode at the kernel level, giving them full access to the operating system and potentially allowing them to perform a wide range of malicious actions.

In order to prevent this type of attack, it is important for IOCTL handlers to be carefully designed and thoroughly tested to ensure that they are not vulnerable to exploitation. This typically involves implementing proper input validation and other security measures to protect against malicious inputs. Additionally, it is important to only use signed code, especially at the kernel level, to help ensure the authenticity and integrity of the code that is being executed.

Comments are closed.