Windows Hook 技术浅析

本文最后更新于:2022年4月7日 晚上

前情提要

众所周知,出于安全性考虑,操作系统的进程间是相互隔离的,窗口也不例外

Windows的通信基于消息机制,操作系统会将各种信息通过消息(Message)的形式传递给各个线程(消息队列是以线程为单位的)

这也意味着一个进程只能获取Windows发送给自身窗体的消息,如:

  • 键盘按键信息
  • 鼠标滚轮、双击信息
  • 窗口最小化/最大化

“两耳不闻窗外事,一心只读圣贤书”了属于是

他想闻也闻不到啊主要是,操作系统给他掐了

Windows:喂,别东张西望

外面的世界

那莫,我要是求知欲特别强,就是想知道别的进程/窗口发生了什么,怎么办呢?

比如:(监听其他窗口的密码输入)

哦,不是不是,误会了,我的老伙计

我是说:帮助一些行动不便的程序,处理一些消息~

咳咳

那就需要用到Windows Hook技术了

Windows Hook

什么是Hook

Hook,直译:钩子,挂钩

非常形象,其作用就是在Windows将消息分发给指定窗口过程中捕获(钩住)消息,从而获得对消息的控制权,以便处理或修改,甚至可以拦截(不继续传递)

比如:在浏览器输入文字,按键消息就会由Windows发布到浏览器窗口,在这个过程中,可以用键盘钩子捕获消息,从而得知用户的输入

Hook类型(这一段可以跳过,查阅用)

有如下的几种常用类型:

  • 键盘钩子和低级键盘钩子可以监视各种键盘消息
  • 鼠标钩子和低级鼠标钩子可以监视各种鼠标消息
  • 外壳钩子可以监视各种Shell事件消息。比如启动和关闭应用程序
  • 日志钩子可以记录从系统消息队列中取出的各种事件消息
  • 窗口过程钩子监视所有从系统消息队列发往目标窗口的消息

详细来说:

1.WH_CALLWNDPROC 和 WH_CALLWNDPROCRET

监视发送到窗口过程的消息。系统在消息发送到接收窗口过程之前调用WH_CALLWNDPROC Hook子程,并且在窗口过程处理完消息之后调用WH_CALLWNDPROCRET Hook子程

2.WH_CBT

在以下事件之前,系统都会调用WH_CBT Hook子程,这些事件包括:

  • 激活,建立,销毁,最小化,最大化,移动,改变尺寸等窗口事件
  • 完成系统指令
  • 来自系统消息队列中的移动鼠标,键盘事件
  • 设置输入焦点事件
  • 同步系统消息队列事件

Hook子程的返回值确定系统是否允许或者防止这些操作中的一个

3.WH_DEBUG

在系统调用系统中与其他Hook关联的子程之前,系统会调用WH_DEBUG Hook子程。你可以使用这个Hook来决定是否允许系统调用与其他Hook关联的子程

4.WH_FOREGROUNDIDLE

当应用程序的前台线程处于空闲状态时,可以使用WH_FOREGROUNDIDLE Hook执行低优先级的任务。当应用程序的前台线程大概要变成空闲状态时,系统就会调用WH_FOREGROUNDIDLE Hook子程

5.WH_GETMESSAGE

应用程序使用WH_GETMESSAGE Hook来监视从GetMessage or PeekMessage函数返回的消息。你可以使用WH_GETMESSAGE Hook去监视鼠标和键盘输入,以及其他发送到消息队列中的消息

6.WH_JOURNALPLAYBACK

WH_JOURNALPLAYBACK Hook使应用程序可以插入消息到系统消息队列。可以使用这个Hook回放通过使用WH_JOURNALRECORD Hook记录下来的连续的鼠标和键盘事件。只要WH_JOURNALPLAYBACK Hook已经安装,正常的鼠标和键盘事件就是无效的。WH_JOURNALPLAYBACK Hook是全局Hook,不会注入dllWH_JOURNALPLAYBACK Hook返回超时值,这个值告诉系统在处理来自回放Hook当前消息之前需要等待多长时间,这就使Hook可以控制实时事件的回放。

7.WH_JOURNALRECORD

WH_JOURNALRECORD Hook用来监视和记录输入事件。可以使用这个Hook记录连续的鼠标和键盘事件,然后通过使用WH_JOURNALPLAYBACK Hook来回放。WH_JOURNALRECORD Hook是全局Hook,不会注入dll

8.WH_KEYBOARD

在应用程序中,WH_KEYBOARD Hook用来监视WM_KEYDOWN and WM_KEYUP消息,这些消息通过GetMessage or PeekMessage 函数返回。可以使用这个Hook来监视输入到消息队列中的键盘消息

9.WH_KEYBOARD_LL

监视即将输入到线程消息队列中的键盘消息

10.WH_MOUSE

监视从GetMessage 或者 PeekMessage 函数返回的鼠标消息。使用这个Hook监视输入到消息队列中的鼠标消息

11.WH_MOUSE_LL

监视即将输入到线程消息队列中的鼠标消息

12.WH_MSGFILTER 和 WH_SYSMSGFILTER

可以监视菜单,滚动条,消息框,对话框消息并且发现用户使用ALT+TAB or ALT+ESC 组合键切换窗口

13.WH_SHELL

当外壳应用程序是激活的并且当顶层窗口建立或者销毁时,系统调用WH_SHELL Hook子程

不管了,先开始

上面一大坨看得头痛是吧,那就先别看了,不如上手试试

其实大多数人入手Hook的第一个例子就是鼠标和键盘钩子,比如:获取全局键盘输入

那么,问题来了,哪一种类型的Hook可以实现这种需求呢?

小学英语也可以筛选出两个:WH_KEYBOARD && WH_KEYBOARD_LL

Q:那这俩怎么选嘛,都是KeyBoard,诶,那个LL后缀是啥呀

A:Low Level,代表低级键盘钩子

Q:有啥区别呢

A:WH_KEYBOARD_LL会在消息即将发布到线程消息队列时调用,而WH_KEYBOARD会在消息被线程处理前调用,也就是说,WH_KEYBOARD_LL调用时机更早

而且使用起来更简单,为啥呢?因为WH_KEYBOARD_LL调用时,消息还未发布到具体线程,所以不用编写独立dll用以注入。坏处是,正由于消息还未发布到具体线程,所以不知道消息的接收者是谁。

WH_KEYBOARD是在线程获取消息时调用,所以上下文已经切换至具体线程,想要执行Hook函数就必须编写独立dll并将dll注入指定线程(Windows执行注入)才能执行(同一片地址空间),这样就会削削有点麻烦,但可以实现监控指定线程

那么什么Hook可以不用dll呢,貌似Global Only的可以不要:

Hook Scope

写了再说

管不了那么多了,直接莽

编写钩子程序的步骤分为三步:定义钩子函数、安装钩子和卸载钩子

1.定义钩子函数

钩子函数是一种特殊的回调函数,钩子监视的特定事件发生后,系统会调用钩子函数进行处理

1
LRESULT CALLBACK HookProc(int nCode ,WPARAM wParam,LPARAM lParam)

参数wParam lParam包含所钩消息的信息,比如鼠标位置、状态,键盘按键等,nCode包含有关消息本身的信息,比如是否从消息队列中移出

在钩子函数中实现了具体功能后,还需要返回值

推荐用CallNextHookEx函数把消息传递给后一个对象(下一个钩子函数或特定线程)

1
LRESULT CallNextHookEx( HHOOK hhk, int nCode, WPARAM wParam, LPARAM lParam )

当然也可以直接return true不继续传递,即拦截消息

(这样非常霸道,可以用来屏蔽键盘)

注:在钩子函数中,不要做耗时操作,否则会阻塞系统,导致性能下降

2.安装钩子

调用函数SetWindowsHookEx安装钩子:

1
HHOOK SetWindowsHookEx( int idHook,HOOKPROC lpfn, INSTANCE hMod,DWORD dwThreadId )
  • idHook表示钩子类型,如:WH_KEYBOARD
  • lpfn是钩子函数的地址
  • hMod是钩子函数所在的实例的句柄
  • dwThreadId指定钩子所监视的线程的ID,若为NULL,则监视所有线程(全局钩子)
  • 返回值为钩子句柄(唯一标识符)

注:最近安装的钩子放在Hook链的开始,而最早安装的钩子放在最后,也就是后加入的先获得控制权

注:如果钩子过程超时,系统将消息传递给下一个钩子。但是,在 Windows 7 及更高版本上,该挂钩会在不被调用的情况下被静默删除。应用程序无法知道挂钩是否被移除

Windows 10 版本 1709 及更高版本系统允许的最大超时值为 1000 毫秒(1 秒)。如果LowLevelHooksTimeout值设置为大于 1000 的值,系统将默认使用 1000 毫秒超时。

3.卸载钩子

钩子特别是系统钩子会消耗消息处理时间,降低系统性能。只有在必要的时候才安装钩子,在使用完毕后要及时卸载:

1
BOOL UnhookWindowsHookEx(HHOOK hhk)

不过,实际上呢,由于Windows系统的鲁棒性,在程序退出时,系统会隐式释放所有句柄,所以其实不用太担心

实验表明,如果钩子函数写在dll中,程序直接退出,系统会释放Hook句柄,并卸载在其他线程注入的dll(可能有延迟)

注:调用SetWindowsHookEx的进程获得句柄(载入dll后调用也是(同一进程空间))

实操:低级键盘钩子(无dll)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void setKeyboardHook()
{
h_key = SetWindowsHookEx(WH_KEYBOARD_LL, keyboardProc, GetModuleHandle(NULL), 0);
}

void unHook()
{
if (h_key != nullptr) {
UnhookWindowsHookEx(h_key);
h_key = nullptr;
}
}
LRESULT keyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
{//关于wParam 和 lParam的定义 不同Hook类型都不同,需查阅微软官方文档
qDebug() << "keyBoard Hook:" << ((KBDLLHOOKSTRUCT*)lParam)->vkCode;
return true;
}

是不是非常简单,这样就实现了屏蔽键盘的功能

但是,这也太low了吧,还不能监控指定线程,换碟!

实操2:鼠标钩子(dll)

一般与特定线程/窗口相关的钩子都需要编写独立dll

那么怎么写呢?

一般用Visual Studio来编写(新建dll工程就有模板)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// dllmain.cpp : 定义 DLL 应用程序的入口点。
#include "pch.h"
#include<Windows.h>

#pragma data_seg(".SHARE")//共享数据段
HWND hWndServer = NULL;
HWND hWndTarget = NULL;
HHOOK hMouse = NULL;
#pragma data_seg()
#pragma comment(linker, "/section:.SHARE,rws")

HINSTANCE hInstance;
UINT UWM_MOUSEHOOK;
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call) {
case DLL_PROCESS_ATTACH://首次载入目标进程
hInstance = hModule;
UWM_MOUSEHOOK = RegisterWindowMessage(L"Chrome_WH_MOUSE");
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}

LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam) {
if (nCode == HC_ACTION && hWndServer) {
MOUSEHOOKSTRUCT* data = (MOUSEHOOKSTRUCT*)lParam;
::PostMessageA(hWndServer, UWM_MOUSEHOOK, wParam, (LPARAM)data->hwnd);//PostMessage不能发送指针
}
return CallNextHookEx(hMouse, nCode, wParam, lParam);
}

extern "C" _declspec(dllexport)
bool setMouseHook(HWND hWnd, HWND target, DWORD* errorCode)
{
if (hWndServer) {
*errorCode = 11451444;
return false;
}
hMouse = SetWindowsHookEx(WH_MOUSE, (HOOKPROC)MouseProc, hInstance, GetWindowThreadProcessId(hWndTarget = target, NULL));
if (hMouse) {
hWndServer = hWnd;
return true;
}
*errorCode = GetLastError();
return false;
}

extern "C" _declspec(dllexport)
bool clearHook(void)
{
hWndServer = NULL;
hMouse = NULL;
return UnhookWindowsHookEx(hMouse);//若hook已被自动卸载 也会返回false
}

注意:

1
2
3
4
5
6
#pragma data_seg(".SHARE")//共享数据段 名字可以随便去 但要和最后一句对应
HWND hWndServer = NULL;
HWND hWndTarget = NULL;
HHOOK hMouse = NULL;
#pragma data_seg()
#pragma comment(linker, "/section:.SHARE,rws")

这段用于定义共享数据段

为什么要共享呢?因为dll注入在不同的线程中,数据是不共享的,那可坏了,Hook句柄和服务端窗口句柄都读不到了

为了共享就需要用到编译器指令#pragma data_seg

同时:

1
extern "C" _declspec(dllexport)

这句话用于声明dll中要导出的函数(以C风格 为了兼容性),否则外部不能访问

处理函数在注入到其他线程的dll中,那么天高皇帝远,我们怎么才能获取到消息呢?

这就要让注入的dll充当内鬼了,将消息通过PostMessage传递出来

为啥是PostMessage而不是SendMessage呢?

因为前面说了,钩子函数要尽量高效,不能阻塞,而SendMessage是阻塞函数,显然不行

那么问题又来了,由于PostMessage异步执行,所以不能传递指针(所指向的对象早就销毁了)

lParam通常是一个指向结构体的指针

那咋办呢?

要么用VirtualAlloc在目标进程开空间,然后复制过去,要么,最多只能传递sizeof(LPARAM),64bits 8字节的数据

那发消息总得有暗号吧

可以使用RegisterWindowMessage定义用户消息,接收端就可以识别了

使用例

Okay,dll写完了,总得有人用吧,我们以Qt为例:

1
2
3
4
5
6
7
8
9
typedef bool (*HookFunc)(HWND, HWND, DWORD*);//声明函数结构
typedef bool (*UnHookFunc)();
QLibrary lib("mouseHook.dll");
HookFunc setMouseHook = (HookFunc)lib.resolve("setMouseHook");//根据函数名定位
UnHookFunc clearHook = (UnHookFunc)lib.resolve("clearHook");
HWND hWndChrome = FindWindowA("Chrome_WidgetWin_1", NULL);
DWORD errorCode;
clearHook();//手动清除hWndServer防止Chrome dll未卸载导致共享变量未删除
setMouseHook((HWND)this->winId(), hWndChrome, &errorCode);

可以用QLibrary载入dllWindows APILoadLibrary

并根据函数名定位函数地址(有了地址就可以调用了)

然后在消息循环中接收内鬼dll发来的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool Widget::nativeEvent(const QByteArray& eventType, void* message, long* result) 
{
MSG* msg = (MSG*)message;
static const UINT UWM_MOUSEHOOK = RegisterWindowMessageW(L"Chrome_WH_MOUSE");
if (msg->message == UWM_MOUSEHOOK && msg->wParam == WM_LBUTTONDBLCLK) {
HWND hwnd = (HWND)msg->lParam;
qDebug() << "WM_LBUTTONDBLCLK" << hwnd;
if (Win::getWindowClass(hwnd) == ChromeClass) {
Win::simulateKeyEvent(QList<BYTE>({ VK_CONTROL, 'W' }));//模拟双击关闭标签页
return true;
}
}
return false; //此处返回false,留给其他事件处理器处理
}

Okay,大功告成,这样就实现了监听Chrome标题栏的左键双击并模拟Ctrl+W关闭标签页的效果

问题和注意事项

1.关于dll被多个进程调用

Win32系统会确保内存中只有一个该DLL的拷贝,这是通过内存映射文件来实现的。不同的进程分别将这份DLL的代码段地址映射到自己的进程空间中,同时不同的进程在自己的进程空间分别有各自的一份该DLL的数据段(全局变量)拷贝

2.既然代码段共享,如果进程A中修改DLL的代码内容,使用同一DLL的其它进程B是否受影响?

NO!

这就涉及到一个内存的COPY ON WRITE机制问题

如果进程A修改了dll代码或数据,则有内容被修改后,会构造副本,重新映射

所以基本上内存中只有一份dll可以节省空间

3.不调用UnhookWindowsHookEx释放钩子会如何?

其实不会如何,由于Windows系统的鲁棒性,在程序退出时,系统会隐式释放所有句柄,所以其实不用太担心

实验表明,如果钩子函数写在dll中,程序直接退出,系统会释放Hook句柄,并卸载在其他线程注入的dll(可能有延迟

所有dll释放后,共享数据段被清空(我猜的)

Ref

Visual Studio关于hook项目的简单使用_吨吨不打野的博客-CSDN博客

HOOK API (一)——HOOK基础+一个鼠标钩子实例 - …..? - 博客园

Hooks and DLLs

有关DLL的几个问题_SONG_CA的博客-CSDN博客

SetWindowsHookExA function (winuser.h) - Win32 apps | Microsoft Docs


Windows Hook 技术浅析
https://mrbeancpp.github.io/2022/04/07/Windows-Hook-技术浅析/
作者
MrBeanC
发布于
2022年4月7日
许可协议