ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++驱动级模拟按键:基于VS2013与WDK8.1的内核实现

C++驱动级模拟按键:基于VS2013与WDK8.1的内核实现 简介基于VS2013的C驱动级模拟按键项目面向需要掌握Windows内核输入模拟的开发者解决常规用户态API无法穿透键盘钩子、需要直接访问硬件层的问题。源码结合WinRing0用户态驱动接口库通过发送键盘扫描码实现按键按下与释放的模拟并附连发功能设计与管理员权限注意事项。包体共28个文件约16.72MB主要以7个C/C头文件、4个C源程序、2个DLL动态库、1个sys驱动、1个lib库及VS2013工程文件sln/vcxproj组成同时包含日志和说明文档便于直接打开工程进行编译调试。WinRing0x64.sys、dm.dll、WinRing0Debug.dll等文件已集成省去环境配置麻烦。目前已有2586人学习下载。通过阅读KeyMouse.cpp、auto keyDlg.cpp等核心源码可以理清驱动级模拟按键的调用链路、钩子对抗思路及定时连发的实现细节配合工程内的调试日志还能快速定位权限不足或扫描码写入失败等常见问题适合作为驱动输入模拟方向的参考项目和入门模板。 虽然平时聊驱动开发的人不算多但驱动级模拟按键这个需求我这几年前前后后折腾过好几轮。第一次意识到应用层模拟不行是在做一个自动化测试工具时——用SendInput往目标窗口发按键结果目标程序完全没反应换上keybd_event、PostMessage也一样后来才确认是目标程序直接过滤了应用层消息。这篇文章就把我用 C 配合 VS2013 写驱动级模拟按键的完整思路、关键代码骨架和踩坑过程整理出来给同样被这个问题卡住的朋友一个参考。要说明的是这套方案只适用于你拥有合法测试权限的软件、自动化测试、无障碍辅助类场景不要拿去做游戏作弊或绕过安全机制的事。1. 为什么普通模拟按键不顶用应用层模拟的三个边界在决定上驱动之前得先把问题定位清楚。很多人第一反应是SendInput不行就换PostMessage再不行就mouse_event配合keybd_event全上一遍其实方向从一开始就偏了。应用层模拟按键的边界是结构性的不是换哪个 API 就能绕过去的。1.1 应用层模拟的三条致命缺陷第一条是消息机制过滤。keybd_event和SendInput最终会走系统的输入注入通道但很多安全软件、游戏反作弊、甚至普通业务软件都会在应用层挂钩GetMessage、PeekMessage或者调用SetWindowsHookEx监控键盘消息。你从应用层发出去的消息在别人眼里就是「非物理键盘产生」的信号从时间戳到LLKHF_INJECTED标志位都有痕迹可查。第二条是会话隔离问题。SendInput默认是往当前会话的输入队列里塞消息如果你的自动化程序需要跨会话操作比如通过服务进程在 Session 0 里操作 Session 1 的用户界面应用层 API 基本就失效了。这也是很多自动化工具在服务场景下死活模拟不了按键的根源。第三条是高权限程序防护。某些程序会在应用层直接调用GetAsyncKeyState轮询物理按键状态你通过消息机制注入的按键这类轮询接口根本感知不到。为了应对这种场景只能从内核态想办法。1.2 驱动级模拟的切入点在哪驱动级模拟按键的核心思路是绕过应用层的消息机制直接往键盘设备栈里插入伪造的输入数据包。在 Windows 上这通常通过键盘过滤驱动实现——把驱动挂在键盘类驱动之上让伪造的按键数据从设备栈上游一路冒到系统输入层系统输入处理器会认为这些按键是真实硬件产生的。这里需要强调一下「为什么能骗过系统」。Windows 的输入系统在识别按键来源时最底层判断依据是KEYBOARD_INPUT_DATA结构这个结构由键盘驱动上报给系统。只要你在驱动层构造出合法的数据结构系统上层根本不会去查「这个数据包是不是真实硬件产生的」。这和应用层模拟有着本质区别也是驱动级方案能绕开大部分应用层过滤的原因。2. VS2013 时代的驱动开发环境工具链与调试环境搭建标题里点名了 VS2013这块确实有点年头了但老工具链在特定场景下依然能用。重点是版本匹配问题和调试环境配置。2.1 VS2013 对应哪个 WDKVS2013 对应的是 WDK 8.1。注意安装顺序——要先装 VS2013再装 WDK 8.1这样 WDK 的 VS 插件才能正确识别到编译器环境。如果反过来装经常出现「New Driver Project」模板不出现的问题。WDK 8.1 装好后在 VS2013 里新建项目时能看到Visual C - Windows Driver - Kernel Mode Driver, Empty (KMDF)模板。KMDFKernel Mode Driver Framework是微软提供的驱动开发框架相当于内核驱动的封装层比直接写 WDM 省心不少。但我们这里选择的实际上更接近「过滤驱动」场景KMDF 提供了现成的IO_QUEUE_CONFIG和请求处理机制直接使用就行。环境版本这块有一个经典坑位WDK 8.1 默认使用_AMD64_、_X86_这类预定义宏而 VS2013 在 2013 Update 2 之后才支持完整的驱动项目构建。如果你装的是老版 VS2013无 Update建议先升级到 Update 5否则编译驱动时可能遇到奇怪的链接错误。2.2 驱动测试签名与调试器配置驱动加载绕不开签名问题。开发阶段最简单粗暴的方式是开启测试签名模式bcdedit /set testsigning on设置完重启Windows 会进入测试签名模式允许加载未签名的测试驱动。注意这个命令需要管理员权限运行重启后系统桌面右下角会出现「测试模式」水印属于正常现象。调试器建议用 WinDbg配合虚拟机的 COM 口串行调试。我的习惯是宿主机装 VS2013 WDK WinDbg虚拟机VMware 或 Hyper-V里跑 Windows 7 x64虚拟机添加一个命名管道串口WinDbg 通过\\.\pipe\com_1连接。驱动开发没有调试器基本等于盲人摸象因为内核态一旦出错大概率直接蓝屏普通打印函数根本不可用。提示Windows 7 x64 是驱动开发调试最省心的系统。Win10/11 上即便开了测试签名模式新版本还引入了内存完整性HVCI等限制老驱动加载会有额外阻碍新版本内核 API 变化也容易踩坑。3. 核心机制拆解键盘设备栈与 IOCTL 通信链路驱动本身的骨架不难写难的是想明白按键数据是怎么从驱动代码走到系统输入层的。我先从机制层面把链路拆开。3.1 键盘设备栈的数据流向在 Windows 里键盘输入的数据链路大致是物理键盘 - 键盘类驱动kbdclass- 系统输入处理器win32k/input- 应用层消息队列键盘设备有一个设备栈kbdclass是最底层的类驱动负责和硬件端口打交道。如果我们写一个过滤驱动挂载在kbdclass之上所有物理键盘上报的数据包都会先经过我们的过滤层我们可以选择原样放行正常按键或修改替换。但模拟按键要做的并不是「被动等物理按键经过再修改」而是要「主动构造一个数据包从驱动层塞进设备栈」。关键操作是在过滤驱动里调用KbdClass的设备扩展拿到输入数据包后通过KeyboardSendInput或直接构造 IRP 的方式将数据往系统输入层送。这里有一个隐蔽的设计过滤驱动不一定要「挂在」键盘设备栈上也可以作为一个独立的功能驱动自己创建一个控制设备然后通过 IOCTL 和应用层通信收到应用层下发的按键请求后直接构造KEYBOARD_INPUT_DATA结构并调用Kbdclass的注入机制。3.2 应用层与内核层的通信设计驱动级模拟方案里应用层负责发指令内核层负责执行注入。通信方式选 IOCTL 是最常规的。应用层通过CreateFile打开驱动设备用DeviceIoControl下发控制码驱动在IRP_MJ_DEVICE_CONTROL分发例程里解析参数。我们定义的数据结构分为两种单键控制包和组合键控制包// 共享头文件应用层与驱动都包含 typedef struct _KEY_EVENT_DATA { USHORT MakeCode; // 扫描码 USHORT Flags; // KEY_UP / KEY_DOWN ULONG Reserved; } KEY_EVENT_DATA, *PKEY_EVENT_DATA; #define IOCTL_SIMULATE_KEY \ CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS) #define KEY_UP 0x0001 #define KEY_DOWN 0x0002用USAGE_PAGE与USAGEHID 用法 ID来表示按键会更贴近硬件语义但扫描码方案在kbdclass层面兼容性更好。对不熟悉 HID 的读者可以简单理解为设备上报的是键盘的物理扫描码Windows 再映射成虚拟键码。4. 驱动骨架与核心例程的实现思路驱动代码的完整工程量其实不小但核心例程就那么几个。我把关键部分拆开讲可以直接照着搭。4.1 过滤驱动的骨架用 KMDF 创建驱动后DriverEntry里主要做三件事创建设备对象、设置分发例程、提供卸载函数。设备对象名建议用\\Device\\KeySimDriver同时创建符号链接\\DosDevices\\KeySimDriver方便应用层CreateFile访问。NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { NTSTATUS status; PDEVICE_OBJECT deviceObj NULL; UNICODE_STRING deviceName; UNICODE_STRING symLinkName; RtlInitUnicodeString(deviceName, L\\Device\\KeySimDriver); RtlInitUnicodeString(symLinkName, L\\DosDevices\\KeySimDriver); status IoCreateDevice(DriverObject, sizeof(DEVICE_EXTENSION), deviceName, FILE_DEVICE_UNKNOWN, 0, FALSE, deviceObj); if (!NT_SUCCESS(status)) return status; IoCreateSymbolicLink(symLinkName, deviceName); DriverObject-MajorFunction[IRP_MJ_CREATE] DispatchCreateClose; DriverObject-MajorFunction[IRP_MJ_CLOSE] DispatchCreateClose; DriverObject-MajorFunction[IRP_MJ_DEVICE_CONTROL] DispatchDeviceControl; DriverObject-DriverUnload DriverUnload; // 初始化设备扩展保存键盘设备对象指针 // ... return STATUS_SUCCESS; }4.2 真正关键的那一步主动构建输入数据包模拟按键能不能生效就看注入这里怎么写。在实现中比较干净的做法是在驱动里先找到键盘类设备的指针然后通过IoCallDriver把构造好的 IRP 发送到kbdclass的输入队列中。不过直接用传统 IRP 构造比较繁琐我在实际项目中参考了更直接的方式——通过Kbdclass的KeyboardSendInput机制或直接构造KEYBOARD_INPUT_DATA数组然后向设备栈上层发送内部 IOCTL通常对应的 IOCTL 是IOCTL_INTERNAL_KEYBOARD_CONNECT相关的调用。简化后的核心流程如下应用层下发按键扫描码驱动收到 IOCTL解析扫描码与按下/抬起标志驱动构造KEYBOARD_INPUT_DATA结构填入按键的MakeCode和Flags调用系统内部注入接口将数据包插入键盘输入流系统输入层认为硬件上报了按键完成一次按键模拟。其中步骤 4 的具体实现有个很实用的手法与键盘类驱动建立连接回调。kbdclass在初始化时会向过滤驱动发送IOCTL_INTERNAL_KEYBOARD_CONNECT过滤驱动可以在该请求中保存一个连接到键盘类驱动的上下文。之后模拟按键时就能通过这个连接调用类驱动的注入函数。不过这一部分涉及内核结构体和函数原型代码量较大最好结合你下载的 WDK 例子kbfiltr一起看那是微软官方的键盘过滤驱动示例比我这里打码的伪代码完整得多。4.3 应用层调用端应用层相对简单核心就是打开设备并发送 IOCTLHANDLE hDevice CreateFile(L\\\\.\\KeySimDriver, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hDevice INVALID_HANDLE_VALUE) { printf(打开驱动失败: %d\n, GetLastError()); return -1; } KEY_EVENT_DATA event { 0 }; event.MakeCode 0x1E; // 以 A 键为例的扫描码 event.Flags KEY_DOWN; DWORD bytesReturned 0; DeviceIoControl(hDevice, IOCTL_SIMULATE_KEY, event, sizeof(event), NULL, 0, bytesReturned, NULL); event.Flags KEY_UP; DeviceIoControl(hDevice, IOCTL_SIMULATE_KEY, event, sizeof(event), NULL, 0, bytesReturned, NULL); CloseHandle(hDevice);注意扫描码这里是 PS/2 兼容扫描码Set 1不是虚拟键码。A 键的 Set 1 扫描码是0x1E如果要发扩展键方向键、功能键等MakeCode 会不同且需要额外设置KEY_EXTENDED等标志位。这里我建议应用层与驱动共享同一个头文件避免两端结构体因为内存对齐不一致导致解析错误。VS2013 项目里可以设置公共包含目录编译驱动的工程和编译应用层的工程都指向同一个头文件目录省去改一处忘一处的麻烦。5. 编译、加载与调试中的实战坑位这套方案从写完代码到真正跑通最大的成本在调试。我把自己踩过的几个典型问题列出来能帮你省掉不少时间。5.1 编译期常见的难题编译内核驱动和编译普通 Win32 程序最大的不同在于——几乎不能依赖 C 运行时库只能用内核 API。如果代码里用了strcpy、malloc、printf链接时大概率报 unresolved external symbol。解决办法是用内核版替代函数strcpy换成RtlCopyMemorymalloc换成ExAllocatePoolWithTagprintf换成DbgPrint。WDK 也提供安全字符串函数RtlStringCchPrintfA需要包含对应的头文件ntstrsafe.h并在源码文件里声明#pragma prefast(disable:__WARNING_OPERATION_NOT_EXPECTED, not needed)还有一个容易忽略的问题VS2013 创建驱动项目时会自动设置_WIN32_WINNT和NTDDI_VERSION但如果版本宏不对某些内核函数找不到正确声明编译报错也很折腾。保持 WDK 项目默认设置就行不要手动去改目标系统版本。5.2 驱动加载失败的排查链路驱动编译成功不代表能装得上。最常见的失败场景是运行时提示「拒绝访问」或加载失败按这个顺序排查第一步确认测试签名模式是否真的生效。在命令行执行bcdedit /enum {current}看testsigning字段是否为Yes。虽然开了测试签名但 x64 系统上驱动仍需带签名只不过可以加载未通过微软签名的测试签名驱动。第二步看系统事件日志。驱动加载失败会在「系统日志」里记录 erro ID比如 7000服务启动失败或 5775签名校验失败。根据日志定位问题方向远比盲目修代码有效。第三步如果驱动是你手动创建的普通内核驱动加载需要先创建服务sc create KeySimDriver type kernel binPath C:\Path\To\KeySimDriver.sys sc start KeySimDriver或者用devcon.exeWDK 自带工具安装。不能像普通 DLL 一样直接双击安装。5.3 调试中容易蓝屏的几个点驱动开发最「刺激」的部分就是蓝屏。我记忆最深刻的几个蓝屏原因第一个是缓冲区越界。应用层下发的 IOCTL 缓冲区在 METHOD_BUFFERED 模式下由系统分配驱动如果假设了缓冲区大小而应用层传的数据更小直接越界访问非分页池内存就蓝给看。case IOCTL_SIMULATE_KEY: if (irpSp-Parameters.DeviceIoControl.InputBufferLength sizeof(KEY_EVENT_DATA)) { status STATUS_BUFFER_TOO_SMALL; break; } // 再访问缓冲区第二个是错误地使用了分页内存。驱动在处理某些 IRQL 级别较高的请求时不能触碰分页内存否则触发PAGE_FAULT_IN_NONPAGED_AREA。惯例是被动级别PASSIVE_LEVEL下才能安全访问分页内存凡是 IOCTL 处理路径里拿到的缓冲区分不清来历时一律用非分页池的内存或者用MmProbeAndLockPages锁定内存。第三个是设备对象没有正确清理。驱动卸载时如果忘了删除符号链接或设备对象可能造成系统崩溃或设备句柄泄漏。DriverUnload里至少要有VOID DriverUnload(PDRIVER_OBJECT DriverObject) { UNICODE_STRING symLinkName; RtlInitUnicodeString(symLinkName, L\\DosDevices\\KeySimDriver); IoDeleteSymbolicLink(symLinkName); IoDeleteDevice(DriverObject-DeviceObject); }5.4 到了 Win10/11 之后的新问题如果目标是 Win10/11老一套代码会遇到几个新状况。最典型的是内核 API 变更——WDK 8.1 里用的某些内部函数到新 WDK 里已经从导出符号表中移除链接时直接找不到符号。另外是 DSE驱动签名强制策略加强测试签名模式虽然还能用但部分安全软件会强制要求关闭测试签名否则自己的驱动加载不了。还有一个常被忽略的问题NtBuildNumber这类导出变量在不同版本间可能变化老代码如果直接引用容易踩坑。我的建议是——如果你是新项目直接用最新版 VS 最新版 WDK 开发API 稳定性好很多VS2013 这套更适合维护老项目或者需要兼容老系统Win7/Server 2008 R2 等的场景。6. 从 IOCTL 到组合键进阶功能实测笔记基础的单键注入跑通后实际项目里马上就面临「组合键怎么发」的问题。CtrlC、AltTab 这类组合键如果只是一前一后发送单键目标程序可能识别不到。组合键的正确姿势是把修饰键按下后再按主键最后抬起修饰键。关键在于按键必须「暂时按住」——即按下修饰键后不能立即抬起要等主键按下并抬起了才松开修饰键。驱动里的处理是收到一个组合键数据结构例如CTRL Ctypedef struct _COMBO_KEY_DATA { USHORT ModifierMakeCode; // 修饰键扫描码 USHORT MainMakeCode; // 主键扫描码 } COMBO_KEY_DATA;驱动收到组合键请求后按顺序构造三个数据包修饰键按下主键按下主键抬起修饰键抬起。这一步在实际自动化测试脚本里极其常用。如果你只是按SendInput的思维去发组合键很容易在时序上出问题——应用层两次DeviceIoControl之间的间隔如果太大目标程序可能识别为单独的按键序列而非组合键。另外我还测试了「忽略物理按键」的过滤场景。过滤驱动在放行链条上可以拦截特定扫描码实现「屏蔽某个按键」的效果。不过这个功能用在生产环境要非常小心一旦驱动误拦截了系统关键按键比如 Win 键系统行为会变得非常诡异。调试时建议先从模拟键开始等链路稳定了再加过滤逻辑。我在实测中还发现一个规律不同程序对扫描码和虚拟键码的依赖不一样。很多老程序直接读扫描码MapVirtualKey映射出来的结果用扫描码注入的方式命中率特别高而现代 UWP 程序有时候对扫描码的响应反而不如虚拟键码。遇到这种场景驱动里可以同时支持两种模式——扫描码模式走上面的链路虚拟键码模式则需要把KEYBOARD_INPUT_DATA替换成系统输入层更上层的模拟机制这一层涉及未公开接口较多我在项目里通常通过测不同参数来找到目标程序最吃哪一套。经验如果你的目标是自研软件或测试环境里的程序优先扫描码方案如果目标程序是 UWP 或 Electron 类可能需要调整为先发扫描码、附带虚拟键码的混合方案具体只能靠实际测试来定没有通吃方案。7. 一些最后想说的话驱动级模拟按键这套东西从原理到落地最大的障碍不在代码本身而在于你能否理解 Windows 输入系统的那条链路。应用层模拟是「往消息队列里塞信封」驱动级模拟是「往邮局内部送件口塞信封」——后者绕过了门卫但也要求你懂邮局内部的路怎么走。搞懂了设备栈、IOCTL 和 KEYBOARD_INPUT_DATA剩下的就只是工程问题。动手实践时我建议按这个顺序推进先把 VS2013 WDK 8.1 的环境搭好把官网的kbfiltr示例编译通过并成功加载到虚拟机然后在此基础上加 IOCTL 接口实现单键注入再扩展组合键最后再考虑过滤和屏蔽。不要一步到位写大而全的驱动内核态出了问题很难排查蓝屏多了心态容易崩。最后分享两个小技巧。调试的时候在关键路径上加DbgPrint然后 WinDbg 里用!dbgprint或远程调试客户端实时看输出比蓝屏后翻 dump 文件高效得多。还有一个是驱动和应用层联调时应用层最好设计一个「按键测试面板」把扫描码和标志位做成参数输入而不是每测一个键就重新编译应用层。我当初就是没做这个每次改按键都重新编译浪费了整整一个下午的时间后来补了个测试面板联调效率直接翻倍。本文还有配套的精品资源点击获取
返回列表