今天小编分享的互联网经验:Win32k的内部结构以及可能出现的漏洞,欢迎阅读。
2022 年 1 月下旬,一个新的微软 Windows 特权更新漏洞 ( CVE-2022-21882 ) 被发现,经分析,这是 Win32k 用户模式回调函数 xxxClientAllocWindowClassExtraBytes 中的一个漏洞。早在 2021 年,微软报告了一个非常类似的漏洞(CVE-2021-1732),并进行了修复。不过分析发现,CVE-2021-1732 的补丁不足以阻止 CVE-2022-21882。
Win32k 背景信息
在 Windows NT 4.0 之前,Microsoft 在名为 Client-Server Runtime SubSystem(CSRSS.exe)的用户模式进程中实现了 Win32 API 的 GUI 功能。然而,用户模式和内核模式之间的上下文切换计算成本高昂,并且需要大量内存消耗。
为了消除这些问题并提高整个 Windows 作業系統的速度,微软决定将 Windows 子系统 ( 視窗管理器、GDI 和图形驱动程式 ) 转移到内核中。这种转变始于 1996 年的 Windows NT 4.0。
这一变化是通过一个名为 Win32k.sys 的内核模式驱动程式实现的,现在被称为内核模式 Windows 子系统,Windows 子系统的用户模式组件仍然驻留在 CSRSS 中。
尽管迁移到内核大大减少了所需的消耗,但微软不得不采用一些老办法,例如在客户端地址空间的用户模式部分缓存管理数据结构。事实上,为了进一步避免上下文切换,一些管理结构在历史上仅以用户模式存储。然而,为了消除内核地址泄漏,微软已经开始实现使用这些结构的用户模式和内核模式副本的方法,以防止内核地址存储在用户模式结构中。
此外,由于 Win32k 需要一种方法来访问这些用户模式结构,并支持一些现有的用户模式功能,如視窗挂钩,因此实现了用户模式回调来促进这些任务。
用户模式回调允许 Win32k 回调到用户模式,并执行诸如调用应用程式定义的挂钩、提供事件通知以及将数据复制到用户模式或从用户模式复制数据等任务。这意味着微软在实现用户模式回调和保持数据完整性方面面临着巨大安全挑战。
研究发现,在进行用户模式回调之前,许多对象没有被正确锁定,这使得用户模式代码可以在用户模式回调期间销毁这些对象,从而导致释放后使用(UAF)漏洞。尽管微软已经解决了许多问题,但用户模式回调在今天仍然被滥用。
Windows GUI API
在讨论 Win32k 内部结构之前,我们将简要介绍一个使用 Win32 API 创建和销毁視窗的简单 C 程式。这将使我们开始了解图形視窗是如何以编程方式创建和操作的。它还允许我们检查定义每个視窗及其菜单的底层结构。
如下图所示,示例程式首先定义一个視窗类。进程必须先注册一个視窗类,然后才能创建 WNDCLASSEX 结构中定义的視窗类型。首先,視窗类对象被声明为 WNDCLASSEX wcx ={},然后填充視窗类结构。
定义視窗类
視窗类的元素如下所示:
cbSize:此结构的大小(以字节为部門),将此成员設定为 sizeof(WNDCLASSEX)。
style:視窗类样式,它可以是类样式的任意组合。
lpfnWndProc:指向处理类中发送到視窗的所有消息并定义視窗行为的函数的指针。通常,默认視窗过程至少用于某些消息。但是,自定义視窗过程通常用于创建独特的視窗体验。
cbClsExtra:在視窗类结构之后要分配的额外字节数,系统将字节初始化为零。
cbWndExtra:在視窗实例之后要分配的额外字节数。系统将字节初始化为零。不要将其与 cbClsExtra 混淆,后者对该視窗类的所有視窗都是通用的。该值通常为 0,但如果不是 0,则内存通常用于存储跨視窗的非恒定数据;
hInstance:包含类的視窗过程的实例的句柄。标识注册该类的应用程式或 .DLL。在此处将 hinstance 参数分配给 WinMain。
hIcon:类的句柄,LoadIcon ( NULL, IDI_APPLICATION ) 加载默认圖示。
hCursor:类游標的句柄,LoadCursor ( NULL, IDC_ARROW ) 加载默认游標。
hbrBackground:类背景笔刷的句柄,GetStockObject ( WHITE_BRUSH ) 返回一个白色笔刷的句柄。返回值必须强制转换,因为 GetStockObject 返回的是泛型对象。
lpszMenuName:指向一个以 null 结尾的字元串的指针,该字元串指定类菜单的资源名称,该名称显示在资源檔案中。如果不需要菜单栏,则此資料欄可以为 NULL。
lpszClassName:用于标识此視窗类结构的类名。
hIconSm:小类圖示的句柄。
既然視窗类的属性已经定义好了,我们需要使用 RegisterClassEx ( ) 在应用程式中注册,如下图所示。如果失败,RegisterClassEX ( ) 返回 0。否则,它返回一个惟一标识所注册类的类原子。注册視窗类将定义该类及其关联的结构成员到 Windows。
正在注册的視窗类
创建視窗
一旦注册了視窗,我们就可以通过调用 CreateWindowExA ( ) 来创建視窗类的实例,如下图所示。
创建視窗的代码
CreateWindowEX 的参数如下图所示。
CreateWindowExA 函数原型
下面列出了每个参数的简要说明:
dwExStyle:正在创建的視窗的扩展視窗样式,在这种情况下,我们将其設定为 WS_EX_LEFT 的默认視窗常数,这为視窗提供了通用的左对齐属性。
lpClassName:类名,取自调用 RegisterClassEX 时声明的 wcx.lpszClassName。
lpWindowName:視窗名称。
dwStyle:所创建視窗的样式,在本例中,我们使用 WS_OVERLAPPEDWINDOW,它创建了一个顶层 ( 父 ) 視窗。
X:視窗的初始水平位置。对于重叠視窗或弹出視窗,x 参数是以螢幕坐标表示的視窗左上角的初始 x 坐标。对于子視窗,x 是視窗左上角相对于父視窗客户区左上角的 x 坐标。如果 x 設定为 CW_USEDEFAULT,系统将选择視窗左上角的默认位置,并忽略 y 参数。
Y:与上述相同,但适用于 Y 坐标。
nWidth:視窗的宽度。
nHeight:視窗的高度。
hWndParent:正在创建的視窗的父視窗或所有者視窗的句柄。
hMenu:菜单的句柄,或指定子視窗标识符,具体取决于視窗样式。对于重叠或弹出視窗,hMenu 标识要与该視窗一起使用的菜单;如果要使用类菜单,则它可以为 NULL。
hInstance:要与視窗关联的模块实例的句柄。
lpParam:传递给視窗的視窗过程的额外信息。如果没有要传输的额外信息,则传递 NULL。
一旦调用 CreateWindowEx ( ) 创建了視窗,就在内部创建了視窗,也就是说,已经分配了内存并填充了其结构,但没有显示。为了显示視窗,我们调用 ShowWindow ( ) 函数。
ShowWindow ( ) 获取从 CreateWindowEXW ( ) 调用获得的句柄和从 WinMain ( ) 获得的状态变量 nCmdShow。nCmdShow 变量确定視窗在螢幕上的显示方式,例如,它是正常的、最大化的还是最小化的。
ShowWindow ( ) 仅控制应用程式視窗的显示方式。这包括诸如标题栏、菜单栏、視窗菜单、最小化按钮等元素。客户端区網域是应用程式显示数据的区網域,例如在文本编辑器中输入文本的区網域。客户端区網域是通过调用 UpdateWindow ( ) 函数绘制的。
如果将 WS_VISIBLE 視窗样式指定为 CreateWindowEXW ( ) 函数的 dwStyle 参数,则不需要调用 ShowWindow ( ) 函数,Windows 会为用户调用它。同样,如果不指定 WS_VISIBLE 样式,也不调用 ShowWindow ( ) 函数,視窗将对视图保持隐藏状态。
視窗消息和視窗过程
调用 UpdateWindow ( ) 之后,視窗就完全可见并可以使用了。在为 Windows 编写更简单的控制台应用程式时,该应用程式会根据控制台中的用户输入进行显式函数调用。
在視窗应用程式中,用户通常可以通过输入文本、点击按钮和菜单或仅仅通过移动滑鼠来与应用程式互動。这些操作中的每一个都有自己的特殊功能。为了实现这一点,微软实现了一个事件驱动系统,该系统将用户输入(如键盘、滑鼠或触摸)的消息中继到每个应用程式中的各个視窗。这些消息由每个視窗内的函数处理,称为視窗过程。
Windows 为每个线程维护一个消息队列,该队列将中继任何影响視窗状态的用户输入事件。然后,Windows 将这些事件转换为消息,并将它们放入消息队列。应用程式通过执行类似于下面中的代码来处理这些消息。
視窗消息队列循环
GetMessage ( ) 函数用于从消息队列中检索下一条消息。MSG 参数是一个结构,它包含所分配的視窗过程正确处理消息所需的消息信息。
MSG 结构的成员中包括其視窗过程接收消息的視窗的句柄(hwnd),以及包含标识符的消息,该标识符确定对視窗过程的请求内容。例如,如果消息包含一个 WM_PAINT 消息,它会告诉視窗过程視窗的工作区已更改,必须重新绘制。
TranslateMessage ( ) 函数可将虚拟密钥消息转换为字元消息,但这对于当前的讨论并不重要。DispatchMessage ( ) 将消息发送到由 msg 结构中的視窗句柄标识的視窗,由该視窗类定义的視窗过程处理。
到目前为止,通过执行以下操作,示例代码已经完成了定义視窗类:
注册視窗;
创建由視窗类定义的視窗实例;
在螢幕上显示視窗;
进入消息循环;
視窗过程决定了显示什么以及如何响应用户输入。Windows 提供了一个默认的視窗过程来处理应用程式未处理的任何視窗消息,并且它为任何視窗正常运行提供了最基本的功能。
視窗过程是定义視窗的所有功能的地方,且它们可能会非常复杂。不过,我们目前只对 Microsoft 的默认視窗过程 DefWindowProc ( ) 感兴趣。
視窗结构
如上所述,Windows 现在通过 Win32k.sys 在内核中管理 GUI 对象,如菜单、視窗等。当创建視窗对象时,会在称为 tagWND 的数据结构中跟踪其属性。
不过,微软删除了许多 Win32k 调试符号,这使得获得这些结构的透明度变得更加困难。基于一些逆向工程,下图显示了 Windows 10 版本 21H1 中的结构。
标记 WND 父结构
在调用 xxxCreateWindowEx 期间查看 HMAllocObject,其中发生了结构的分配,我们可以确认该结构的大小为 0x150(336)字节。
在调用 HMAllocObject 之前的 WinDbg 输出如下图所示。你可以看到第四个参数,它表示分配大小,存储在 r9 寄存器中,等于 0x150。
WinDbg 输出显示 HMAllocObject 的输入参数
tagWND 结构在线程环境块 ( TEB ) 的 Win32ClientInfo 条目中被引用,为了防止内核模式地址泄露,它已被删除了。
内核 tagWND 结构中的第一个条目是視窗句柄。在内核中,每个視窗都有一个与之相关联的代表性 tagWND 结构。
在分析 CVE-2022-21882 期间,此结构将很重要,但现在,我们将重点关注偏移量 0x28。我将其标记为 *pWND,因为微软不再提供符号。此外,微软不再为这个结构提供名称,在过去它被称为 state 或 WW。据微软称,这些名称已被弃用,不再在内部使用。要知道它是 tagWND 数据的用户模式版本,不包括内核地址,且它的结构与其父 tagWND 结构不同。这个子结构既存在于内核中,也存在于用户模式中。这就是 Windows 管理数据的方式,以避免泄露内核地址,因为任何用户模式应用程式都将使用位于用户模式桌面堆上的 tagWND 结构的副本,因此将无法看到任何内核模式地址。
接下来继续将子结构称为 tagWND 结构,不过,它的结构与上面的父 tagWND 结构不同,但在其他研究中仍然通常称为 tagWND。
子标签 WND 结构如下图所示,通过逆向工程确定了元素及其偏移量。
在关于创建視窗的部分中讨论的 WNDCLASSEX 结构的许多元素可以在 tagWND 结构中看到。因此,很明显,当创建視窗时,通过 WNDCLASSEX 结构分配的属性被传递给内核并存储在 tagWND 结构中。然后将属性传播到内核和用户模式桌面堆中的用户副本。
tagWND 结构的用户模式安全副本
下面两个图分别显示了父 tagWND 和用户模式安全 tagWND 结构的内核副本。
父 tagWND 结构的内存转储
上图是父 tagWND,你可以看到句柄 ( 偏移量 0x00 ) 与下面的复制 tagWND 的句柄相同。你还可以看到父结构具有内核地址,而用户模式安全副本仅具有用户模式地址。最后,注意父 tagWND+0x28 是指向子 tagWND 副本地址的指针。
子 tagWND 结构的内存转储
有几种方法可以泄露視窗对象的内核模式地址,Win32k 中存储由用户模式代码設定的属性的所有对象(例如,視窗、菜单)通常被称为用户对象。
所有用户对象(tagWND 结构的用户模式副本是众多对象之一)都在通常称为 UserHandleTable 的每个会话句柄表中进行索引。尽管 tagWND 结构并不总是用户模式安全的副本,并且曾经包含内核地址。
过去,可以通过 UserHandleTable 用 User32.dll 中名为 gSharedInfo 的可导出结构来定位 tagWND 对象。从 Windows 10 版本 1703 起,这个方法将不再有用。由于微软不断努力消除内核地址泄露,他们已经从 UserHandleTable 中删除了桌面堆中对象的内核地址。
視窗管理器使用位于 User32.dll 中的非导出函数 HMValidateHandle 验证句柄。在 Windows 10 版本 1803 之前,視窗管理器返回内核模式指针,指向要验证其句柄的对象,该指针通常用于泄露此地址。尽管内核地址泄漏已经被修复,但当我们稍后查看这两个漏洞时,这种方法将非常重要。
从 Windows 10 版本 1703 开始,任何由 SetWindowLong 写入的字节都不再写入内核。这个修复有效地消除了这种创建任意写入的技术。
确定感兴趣的对象在内核中的位置,以绕过内核地址空间布局随机化(KASLR)。因此,非常需要知道桌面堆的位置。从 Windows 10 1607 版本开始,微软就开始添加缓解措施,试图阻止漏洞编写者在内核中定位桌面堆及其相关对象。这些缓解措施包括从 UserHandleTable 中删除内核地址,如上所述,以及在每个进程的线程环境块 ( TEB ) 中的 Win32ClientInfo 结构中删除对桌面堆的内核指针引用。此外,HMValidateHandle 现在为传递给它的任何对象句柄返回用户模式(相对于内核模式)指针。
用户模式回调
由于 windows 子系统主要位于 windows 内核中,而 windows 本身在用户模式下运行,因此 Win32k 必须频繁地从内核调用到用户模式。用户模式回调提供了一种机制来实现诸如应用程式定义的挂钩、事件通知以及从用户模式向内核复制 / 从内核复制数据等项目。
当进行用户模式回调时,Win32k 调用 KeUserModeCallback,并使用它想要调用的用户模式函数的关联 ApiNumber。ApiNumber 是位于 USER32 .dll ( USER32!apfnDispatch ) 中的函数表的索引。在每个进程的 User32.dll 初始化期间,该表的地址被复制到进程环境块 ( PEB ) ( PEB. kernelcallbacktable ) 。
在即将进行的漏洞分析中,我们将展示如何通过 KernelCallback 表钩住用户模式回调,并展示该表在 WinDbg 中的样子。KeUserModeCallback 的函数原型及其相关参数如下图所示。
KeUserModeCallback 函数原型
用户模式回调输入参数通过 InputBuffer 传递,而回调函数的输出在 OutputBuffer 中返回。在调用系统调用时,ntdll!KiSystemService 或 ntdll!KiFastCallEntry 在内核线程堆栈上存储一个 trap 帧 ( TRAP_FRAME ) ,以保存当前线程上下文,并在返回到用户模式时启用寄存器恢复。
为了在用户模式回调中转换回用户模式,KeUserModeCallback 首先使用线程对象持有的 trap 帧信息将 InputBuffer 复制到用户模式堆栈。然后创建一个新的 trap 帧,EIP 設定为 ntdll!KiUserCallbackDispatcher,替换线程对象的 TrapFrame 指针,最后调用 ntdll!KiServiceExit 将执行返回给用户模式回调调度程式。
KiUserCallbackDispatcher 函数原型
一旦用户模式回调完成,就会调用 NtCallbackReturn 以恢复内核中的执行。此函数将回调的结果复制回原始内核堆栈,并恢复保存在 kernel_stack_CONTROL 结构中的 trap 帧(PreviousTrapFrame)和内核堆栈。在跳转到它先前停止的位置之前 ( 在 ntdll!KiCallUserMode 中 ) ,内核回调堆栈被删除。
函数原型
如果 Win32k 在调用用户模式回调时没有释放资源,并且该用户模式回调允许应用程式冻结 GUI 子系统,则 Win32k 将无法在 GUI 子系统被冻结时执行其他任务。因此,Win32k 总是在调用用户模式回调时释放资源。在从用户模式回调返回时,Win32k 必须确保引用的对象仍然处于不受信任的状态。在未执行适当检查或对象锁定的情况下对此类对象进行操作可能也确实会造成安全漏洞。事实上,研究人员确定了这些类型漏洞的多个实例。
总结
本文介绍了如何使用 Win32 API 创建 GUI 对象,如視窗和菜单,还介绍了用于管理这些对象的用户模式和内核模式数据结构。