挂钩 Windows 的窗口消息处理函数

[src: Terraria]

动机

很多游戏在窗口失焦时会自动暂停,绝大多数情况下这没问题,也是玩家期待的行为,因为游戏大概率是全屏或最大化运行,没有玩家希望因为一个弹窗而导致角色死亡。但是如果游戏的输入操作都已经被自动化(如游戏内机制、脚本),或者游戏本身就并不需要太多输入,甚至双屏且大脑具有超线程的用户希望同时激活两个窗口。这个【自动暂停】显然是不利的,强制保持ACTIVE意味着进行其他工作必然会暂停该游戏。(Windows桌面环境只能一个窗口处于激活状态)

把行为类似这个游戏的程序称为【独占程序】,与之相对的,失焦不会暂停的程序(如浏览器、视频播放器)称为【非独占程序】。

解决方案

我们不能对操作系统下手,要解决这个问题,只能改变【独占程序】的行为。让【独占程序】不再独占需要对 消息处理函数 WndProc 下手,用 Win32 API写过窗口程序的朋友应该熟悉这个函数,该函数处理发送到窗口的消息。用户的所有操作都以操作系统桌面发消息的形式与程序交互,只要拦截了可能导致暂停的消息,那么就去掉了这个程序的“独占性”。

而对 WndProc 下手的做法就是一种挂钩(hooking),而处理拦截的代码,被称为钩子(hook)。

以下来自 wiki 条目:

钩子编程(hooking),也称作“挂钩”,是计算机程序设计术语,指通过拦截软件模块间的函数调用、消息传递、事件传递来修改或扩展操作系统、应用程序或其他软件组件的行为的各种技术。处理被拦截的函数调用、事件、消息的代码,被称为钩子(hook)。

可以见得,并不是只有拦截 消息处理函数 的行为是挂钩。

而拦截了 WndProc 也不是只能处理这里【独占程序】的问题,与消息处理有关的其他问题也有可能通过这种方法解决(如自定义×按钮的行为、自定义组合键、分离输入)

细节

因为知道挂钩 Win32 API 已经有一些可以拿来主义的成品了1,如 EasyHook,顺着这个思路考虑。WndProc 函数是实现了窗口功能的程序员提供的,本身并不是 Win32 API,没法直接挂钩这个函数,只能挂钩任何与 WndProc 相关的 Win32 API 函数,如 CallWindowProc,DefWindowProc,RegisterClass,任何对这些函数的调用将被拦截,每次调用传入的 WndProc 参数都会被修改后传入原版 Win32 API 函数,这样就能完成任务。 这个方法很绕而且不干净,遇到了一些问题后我放弃了这个方法。

后来才知道 SetWindowLongPtr 能在运行时直接修改 WndProc,这时我才从圈子里转出来,挂钩消息处理函数并不需要挂钩 Win32 API。挂钩 WndProc 的代码大概像下面一样。

#include <Windows.h>

WNDPROC originalWndProc = NULL;

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) {
if (message == WM_NCACTIVATE)
return 0;
return CallWindowProc(originalWndProc, hWnd, message, wParam, lParam);
}

void Hook() {
LONG_PTR lp = SetWindowLongPtr(hWindow, GWLP_WNDPROC, (LONG_PTR)WndProc);
// Changes WNDPROC of the specified window
originalWndProc = (WNDPROC)lp;
}

这里仅仅屏蔽了 WM_NCACTIVATE,根据实际情况可能还需要屏蔽更多消息。

实施

AutoHotkey

因为我使用了 AutoHotkey 进行了一些输入自动化和窗口管理功能,很自然地,我希望用 AutoHotkey 解决这个问题。遗憾的是,结果一番调查发现 AutoHotkey 无法实现此功能。

辅助工具

使用 Visual Studio 的自带工具 Spy++ 或者其他类似工具可以获取窗口信息,这对于实施本节的内容非常有帮助。

侵入式修改

Wiki 提到可以通过修改可执行程序来执行自己添加的代码,这要借助调试器找到 WndProc 的入口点并参考前文作出修改。注意:找入口点也可以直接借助 Spy++ 或其他类似工具。这涉及逆向的内容,虽然麻烦但是有效。

运行时修改

操作系统的事件钩子对这个问题没有帮助。不过我们可以直接向进程注入代码达到目的,或者进行 DLL注入,因为DLL注入用在这里很合适,所以这里给出被注入DLL本身的完整源代码:

// Hook.dll
#include <Windows.h>

WNDPROC originalWndProc = NULL;

// export is unnecessary
LRESULT __declspec(dllexport) CALLBACK
WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) {
if (message == WM_NCACTIVATE)
return 0;
if (message == WM_ACTIVATEAPP && wParam == FALSE)
return 0;
return CallWindowProc(originalWndProc, hWnd, message, wParam, lParam);
}

BOOL CALLBACK EnumWindowsProc(HWND hWnd, LPARAM lParam) {
DWORD pid = GetCurrentProcessId();
DWORD hWnd_pid = 0;
GetWindowThreadProcessId(hWnd, &hWnd_pid);
if (pid == hWnd_pid) {
*(HWND *)lParam = hWnd;
return FALSE;
}
return TRUE;
}

HWND GetCurrentHWND() {
HWND hWnd = 0;
EnumWindows(EnumWindowsProc, (LPARAM)&hWnd);
return hWnd;
}

void Hook() {
HWND hWindow = NULL;
while (hWindow == NULL) {
hWindow = GetCurrentHWND();
// FindWindow is an alternative
Sleep(100);
}
LONG_PTR lp = SetWindowLongPtr(hWindow, GWLP_WNDPROC, (LONG_PTR)WndProc);
// Changes WNDPROC of the specified window

originalWndProc = (WNDPROC)lp;
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call,
LPVOID lpReserved) {
switch (ul_reason_for_call) {
case DLL_PROCESS_ATTACH:
DisableThreadLibraryCalls(hModule);
// This can reduce the size of the working set for some applications.

CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)Hook, hModule,
NULL, NULL);
// Run Hook in new thread
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}

以上代码通过枚举所有窗口来获取当前进程对应的窗口句柄,也可以通过Spy++或其他类似工具取得窗口类名和标题,再借由 FindWindow 获取窗口句柄。

要做的工作还没有结束,还要将编译好 DLL 注入进程。

DLL注入

这节实在没什么可写的(毕竟作者也所知甚少),就直接把wiki放在在这里了,里面搜罗了很多方法。值得一提的是,除了注册表方法(全局),可实施性和通用性都比较好的方法应该就是 CreateRemoteThread 的代码注入方法了,也很适合用在这里。

我把 wiki 的示例代码增补成了一个命令行工具 Injector,和前文的 Hook DLL 源码放在了同一个 repo 里,非常粗糙的程序,小心取用。

实际上,要求程序加载指定 DLL,也可以不在运行时注入 DLL,通过修改 PE 文件的导入表(import table)解决问题2,这有一定的侵入性但某些情况下可能有优势。

分享到