自15年前Windows 3.1推出以来,Win32全局钩子的实现始终是32位Windows程序设计中最富挑战性的课题之一。全局钩子可以捕获系统向应用程序发送的消息(比如键盘和鼠标操作、系统设置改变等等),因而被广泛地应用在各种商用应用软件中,其中又以金山词霸的“屏幕取词”功能最为国人所熟知。另一方面,黑客们不断地开发出更隐蔽、更强大的钩子程序来盗取他人的密码和隐私。在互联网上我们可以轻易找到成百上千写法各异的Delphi 钩子程序,可到目前为止,它们中没有一个是可以同时正确运行于Windows9x/Me和NT/2000/XP下的,尤其是最常用的Windows XP下的全局钩子的例程,大多存在着各种各样的问题,最终导致它们或在运行时出错,或只能实现局部钩子的功能。
本文将从Win32钩子的基础概念和Win32进程内存管理的特点讲解起,最终为读者提供一个完整的、可以运行在所有32位Windows平台下的全局鼠标钩子程序(一个TOEFL报名防盲抢计时器)。通过这个例子我们将会看到,在Win32全局钩子的实现中,对钩子本身的正确理解和使用只占到了大约一半的工作量。
下面就让我们揭开钩子的神秘面纱。我们首先剖析钩子的工作机制,再讨论在Delphi 下的每一处实现方法及其必要性。
什么是钩子?钩子如何工作?
Win32钩子的英文名称是Win32 Hooks,它是Windows操作系统消息驱动(Message Driven)机制的重要应用,也是Windows系统的重要特色。当Windows产生或收到用户操作的某一事件时,就会向应用程序发送一条消息(Message)。将会收到这条消息的应用程序被串在一条链(Chain)上。这条链是一个类似于队列(FIFO,First-In, First-Out)的数据结构,首先由排在队首的程序接收消息,然后逐个向后传递,直到队尾,这样就形成了消息处理的优先级。这种思想事实上是从 8086时代的中断优先级思想发展而来的,具有很强的实用性和合理性。
现在我们希望能在消息到达应用程序之前,亦即在刚刚提到的链首之前,“挂”上一些“钩子”,来捕获、处理、甚至于截断系统消息。钩子就像是插队的应用程序,它和普通应用程序的不同之处在于:它可以在消息到达应用程序之前就捕获消息。这个特性也是钩子最有用的地方之一。设想,当用户在键盘上按下一个键时,我们可以让它在到达应用程序时变成另一个,或者干脆不让应用程序知道这回事。
当然,钩子也可以挂不止一个,我们约定最后挂上去的钩子总在最前面(也就是第一个得到和处理系统消息)。
安装和卸载钩子的方法
刚才我们提到了挂钩子,事实上应该说成安装钩子(Installing a hook)更加标准。在我们的程序或任务结束时,我们也应该“脱钩”,亦即卸载钩子(Unhook a hook)。这两项工作是通过调用API函数(API Call)来完成的,在Win32中,我们应该使用SetWindowsHookEx和UnhookWindowsHookEx。它们在我们的程序中的调用方法为:
HookHandle := SetWindowsHookEx(WH_MOUSE,MouseProc,hInstance,0);
UnhookWindowsHookEx(HookHandle);
对于SetWindowsHookEx,我们需要提供钩子的类型(如WH_MOUSE)、钩子的过滤函数(Filter Function)的地址(如MouseProc)、调用钩子的应用程序实例句柄(Handle,如hInstance),以及钩子需要作用的范围的句柄(本例中的0表示全局)。
首先我们来了解钩子的类型及其作用范围。不同类型的钩子所支持的作用范围是有所不同的,Windows一共提供了12类钩子,用来捕获12类不同的消息。具体的钩子及其类型约束请参阅MSDN中的《Win32 Hooks》。在本例中,我们使用的是WH_MOUSE类型的钩子,它用于捕获鼠标操作。它同时支持线程和全局范围作用,为了让其作用范围为全局,我们设置第四个参数为0即可。如果要让这个钩子作用于某个线程,我们则要提供线程句柄。
安装钩子时会返回一个句柄,在卸载时用得上,卸载钩子的方法很简单,将句柄作为参数调用UnhookWindowsHookEx即可。
过滤函数(Filter Function)或回调函数(Callback Function)
在安装钩子时,我们提供了钩子的所谓过滤函数(Filter Function)的地址。在本例中,我们提供的是MouseProc,这个函数是要我们自己编写的。我们要在这个函数中指定当我们的钩子接收到系统消息时要怎么做。由于这个函数通常是由Windows调用的,也被形象地称为回调函数(Callback Function)。有些参考书上提及这个函数的名称是固定的,事实上在Delphi 中并没有这个限制。
本例中的MouseProc是这样写的:
function MouseProc(nCode:integer; wParam:WParam; lParam:LParam): LRESULT; stdcall;
虽然函数的名称可以任取,但函数的原型(prototype)是固定的。在我们的钩子每一次被系统消息触发(trigger)时,我们同时得到的有 nCode,wParam和lParam这三个参数。其中nCode是一个整数,它包含有一些额外信息,不过我们只需要按下面的规则来处理它就可以了:如果nCode小于零,我们就原封不动地用CallNextHookEx来把参数传给下一个钩子。钩子链是由Windows管理的,其中的机制我们并不需要了解。wParam和lParam则包含了更多我们关心的的内容。在本例中,wParam包含了鼠标事件的具体类型,比如到底是左击还是右击,单击还是双击等等,有一系列常量与之对应,只要看看源码就不难理解。lParam是一个指针,它指向一个TMouseHookStruct,这个结构体中包含了鼠标的一系列当前状态,比如位置坐标信息。
引入动态链接库(Dynamic Link Library)必要性
在本例中,由于我们要安装一个全局钩子,必须将和钩子有关的函数放在一个动态链接库(DLL,Dynamic Link Library)中。所谓动态链接库,就是在程序需要时可以被装入内存的函数库。通常我们把这个过程称为注入(Injection)。由于32位 Windows下的每个应用程序都拥有自己的地址空间,无法相互访问,如果我们把钩子放在EXE程序中就不能监控其它程序鼠标操作了,所以必须引入 DLL。
顺带提及,使用不同于本文阐述的、更加复杂的方法,是可以不引入DLL而编写一个全局键盘钩子的。但编写全局鼠标钩子必须引入DLL,这也是鼠标操作更加安全的原因。在某些银行的网上支付系统中,要求用户在软键盘上用鼠标输入密码,就是这个原因。
内存映像文件(File Mapping)
既然我们要把钩子放在DLL中,钩子和程序就变成了两个独立的线程(Thread),一个是DLL,一个是EXE。这时就不可避免地需要两个进程间通信的技术。在Windows 9x/Me下,我们可以通过声明一个共享数据段来共享数据;但在Windows NT/2000/XP环境下,我们必须使用所谓的内存映像文件(File Mapping)技术。
内存映像文件技术隶属于Win32 API,它可以创建一块共享内存,供所有Windows应用程序访问。每一块共享内存都有一个文件名,不同的程序可以通过这个文件名来定位这块内存。这块共享内存实质上可以是一个文件系统中的文件,也可以是Windows页面文件(Page file)中的一块。
在程序中我们编写了一个名为untMouseHookConst.pas的单元,其中的下列代码是值得说明的:
const MappingFileName='_TOEFLDllMouse';
type TSharedMem=record
InstHandle :DWord;
MessageID :DWord;
end;
这个单元同时被DLL和EXE引用,其中的MappingFileName就是内存映像文件的文件名,而TSharedMem类型则是我们要创建的共享内存在实际存储时采用的结构体类型。InstHandle用于在程序开始时由EXE向DLL传递EXE主窗口的句柄,这样当DLL钩子收到系统消息时可以使用SendMessage函数向EXE回传消息,InstHandle就是用于定位EXE句柄。MessageID则是我们自定义的 SendMessage用户消息编号。
具体的实现方法请参考源码及其注释,阅读时请特别注意内存映像文件产生和读取的时序关系。
进程间的通信:使用SendMessage发送消息
创建内存映像文件的目的就是实现进程间的通信。在本例中,我们通过API函数SendMessage从DLL发送消息,在EXE中通过接管WndProc过程来处理消息。
本例中SendMessage的调用语句为:
SendMessage(pSharedMem^.InstHandle,pSharedMem^.MessageID,0,0);
为了能让接收端(即EXE)顺利地得到SendMessage所提供的参数,这里要求所有的参数都必须在内存映像文件中。参数中后面的两个0分别是 wParam和lParam,这里我们不用所以赋予0,它和过滤函数中的wParam和lParam是完全类似的。事实上,我们可以直接把过滤函数 wParam和lParam复制给SendMessage,当然必须保证数据都保存在内存映像文件中。之所以采用wParam和lParam这两个名称,是为了在形式上使得一系列API函数具有统一的接口,方便传递数据。从数据结构上看,wParam和lParam只是一般的整型变量而已。
此外,MessageID是被定义为WM_User+121的常量,WM_USER表示此消息是用户消息,121是随机取的偏移量,一般在100-1024间选取都是可以的。
进程间的通信:接管WndProc以接收消息
在接收端(即EXE),我们采用接管WndProc的方法来处理SendMessage发出的消息。由SendMessage发出的消息是同步到达EXE的,所以不必担心延迟问题,详见MSDN中的有关SDK文档。
WndProc是窗口过程(Window Procedure)的缩写,它是应用程序真正开始动作的地方。前已述及,Windows程序是消息驱动的,每当收到一条消息程序就会执行 WndProc,在WndProc中进一步调用更多的函数和过程来完成更多操作。默认情况下,Delphi 会帮助程序员自动完成WndProc,但为了能处理自定义消息,我们需要使用override方法来接管WndProc,完成需要处理的特殊情况,再将其它的情况用Inherited方法继承回去。
在EXE主窗口的单元中,我们需要把如下的语句放置在公用声明(Public Declaration)段:
procedure WndProc(var Messages:TMessage); override;
这表明我们要接管WndProc。而在WndProc完成必要的工作后,必须加上Inherited语句。
例程:TOEFL报名防盲抢计时器
TOEFL报名防盲抢(Blind Try)计时器在每一次用户单击鼠标左键后开始数秒,并用一个总在最前(Always On Top)的窗体显示于桌面上,这样用户就可以很清楚自己的操作是否达到了网站给出的最短间隔时间。
这个程序中需要额外注意的一个问题是刚刚提到过的鼠标事件的具体类型,即过滤函数中的wParam参数。在我们判断时用到了两个事件:WM_LBUTTONUP和WM_NCLBUTTONUP。如果我们只判断前者,将会发现当我们单击屏幕上的标题栏、任务栏等区域时,计数器将不会重新计时。这是因为这些区域被称为非客户区(Non-client Area),单击这些区域的事件被定义为WM_NCLBUTTONUP。
DLL模块源代码:dllTOEFLHook.dpr
library dllTOEFLHook;
uses
SysUtils,
Windows,
untMouseHook in 'untMouseHook.pas',
untMouseHookConst in 'untMouseHookConst.pas';
exports
StartHook,StopHook;
begin
end.
DLL模块源代码:untMouseHook.pas
unit untMouseHook;
interface
uses Windows, Messages, Dialogs, SysUtils, untMouseHookConst;
var hMappingFile :THandle; //Handle for Mapping file
pSharedMem :^TSharedMem; //Pointer for Shared Memory
HookHandle :HHook; //Handle for the hook
function StartHook(Sender:HWnd; MessageID:word):BOOL; stdcall;
function StopHook:BOOL; stdcall;
implementation
function MouseProc(nCode:integer; wParam:WParam; lParam:LParam): LRESULT; stdcall;
begin
Result := 0;
if nCode<0 then Result:=CallNextHookEx(HookHandle,nCode,wParam,lParam);
//Rule of API call, which referred to Win32 Hooks topic in MSDN
if ( (wParam = WM_LBUTTONUP ) or ( wParam = WM_NCLBUTTONUP) ) then
SendMessage(pSharedMem^.InstHandle,pSharedMem^.MessageID,0,0);
//Sends Message to Instance to which was injected this DLL
end;
function StartHook(Sender:HWnd; MessageID:word):BOOL;
begin
Result := False;
if HookHandle<>0 then Exit; //Already Installed the hook
pSharedMem^.InstHandle := Sender;
pSharedMem^.MessageID := MessageID;
HookHandle := SetWindowsHookEx(WH_MOUSE,MouseProc,hInstance,0);
Result := HookHandle <> 0;
end;
function StopHook:BOOL;
begin
if HookHandle <> 0 then
begin
UnhookWindowsHookEx(HookHandle);
HookHandle := 0;
end;
Result := HookHandle = 0;
end;
initialization
hMappingFile := OpenFileMapping(FILE_MAP_WRITE,False,MappingFileName);
//Try to open an existing mapping file as MappingFileName specified
if hMappingFile = 0 then //Not exist
hMappingFile := CreateFileMapping($FFFFFFFF,nil,PAGE_READWRITE,0,sizeof(TSharedMem),MappingFileName);
//Here $FFFFFFFF is a invalid file handle, which cause this file being created in Windows page file.
if hMappingFile = 0 then //Still unable to create a mapping file
Exception.Create('Unable to create shared memory. Make sure your system have enough memory and page file space.');
pSharedMem := MapViewOfFile(hMappingFile,FILE_MAP_WRITE or FILE_MAP_READ,0,0,0);
//Details of this API call, refer to MapViewOfFile in MSDN
if pSharedMem = nil then //Create a pointer to the mapped file
begin
CloseHandle(hMappingFile);
Exception.Create('Unable to map shared memory. Program halt.');
end;
HookHandle := 0;
//Whether HookHandle = 0 is used to judge if this hooked was installed
//In function StartHook, we will later give a value to HookHandle
finalization
UnMapViewOfFile(pSharedMem);
CloseHandle(hMappingFile);
end.
公用模块源代码:untMouseHookConst.pas
unit untMouseHookConst;
interface
uses Windows;
const MappingFileName='_TOEFLDllMouse';
type TSharedMem=record
InstHandle :DWord;
MessageID :DWord;
end;
implementation
end.
EXE模块源代码:untMain.pas
unit untMain;
interface
uses
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
Dialogs, untMouseHookConst, StdCtrls, ExtCtrls;
type
TfrmMain = class(TForm)
lblMain: TLabel;
tmrMain: TTimer;
procedure tmrMainTimer(Sender: TObject);
procedure FormClose(Sender: TObject; var Action: TCloseAction);
procedure FormCreate(Sender: TObject);
private
{ Private declarations }
public
{ Public declarations }
procedure WndProc(var Messages:TMessage); override;
end;
const MessageID = WM_User+121;
DLLFileName = 'dllTOEFLHook.dll';
var
frmMain :TfrmMain;
hMappingFile :THandle;
pSharedMem :^TSharedMem;
time_counter :integer;
implementation
{ $R *.dfm }
function StartHook(Sender:HWnd; MessageID:word):BOOL; stdcall; external DLLFileName;
function StopHook:BOOL; stdcall; external DLLFileName;
procedure TfrmMain.FormClose(Sender: TObject; var Action: TCloseAction);
begin
if not StopHook then
Exception.Create('Unable to uninstall the mouse hook. Abnormal termination.');
end;
procedure TfrmMain.FormCreate(Sender: TObject);
begin
pSharedMem := nil;
if not StartHook(frmMain.Handle,MessageID) then //Sends handle and MessageID to DLL
Exception.Create('Unable to install a mouse hook. Program halt.');
time_counter := 0;
end;
procedure TfrmMain.tmrMainTimer(Sender: TObject);
begin
inc(time_counter);
lblMain.Caption := IntToStr(time_counter);
end;
procedure TfrmMain.WndProc(var Messages: TMessage); //Override WndProc, see MSDN for details
begin
if pSharedMem = nil then
begin
hMappingFile := OpenFileMapping(FILE_MAP_WRITE,False,MappingFileName);
if hMappingFile = 0 then Exception.Create('Unable to access shared memory. Program halt.');
pSharedMem := MapViewOfFile(hMappingFile,FILE_MAP_WRITE or FILE_MAP_READ,0,0,0);
if pSharedMem = nil then
begin
CloseHandle(hMappingFile);
Exception.Create('Unable to map to shared memory. Program halt.');
end;
end;
if pSharedMem = nil then exit; //Halt program if unable to create/open/map shared memory.
if Messages.Msg = MessageID then //Global mouse On_Left_Button_Up
begin
time_counter := 0;
lblMain.Caption := 'CLICK!';
tmrMain.Interval := 0;
tmrMain.Interval := 1000; //Cause timer to restart timing
end
else Inherited; //Do traditional WndProc without override
end;
end.
结语
程序在Delphi 7和Delphi 2006下均可编译通过。值得一提的是,本文只给出了全局鼠标钩子的一个简单实例。事实上,不同类型的钩子在实现中的差别还是比较大的,在编写不同类型的全局钩子时应当多参考不同参考书和互联网上的例程(虽然大多存在错误,但总是有参考价值的),并注意参阅MSDN中的解释并以此为准,毕竟MSDN才是最权威的Win32 API参考资料。
Win32 Hooks编程是非常考验程序员的编程功底和技巧的,学习时一定要有花大量时间反复调试的准备,如果你遇到了任何问题或有任何发现,也欢迎和我交流、分享。请关注我的网站Wecan's Weblog: http://wecan.name/diary 。如果您转载本文,请不要修改这一信息。谢谢您的合作。