CVE-2022-21882 Win32k内核提权漏洞深入分析

 

CVE-2022-21882漏洞是Windows系统的一个本地提权漏洞,微软在2022年1月份安全更新中修补此漏洞。本文章对漏洞成因及利用程序进行了详细的分析。

 

漏洞介绍

CVE-2022-21882是对CVE-2021-1732漏洞的绕过,属于win32k驱动程序中的一个类型混淆漏洞。

攻击者可以在user_mode调用相关的GUI API进行内核调用,如xxxMenuWindowProc、xxxSBWndProc、xxxSwitchWndProc、xxxTooltipWndProc等,这些内核函数会触发回调xxxClientAllocWindowClassExtraBytes。攻击者可以通过hook KernelCallbackTable 中 xxxClientAllocWindowClassExtraBytes 拦截该回调,并使用 NtUserConsoleControl 方法设置 tagWNDK 对象的 ConsoleWindow 标志,从而修改窗口类型。

最终回调后,系统不检查窗口类型是否发生变化,由于类型混淆而引用了错误的数据。flag修改前后的区别在于,在设置flag之前,系统认为tagWNDK.pExtraBytes保存了一个user_mode指针;flag设置后,系统认为tagWNDK.pExtraBytes是内核桌面堆的偏移量,攻击者可以控制这个偏移量,从而导致越界R&W。

本篇文章分析了漏洞成因及漏洞利用手法分析,侧重动态调试及利用手法分析。

 

漏洞影响版本

Windows 10 Version 21H2 for x64-based Systems
Windows 10 Version 21H2 for ARM64-based Systems
Windows 10 Version 21H2 for 32-bit Systems
Windows 11 for ARM64-based Systems
Windows 11 for x64-based Systems
Windows Server, version 20H2 (Server Core Installation)
Windows 10 Version 20H2 for ARM64-based Systems
Windows 10 Version 20H2 for 32-bit Systems
Windows 10 Version 21H1 for ARM64-based Systems
Windows 10 Version 21H1 for x64-based Systems
Windows 10 Version 1909 for x64-based Systems
Windows 10 Version 1909 for 32-bit Systems
Windows Server 2019 (Server Core installation)
Windows Server 2019
Windows 10 Version 1809 for ARM64-based Systems
Windows 10 Version 1809 for x64-based Systems
Windows 10 Version 1809 for 32-bit Systems
Windows 10 Version 20H2 for x64-based Systems
Windows 10 Version 1909 for ARM64-based Systems
Windows Server 2022 (Server Core installation)
Windows Server 2022
Windows 10 Version 21H1 for 32-bit Systems

 

分析环境

Windows 10 21H2 19044.1415 x64
Vmware 16.2.1
VirtualKD-Redux 2020.4.0.0
Windbg 10.0.22000.194

 

背景知识

本节内容描述了创建窗口时需要用到的结构体及函数:

  1. 用户态的窗口数据结构体:WNDCLASSEXW,需要关注cbWndExtra。
  2. 窗口数据保存在内核态时使用:tagWND和tagWNDK结构体,需要关注tagWNDK。
  3. 用户态调用SetWindowLong可以设置窗口扩展内存数据,逆向分析SetWindowLong如何设置窗口扩展内存数据。

窗口类拥有如下属性结构,此处仅列出比较重要的结构:

typedef struct tagWNDCLASSEXW {
    UINT        cbSize;             //结构体的大小
    …
    UINT        style;              //窗口的风格
    WNDPROC     lpfnWndProc;        //处理窗口消息的回调函数地址
    int         cbClsExtra;         //属于此类窗口所有实例共同占用的内存大小
    int         cbWndExtra;         //窗口实例扩展内存大小
    LPCWSTR     lpszClassName;      //类名
    …
} WNDCLASSEXW

在用户态创建窗口时,需要调用RegisterClass注册窗口类,每个窗口类有自己的名字,调用CreateWindow创建窗口时传入类的名字,即可创建对应的窗口实例。
当cbWndExtra不为0时,系统会申请一段对应大小的空间,如果回调到用户态申请空间时,可能会触发漏洞。
内核中使用两个结构体来保存窗口数据tagWND和tagWNDK:

ptagWND             //内核中调用ValidateHwnd传入用户态窗口句柄可返回此数据指针
    0x18 unknown
        0x80 kernel desktop heap base   //内核桌面堆基址
    0x28 ptagWNDk   // 需要重点关注这个结构体,结构体在下方:
    0xA8 spMenu

tagWNDK结构体,需要重点关注此结构体:

struct tagWNDK
{
    ULONG64 hWnd;               //+0x00
    ULONG64 OffsetToDesktopHeap;//+0x08 tagWNDK相对桌面堆基址偏移
    ULONG64 state;              //+0x10
    DWORD dwExStyle;            //+0x18
    DWORD dwStyle;              //+0x1C
    BYTE gap[0x38];
    DWORD rectBar_Left;         //0x58
    DWORD rectBar_Top;          //0x5C
    BYTE gap1[0x68];
    ULONG64 cbWndExtra;         //+0xC8 窗口扩展内存的大小
    BYTE gap2[0x18];
    DWORD dwExtraFlag;          //+0xE8  决定SetWindowLong寻址模式
    BYTE gap3[0x10];            //+0xEC
    DWORD cbWndServerExtra;     //+0xFC
    BYTE gap5[0x28];
    ULONG64 pExtraBytes;    //+0x128 模式1:内核偏移量 模式2:用户态指针
};

当WNDCLASSEXW 中的cbWndExtra值不为0时,创建窗口时内核会回调到用户态函数USER32!_xxxClientAllocWindowClassExtraBytes申请一块cbWndExtra大小的内存区域,并且将返回地址保存在tagWNDK结构体的pExtraBytes变量中。

使用函数SetWindowLong和GetWindowLong,可对窗口扩展内存进行读写,进入内核后调用栈如下:

win32kfull!xxxSetWindowLong
win32kfull!NtUserSetWindowLong+0xc7
win32k!NtUserSetWindowLong+0x16
nt!KiSystemServiceCopyEnd+0x25
win32u!NtUserSetWindowLong+0x14
USER32!_SetWindowLong+0x6e
CVE_2022_21882!wmain+0x25d

SetWindowLong函数形式如下:

第二个参数为index,含义为设置扩展内存偏移index处的内容。
在win32kfull!xxxSetWindowLong函数中,会对第二个参数index进行判断,防止越界:

137行代码判断index+4如果大于cbWndServerExtra+ cbWndExtra,表明越界,一般情况下cbWndServerExtra为0,如果越界,会跳转到117行LABEL_34,设置v18为1413,跳转到LABEL_55,调用UserSetLastError设置错误值,我们可以在cmd下查看此错误值的含义:

如果没有越界的话,接下来会根据不同的模式来使用pExtraBytes,如下:

在xxxSetWindowLong函数中:

正常情况下cbWndServerExtra为0,157行如果index+4< cbWndServerExtra,那么修改的是窗口的保留属性,例如GWL_WNDPROC对应-4,含义为设置窗口的回调函数地址。我们需要设置的是窗口扩展内存,所以进入165行的代码区域。

在167行会判断dwExtraFlag属性是否包含0x800,如果包含,那么168行代码destAddress=pExtraBytes+index+内核桌面堆基址,此处pExtraBytes作为相对内核桌面堆基址的相对偏移量,(QWORD)(pTagWnd->field_18+128)为内核桌面堆基地址 ,对应的汇编代码为

在171行处,dwExtraFlag属性不包含0x800,此时destAddress=index+pExtraBytes,此处pExtraBytes作为用户态申请的一块内存区域地址。

dwExtraFlag的含义:

dwExtraFlag&0x800 != 0时,代表当前窗口是控制台窗口。调用AllocConsole申请控制台窗口时,调用程序会与conhost程序通信,conhost去创建控制台窗口,调用栈如下:

conhost获取到窗口句柄后,调用NtUserConsoleControl修改窗口为控制台类型,调用栈如下:

dwExtraFlag&0x800 ==0时,代表当前窗口是GUI窗口,调用CreateWindow时窗口就是GUI窗口。

总结:

  1. xxxSetWindowLong设置扩展内存数据时,有如下两种模式:
    模式1:tagWND的dwExtraFlag属性包含0x800,使用间接寻址模式,基址为内核桌面堆基地址,pExtraBytes作为偏移量去读写内存。
    模式2:tagWND的dwExtraFlag属性不包含0x800,使用直接寻址模式,pExtraBytes直接读写内存。
  2. xxxSetWindowLong会检查index,如果index+4超过cbWndExtra,那么返回索引越界错误。

 

漏洞成因

此漏洞是对CVE-2021-1732漏洞的绕过,此处简要介绍下CVE-2021-1732漏洞:

用户调用CreateWindow时,在对应的内核态函数中检查到窗口的cbWndExtra不为0,通过xxxCreateWindowEx-> xxxClientAllocWindowClassExtraBytes->调用回调表第123项用户态函数申请用户态空间,

1027行会调用USER32!_xxxClientAllocWindowClassExtraBytes,EXP在回调函数中调用NtUserConsoleControl修改窗口的dwExtraFlag和pExtraBytes,修改窗口类型为控制台。

Windows修复代码在1039行,检查pExtraBytes是否被修改,此处查看汇编代码更为清晰

rdi+0x140-0x118 = rdi+0x28,得到tagWNDK,偏移0x128得到pExtraBytes,判断是否不等于0,如果不等于0,1045行代码会跳转,最终释放窗口,漏洞利用失败。

也就是说:CVE-2021-1732的修复方法是在调用xxxClientAllocWindowClassExtraBytes函数后,在父函数CreateWindowEx中判断漏洞是否被利用了,这个修补方法之前是没有问题的。

但是在后续代码更新后,有了新的路径来触发xxxClientAllocWindowClassExtraBytes函数:

在xxxSwitchWndProc函数中调用xxxClientAllocWindowClassExtraBytes后也有检查pExtraBytes是否为0,如果不为0,那么就复制pExtraBytes内存数据到新申请的内存地址中,没有检查dwExtraFlag是否被修改。

总结:
由于CVE-2021-1732漏洞修补时是在父函数中修复的,虽然当时没有问题,但是当多了xxxClientAllocWindowClassExtraBytes函数的触发路径后,同样的漏洞又存在了,而且 CVE-2021-1732漏洞触发路径是在xxxCreateWindowEx中,此时窗口句柄还未返回给用户态,漏洞利用时需要更多的技巧,此漏洞利用时已经返回了窗口句柄,利用起来更加简单。

 

利用漏洞的流程

本节介绍了漏洞触发的流程,并介绍了触发漏洞及利用漏洞需要的各个知识点。

漏洞触发利用的流程:

要利用这个漏洞,需要以下背景知识:

6.1 触发用户态回调

本节描述如何触发用户态回调,使内核回调到USER32!_xxxClientAllocWindowClassExtraBytes。

在IDA中查看xxxClientAllocWindowClassExtraBytes的引用,有多处地方调用到了此函数,

查看xxxSwitchWndProc代码如下:

98行代码有cbWndServerExtra变量赋值,而在调用SetWindowLong时会使用index-cbWndServerExtra,所以我们真正想设置内存区域偏移index位置的变量时,参数2应该传入index+cbWndServerExtra。

103行代码调用xxxClientAllocWindowClassExtraBytes返回值赋值给了v20变量。

111行代码检查原来的pExtraBytes是否为0,如果不为0,那么就复制内存的数据,还会释放原来的pExtraBytes。

117、123行代码都会将v20变量赋值给pExtraBytes。

而xxxSwitchWndProc函数是可以通过win32u! NtUserMessageCall函数来触发的,在用户态调用NtUserMessageCall函数会触发内核态函数xxxClientAllocWindowClassExtraBytes,函数调用栈如下:

win32kfull!xxxClientAllocWindowClassExtraBytes
win32kfull!xxxSwitchWndProc+0x167
win32kfull!xxxWrapSwitchWndProc+0x3c
win32kfull!NtUserfnINLPCREATESTRUCT+0x1c4
win32kfull!NtUserMessageCall+0x11d    内核态
…
win32u! NtUserMessageCall             用户态

在内核态的win32kfull!xxxClientAllocWindowClassExtraBytes函数中,会调用用户态的xxxClientAllocWindowClassExtraBytes函数。
win32kfull!xxxClientAllocWindowClassExtraBytes函数如下:

KernelCallbackTable第123项对应_xxxClientAllocWindowClassExtraBytes函数,使用IDA查看函数内容:

此函数中调用RtlAllocateHeap函数来申请(a1)大小的内存,内存地址保存在addr变量中,然后调用NtCallbackReturn函数返回到内核态,返回的数据为addr变量的地址,对应在上面win32kfull!xxxClientAllocWindowClassExtraBytes函数中的v7变量,v7为addr变量的地址,v7即为上图中的addr。

总结:
触发回调函数的路径为:
Win32u!NtUserMessageCall(用户态)->win32kfull!NtUserMessageCall(内核态)-> win32kfull!xxxSwitchWndProc(内核态)-> win32kfull!xxxClientAllocWindowClassExtraBytes(内核态)-> nt!KeUserModeCallback(内核态)-> USER32!_xxxClientAllocWindowClassExtraBytes(用户态,HOOK此函数)
本节讲了如何从用户态进入到内核,又回调到USER32!_xxxClientAllocWindowClassExtraBytes函数的方法。

6.2 HOOK回调函数

上一小节讲了触发到USER32!_xxxClientAllocWindowClassExtraBytes函数的流程,我们还需要hook此回调函数,在回调函数中触发漏洞。下面代码可以将回调函数表项第123、124分别修改为MyxxxClientAllocWindowClassExtraBytes、MyxxxClientFreeWindowClassExtraBytes。

6.3 修改窗口模式为模式1

上一小节讲了如何进入到用户态自定义的函数,本节讲述在自定义的函数中通过用户态未公开函数NtUserConsoleControl修改窗口模式为模式1,本节对NtUserConsoleControl函数进行逆向分析。

函数win32u! NtUserConsoleControl可以设置模式为内核桌面堆相对寻址模式,此函数有三个参数,第一个参数为功能号,第二个参数为一个结构体的地址,结构体内存中第一个QWORD为窗口句柄,第三个参数为结构体的大小。

NtUserConsoleControl函数会调用到内核态win32kfull模块的NtUserConsoleControl函数,调用栈如下:

win32kfull!NtUserConsoleControl         内核态
win32k!NtUserConsoleControl+0x16        内核态
nt!KiSystemServiceCopyEnd+0x25
win32u!NtUserConsoleControl+0x14        用户态
CVE_2022_21882!wmain+0x3f4              用户态

win32kfull模块NtUserConsoleControl判断参数,然后调用xxxConsoleControl如下:

17行判断参数index不大于6

22行判断参数length小于0x18

26行判断参数2指针不为空且length不为0

以上条件满足时会调用xxxConsoleControl函数,传入参数为index、变量的地址,传入数据的长度, xxxConsoleControl函数会对index及len进行判断:

110行代码可知,index必须为6,113行代码可知len必须为0x10,115行到119行代码可知,传入参数地址指向的第一个QWORD数据必须为一个合法的窗口句柄,否则此函数会返回。

134、136行判断是否包含0x800属性,如果包含,v23赋值为内核桌面堆基地址+偏移量pExtraBytes,得到的v23为内核地址。

140行代码,如果不包含0x800属性,那么调用DesktopAlloc申请一段cbWndExtra大小的内存保存在v23中。

149到156行代码判断原来的pExtraBytes指针不为空,就拷贝数据到刚申请的内存中,并调用xxxClientFreeWindowClassExtraBytes->USER32!_xxxClientFreeWindowClassExtraBy释放内存。

159、160行代码使用内核地址v23减去内核桌面堆基址得到偏移量v21,将v21赋值给pExtraBytes变量。

使用如下代码可以修改窗口模式为模式1:

ULONG64 buff[2]={hwnd};
NtUserConsoleControl(6, &buff, sizeof(buff));即可将hwnd对应的窗口模式设置为模式1。

总结:
在自定义回调函数中调用win32u!NtUserConsoleControl可以设置窗口模式为模式1,传入参数需要符合下列要求:

  1. 参数1 index必须为6
  2. 参数2指向一段缓冲区,缓冲区第一个QWORD必须为一个合法的窗口句柄
  3. 参数3 len必须为0x10

6.4 回调返回伪造偏移量

在_xxxClientAllocWindowClassExtraBytes 函数中调用NtCallBackReturn回调函数可以返回到内核态:

伪造一个合适的偏移量Offset,然后应该取Offset地址传给NtCallbackReturn函数,可以将offset赋值给pExtraBytes变量。

由于之前已经切换窗口为模式1,pExtraBytes含义为相对于内核桌面堆基址的偏移,再查看tagWNDK结构体,关注以下字段:

+0x08   ULONG64 OffsetToDesktopHeap;    //窗口tagWNDK相对桌面堆基址偏移
+0xE8   DWORD dwExtraFlag;              //包含0x800即为模式1
+0x128  ULONG64 pExtraBytes;            //模式1:内核桌面堆偏移量 模式2:用户态指针

OffsetToDesktopHeap为窗口本身地址tagWNDK相对于内核桌面堆基址的偏移,可以使用如下方法来伪造合适的偏移量:

  1. 创建多个窗口,如窗口0和窗口2(为了与EXP匹配),窗口2触发回调函数,返回窗口0的OffsetToDesktopHeap ,赋值给窗口2的pExtraBytes变量。
  2. 对窗口2调用SetWindowLong时,写入的目标地址为:内核桌面堆基址+pExtraBytes+index,此时pExtraBytes为窗口0的地址偏移,对窗口2调用SetWindowLong可以写窗口0的tagWNDK结构数据,这是第一次越界写。

总结:
调用NtCallbackReturn可以返回到内核中,伪造偏移量为窗口0的OffsetToDesktopHeap,赋值给窗口2的pExtraBytes,当对窗口2调用SetWindowLong时即可修改到窗口0的tagWNDK结构体。
接下来我们需要获取窗口0的OffsetToDesktopHeap。

6.5 泄露内核窗口数据结构

上一小节中我们在用户态中要返回窗口0的OffsetToDesktopHeap到内核态,OffsetToDesktopHeap是内核态的数据,要想获取这个数据还需要一些工作。

调用CreateWindow只能返回一个窗口句柄,用户态无法直接看到内核数据,但是系统把tagWNDK的数据在用户态映射了一份只读数据,只需要调用函数HMValidateHandle即可,动态库中没有导出此函数,需要通过IsMenu函数来定位:

定位USER32!HMValidateHandle的代码如下:

定位到USER32!HMValidateHandle函数地址后,传入hwnd即可获取tagWNDK数据地址。

    tagWNDK* p = HMValidateHandle(hwnd),通过tagWNDK指针即可获取到OffsetToDesktopHeap数据。

6.6 如何布局内存

通过上面的知识,我们可以通过窗口2修改窗口0的tagWNDK结构体数据,本节描述如何布局内存,构造写原语。

应该通过NtUserConsoleControl修改窗口0切换到模式1,这样对窗口0调用SetWindowLong即可修改内核数据,但是调用SetWindowLong时index有范围限制,所以通过窗口2将窗口0的tagWNDK. cbWndExtra修改为0xFFFFFFFF,扩大窗口0可读写的范围。

现在我们开始内存布局:

创建窗口0,窗口0切换到模式1,pExtraBytes为扩展内存相对内核桌面堆基址的偏移量

窗口2触发回调后,回调函数中对窗口2调用NtUserConsoleControl,所以窗口2也处于模式1,pExtraBytes为扩展内存相对内核桌面堆基址的偏移量。

回调函数中返回窗口0的OffsetToDesktopHeap,此时内存如下:

图中红色线条,此时窗口2的pExtraBytes为窗口0的OffsetToDesktopHeap,指向了窗口0的结构体地址,此时对窗口2调用SetWindowLong即可修改窗口0的内核数据结构

通过窗口2修改窗口0的cbWndExtra

SetWindowsLong(窗口2句柄, 0xC8(此处还有一个偏移量),0xFFFFFFFF),即可修改窗口0的cbWndExtra为极大值,且此时窗口0处于模式1,如果传入一个较大的index且不大于0xFFFFFFFF,那么就可以越界修改到内存处于高地址处的其他窗口的数据。

再次创建一个窗口1,窗口1处于模式2,不用修改模式

窗口1刚开始pExtraBytes指向用户态地址,使用模式2直接寻址。
由于窗口0的pExtraBytes是相对于内核桌面堆基址的偏移量,窗口1的OffsetToDeskTopHeap是当前tagWNDK结构体与内核桌面堆基址的偏移量,所以这两个值可以计算一个差值,对窗口0调用SetWindowLong时传入这个差值即可写入到窗口1的结构体,再加上pExtraBytes相对于tagWNDK结构体的偏移即可设置窗口1的pExtraBytes为任意值。

由于此时窗口1处于模式1直接寻址,且我们可以设置窗口1扩展内存地址pExtraBytes为任意地址,所以对窗口1调用SetWindowLong即可向任意内核地址写入数据。

总结:
内存布局的关键在于窗口0的pExtraBytes必须小于窗口1和窗口2的OffsetToDesktopHeap,这样的话在绕过了窗口0的cbWndExtra过小的限制后,对窗口0调用SetWindowLong传入的第二个参数,传入一个较大值,即可向后越界写入到窗口1和窗口2的tagWNDK结构体。
我们来设想一下不满足内存布局的情况,假如窗口1的OffsetToDesktopHeap小于窗口0的pExtraBytes,即窗口1的tagWNDK位于低地址,窗口0的扩展内存位于高地址,那从窗口0越界往低地址写内容时,SetWindowLong的index必须传入一个64位的负数,但是SetWindowLong的第二个参数index是一个32位的值,调用函数时64位截断为32位数据,在内核中扩展到64位后高位为0还是个正数,所以窗口0无法越界写到低地址。

 

EXP分析调试

首先动态定位多个函数地址,接下来需要调用

创建窗口类:

#define MAGIC_CB_WND_EXTRA 0x1337

调用函数RegisterClassEx创建两个窗口类:

类名为NormalClass的窗口,窗口的cbWndExtra大小为0x20。

类名为MagicClass的窗口,窗口的cbWndExtra大小为0x1337,使用MagicClass类创建的窗口会利用漏洞构造一个内核相对偏移量。

内存布局的代码如下:

第241行到244行,创建了菜单,之后创建窗口使用此菜单。

第245行到250行,使用NormalClass类名创建了50个窗口存放在g_hWnd数组中,然后销毁后面的48个窗口,这样是为了后面创建窗口时可以占用被销毁窗口的区域,缩短窗口之间的间距,此时g_hWnd[0]和g_hWnd[1]存放句柄,将这两个窗口称为窗口0和窗口1,其中247行调用HMValidateHandle函数传入句柄得到对应窗口在用户态映射的tagWNDK数据内存地址保存在g_pWndK数组中。

第245行到255行,调用NtUserConsoleControl函数设置窗口0由用户态直接寻址切换为内核态相对偏移寻址,并且窗口0的pExtraBytes是相对于内核桌面堆基址的偏移。

第257行到258行,使用MagicClass类名创建窗口2保存在g_hWnd[2]中,称为窗口2,然后调用HMValidateHandle获得窗口2的tagWNDK数据映射地址保存在g_pWndK[2]中。

第260和278行代码判断内存布局是否成功,此时窗口0处于内核模式,所以窗口0的pExtraBytes为申请的内核内存空间(不是窗口内核对象地址)相对于内核桌面堆基地址的偏移,窗口1和窗口2为用户态模式,OffsetToDesktopHeap为窗口内核对象地址相对于内核桌面堆基地址的偏移,内存布局必须满足:

窗口0的pExtraBytes小于窗口1的OffsetToDesktopHeap,计算差值extra_to_wnd1_offset,为正数。

窗口0的pExtraBytes小于窗口2的OffsetToDesktopHeap,计算差值extra_to_wnd2_offset,为正数。

如果布局失败,那就销毁窗口继续布局,如果最后一次布局失败,就退出。

布局完成后,程序运行到此处:

程序在虚拟机中运行到DebugBreak()函数时,如果有内核调试器,调试器会自动中断:

此时指令位于DebugBreak函数中,输入k,栈回溯只显示了地址,没有显示符号表,输入

gu;.reload /user

.reload /user会自动加载用户态符号,pdb文件位于本地对应目录,再次输入k,显示栈回溯,可以看到显示正常。
我们先查看三个窗口的内核数据结构
使用命令 dt tagWNDK poi(CVE_2022_21882!g_pWndK+0)可以以结构体方式查看窗口0的tagWNDK结构,在内存布局时已经对窗口0切换了模式,如下:

在调用NtUserMessageCall之前,窗口0处于模式1,窗口1和2处于模式2。
接下来调用HookUserModeCallBack 来Hook回调函数,代码如下:

动态调试时查看KernelCallbackTable表:

kd> !peb
PEB at 0000001eb0c75000
kd> dt ntdll!_PEB KernelCallbackTable 0000001eb0c75000
   +0x058 KernelCallbackTable : 0x00007ffe`bc6f2070 Void

查看KernelCallbackTable表项

我们需要查看123项的内容,如下:

调试运行HookUserModeCallBack函数后,再次查看:

在自定义的回调函数MyxxxClientAllocWindowClassExtraBytes中

接着下断点:

并且在MyxxxClientAllocWindowClassExtraBytes函数中下断点:

在调试器中输入g运行,现在运行到如下位置:

在运行NtUserConsoleControl前后分别查看窗口2的模式:

继续按g运行,中断在SetWindowLong函数前

此时窗口2处于模式1,并且pExtraBytes为窗口0的OffsetToDesktopHeap,再调用SetWindowLong函数:

这是第一次越界写,第一个参数为窗口2的句柄,第二个参数为index,为cbWndExtra相对tagWNDK结构体首地址的偏移量+cbWndServerExtra,由于窗口2调用了NtUserMessageCall,所以cbWndServerExtra为0x10,调用SetWindowLong时会使用index-cbWndServerExtra,所以此处要加上cbWndServerExtra来抵消,可参考前文SetWindowLong函数的分析。

单步运行后

可以看到窗口0的cbWndExtra变成了0xFFFFFFFF,接下来对窗口0调用SetWindowLong时传入index可以传入之前计算得到的extra_to_wnd1_offset和extra_to_wnd2_offset来分别修改窗口1和窗口2的窗口内核数据。

此时窗口1处于直接寻址模式,对窗口0调用SetWindowLongPtr修改窗口1的pExtraBytes为任意值,使用SetWindowLongPtr是因为此函数第三个参数可以传入64位数据,将窗口1的pExtraBytes设置为任意值,接下来对窗口1调用SetWindowLong即可实现任意地址写数据。

 

两种提权方式

8.1 设置token

第一种为设置当前进程的token为system进程的token,将当前进程提升到system权限,这种需要读取进程的EPROCESS结构,再定位到token变量的地址,修改token,公开的EXP中使用GetMenuBarInfo函数来实现内核任意地址读原语。

我们先分析这种方式,先看下Menu内核结构体:

ptagWND
    0x10 THREADINFO
        0x1A0 PROCESSINFO
            0x00 EPROCESS
    0x18 unknown
        0x80 kernel desktop heap base
    0x28 ptagWNDk
    0xA8 spMenu
        0x28 obj28
            0x2C cItems(for check)      设置为1
        0x40 cxMenu(for check)          设置为1
        0x44 cyMenu(for check)          设置为1
        0x50 ptagWND
        0x58 rgItems
            0x00 unknown(for exploit)   //要读的地址-0x40
        0x98 ppMenu
            0x00 pSelf                  //指向spMenu

在EXP中先构造一个假的Menu

其中401行设置ppMenu偏移0x00处的值为spMenu,404、408、409设置spMenu结构体内部数据是为了绕过GetMenuBarInfo的验证,GetMenuBarInfo函数会调用内核中的NtUserGetMenuBarInfo,最终调用到xxxGetMenuBarInfo,GetMenuBarInfo对应有四个参数,对应xxxGetMenuBarInfo的四个参数,其中参数2为idObject,参数3为idItem。
xxxGetMenuBarInfo对参数有校验:

164行判断idObject!=-3如果满足,就不能触发到下面读内存的代码路径,所以idObject必须为-3。

316行代码判断dwStyle不能包含WS_CHILD属性。

322行代码从spMenu中偏移0x98取值,赋值给ppMenu。

325行代码判断idItem不能小于0。

328行代码判断idItem不能大于spMenu偏移0x28取值再偏移0x2c取值。

335行代码判断spMenu偏移0x40取值不为0并且偏移0x44取值不为0。

338行到344行,如果idItem不为0,可以让idItem为1,那么_readAddrSub40的值为spMenu偏移0x58取值。

接下来程序进入353行

v5是传入的第四个参数,用作保存读取到的数据。

在353、354行,可以读取传入地址的数据+窗口RECT的left坐标。

在357、358行,可以读取传入地址的数据+4+窗口RECT的top坐标。

所以只要我们可以绕过构造一个假的Menu,绕过上述限制,在Menu偏移0x58再偏移0x00的地址处存放想读取的地址-0x40,当GetMenuBarInfo返回时left和top中保存的就是目标地址处的8字节数据。

要想替换窗口的Menu为假的Menu,还是需要用到SetWindowLong函数,在内核态win32kfull!xxxSetWindowLong函数中会调用xxxSetWindowData函数:

xxxSetWindowData函数如下:

134、136行,判断如果index为0xFFFFFFF4,为-12,对应为GWLP_ID。

138行判断如果dwStyle是否包含WS_CHILD属性。

140行取出原来的menu指针,赋值给retValue,最终会作为用户态SetWindowLong函数的返回值。

142行修改spMenu为SetWindowLong传入第三个参数newValue值。

所以我们需要如下步骤才能完成任意地址读:

  1. 先对窗口0使用内核越界写修改窗口1的dwStyle值为包含WS_CHILD,这样调用SetWindowLong时即可绕过上面138行的判断。
  2. 对窗口1使用SetWindowLong函数传入index为GWLP_ID,修改窗口1的Menu为构造的假的Menu,并且SetWindowLong会返回原先的Menu的地址。
  3. 使用原先的Menu通过内核数据结构即可定位到当前进程的EPROCESS,进而定位到token的地址。
  4. 再次对窗口0使用内核越界写修改窗口1的dwStyle值为不包含WS_CHILD,这样调用GetMenuBarInfo时可以绕过xxxGetMenuBarInfo中316行代码的判断。
  5. 需要读取数据时,将目标地址-0x40赋值给假的Menu偏移0x58对应的内存空间中,再调用GetMenuBarInfo函数。

单步运行413行代码,窗口1的dwStyle就包含了WS_CHILD属性。

可以看到修改完成后,窗口1的dwStyle包含了WS_CHILD属性。

继续执行415行代码:

在416行下断点后运行:

此时SetWindowLong函数刚执行完毕,返回值rax为0xfffffa49c0821e60,保存的是旧的spMenu指针,而根据之前的数据结构,可以使用spMenu定位到当前进程的EPROCESS。

执行419行代码,移除窗口1的WS_CHILD属性,为接下来调用GetMenuBarInfo做准备

窗口1的dwStyle移除了WS_CHILD属性。
然后构造读原语如下:

根据之前的数据结构

ptagWND
    0x10 THREADINFO
        0x1A0 PROCESSINFO
            0x00 EPROCESS
    0x18 unknown
        0x80 kernel desktop heap base
    0x28 ptagWNDk
    0xA8 spMenu
        0x50 ptagWND

所以获取到spMenu后可以使用如下代码来获取当前进程的EPROCESS

在调试器中查看如下:

上图中可以看到通过spMenu取偏移和使用命令.process两种方式获取到的EPROCESS值是一致的。

查看当前进程的token

kd> !token
…
Privs: 
 19 0x000000013 SeShutdownPrivilege               Attributes - 
 23 0x000000017 SeChangeNotifyPrivilege           Attributes - Enabled Default 
 25 0x000000019 SeUndockPrivilege                 Attributes - 
 33 0x000000021 SeIncreaseWorkingSetPrivilege     Attributes - 
 34 0x000000022 SeTimeZonePrivilege               Attributes -
…

我们直接运行到454行,此时当前进程的token被替换为系统token

EPROCESS中token结构体为_EX_FAST_REF

kd> dt _EX_FAST_REF
ntdll!_EX_FAST_REF
   +0x000 Object           : Ptr64 Void
   +0x000 RefCnt           : Pos 0, 4 Bits
   +0x000 Value            : Uint8B

调试运行到454行,重新运行一次,所以EPROCESS值与之前不一样。

可以看到此时调用到if(iCount<5000),_EX_FAST_REF结构体中的object值已经修改了。

查看system进程的EPROCESS

kd> dt nt!_EX_FAST_REF ffffe504`89885080+0x4b8
   +0x000 Object           : 0xffffbe09`9a242744 Void
   +0x000 RefCnt           : 0y0100
   +0x000 Value            : 0xffffbe09`9a242744

system进程_EX_FAST_REF的Object也为0xffffbe09`9a242744,当前进程修改成功,使用!token命令验证下:

修改token的代码如下:

  1. EPROCESS结构体中有一个进程链表,保存了当前系统的所有进程,我们主要关注ActiveProcessLinks和UniqueProcessId属性
    kd> dt nt!_EPROCESS
    +0x000 Pcb                : _KPROCESS
    +0x438 ProcessLock        : _EX_PUSH_LOCK
    +0x440 UniqueProcessId    : Ptr64 Void     //进程ID
    +0x448 ActiveProcessLinks : _LIST_ENTRY    //进程链表
    

通过遍历进程链表ActiveProcessLinks,找到进程PID UniqueProcessId为4的system进程,偏移0x4b8得到_EX_FAST_REF结构体地址,取出Object的值。

  1. 之前eprocess变量中保存了当前进程的EPROCESS地址,定位到_EX_FAST_REF结构体地址
  2. 通过窗口0越界写窗口1的pExtraBytes,传入第二步找到的地址,下面448行代码。
  3. 449行通过窗口1调用SetWindowLong设置Object修改值为第一步找到的Object。
  4. 450行代码恢复窗口1的pExtraBytes。

恢复内核数据:

407行到414行都是为了恢复内核窗口内容,防止蓝屏。

408行设置窗口2的pExtraBytes为正常的用户态指针。

409行设置窗口2的dwExtraFlag不包含0x800属性,即从模式1修改为模式2。

411到414行恢复窗口1的Menu指针。

418行恢复KernelCallbackTable表项。

自定义的释放内存的回调函数MyxxxClientFreeWindowClassExtraBytes,判断如果是特定窗口,就不释放内存,直接返回。

最终在回调函数表中恢复此项,释放窗口2的pExtraBytes,之前恢复内核数据代码处设置了窗口2的pExtraBytes为RtlAllocateHeap返回的指针。

8.2 修改Privileges

第二种漏洞利用要修改token的变量Privileges,这种实现相对来说简单,不需要构造写原语,为当前进程添加SE_DEBUG权限并启用,遍历进程,过滤与当前进程位于同一session下的winlogon登录进程,此进程是system权限,打开此进程并注入代码执行。

背景知识:

要打开系统安全进程和服务进程,并且有写入数据权限,需要当前进程拥有SeDebugPrivilege权限,这个是调试进程会用到的权限,当一个进程启动后,正常情况下,是无法提升权限的,正向开发时使用的AdjustTokenPrivileges函数只能是启用某个权限或者禁用某个权限。

之前我们已经实现了任意地址写数据,窗口1本身为用户态直接寻址模式,通过设置窗口1的pExtraBytes值为任意值,调用SetWindowLongPtr时即可对任意地址写数据,上一种利用手法是调用SetWindowsLong来构造写原语,调用GetMenuBarInfo来构造读原语,然后通过EPROCESS的ActiveProcessLinks链遍历进程,当进程号为4时,认为是system进程,获取system的Token变量覆盖到当前进程的Token,当前进程就提权到了system级别。

漏洞利用思路为:使用OpenProcessToken打开当前进程调整权限的句柄,使用NtQuerySystemInformation函数泄露句柄在内核中的地址,泄露出的地址为进程Token在内核中的地址,然后偏移0x40:

0: kd> dt _TOKEN
nt!_TOKEN
   …
   +0x040 Privileges       : _SEP_TOKEN_PRIVILEGES
    …

在EPROCESS结构体中的token变量类型为nt!_EX_FAST_REF

kd> dt nt!_EX_FAST_REF
   +0x000 Object           : Ptr64 Void
   +0x000 RefCnt           : Pos 0, 4 Bits
   +0x000 Value            : Uint8B

其实这个结构体中Object才属于TOKEN结构体,但Object的值不是简单的对应TOKEN结构体,而是需要经过计算,上面的结构体中RefCnt也是位于偏移0x00,只占4位,这四位表示了Object对象的引用计数,这里我们使用上面第一种利用方法利用成功后的数据

kd> dt nt!_EX_FAST_REF ffffe504`89885080+0x4b8
   +0x000 Object           : 0xffffbe09`9a242744 Void
   +0x000 RefCnt           : 0y0100
   +0x000 Value            : 0xffffbe09`9a242744

Object为0xffffbe09`9a242744,RefCnt 为0y0100,需要经过如下换算才可以:

0xffffbe09`9a242744&0xFFFFFFFFFFFFFFF0=0xffffbe09`9a242740

Windbg中查看:

Token偏移0x40为Privileges,Privileges中Present和Enable分别表明进程当前是否可以启用对应权限和是否启用了对应权限,EnabledByDefault是默认启用了对应权限,EnabledByDefault这个变量不需要修改,都是8字节数据,如果将Present和Enable都修改为0xFFFFFFFFFFFFFFFF,

在windbg中可以看到位与权限对应关系如下:

其中2位到32位是有效数据,我们只需要启用第20位SeDebugPrivilege权限就可以打开winlogon进程,之后注入shellcode,运行shellcode启动一个system级别的cmd进程。

内存布局与之前的第一种利用方法一样,接着hook回调函数,对窗口2调用NtUserMessageCall,接下来就不一样了:

调用LeakEporcessKtoken泄露token的地址,

LeakEporcessKtoken函数调用OpenProcessToken打开自身进程的token,第二个参数访问掩码设置为TOKEN_ADJUST_PRIVILEGES,为调整令牌权限,然后调用GetKernelPointer泄露token的内核地址:

其中结构体SYSTEM_HANDLE_TABLE_ENTRY_INFO和SYSTEM_HANDLE_INFORMATION在移植到64位版本时,笔者有对结构体内容进行一些修正,结构体中都多了一个变量ULONG xxxCDCDCD用来占位,保持8字节对齐。
泄露token地址后,token+0x40即可定位到Privileges变量地址,

313行通过窗口0越界写修改窗口1的pExtraBytes为token+0x40,定位到Privileges。

314到319行,设置新的权限值,其实只需要设置第20位,但是此处设置了第2到第36位都为1。

320行设置Present属性。

321行设置Enabled属性。

322行恢复窗口1的pExtraBytes值。

324行定位winlogon进程的pid,此处需要注意如果有多个用户登录那么存在多个winlogon进程,需要找到跟当前进程处于同一会话中的winlogon进程,否则最终启动的cmd当前用户无法看到。

325行写shellcode到winlogon进程中并执行。

328到331行是为了修复窗口内核数据。

总结两种漏洞利用方法的优劣:
第一种方法:对比第二种稍微有点复杂,要构造读写原语,优势在于不管是低权限进程还是中等权限进程都可以进行提权。
第二种方法:只需要构造一个写原语,然后开启各种权限,通过注入的方法来获取高权限,相对难度低点,但是要调用NtQuerySyetemInformation函数至少需要中等权限,对权限要求较高。

 

补丁分析

此漏洞对应的补丁为KB5009543,打补丁后调用NtUserMessageCall时触发到内核函数的调用栈如下:

win32kfull!xxxClientAllocWindowClassExtraBytes
win32kfull!xxxValidateClassAndSize+0x171
win32kfull!xxxSwitchWndProc+0x5a
win32kfull!xxxWrapSwitchWndProc+0x3c
win32kfull!NtUserfnINLPCREATESTRUCT+0x1c4
win32kfull!NtUserMessageCall+0x11d
win32k!NtUserMessageCall+0x3d

在函数xxxClientAllocWindowClassExtraBytes中调用回调函数后,内核函数对窗口的dwExtraFlag属性校验:

43行判断dwExtraFlag是否包含0x800属性,如果包含,说明用户态函数被hook,当前函数返回值不使用用户态申请的空间,而是返回0,返回到xxxValidateClassAndSize函数后,

判断返回值为0,直接返回,不会再去修改pExtraBytes为用户伪造的值。

 

参考链接

https://www.anquanke.com/post/id/241804#h3-12

https://bbs.pediy.com/thread-266362.htm

https://www.4hou.com/posts/3KPr

https://blog.l4ys.tw/2022/02/CVE-2021-21882/

https://googleprojectzero.github.io/0days-in-the-wild/0day-RCAs/2022/CVE-2022-21882.html

(完)