Malware Development — Writing A Simple Echo Kernel Driver

Pygrum
6 min readAug 28, 2023

--

This project is derived from this post describing how to communicate with kernel drivers from a user land program. I modify the existing code, and explain the changes made.

Background

Interacting with a kernel driver directly from user land can be done via a device utilizing that driver. I/O Request Packets (IRPs) are used for these communications. They work similarly to network packets, as both requests and responses are transmitted between parties (where the driver is like the server, and the programs, clients). Device control IRPs are given a control code (known as an IOCTL), and based on this code, a driver may perform different operations on the related device.

We can use IOCTLs in our favor when developing malware. With kernel level access, we’re given wider capabilities than before. Thus, by defining our own IOCTLs and building handling logic into our driver, we can perform operations on the OS that we wouldn’t be able to with user or admin level access. In this post, we write a kernel driver that echoes back messages received from our user mode program.

Requirements

Driver Entry

The driver entry routine is the entry point to our driver. It is used for initializing data and objects that are going to be used by the program. In our case, we need to define:

UNICODE_STRING DeviceName = RTL_CONSTANT_STRING(L"\\Device\\Decoy"); // \Device path stores actual devices
UNICODE_STRING DeviceSymlinkName = RTL_CONSTANT_STRING(L"\\??\\DecoyLnk"); // NT \??\ path stores device aliases aka symlinks

DRIVER_UNLOAD DriverUnload;
DRIVER_DISPATCH MajorFunctions;
DRIVER_DISPATCH HandleIOCTL;

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

NTSTATUS status = 0;

DriverObject->DriverUnload = DriverUnload;

// Triggered when opening / closing symlink to the driver device
DriverObject->MajorFunction[IRP_MJ_CREATE] = MajorFunctions;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = MajorFunctions;

DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = HandleIOCTL;

The DriverUnload function is called before system unloads the driver to perform any necessary operations first. In our case, we’d need to destroy our device and its symlink.

The MajorFunction property of our DriverObject is an array that contains the driver dispatch routines for each major function code — in other words, the functions assigned to each function code are responsible for satisfying that specific I/O request type. In this case, the MajorFunctions dispatch routine handles the opening and closing of a handle to an associated device — codes IRP_MJ_CREATE and IRP_MJ_CLOSE , respectively.

The IRP_MJ_DEVICE_CONTROL handler is used for handling device-specific instructions. These include our custom I/O control code:

#define IOCTL_ECHO CTL_CODE(FILE_DEVICE_UNKNOWN, 0x1000, METHOD_BUFFERED, FILE_ANY_ACCESS)

Now we have our own ‘echo’ code. Once instructed, the driver will return a message we send to it using this IOCTL.


// create device, assigning it to the driver with the last parameter (pointer to device objects, array)
status = IoCreateDevice(DriverObject, 0, &DeviceName, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE, &DriverObject->DeviceObject);
if (!NT_SUCCESS(status))
DbgPrint("Could not create device %wZ", &DeviceName);
else
DbgPrint("Device %wZ created", &DeviceName);

status = IoCreateSymbolicLink(&DeviceSymlinkName, &DeviceName);
if (!NT_SUCCESS(status))
DbgPrint("Could not create symbolic link %wZ", DeviceSymlinkName);
else
DbgPrint("Symbolic link %wZ created", DeviceSymlinkName);

return status;
}

Here is where we create our device and its symbolic link. A symbolic link is the only way we can access our device from our win32 application. DriverObject->DeviceObject is a pointer to device objects associated with our driver, which is updated after every call to IoCreateDevice .

Driver Unload

VOID DriverUnload(PDRIVER_OBJECT obj)
{
DbgPrint("Cleaning up devices for driver %wZ", obj->DriverName);
IoDeleteSymbolicLink(&DeviceSymlinkName);
IoDeleteDevice(obj->DeviceObject);
}

All that is needed is to delete our device and symbolic link. This should be completely safe since we created the device ourselves from within the driver, which means that no other programs depend on it.

Custom IOCTL Handler

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

PIO_STACK_LOCATION stackLoc = NULL;

stackLoc = IoGetCurrentIrpStackLocation(Irp);

The data associated with I/O requests to a driver is stored on a stack. To access these parameters, IoGetCurrentIrpStackLocation must be used.

 if (stackLoc)
{
// stackLoc->Parameters.DeviceIoControl.IoControlCode stores the control code we sent to the driver
switch (stackLoc->Parameters.DeviceIoControl.IoControlCode)
{
case IOCTL_ECHO:
Irp->IoStatus.Status = STATUS_SUCCESS;
// Must be set to size of buffer returned: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ns-wdm-_io_status_block#members
Irp->IoStatus.Information = 256;

char echoResponse[256];
// possible buffer overflow
sprintf(echoResponse, "echo from kernel: %s", (PCHAR)Irp->AssociatedIrp.SystemBuffer);

// possible buffer overflow
RtlCopyMemory(Irp->AssociatedIrp.SystemBuffer, echoResponse, 256);
IoCompleteRequest(Irp, IO_NO_INCREMENT);
break;
default:
break;
}
}
return STATUS_SUCCESS;
}

We overwrite certain fields in the IRP struct with data we want to transmit back to the ‘client’. We copy our response message into the IRPs associated buffer, which is used for both input and output, and complete the request with IoCompleteRequest .

It’s important to note that since we receive a buffer from another program to return our response in, we must do bound checks to ensure that our received data does not exceed the buffer that we put it in. We can make this program safer by checking the associated buffer size for our return data:

   char echoResponse[256];
const char* fmt = "echo from kernel: %s";
if (strlen(fmt) + strlen(Irp->AssociatedIrp.SystemBuffer) > sizeof(echoResponse))
return STATUS_BUFFER_TOO_SMALL;

sprintf(echoResponse, fmt, (PCHAR)Irp->AssociatedIrp.SystemBuffer);

if (stackLoc->Parameters.DeviceIoControl.OutputBufferLength < strlen(echoResponse) + 1)
return STATUS_BUFFER_TOO_SMALL;

RtlCopyMemory(Irp->AssociatedIrp.SystemBuffer, echoResponse, strlen(echoResponse) + 1);

// Set to size of buffer returned: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ns-wdm-_io_status_block#members
Irp->IoStatus.Information = strlen(echoResponse) + 1;
IoCompleteRequest(Irp, IO_NO_INCREMENT);

…not the cleanest solution, but it works.

Major Function Handler

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

PIO_STACK_LOCATION stackLoc = NULL;

// Must be called to get any parameters for the irp request: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-iogetcurrentirpstacklocation#remarks
stackLoc = IoGetCurrentIrpStackLocation(Irp);
switch (stackLoc->MajorFunction)
{
case IRP_MJ_CREATE:
DbgPrint("Handle to symlink %wZ opened", DeviceSymlinkName);
break;
case IRP_MJ_CLOSE:
DbgPrint("Handle to symlink %wZ closed", DeviceSymlinkName);
break;
default:
break;
}
Irp->IoStatus.Information = 0;
Irp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest(Irp, IO_NO_INCREMENT);

return STATUS_SUCCESS;
}

Here, we don’t actually need to do anything significant when a handle to our device is opened or closed — only acknowledge the event, and complete the request. We use similar logic as our custom IOCTL handler, except we handle the request based on the Major Function Code rather than our device control codes, which are all under the major function code IRP_MJ_DEVICE_CONTROL .

User mode program

Most of the work is done by the driver. Our user mode application only handles sending control codes to and receiving responses from the driver once we acquire a handle to our device. Because of this, in a larger scale project, we’d typically define our IOCTLs in a separate header that’s shared by both the driver program and user program. Because of how simple our project is, we’ll just redefine it in both.

int main()
{
HANDLE DeviceHandle = NULL;
DeviceHandle = CreateFileW(
L"\\??\\DecoyLnk",
GENERIC_READ,
0,
0,
OPEN_EXISTING,
FILE_ATTRIBUTE_SYSTEM,
0);
if (DeviceHandle == INVALID_HANDLE_VALUE)
{
printf("could not get handle for device: CreateFileW failed with code %d\n", GetLastError());
return 1;
}
BOOL success = FALSE;

CHAR message[256] = "Hello World!";
CHAR response[256];
DWORD n_bytes_returned;

success = DeviceIoControl(
DeviceHandle,
IOCTL_ECHO,
message,
sizeof(message),
response,
sizeof(response),
&n_bytes_returned,
(LPOVERLAPPED)NULL);
if (!success)
{
printf("could not transmit irp: DeviceIoControl failed with code %d\n", GetLastError());
return 1;
}
printf("%s\n", response);
return 0;
}

CreateFileW is the most common way of acquiring a handle to a file object. We need at least read permissions to acquire a handle to the device and send IOCTLs. We pass the two buffers that we use to send to and receive data from the kernel to DeviceIoControl. The IRP will use the larger of the two to set the size of the shared buffer.

We handle possible errors from calling the WinAPI functions in our code by checking return values based on Microsoft’s documentation.

Conclusion

This code can be used in a VS kernel driver project and deployed to a virtual machine for debugging. For more information about writing and debugging drivers, read this article.

If we were to implement our own kernel calling convention as part of malware, there would be things that we should be more careful about. Best practice would be to have a predefined schema for communicating with our driver — that way, both components during communication are always aware of the format of the commands and data that they receive. For example — a driver’s response can have a known maximum size. That way, the user program knows what size to set its receiving buffer to.

I’ll go into designing and implementing custom protocols in another post.

--

--