Input/Output Control (IOCTL)

In this documentation, we will discuss IOCTL mechanism on Windows.

What is IOCTL?

I/O Control Codes (IOCTLs) are a mechanism used for communication between user-mode applications and drivers, or between drivers within a stack. I/O control codes are sent via IRPs (I/O Request Packets), as mentioned in the previous section. If you are not familiar with IRPs, click here to learn more about them.

In Windows, user-mode programs send IOCTL codes to drivers using the DeviceIoControl API. This API delivers the received IOCTL code to the driver through the IRP_MJ_DEVICE_CONTROL request. It’s important to note that this is not the only way the IOCTL mechanism is used. Advanced drivers can also generate requests using IRP_MJ_DEVICE_CONTROL or IRP_MJ_INTERNAL_DEVICE_CONTROL to send IOCTL requests to lower-level drivers in the stack.

IOCTL codes can be custom-defined by driver developers, but Windows also provides a set of predefined codes for common operations. These standard IOCTL codes are intended to standardize and simplify communication between hardware and software components. For example, some IOCTL codes are used for general tasks such as querying device properties or initiating specific operations. Developers can customize these codes or define new ones according to their specific needs, providing flexibility and extensibility.

Coding

Kernel Mode Driver

#include "main.h"

#define TAG 'beko'

#define IOCTL_MEM_ALLOCATE \
	CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)

#define IOCTL_MEM_READ \
	CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)

PVOID GlobalMemoryAddr = NULL;

NTSTATUS IoCreateClose(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
	UNREFERENCED_PARAMETER(DeviceObject);

	PIO_STACK_LOCATION Stack = IoGetCurrentIrpStackLocation(Irp);
	NTSTATUS status = STATUS_SUCCESS;

	switch (Stack->MajorFunction) {
	case IRP_MJ_CREATE:
		Irp->IoStatus.Status = STATUS_SUCCESS;
		break;

	case IRP_MJ_CLOSE:
		Irp->IoStatus.Status = STATUS_SUCCESS;
		break;

	default:
		status = STATUS_INVALID_DEVICE_REQUEST;
		break;
	}
	Irp->IoStatus.Information = 0;
	IoCompleteRequest(Irp, IO_NO_INCREMENT);

	return Irp->IoStatus.Status;
}

NTSTATUS IoControl(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
	UNREFERENCED_PARAMETER(DeviceObject);

	PIO_STACK_LOCATION Stack = IoGetCurrentIrpStackLocation(Irp);
	PVOID Data = Irp->AssociatedIrp.SystemBuffer;
	PCHAR UserBuffer = (PCHAR)Data;
	ULONG OutLength = Stack->Parameters.DeviceIoControl.OutputBufferLength;
	ULONG Length = Stack->Parameters.DeviceIoControl.InputBufferLength;

	ULONG Tag = TAG;

	switch (Stack->Parameters.DeviceIoControl.IoControlCode) {
	case IOCTL_MEM_ALLOCATE:
		if (NULL == UserBuffer || 0 == Length) {
			Irp->IoStatus.Status = STATUS_INVALID_PARAMETER;
			Irp->IoStatus.Information = 0;
			break;
		}
		DbgPrintEx(0, 0, "Data from UserLand program: %.*s", Length, UserBuffer);

		GlobalMemoryAddr = ExAllocatePool2(POOL_FLAG_NON_PAGED, Length, Tag);
		if (NULL == GlobalMemoryAddr) {
			Irp->IoStatus.Status = STATUS_INSUFFICIENT_RESOURCES;
			Irp->IoStatus.Information = 0;
			break;
		}
		RtlCopyMemory(GlobalMemoryAddr, UserBuffer, Length);

		Irp->IoStatus.Status = STATUS_SUCCESS;
		Irp->IoStatus.Information = Length;
		break;

	case IOCTL_MEM_READ:
		RtlCopyMemory(UserBuffer, GlobalMemoryAddr, OutLength);

		Irp->IoStatus.Status = STATUS_SUCCESS;
		Irp->IoStatus.Information = OutLength;
		break;
	default:
		Irp->IoStatus.Status = STATUS_INVALID_DEVICE_REQUEST;
		Irp->IoStatus.Information = 0;
		break;
	}
	IoCompleteRequest(Irp, IO_NO_INCREMENT);

	return Irp->IoStatus.Status;
}

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
	UNREFERENCED_PARAMETER(RegistryPath);

	UNICODE_STRING DeviceName = RTL_CONSTANT_STRING(L"\\Device\\MyDevice");
	UNICODE_STRING SymName = RTL_CONSTANT_STRING(L"\\??\\MyDevice");
	PDEVICE_OBJECT DeviceObject;
	NTSTATUS Status;

	Status = IoCreateDevice(DriverObject, 0, &DeviceName, FILE_DEVICE_UNKNOWN, 0, FALSE, &DeviceObject);
	if (!NT_SUCCESS(Status)) {
		DbgPrintEx(0, 0, "Failed to Create I/O Device!\n");
		return Status;
	}

	Status = IoCreateSymbolicLink(&SymName, &DeviceName);
	if (!NT_SUCCESS(Status)) {
		DbgPrintEx(0, 0, "Failed to Create Symbolic Link!\n");
		return Status;
	}
	DriverObject->MajorFunction[IRP_MJ_CREATE] = IoCreateClose;
	DriverObject->MajorFunction[IRP_MJ_CLOSE] = IoCreateClose;
	DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = IoControl;
	DriverObject->DriverUnload = UnloadDriver;

	return STATUS_SUCCESS;
}

NTSTATUS UnloadDriver(PDRIVER_OBJECT DriverObject) {
	UNICODE_STRING SymName = RTL_CONSTANT_STRING(L"\\??\\MyDevice");
	DbgPrintEx(0, 0, "Unloading the Driver...\n");

	IoDeleteSymbolicLink(&SymName);
	IoDeleteDevice(DriverObject->DeviceObject);

	return STATUS_SUCCESS;
}

If you are familiar with the previous IRP documentation, you will already understand that the code is almost identical. Let’s take a closer look:

#define IOCTL_MEM_ALLOCATE \
	CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)

#define IOCTL_MEM_READ \
	CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)

The CTL_CODE macro is used to define IOCTL codes, and it creates an IOCTL code from various parameters:

  • DeviceType – Specifies the type of device. This can be FILE_DEVICE_UNKNOWN, FILE_DEVICE_DISK, FILE_DEVICE_KEYBOARD, and others. In our case, we use FILE_DEVICE_UNKNOWN to indicate that we’re not targeting a specific hardware type, but rather a generic device type.

  • FunctionCode – Specifies a unique code for the IOCTL operation. This is a number that identifies the operation and is typically defined by the application or driver developer. For example, it could be values like 0x800 or 0x801.

  • Method – Defines how the IOCTL operation transfers data. Possible values include METHOD_BUFFERED, METHOD_IN_DIRECT, METHOD_OUT_DIRECT, and METHOD_NEITHER.

  • Access – Specifies the access permissions required for the IOCTL operation. Examples include FILE_ANY_ACCESS, FILE_SHARE_READ, and FILE_SHARE_WRITE.

UNICODE_STRING DeviceName = RTL_CONSTANT_STRING(L"\\Device\\MyDevice");
UNICODE_STRING SymName = RTL_CONSTANT_STRING(L"\\??\\MyDevice");
PDEVICE_OBJECT DeviceObject;
NTSTATUS Status;

These store the driver’s device name and symbolic name, respectively, which we will use to identify and access the driver.

Status = IoCreateDevice(DriverObject, 0, &DeviceName, FILE_DEVICE_UNKNOWN, 0, FALSE, &DeviceObject);
if (!NT_SUCCESS(Status)) {
    DbgPrintEx(0, 0, "Failed to Create I/O Device!\n");
    return Status;
}

Here, we call IoCreateDevice to create a device object for our driver. This object is stored in the DeviceObject variable.

Status = IoCreateSymbolicLink(&SymName, &DeviceName);
if (!NT_SUCCESS(Status)) {
    DbgPrintEx(0, 0, "Failed to Create Symbolic Link!\n");
    return Status;
}

Next, we create a symbolic link using IoCreateSymbolicLink. This allows a user-mode program to access the driver via its symbolic name.

We then set up the major function callbacks:

DriverObject->MajorFunction[IRP_MJ_CREATE] = IoCreateClose;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = IoCreateClose;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = IoControl;
DriverObject->DriverUnload = UnloadDriver;

Now we can take a look at IoControl:

PIO_STACK_LOCATION Stack = IoGetCurrentIrpStackLocation(Irp);
PVOID Data = Irp->AssociatedIrp.SystemBuffer;
PCHAR UserBuffer = (PCHAR)Data;
ULONG OutLength = Stack->Parameters.DeviceIoControl.OutputBufferLength;
ULONG Length = Stack->Parameters.DeviceIoControl.InputBufferLength;
ULONG Tag = TAG;
  • We get the current IRP stack location using IoGetCurrentIrpStackLocation.

  • We retrieve data sent from user-mode via Irp->AssociatedIrp.SystemBuffer.

  • We extract the input and output buffer sizes from the DeviceIoControl parameters.

  • We define a memory allocation TAG to identify our memory block in pool tracking.

switch (Stack->Parameters.DeviceIoControl.IoControlCode) {
    case IOCTL_MEM_ALLOCATE:
        if (NULL == UserBuffer || 0 == Length) {
            Irp->IoStatus.Status = STATUS_INVALID_PARAMETER;
            Irp->IoStatus.Information = 0;
            break;
        }
        GlobalMemoryAddr = ExAllocatePool2(POOL_FLAG_NON_PAGED, Length, Tag);
        if (NULL == GlobalMemoryAddr) {
            Irp->IoStatus.Status = STATUS_INSUFFICIENT_RESOURCES;
            Irp->IoStatus.Information = 0;
            break;
        }
        RtlCopyMemory(GlobalMemoryAddr, UserBuffer, Length);
        Irp->IoStatus.Status = STATUS_SUCCESS;
        Irp->IoStatus.Information = Length;
        break;

    case IOCTL_MEM_READ:
        RtlCopyMemory(UserBuffer, GlobalMemoryAddr, OutLength);
        Irp->IoStatus.Status = STATUS_SUCCESS;
        Irp->IoStatus.Information = OutLength;
        break;

    default:
        Irp->IoStatus.Status = STATUS_INVALID_DEVICE_REQUEST;
        Irp->IoStatus.Information = 0;
        break;
}
IoCompleteRequest(Irp, IO_NO_INCREMENT);

We check IoControlCode to determine the requested operation.

  • IOCTL_MEM_ALLOCATE

    • Validate input parameters.
    • Allocate non-paged kernel memory using ExAllocatePool2.
    • Copy user-mode data into kernel memory using RtlCopyMemory.

  • IOCTL_MEM_READ

    • Copy data from the allocated kernel buffer back to user-mode.

  • Default

    • Return an error for unsupported IOCTL codes.

User Mode Program

#include <stdio.h>
#include <Windows.h>

#define DEVICE_NAME L"\\\\.\\MyDevice"

#define IOCTL_MEM_ALLOCATE \
    CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)

#define IOCTL_MEM_READ \
    CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)

int main(int argc, char* argv[]) {
	HANDLE HandleDevice = NULL;
	CHAR InBuffer[] = "Piyanis bana biraaak";
	CHAR OutBuffer[sizeof(InBuffer)] = { 0 };
	DWORD InputBytesReturned = 0;
	DWORD OutputBytesReturned = 0;
	BOOL Result = 0;

	HandleDevice = CreateFile(DEVICE_NAME, GENERIC_WRITE | GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
	if (INVALID_HANDLE_VALUE == HandleDevice) {
		printf("Failed to connect Driver! Error Code: 0x%lx\n", GetLastError());
		return -1;
	}

	Result = DeviceIoControl(HandleDevice, IOCTL_MEM_ALLOCATE, InBuffer, sizeof(InBuffer), NULL, 0, &InputBytesReturned, NULL);
	if (!Result) {
		printf("Failed to Allocate Memory! Error Code: 0x%lx\n", GetLastError());
		return -1;
	}

	Result = DeviceIoControl(HandleDevice, IOCTL_MEM_READ, NULL, 0, OutBuffer, sizeof(OutBuffer), &OutputBytesReturned, 0);
	if (!Result) {
		printf("Failed to get Data!\n");
		return -1;
	}
	printf("Output Buffer: %s\n", OutBuffer);
	
	return 0;
}

Let’s see the code:

#include <stdio.h>
#include <Windows.h>

#define DEVICE_NAME L"\\\\.\\MyDevice"

#define IOCTL_MEM_ALLOCATE \
    CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)

#define IOCTL_MEM_READ \
    CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)

We define the device name and the same IOCTL codes that the driver understands.

HANDLE HandleDevice = CreateFile(
    DEVICE_NAME,
    GENERIC_WRITE | GENERIC_READ,
    0, NULL, OPEN_EXISTING,
    FILE_ATTRIBUTE_NORMAL, NULL
);
if (INVALID_HANDLE_VALUE == HandleDevice) {
    printf("Failed to connect Driver! Error Code: 0x%lx\n", GetLastError());
    return -1;
}

Here, we connect to the driver using CreateFile.

CHAR InBuffer[] = "Piyanis bana biraaak";
CHAR OutBuffer[sizeof(InBuffer)] = { 0 };
DWORD InputBytesReturned = 0;
DWORD OutputBytesReturned = 0;

BOOL Result = DeviceIoControl(
    HandleDevice,
    IOCTL_MEM_ALLOCATE,
    InBuffer, sizeof(InBuffer),
    NULL, 0,
    &InputBytesReturned, NULL
);

We Send IOCTL_MEM_ALLOCATE to the driver with our input data. If the call fails, print the error and exit.

Result = DeviceIoControl(
    HandleDevice,
    IOCTL_MEM_READ,
    NULL, 0,
    OutBuffer, sizeof(OutBuffer),
    &OutputBytesReturned, 0
);
if (!Result) {
    printf("Failed to get Data!\n");
    return -1;
}
printf("Output Buffer: %s\n", OutBuffer);

Then we send anohter IOCTL Code, IOCTL_MEM_READ to retrieve the data from kernel memory. This time, no input buffer is provided (NULL, 0). The driver writes the result to OutBuffer, which we print to the console.

Running the Project

As you can see, when we run our program, we can see that Windbg prints the data sent by the user-mode program to the screen, and then prints the data sent by the driver to the user-mode program to the screen.

Conclusion

In this documentation, we have explored the complete process of implementing a simple Windows kernel-mode driver that communicates with a user-mode application using IOCTL (Input/Output Control) codes.

We demonstrated how to define IOCTL codes using the CTL_CODE macro, create device and symbolic link objects in the kernel, and handle IRP requests in the driver. Specifically, we implemented memory allocation and memory reading operations in kernel space and exposed them to user-mode applications.

References

Last updated on