键盘模拟
为什么会想到用键盘模拟呢?原因是鄙人在玩某款游戏时想要实现一个yolo识图+逻辑决策自动搬砖的脚本,然后周末时候把需求提给gpt,让它直接给生成了一版代码(效率是真的高啊...),然后gpt对于按键模拟部分采用的是keyboard event,试了下游戏没反应,然后换成postmessage,也不行。sendinput还是不行,故研究起了键盘模拟。
最初设想
由于鄙人是个网络自闭患者,对于被动的知识接受信任度几乎为0,故研究设备堆栈,从键盘类到具体的小端口设备,结合gpt解惑及毛教授的《Windows内核编程》一书,将键盘输入的整个逻辑进行归纳:小端口设备注册中断例程,键盘按键触发中断,回调小端口设备的中断例程,小端口设备将实际工作函数插入dpc队列,dpc队列进行数据收集,然后通过小端口设备扩展中记录的上层键盘类提供的ClassServer进行数据上报,上层类设备收到下层的数据后从类设备的设备扩展中取出windows子系统发送过来的READ IRP,然后将数据写入IRP->AssociatedIrp.SystemBuffer,最后完成IRP。windows子系统从pending状态切换至completed状态,从而完成一次完整的键盘输入。在这个过程中可以看出最关键的就是数据上报,也就是ClassService函数,在Recatos中,这个函数就叫什么ClassService(我也忘了 大差不差),如果能直接调用ClassService函数岂不是就相当于我按了一次键盘么?思路打开说干就干。
在windows中,它可能不叫这个名字。随便找个win10的kbdclass.sys,打开IDA,搜索ClassService,发现一个好像相关的函数:KeyboardClassServiceCallback
用ida看看吧~
KeyboardClassServiceCallback

初看函数有四个参数,和Recatos中的ClassService一样欸,难道?为了验证猜想,将相关参数按照对应结构体转换了下,好像是那么回事,逻辑有点像

为了验证将ida反编译代码和之前泄露的nt5src中关于kbdclass模块的源码进行比对,相似度高达90%!!!那还逆个p啊,直接上才艺。
对这个函数的定义如下:
typedef VOID(__stdcall* pfnKeyboardClassServiceCallback)(PDEVICE_OBJECT DeviceObject, PKEYBOARD_INPUT_DATA InputDataStart, PKEYBOARD_INPUT_DATA InputDataEnd, PULONG InputDataConsumed);
然后让万能的gpt写了一份关于特征码搜索的代码(gpt实在是太方便啦!!!),让它把整个框架先搭起来,自己去修饰细节就行。
//
// 初始化: 获取KeyboardClassServiceCallback地址 和 键盘设备对象
//
NTSTATUS InitKeyboardInjection()
{
// 1. 获取kbdclass模块基址
ULONG_PTR kbdclassBase = GetKernelModuleBase("kbdclass.sys");
if (!kbdclassBase) {
DbgPrint("[-] kbdclass.sys not found\n");
return STATUS_NOT_FOUND;
}
// 2. 想办法找到KeyboardClassServiceCallback函数地址
g_KeyboardServiceCallback = GetKeyboardClassServiceCallBackAddr(kbdclassBase);
if (!g_KeyboardServiceCallback) {
DbgPrint("[-] Cannot find KeyboardClassServiceCallback\n");
return STATUS_NOT_FOUND;
}
DbgPrint("[+] KeyboardClassServiceCallback = %p\n", g_KeyboardServiceCallback);
// 3. 获取 \Device\KeyboardClass0 的 DeviceObject
UNICODE_STRING deviceName;
RtlInitUnicodeString(&deviceName, L"\\Device\\KeyboardClass0");
PFILE_OBJECT fileObject = NULL;
NTSTATUS status = IoGetDeviceObjectPointer(&deviceName, FILE_READ_DATA, &fileObject, &g_KeyboardDevice);
if (!NT_SUCCESS(status)) {
DbgPrint("[-] IoGetDeviceObjectPointer failed: %08X\n", status);
return status;
}
ObDereferenceObject(fileObject); // 释放fileObject
DbgPrint("[+] Got Keyboard DeviceObject = %p\n", g_KeyboardDevice);
return STATUS_SUCCESS;
}
pfnKeyboardClassServiceCallback GetKeyboardClassServiceCallBackAddr(ULONG_PTR ModuleBase) {
PIMAGE_DOS_HEADER pModule;
PIMAGE_NT_HEADERS pNtHeader;
SIZE_T szImage = 0;
const char* CallBackPattern = "48 8b 47 70 4c 8b c3 49 8b d6 48 8b c8 e8 ? ? ? ? 48 01 5f 70";//x64
const char* FunctionStartPattern = "48 8B C4 48 89 58 08 48 89 70 10 48 89 78 18 4C 89 48 20 55 41 54 41 55 41 56 41 57";
ULONG_PTR PatternAddres = 0;
ULONG_PTR CallBackFuncStart = 0;
if (ModuleBase == 0) {
return NULL;
}
pModule = ModuleBase;
pNtHeader = ModuleBase + pModule->e_lfanew;
szImage = pNtHeader->OptionalHeader.SizeOfImage;
PatternAddres = FindPattern(ModuleBase, szImage, CallBackPattern, TRUE);
if (PatternAddres == 0)
{
DbgPrint("FindPattern CallBackPattern failed!\n");
return NULL;
}
CallBackFuncStart = FindPattern(PatternAddres, 0x350, FunctionStartPattern, FALSE);
if (CallBackFuncStart == 0)
{
DbgPrint("FindPattern FuncStartCallBackPattern failed!\n");
return NULL;
}
return CallBackFuncStart;
}
按照这么个代码一折腾,然后实现下控制码控制逻辑部分代码,再让gpt生成一个ANSCI转扫描码的表,完事编译加载驱动,发送控制码一气呵成:

思考 重构KeyboardClassServiceCallback函数
做完了这些想着直接上互联网进行"全网首发",结果发现有大佬已经在很多年前就使用了这个办法了... emmm,就当是研究了哈哈。后来就在想,一些反外挂驱动会不会通过hook方式检查栈回溯进行过滤呢?妈的 很有可能啊... 于是我就想自己实现ClassService函数,直接向键盘类设备写入数据,这样就不会触发hook导致栈回溯了!!说干就干,于是就仔细研究了下ClassService函数,发现其逻辑比较简单,就是对一个环回缓冲区的读写而已。于是就有了以下简便版的ClassSevice实现:
BOOLEAN MyKeyboardClassServiceCallback(PDEVICE_OBJECT DeviceObject, PKEYBOARD_INPUT_DATA pInputData)
{
PKBD_DEVICE_EXTENSION deviceExtension;
PIO_STACK_LOCATION irpSp;
PIRP irp;
ULONG bytesToMove;
SIZE_T moveSize;
ULONG bytesInQueue;
PUCHAR pInputBuf = pInputData;
DbgBreakPoint();
deviceExtension = DeviceObject->DeviceExtension;
PoSetSystemState(ES_USER_PRESENT);
KeAcquireSpinLockAtDpcLevel(&deviceExtension->SpinLock);
irp = KeyboardClassDequeueRead(deviceExtension);
if (irp) {
irpSp = IoGetCurrentIrpStackLocation(irp);
bytesToMove = irpSp->Parameters.Read.Length;
moveSize = sizeof(KEYBOARD_INPUT_DATA) <= bytesToMove ? sizeof(KEYBOARD_INPUT_DATA) : 0; //管他请求几个input 我就放一个
if (moveSize == 0) {
DbgPrint("[-] Irp Insufficient buffer space\n");
KeyboardClassPushQueueRead(deviceExtension,irp);
KeReleaseSpinLockFromDpcLevel(&deviceExtension->SpinLock);
return FALSE;
}
RtlMoveMemory(irp->AssociatedIrp.SystemBuffer, (PUCHAR)pInputData, moveSize);
irp->IoStatus.Status = STATUS_SUCCESS;
irp->IoStatus.Information = moveSize;
irpSp->Parameters.Read.Length = moveSize;
}
else
{
moveSize = sizeof(KEYBOARD_INPUT_DATA);
bytesInQueue = ((PUCHAR)deviceExtension->InputData + deviceExtension->KeyboardAttributes.InputDataQueueLength) - (PUCHAR)deviceExtension->DataIn;
if (bytesInQueue == 0) {
//kbdclass的输入缓冲区已经满啦 不管它满任它满 直接返回false 让驱动等会再写入
KeReleaseSpinLockFromDpcLevel(&deviceExtension->SpinLock);
return FALSE;
}
else
{
moveSize = bytesInQueue < moveSize ? bytesInQueue : moveSize;
RtlMoveMemory(deviceExtension->DataIn, pInputData, moveSize);
deviceExtension->DataIn = ((PUCHAR)deviceExtension->DataIn + moveSize);
//判断更新后的DataIn是否写满或是溢出缓冲区
if (deviceExtension->DataIn >= (PUCHAR)deviceExtension->InputData + deviceExtension->KeyboardAttributes.InputDataQueueLength)
{
//修正环回指针到最开始
deviceExtension->DataIn = deviceExtension->InputData;
}
//如果发生数据写到尾部没写完的情况 表明当前input数据还没全部放入环回缓冲区
if (moveSize < sizeof(KEYBOARD_INPUT_DATA)) {
pInputBuf += moveSize;
moveSize = sizeof(KEYBOARD_INPUT_DATA) - moveSize;
//由于实现的这个函数一次就传一个INPUT所以剩余的大小肯定小于缓冲区大小 就不做更多检查了
RtlCopyMemory(deviceExtension->DataIn, pInputBuf, moveSize);
deviceExtension->DataIn = (PUCHAR)deviceExtension->DataIn + moveSize;
}
deviceExtension->InputCount += 1;
}
}
KeReleaseSpinLockFromDpcLevel(&deviceExtension->SpinLock);
if (irp) {
ASSERT(NT_SUCCESS(irp->IoStatus.Status) && irp->IoStatus.Status != STATUS_PENDING);
IoCompleteRequest(irp, IO_KEYBOARD_INCREMENT);
IoReleaseRemoveLock(&deviceExtension->RemoveLock, irp);
return TRUE;
}
return TRUE;
}
再次编译加载运行,你猜怎么着,也成啦,这个方案的好处就是神不知鬼不觉的就把input插入到了键盘类的输入缓冲区中:

有没有其他办法呢?当然有我还想着模拟中断信号将数据写入到对应的寄存器,让系统原版的小端口设备注册的DPC函数去读数据完成键盘模拟的方案。只要想办法获取键盘类中等待数据的irpp然后将数据写进去,并完成该irp就能进行一次数据的上报。