ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++Builder实战:全局键盘钩子原理与DLL注入实现

C++Builder实战:全局键盘钩子原理与DLL注入实现 简介面向CBuilder开发者的系统钩子实现项目压缩包内含完整可编译的DLL钩子工程与调用示例适合需要监听键盘、鼠标或系统级事件的桌面应用开发者。包体体积约26KB共18个文件以cpp/h源文件、bpr/bpf工程配置、def导出定义、dfm窗体布局为主并附有已编译的exe和dll便于直接运行验证目前已有205人学习下载。源码结构区分主程序与DLL两部分主程序模块负责界面初始化和钩子的安装卸载DLL模块则在DLLMain中完成钩子过程注册并利用回调函数过滤或改写系统消息这种拆分方式便于复用也为理解Windows钩子链协作提供了直观范例。实现中涉及SetWindowsHookEx、CallNextHookEx、共享数据段等关键API可扩展到低级键盘鼠标监听、日志记录等场景也可据此排查钩子失效、消息丢失等常见问题适合具备C基础、希望借完整示例深入理解Windows消息机制的开发者。1. 系统钩子一上来就打破的“进程边界”幻觉CBuilder 正好接住很多人在 C# 里做全局键盘钩子做到一半就回头翻 CBuilder 的老项目系统钩子回调必须住进目标进程的地址空间.NET 程序集在非托管进程里初始化运行时往往失控。这里说的系统钩子是指通过SetWindowsHookEx挂进系统钩子链的那类钩子它让一个 DLL 里的回调函数被 Windows 注入到其他进程的消息处理流程里因而能捕获跨进程的键盘鼠标事件。典型场景有收银终端锁键盘、教学软件拦截全局快捷键、无人值守设备自动灭屏。这篇文章以 CBuilder 为载体从钩子原理、DLL 导出到安装端主程序和验证给一套能直接编译的运行路径WH_KEYBOARD与WH_KEYBOARD_LL的差异以及回调里为什么不能碰 VCL也会一并讲清。2. 系统钩子的运行原理与钩子类型选型哪种钩子必须进 DLL2.1 钩子链、SetWindowsHookEx 的参数表和线程绑定在 Windows 中SetWindowsHookEx将一个钩子过程挂到一类消息的处理链上。钩子链是倒序单向的最后挂的最先被调用回调只有把不处理的消息交给CallNextHookEx后一位钩子才有机会看到。函数签名是HHOOK SetWindowsHookEx( int idHook, HOOKPROC lpfn, HINSTANCE hMod, DWORD dwThreadId );四个参数分别决定钩子类型、回调入口、回调所在的模块以及挂在哪个线程上。dwThreadId为 0 表示挂到系统全局不为 0 则只挂在指定线程单线程钩子通常不需要 DLL因为回调函数就在当前进程里。一个直接的选型表比文字描述更直观idHook 值触发范围全局钩子是否需要 DLL回调运行位置WH_KEYBOARD (2)全局或单线程需要目标进程的消息线程WH_MOUSE (7)全局或单线程需要目标进程的消息线程WH_KEYBOARD_LL (13)仅全局不需要安装钩子的线程WH_MOUSE_LL (14)仅全局不需要安装钩子的线程这里有个反直觉的地方并不是所有系统钩子都要 DLL。WH_KEYBOARD_LL这类低级钩子由系统把结构化事件投递到安装线程的消息队列里回调在安装者进程内执行所以可以完全避开 DLL 注入。但它要求安装线程必须跑消息循环否则回调一次都不会进来。2.2 为什么全局键盘钩子必须放进 DLL函数指针跨进程没有意义如果把一个普通 EXE 里的函数指针交给SetWindowsHookExWindows 要把这个函数调用到目标进程的消息流程里但目标进程没有你的代码。虚拟地址空间是隔离的同一地址在不同进程里可能放着完全不同的数据操作系统又不能只复制一段函数过去因为函数可能引用全局变量、调用 CRT 函数。解决办法是让回调所在的 DLL 被注入目标进程目标进程加载 DLL 后自然就有一份完整且地址正确的KeyboardProc可执行。这就是为什么老文档里强调“全局钩子必须写在 DLL 里”。用 CBuilder 做这件事的典型组合是一个 DLL 工程导出钩子过程和安装卸载函数一个 EXE 工程负责加载 DLL、调用安装函数、保持进程运行。DLL 同时也要导出extern C的导出名否则回调函数经过 C 名称改编后GetProcAddress根本找不到。2.3 从 DLL Wizard 到最小导出骨架CBuilder 的钩子 DLL 工程在 CBuilder 里新建 DLL 向导后默认入口是DllMain或DllEntryPoint它会在DLL_PROCESS_ATTACH时拿到模块句柄这个句柄必须保存下来因为SetWindowsHookEx的hMod参数需要它。最小骨架可以写成这样extern C __declspec(dllexport) LRESULT CALLBACK KeyboardProc( int nCode, WPARAM wParam, LPARAM lParam) { return CallNextHookEx(NULL, nCode, wParam, lParam); }CALLBACK在windows.h里定义成__stdcall这是系统回调约定不能换。用extern C是为了避免 C 链接修饰否则导出名会变成带和参数长度的怪名字。编译时还要注意CBuilder 默认 32 位 DLL 的调用约定和导出符号修饰最好显式依赖__declspec(dllexport)并在 Project Options 的链接器设置里确认生成的是标准 Windows DLL而不是控制台程序误用的库。3. 在 CBuilder 中编写系统钩子 DLL钩子过程、共享数据与导出参数3.1 带计数器的 WH_KEYBOARD 钩子 DLL 完整代码一个能直接编译的全局键盘钩子 DLL可以按下面这个结构写。它只做一件事每次有按键经过钩子链时用一个跨进程计数变量记录次数不拦截任何按键。#include windows.h #pragma data_seg(.Hooks) volatile LONG g_counter 0; // 准备好让所有进程共享的计数 #pragma data_seg() HINSTANCE g_hInst; HHOOK g_hHook NULL; BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD reason, LPVOID) { g_hInst hinstDLL; // 记住模块句柄安装钩子时必须用到 return TRUE; } extern C __declspec(dllexport) LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) { // nCode 0 时系统要求必须交给 CallNextHookEx不能做任何处理 if (nCode 0) return CallNextHookEx(NULL, nCode, wParam, lParam); if (nCode HC_ACTION) { // 这里运行在被注入进程的线程上下文里 // 不能访问调用端 Form也不能直接弹对话框 InterlockedIncrement(g_counter); } // 永远不要吞掉消息否则其他钩子和目标窗口会行为异常 return CallNextHookEx(NULL, nCode, wParam, lParam); } extern C __declspec(dllexport) BOOL WINAPI InstallHook() { if (g_hHook) return TRUE; g_hHook SetWindowsHookEx(WH_KEYBOARD, KeyboardProc, g_hInst, 0); return g_hHook ! NULL; } extern C __declspec(dllexport) void WINAPI UninstallHook() { if (g_hHook) { UnhookWindowsHookEx(g_hHook); g_hHook NULL; } } extern C __declspec(dllexport) LONG WINAPI GetHookCount() { return g_counter; }代码里的CallNextHookEx(NULL, ...)是常见写法当 DLL 被注入到其他进程时那份副本里的g_hHook是 NULL因为非共享数据在每个进程各有一份把句柄传 NULL 并不会中断钩子链系统会继续找下一个钩子。wParam在HC_ACTION分支里是虚拟键码想过滤按键时直接和VK_F2这类常量比较即可lParam的低位字是重复计数高位字里携带扫描码和扩展键标志一般不用主动解析。InterlockedIncrement必须用因为g_counter一旦放进共享段就会同时被多个进程的多个线程读写普通不是原子操作长时间跑下来计数一定偏。3.2 跨进程共享数据共享数据段与内存映射文件的两种做法上面代码里的#pragma data_seg(.Hooks)能不能真正跨进程共享取决于链接阶段是否把.Hooks段标成 Read、Write、Shared。MSVC 下常见的是补一句#pragma comment(linker, /SECTION:.Hooks,RWS)但 CBuilder 的链接器对这条指令的接受程度不如 MSVC。如果编译后你发现每个进程的计数都从零开始说明段属性没有生效。更稳妥的做法是在 DLL 里用内存映射文件结构如下struct HookSharedData { LONG count; }; HANDLE g_hMap NULL; HookSharedData* g_data NULL; BOOL InitSharedData() { g_hMap CreateFileMappingA( INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0, sizeof(HookSharedData), HookCounterDemo); if (!g_hMap) return FALSE; g_data (HookSharedData*)MapViewOfFile( g_hMap, FILE_MAP_ALL_ACCESS, 0, 0, 0); return g_data ! NULL; }注意名字不要写成Global\\HookCounterDemo。不带动词前缀时命名对象默认在当前会话的 Local 命名空间里普通权限就能打开强行用Global\\反而容易撞上权限拒绝。InitSharedData要在DLL_PROCESS_ATTACH里调用一次这样每个被注入的进程都会映射到同一块内核对象计数就真正全局一致了。共享段的优势是代码量小适合放标志位和短整数内存映射文件适合放结构体可扩展成带互斥量的消息队列。做系统钩子时多数数据量也就几十字节按团队习惯选一种即可。3.3 回调里为什么不能碰 VCL 对象跨进程转发的正确姿势WH_KEYBOARD的回调运行在被注入进程的线程里那个线程既没有你的Form1也没有Application-Handle。在回调里写Form1-Caption ...等于访问另一个进程的虚拟地址轻则静默失效重则直接访问违例。常见做法是钩子 DLL 只负责记录和简单拦截业务逻辑通过跨进程通道交给安装端。最简单的通道就是共享计数安装端轮询更主动的通道可以用命名管道、窗口消息或事件对象。如果一定要通知 GUI建议在安装端创建一个隐藏窗口使用RegisterWindowMessage注册一个全局消息名然后在 DLL 里PostMessage到这个窗口。这样既不用暴露窗口句柄也能避免跨进程调用函数指针的坑。4. 用 CBuilder 写调用端安装系统钩子、消息循环与卸载顺序4.1 动态加载 DLL 的最小调用端调用端用LoadLibrary加GetProcAddress比静态链接.lib更省事因为不需要为 CBuilder 单独生成导入库。下面是一个控制台版本的最小主程序#include windows.h #include cstdio typedef BOOL (WINAPI *InstallHookFn)(); typedef void (WINAPI *UninstallHookFn)(); int main() { HMODULE hDll LoadLibraryA(HookDll.dll); if (!hDll) { printf(LoadLibraryA failed, error%lu\n, GetLastError()); return 1; } InstallHookFn install (InstallHookFn)GetProcAddress(hDll, InstallHook); UninstallHookFn uninstall (UninstallHookFn)GetProcAddress(hDll, UninstallHook); if (!install || !uninstall) { printf(GetProcAddress failed, error%lu\n, GetLastError()); FreeLibrary(hDll); return 1; } if (!install()) { printf(SetWindowsHookEx failed, error%lu\n, GetLastError()); FreeLibrary(hDll); return 1; } printf(global hook running, press Enter to remove...\n); getchar(); // 顺序先摘钩再释放模块 uninstall(); FreeLibrary(hDll); return 0; }这段代码的要点是先保存HMODULE所有失败分支都要返回前FreeLibrary。install()里的SetWindowsHookEx使用的是 DLL 自己的模块句柄Windows 会锁定这份 DLL 的映射所以主程序在退出前必须先uninstall()再FreeLibrary。如果反过来被注入的进程里可能还残留着指向已卸载 DLL 的钩子入口下一次按键事件直接访问违例。如果你在这个 EXE 里改用WH_KEYBOARD_LL就得把getchar()换成消息循环因为低级钩子回调依赖安装线程的消息泵而WH_KEYBOARD全局钩子的回调发生在目标进程侧所以这里用阻塞输入并不会阻挡按键捕获。4.2 32 位与 64 位进程的边界为什么钩子对一部分程序无效这是 CBuilder 老项目最容易踩的硬坑32 位钩子 DLL 只能注入到 32 位目标进程64 位钩子 DLL 只能注入到 64 位目标进程。假如你的安装端是 32 位编译系统里跑着 64 位记事本钩子根本收不到它的按键。安装端位数钩子 DLL 位数典型可见目标32 位32 位32 位进程64 位64 位64 位进程CBuilder 老版本只能编 32 位现代 Windows 默认记事本又是 64 位所以测试时会发现“自己在自己程序里按键有反应记事本没反应”。解决方法是把安装端和 DLL 都按目标进程位数编译需要全覆盖时准备两份钩子 DLL然后由安装端按目标进程位数分别加载。注意这里说的只是位数不是权限。4.3 权限隔离系统钩子不能通吃高权限进程Windows 的用户界面特权隔离会把完整性级别不同的窗口隔开。普通权限进程安装的全局钩子无法注入到以管理员身份运行的进程里反过来管理员进程安装的钩子也看不到更低完整性级别的受保护进程。测试时最容易遇到的现象是钩子对普通记事本有效对“以管理员身份运行”的编辑器无效。因此验证逻辑时保持安装端和目标程序处在同一权限级别能省掉大量无谓排查。5. 系统钩子的验证、排错与收尾技巧从 GetLastError 到进程隔离5.1 用共享计数器验证 DLL 确实被注入光看安装函数返回 TRUE 不算验证。更可靠的验证是打开另一个进程敲几个键再回到调用端读取计数。上面 DLL 里导出的GetHookCount可以直接用typedef LONG (WINAPI *GetCountFn)(); GetCountFn getCount (GetCountFn)GetProcAddress(hDll, GetHookCount); // 在 getchar() 之后、卸载之前打印 printf(hook count %ld\n, getCount());如果计数不变先确认目标进程位数再确认权限级别一致。常见组合是32 位安装端 32 位 DLL 64 位记事本计数永远为零。把记事本换回 32 位测试程序后计数就动了。5.2 安装失败时优先看 GetLastErrorSetWindowsHookEx返回 NULL 时GetLastError基本能定位方向常见的几个是ERROR_MOD_NOT_FOUNDDLL 路径不对或者hMod传了 EXE 模块句柄。ERROR_PROC_NOT_FOUND导出函数名找不到多半是extern C丢失或调用约定不一致。ERROR_ACCESS_DENIED完整性级别不匹配或者跨会话安装全局钩子时权限不足。ERROR_INVALID_HOOK_FILTER钩子类型和回调参数不匹配低级钩子用错了回调签名。回调不触发时GetLastError不一定有值因为安装本身可能成功。此时要在回调入口处写文件日志或使用共享计数确认 DLL 是否真的进入目标进程而不是盯住安装端的状态。5.3 收尾技巧先摘钩再释放最后验证链完整实际项目中我一般会让 DLL 提供InstallHook、UninstallHook、GetHookCount三个导出函数安装端在进程退出、窗口销毁、服务停止三个点都调用UninstallHook。卸载后不要立刻释放 DLL可以先休眠 50 毫秒再FreeLibrary给操作系统清理钩子条目留一点余量。如果你把系统钩子写成了后台服务还要注意服务运行在 Session 0普通交互进程在别的会话里全局钩子默认看不到它们。这是另一个独立话题但和权限隔离都属于“安装成功但无效果”的高频原因。设备端测试前先打开任务管理器确认双方完整性级别相同再用共享计数验证注入这两步能替你挡掉后续大半排错时间。本文还有配套的精品资源点击获取
返回列表