ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++Builder系统钩子实战:从低级钩子到DLL注入的完整指南

C++Builder系统钩子实战:从低级钩子到DLL注入的完整指南 简介这是一份基于CBuilder的Windows系统钩子示例工程面向CBuilder开发者和Windows系统编程学习者适合有一定C基础、想进阶系统编程的读者。资源演示了以动态链接库方式创建、安装和管理系统钩子可监听键盘、鼠标等系统事件直观展示SetWindowsHookEx、CallNextHookEx等API的用法。压缩包共18个文件体积约26KB涵盖C源代码、工程配置、窗体设计文件、资源文件、说明文档、DLL动态库及可执行程序其中DLL导出定义文件声明了钩子函数的外部接口主程序与DLL配合演示了钩子回调与事件响应流程结构紧凑适合直接编译运行。目前已有205人学习对希望深入Windows消息机制、在CBuilder中实现全局或局部钩子的开发者很有帮助。通过本示例可快速搭建监听框架理解DLLMain入口编写、钩子安装与卸载、钩子链传递等核心步骤并基于此扩展自定义事件处理逻辑。1. 系统钩子不是你想装就能装先看懂这条调用链拆开这个标题核心是用 CBuilder 调 SetWindowsHookEx 这组 API门槛不在那四个参数而在回调执行环境。无论你拿的是别人整理的系统钩子模块还是自己要写全局快捷键或输入监控都得遵守同一条规则——回调要么留在当前进程低级钩子要么被系统注入到目标进程全局钩子。全局模式必须准备一个独立 DLL公开导出回调并处理调用约定、共享内存、VCL 边界否则就会出现装了没反应、退出崩这类老问题。下面按机制、最小实现、参数排错到验证的顺序把这套在 CBuilder 里能落地、能排障的写法说完。2. 系统钩子机制的底牌DLL 注入和低级钩子差在哪2.1 先用一张表把钩子类型按“要不要 DLL”分开Windows 里“系统钩子”是个笼统说法后面跟的具体值才是真正决定写法的东西。SetWindowsHookEx 第一个参数 idHook 传什么直接决定了回调是不是必须待在 DLL 里。常年在 CBuilder 项目里见到的就这么几类idHook钩子名回调执行位置要不要 DLL典型用途2WH_KEYBOARD被注入的每个进程跨进程时必须键盘输入拦截与改造7WH_MOUSE被注入的每个进程跨进程时必须鼠标消息拦截3WH_GETMESSAGE被注入的每个进程跨进程时必须窥探窗口消息队列5WH_CBT被注入的每个进程跨进程时必须窗口创建、激活、销毁13WH_KEYBOARD_LL安装者自己的进程不需要全局键盘监听、快捷键14WH_MOUSE_LL安装者自己的进程不需要全局鼠标监听表里“跨进程时必须”这个限定词要保留如果 dwThreadId 指向的是当前 EXE 自己的线程回调放在 EXE 里也能用只有钩子目标扩到别的进程时才必须把回调搬进 DLL。最容易踩的误区就是把 LOW-Level 钩子当成全局钩子然后把回调写进 DLL白做了很多跨进程通信工作。2.2 选型你是要“监听”还是要“改造消息”如果业务目标只是全局快捷键、统计键盘活动、监控鼠标行为我一般直接选 WH_KEYBOARD_LL 或 WH_MOUSE_LL。理由不是低级钩子性能更好而是它的代码面小、事故面小不注入别人进程就没有 DLL 版本冲突、没有 VCL 被带到陌生环境、没有 32/64 位 DLL 是否匹配的问题。低级钩子回调里返回 1 也能吃掉这个键实现全局快捷键完全够用。如果目标是要在所有进程的消息队列层面统一改造消息比如把 WM_GETMINMAXINFO 拦下来限制窗口尺寸或者对某个按钮的消息做附加处理这类需求只能用注入型钩子。WH_GETMESSAGE、WH_CBT 的链点比低级钩子更靠后能看到从 SendMessage 进来的窗口消息而低级钩子只能看到原始输入。两者的取舍本质是监听原始输入用 LL处理“已经成型”的消息用注入钩子。2.3 为什么 CBuilder 的 VCL 不能直接塞进钩子回调这是 CBuilder 特有的一个坎翻老项目源码时经常看到各种栽在上面的写法。钩子回调被注入到目标进程后运行环境和你的 EXE 完全没有关系那个进程可能没加载 VCL、没有 TApplication、没有主窗体。若在回调里顺手 new 一个 TForm或者直接 ShowMessage轻则弹窗出现在别的桌面重则因为消息循环时序不对直接卡死。原则就一条回调函数里只做值搬运不创建 VCL 对象。把需要的按键码、鼠标坐标、消息字包装成消息参数通过 PostMessage 抛回宿主进程宿主进程的窗体收到后才更新界面、弹提示、做业务判断。如果需要传输结构体用文件映射或 WM_COPYDATA不要直接传指针因为注入进程和宿主进程的地址空间不同。2.4 两种工程结构的取舍对应下来常见做法是两种工程结构单 EXE 方案和 EXE DLL 方案。单 EXE 方案适合快速验证、自己机器上用监控程序常驻系统托盘也没问题EXE DLL 方案适合交付给别的机器或者要拦截其它进程窗口消息的成熟项目。方案组成钩子类型优点A单 EXEWH_KEYBOARD_LL / WH_MOUSE_LL代码少、跨进程问题少BEXE Hook DLLWH_KEYBOARD / WH_GETMESSAGE 等能注入其它进程消息链更深方案 A 直接写在 Form 里就能工作方案 B 要把回调、安装、卸载做成 DLL 导出。下面会把两套都写好重点放在 B 的 DLL 写法上因为它才是“系统钩子”这个词通常指的东西。3. CBuilder 里系统钩子的最小实现从 EXE 内钩子到注入 DLL3.1 先写一版 WH_KEYBOARD_LL不依赖 DLL在 CBuilder 窗体单元里直接贴这段代码就能看到所有键盘按键。这是一个标准的低级键盘钩子回调在自己进程内执行不需要 DLL。// Unit1.cpp —— CBuilder 窗体单元 #include vcl.h #pragma hdrstop #include Unit1.h #include windows.h #pragma package(smart_init) #pragma resource *.dfm HHOOK g_hHook NULL; HWND g_hWnd NULL; UINT g_uMsg WM_APP 1001; LRESULT CALLBACK LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode HC_ACTION (wParam WM_KEYDOWN || wParam WM_SYSKEYDOWN)) { KBDLLHOOKSTRUCT *p reinterpret_castKBDLLHOOKSTRUCT*(lParam); if (g_hWnd) PostMessage(g_hWnd, g_uMsg, p-vkCode, p-scanCode); } return CallNextHookEx(NULL, nCode, wParam, lParam); }然后在窗体的 FormCreate 里安装在 FormClose 里卸载void __fastcall TForm1::FormCreate(TObject *Sender) { g_hWnd Handle; g_uMsg RegisterWindowMessage(LMyApp.HookNotify); g_hHook SetWindowsHookEx(WH_KEYBOARD_LL, LowLevelKeyboardProc, GetModuleHandle(NULL), 0); if (!g_hHook) ShowMessage(L安装失败, LastError IntToStr(GetLastError())); } void __fastcall TForm1::FormClose(TObject *Sender, TCloseAction Action) { if (g_hHook) { UnhookWindowsHookEx(g_hHook); g_hHook NULL; } }最后重写 WndProc 接收自绘消息。这一步是关键回调里 PostMessage 发给窗体VCL 的 WndProc 能把这条消息从普通系统消息里筛出来。void __fastcall TForm1::WndProc(TMessage Message) { if (Message.Msg g_uMsg) { int vk (int)Message.WParam; int scan (int)Message.LParam; // 在这里做快捷键判断或输入记录 Memo1-Lines-Add(String().sprintf(LVK%d Scan%d, vk, scan)); return; } TForm::WndProc(Message); }这段代码里的参数要分清SetWindowsHookEx 的 hMod 传 GetModuleHandle(NULL) 是合法的因为低级钩子的回调就在当前进程系统不要求这个句柄是被注入模块的句柄dwThreadId 传 0 表示关联桌面上的所有线程。回调里只做了 PostMessage没有直接操作 VCL所以跨线程的时序问题被完全绕开。3.2 全局钩子把回调搬进 DLL并开一个共享数据段把 idHook 换成 WH_KEYBOARD 这类注入型钩子时工作重心就移到 DLL 项目里。CBuilder 新建 DLL 向导会默认生成#include vcl.h对裸钩子建议只留 windows.h避免把 VCL 导入表带进别的进程。// GlobalHookDll.cpp —— 不带 VCL 的裸 DLL #include windows.h #pragma data_seg(.SHARE) HHOOK g_hHook NULL; HWND g_hWnd NULL; UINT g_uMsg 0; #pragma data_seg() #pragma comment(linker, /SECTION:.SHARE,RWS) HINSTANCE g_hDllInst NULL; BOOL WINAPI DllMain(HINSTANCE hInst, DWORD reason, LPVOID) { if (reason DLL_PROCESS_ATTACH) g_hDllInst hInst; return TRUE; } extern C __declspec(dllexport) LRESULT CALLBACK KeyboardProc( int nCode, WPARAM wParam, LPARAM lParam) { if (nCode HC_ACTION (wParam WM_KEYDOWN || wParam WM_SYSKEYDOWN)) { UINT vk (UINT)wParam; UINT scan (UINT)((lParam 16) 0xFF); if (g_hWnd) PostMessage(g_hWnd, g_uMsg, (WPARAM)vk, (LPARAM)scan); } return CallNextHookEx(g_hHook, nCode, wParam, lParam); } extern C __declspec(dllexport) BOOL WINAPI InstallGlobalHook(HWND hWnd, UINT uMsg) { g_hWnd hWnd; g_uMsg uMsg; // WH_KEYBOARD 是注入型钩子hMod 必须是本 DLL 的句柄 g_hHook SetWindowsHookEx(WH_KEYBOARD, KeyboardProc, g_hDllInst, 0); return (g_hHook ! NULL); } extern C __declspec(dllexport) void WINAPI UninstallGlobalHook() { if (g_hHook) { UnhookWindowsHookEx(g_hHook); g_hHook NULL; } }这里有几个必须讲透的参数。第一#pragma data_seg(.SHARE)把 g_hHook、g_hWnd、g_uMsg 放进共享段是要让 DLL 被注入到每个进程后大家读到的都是同一份数据如果不加共享段宿主进程给 g_hWnd 赋了值注入进程里的副本却还是 NULLPostMessage 永远不会触发。第二WH_KEYBOARD 回调的 lParam 不是指向 KBDLLHOOKSTRUCT 的指针而是一个按位编码的整数值0-15 位是重复计数16-23 位是扫描码第 24 位是扩展键标志所以代码里直接做位移和掩码。不少人在这一步直接把低级钩子的结构体搬过来用得到的就是垃圾数据。第三回调里必须调 CallNextHookEx把事件交还给钩子链上后面的钩子。链式调用是 Windows 钩子的基本纪律漏掉它别的程序、输入法、系统自身的钩子都会被断掉。UninstallGlobalHook 里先摘钩子再让宿主进程决定是否 FreeLibrary顺序不能反。3.3 装载端用 LoadLibrary 动态挂上 DLL编译 DLL 时 CBuilder 会生成一个导入库 .lib静态链接当然可以但钩子 DLL 这种“可能晚装晚卸”的模块我习惯用 LoadLibrary 动态加载启动时就算 DLL 不在也不会让 EXE 直接起不来。#include windows.h typedef BOOL (WINAPI *InstallGlobalHookFn)(HWND, UINT); typedef void (WINAPI *UninstallGlobalHookFn)(); HINSTANCE g_hHookDll NULL; InstallGlobalHookFn g_fnInstall NULL; UninstallGlobalHookFn g_fnUninstall NULL; bool LoadHookDll(HWND hReceiver, UINT uNotifyMsg) { g_hHookDll LoadLibrary(_T(GlobalHookDll.dll)); if (!g_hHookDll) return false; g_fnInstall (InstallGlobalHookFn)GetProcAddress(g_hHookDll, InstallGlobalHook); g_fnUninstall (UninstallGlobalHookFn)GetProcAddress(g_hHookDll, UninstallGlobalHook); if (!g_fnInstall || !g_fnUninstall) return false; return g_fnInstall(hReceiver, uNotifyMsg); } void UnloadHookDll() { if (g_fnUninstall) g_fnUninstall(); if (g_hHookDll) FreeLibrary(g_hHookDll); g_hHookDll NULL; }这段代码的参数说明LoadLibrary 返回的 HINSTANCE 就是 hMod 需要的模块句柄但 DLL 内部自己的句柄必须在 DllMain 里保存因为宿主进程调用 GetModuleHandle 拿到的可能是 EXE 句柄GetProcAddress 的第二个参数必须和导出名完全一致extern C能避免 C 名字改编否则导出名会变成一串带修饰符号的长串。卸载时要先调 UninstallGlobalHook 再 FreeLibrary顺序反了会导致系统在回调里执行未映射的代码直接崩溃。3.4 安装顺序里三个必须遵守的原则整合上面的代码安装动作按三个原则做。第一通知消息先用 RegisterWindowMessage 登记不要直接用 WM_APP 常量多套程序共用 WM_APP 段很容易串号。第二UnhookWindowsHookEx 必须放在 FormClose 或程序退出流程里绝不能放进 DllMainDllMain 里加载器锁还挂着任何需要其他 DLL 配合的操作都可能死锁。第三回调签名必须严格用 LRESULT CALLBACK也就是 __stdcallCBuilder 默认函数调用约定是 __cdecl写漏了不会编译报错但运行时会因为栈不平衡产生奇怪错误。4. 系统钩子参数表、报错识别与掉钩排查4.1 SetWindowsHookEx 四个参数怎么查参数问题占了钩子失败原因的一大半下面这张表把每个参数容易错的地方列出来参数值怎么给容易错的地方idHook按钩子类型查表低级钩子用 13/14注入型钩子用 2/3/7记混了行为完全不同lpfn回调函数指针函数不是 stdcall函数不是导出的 DLL 函数却用在注入钩子里hMod注入型钩子传 DLL 实例句柄误传 EXE 句柄低级钩子传了 DLL 句柄也没错但没必要dwThreadId0 表示全局具体线程 ID 表示只看那条线程从 32 位进程里传 64 位线程 ID结果不可靠低级钩子还有一个特殊点dwThreadId 传 0 时hMod 传 GetModuleHandle(NULL) 就可以但如果不是当前 EXE 的线程比如想钩别的进程里某个线程的输入仍然要提供包含回调的模块句柄。规则记成“回调在哪段代码里hMod 就是包含这段代码的模块的句柄”最稳妥。4.2 报错 87、126、5、1428 的排查路径SetWindowsHookEx 返回 NULL 时拉出 GetLastError 看一下常见就这几个值。87 是参数错误检查 idHook 常量有没有被误写成别的数字检查 dwThreadId 和 hMod 的组合是否合法某些钩子类型对桌面、Session 还有额外要求。126 是模块找不到LoadLibrary 返回 NULL 时最常见的错误把 DLL 放到 EXE 同目录确认它依赖的 CBuilder 运行时也装了。5 是拒绝访问经常出现在 UAC 场景目标是管理员权限运行的进程而当前进程是普通权限系统不会把钩子回调注入到比你完整性级别高的进程里。1428 在旧 CBuilder 项目里几乎等于“调用约定错”回调函数必须按 CALLBACK 导出DLL 导出声明要写extern C __declspec(dllexport)再用 .def 文件导出一份纯名称双保险。4.3 掉钩LowLevelHooksTimeout 和钩子链低级钩子还有一个很隐蔽的问题回调超时会被系统静默摘掉。系统给低级钩子回调的执行时间限制在一个很短的超时窗口内不同版本 Windows 默认值不同注册表HKCU\Control Panel\Desktop\LowLevelHooksTimeout可以改。回调里做文件写入、数据库访问、Sleep 这类重活一旦超时钩子被系统移除之后按键事件完全不经过你而且没有任何错误码。提示回调里只做数据搬移重活全部投递回宿主进程。用 PostMessage 丢回去宿主窗体收到后再写日志、做统计、弹提示。注入型钩子没有这个超时窗口但也别把耗时操作放进去因为回调阻塞的是被注入进程的输入处理线程用户会感到整个系统卡顿。钩子链本身是“后安装先调用”的前一个回调不调 CallNextHookEx后面所有钩子都断掉。4.4 32 位和 64 位混编时的基本选择全局钩子 DLL 的位数必须和目标进程一致。CBuilder 的 EXE 通常默认 32 位安装 WH_KEYBOARD 钩子时只能把 32 位 DLL 注入到 32 位进程64 位进程不会加载 32 位钩子 DLL等于你的钩子对 64 位应用全部失效。要覆盖 64 位进程必须在 RAD Studio 里加一个 64 位目标单独编译 EXE 和 DLL 整套。低级钩子没有这个问题因为回调不注入系统是在安装钩子的线程上下文里把它拉起来的。所以混编环境里想快速做一个能覆盖所有进程的全局按键监听低级钩子是省事得多的选择只有真正需要注入型钩子的场景才值得去维护两套位宽的编译产物。5. 提高系统钩子可靠性的三个具体做法5.1 通知消息用 RegisterWindowMessage 登记WM_APP1001 这类自定义消息在单个进程里没问题但系统钩子要跨进程通知宿主其它程序也可能用同一个消息号接收方容易误判。改用一个字符串登记系统会返回全局唯一消息 ID不同程序之间即使字符串相同也会拿到同一个 ID反而方便协作UINT g_uMsg RegisterWindowMessage(LMyApp.GlobalHook.Notify);宿主 WndProc 里用 g_uMsg 比较即可不用关心它具体是多少。这个函数注册成功的返回值在 0xC000 到 0xFFFF 之间返回值 0 表示失败但实际很少发生。5.2 用 LLKHF_INJECTED 过滤自动化注入做输入监控时经常会遇到 SendInput 或自动化测试工具注入的假按键。低级钩子的 KBDLLHOOKSTRUCT 结构里有一个 flags 字段用 LLKHF_INJECTED 可以直接判断是不是真实键盘输入KBDLLHOOKSTRUCT *p reinterpret_castKBDLLHOOKSTRUCT*(lParam); if ((p-flags LLKHF_INJECTED) 0) { // 只处理真实硬件按键 }Windows 10 及以后的版本还有一个 LLKHF_LOWER_IL_INJECTED 标志用于标记由更低完整性级别进程注入的输入。做安全类、合规类监控项目时这两个标志能帮你挡住很大一部分脚本干扰避免把自动化工具产生的假事件记进业务数据里。5.3 验证钩子是否真的在链上用共享计数器安装后返回非 NULL 不代表回调被调用了很多坑是回调压根没进你的代码。比较可靠的验证方式是建一个命名文件映射回调里对计数器加一宿主进程用定时器读值。对着键盘敲几下计数持续增长才说明系统确实调了你的回调。// 宿主端 HANDLE hMap CreateFileMapping(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0, sizeof(DWORD), LLocal\\MyHookCounter); volatile DWORD *pCount (volatile DWORD*)MapViewOfFile( hMap, FILE_MAP_ALL_ACCESS, 0, 0, 0);回调里的写法是在 nCode HC_ACTION 时执行(*pCount)这里不演示完整代码核心思路是用一个跨进程可见的整数做探针。计数不涨时先查位宽是否匹配再查程序是否以管理员权限运行最后看回调里有没有早退分支把处理逻辑跳过了。这个探针比 OutputDebugString 更耐得住高频事件消息量大时也不会拖慢调试器。等计数确认无误再把探针代码摘掉进入正式业务逻辑。本文还有配套的精品资源点击获取
返回列表