0%

Windows Hook 入门:局部钩子与远程钩子,兼全局键盘钩子实战

从一个误会说起

王端明《深入浅出 Windows API 程序设计》里对 Hook 的阐述是:Hook 是 Windows 消息处理机制中的一个监视点,应用程序可以在这里安装钩子函数(一个回调),监视系统消息,并在消息到达目标窗口过程之前先由钩子函数处理。

第一次读容易被”目标窗口”带偏:Hook 是不是窗口程序专属的技术?不是。它监视的是”别人的消息流”,你自己的程序有没有窗口都行。这篇笔记按书上的分类把消息钩子理清,再用书上的 HookDll + HookApp 工程实战一次。

书上的分类:局部钩子与远程钩子

Windows 用 SetWindowsHookEx 安装的钩子,书上按作用范围分两类:

  • 局部钩子:只挂钩自身进程的事件
  • 远程钩子:挂钩其他进程的事件,又分两种——基于其他进程中某个线程的线程钩子,和系统范围的系统钩子

对照到 API 参数上:

分类 dwThreadId 参数 钩子函数位置
局部钩子 自己进程的线程 ID 可以在 exe 里,不用 DLL
远程钩子-线程钩子 别的进程的线程 ID 必须在 DLL 里(系统把 DLL 注入那个进程)
远程钩子-系统钩子 0 必须在 DLL 里(注入所有 GUI 进程)

一条规律贯穿:远程钩子的钩子函数必须位于 DLL 中。原因很直接:远程钩子的回调要在别的进程上下文里执行,代码总得有个载体过去——系统的做法就是把装着回调的 DLL 映射进目标进程地址空间。局部钩子不跨进程,exe 里直接写回调就行。

书里 InstallHook(int idHook, DWORD dwThreadId, HWND hwnd) 保留了 dwThreadId 参数,正是兼容这几种用法——传 0 是系统钩子,传具体线程 ID 是线程钩子。

实战:远程钩子之系统钩子(HookDll + HookApp)

书上这个演示工程是远程钩子中的系统钩子:dwThreadId = 0,监视全系统的键盘消息。

工程结构

两个工程配合:

  • HookDll:钩子 DLL,导出 InstallHook / UninstallHook,内部含钩子回调 KeyboardProc
  • HookApp:对话框程序,两个按钮负责装/卸钩子,界面上实时显示全系统敲下的按键

运行效果:点”安装钩子”后,在任何程序里打字——记事本、浏览器地址栏、别的对话框——按键都会同步出现在 HookApp 的编辑框里。这就是”系统范围”的含义。

为什么是 DLL?这是远程钩子的硬性要求

SetWindowsHookEx 的签名决定了架构:

1
2
3
4
5
6
HHOOK SetWindowsHookEx(
int idHook, // 钩子类型, 如 WH_KEYBOARD.
HOOKPROC lpfn, // 钩子回调函数指针.
HINSTANCE hmod, // 回调所在模块的句柄(DLL).
DWORD dwThreadId // 0 = 系统钩子, 否则指定线程.
);

钩子回调要在别的进程的上下文里执行(键盘事件发生在别人那里),系统实现这一点的方式很粗暴:把装着回调的 DLL 映射进每一个 GUI 进程的地址空间。所以系统钩子的回调必须放在 DLL 里——这不是作者的设计选择,是系统机制的要求。

HookDll 核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 钩子回调: 每次系统里有键盘消息, 先经过这里.
LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
{
if (nCode < 0)
return CallNextHookEx(NULL, nCode, wParam, lParam);

if (nCode == HC_ACTION)
{
// wParam 是虚拟键码, 转成可见字符.
GetKeyboardState(bKeyState);
ToUnicode(wParam, lParam >> 16, bKeyState, g_szBuf, _countof(g_szBuf), 0);
// 用 WM_COPYDATA 把字符跨进程发回 HookApp 窗口.
SendMessage(g_hwnd, WM_COPYDATA, (WPARAM)g_hwnd, (LPARAM)&copyDataStruct);
}

return CallNextHookEx(NULL, nCode, wParam, lParam); // 放行给下一个钩子/目标窗口.
}

BOOL InstallHook(int idHook, DWORD dwThreadId, HWND hwnd)
{
g_hwnd = hwnd;
g_hHookKeyboard = SetWindowsHookEx(idHook, KeyboardProc, g_hMod, dwThreadId);
return g_hHookKeyboard != NULL;
}

调用时传 InstallHook(WH_KEYBOARD, 0, hwndDlg):WH_KEYBOARD 是钩子类型,0 表示系统钩子,hwndDlg 是 HookApp 的窗口句柄——也就是钩子回调里 SendMessage 的第一个参数,用于把按键字符回传给 HookApp 窗口。

HookApp 一侧就简单了

1
2
3
4
5
6
7
8
case IDC_BTN_INSTALLHOOK:
InstallHook(WH_KEYBOARD, 0, hwndDlg); // 装钩子.
break;

case WM_COPYDATA: // 收按键并显示.
SendMessage(GetDlgItem(hwndDlg, IDC_EDIT_KEYBOARD), EM_REPLACESEL,
TRUE, (LPARAM)(LPTSTR)(((PCOPYDATASTRUCT)lParam)->lpData));
return TRUE;

装/卸钩子调 DLL 导出的两个函数;收到 WM_COPYDATA 就把字符追加到编辑框。

整条数据流

1
2
3
4
5
6
用户在任何程序里敲键
→ 系统消息队列
→ KeyboardProc(在目标进程内执行, DLL 已注入)
→ ToUnicode 转成字符
→ WM_COPYDATA 发回 HookApp 窗口
→ CallNextHookEx 放行, 目标程序正常收到按键(无感知)

三个要点:

  1. 先经过你,再放行:钩子函数先于目标窗口过程被调用,看完可以放行(CallNextHookEx),也可以吞掉(不调用即可),这就是”监视点”的含义。
  2. 钩子构成链:系统里可以装多个同类钩子,CallNextHookEx 把消息传给链上的下一个,谁不放行谁就截断了后面的所有环节。
  3. 回调跑在别人家里:KeyboardProc 的执行上下文是目标进程,这也是为什么回传结果要用 WM_COPYDATA(系统支持的跨进程传数据方式),而不能直接读写 HookApp 的内存。

消息钩子的常见类型

SetWindowsHookEx 的第一个参数决定监视什么,常用的:

类型 监视内容 是否需要 DLL
WH_KEYBOARD 键盘消息 做远程钩子时需要
WH_KEYBOARD_LL 低级键盘输入(在自己进程上下文执行) 不需要,系统钩子也不用
WH_MOUSE / WH_MOUSE_LL 鼠标,同上规律 视范围而定
WH_GETMESSAGE 投递到消息队列的所有消息 做远程钩子时需要
WH_CBT 窗口创建/激活/关闭等事件 做远程钩子时需要

顺带一提:如果只是做全局键盘记录,WH_KEYBOARD_LL 是更简单的选择——它在自己进程里执行,不用 DLL 注入。书里用 WH_KEYBOARD + DLL 的写法,教学价值更高:它把”DLL 注入”这个后面 API Hook、内核开发都要用的机制提前演示了一遍。

更大视角:Hook 不止消息钩子

消息钩子只是 Hook 技术的一个分支——有官方设施、最安全的那一支。把视野放大,凡是”在执行路径上插一个点,先经过你,你再决定放行、修改还是替换”的技术都叫 Hook:

类别 拦截的是什么 典型手段 是否有官方支持
消息钩子 窗口消息(键盘、鼠标、窗口事件) SetWindowsHookEx(本文实战) 有,系统原生设施
API 钩子 函数调用 IAT 改写、inline hook(改函数开头跳转)、Detours 无,自己动手
内核钩子 系统调用、内核事件 SSDT hook、回调注册(ObRegisterCallbacks 等) 部分有(回调注册类)

API 钩子应用面更广(安全软件、注入、调试器都靠它),但没有官方支持,涉及内存改写,坑也多,是下一步的学习内容。

安全边界

全局键盘钩子就是键盘记录器(keylogger)的技术原型,攻防同源。学习时在自己的虚拟机和测试程序里跑,搞清楚机制;不要用在任何他人机器或生产环境。

小结

1
2
3
4
5
6
7
消息钩子(SetWindowsHookEx) 按作用范围分(书上分类)
├── 局部钩子: 只钩自身进程, 回调可在 exe 里
└── 远程钩子: 钩其他进程, 回调必须在 DLL(系统注入目标进程)
├── 线程钩子: dwThreadId = 目标线程 ID
└── 系统钩子: dwThreadId = 0, 注入所有 GUI 进程(本文实战)

更大视角: 消息钩子 | API 钩子(IAT/inline/Detours) | 内核钩子(SSDT/回调注册)

下一篇计划讲 API 钩子:IAT 改写和 inline hook,看看”没有官方设施时,怎么自己造一个钩子”。