20、键盘过滤驱动:键盘设备栈、按键过滤、按键记录与防护

键盘过滤驱动,这名字听着挺唬人。其实说白了,就是在键盘输入这条路上,我们插了一脚。你按下一个键,系统怎么知道?中间经过了多少环节?我们又能做什么?今天咱们就把它聊透。

我个人习惯把这类驱动叫做“中间人驱动”。它不生产数据,只是数据的搬运工——顺便还能看看、改改、拦拦。嗯,就是这么个角色。

20.1 键盘设备栈:一条消息的旅程

你按下一个键,比如字母'A'。这个物理信号先被键盘硬件捕获,然后通过USB或PS/2接口传给系统。但系统内部是怎么流转的?

我画了一张图,你看完就明白了。

键盘设备栈结构图 物理键盘硬件 端口驱动 (i8042prt.sys / kbdhid.sys) 类驱动 (kbdclass.sys) 过滤驱动 (我们的位置) 用户态应用程序 数据流向:硬件 → 端口驱动 → 类驱动 → 用户态

看到那个黄色的框了吗?那就是我们过滤驱动可以插入的位置。我习惯把它挂在端口驱动和类驱动之间,或者直接绑在类驱动上面。为什么?因为这里的数据最“新鲜”,还没被上层处理过。

关键点:键盘设备栈是一个典型的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对不对。这两点抓住了,键盘过滤驱动就稳了。