20、键盘过滤驱动:键盘设备栈、按键过滤、按键记录与防护
键盘过滤驱动,这名字听着挺唬人。其实说白了,就是在键盘输入这条路上,我们插了一脚。你按下一个键,系统怎么知道?中间经过了多少环节?我们又能做什么?今天咱们就把它聊透。
我个人习惯把这类驱动叫做“中间人驱动”。它不生产数据,只是数据的搬运工——顺便还能看看、改改、拦拦。嗯,就是这么个角色。
20.1 键盘设备栈:一条消息的旅程
你按下一个键,比如字母'A'。这个物理信号先被键盘硬件捕获,然后通过USB或PS/2接口传给系统。但系统内部是怎么流转的?
我画了一张图,你看完就明白了。
看到那个黄色的框了吗?那就是我们过滤驱动可以插入的位置。我习惯把它挂在端口驱动和类驱动之间,或者直接绑在类驱动上面。为什么?因为这里的数据最“新鲜”,还没被上层处理过。
关键点:键盘设备栈是一个典型的WDM设备栈。我们的过滤驱动通过IoAttachDeviceToDeviceStack把自己挂上去。挂上去之后,所有IRP都得先经过我们。
20.2 按键过滤:三种核心手法
过滤驱动能做什么?我总结了三种最常见的操作。你在项目中遇到的需求,基本跑不出这三个圈子。
| 操作类型 | 实现方式 | 典型场景 |
|---|---|---|
| 按键拦截 | 在DispatchRead中完成IRP后,不传递 | 屏蔽特定快捷键、防误触 |
| 按键修改 | 修改IRP中的扫描码/虚拟键码 | 按键映射、键盘布局切换 |
| 按键记录 | 复制IRP数据到缓冲区,再传递原IRP | 键盘记录器、安全审计 |
我当年做第一个键盘过滤驱动时,犯了个低级错误——直接修改了IRP的缓冲区,结果上层拿到的数据全乱了。后来才明白,要复制一份数据出来处理,别动原数据。除非你明确想改按键。
20.3 按键记录:怎么做到不漏键?
按键记录,说白了就是偷看。但偷看也得有技术含量。你不能丢数据,不能影响正常输入,还不能被轻易发现。
我常用的做法是这样的:
// 伪代码:按键记录核心逻辑
NTSTATUS DispatchRead(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
PIO_STACK_LOCATION irpStack = IoGetCurrentIrpStackLocation(Irp);
// 1. 先设置完成例程
IoCopyCurrentIrpStackLocationToNext(Irp);
IoSetCompletionRoutine(Irp, OnReadComplete, Context, TRUE, TRUE, TRUE);
// 2. 传递IRP到下层
return IoCallDriver(NextDeviceObject, Irp);
}
NTSTATUS OnReadComplete(PDEVICE_OBJECT DeviceObject, PIRP Irp, PVOID Context) {
if (Irp->IoStatus.Status == STATUS_SUCCESS) {
PKEYBOARD_INPUT_DATA data = (PKEYBOARD_INPUT_DATA)Irp->AssociatedIrp.SystemBuffer;
ULONG count = Irp->IoStatus.Information / sizeof(KEYBOARD_INPUT_DATA);
// 3. 在完成例程中复制数据
for (ULONG i = 0; i < count; i++) {
LogKeyPress(&data[i]); // 记录到缓冲区或文件
}
}
return STATUS_SUCCESS;
}
我的经验:完成例程里做记录,一定要快。我曾经在完成例程里写日志文件,结果导致系统键盘响应变慢。后来改成用环形缓冲区+DPC延迟写入,问题就解决了。
20.4 按键防护:反键盘记录器
有矛就有盾。既然我们能做键盘记录,那别人也能。怎么防?
我做过一个反键盘记录的小工具,思路是这样的:
- 检测异常过滤驱动:遍历键盘设备栈,检查是否有未知的过滤驱动挂载。如果发现不是系统自带的驱动,就报警。
- 保护IRP路径:在关键IRP上设置自己的完成例程,检查是否有其他驱动篡改数据。
- 加密传输:在驱动层对按键数据进行简单加密,用户态程序再解密。这样即使被记录了,拿到的也是乱码。
嗯,第三种方法最实用。我建议你在做安全类产品时,优先考虑加密方案。因为检测总有漏网之鱼,但加密是硬防护。
注意:反键盘记录器不能做得太“霸道”。我曾经见过一个驱动,把所有非微软签名的过滤驱动都拦了,结果用户的输入法驱动也挂了。用户投诉电话直接打爆。所以,白名单策略比黑名单更安全。
20.5 实战中的坑与避坑指南
做键盘过滤驱动,有几个坑我踩过,你千万别再踩了。
坑一:IRP完成顺序搞反了。 我刚开始做时,在完成例程里又调用了IoCompleteRequest,结果蓝屏。记住:完成例程里不要碰IRP的完成状态,除非你知道自己在做什么。
坑二:忘记处理PNP IRP。 键盘设备可能会热插拔(USB键盘)。如果你只处理了Read IRP,没处理PNP IRP,设备拔掉再插上,你的过滤驱动就失效了。我建议你在DriverEntry里注册一个设备接口通知,动态绑定新接入的键盘设备。
坑三:在DISPATCH_LEVEL做分页内存访问。 完成例程可能运行在DISPATCH_LEVEL,这时候访问分页内存会崩溃。我习惯用非分页池分配缓冲区,或者用锁保护共享数据。
20.6 一个完整的过滤驱动骨架
说了这么多,给你一个能直接用的骨架。这个骨架实现了按键记录和拦截两种功能,你根据自己的需求改改就能用。
// 键盘过滤驱动骨架 - 仅展示核心结构
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
DriverObject->MajorFunction[IRP_MJ_CREATE] = DispatchPass;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = DispatchPass;
DriverObject->MajorFunction[IRP_MJ_READ] = DispatchRead;
DriverObject->MajorFunction[IRP_MJ_PNP] = DispatchPnp;
DriverObject->DriverUnload = DriverUnload;
// 绑定到所有键盘设备
IoRegisterPlugPlayNotification(...);
return STATUS_SUCCESS;
}
NTSTATUS DispatchRead(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
// 设置完成例程,记录按键
IoCopyCurrentIrpStackLocationToNext(Irp);
IoSetCompletionRoutine(Irp, OnReadComplete, DeviceObject->DeviceExtension, TRUE, TRUE, TRUE);
return IoCallDriver(((PDEVICE_EXTENSION)DeviceObject->DeviceExtension)->LowerDeviceObject, Irp);
}
NTSTATUS OnReadComplete(PDEVICE_OBJECT DeviceObject, PIRP Irp, PVOID Context) {
if (Irp->IoStatus.Status == STATUS_SUCCESS) {
PKEYBOARD_INPUT_DATA keys = (PKEYBOARD_INPUT_DATA)Irp->AssociatedIrp.SystemBuffer;
ULONG numKeys = Irp->IoStatus.Information / sizeof(KEYBOARD_INPUT_DATA);
for (ULONG i = 0; i < numKeys; i++) {
// 拦截特定按键(比如Win键)
if (keys[i].MakeCode == 0x5B) { // 左Win键
keys[i].Flags |= KEY_BREAK; // 模拟释放,实现拦截
}
// 记录所有按键
LogToBuffer(&keys[i]);
}
}
return STATUS_SUCCESS;
}
核心要点回顾:
- 键盘过滤驱动通过绑定设备栈实现中间人攻击/防护
- 按键记录在完成例程中完成,注意IRQL和内存访问限制
- 按键拦截通过修改IRP数据或直接完成IRP实现
- 防护手段包括检测、加密、白名单三种策略
- 务必处理PNP IRP,支持热插拔场景
键盘过滤驱动这块,说白了就是“你按的每个键,我都知道”。但知道之后做什么,是记录、拦截还是防护,取决于你的业务场景。我建议你从最简单的按键记录开始练手,跑通了再往上加功能。别一上来就想搞个大而全的驱动,容易翻车。
嗯,今天就聊到这儿。代码骨架给你了,剩下的就是动手写、动手测。遇到问题别慌,先看IRP的流转对不对,再看完成例程的IRQL对不对。这两点抓住了,键盘过滤驱动就稳了。