0x00 前言
Windows 11对TrayNotifyWnd
进行了改动,导致部分软件鼠标悬停托盘图标失效。本文以“火绒安全软件”为例进行分析。在本例中,Windows 11下无法通过像Windows 10那样鼠标悬停托盘图标打开“功能消息”。
0x01 原因分析
通过语言模型分析HipsTray.exe
,得到以下结果。
// Function to find the ToolbarWindow32 by traversing the window hierarchy.
HWND FindToolbarWindow32_439420()
{
// Find the Shell_TrayWnd window, which is the top-level window for the system tray.
HWND hWnd_Shell_TrayWnd = FindWindowW(L"Shell_TrayWnd", NULL);
if (hWnd_Shell_TrayWnd)
{
// Find the TrayNotifyWnd window, which is a child of Shell_TrayWnd.
HWND hWnd_TrayNotifyWnd = FindWindowExW(hWnd_Shell_TrayWnd, NULL, L"TrayNotifyWnd", NULL);
if (hWnd_TrayNotifyWnd)
{
// Find the SysPager window, which is a child of TrayNotifyWnd.
HWND hWnd_SysPager = FindWindowExW(hWnd_TrayNotifyWnd, NULL, L"SysPager", NULL);
if (hWnd_SysPager)
{
// Return the ToolbarWindow32 window, which is a child of SysPager.
return FindWindowExW(hWnd_SysPager, NULL, L"ToolbarWindow32", NULL);
}
}
}
return NULL; // Return NULL if any of the windows are not found.
}
// Method implementation to get the button rectangle under the cursor position.
signed int Class::GetCursorPosButtonRect_439570(RECT *rectButton)
{
RECT rect{}; // Temporary RECT structure to store window coordinates.
BOOL bUpdateCursorPos = FALSE; // Flag to determine if cursor position needs to be updated.
// Check if the rectButton pointer is NULL and return an error code if it is.
if (!rectButton)
return -22;
POINT cursorPos{}; // Point structure to store the current cursor position.
// Get the current cursor position and return an error code if it fails.
if (!GetCursorPos(&cursorPos))
return -13;
// Get the window handle at the current cursor position.
HWND hWnd = WindowFromPoint(cursorPos);
WCHAR ClassName[MAX_PATH]{}; // Array to store the class name of the window at the cursor position.
// Get the class name of the window at the cursor position and return an error code if it fails.
if (!GetClassNameW(hWnd, ClassName, 260))
return -13;
// Check if the class name is not "ToolbarWindow32".
if (_wcsicmp(ClassName, L"ToolbarWindow32") != 0)
{
// If Field is non-zero, return an error code.
if (this->Field)
return -14;
// Check if the class name is not "TrayNotifyWnd".
if (_wcsicmp(ClassName, L"TrayNotifyWnd") != 0)
{
// Check if the class name is not "TopLevelWindowForOverflowXamlIsland".
if (_wcsicmp(ClassName, L"TopLevelWindowForOverflowXamlIsland") != 0)
return -14;
// Get the window rectangle and update the cursor position if necessary.
GetWindowRect(hWnd, &rect);
cursorPos.y = rect.top;
bUpdateCursorPos = TRUE;
}
// Find the ToolbarWindow32 window using the helper function.
hWnd = FindToolbarWindow32_439420();
// If the ToolbarWindow32 window is not found, return an error code.
if (!hWnd)
return -14;
}
// Get the button size from the ToolbarWindow32.
DWORD button_hw = (DWORD)SendMessageW(hWnd, TB_GETBUTTONSIZE, 0, 0);
WORD button_h = HIWORD(button_hw); // Height of the button.
WORD button_w = LOWORD(button_hw); // Width of the button.
// If the cursor position does not need to be updated, get the window rectangle.
if (!bUpdateCursorPos)
if (!GetWindowRect(hWnd, &rect))
return -13;
// Calculate the button rectangle based on the cursor position and button size.
LONG left_align = rect.left + button_w * ((cursorPos.x - rect.left) / button_w);
rectButton->left = left_align;
rectButton->right = button_w + left_align;
LONG top_align = rect.top + button_h * ((cursorPos.y - rect.top) / button_h);
rectButton->top = top_align;
rectButton->bottom = button_h + top_align;
// Return 0 on success.
return 0;
}
有经验不难发现该段代码大意为获取鼠标悬停托盘图标的方形区域。在Windows 10中,触发消息时鼠标悬停位置对应的窗口类可能为ToolbarWindow32
,也即包含多个托盘图标的托盘区域,通过GetWindowRect
获取得到托盘的方形区域,再由TB_GETBUTTONSIZE
获取得到单个托盘图标的大小,稍加计算即可完成。
然而,在Windows 11中,触发消息时鼠标悬停位置对应的窗口类可能为TrayNotifyWnd
。在这一情况下,该段代码尝试在Shell_TrayWnd
>TrayNotifyWnd
>SysPager
>ToolbarWindow32
下二次查找。不幸的是,Windows 11对TrayNotifyWnd
进行了改动,以24H2为例,没有SysPager
>ToolbarWindow32
。查找失效,该段代码自然返回错误。
至此,我们找到了原因。那么应该如何修复呢?直接修改代码是不可行的。一般在解决兼容性问题场景中,我们无法获取代码。那么修改既有软件呢?抛开EULA协议等因素,无论是修改文件还是修改内存,都面临难以同步软件版本的问题。即使使用特征匹配,也存在假阴性和假阳性。此外,修改文件还会破坏既有数字签名。在本例中,作为安全软件,修改其内存更是不可能的。
0x02 修复方案
那么应该如何修复呢?非常简单,其实不妨“模仿”一个假冒的窗口即可。大概代码如下。
#include <stdlib.h>
#include <windows.h>
int main()
{
HWND hWnd_Shell_TrayWnd;
hWnd_Shell_TrayWnd = FindWindowA("Shell_TrayWnd", NULL);
if (!hWnd_Shell_TrayWnd)
abort();
HWND hWnd_TrayNotifyWnd;
hWnd_TrayNotifyWnd = FindWindowExA(hWnd_Shell_TrayWnd, NULL, "TrayNotifyWnd", NULL);
if (!hWnd_TrayNotifyWnd)
abort();
HWND hWnd_SysPager;
hWnd_SysPager = FindWindowExA(hWnd_TrayNotifyWnd, NULL, "SysPager", NULL);
if (!hWnd_SysPager)
hWnd_SysPager = CreateWindowA("SysPager", NULL, WS_CHILD, 0, 0, 0, 0, hWnd_TrayNotifyWnd, NULL, NULL, NULL);
if (!hWnd_SysPager)
abort();
HWND hWnd_ToolbarWindow32;
hWnd_ToolbarWindow32 = FindWindowExA(hWnd_SysPager, NULL, "ToolbarWindow32", NULL);
if (!hWnd_ToolbarWindow32)
hWnd_ToolbarWindow32 = CreateWindowA("ToolbarWindow32", NULL, WS_CHILD, 0, 0, 0, 0, hWnd_SysPager, NULL, NULL, NULL);
if (!hWnd_ToolbarWindow32)
abort();
MSG msg;
BOOL fGotMessage;
while ((fGotMessage = GetMessageA(&msg, NULL, 0, 0)) != 0 && fGotMessage != -1)
{
TranslateMessage(&msg);
DispatchMessageA(&msg);
}
}
在实际使用时,应当完善以上代码,如做好资源管理释放等。代码大意为逆向实现部分软件的判断逻辑,创建必要的窗口供查找。编译运行以上代码,此时鼠标悬停托盘图标已经可以得到正确响应了,不妨在这之后手动结束。
0xFF 后记
写到这里,有许多不得不提的话。实际上,有个形象的词语形容我们所做的事:“decoy”。这样的解决方案上个世纪就有之,也时常被人捡起。作为参考,不妨看看Raymond Chen的The Old New Thing博客。
https://devblogs.microsoft.com/oldnewthing/20060109-27/?p=32723
https://devblogs.microsoft.com/oldnewthing/20060410-17/?p=32703
https://devblogs.microsoft.com/oldnewthing/20241105-00/?p=110472
这里引用很早一篇博客里面的话。
The solution: Create a “decoy” Control Panel window with the same class name as Windows 3.1, so that this program would find it. The purpose of these “decoys” is to draw the attention of the offending program, taking the brunt of the mistreatment and doing what they can to mimic the original behavior enough to keep that program happy. In this case, it waited patiently for the garbage WM_COMMAND message to arrive and dutifully launched the Printers Control Panel.
Nowadays, this sort of problem would probably have been solved with the use of a shim. But this was back in Windows 95, where application compatibility technology was still comparatively immature. All that was available at the time were application compatibility flags and hot-patching of binaries, wherein the values are modified as they are loaded into memory. Using hot-patching technology was reserved for only the most extreme compatibility cases, because getting permission from the vendor to patch their program was a comparatively lengthy legal process. Patching was considered a “last resort” compatibility mechanism not only for the legal machinery necessary to permit it, but also because patching a program fixes only the versions of the program the patch was developed to address. If the vendor shipped ten versions of a program, ten different patches would have to be developed. And if the vendor shipped another version after Windows 95 was delivered to duplication, that version would be broken when Windows 95 hit the shelves.
It is important to understand the distinction between what is a documented and supported feature and what is an implementation detail. Documented and supported features are contracts between Windows and your program. Windows will uphold its end of the contract for as long as that feature exists. Implementation details, on the other hand, are ephemeral; they can change at any time, be it at the next major operating system release, at the next service pack, even with the next security hotfix. If your program relies on implementation details, you’re contributing to the compatibility cruft that Windows carries around from release to release.
可以发现,近20年前这段话,和我们今天所考虑的何其相似。Windows一直以良好的兼容性著称,这背后是大量不为人知的工作,也带来了大量的历史包袱,甚至被人误解(如之前explorer.exe
检测360)。Windows 11诞生以来,似乎有意减少历史包袱,也造成了更多部分软件不兼容的问题。历史车轮滚滚向前,是是非非难以评说,作为我们,能做好的,就是少一些“hack into”,少依赖具体实现,多支持文档特性。