资讯动态

驱动级模拟按键:键盘过滤驱动实现原理与实战

发布时间:2026/9/1 2:43:53 来源:尧图企业网站定制
简介这是一份基于 Visual Studio 2013 的 C 驱动级模拟按键项目包围绕驱动编程、WinRing0 库与键盘扫描码输入展开适合想了解内核态与用户态交互、按键模拟及模拟连发功能的开发者也适用于需要编写自动操作工具的进阶学习者。压缩包共 28 个文件约 16.72MB除 .h/.cpp 源码外还包含完整的 .sln/.vcxproj 工程配置、WinRing0 的 .dll/.lib/.sys以及 .rc/.ico 界面资源与 ReadMe 说明可直接在 VS2013 中打开编译、调试省去库配置麻烦。目前已有 2586 人学习源码内含自动按键对话框、KeyMouse 控制逻辑和 WinRing0 调试库通过向键盘缓冲区发送扫描码实现按下/释放模拟并针对管理员权限、系统钩子捕获、连发间隔等关键点做了处理。对驱动级输入模拟的深入学习或自动化工具研发而言是一份完整且可运行的真实范例。1. 项目概述为什么要做驱动级模拟按键1.1 应用场景与核心需求驱动级模拟按键简单来说就是绕过常规的API调用链直接在内核层面构造并注入键盘输入事件。很多人第一次接触这个概念多半是游戏脚本、自动化测试工具或者远程控制软件。但真正的驱动级方案和普通的SendInput、keybd_event有本质区别。普通API模拟按键的痛点很明显它走的是应用层消息队列很多游戏或高权限软件能轻松识别并屏蔽。驱动级方案则不同它把输入事件直接插入到系统输入栈的更底层看起来就像物理键盘真的被按下了一样排查难度大大提升。在Windows环境下实现驱动级模拟按键有两条主流路线一是键盘过滤驱动在现有键盘设备栈上挂一层过滤设备二是键盘类驱动或总线驱动直接创建一个虚拟键盘设备把扫描码注入系统。我这次选择的方案是键盘过滤驱动配合VS2013开发环境用C实现。选择过滤驱动而不是总线驱动主要原因是代码量相对可控、稳定性更好不需要处理复杂的即插即用和总线枚举逻辑而且对新手来说更好理解驱动绑定的基本套路。VS2013作为编译环境对应的是WDK 8.1这套组合在Win7到Win10早期版本上兼容性都很不错。1.2 环境选型背后的考虑VS2013可能有人觉得老但做内核驱动开发工具链的稳定性比版本新更重要。WDK 8.1配合VS2013编译出的驱动可以兼容Win7到Win10 1809之前的绝大多数系统。如果你用的是最新版VS2022配最新WDK那编译出来的驱动往往只兼容Win10 2004以上的系统覆盖范围反而变窄了。另外VS2013对WDK的集成非常成熟不需要额外配置复杂的构建脚本。安装完WDK 8.1后VS2013会自动多出“Driver”项目模板直接新建一个“Empty Kernel Mode Driver”就能开工。这一点对新手极其友好毕竟内核开发的核心难点在驱动模型本身而不是环境配置。注意做驱动开发一定要在虚拟机或专门的测试机上操作千万不要在自己的主力机器上直接装未签名的测试驱动。蓝屏是家常便饭重要数据丢一次就够你心疼半年的。2. 驱动级模拟按键的原理拆解2.1 输入链路的底层逻辑我们按下物理键盘上的一个键到应用程序收到这个消息中间经过了硬件、中断、内核驱动、系统输入管理器、应用层消息队列等若干环节。键盘中断触发Kbdclass类驱动它读取扫描码放入系统输入队列再由Win32k.sys把消息分发到前台窗口。应用层的SendInput和keybd_event本质上是调用了NtUserSendInput这个系统调用最终把事件注入到同一个输入队列里。问题就出在这里——既然入口是公开的、可被拦截的安全软件自然也能在同样的位置做手脚识别出事件不是来自真实硬件。键盘过滤驱动的方式就绕开了这个入口。它不再走NtUserSendInput而是在键盘设备栈中插入一层自己的过滤设备。当上层驱动发送IRP输入输出请求包时这层过滤设备可以解析IRP里的扫描码然后把伪造的键按下/抬起事件向下传给真实的键盘驱动或者直接修改、拦截真实的输入IRP甚至向上层伪造一个虚拟的按键事件。换句话说应用层看起来就是“有一个键被物理按下了”因为事件是从键盘驱动栈内部出现的而非上层注入。这就像你把一封信混进了邮局的分拣流水线而不是从邮筒投递——收件人没法分辨这封信到底是邮寄的还是有人直接在分拣中心塞进去的。2.2 过滤驱动IRP处理的关键机制键盘过滤驱动需要处理的核心IRP主要是IRP_MJ_READ。Kbdclass驱动通过IRP_MJ_READ向端口驱动读取键盘扫描码而过滤驱动挂在这两者之间。处理逻辑分三种第一下传并监听也就是把IRP正常下传然后在完成例程里检查读取到的扫描码用来做输入拦截或记录。第二主动完成IRP由过滤驱动自己构造一个键盘输入数据包把这个IRP完成掉并向上返回数据这样上层驱动就能读到伪造的按键。第三补充插入也就是IRP照常下传、数据正常返回但过滤驱动在完成例程里额外附加一组伪造的键盘数据到读取缓冲区实现一次按键触发多次按键响应的效果。驱动级模拟按键实际上用的是第二种方式。构造的键盘输入数据结构是KEYBOARD_INPUT_DATA定义中有两个关键字段MakeCode扫描码和Flags标志位。Flags为0表示按下为KEY_MAKE或KEY_BREAK值为1表示抬起KEY_E0值为2表示扩展按键标志。构造一次按键事件最少要两个包一个按下、一个抬起。只发按下不发抬起系统会一直认为键被按住出现自动重复效果这个特性在游戏里可以故意利用但大多数场景下要成对发送。3. VS2013驱动开发环境的搭建3.1 安装与配置步骤实录我用的是Windows 7 SP1虚拟机做开发宿主机也有兼容方案但虚拟机的快照功能在调试驱动崩溃时太方便了。安装顺序有讲究先装VS2013再装WDK 8.1最后装SDK。如果顺序反了VS2013的驱动项目模板可能出不来。具体版本上VS2013用Update 5WDK用8.1 Update。安装WDK时建议勾选完整安装因为后续调试需要用到windbg和kdnet工具。全部装完后新建项目时选择“Visual C”下的“Windows Driver”分类就能看到“Empty Kernel Mode Driver”模板。配置项目还需要调整几处目标平台选择Win7或Win8不要选Win10否则编译器会启用新的要求导致生成的驱动在Win7上加载失败。驱动签名模式选择Test Mode如果测试机开启了测试签名模式就不需要每次手动禁用签名强制。链接器设置里把Driver Entry符号名保持默认的DriverEntry不要改成其他名字否则入口点会找不到。提示VS2013配WDK 8.1编译出来的驱动文件后缀是.sys输出目录默认在解决方案下的x64或Win32文件夹。64位系统务必编译x64版本用32位驱动在64位系统上是无法加载的。3.2 测试机的签名与调试准备64位Windows强制要求内核驱动有数字签名测试模式下可以用测试证书绕过这个限制。在测试机上执行bcdedit /set testsigning on并重启就能开启测试签名模式。然后用WDK自带的makecert工具生成一个自签名证书再用signtool给.sys文件签名。签名的命令行大致是这样makecert -r -pe -ss PrivateCertStore -n CNMyDriverTest MyDriverTest.cer signtool sign /s PrivateCertStore /n MyDriverTest /t http://timestamp.digicert.com MyDriver.sys签名完成后把驱动文件拷贝到测试机通过sc create命令创建服务并启动加载sc create MyKeyDriver type kernel binPath C:\drivers\MyKeyDriver.sys sc start MyKeyDriver加载成功后设备管理器中能看一个“键盘设备”下多了个过滤驱动。用sc stop和sc delete可以卸载驱动。每次重新编译驱动都要重新签名再加载否则系统会因为签名失效而拒绝加载。4. 核心代码实现与逐段解析4.1 驱动入口与设备绑定驱动入口函数DriverEntry里要做三件事设置卸载例程、创建设备对象、绑定键盘设备栈。下面是核心骨架extern C NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { NTSTATUS status; PDEVICE_OBJECT filterDeviceObject NULL; PDEVICE_OBJECT targetDeviceObject NULL; PFILTER_EXTENSION filterExtension NULL; DriverObject-DriverUnload KeyDriverUnload; DriverObject-MajorFunction[IRP_MJ_READ] KeyFilterRead; DriverObject-MajorFunction[IRP_MJ_DEVICE_CONTROL] KeyFilterDeviceControl; DriverObject-MajorFunction[IRP_MJ_PNP] KeyFilterPnp; // 创建过滤设备 status IoCreateDevice(DriverObject, sizeof(FILTER_EXTENSION), NULL, FILE_DEVICE_KEYBOARD, 0, FALSE, filterDeviceObject); if (!NT_SUCCESS(status)) return status; filterExtension (PFILTER_EXTENSION)filterDeviceObject-DeviceExtension; filterExtension-DeviceObject filterDeviceObject; // 绑定到键盘类驱动的设备栈 targetDeviceObject IoAttachDevice(filterDeviceObject, L\\Device\\KeyboardClass0); if (!NT_SUCCESS(status)) { IoDeleteDevice(filterDeviceObject); return status; } filterExtension-TargetDeviceObject targetDeviceObject; filterDeviceObject-Flags | DO_DIRECT_IO; filterDeviceObject-Flags ~DO_DEVICE_INITIALIZING; return STATUS_SUCCESS; }绑定设备用的是IoAttachDevice把过滤设备挂到\Device\KeyboardClass0的设备栈上。这个设备名是键盘类驱动的默认设备名绝大多数Windows系统都存在。如果你挂了多个键盘可以枚举所有键盘设备逐个绑定但一般场景只需要绑默认的Class0就够了。4.2 模拟按键的核心逻辑模拟按键关键在完成IRP_MJ_READ也就是主动完成一个读请求并把仿真的键盘数据写到用户缓冲区。代码实现NTSTATUS KeyFilterRead(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PIO_STACK_LOCATION irpSp IoGetCurrentIrpStackLocation(Irp); PKEYBOARD_INPUT_DATA inputData NULL; ULONG dataLength irpSp-Parameters.Read.Length; // 注入标志由应用层通过IOCTL设置 PFILTER_EXTENSION filterExtension (PFILTER_EXTENSION)DeviceObject-DeviceExtension; if (filterExtension-InjectPending) { // 检查缓冲区是否能放得下键盘输入数据 if (dataLength sizeof(KEYBOARD_INPUT_DATA) * 2) { inputData (PKEYBOARD_INPUT_DATA)Irp-AssociatedIrp.SystemBuffer; // 第一个包按键按下 inputData[0].MakeCode filterExtension-InjectMakeCode; inputData[0].Flags KEY_MAKE; inputData[0].Reserved 0; inputData[0].UnitId 1; // 第二个包按键抬起 inputData[1].MakeCode filterExtension-InjectMakeCode; inputData[1].Flags KEY_BREAK; inputData[1].Reserved 0; inputData[1].UnitId 1; Irp-IoStatus.Status STATUS_SUCCESS; Irp-IoStatus.Information sizeof(KEYBOARD_INPUT_DATA) * 2; IoCompleteRequest(Irp, IO_NO_INCREMENT); filterExtension-InjectPending FALSE; return STATUS_SUCCESS; } } // 非注入场景走正常下传路径 IoSkipCurrentIrpStackLocation(Irp); return IoCallDriver(filterExtension-TargetDeviceObject, Irp); }这段代码有几个细节值得注意。Irp-AssociatedIrp.SystemBuffer直接指向读取缓冲区前提是设备对象设置了DO_DIRECT_IO或DO_BUFFERED_IO标志。这里创建设备时设了DO_DIRECT_IO所以系统会用MDL直接映射缓冲区读写都走SystemBuffer。缓冲区大小检查是必须的因为键盘应用层读取时不一定每次都读4字节极少数情况下可能只读1个包。如果不检查长度直接写两个包就会造成缓冲区溢出这是内核开发的大忌——直接蓝屏。KEY_MAKE在WDK中的定义是0KEY_BREAK是1。有的资料写Flags等于0就是按下1就是抬起对应的就是这两个宏。4.3 应用层通信接口驱动里暴露一个IRP_MJ_DEVICE_CONTROL接口应用层通过DeviceIoControl下发指令。为了让应用层能找到驱动设备需要在创建设备时设置一个符号链接比如\??\KeyInject。然后IOCTL处理函数里接收两个参数操作码和按键码。IOCTL分发逻辑NTSTATUS KeyFilterDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PIO_STACK_LOCATION irpSp IoGetCurrentIrpStackLocation(Irp); ULONG ioControlCode irpSp-Parameters.DeviceIoControl.IoControlCode; if (ioControlCode IOCTL_KEY_INJECT) { ULONG inputBufLength irpSp-Parameters.DeviceIoControl.InputBufferLength; PKEY_INJECT_REQUEST request (PKEY_INJECT_REQUEST)Irp-AssociatedIrp.SystemBuffer; if (inputBufLength sizeof(KEY_INJECT_REQUEST)) { PFILTER_EXTENSION filterExtension (PFILTER_EXTENSION)DeviceObject-DeviceExtension; filterExtension-InjectMakeCode request-MakeCode; filterExtension-InjectPending TRUE; Irp-IoStatus.Status STATUS_SUCCESS; Irp-IoStatus.Information 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_SUCCESS; } } Irp-IoStatus.Status STATUS_INVALID_DEVICE_REQUEST; Irp-IoStatus.Information 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_INVALID_DEVICE_REQUEST; }应用层C调用DeviceIoControl注入按键的代码#include windows.h #include iostream struct KEY_INJECT_REQUEST { USHORT MakeCode; }; int main() { HANDLE hDevice CreateFileW( L\\\\.\\KeyInject, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hDevice INVALID_HANDLE_VALUE) { std::cerr Failed to open driver handle, error: GetLastError() std::endl; return 1; } KEY_INJECT_REQUEST request; request.MakeCode 0x1E; // 扫描码对应A键 DWORD bytesReturned 0; BOOL success DeviceIoControl( hDevice, IOCTL_KEY_INJECT, request, sizeof(request), NULL, 0, bytesReturned, NULL); if (success) { std::cout Key injected successfully! std::endl; } else { std::cerr DeviceIoControl failed, error: GetLastError() std::endl; } CloseHandle(hDevice); return 0; }扫描码表可以查“Set 1 Make Code”对照表0x1E对应字母A0x10对应Q0x1F对应S。使用前建议用CtrlAltDel这类特殊键测试注入效果因为这类组合键在应用层模拟常常失效而驱动级注入却能正常工作非常适合做测试验证。5. 实际测试与问题排查记录5.1 环境与测试结果我是在Win7 SP1 x64虚拟机里跑的这套方案。测试步骤分四步第一步开启测试签名安装驱动服务启动驱动确认设备管理器中“键盘设备”属性里出现了过滤驱动。第二步运行一个简单的记事本程序焦点停留在编辑区。第三步用编译好的应用层测试程序注入A键扫描码。第四步观察记事本是否出现了字母A。实测结果记事本里成功出现了A。用按键检测工具查看也能看到A键的按下和抬起事件。之后我测试了游戏场景在虚拟机里跑了一款支持键盘操作的小游戏同样能正常响应没有出现按键丢失或重复触发的问题。5.2 踩过的坑与排查思路开发过程中最典型的问题有三个第一个是驱动加载时报“拒绝访问”。十有八九是签名问题检查测试签名是否开启检查signtool是否真的签名成功。可以用signtool verify /pa验证签名。第二个是驱动加载成功但注入无效。这时要先确认IOCTL有没有执行。用DbgPrint输出调试信息用DebugView捕获内核日志看InjectPending有没有被置位。如果置位了但按键没反应检查缓冲区长度处理很多情况是应用层传的结构体大小不对导致驱动里的长度检查没过代码静默走了下传路径。第三个是蓝屏BugCheck代码通常是DRIVER_IRQL_NOT_LESS_OR_EQUAL或SYSTEM_SERVICE_EXCEPTION。这多半是指针访问错误或IRQL过高。键盘设备的IRP主要在DISPATCH_LEVEL如果在这个级别访问分页内存就会蓝屏。所以注入用的MakeCode一定要提前存到设备扩展里不能在处理IRP时才去访问应用层传下来的缓冲区。5.3 键盘过滤驱动常见问题速查表问题现象可能原因排查/处理方式驱动启动失败每次重启自动删除驱动签名无效重新签名开启测试签名模式注入无反应但驱动已加载IOCTL未到达或缓冲区长度不足DbgPrint调试检查结构体大小按键重复触发多次只发了MakeCode没发BreakCode检查代码中是否补发了抬起包蓝屏IRQL_NOT_LESS_OR_EQUAL在DISPATCH_LEVEL访问分页内存把数据存DeviceExtension改用非分页内存特定按键注入失败扫描码使用了扩展键标志带E0前缀的键需要设置KEY_E0标志切换用户或锁屏后失效会话切换导致设备栈重建需要监听会话通知事件并重新绑定设备第四个问题比较有意思就是扩展键。方向键、Home、End这些键的扫描码带E0前缀构造数据时要设置KEY_E0标志否则驱动会注入一个普通的按键事件但系统识别成数字小键盘区的键。这个细节很容易被忽略我身边就有同事在这个问题上折腾了一下午。6. 内核事件与多会话安全注意6.1 会话切换对设备栈的影响如果系统中有用户切换到锁屏界面设备栈有可能会被重建导致绑定的过滤设备失联。解决思路有几种最简单的是在DriverEntry里注册一个IoRegisterPlugPlayNotification监听键盘设备的到达事件动态重新绑定。或者用WdfVersionBind和WDF框架的事件回调来处理但对纯WDK方案来说还得自己管理设备扩展的同步。6.2 键盘过滤驱动的双刃剑属性驱动级模拟按键的能力很强但能力越强越需要自律。这类技术的典型应用包括无障碍输入工具、自动化测试框架、远程协助软件、自定义键位映射工具等。做这些工具完全正当也是很多生产力软件的底层技术。但反过来驱动级模拟也能被用来制作游戏外挂脚本或恶意软件。内核态代码一旦被利用会导致系统完全失控。我这里分享的全部内容仅限教学和正当工具开发用途所有测试都应当在虚拟机里进行不要在自己的主力设备上做危险实验更不要拿这套技术去做任何违反软件用户协议的事情。内核编程的道德底线很清晰能用应用层API解决的问题就不要用驱动用驱动解决的问题就不要做越权的事做出来的工具要让人能用、没法滥用。说到底技术能力决定你能做什么判断力决定你该做什么。6.3 驱动开发必须养成的安全习惯整个开发过程中我最重要的习惯是虚拟机快照。每次编译新版本驱动、加载测试之前先在虚拟机里拍一个快照。蓝屏后一键还原到快照不用反复重装系统调试效率提升太多了。还有一个习惯是用windbg远程调试替代本地打印。用windbg -k com:pipe,port\\.\pipe\com_1,resets0连接调试机可以在断点时查看内存、堆栈、变量值。虽然配置比DebugView复杂但遇到真正的疑难杂症只有内核调试器能救你。最后是小步快跑原则。不要一次性地写完整个驱动再测试而是一个功能一个功能地加。先实现驱动加载和卸载再实现IOCTL通信再实现按键注入每步都验证通过再进行下一步。这个习惯帮我隔离了很多问题调试起来一目了然。7. 从驱动通信走向完整项目的扩展方向当基础驱动能稳定加载、IOCTL通信通畅、按键注入成功后这个项目其实已经具备了继续扩展的骨架。我后续经常在这些方向上加功能实现都比较顺畅。第一个扩展方向是键位映射和宏录制。在过滤驱动的完成例程里拦截真实的KEYBOARD_INPUT_DATA修改MakeCode后继续下传就能实现真正的全局键位映射——不管焦点在哪个程序、有没有管理员权限映射都生效。宏录制则是在拦截层保存一系列按键时间序列再通过注入接口按时间间隔重放。第二个扩展方向是组合键和特殊键处理。把KEY_E0标志、KEY_E1标志完整地解析出来配合一段扫描码状态机就能正确处理Shift、Ctrl、Alt组合以及大小写锁定、Win键等特殊按键。这里有个经验模拟组合键时不能只发组合键的按键事件还要让系统认为修饰键已经处于按下状态否则某些软件特别是游戏会判断不出组合键。第三个方向是把通信对面从控制台程序改成Windows服务。服务运行在SYSTEM上下文配合Session 0隔离机制能实现开机自动加载且不需要用户登录。配合一个常驻托盘程序做用户界面就是完整的远程协助或自动化工具的雏形。第四个方向是跨平台与跨体系结构适配。64位系统需要重新编译驱动ARM64架构的Windows也没有被完全堵死。如果未来有需求可以在同一套代码基础上用编译宏区分平台差异。这些扩展方向基本都是搭积木式的驱动骨架不动加新功能时只加新的IOCTL处理分支和对应的处理函数。我个人的体会是驱动开发最难的一步永远是“第一个能跑通的驱动”跨过这一步之后剩下的都是耐心的活。希望这篇记录能让你少踩几个坑早点看到记事本里蹦出你自己注入的那个“A”。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价