深入探究 GetProcessHandleFromHwnd API
在我之前的博客文章中,我提到了 GetProcessHandleFromHwnd API。这是一个我直到发现一个公开披露的、使用 Quick Assist UI Access 应用程序的 UAC 绕过 之前都不知道其存在的 API。这个 API 看起来很有趣,所以我认为应该更仔细地研究一下。
我通常从阅读我不了解的 API 的文档开始,假设它有文档的话。它可以让你了解这个 API 存在了多久以及它的安全特性。文档的备注部分包含以下三个我认为有趣的陈述:
但是,如果调用者具有 UIAccess,他们可以使用窗口钩子将代码注入到目标进程中,并从目标进程内部将句柄发送回调用者。
GetProcessHandleFromHwnd 是一个便利函数,它使用这种技术来获取拥有指定 HWND 的进程的句柄。
请注意,它仅在调用者和目标进程以同一用户身份运行时才会成功。
这些陈述有趣的地方在于,它们都不完全正确。首先,正如之前的博客文章概述的那样,仅仅启用 UI Access 并不足以使用窗口钩子,你需要拥有与目标进程相同或更高的完整性级别。其次,如果你去查看 Windows 11 中 GetProcessHandleFromHwnd 是如何实现的,它是一个 Win32k 内核函数,直接打开进程,而不是使用窗口钩子。最后,使用该 API 的 Quick Assist 绕过在启用管理员保护的情况下仍然有效,这意味着进程可以以不同的用户身份运行。
当然,一些事实上的不准确之处可能是自 Vista 发布以来,UAC 和 UI Access 多年来发生的变化。因此,我认为做一点快速的代码考古会很有趣,看看这个 API 多年来是如何变化的,或许能发现一些有趣的行为。
第一个版本
该 API 的第一个版本存在于 Vista 中,在 oleacc.dll 库中实现。文档声称它早在 Windows XP 中就受支持,但这对于该 API 的设计目的来说没什么意义。检查 XP SP3 的库副本并没有显示该 API,所以我们可以假设文档是错误的。该 API 首先尝试直接打开进程,但如果失败,它将完全按照文档描述的那样使用窗口钩子。
带有钩子的 oleacc.dll 库将使用 SetWindowsHookEx API 并指定线程 ID 参数,加载到与窗口关联的进程中。但是,在向窗口发送自定义窗口消息 WM_OLEACC_HOOK 之前,它仍然不会做任何事情。钩子函数大致如下(我已移除错误检查):
void HandleHookMessage(CWPSTRUCT *cwp) {
UINT msg = RegisterWindowMessage(L"WM_OLEACC_HOOK");
if (cwp->message != msg)
return;
WCHAR name[64];
wParam = cwp->wParam;
StringCchPrintf(name, _countof(name),
L"OLEACC_HOOK_SHMEM_%d_%d", wParam,
cwp->lParam);
HANDLE mapping = OpenFileMapping(FILE_MAP_READ |
FILE_MAP_WRITE, FALSE,
name);
DWORD* buffer = (DWORD*)MapViewOfFile(mapping,
FILE_MAP_READ | FILE_MAP_WRITE,
0, 0, sizeof(DWORD));
HANDLE caller = OpenProcess(PROCESS_DUP_HANDLE, FALSE,
cwp->wParam);
HANDLE current = OpenProcess(PROCESS_DUP_HANDLE |
PROCESS_VM_OPERATION | PROCESS_VM_READ |
PROCESS_VM_WRITE | SYNCHRONIZE,
FALSE, GetCurrentProcessId());
HANDLE dup;
DuplicateHandle(CurrentProcess, current, caller, &dup,
0, 0, DUPLICATE_SAME_ACCESS);
InterlockedExchange(buffer, (DWORD)dup);
// Cleanup handles etc.
}
消息参数是调用者的进程 ID(谁想要打开进程句柄)和一个递增的计数器。这些参数用于打开一个命名的内存段,将复制的句柄值传回给调用者。然后,使用一组有限的访问权限打开当前进程句柄的一个副本,并将其复制给调用者。最后,句柄值被复制到共享内存中,消息处理程序返回。API 的调用者现在可以获取复制的句柄并按需使用。
这段代码可能解释了 API 文档中的一些额外内容。如果两个进程以不同的用户身份运行,目标进程可能无法为 PROCESS_DUP_HANDLE 访问打开调用者,传输将失败。虽然 API 确实设置了共享内存的完整性级别,但它没有设置 DACL,因此这也将阻止不同用户打开它。当然,如果目标进程以管理员身份运行,就像在 UAC 的情况下,它几乎肯定可以访问调用者进程以及共享内存,这使得这一点变得无关紧要。
在 Windows 7 中做了一个小改动,钩子函数从主 oleacc.dll 库移到了它自己的二进制文件 oleacchooks.dll 中。钩子函数在导出表中以序号 1 暴露,没有名称。这个 DLL 仍然存在于最新版本的 Windows 11 上,尽管该 API 后来已经移入内核,并且不再有任何用户。
第二个版本
该 API 的第二个版本直到 Windows 10 生命周期的后期,即 1803 版本才出现。这个版本中,API 被移入了一个 Win32k 内核函数。内核 API 从 win32kfull.sys 暴露为 NtUserGetWindowProcessHandle。它大致实现如下:
HANDLE NtUserGetWindowProcessHandle(HWND hWnd,
ACCESS_MASK DesiredAccess) {
WND* wnd = ValidateHwnd(Wnd);
if (!wnd) {
return NULL;
}
THREADINFO* curr_thread =
W32GetThreadWin32Thread(KeGetCurrentThread());
THREADINFO* win_thread = wnd->Thread;;
if (curr_thread->Desktop != win_thread->Desktop) {
goto access_denied;
}
PROCESSINFO* win_process = win_thread->ppi;
PROCESSINFO* curr_process = curr_thread->ppi;
if (gbEnforceUIPI) {
if (!CheckAccess(curr_process->UIPIInfo,
win_process->UIPIInfo)) {
if (!curr_process->HasUiAccessFlag) {
goto access_denied;
}
}
}
else if (win_thread->AuthId != curr_thread->AuthId) {
goto access_denied;
}
if (win_thread->TIF_flags & (TIF_SYSTEMTHREAD |
TIF_CSRSSTHREAD)) {
goto access_denied;
}
KPROCESS process = NULL;
DWORD process_id = PsGetThreadProcessId(win_thread->KThread);
PsLookupProcessByProcessId(process_id, &process);
HANDLE handle = NULL;
ObOpenObjectByPointer(process, 0, NULL, DesiredAccess,
PsProcessType, KernelMode, &handle);
return handle;
access_denied:
UserSetLastError(ERROR_ACCESS_DENIED);
return NULL;
}
新 API 需要注意的一点是,它接受一个 ACCESS_MASK 来指定调用者希望进程句柄具有什么访问权限。这与旧实现不同,旧实现中所需的访问权限是一个固定值。窗口句柄被验证并用于查找关联线程的 Win32k THREADINFO 结构,并进行检查以确保调用者的线程和目标窗口位于同一个桌面上。
然后我们进入 UIPI 强制检查,首先它检查 gbEnforceUIPI 全局变量。如果启用了 UIPI,它将调用一个 CheckAccess 方法来查看是否允许调用者访问目标窗口的进程。如果检查失败,它将测试调用者是否启用了 UI Access 标志,如果没有,函数将拒绝访问,否则将允许继续。访问检查非常简单:
BOOLEAN CheckAccess(UIPI_INFO *Current, UIPI_INFO* Target) {
if (Current->IntegrityLevel > Target->IntegrityLevel) {
return TRUE;
}
if (Current->IntegrityLevel != Target->IntegrityLevel) {
return FALSE;
}
if (Current->AppContainerNo != Target->AppContainerNo &&
Current->AppContainerNo != -1 &&
Target->AppContainerNo != -1) {
return FALSE;
}
return TRUE:
}
如果调用者的完整性级别高于目标,则立即通过检查。如果低于目标,则立即失败。但是,如果完整性级别相同,它会进行检查以确保进程是否在 AppContainer 沙箱中以及它们是否在同一个沙箱中。如果进程不在 AppContainer 沙箱中,AppContainerNo 值设置为 -1。该检查还确保这不会允许低完整性进程访问 AppContainer 进程,因为已经存在一个检查来防止通过 OpenProcess 发生这种情况。如果一切通过,检查返回 TRUE。
如果不强制执行 UIPI,则比较身份验证 ID。该函数仅允许调用者在同一登录会话中时访问,这意味着如果 UIPI 被禁用,这将不允许访问提升的 UAC 进程。最后的检查是目标线程是否在系统(即内核)进程或 CSRSS 进程中。如果是,则拒绝访问。
最后,通过查找 KPROCESS 指针,然后使用 ObOpenObjectByPointer 以所需的访问权限打开句柄,从而通过其进程 ID 打开目标进程。关键的是,访问模式设置为 KernelMode。这意味着不对进程对象执行任何访问检查。
这个函数一个明显的安全问题是,目标进程的打开没有对调用者想要的任何访问权限进行访问检查。这是一个问题,因为它允许任何具有相同或更高完整性级别的进程打开任何其他进程,只要它至少有一个窗口。
这对于两种进程类型来说是一个特殊问题,首先是受限令牌沙箱进程。虽然你可能认为如果两个以相同完整性级别运行的受限令牌沙箱进程可以相互访问,这没什么大不了的,但情况并非总是如此。例如,Chromium 不允许渲染器相互打开,并且一些渲染器比其他渲染器拥有更多权限,例如,如果它们正在渲染 WebUI 内容。幸运的是,至少在这种情况下,渲染器在 win32k 锁定下运行,这意味着即使它们想创建窗口也无法创建。
第二个是受保护进程。如果你以访问模式设置为 KernelMode 的方式打开受保护进程的句柄,那么它将完全绕过保护而被允许。你可能认为受保护进程不会创建窗口,但它可能是一个仅消息窗口,例如为了支持 COM,代码甚至可能没有意识到它创建了窗口。
然而,即使调用者没有合适的完整性级别,只要启用了 UI Access 标志就足够了。这意味着,像我的 令牌窃取攻击 这样的技巧就足以打开同一桌面上创建了窗口的任何其他进程。这个问题已报告给 MSRC 并作为 CVE-2023-41772 修复。报告者是同一位研究员 Sascha Mayer,他找到了我之前提到的 Quick Assist UI Access 绕过。
第三个版本
这个版本的目标是修复 CVE-2023-41772,有两个主要变化。首先也是最重要的,如果 UIPI 检查失败,函数仍将检查 UI Access 标志是否启用。但是,不是允许它继续,而是强制调用 ObOpenObjectByPointer 以访问模式设置为 UserMode 而不是 KernelMode 的方式打开句柄。
传递 UserMode 确保启用访问检查。最终结果是,启用 UI Access 标志不会授予比直接调用 NtOpenProcess 系统调用更多的任何额外权限。推测这是出于兼容性原因而保留的。然而,当调用者的完整性级别大于或等于目标时,这并没有改变行为,进程对象仍将以访问模式设置为 KernelMode 的方式打开。这意味着对于受限令牌沙箱或受保护进程,没有任何变化。
第二个不太重要的变化是,所需的访问权限现在被限制在一组有限的访问权限内,与原始的基于钩子的实现相匹配。调用者只能向函数传递以下访问权限:PROCESS_DUP_HANDLE、PROCESS_VM_OPERATION、PROCESS_VM_READ 和 PROCESS_VM_WRITE,否则访问将被拒绝。然而,这种访问量足以完全破坏目标进程。
最新版本
Windows 11 24H2 对 NtUserGetWindowProcessHandle 的行为引入了两个重大变化。首先是对 UIPI 访问检查的更改,让我们看一段代码片段:
BOOLEAN UIPrivilegeIsolation::CheckAccess(UIPI_INFO *Current, UIPI_INFO* Target) {
if (!Feature_UIPIAlwaysOn_IsEnabled() &&
!UIPrivilegeIsolation::fEnforceUIPI) {
return TRUE;
}
if (Target->ProcessProtection != 0 &&
(Target->ProcessProtection != Current->Protection)) {
return FALSE;
}
if (Current->IntegrityLevel > Target->IntegrityLevel) {
return TRUE;
}
...
}
这个变化引入了一个窗口功能标志来强制始终启用 UIPI,以前可以通过系统配置更改来禁用 UIPI。功能标志允许微软在 Windows 系统上进行 A/B 测试;这可能意味着他们希望在未来永久启用 UIPI。
内核驱动程序还将进程保护作为 UIPI 信息的一部分捕获,并进行检查,确保目标未受保护或者调用者具有匹配的保护级别。这阻止了之前允许 NtUserGetWindowProcessHandle 打开受保护进程的攻击。
这个检查的一个弱点是它没有使用内核用来确定一个保护级别是否取代另一个的比较。虽然这在某种程度上是好的,但有一个小错误。有一个 PPL App 级别,其设计目的是使同一级别的其他进程无法相互打开。这种行为可能是因为 PPL App 级别设计为由 Windows Store 的第三方应用程序使用。实现的检查将允许一个 PPL App 进程打开另一个,当然,你仍然需要首先在 PPL App 进程中获取代码执行权限,所以这似乎不是一个主要问题。
需要注意的是,如果在系统级别禁用了 UIPI,则忽略保护检查。因此,如果你愿意重启系统并拥有管理员访问权限,你可以通过在 HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System 键内设置一个值为 0 的 EnforceUIPI DWORD 注册表值来禁用 UIPI。你可能还需要禁用 UIPIAlwaysOn 功能标志,你可以使用像 ViVe 这样的工具并以管理员身份运行命令 ViveTool.exe /disable /id:56625134 并重启机器来实现。
第二个重大变化在 NtUserGetWindowProcessHandle 中。该函数现在有两个路径,由功能标志 ResponsiblePid 控制。如果功能标志被禁用,它采用旧路径,但如果启用,它会调用一个新函数 GetWindowProcessHandleUnsafe。具有讽刺意味的是,与名称相反,这似乎是该 API 的一个更安全的版本。
这里的一个重大变化是,要打开一个进程,调用者必须启用 UI Access 标志。在没有启用 UI Access 标志的情况下调用 API 将给出访问被拒绝错误。此外,如果你在系统级别禁用 UIPI,API 也将返回访问被拒绝,它不会回退到不安全的操作模式。至少在我的 25H2 虚拟机上,ResponsiblePid 功能标志总是启用的,但我可能只是受到了 A/B 测试的影响。
要以 KernelMode 访问权限打开进程,你仍然需要通过 UIPI 检查。由于你不能通过禁用强制来绕过检查;这阻止了打开受保护进程。因此,在最新版本的 Windows 11 上,要访问受保护进程,你不仅需要禁用 UIPI 和 UIPIAlwaysOn 功能标志,还需要禁用 ResponsiblePid 功能标志以访问旧实现。如果你想用 ViVe 禁用它,ResponsiblePid 功能标志 ID 是 56032228。这当然需要管理员访问权限并重启机器,可能加载内核驱动程序更容易。
劫持 TCB 级别的受保护进程
假设你仍在运行 Windows 10(这可能是一个永久性的漏洞)、24H2 之前的 Windows 11(23H2 企业版/教育版在 2026 年 11 月之前仍受支持)或已完全禁用 UIPI,我们现在可以 GetProcessHandleFromHwnd 来破坏一个受保护进程。
理想情况下,我们希望获得最高级别 Protected TCB,以允许我们随后打开系统上的任何其他用户进程,无论其保护状态如何。我们如何让一个在 Protected TCB 级别运行的进程创建一个我们可以用来打开进程句柄的窗口?我在 2018 年的一篇关于通过使用 COM IRundown 接口劫持受保护进程的 博客文章 中已经描述了如何做到这一点。
具体来说,可以强制在 Protected TCB 级别运行的 WerFaultSecure.exe 初始化一个 COM 单线程单元 (STA)。这允许访问 IRundown 接口,但更重要的是,对于我们的目的,STA 还设置了一个带有 OleMainThreadWndClass 类的仅消息窗口,用于将调用发布回单元线程。
然而,如果我们不再需要强制 COM 初始化,结果会更容易。WerSecureFault.exe 在正常操作期间会自动创建许多窗口。首先,你需要在“上传”模式下以受保护级别运行该进程。使用以下命令行:
WerFaultSecure.exe -u -p {PID} -ip {PARENT_PID} -s {SECTION_HANDLE}
将 PID 替换为虚拟调试进程的进程 ID,PARENT_PID 替换为你当前进程的进程 ID,SECTION_HANDLE 是一个指向包含以下 32 位整数的共享内存段的句柄:0xF8、PID 和 TID,其中 PID 和 TID 是虚拟调试进程的进程 ID 和线程 ID。这个段句柄必须在创建时继承到新进程中。
接下来,你需要找到创建的窗口,但这很容易。只需使用 FindWindowEx API 枚举窗口。对于每个窗口,你可以使用 GetWindowThreadProcessId 查找 PID,并与创建的受保护进程进行匹配。你可能需要使用类似机会锁的东西在 WerFaultSecure.exe 进程创建窗口后暂停它,以便给你时间枚举它们。
最后一步是使用找到的窗口句柄调用 GetProcessHandleFromHwnd,你应该会得到一个具有 PROCESS_DUP_HANDLE, PROCESS_VM_OPERATION, PROCESS_VM_READ, PROCESS_VM_WRITE, PROCESS_QUERY_LIMITED_INFORMATION 访问权限的进程句柄。通常,使用这种访问权限,我会复制当前进程伪句柄的副本以获得完全访问句柄。然而,由于受保护进程的工作方式,这将失败,因为保护检查既包括直接打开进程也包括复制句柄。
因此,这就是你将获得的所有访问权限。虽然你不能在进程中直接创建一个新线程,但它为你提供了对进程的足够访问权限来分配和修改可执行内存,所以一个简单的攻击是将一些 shellcode 写入进程并修改现有的跳转来执行代码。我将把最终的利用留给读者作为练习。或者,在我 发布了我版本的控制台输出截图 之后,Sascha Mayer 已经 发布了一个 PoC,你可以用它来尝试。
结论
总之,GetProcessHandleFromHwnd 函数在其多年来的演变过程中非常有趣。第一个使用窗口钩子的版本实际上在访问受保护进程方面是安全的,因为你无法将具有 PROCESS_VM_READ 等访问权限的进程句柄从受保护进程复制到非受保护进程。然而,决定在内核模式下完成所有操作会更好,但忘记了对受保护进程的检查。
最后,在 Windows 11 24H2 中,随着对 UIPI 的全面调整,这个问题似乎得到了修复,并且该函数也不再那么危险。时间会告诉我们,至少其中一些变化,比如使 UIPI 永久化,是否会实现。