背景

当制作一个屏幕截图工具或者窗口标记工具时,为了在屏幕上标记某个形状,有几种可行方案:

  • 获取当前屏幕的HDC,直接绘制你的图形
  • 创建一个无边框窗口,然后在窗口上面绘制

然而第一种方案对于动态变化的窗口适应性不太好,容易产生残影,另外还需要考虑DPI缩放,比较复杂。第二种在以前版本的Windows下没问题,但从Windows 8以后,就需要面对新的情况:窗口拿不到最高Z序。

下面的动图演示了“总在最前”属性的窗口的真实情况:

启用前

可见,普通的应用窗口无论是否设置WS_EX_TOPMOST,窗口的Z序总低于一些特定的程序。

窗口Z序的介绍

在Windows7及以下系统,直接用SetWindowPos(HWND_TOPMOST)可以使窗口在最上层。但从Windows8开始,微软引入了其他窗口段(Band),它们从低到高的顺序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
ZBID_DESKTOP
ZBID_IMMERSIVE_BACKGROUND
ZBID_IMMERSIVE_APPCHROME
ZBID_IMMERSIVE_MOGO
ZBID_IMMERSIVE_INACTIVEMOBODY
ZBID_IMMERSIVE_NOTIFICATION
ZBID_IMMERSIVE_EDGY
ZBID_SYSTEM_TOOLS
ZBID_LOCK(仅Windows 10)
ZBID_ABOVELOCK_UX(仅Windows 10)
ZBID_IMMERSIVE_IHM
ZBID_GENUINE_WINDOWS
ZBID_UIACCESS

默认的窗口段是ZBID_DESKTOP,这导致无论如何SetWindowPos,窗口的Z序始终低于设置过其他更高层段的窗口。

那么为什么不设置其他窗口段呢?

Windows中有下面这些API可以改变程序的窗口段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
HWND WINAPI CreateWindowInBand(
DWORD dwExStyle,
LPCWSTR lpClassName,
LPCWSTR lpWindowName,
DWORD dwStyle,
int x,
int y,
int nWidth,
int nHeight,
HWND hWndParent,
HMENU hMenu,
HINSTANCE hInstance,
LPVOID lpParam,
DWORD dwBand
);
HWND WINAPI CreateWindowInBandEx(
DWORD dwExStyle,
LPCWSTR lpClassName,
LPCWSTR lpWindowName,
DWORD dwStyle,
int x,
int y,
int nWidth,
int nHeight,
HWND hWndParent,
HMENU hMenu,
HINSTANCE hInstance,
LPVOID lpParam,
DWORD dwBand,
DWORD dwTypeFlags
);
BOOL WINAPI SetWindowBand(
HWND hWnd,
HWND hwndInsertAfter,
DWORD dwBand
);

但调用CreateWindowInBand(Ex)的程序必须使用微软的证书进行数字签名,也就是说,只有Windows内置的程序才能使用这些API,任务管理器正是这么做的。而SetWindowBand需要调用私有API:NtUserEnableIAMAccess,它有一个类似句柄的参数(key),此句柄只能通过NtUserAcquireIAMKey获取。而NtUserAcquireIAMKey调用成功的条件是,调用线程必须是当前桌面线程(即调用SetShellWindows(Ex)的线程),而且只能获取一次,否则函数都会ERROR_ACCESS_DENIED,你甚至不能注入explorer.exe获取key,因为explorer.exe已经调用过一次NtUserAcquireIAMKey了。也就是说,只有桌面的管理者能使用SetWindowBand

那有没有其他办法呢?注意到屏幕键盘(osk.exe)和VS的一个工具Inspect.exe的窗口也可以设置比任务管理器高的窗口。逆向后发现它们也不过只是SetWindowPos(HWND_TOPMOST)而已,最后发现是程序的清单中有一项:

1
<requestedExecutionLevel level="asInvoker" uiAccess="true"/>

MSDN中解释说,这个UIAccess权限用于支持无障碍服务,通过它可以在未提权的进程下访问已提权进程的窗口。出于安全考虑,如果要启用它,必须满足:

  • 应用程序必须具有数字签名,可以使用与本地计算机上的受信任根证书颁发机构存储关联的数字证书进行验证。
  • 应用程序必须安装在只能由管理员写入的本地文件夹中,例如Program Files目录。允许的目录包括:
    • %ProgramFiles%和它的子目录
    • %WinDir%和它的子目录,除了少数标准用户具有写权限的子目录。

进程令牌中就有着TokenUIAccess这个属性,这意味着我们在提权后,可以通过SetTokenInformation设置此权限,从而绕过数字签名和指定的安装路径。

但经过一番测试,我最终发现要完成这个操作必须具有SeTcbPrivilege权限,所以一个解决方案是从其他系统进程中“偷”一个令牌,这样就能获取权限了。然而修改已运行的程序的UIAccess是无效的,所以最后只能另起一个进程了。虽然这样有点瑕疵,但还是比之前的注入explorer.exe容错性要好、比数字签名更实际。

获取UIAccess

这里获取项目的源代码。加入uiaccess.cuiaccess.h到你的项目中,然后在程序初始化时调用PrepareUIAccess()即可。

为了正确使用这个模块,程序需要提权运行(elevated),因此最好设置请求管理员权限的清单,或者通过某个已提权的进程启动。

原理

进程以管理员权限启动,然后检测自身是否具有UIAccess权限。此时还未获取权限,所以它遍历进程列表,尝试获取同一Session下winlogon.exe的令牌 ,并用这个令牌创建另一个具有TokenUIAccess的令牌,然后用它启动另一个实例。此实例检测UIAccess权限,权限已经满足,返回ERROR_SUCCESS,随后旧进程退出,具有权限的新进程继续运行。

启用UIAccess后的效果

启用UIAccess并调用SetWindowPos(HWND_TOPMOST)后,窗口Z序将高于任务管理器(ZBID_SYSTEM_TOOLS),与屏幕键盘同层(ZBID_UIACCESS):

启用后

参考

Window z-order in Windows 10 – ADeltaX Blog,中文版:Windows 10中的窗体Z序