Malware Development — Alternative KM-UM Communication

Pygrum
8 min readSep 9, 2023

--

In the last post, we went through how to create a simple kernel mode driver that echoes back a message it receives from a user mode program. This time around, some changes are made to the design of both programs.

If you recall, IO control codes, or IOCTLs, are what we used to implement our driver’s custom functionality. These codes are sent via devices to a driver, that causes the device to perform actions. We defined our own custom IOCTL that caused the driver to echo back a message we sent to it. From a normal kernel development perspective, this is all well and good. But when developing malware, the hardest part is finding a way to get our software operate with minimal risk of detection. To get closer to achieving this, a little background knowledge is required first.

Driver Signature Enforcement (Windows)

Driver Signature Enforcement (DSE) is a security feature built into newer versions of Windows (10/11+) that only allows the use of drivers that Microsoft has signed. If you try to install an unsigned driver in an environment with DSE enabled, you’ll get an error like so:

Windows cannot verify the digital signature for this file.

This makes it a challenge for malware developers to get kernel mode components to run, and has contributed to the decline in popularity of Windows rootkits. There have been ways people manage to do so anyway — one method is exploiting an already-signed vulnerable driver, and using it to map your driver manually into memory, or disable DSE.

The advantage manual mapping has over DSE disabling is that the variables that control whether DSE is enabled or not (g_CiOptions, g_CiEnabled) are protected by Windows Kernel Patch Protection (aka PatchGuard). Manually mapping the drivers does not involve modifying these variables.

More advanced threat actors have been known to steal signing keys to validate their drivers with. Which option is easiest? That’s up to you.

Avoiding Discovery

How can one lessen the chances of detection when designing a manually mapped driver? What’s wrong with IOCTLs?

Lets say the antivirus installed on the compromised machine regularly performed checks by iterating through connected devices, and validating the driver controlling these devices. Here is an example of what we can see in Device Manager:

Typically, manually mapped drivers are loaded into an executable buffer, and executed from their entry point. The nature of execution means they don’t appear on the list of registered drivers, and the parameters we got from our DriverEntry ( RegistryPath and DriverObject ) will not be valid. To communicate with IOCTLs, you’d have to create your own driver object and device object. As the driver is not really meant to be executing, devices that are created using it will not have valid linked drivers, unlike AMD Crash Defender’s in the image above. Although I personally am not sure exactly how this is done, but if antivirus could verify whether the module providing the driver object is valid / actually exists, our driver would be detected in that instant.

So if IOCTLs are off the table, what other options are there? In this post, we’ll demonstrate how to use named pipes for communication.

Architecture

Our development will be based on Microsoft’s article on building a multi-threaded pipe server, and writing a client for it.

Our user-mode component will act as the pipe server, and our kernel mode component will act as the client. I’m choosing this setup because of pure suspicion; in case defense software can somehow detect the creation of pipes from an invalid kernel mode thread.

Kernel Program

It may seem daunting at first, but I find putting down bullet points of things I need to implement in order helps. In this case, we have to:

  1. Open our pipe
  2. Write to our pipe
  3. Close our pipe

Named pipes are controlled using CreateFile (to retrieve a handle to the pipe), ReadFile (read from the pipe), WriteFile (write to the pipe) and CloseHandle (close the pipe). Because our kernel mode program is the client, we won’t be using ReadFile for this example, and we’ll need to use the kernel routines, namely ZwCreateFile, ZwWriteFile, and ZwClose.

Our DriverEntry puts these operations together in their own individual functions. Remember that since this driver is designed to be compatible with manual mapping, the parameters remain unused.

NTSTATUS
DriverEntry(
_In_ PDRIVER_OBJECT DriverObject,
_In_ PUNICODE_STRING RegistryPath
)
{
// Will never use or create driver object - This will be manually mapped
UNREFERENCED_PARAMETER(DriverObject);
UNREFERENCED_PARAMETER(RegistryPath);

OpenPipe();
WritePipeMessage("Hello from kernel");
ClosePipe();

return STATUS_SUCCESS;
}

Opening A Named Pipe Connection

#include <wdm.h>
#include <stdio.h>

static HANDLE hPipe = NULL;

VOID OpenPipe()
{
UNICODE_STRING usPipeName;
RtlInitUnicodeString(&usPipeName, L"\\Device\\NamedPipe\\MyPipe");

OBJECT_ATTRIBUTES ObjectAttributes;
InitializeObjectAttributes(&ObjectAttributes, &usPipeName, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, NULL);

IO_STATUS_BLOCK IoStatusBlock;
NTSTATUS ntStatus = ZwCreateFile(&hPipe, FILE_WRITE_DATA | SYNCHRONIZE, &ObjectAttributes, &IoStatusBlock, 0, FILE_ATTRIBUTE_NORMAL, 0, FILE_OPEN, FILE_SYNCHRONOUS_IO_NONALERT, NULL, 0);
if (!NT_SUCCESS(ntStatus))
{
DbgPrint("ZwCreateFile failed with status code %ld\n", ntStatus);
}
}

ObjectAttributes describe the object that we are going to be performing operations on. We pass the name and attributes to this structure before acquiring our object. Possible values for the third argument to InitializeObjectAttributes can be found here. We use the IoStatusBlock to receive final completion status information from ZwCreateFile. Best practice would be to check this struct for the specific result of the function call ( IoStatusBlock.Information ).

Writing to the Named Pipe

VOID WritePipeMessage(LPCSTR szMessage)
{
if (hPipe)
{
IO_STATUS_BLOCK IoStatusBlock;
ZwWriteFile(hPipe, 0, NULL, NULL, &IoStatusBlock, (PVOID)szMessage, (ULONG)strlen(szMessage) + 1, NULL, NULL);
}
}

Closing the Named Pipe

VOID ClosePipe()
{
if (hPipe)
{
ZwClose(hPipe);
hPipe = NULL;
}
}

Named Pipe Server

If you’ve ever written a web server or similar, writing a pipe server follows similar steps. You wait for connections, and handle each connection in its own thread. This way, connections can be dealt with concurrently. The bigger picture for this series is to develop a user-mode program that interacts with a singular kernel module. That’s why I didn’t use mutexes for pipe write synchronization in the kernel module.

I’ll be showing the whole code at once since most of it is from Microsoft’s article on writing a pipe server. I’ve made one slight change, which is printing our kernel message using printf instead of _tprintf as we’re not sending a wide string over the pipe.

The basic steps to recreating a pipe server are:

  1. Create a named pipe
  2. Wait for a connection
  3. Handle the connection in a separate thread

Our handle function will perform the following operations:

  1. Allocate memory for the received message from client ( HeapAlloc )
  2. Continue to read data from client with ReadFile
  3. Shutdown the pipe after reading has finished
#include <windows.h> 
#include <stdio.h>
#include <tchar.h>
#include <strsafe.h>

#define BUFSIZE 512

DWORD WINAPI InstanceThread(LPVOID);

int _tmain(VOID)
{
BOOL fConnected = FALSE;
DWORD dwThreadId = 0;
HANDLE hPipe = INVALID_HANDLE_VALUE, hThread = NULL;
LPCTSTR lpszPipename = TEXT("\\\\.\\pipe\\MyPipe");

// The main loop creates an instance of the named pipe and
// then waits for a client to connect to it. When the client
// connects, a thread is created to handle communications
// with that client, and this loop is free to wait for the
// next client connect request. It is an infinite loop.

for (;;)
{
_tprintf(TEXT("\nPipe Server: Main thread awaiting client connection on %s\n"), lpszPipename);
hPipe = CreateNamedPipe(
lpszPipename, // pipe name
PIPE_ACCESS_DUPLEX, // read/write access
PIPE_TYPE_MESSAGE | // message type pipe
PIPE_READMODE_MESSAGE | // message-read mode
PIPE_WAIT, // blocking mode
PIPE_UNLIMITED_INSTANCES, // max. instances
BUFSIZE, // output buffer size
BUFSIZE, // input buffer size
0, // client time-out
NULL); // default security attribute

if (hPipe == INVALID_HANDLE_VALUE)
{
_tprintf(TEXT("CreateNamedPipe failed, GLE=%d.\n"), GetLastError());
return -1;
}

// Wait for the client to connect; if it succeeds,
// the function returns a nonzero value. If the function
// returns zero, GetLastError returns ERROR_PIPE_CONNECTED.

fConnected = ConnectNamedPipe(hPipe, NULL) ?
TRUE : (GetLastError() == ERROR_PIPE_CONNECTED);

if (fConnected)
{
printf("Client connected, creating a processing thread.\n");

// Create a thread for this client.
hThread = CreateThread(
NULL, // no security attribute
0, // default stack size
InstanceThread, // thread proc
(LPVOID)hPipe, // thread parameter
0, // not suspended
&dwThreadId); // returns thread ID

if (hThread == NULL)
{
_tprintf(TEXT("CreateThread failed, GLE=%d.\n"), GetLastError());
return -1;
}
else CloseHandle(hThread);
}
else
// The client could not connect, so close the pipe.
CloseHandle(hPipe);
}
return 0;
}

DWORD WINAPI InstanceThread(LPVOID lpvParam)
// This routine is a thread processing function to read from and reply to a client
// via the open pipe connection passed from the main loop. Note this allows
// the main loop to continue executing, potentially creating more threads of
// of this procedure to run concurrently, depending on the number of incoming
// client connections.
{
HANDLE hHeap = GetProcessHeap();
CHAR* pchRequest = (CHAR*)HeapAlloc(hHeap, 0, BUFSIZE * sizeof(CHAR));

DWORD cbBytesRead = 0, cbReplyBytes = 0, cbWritten = 0;
BOOL fSuccess = FALSE;
HANDLE hPipe = NULL;

// Do some extra error checking since the app will keep running even if this
// thread fails.

if (lpvParam == NULL)
{
printf("\nERROR - Pipe Server Failure:\n");
printf(" InstanceThread got an unexpected NULL value in lpvParam.\n");
printf(" InstanceThread exitting.\n");
if (pchRequest != NULL) HeapFree(hHeap, 0, pchRequest);
return (DWORD)-1;
}

if (pchRequest == NULL)
{
printf("\nERROR - Pipe Server Failure:\n");
printf(" InstanceThread got an unexpected NULL heap allocation.\n");
printf(" InstanceThread exitting.\n");
return (DWORD)-1;
}

hPipe = (HANDLE)lpvParam;

// Loop until done reading
while (1)
{
// Read client requests from the pipe. This simplistic code only allows messages
// up to BUFSIZE characters in length.
fSuccess = ReadFile(
hPipe, // handle to pipe
pchRequest, // buffer to receive data
BUFSIZE, // size of buffer
&cbBytesRead, // number of bytes read
NULL); // not overlapped I/O

if (!fSuccess || cbBytesRead == 0)
{
if (GetLastError() == ERROR_BROKEN_PIPE)
{
_tprintf(TEXT("InstanceThread: client disconnected.\n"));
}
else
{
_tprintf(TEXT("InstanceThread ReadFile failed, GLE=%d.\n"), GetLastError());
}
break;
}
printf("Client Request String:\"%s\"\n", pchRequest);
}

// Flush the pipe to allow the client to read the pipe's contents
// before disconnecting. Then disconnect the pipe, and close the
// handle to this pipe instance.

FlushFileBuffers(hPipe);
DisconnectNamedPipe(hPipe);
CloseHandle(hPipe);

HeapFree(hHeap, 0, pchRequest);

printf("InstanceThread exiting.\n");
return 1;
}

Conclusion

Although we already had code provided for a pipe client, adapting this code to work in a kernel module is not always as straightforward, and there’s usually very little resources to do so available on the internet. I hope this post made it easy to understand the logic and decisions made while creating our own kernel pipe client.

There is room for improvement with these implementations, and they’re certainly not production ready. Drivers need to be bug-free, which means being careful about handling exceptions and other pitfalls. At some point, I’ll build on this project, and demonstrate the capabilities we have whilst having ring 0 access to the OS.

By the end of the Malware Development series, I hope you and I acquire a technical understanding of different types of malware, and how they operate stealthily, which in my opinion is the hardest part.

--

--