news 2026/10/13 23:11:10

VS2013+WDK8.1实现键盘过滤驱动:驱动级模拟按键与IRP处理详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
VS2013+WDK8.1实现键盘过滤驱动:驱动级模拟按键与IRP处理详解

简介:在VS2013环境下用C++编写驱动级模拟按键的完整工程,面向驱动开发与系统编程学习者。资源围绕WinRing0驱动接口库展开,演示驱动级按键模拟的核心原理:通过读写键盘控制器端口,发送键盘扫描码,模拟按键按下、释放与连发;同时涵盖KMDF/UMDF框架、IRQL、中断处理等内核概念,并针对管理员权限、键盘钩子捕获等实践问题给出代码级处理策略。项目基于VS2013构建,包含可编译运行的MFC示例,以及调试日志、说明文档、驱动文件与运行库;压缩包共28个文件,整体16.72MB,目录结构完整,便于直接加载编译与二次修改。已有2589人学习浏览。资源提供完整可运行的示例工程,可帮助读者边看源码边理解驱动级按键模拟的落地流程,适合自动化测试、游戏辅助输入或研究内核态与寄存器交互。

1. 驱动级模拟按键 vs 应用层模拟:为什么在 VS2013 里写内核过滤驱动

当你在用户态用 SendInput 或 keybd_event 模拟一个按键时,这套逻辑其实活在 Win32 子系统的“信任圈”里。很多游戏、录屏工具、远程控制软件都会 Hook 掉这两个 API,或者直接检测窗口消息里有没有异常按键记录。更狠的还会检查键盘设备驱动的输入数据时间戳。于是做自动化的人开始往内核走:驱动级模拟按键,本质是写一个键盘过滤驱动,挂在系统键盘设备栈上,把键盘设备上报的原始扫描码在到达 kbdclass 之前直接改掉,或者把一个额外的 KEYBOARD_INPUT_DATA 塞进 IRP 的读缓冲区。这样系统到手的输入就是“真实存在的物理按键”,任何应用层校验都看不到破绽。这篇文章要做的就是带你用 VS2013 + WDK 8.1,把这件事从工程上跑通。

正文我默认你已经装好了 VS2013,并且有一点 WDM / 驱动编写基础但还没真正写过一个过滤驱动。下面我会按“搭环境 → 绑设备 → 处理 IRP → 改数据 → 排坑”的顺序,把一个可用的键盘过滤驱动拆给你看。代码不是玩具,是能给真实项目做底子的骨架;但你也要知道,内核里没有后悔药,一个野指针就能让你蓝屏重启。

2. 环境与工程骨架:VS2013 下驱动工程的正确搭法

VS2013 要写驱动,不是新建一个“控制台应用程序”就能跑的。你需要匹配的驱动开发包是 WDK 8.1,这套组合在微软工具链里算是比较经典的一组,今天很多老驱动项目还在用,因为改造成本低、DDK 文档全,网上踩坑案例也多。如果你手头是 VS2019 或 2022,驱动工程结构差异不大,但 VS2013 的对应 SDK 是 8.1 或 8.0,别混用,否则链接阶段会因为库版本冲突报出各种玄学错误。

2.1 先理清 WDK 与 VS2013 的匹配关系

WDK 8.1 随 VS2013 一起发布,官方支持 VS2013。如果你是从较老的 WinDDK 时代转过来,注意:从 WDK 8 开始,驱动项目的生成方式变成了 MSBuild,不再用传统的 sources 文件。工程文件是以 .vcxproj 结尾的 VS 工程,驱动类型通过项目属性里的 Configuration Properties -> Driver Settings 来选,比如“Kernel Mode Driver”或“Filter Driver”。这一点很关键,很多新手拿一个 WDK 8 的 Sample 工程,用 VS2013 打开后无法编译,原因就是没有给项目设置正确的驱动类型和 TargetPlatform。

安装时建议完整安装 WDK 8.1,勾选“Windows Driver Kit”全部组件。装完后 VS2013 的“新建项目”里会出现 Driver 模板组,里面有“Empty Kernel Mode Driver (KMDF)”这类模板。我们写过滤驱动可以用空模板,也可以直接导入 WDK 自带的键盘过滤 Sample 目录作为起点。我一般会建一个空的“Kernel Mode Driver (Legacy)”或“Empty Kernel Mode Driver”,然后把 .vcxproj 中 Linker 选项里的 SubSystem 保持为 Windows,不要改成 Console。

2.2 最小驱动骨架:DriverEntry、Unload 与设备对象

不管驱动最终干什么,你都得先有一个能加载、能卸载、不出蓝屏的骨架。下面这个代码块就是我们要扩展的起点,基于 C++ 和 WDK 8.1,文件命名为 KeySimDriver.cpp:

extern "C" { #include <ntddk.h> #include <ntdd8042.h> } #define DEVICE_NAME L"\\Device\\KeySimDevice" #define SYMLINK_NAME L"\\DosDevices\\KeySim" typedef struct _DEVICE_EXTENSION { PDEVICE_OBJECT pKeyboardDevice; // 底层键盘设备对象 PFILE_OBJECT pKeyboardFile; // IoGetDeviceObjectPointer 返回的文件对象 KSPIN_LOCK SimKeyLock; // 保护模拟按键表 ULONG TriggerMakeCode; // 触发键扫描码 ULONG TargetMakeCode; // 要模拟出的目标键扫描码 BOOLEAN bAddExtraKey; // 是否在真实按键基础上追加一个模拟键 } DEVICE_EXTENSION, *PDEVICE_EXTENSION; extern "C" NTSTATUS DriverEntry(PDRIVER_OBJECT pDriver, PUNICODE_STRING pRegPath) { NTSTATUS status; PDEVICE_OBJECT pFilterDevice = NULL; UNICODE_STRING uszDeviceName = RTL_CONSTANT_STRING(DEVICE_NAME); UNICODE_STRING uszSymLink = RTL_CONSTANT_STRING(SYMLINK_NAME); // 设置分发例程:Create/Close/DeviceControl 由用户态操作控制句柄时使用 pDriver->DriverUnload = KeySimUnload; pDriver->MajorFunction[IRP_MJ_CREATE] = KeySimDispatchCreateClose; pDriver->MajorFunction[IRP_MJ_CLOSE] = KeySimDispatchCreateClose; pDriver->MajorFunction[IRP_MJ_DEVICE_CONTROL] = KeySimDispatchDeviceControl; pDriver->MajorFunction[IRP_MJ_READ] = KeySimPassThroughRead; // 创建设备对象,并追加扩展结构 status = IoCreateDevice( pDriver, sizeof(DEVICE_EXTENSION), &uszDeviceName, FILE_DEVICE_UNKNOWN, 0, FALSE, &pFilterDevice); if (!NT_SUCCESS(status)) return status; // 初始化扩展与符号链接 PDEVICE_EXTENSION pExt = (PDEVICE_EXTENSION)pFilterDevice->DeviceExtension; RtlZeroMemory(pExt, sizeof(DEVICE_EXTENSION)); KeInitializeSpinLock(&pExt->SimKeyLock); pExt->TriggerMakeCode = 0; pExt->TargetMakeCode = 0; pExt->bAddExtraKey = FALSE; status = IoCreateSymbolicLink(&uszSymLink, &uszDeviceName); if (!NT_SUCCESS(status)) { IoDeleteDevice(pFilterDevice); return status; } // 这是关键:我们还要把控制设备附加到键盘设备栈上,见下一节 AttachToKeyboard。 // 这里先只返回成功,让驱动能跑起来。 DbgPrint("[KeySim] DriverEntry loaded\n"); return STATUS_SUCCESS; } VOID KeySimUnload(PDRIVER_OBJECT pDriver) { // 卸载时清理符号链接和设备 UNICODE_STRING uszSymLink = RTL_CONSTANT_STRING(SYMLINK_NAME); IoDeleteSymbolicLink(&uszSymLink); if (pDriver->DeviceObject) { IoDeleteDevice(pDriver->DeviceObject); } DbgPrint("[KeySim] Driver unloaded\n"); }

这段代码的逻辑是:先创建设备对象,让用户态可以用 CreateFile 打开并发送 IOCTL。所有分发函数都指向我们需要后续实现的公共处理函数。注意DriverUnload必须显式指定,否则驱动无法卸载,调试期你会非常痛苦。IoCreateDevice的扩展结构被初始化后,后续的绑定和按键映射都存进这个结构里。

参数方面,FILE_DEVICE_UNKNOWN表示这是一个自定义设备,不参与系统已有类型过滤;FALSE表示这是 Exclusively 模式的相反选项,我们不独占设备,允许用户态多次打开。耦合到后续代码,这个设备对象同时也是要被绑定到键盘设备栈的过滤设备,所以它的 Flags 不要乱设,否则会影响 IRP 传递方向。

2.3 加载测试:用 INF 注册与 DevCon 安装

驱动骨架能编译后,第一步不是在设备管理器里硬装,而是用工具加载。常见的做法是写一个简单的 INF 文件,把驱动注册为一个非 PnP 的“Legacy”驱动,然后用 DevCon 工具手动创建服务启动。INF 里有许多坑,比如节名、版本号格式,简单给一个能用的模板:

[Version] Signature = "$WINDOWS NT$" Class = LegacyDriver ClassGuid = {4d36e97d-e325-11ce-bfc1-08002be10318} Provider = %Prov% DriverVer = 10/01/2016,1.0.0.0 [DestinationDirs] DefaultDestDir = 12 [DefaultInstall] CopyFiles = KeySimCopyFiles [DefaultInstall.Services] AddService = KeySim, 0x00000002, KeySimService [KeySimCopyFiles] KeySimDriver.sys [KeySimService] DisplayName = "KeySim Keyboard Filter Driver" ServiceType = 1 StartType = 3 ErrorControl = 0 ServiceBinary = %12%\KeySimDriver.sys

这个 INF 的手动安装逻辑:[DefaultInstall]负责复制 sys 文件到 drivers 目录,[DefaultInstall.Services]添加一个内核服务。StartType = 3表示手动拉起。你需要在测试机上以管理员权限执行:

devcon.exe install KeySimDriver.inf Root\KeySim devcon.exe start Root\KeySim

devcon install之后,驱动服务会被注册,但设备实例不会自动启动,所以需要devcon start。每次修改代码重新编译后,得先devcon stop或sc stop KeySim停掉老驱动,再替换文件,最后启动。如果你在加载时看到“设备无法启动”的 error 39,通常是 INF 里的DeviceID没有匹配上 PnP 设备节点,或者驱动签名没有过(Win10 以上)导致拒绝。XP 到 Win7 测试机,建议先临时打开测试签名模式。

你可能会问:为什么不用 KDMF 或 KMDF?因为过滤键盘设备栈,WDM 的原生 IRP 操作最直接,KMDF 虽然封装了框架,但你会被它的设备栈模型绕晕。老项目基本都是 WDM 写法,VS2013 对这种代码的编译支持也是最干净的。

3. 绑定键盘设备与 IRP 处理的骨架

驱动要模拟按键,前提是你得让系统键盘输入先经过你的过滤层。这一节我们把键盘设备栈的绑定逻辑讲清楚,并且把 IRP 的分发链路打通。很多人写到这里会翻车,原因是没搞清楚过滤驱动应该附加在键盘设备栈的哪一层。

3.1 获取键盘设备对象:IoGetDeviceObjectPointer 的两条路

系统里键盘设备通常表现为\\Device\\KeyboardClass0或\\Device\\KeyboardClass1,由 kbdclass 创建。过滤驱动要附加,一般先用 IoGetDeviceObjectPointer 打开这个设备,拿到设备对象指针,再调用 IoAttachDeviceToDeviceStack 把自己附加进去。示例如下:

NTSTATUS AttachToKeyboard(PDEVICE_OBJECT pOurDevice) { NTSTATUS status; UNICODE_STRING uszKbdDevice; PFILE_OBJECT pFileObject = NULL; PDEVICE_OBJECT pKbdDevice = NULL; PDEVICE_EXTENSION pExt = (PDEVICE_EXTENSION)pOurDevice->DeviceExtension; // 常见键盘类设备名;部分系统上可能是 KeyboardClass1,可遍历设备树获取 RtlInitUnicodeString(&uszKbdDevice, L"\\Device\\KeyboardClass0"); // 获得设备对象和文件对象,文件对象必须被引用并保存,卸载时释放 status = IoGetDeviceObjectPointer( &uszKbdDevice, FILE_READ_ATTRIBUTES, &pFileObject, &pKbdDevice); if (!NT_SUCCESS(status)) { DbgPrint("[KeySim] Failed to open keyboard device: %x\n", status); return status; } // 将我们的过滤设备叠加到键盘设备栈上 status = IoAttachDeviceToDeviceStack(pOurDevice, pKbdDevice); if (!NT_SUCCESS(status)) { // 失败要记得释放引用,否则对象泄漏 ObDereferenceObject(pFileObject); return status; } // 保存底层设备与文件对象到扩展 pExt->pKeyboardDevice = pKbdDevice; pExt->pKeyboardFile = pFileObject; // 卸载时需要 ObDereferenceObject 释放 pFileObject return status; }

代码里IoGetDeviceObjectPointer返回的pFileObject具备引用计数,你必须把它保存在设备扩展里,等驱动卸载时调用ObDereferenceObject释放,否则内核对象泄漏。而pKbdDevice的引用则由 IoGetDeviceObjectPointer 内部的ObReferenceObject持有,同样要配合释放。

如果KeyboardClass0打开失败,常见原因有:键盘设备名被系统改成了KeyboardClass1,或IoGetDeviceObjectPointer不允许打开没有符号链接的内部设备。另一种稳健方法是遍历\\Driver\\kbdclass的设备队列,获取所有键设对象,这对多键盘场景更准确。但在最小实现里,先固定KeyboardClass0就够,我会在最后调试阶段换用遍历方式覆盖多键盘。

3.2 IRP 分发:让读请求穿过过滤层并挂上完成例程

键盘输入数据的读取走的是 IRP_MJ_READ。系统 kbdclass 会持续向下发起读请求,底层 PS/2 或 HID 驱动把按键数据填进缓冲区,然后完成 IRP。我们的过滤驱动必须把自己放在这个读请求的通路上。因此所有不是我们关心的 IRP,直接透传给下一层;而 IRP_MJ_READ 则要在转发前挂一个完成例程,这样底层完成时,我们有机会篡改缓冲区。代码如下:

NTSTATUS KeySimPassThroughRead(PDEVICE_OBJECT pDevice, PIRP pIrp) { PDEVICE_EXTENSION pExt = (PDEVICE_EXTENSION)pDevice->DeviceExtension; // 读请求必须透传给下层设备 IoCopyCurrentIrpStackLocationToNext(pIrp); // 挂完成例程:底层完成后触发 KeySimReadCompletion IoSetCompletionRoutine( pIrp, KeySimReadCompletion, pDevice, TRUE, // InvokeOnSuccess TRUE, // InvokeOnError TRUE); // InvokeOnCancel return IoCallDriver(pExt->pKeyboardDevice, pIrp); }

IoCopyCurrentIrpStackLocationToNext会把当前栈位置里的 Parameters 拷贝给下一层,然后IoCallDriver把 IRP 发下去。注意这里不能写IoSkipCurrentIrpStackLocation,因为跳栈会把我们的完成例程整个绕过,挂不上去。这是和纯透传驱动的最大区别。设置完成例程的三个布尔参数都置 TRUE,意思是无论底层最终成功、出错还是取消,都要调用我们的回调,避免模拟按键在异常路径上失效。

回调函数本身需要对状态机保持绝对的小心。在完成例程里可以访问 IRP 的用户缓冲区,但不能阻塞、不能分配内存、不能调用需要 PASSIVE_LEVEL 的同步函数。键盘过滤驱动的完成回调运行在 DISPATCH_LEVEL,所以只能用免同步的链表操作和自旋锁。后面第 5 章我会专门列这块的踩坑。

3.3 完成例程里拿到缓冲区:KEYBOARD_INPUT_DATA 的结构与指针

键盘驱动的读缓冲区是一个 KEYBOARD_INPUT_DATA 数组,定义在 ntddkbd.h 中。每个元素包含 MakeCode、Flags 和几个保留字段。我们需要在完成例程里检查pIrp->IoStatus.Status是否是 STATUS_SUCCESS,再通过pIrp->AssociatedIrp.SystemBuffer取得数据。下面是典型实现片段:

NTSTATUS KeySimReadCompletion( PDEVICE_OBJECT pDevice, PIRP pIrp, PVOID pContext) { PDEVICE_EXTENSION pExt = (PDEVICE_EXTENSION)pDevice->DeviceExtension; NTSTATUS status = pIrp->IoStatus.Status; if (status == STATUS_SUCCESS && pIrp->IoStatus.Information > 0) { ULONG dwBytesReturned = (ULONG)pIrp->IoStatus.Information; ULONG dwEntryCount = dwBytesReturned / sizeof(KEYBOARD_INPUT_DATA); PKEYBOARD_INPUT_DATA pData = (PKEYBOARD_INPUT_DATA)pIrp->AssociatedIrp.SystemBuffer; if (pData && dwEntryCount > 0) { // 对每一个真实按键事件,做扫描码映射或追加 ApplySimulatedKey(pExt, pData, dwEntryCount); } } return status; }

IoStatus.Information表示实际写入的字节数,除以结构体大小得到事件条数。这里有个坑:有些键盘驱动用的是 MDL 而不是 SystemBuffer,此时AssociatedIrp.SystemBuffer为 NULL,需要走MmGetSystemAddressForMdlSafe。考虑到我们是附加在 kbdclass 之上的最小过滤,一般能拿到 SystemBuffer,但为了健壮性,第 5 章会贴出针对 MDL 的替代写法。

4. 模拟按键的核心实现:完成例程里改扫描码与追加事件

到这一步,过滤驱动已经能“看”到每一个按键了。现在要决定怎么模拟。最直接的做法是把触发键的扫描码改成目标扫描码,相当于硬件层面改键。但“模拟按键”在产品上通常需要“没有物理按键也能触发某键”。所以我在这章给你两种能力:一是改键,二是追加一个额外的模拟按键事件。追加能力受限较多,但满足大多数自动化场景。

4.1 映射逻辑:把物理扫描码改写成目标扫描码

我们先定义 IOCTL 让用户态设置两个码值。Ioctl 代码用 CTL_CODE 生成,注意缓冲方法是 METHOD_BUFFERED:

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

DeviceControl 分发处理里,从系统缓冲区取出两个 ULONG,存进设备扩展:

NTSTATUS KeySimDispatchDeviceControl(PDEVICE_OBJECT pDevice, PIRP pIrp) { PIO_STACK_LOCATION pStack = IoGetCurrentIrpStackLocation(pIrp); ULONG ioctl = pStack->Parameters.DeviceIoControl.IoControlCode; PDEVICE_EXTENSION pExt = (PDEVICE_EXTENSION)pDevice->DeviceExtension; NTSTATUS status = STATUS_SUCCESS; ULONG bytesReturn = 0; if (ioctl == IOCTL_KEY_SIM_MAP_KEYS) { PVOID pBuf = pIrp->AssociatedIrp.SystemBuffer; ULONG len = pStack->Parameters.DeviceIoControl.InputBufferLength; if (pBuf && len >= 2 * sizeof(ULONG)) { PULONG pArgs = (PULONG)pBuf; KeAcquireSpinLockAtDpcLevel(&pExt->SimKeyLock); pExt->TriggerMakeCode = pArgs[0]; pExt->TargetMakeCode = pArgs[1]; KeReleaseSpinLockFromDpcLevel(&pExt->SimKeyLock); DbgPrint("[KeySim] map trigger=%02x target=%02x\n", pArgs[0], pArgs[1]); } else { status = STATUS_INVALID_PARAMETER; } bytesReturn = 0; } else { status = STATUS_INVALID_DEVICE_REQUEST; } pIrp->IoStatus.Status = status; pIrp->IoStatus.Information = bytesReturn; IoCompleteRequest(pIrp, IO_NO_INCREMENT); return status; }

这一段的完成例程里,就能根据这两个码值改写数据。真正发挥作用的ApplySimulatedKey实现如下:

VOID ApplySimulatedKey(PDEVICE_EXTENSION pExt, PKEYBOARD_INPUT_DATA pData, ULONG dwCount) { ULONG i; BOOLEAN bMapped = FALSE; ULONG triggeredMakeCode; ULONG targetMakeCode; KIRQL oldIrql; // 在 DPC 级别下使用自旋锁不可睡眠,所以用 KeAcquireSpinLock 的 DPC 变体 KeAcquireSpinLock(&pExt->SimKeyLock, &oldIrql); triggeredMakeCode = pExt->TriggerMakeCode; targetMakeCode = pExt->TargetMakeCode; KeReleaseSpinLock(&pExt->SimKeyLock, oldIrql); if (triggeredMakeCode == 0 || targetMakeCode == 0) return; // 第一遍:把触发键对应的真实按键替换成目标键 for (i = 0; i < dwCount; i++) { // 只处理按键事件(MakeCode 非 0),且匹配触发扫描码 if (pData[i].MakeCode == triggeredMakeCode) { pData[i].MakeCode = targetMakeCode; bMapped = TRUE; } } // 如果需要追加额外按键,计算空间后追加(见 4.2 节) // ... }

这段代码每读到一个键盘事件,都会扫描缓冲区中的所有事件。匹配到触发码就替换成目标码。系统上层看到的键名会跟着变。要注意的是,扫描码包含通码(E0 前缀)和断码(释放),KEYBOARD_INPUT_DATA里的 MakeCode 是实际扫描码,而 Flags 的KEY_MAKE标识可以判断是按下还是释放。我们要保留 Flags 不变,只改 MakeCode,这样释放事件也会带着新码,否则键会卡住。

4.2 追加模拟按键:在缓冲区尾部插入一个事件

假设你想让某程序在收到自定义触发时额外发一个 F5,但不想牺牲原键。替换做不到追加,所以我们要在完成例程里往缓冲区尾部写一个 KEYBOARD_INPUT_DATA。关键点在于缓冲区容量。IRP 在下发时,Parameters.Read.Length 指定了最大字节数,完成例程的pIrp->IoStatus.Information是当前有效数据长度。只要当前有效长度加 sizeof(KEYBOARD_INPUT_DATA) 不超过请求长度,就可以安全追加:

// 在 ApplySimulatedKey 后半段追加一个模拟按键事件 PIO_STACK_LOCATION pIrpStack = NULL; // 注意:完成例程里获取的是当前栈位置,也就是下一层的栈位置 pIrpStack = IoGetCurrentIrpStackLocation(pIrp);

这里有一层容易误解:完成例程执行时,IRP 的栈位置已经被 IoCompleteRequest 回滚了,因此IoGetCurrentIrpStackLocation拿到的是我们自己(过滤驱动)传给 IoCallDriver 前写入的栈位置。也就是说,这个栈位置里的 Parameters.Read.Length 是过滤驱动在KeySimPassThroughRead里拷贝下来的原始长度。因此追加前可以直接读它:

PIO_STACK_LOCATION pStack = IoGetCurrentIrpStackLocation(pIrp); ULONG maxBytes = pStack->Parameters.Read.Length; ULONG usedBytes = (ULONG)pIrp->IoStatus.Information; if (usedBytes + sizeof(KEYBOARD_INPUT_DATA) <= maxBytes) { PKEYBOARD_INPUT_DATA pNewEntry = (PKEYBOARD_INPUT_DATA) ((PUCHAR)pData + usedBytes); pNewEntry->MakeCode = (USHORT)targetMakeCode; pNewEntry->Flags = KEY_MAKE; // 先制造一个按下事件 pNewEntry->Reserved = 0; pNewEntry->ExtraInformation = 0; // 更新返回字节数,这样上层会把附加事件当成真实输入 pIrp->IoStatus.Information = usedBytes + sizeof(KEYBOARD_INPUT_DATA); }

这里追加了一个单纯的按下事件,没有释放事件。系统会在后续读到下一次真实按键的释放事件,但那个事件带着物理键的码,不是我们的目标码,所以会造成按键卡住。要解决这一点,要么追加一对按下+释放事件,占用 2 个条目;要么在后续完成例程里检测到“模拟键处于按下状态”时,自动追加对应的释放事件。常见做法是维护一个 BOOLEAN 的模拟键状态位,并在追加的释放事件里清除它。

另一个隐蔽问题:如果目标码与触发码相同,替换没有意义,还会产生递归映射,必须在 IOCTL 里禁止两者相等。我会在避坑章节里重新提到它。

4.3 参数设定:怎么做才能使模拟按键看起来像真实的

驱动级模拟的“像”不只是改一个 MakeCode。系统上层还会检查按键的时间间隔、重复节奏、以及同时按下的套件组合。纯键盘过滤驱动能控制的是字节层面的数据流,但它无法伪造系统级的热键时序。所以实操时我会把“模拟按键”分成三层:

  • 最简单:固定改码。适合把不常用的键映射成宏按键,响应快,但无法主动触发。
  • 中等:追加单个事件。适合在特定真实按键到来时补发一个组合键的“辅助键”,比如按 Shift 时额外补一个 Ctrl。
  • 较复杂:维护状态机,在追加按下后,再追加释放,并将追加事件的间隔调到 1~5ms,避免被当成异常输入。

这些参数的取舍,取决于你的目标程序怎么读键盘。如果是简单游戏,只要每次按下一个键时波流里出现目标码就行;如果是带反作弊检测的系统,那还要自带随机时间抖动,这已经属于工程调参,不是驱动本身的问题。

5. 必踩的坑:蓝屏、缓冲类型、MDL 与映射回环

驱动开发最怕的不是逻辑错,而是踩了坑还不知道,然后进入“每次改一行代码就蓝屏重启”的痛苦循环。以下五条都是我这几年做键盘过滤驱动实实在在遇到过的,按频率从高到低排列。

5.1 现象:完成例程里访问 SystemBuffer 直接蓝屏,代码 0x00000050

原因:键盘底层设备的读请求不总是使用 SystemBuffer。如果 IRP 是由 kbdclass 下发的,它往往通过 MDL 做直接 I/O;过滤驱动设置的设备对象 Flags 与下层不一致,IRP 的实际缓冲区是 MDL 而非系统缓冲区,完成例程里直接读 AssociatedIrp.SystemBuffer 就等于访问一个无效指针。

解决:不要假设缓冲区类型。在完成例程里先判断 IRP 是否带 MDL,带 MDL 就用 MmGetSystemAddressForMdlSafe 取得虚拟地址,再从中拷贝 KEYBOARD_INPUT_DATA。判断代码:

PVOID pBuf = NULL; PMDL pMdl = pIrp->MdlAddress; if (pMdl) { pBuf = MmGetSystemAddressForMdlSafe(pMdl, NormalPagePriority); } else { pBuf = pIrp->AssociatedIrp.SystemBuffer; } if (!pBuf) return status;

补充:如果你的过滤设备设了 DO_DIRECT_IO,IoCallDriver 给下层传递时,系统会重新构造 MDL,所以保持过滤设备 Flags 为 0(默认)会让 IRP 原样传递。千万不要自己给过滤设 DO_BUFFERED_IO,那会导致所有转发 IRP 都被重新组织,底层的 MDL 反而失效。

5.2 现象:按键被替换后,目标键“卡住”,表现为持续按住或重复触发

原因:你只替换了 MakeCode,没有同时替换释放事件。物理键释放时,键盘设备上报的断码(扫描码的最高位,或 Flags 里的 KEY_BREAK)仍然对应旧码。比如你把 A 改成了 B,按下 A 时系统认为 B 被按下,释放 A 时系统仍认为 A 被释放,B 没有收到释放事件,于是 B 一直处于按下状态。

解决:修改事件时要成对处理按下/释放。具体做法是根据 Flags 判断,KEY_MAKE 为按下时替换并更新模拟键状态,KEY_BREAK 触发时也替换成目标键的断码版本。KEYBOARD_INPUT_DATA 的 MakeCode 本身就包含断码标记(Set 1 中最高位为 1),但更稳妥是用 Flags 的 KEY_BREAK 位。代码里校验(pData[i].Flags & KEY_BREAK)来决定是否替换。

5.3 现象:追加模拟按键时,IoStatus.Information 改大后,上层读取异常或驱动崩溃

原因:你对缓冲区的长度判断没有真正基于底层提供的Parameters.Read.Length。键盘过滤驱动位于 kbdclass 上面,底层设备栈可能已经把 IRP 的长度改小,或Parameters.Read.Length不是整个缓冲区容量。不要直接信任该 Length,应当同时检查MmGetMdlByteCount(pIrp->MdlAddress)或用pIrp->MdlAddress->ByteCount。

解决:统一以底层 MDL 的字节数作最大容量:

ULONG maxBytes = 0; if (pIrp->MdlAddress) { maxBytes = MmGetMdlByteCount(pIrp->MdlAddress); } else if (pIrp->AssociatedIrp.SystemBuffer) { PIO_STACK_LOCATION st = IoGetCurrentIrpStackLocation(pIrp); maxBytes = st->Parameters.Read.Length; }

同时检查usedBytes + sizeof(KEYBOARD_INPUT_DATA) <= maxBytes,一字不差。否则追加越界,你连怎么死都不知道。

5.4 现象:驱动能加载,但按键不生效,DebugView 里看不到任何输出

原因:过滤驱动没有真正附加到键盘设备栈。很多情况下IoGetDeviceObjectPointer返回成功,但得到的设备对象是 kbdclass 的 FDO,而不是物理端口栈的顶层;或者你附加时用了 IoAttachDeviceToDeviceStack 但没检查返回值。如果设备栈里已经有别的过滤驱动把它挡住了,你的过滤设备会被排到底层,读请求根本不经过你。

解决:加载完驱动后,用 DeviceTree 工具查一下设备栈,确认KeySimDevice和键盘设备之间有明确的 AttachTo 关系。若无,尝试遍历\\Driver\\kbdclass的驱动对象设备队列,找到全部键盘设备并附加。附加顺序上,先附加离 kbdclass 最近的位置,越靠近 class 就越容易跟在所有上层过滤之后,但也能覆盖所有输入。

5.5 现象:用户态 DeviceIoControl 发给驱动成功,但映射码不生效,过一会蓝屏

原因:驱动在 DISPATCH_LEVEL 或更高 IRQL 里读取了由用户态传入的缓冲区。DeviceIoControl 的 METHOD_BUFFERED 机制会把用户态数据复制到系统缓冲区,但如果你在完成例程里调用pIrp->AssociatedIrp.SystemBuffer,这个缓冲区的所有权属于 IRP,在完成例程里它还在生命周期中,理论上是安全的。蓝屏多半是因为你在完成例程里调用了KeAcquireSpinLock而不是 DPC 版本,或者你尝试使用分页内存,导致 IRQL 违规。

解决:在完成例程里,锁和内存都必须是无分页的。代码中我使用 KeAcquireSpinLock 加锁,这会把 IRQL 提升到 DISPATCH_LEVEL,但如果你已经在 DISPATCH_LEVEL,直接调用会导致系统断言失败。标准做法是用KeAcquireSpinLockAtDpcLevel作为完成例程内的锁原语。此外,映射表数据成员必须分配在设备扩展里(非分页池),不能引用可分页函数。

6. 进阶用法:按键状态机、E0 扩展键与驱动级验证技巧

到这里,一个能改键、能追加事件的键盘过滤驱动已经具备雏形。但真实项目往往还会遇到两类问题:一类是游戏里的组合键,另一类是多媒体键这种带 E0 前缀的扩展扫描码。处理不好,你的模拟按键将只对字母键有效,换到方向键或小键盘就全乱。

E0 前缀键(方向键、Insert、Delete、Home、End 等)在 KEYBOARD_INPUT_DATA 里表现为 MakeCode 高位有扩展标记,通常是把 0xE0 单独作为一个 MakeCode 事件,然后用紧接着的下一个事件表示具体键。处理这类键时,不要只匹配单一的 MakeCode,要维护上下文中是否存在一个 E0 事件。你可以记录一个bPrevE0标志,在遍历事件时,遇到 MakeCode 等于 0xE0 就置位,遇到真实键时根据标志决定是 E0 键还是普通键。映射时也要成对生成 E0+具体键两个事件,否则上层无法识别。

组合键的模拟比单键复杂得多。比如说你要模拟“Ctrl+Alt+G”,需要追加三个事件:Ctrl 按下、Alt 按下、G 按下,然后按相反顺序追加释放。为了保证时序正确,我建议在驱动里放一个小的状态机,把所有待发送事件串成一个 FIFO,每次完成例程时往真实数据后挂一批。这个队列用自旋锁保护,不进分页池。

最后说验证。别一上来就拿到真机上玩。我习惯先用一台虚拟机,跑 Win7 32 位,加载驱动后用按键测试工具看输入是否到达应用层。重点验证三件事:驱动加载后系统键盘是否还正常工作;映射后按键码是否改变;追加事件是否被上层收到。验证方法是在驱动里定时 DbgPrint 原始扫描码和目标扫描码,然后用 DebugView 在实机上看。

如果一切正常,再放到重负载测试:连续按下触发键 1000 次,观察是否有蓝屏、按键卡死或漏键。我这边实际跑过一个版本,追加释放事件时因为状态位没有被完整更新,导致每第 8 次模拟会出现一次卡键。后来加了校验和和状态复位才稳定。这种问题在用户态程序里根本不可能暴露,只有驱动会逼着你把状态机写严谨。

驱动级模拟按键值不值得做,取决于你的对抗场景。如果只是想自动输密码、自动跑重复环节,应用层的 SendInput 更快,没必要为了“显得高端”把一个映射功能拖进内核。如果你要对抗的是已注入全局 Hook 或多层反作弊检测,那这层过滤才值得做。做的时候记住一个底线:过滤驱动永远不要让系统原有的键盘数据丢失。宁可少模拟一次,也不能把真实按键吞掉,否则用户会直接退回到物理键盘都失效的局面。写内核代码多用 DbgPrint,少盲改,多看设备栈图——这是我用无数次蓝屏换回来的习惯,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 23:10:34

比特币底层机制问答笔记:UTXO、确认数与工作量证明

把 BTC 课堂上的问答整理成笔记&#xff0c;是我最近觉得收获最大的一件小事。原因很简单&#xff1a;比特币的很多概念不是背出来的&#xff0c;而是问出来的。比如“为什么要等六个确认”“为什么区块里要放 Merkle 根”“手续费到底怎么算”&#xff0c;这些坑如果不通过提问…

作者头像 李华
网站建设 2026/10/13 23:08:48

二级 MySQL 备考速查:14 章语法点与易错细节全梳理

简介&#xff1a;这份PDF资料面向备考全国计算机等级考试二级MySQL的考生&#xff0c;也可供数据库初学者梳理知识框架。内容围绕数据库技术基本概念、三级模式与两级映像、数据模型、关系规范化、数据库设计流程&#xff0c;以及MySQL系统特性、安装配置、启动关闭、客户端工具…

作者头像 李华
网站建设 2026/10/13 23:06:20

AI-Infra实战指南:从GPU调度到分布式训练系统优化

我第一回正经接触AI基础设施&#xff0c;是在一个特别现实的问题面前——训练任务排队排到天亮&#xff0c;GPU一堆一堆空转&#xff0c;代码却老报OOM。那时候团队里都在卷模型结构、卷数据质量&#xff0c;没人正经想过算力、存储、网络调度这些东西&#xff0c;结果模型卷上…

作者头像 李华
网站建设 2026/10/13 23:05:07

软件功能与组件拆分实践:从迭代重构到模块化演进

写这个复盘的时候&#xff0c;我刚把“Software Features & Components 2”这个项目最后一批组件文档归档完毕。这个标题初看平平无奇&#xff0c;但凡是做过中型软件项目的人都会懂&#xff0c;前面带着“2”字&#xff0c;意味着这是一次迭代&#xff0c;是踩过坑之后的重…

作者头像 李华
网站建设 2026/10/13 23:04:49

MySQL万年历黄历数据库脚本:从表结构设计到导入避坑指南

简介&#xff1a;一套以MySQL为核心的万年历数据脚本&#xff0c;面向需要快速搭建农历、黄历查询系统的开发者&#xff0c;资源覆盖1970年至2100年共约4.7万天的公历农历对照、二十四节气、财神方位、每日宜忌、星座、天干地支与五行信息&#xff0c;并提供了相应字段的完整数…

作者头像 李华
网站建设 2026/10/13 23:03:52

Win10下编译ijkplayer全流程:环境配置、NDK踩坑与so库生成

1. 写在前面&#xff1a;Win10上编译ijkplayer会遇到什么问题先说结论&#xff1a;在Win10下编译ijkplayer确实能成功&#xff0c;但过程远没有官方文档写的那么顺滑。我前后折腾了差不多三天&#xff0c;真正执行编译可能只花了半小时&#xff0c;其余时间全部在跟各种奇奇怪怪…

作者头像 李华