ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Windows驱动级模拟按键:内核驱动注入键盘事件的原理与实现

Windows驱动级模拟按键:内核驱动注入键盘事件的原理与实现 简介一套 C 驱动级模拟按键的完整工程基于 Visual Studio 2013 编写面向需要在 Windows 下实现键盘级模拟输入、按键连发或绕过用户态钩子拦截的开发者。工程内含 28 个文件主要包括 7 个 h 头文件、4 个 cpp 源文件、WinRing0 调试用 dll 与 lib以及 sys 驱动文件配套 sln/vcxproj 工程和调试日志压缩包约 16.72MB目录结构清晰便于直接打开编译和二次修改。资源重点展示了驱动级按键模拟的完整流程包含扫描码写入、按键按下/释放事件构造、定时连发逻辑以及针对键盘钩子拦截的反钩子策略和内核调试排错思路可帮助有 C 与 Windows 内核基础的读者快速掌握用户态调用驱动接口、权限配置与稳定性测试方法。目前已有 2586 人学习浏览适合需要提升输入模拟层级、研究游戏外挂防护或学习内核交互的进阶开发者。1. 项目概述为什么需要“驱动级”模拟按键先说结论如果你只是想在软件里模拟键盘输入用SendInput、keybd_event就够了完全不需要碰驱动。但当你的目标是让系统“相信”按键真的来自物理键盘或者你的模拟操作需要绕过某些用户态钩子、在登录界面生效、甚至在系统会话 0 中工作普通的应用层 API 就无能为力了。这时就需要驱动级模拟按键。这个项目的核心就是用 C 在 Windows 下编写一个内核驱动通过创建键盘过滤设备或直接构造输入数据包把伪造的按键事件以接近硬件中断的方式注入系统输入栈。配合 VS2013 开发环境完成驱动的编译、签名和部署最后从应用层通过设备控制请求IOCTL向驱动下发按键指令。这个方案能解决的问题很明确对抗用户态全局钩子、突破无窗口环境下的输入限制、实现系统级自动化操作比如无人值守的测试、服务器管理场景。适合的人群主要是做 Windows 驱动开发、系统自动化、游戏外挂检测对抗研究或者安全测试的开发者。如果你只是写个按键精灵看到这里可以退出了如果你确实需要“骗过系统”这篇文章会把关键路径给你捋清楚。2. 技术路线与整体设计拆解2.1 驱动级模拟的三种常见实现路径我在实际项目中主要接触过三种驱动级模拟方案各有优劣先放在一个表里对比一下。方案原理优点缺点适用场景键盘过滤驱动过滤绑定到\Device\KeyboardClass0拦截键盘设备栈上的 IRP注入伪造的按键报告实现相对简单业界资料多容易在系统版本升级时出现兼容问题常规驱动级按键模拟键盘设备栈上附加自己的设备对象并伪造 IRP在设备栈中插入一层主动构造键盘 IRP 下发可控性强可拦截也可注入需要处理复杂 IRP 栈风险较高既需要过滤又需要注入的场景通过Kbdclass的Dispatch函数直接调用直接把扫描码写入内部缓冲区代码量最少效果接近硬件没有官方文档支持属于“黑科技”稳定性难以保证快速原型验证考虑到通用性和可维护性我最终选择了第一种编写一个键盘过滤驱动附加到系统的键盘设备对象上在上层驱动程序处理 IRP 时把伪造的按键报告“夹带”进去。这种方式在 Windows 7 到 Windows 10 的 18362 版本之前都表现稳定之后系统安全策略收紧签名要求更高但只要驱动签名有效仍然可行。2.2 如何设计应用层与驱动层的通信协议驱动本身不产生按键数据它只是一个“执行者”真正的指令来源是应用层。所以需要设计一个简单可靠的通信机制让用户态程序把“按下 A 键”或“按下回车”这样的请求发到驱动里。标准的做法是使用 IOCTL设备输入输出控制。应用层调用DeviceIoControl向驱动设备发送控制码驱动在派遣函数里解析缓冲区里的数据提取按键信息。我设计的协议数据包非常简单一个结构体搞定。typedef struct _KEYBOARD_INJECT_DATA { ULONG KeyCode; // 虚拟键码如 VK_A ULONG ScanCode; // 硬件扫描码如 0x1E 表示 A 键 BOOLEAN KeyDown; // TRUE 按下FALSE 释放 ULONG Flags; // 保留扩展键标记等 } KEYBOARD_INJECT_DATA, *PKEYBOARD_INJECT_DATA;从应用层下发时KeyCode和ScanCode可以同时填驱动这边优先使用ScanCode这样在注入到键盘设备栈时更贴近真实硬件行为。KeyDown字段用于区分按下和释放模拟一次完整的敲击必须两个动作都下发中间最好加 20 到 50 毫秒的延时否则某些程序会识别出按键持续时间异常。2.3 VS2013 环境搭建的几个关键点VS2013 虽然是老版本但配套 Windows Driver Kit (WDK) 8.1 依然是经典的驱动开发组合。安装时注意顺序先装 VS2013再装 WDK 8.1SDK 则根据目标系统版本选择。装好后新建项目时选择“Kernel Mode Driver, Empty”模板。这里有个特别容易踩坑的地方VS2013 默认的驱动项目配置里Target OS Version要提前选好否则编译出来的驱动在你目标系统上可能无法加载。我习惯设置成Win10或Win8.1具体看你测试机的版本高版本系统可以用兼容模式加载低版本编译的驱动反过来不行。还有一个环境变量问题如果 WDK 装完后 VS2013 没有识别到驱动模板大概率是WDKContentRoot环境变量丢失了。打开系统属性新建一个指向C:\Program Files (x86)\Windows Kits\8.1\的环境变量重启 VS2013 即可解决。这属于老生常谈但几乎每个新手都会卡一下。3. 核心细节解析驱动如何“伪造”一次真实按键3.1 设备对象、符号链接与派遣函数驱动的入口是DriverEntry在这里要做三件事创建设备对象、创建符号链接、设置各个 IRP 的派遣函数。NTSTATUS DriverEntry(PDRIVER_OBJECT pDriver, PUNICODE_STRING pRegistry) { NTSTATUS status; PDEVICE_OBJECT pDeviceObj; UNICODE_STRING deviceName; UNICODE_STRING symbolicLinkName; RtlInitUnicodeString(deviceName, L\\Device\\MyKbdFilter); RtlInitUnicodeString(symbolicLinkName, L\\DosDevices\\MyKbdInject); // CreateDevice 是封装函数返回设备对象指针 status IoCreateDevice(pDriver, 0, deviceName, FILE_DEVICE_KEYBOARD, 0, FALSE, pDeviceObj); if (!NT_SUCCESS(status)) return status; // 创建符号链接应用层才能通过 CreateFile 打开设备 status IoCreateSymbolicLink(symbolicLinkName, deviceName); if (!NT_SUCCESS(status)) { IoDeleteDevice(pDeviceObj); return status; } pDeviceObj-Flags | DO_BUFFERED_IO; // 设置派遣函数 pDriver-MajorFunction[IRP_MJ_CREATE] KbdDispatchPassDown; pDriver-MajorFunction[IRP_MJ_CLOSE] KbdDispatchPassDown; pDriver-MajorFunction[IRP_MJ_DEVICE_CONTROL] KbdDispatchDeviceControl; pDeviceObj-Flags ~DO_DEVICE_INITIALIZING; return STATUS_SUCCESS; }这个驱动不直接生成键盘数据而是作为过滤设备附加到系统键盘设备上。本质上它是在键盘设备栈里“截住”从系统发出的键盘报告往里面塞自己的数据。所以IRP_MJ_DEVICE_CONTROL是接收应用层命令的入口。3.2 附加到键盘设备栈处理 IRP 链这是整个项目最关键的一步。键盘数据在系统内部的传递是通过 IRP 完成的一个完整的按键流程会触发IRP_MJ_READ请求键盘类驱动从端口驱动拿到扫描码放入缓冲区然后上层完成这个 IRP。我们要做的是创建一个新的设备对象把它绑定到\Device\KeyboardClass0上然后所有发给键盘设备的 IRP 都会先经过我们的过滤设备。在IRP_MJ_READ的完成例程里检查缓冲区中已有的按键数据把我们需要注入的数据追加进去。NTSTATUS KbdDispatchPassDown(PDEVICE_OBJECT pDeviceObj, PIRP pIrp) { IoSkipCurrentIrpStackLocation(pIrp); return IoCallDriver(pDeviceObj-DeviceExtension-LowerDeviceObject, pIrp); }过滤驱动的核心是拿到底层设备对象LowerDeviceObject和顶层设备对象UpperDeviceObject一般通过IoAttachDevice完成绑定。完成绑定后过滤设备成为键盘设备栈的一部分任何按键事件都会经过我们这里。3.3 注入按键数据的核心流程当应用层下发一次按键指令时驱动收到IRP_MJ_DEVICE_CONTROL从SystemBuffer中取出KEYBOARD_INJECT_DATA然后做两件事第一调用模拟的KeyboardClassServiceCallback把按键报告直接推送到输入缓冲第二通过IoAllocateIrp等机制构造一个完整的键盘 IRP沿设备栈下发。以最简单的方案来说直接修改KEYBOARD_INPUT_DATA结构体数组是常见做法。这个结构体定义大致如下typedef struct _KEYBOARD_INPUT_DATA { USHORT UnitId; USHORT MakeCode; // 扫描码 USHORT Flags; // KEY_MAKE / KEY_BREAK USHORT Reserved; ULONG ExtraInformation; } KEYBOARD_INPUT_DATA, *PKEYBOARD_INPUT_DATA;当完成例程检测到IRP_MJ_READ返回成功后备份原有数据在数组尾部追加我们伪造的数据项设置MakeCode为扫描码、Flags为KEY_MAKE或KEY_BREAK然后把IoStatus.Information字段增加对应长度系统就会把伪造的按键当作真实按键来处理。从我的实际测试来看这种方式注入的按键对应用层来说是绝对真实可信的因为数据确实是从键盘设备栈里出来的经过了输入系统完整的处理链。4. 实操过程记录从代码到跑通的一次完整流程4.1 创建驱动项目编写核心过滤逻辑新建一个“Kernel Mode Driver, Empty”项目添加一个main.c文件。下面是我整理过的最小可用实现注意这里省略了部分繁琐的错误处理只保留主流程。#include ntddk.h #include ntddkbd.h #include kbd.h #include ..\inc\kbd_filter_ioctl.h // 自定义头包含IOCTL定义 typedef struct _DEVICE_EXTENSION { PDEVICE_OBJECT LowerDeviceObject; PDEVICE_OBJECT UpperDeviceObject; } DEVICE_EXTENSION, *PDEVICE_EXTENSION; NTSTATUS KbdFilterReadCompletion(PDEVICE_OBJECT pDeviceObj, PIRP pIrp, PVOID pContext) { PIO_STACK_LOCATION currentSp IoGetCurrentIrpStackLocation(pIrp); PKEYBOARD_INJECT_DATA pInject (PKEYBOARD_INPUT_DATA)pContext; PKEYBOARD_INPUT_DATA pData; ULONG oldLen, newLen; ULONG numKeys, i; if (pInject NULL) { IoFreeIrp(pIrp); return STATUS_SUCCESS; } // 原有按键数据 pData (PKEYBOARD_INPUT_DATA)pIrp-AssociatedIrp.SystemBuffer; oldLen (ULONG)pIrp-IoStatus.Information; numKeys oldLen / sizeof(KEYBOARD_INPUT_DATA); // 追加一个按键 newLen oldLen sizeof(KEYBOARD_INPUT_DATA); // 构造附加数据 pData[numKeys].MakeCode (USHORT)pInject-ScanCode; pData[numKeys].Flags pInject-KeyDown ? KEY_MAKE : KEY_BREAK; pData[numKeys].UnitId 0; pData[numKeys].Reserved 0; pIrp-IoStatus.Information newLen; IoFreeIrp(pIrp); return STATUS_SUCCESS; }这段代码在完成例程里直接修改SystemBuffer所以设备对象必须设置DO_BUFFERED_IO。读取完成之后原有按键数据会被保留新增的数据追加在尾部然后返回非STATUS_SUCCESS的状态来强制结束 IRP 的处理流程。IoFreeIrp确保 IRP 被释放避免泄漏。4.2 IOCTL 处理函数与按键分发在IRP_MJ_DEVICE_CONTROL中根据控制码解析应用程序传入的数据然后通过设备栈向下构造一个 IRP 来触发对IRP_MJ_READ的再次处理。NTSTATUS KbdDispatchDeviceControl(PDEVICE_OBJECT pDeviceObj, PIRP pIrp) { PIO_STACK_LOCATION irpSp IoGetCurrentIrpStackLocation(pIrp); ULONG ioctlCode irpSp-Parameters.DeviceIoControl.IoControlCode; PVOID inputBuffer pIrp-AssociatedIrp.SystemBuffer; ULONG inputLen irpSp-Parameters.DeviceIoControl.InputBufferLength; if (ioctlCode IOCTL_KBD_INJECT_KEY) { PKEYBOARD_INJECT_DATA pData (PKEYBOARD_INJECT_DATA)inputBuffer; if (inputLen sizeof(KEYBOARD_INJECT_DATA)) { // 下发一次伪造的键盘IRP InjectKeyPress(pDeviceObj, pData); pIrp-IoStatus.Status STATUS_SUCCESS; pIrp-IoStatus.Information 0; } } IoCompleteRequest(pIrp, IO_NO_INCREMENT); return STATUS_SUCCESS; }InjectKeyPress的实现里我会用IoBuildAsynchronousFsdRequest构造IRP_MJ_READ把过滤设备附加的下层设备对象作为目标并在完成例程中注入按键数据。核心逻辑就是上面展示的KbdFilterReadCompletion。这里要特别注意 IRP 堆栈位置的设置一个笔误就可能导致蓝屏。4.3 应用层调用端的编写VS2013 控制台工程应用层代码比较常规重点是把 IOCTL 控制码和设备路径匹配上。我用的是\\.\MyKbdInject这个符号链接。#include windows.h #include iostream #include ..\inc\kbd_filter_ioctl.h int main() { HANDLE hDevice CreateFileA(\\\\.\\MyKbdInject, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hDevice INVALID_HANDLE_VALUE) { std::cout 无法打开驱动设备错误码: GetLastError() std::endl; return -1; } KEYBOARD_INJECT_DATA data { 0 }; data.ScanCode 0x1E; // A键 data.KeyDown TRUE; DWORD bytesReturned 0; DeviceIoControl(hDevice, IOCTL_KBD_INJECT_KEY, data, sizeof(data), NULL, 0, bytesReturned, NULL); // 模拟按下后需要间隔再释放 Sleep(50); data.KeyDown FALSE; DeviceIoControl(hDevice, IOCTL_KBD_INJECT_KEY, data, sizeof(data), NULL, 0, bytesReturned, NULL); CloseHandle(hDevice); return 0; }这个测试程序跑通之后你会看到所有窗口里都自动打印出一个“a”字母。先用记事本测试不要在重要软件里直接测试我之前在远程桌面里测按一次 A 键远端直接弹出一串 a差点把正在写的文档覆盖掉。4.4 驱动的安装与加载驱动编译出来是.sys文件在测试机上加载需要先创建一个服务用管理员权限运行命令行。sc create MyKbdFilter type kernel binPath C:\drivers\MyKbdFilter.sys sc start MyKbdFilter测试完成后删除服务sc stop MyKbdFilter sc delete MyKbdFilter如果目标测试机开启了强制签名校验Windows 10 及以上默认开启可以在启动菜单里选择“禁用驱动程序强制签名”模式进入测试环境或者用测试证书给驱动签名。为了方便调试我一般会在开发机装一个 Win7 虚拟机关闭签名验证来跑驱动开发效率高很多。5. 常见问题与排查技巧实录5.1 驱动加载失败错误码 577 或系统找不到指定文件出现错误码 577Windows 无法验证此文件的数字签名时原因基本只有一个驱动没有签名或签名无效。优先检查是否开启了测试签名模式。bcdedit /set testsigning on然后重启在命令行里执行winver或者bcdedit确认测试模式已经开启。如果还是不行检查驱动编译的架构是否匹配x86 驱动不能在 x64 系统加载以及 WDK 版本与系统版本的兼容性。5.2 设备路径错误导致 CreateFile 失败我用CreateFileA打开驱动设备时路径必须写\\\\.\\MyKbdInjectC 字符串里的\\\\.\\实际上是\\.\。如果路径写错返回错误码 2文件找不到这时先检查符号链接是否创建成功可以用 WinObj 工具查看\??\目录下是否存在MyKbdInject。没有符号链接的常见原因是驱动加载后DriverEntry中途返回了失败。5.3 注入按键后系统卡死或蓝屏DRIVER_IRQL_NOT_LESS_OR_EQUAL蓝屏代码DRIVER_IRQL_NOT_LESS_OR_EQUAL通常是在处理 IRP 的过程中访问了分页内存或者 IRP 被重复完成了。我遇到过的案例是在KbdFilterReadCompletion里调用了IoFreeIrp之后又在外层派发函数里对同一个 IRP 调用了IoCompleteRequest导致双重完成系统直接蓝屏。这个问题的排查思路是给驱动加_SEH异常捕获没有意义因为内核态蓝屏不经过异常处理最可靠的做法是在每个可能返回的地方检查 IRP 状态用 WinDbg 配合.dump抓内核转储定位到具体出错的 IRP 地址和派遣函数。5.4 按键模拟不生效但驱动加载正常这个问题十有八九出在附加设备对象时没有指定正确的DeviceName。Windows 键盘设备可能不止一个如果你附加到错误的设备对象上按键还是从底层真实键盘上走你注入的数据永远不会被读取。我的排查方法是启动调试器在DriverEntry里遍历系统所有键盘设备对象打印它们的名称。实际测试中发现有些机器上键盘设备是KeyboardClass1而不是KeyboardClass0所以不能写死最好通过IoGetDeviceObjectPointer动态查找。5.5 会话隔离与权限问题如果应用层以普通用户权限运行向\\.\MyKbdInject发送 IOCTL 时会被拒绝因为驱动设备默认的安全描述符只允许 SYSTEM 和管理员访问。解决方案是用管理员权限运行应用层程序或者在驱动初始化时调用IoCreateDevice后设置SecurityDescriptor允许所有人访问。// 设置安全的 DACL允许所有用户访问 pDeviceObj-Characteristics | FILE_DEVICE_SECURE_OPEN;不过从工程角度我通常只是用管理员运行了事毕竟驱动都加载了应用层给个管理员权限也不算什么负担。6. 实操心得与扩展驱动级模拟按键不是一个很难的项目但耐着性子把 IRP 处理流程啃下来收益很大。做完它之后你对 Windows 输入系统、设备栈、IRP 的生命周期都会有比绝大多数应用层程序员深刻得多的理解。几点经验想分享给尝试这个项目的朋友内联函数和宏在驱动里尽量少用编译优化级别调到/Od调试时看到的行为才和源逻辑一致。Driver Verifier是个好东西但不要一上来就开启全部选项先开IRP Logging和Deadlock Detection能帮你省几十个小时。多做快照备份驱动蓝屏的恢复过程很耽误时间用 VM 虚拟机做开发环境能大幅提升效率。申请一个真正的 EV 签名证书不要拖到最后一刻才申请。没有签名驱动在长期运行场景下基本寸步难行。这个驱动现在已经托管在我的私有仓库里后面打算扩展两个方向增加对鼠标绝对位置注入的支持以及通过映射内核回调实现跨会话的按键广播。如果你也正在搞内核输入注入遇到具体问题可以留言聊项目里那些坑我基本都踩过一遍了。本文还有配套的精品资源点击获取
返回列表