Win32k 类型混淆漏洞分析指南

Page content

0. 前言

众所周知,win32k 在近几年为 Windows 提权漏洞贡献了很多力量,我也一直想搞清楚这类漏洞的原理,为此看了很多相关论文。但是作为一个内核漏洞研究的菜鸟,大佬们写的 win32k 漏洞的 write-up 在我看来还是和天书一样。

当然,我知道什么是 UAF,什么是类型混淆,我也知道 win32k 的很多漏洞都是由于回调函数导致的,可是当所有的这些概念组合在一起,再加上一些专业名词还有很长的函数名称,事情就完全不同了。

为此,我想通过对 CVE-2021-1732 和 CVE-2022-21881 这两个漏洞的分析,复盘自己在面对此类漏洞时遇到的困惑,并尝试进行解决,这样以后再遇到 win32k 的漏洞时,可以有的放矢,迅速解决问题。

1. 基础知识

1.1 基础建设

在没有接触 win32k 之前,我对于系统调用的理解,无非就是应用程序调用 DLL 文件中某个函数,从而实现某个系统功能,如果需要内核的参与,DLL 文件会自己处理关于内核的问题。而每个 DLL 文件就是一些具有关联性的功能的合集。

当我尝试了解 win32k 的时候,被告诉的第一个事实就是,win32k 主要处理 Windows GUI 相关的问题,作为一个日常使用 GUI 系统的普通用户,我会有一个下意识的错误观念 —— GUI 即一切,所以当时我的第一反应就是:怎么可能所有的功能都放到了一个文件里?

但是如果仔细思考,或者去看一下 C:/Windows/System32 目录下的 DLL 文件,会发现 Windows 的功能太多了,通信、加密、邮件、浏览器……我之所以会认为 GUI 即一切,是因为很多功能最终都会通过 GUI 反应到使用者的视觉中,因此可以这样理解,win32k 是其他功能的基石,其他功能或多或少的需要 win32k 中的功能。实际上,除了 win32k 之外,还有 csrss,两者共同构成了 win32 子系统,关于这一点可以看我之前的文章CSRSS 基础知识历史背景部分。

1.2 用户态 GUI 知识点

建议所有对 win32k 有疑惑的朋友都先看一下 Programming Windows 第五版的第三章 Windows and Messages,我在读大学的时候曾经从编程的角度看过这本书的一部分内容,但是现在回过头来再读,还是发现了很多自己遗漏的细节。

比较重要的概念就是,Windows GUI 编程实际上是一种“面向对象编程”,窗口是一种对象。对面向对象编程有所了解的朋友都知道对象是根据类创建的,每个对象都有对应的数据和方法,在创建窗口的时候,就需要先注册一个窗口类,并指定窗口过程。

截取一部分书中的代码:

int WINAPI WinMain (HINSTANCE hInstance, HINSTANCE hPrevInstance,
                    PSTR szCmdLine, int iCmdShow)
{
     static TCHAR szAppName[] = TEXT ("HelloWin") ;
     HWND         hwnd ;
     MSG          msg ;
     WNDCLASS     wndclass ;
     wndclass.style         = CS_HREDRAW | CS_VREDRAW ;
     wndclass.lpfnWndProc   = WndProc ;
     wndclass.cbClsExtra    = 0 ;
     wndclass.cbWndExtra    = 0 ;
     wndclass.hInstance     = hInstance ;
     wndclass.hIcon         = LoadIcon (NULL, IDI_APPLICATION) ;
     wndclass.hCursor       = LoadCursor (NULL, IDC_ARROW) ;
     wndclass.hbrBackground = (HBRUSH) GetStockObject (WHITE_BRUSH) ;
     wndclass.lpszMenuName  = NULL ;
     wndclass.lpszClassName = szAppName ;
     if (!RegisterClass (&wndclass))
     {
          MessageBox (NULL, TEXT ("This program requires Windows NT!"), 
                      szAppName, MB_ICONERROR) ;
          return 0 ;
     }
     hwnd = CreateWindow (szAppName,                  // window class name
                          TEXT ("The Hello Program"), // window caption
                          WS_OVERLAPPEDWINDOW,        // window style
                          CW_USEDEFAULT,              // initial x position
                          CW_USEDEFAULT,              // initial y position
                          CW_USEDEFAULT,              // initial x size
                          CW_USEDEFAULT,              // initial y size
                          NULL,                       // parent window handle
                          NULL,                       // window menu handle
                          hInstance,                  // program instance handle
                          NULL) ;                     // creation parameters
     ShowWindow (hwnd, iCmdShow) ;
     UpdateWindow (hwnd) ;
     while (GetMessage (&msg, NULL, 0, 0))
     {
          TranslateMessage (&msg) ;
          DispatchMessage (&msg) ;
     }
     return msg.wParam ;
}

其中 WNDCLASS 是窗口类,它指定了窗口过程 lpfnWndProc,类名称 lpszClassName,以及窗口的其他一些属性。其中 cbWndExtra 字段需要注意,该字段告诉系统在内部为由此窗口类创建的窗口保留一些额外空间,如果这个字段不为 0,那么之后可以使用 SetWindowLongGetWindowLong 函数对额外空间进行访问,这个字段会在下面提到。

LONG SetWindowLong( 
  HWND hWnd, 
  int nIndex,       // 要设置数值在额外空间中的偏移量,有效范围 [0,cbWndExtra-4]
  LONG dwNewLong    // 要设置的数值
); 

之后使用 RegisterClass 注册窗口类,并在 CreateWindow 创建窗口时,把类名 szAppName 作为第一个参数,创建一个该类的窗口。

1.3 内核态 GUI 知识点

这部分内容可以看一下 Kernel Attacks through User-Mode Callbacks 这篇论文背景知识介绍的部分。

Windows 使用窗口管理器对 GUI 进行管理,窗口管理器同样把窗口、菜单这样的用户实体看作用户对象(user objects),并会在每个用户会话中维护一个句柄表对这些对象进行追踪,当我们在用户态编程时提供了某个用户实体的句柄值时,窗口管理器就会根据句柄表把这个句柄值转换为内核空间中对应的对象。

用户对象在内核中也使用不同的数据结构进行表示,在我们上面提到的“系统在内部为由此窗口类创建的窗口保留一些额外空间”,这个额外空间就保存在数据结构 win32k!tagWND 中,由于微软目前已经将该结构体从符号表中删除,而 Win10 和 Win7 相比这个结构体又发生了很大的变化,因此只能通过代码分析和动态调试结构体中的字段进行猜测。幸运的是,已经有大佬对该结构体进行了分析(经过自己的调试,我修改了一些细节):

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

struct tagWNDK {
    ULONG64 hWnd;                   //+0x00
    ULONG64 OffsetToDesktopHeap;    //+0x08 tagWNDK相对桌面堆基址偏移
    ULONG64 state;                  //+0x10
    DWORD dwExStyle;                //+0x18
    DWORD dwStyle;                  //+0x1C
    BYTE gap[0x38];
    RECT rect;                      //+0x58
    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:用户态指针
}

其中的 dwExtraFlagpExtraBytes 字段会影响 SetWindowLong 的执行流程,因为和下面要分析的漏洞有关,这里做一下详细说明。

首先有一点要明确,当应用程序在代码中调用 SetWindowLong 尝试设置额外空间中的数据时,对应到内核中被调用的是 win32kfull!NtUserSetWindowLong,这个函数会进一步调用 xxxSetWindowLong,关于这两个字段的含义可以在 xxxSetWindowLong 函数中体现出来:

__int64 __fastcall xxxSetWindowLong(struct tagWND *tagWND, int nIndex, unsigned int dwNewLong, unsigned int a4, int a5) {
    ...
    if ( nIndex_1 + 4i64 <= cbWndServerExtra_1 )
    {
      v31 = *(tagWND + 0x23);
      dwOldLong = *(nIndex_1 + v31);
      *(nIndex_2 + v31) = dwNewLong_1;
    }
    else
    {
      v18 = nIndex_1 - cbWndServerExtra_1;
      pExtraBytes = *(tagWNDK + 0x128);
      if ( (*(tagWNDK + 0xE8) & 0x800) != 0 )     // dwExtraFlag 与 0x800 不为 0 时
        dst = (pExtraBytes + v18 + *(*(tagWND + 3) + 0x80i64));// pExtraBytes 中保存的是额外空间相对于桌面堆基址的偏移量
      else                                        // dwExtraFlag 与 0x800 为 0 时
        dst = (v18 + pExtraBytes);                // pExtraBytes 中保存的是额外空间的地址
      dwOldLong = *dst;
      *dst = dwNewLong_1;
    }
}

可以看到 dwExtraFlag 字段控制了 pExtraBytes 字段的含义,且一种情况下保存的是地址偏移,一种情况下保存的是地址,如果可以以某种手段造成两个字段不匹配,就会导致类型混淆。

1.4 内核态和用户态的转换

有些时候 win32k 需要在用户态执行一些功能,比如调用应用程序定义的钩子、提供事件通知或者和用户态进行数据传输,这个时候就会通过 KeUserModeCallback 从内核态转向用户态:

NTSTATUS KeUserModeCallback (
    IN ULONG ApiNumber,
    IN PVOID InputBuffer,
    IN ULONG InputLength,
    OUT PVOID *OutputBuffer,
    IN PULONG OutputLength );

ApiNumber 用于指定想要调取的回调函数在函数指针表 USER32!apfnDispatch 中的索引值,这个函数指针表会在进程初始化 USER32.dll 的时候复制到 PEB 中,即 PEB.KernelCallbackTable。可以在 USER32.dll 中找到这个表:

apfnDispatch    0x00 dq offset __fnCOPYDATA  ; DATA XREF: _UserClientDllInitialize+353↑o
                0x01 dq offset __fnCOPYGLOBALDATA
                0x02 dq offset __fnDWORD
                      ......
                0x7B dq offset __xxxClientAllocWindowClassExtraBytes
                0x7C dq offset __xxxClientFreeWindowClassExtraBytes
                0x7D dq offset __fnGETWINDOWDATA
                0x7E dq offset __fnINOUTSTYLECHANGE
                0x7F dq offset __fnHkINLPMOUSEHOOKSTRUCTEX
                0x80 dq offset __xxxClientCallDefWindowProc

因此如果我们可以在触发用户态回调之前,对函数指针表中的函数进行 hook,那么当被 hook 的函数真正调用时,就会执行我们想要的代码。

在内核态的时候,窗口管理器通过临界区和全局锁对各种资源进行管理,防止出现访问冲突或者释放重利用的问题,但是当 win32k 想进入用户态的时候,需要先离开临界区,这样用户态的代码就可以随意进行更改对象属性、重新分配数组等操作,如果内核在从用户态返回之后没有对拥有的资源进行正确检查,就可能使用由用户态回调函数篡改的数据。

还有一个小知识点,由于内核态和用户态的转换容易出现问题,为了方便对其进行追踪检查,win32k 有一个默认的命名规则,可能发生这种转换的函数在命名时会加上前缀 xxx 或者前缀 zzz

2. CVE-2021-1732 漏洞分析

2.1 补丁分析

CVE-2022-21882 是对 CVE-2021-1732 的绕过,因此我会同时分析这两个漏洞,首先看 CVE-2021-1732,这个漏洞时在 21 年 2 月被修复的,漏洞发生在窗口创建的时候,对比一下 1 月份和 2 月份的补丁中的 win32kfull!xxxCreateWindowEx 函数

修复前:

  ...
  *(*(tagWND + 5) + 0x128i64) = xxxClientAllocWindowClassExtraBytes(cbWndExtra);
  if ( !*(*(tagWND + 5) + 0x128i64) )
  {
    v265 = 2;
    goto LABEL_202;
  }
  if ( IsWindowBeingDestroyed(tagWND) || (*(_HMPheFromObject(v97) + 25) & 1) != 0 )
  {
    *(*(tagWND + 5) + 0x128i64) = 0i64;         // pExtraBytes 置零
    goto destroyWindow;
  }
LABEL_212:
  if ( PsGetWin32KFilterSet() != 5 || v94 )
  {
    v101 = v275;
  }
  else
  ...

修复前将 xxxClientAllocWindowClassExtraBytes 分配的空间地址直接赋值给了 pExtraBytes 字段,注意函数带有 xxx 前缀,说明其内部会发生到用户态的转换,而根据上面的分析,我们知道 pExtraBytesdwExtraFlag 字段是具有关联性的,但是这里并没有对 dwExtraFlag 字段进行检查。

修复后:

  ...
  v96 = xxxClientAllocWindowClassExtraBytes(v95);
  v338 = v96;
  if ( !v96 )
  {
    v261 = 2;
    goto LABEL_202;
  }
  if ( IsWindowBeingDestroyed(tagWND_1)
    || (*(_HMPheFromObject(v97) + 25) & 1) != 0
    || (v316[0] = 0i64, tagWND::RedirectedFieldpExtraBytes::operator!=<unsigned __int64>(tagWND_1 + 0x140, v316)) )
  {
    // destroyWindow 这部分代码没变化
  }
  tagWNDK = *(tagWND_1 + 5);
  if ( (*(tagWNDK + 0xE8) & 0x800) != 0 )      
  {
    MicrosoftTelemetryAssertTriggeredNoArgsKM();
    tagWNDK = tagWND[5];
  }
  *(tagWNDK + 0x128) = v96;
LABEL_215:
  if ( PsGetWin32KFilterSet() != 5 || v93 )
  {
    v102 = v270;
  }
  else
  ...

可以看到修复后没有直接进行赋值,而是是多了一个判断 (v316[0] = 0i64, tagWND::RedirectedFieldpExtraBytes::operator!=<unsigned __int64>(tagWND + 0x140, v316)),如果该判断为真会直接结束窗口。不会再继续进行正常流程,该函数定义为:

bool __fastcall tagWND::RedirectedFieldpExtraBytes::operator!=<unsigned __int64>(__int64 a1, _QWORD *a2)
{
  return *(*(a1 - 0x118) + 0x128i64) != *a2;  // 即 *(*(tagWND + 0x140 - 0118) + 0x128 ) != 0
}

根据注释,这个函数在判断 pExtraBytes 字段的数值是否为 0,如果不为 0,就判断为真。

2.2 补丁存在的问题

目前来看补丁存在两个问题,第一个问题比较明显:补丁只比较了 pExtraBytes 字段和 0 之间的差别,并没有考虑和 dwExtraFlag 的关联性。

第二个问题可能还需要仔细想一下,当我们在上面说 xxxClientAllocWindowClassExtraBytes 中存在内核态和用户态之间的转换时,它的实际含义是,这个函数内存在很多语句,其中有一个语句是对函数 KeUserModeCallback 的调用:

v2 = KeUserModeCallback(123i64, &length, 4i64, &outputbuffer, &outputlength);

ApiNumber 为 0x7B,根据 USER32!apfnDispatch 的定义,该数值代表函数 __xxxClientAllocWindowClassExtraBytes

真正发生状态转换的是 KeUserModeCallback,所以更好的方法是在这个函数调用之后马上对相关字段进行检查,否则如果有其他函数调用 xxxClientAllocWindowClassExtraBytes,同样的问题仍会出现。

而之所以会如此修复,和公开的漏洞利用代码有关。

3. 漏洞利用

注:因为这是第一次分析 win32k 漏洞利用,涉及很多具体漏洞无关的知识点,因此单独分出一个小节进行介绍。

本小节分析的漏洞利用代码来自这里

3.1 如何造成类型混淆

根据上面的分析,如果想要利用 pExtraBytesdwExtraFlag 的关联性,需要以某种手段,在 KeUserModeCallback 调用 0x7B 用户态回调时,修改这两个字段的数值,让其含义不匹配。

而实现这一目标的,就是函数 NtUserConsoleControl,该函数会调用函数 xxxConsoleControl(int num, struct _CONSOLE_PROCESS_INFO *proInfo, int size),当参数 num 为 6 时,该函数会执行到以下代码:

    ptagWND = ValidateHwnd(*proInfo);   // proInfo 中保存了窗口句柄
    pptagWNDK = ptagWND + 5;

    if ( (*(*pptagWNDK + 0xE8i64) & 0x800) != 0 )// dwExtraFlag & 0x800 != 0
    {
      heapMem = (*(*(ptagWND_1 + 0x18) + 0x80i64) + *(ptagWNDK_1 + 0x128));// 内核桌面堆基址 + pExtraBytes
    }
    else                                        // dwExtraFlag & 0x800 == 0
    {
      heapMem = DesktopAlloc(*(ptagWND_1 + 0x18), *(ptagWNDK_1 + 0xC8), 0i64);
      Object[3] = heapMem;
      if...
      if ( *(*pptagWNDK + 0x128i64) )           // 如果之前 pExtraBytes 不为 0,这里认为就是额外空间的地址
      {
        curProc = PsGetCurrentProcess(v24);
        cbWndExtra = *(*pptagWNDK + 0xC8i64);
        pExtraBytes = *(*pptagWNDK + 0x128i64);
        memmove(heapMem, pExtraBytes, cbWndExtra);// 将额外空间的数据移动到新分配的空间
        if ( (*(curProc + 0x464) & 0x40000008) == 0 )
          xxxClientFreeWindowClassExtraBytes(ptagWND_1, *(*(ptagWND_1 + 40) + 296i64));
      }
      *(*pptagWNDK + 0x128i64) = heapMem - *(*(ptagWND_1 + 0x18) + 0x80i64);// pExtraBytes 重新赋值为新分配空间相对于内核桌面堆基址的偏移
    }
    if...
    *(*pptagWNDK + 0xE8i64) |= 0x800u;          // dwExtraFlag 设置 0x800 标志
    goto rtn;

注意到这个函数实际上是在为 dwExtraFlag 设置 0x800 标志,同时如有必要,修改 pExtraBytes 为对应的偏移值。

当 0x7B 用户回调发生时,实际调用的是函数 user32!_xxxClientAllocWindowClassExtraBytes

NTSTATUS __fastcall _xxxClientAllocWindowClassExtraBytes(unsigned int *a1)
{
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

  v3 = 0;
  v4 = 0i64;
  Result = RtlAllocateHeap(pUserHeap, 8u, *a1);
  return NtCallbackReturn(&Result, 0x18u, 0);
}

可以看到它是通过函数 NtCallbackReturn 将新分配的空间地址作为第一个参数传回内核的。

所以我们可以在 hook __xxxClientAllocWindowClassExtraBytes 的时候,调用 NtUserConsoleControldwExtraFlag 字段进行修改,设置其 0x800 标志,然后调用 NtCallbackReturn,并选择一个自己想要的 pExtraBytes 作为第一个参数。之后系统会把我们设置的 pExtraBytes 作为额外空间相对于桌面堆基址的偏移进行访问。

也就是说,通过构造类型混淆,我们可以读/写 桌面堆基址 + 有限偏移值 处的数据。

3.2 类型混淆到本地提权

之前在学习 HEVD 的时候,我忽略了这一部分的内容,导致在学习这个漏洞的利用时还是缺乏思路。内核漏洞一般能够做到任意地址读/写,但是这样的能力该如何实现权限提升呢?针对这次分析的漏洞,甚至只能做到 桌面堆基址 + 有限偏移值 的读/写操作,这种情况又要怎么提权呢?

参考资料[7] 在这个问题上给出了很好的解答,从内核漏洞到达本地提权,需要三个步骤:

  1. 将内核漏洞扩展为任意地址读/写原语;
  2. 寻找内核地址泄露的方法,获取任一内核对象地址,从而通过 EPROCESS 链找到系统进程;
  3. 将系统进程令牌复制到漏洞利用进程中,从而实现权限提升。

3.2.1 任意读写原语

首先有一个知识点需要明确,在学习 HEVD 以及上面 IDA 代码分析的过程,已知可以通过函数 HMValidateHandle,传入窗口句柄作为参数,获取该窗口的 tagWNDK 结构的地址。而函数 HMValidateHandle 并不是导出函数,因此需要通过 学习 HEVD 中的方法遍历搜索得到该函数地址。

接下来继续获取任意读写原语。原语的获得主要涉及到 3 个窗口:窗口 0、窗口 1 和窗口 2。

  1. 通过喷射的方式创建多个(50个)窗口,释放后面的窗口,只保留前两个窗口作为窗口 0 和窗口 1,同时调用 NtUserConsoleControl 将窗口 0 修改为偏移模式。注意这里并不是在利用漏洞,只是单纯做函数调用,并没有引起类型混淆,此时窗口 0 的 pExtraBytes 字段保存的是窗口 0 额外空间的偏移地址;
  2. 通过调用 HMValidateHandle 获得窗口 0 和窗口 1 tagWNDK 的偏移地址;
  3. 创建窗口 2,通过 Hook 的方式将窗口 2 修改为偏移模式,同时修改 pExtraBytes 字段为窗口 0 tagWNDK 的偏移地址。此时可以通过窗口 2 的 SetWindowLongW 修改窗口 0 的 tagWNDK 结构;
  4. “任意”写原语:修改窗口 0 的 cbWndExtra 字段为可能的最大值,此时可以通过窗口 0 的 SetWindowLongW/SetWindowLongPtrW 进行很大范围的 OOB 写入。其实并没有实现“任意”写,但是已经足够进行漏洞利用;

任意读原语比较复杂,它的关键在于函数 GetMenuBarInfo

BOOL GetMenuBarInfo(
  [in]      HWND         hwnd,
  [in]      LONG         idObject,   // 当数值为 OBJID_MENU(-3) 时,表示获取指定窗口的菜单栏
  [in]      LONG         idItem,     // 当数值为 1 时表示获取菜单上第一项的信息,以此类推
  [in, out] PMENUBARINFO pmbi        // 用于接收信息
);

这个函数最终会调用 win32kfull!xxxGetMenuBarInfo,当 idObject 数值为 -3 时,它会执行以下一段代码:

  if ( (*(ptagWNDK + 0x1F) & 0x40) != 0 )       // 0x1C 偏移处是 dwStyle,WS_CHILD 数值为 0x40000000L,这里就是在检查是否设置了 WS_CHILD,如果设置则无法继续向下执行
    goto toEnd;
  spMenu = *(ptagWND + 0xA8);                   // idObject == -3 才继续往下执行
  SmartObjStackRefBase<tagMENU>::operator=(&spMenu_1, spMenu);
  if ( !SmartObjStackRef<tagMENU>::operator bool(&spMenu_1)
    || idItem_1 < 0
    || idItem_1 > *(*(*spMenu_1 + 0x28i64) + 0x2Ci64) )// [+28h]+2ch >= idItem 才继续往下执行
  {
    goto toEnd;
  }
  ...
  if ( *(*spMenu_1 + 0x40i64) && *(*spMenu_1 + 0x44i64) )// +40h +44h 偏移处不为零
  {
    if ( idItem_1 )                             // idItem 不为 0
    {
      ptagWNDK_2 = *(ptagWND + 0x28);
      x60 = 0x60 * idItem_1;                    // idItem 设置为 1,方便计算
      pdesired = *(*spMenu_1 + 0x58i64);        // +58h 这里存储想要读取的地址 target 再减去 0x40
      desired = *(0x60 * idItem_1 + pdesired - 0x60);
      if ( (*(ptagWNDK_2 + 0x1A) & 0x40) != 0 ) // 这里检查的是 dwExStyle 是不是 WS_EX_LAYOUTRTL,这里没设置这个风格,所以进入下边的 else
      {
        // pass
      }
      else                                      // 进入这里执行
      {
        v64 = *(desired + 0x40) + *(ptagWNDK_2 + 0x58);// desired + 0x40h, 所以传入的是 target - 0x40h
        pmbi->rcBar.left = v64;                 // 最终输出的 mbi 中,rcBar.left 位置存储的是 target(0~3) + Rect.left
        pmbi->rcBar.right = v64 + *(*(x60 + pdesired - 0x60) + 0x48i64);// 最终输出的 mbi 中,rcBar.right 位置存储的是 rcBar.left + target(8~11)
      }
      v65 = *(*(ptagWND + 0x28) + 0x5Ci64) + *(*(x60 + pdesired - 0x60) + 0x44i64);
      pmbi->rcBar.top = v65;                    // 最终输出的 mbi 中,rcBar.top 位置存储的是 Rect.top + target(4~7)
      v40 = v65 + *(*(x60 + pdesired - 0x60) + 0x4Ci64);
    }
    else
    {
      // pass
    }
    pmbi->rcBar.bottom = v40;                   // 最终输出的 mbi 中,rcBar.bottom 位置存储的是 rcBar.top + target(12~15)
  }

spMenu 位于 tagWND 偏移 0xA8 的位置,如果可以将该字段修改为自己构造的假的 spMenu 结构,就能够实现任意读。其中 fakeSpMenu 需要满足以下条件:

  1. 偏移 0x40、0x44 处的数据不为零;
  2. 偏移 0x28 位置指向一个空间,该空间偏移 0x2C 处的数据不为零;
  3. 偏移 0x58 位置指向一个空间,该空间处用于保存想要读取的地址 target 再减去 0x40 的数值;

fakeSpMenu 的设置不能通过任意写的方式,因为到目前为止,从始至终我们获得的都是 tagWNDK 的偏移地址,而无法确定 tagWND 的位置,幸运的是,有一个更方便的方法可以用来设置 spMenu —— SetWindowLongPtr

LONG_PTR SetWindowLongPtrW(
  [in] HWND     hWnd,
  [in] int      nIndex,
  [in] LONG_PTR dwNewLong
);

这个函数和 SetWindowLong 类似,不过可以操作 8 字节数据。它会进一步调用 win32kfull!xxxSetWindowLongPtr,当 nIndexGWLP_ID(-12) 时,它会调用 win32kfull!xxxSetWindowData 并执行以下代码:

  switch ( nIndex )
  {
    case -12:
      ptagWNDK = *(ptagWND + 40);
      if ( (*(ptagWNDK + 0x1F) & 0xC0) == 0x40 )// 如果设置了 WS_CHILD
      {
        v18 = *(ptagWND + 0xA8);                // +A8h spmenu
        *(ptagWNDK + 0x98) = dwNewLong;
        *(ptagWND + 0xA8) = dwNewLong;
      }
      else
      {
        // pass
      }
    // pass
  }
  // pass
  return v18;

也就是说如果传入参数 nIndex 数值为 -12,而且参数 hWnd 代表的窗口设置了 WS_CHILD 样式,SetWindowLongPtr 会对窗口的 spMenu 进行设置,而且会把旧的 spMenu 数值作为返回值返回。

对任意读原语进行总结:

  1. 通过任意写原语设置窗口 1 的样式 WS_CHILD。注:dwStyle 位于 tagWNDK 偏移 0x1C 的位置,只要窗口 1 的 tagWNDK 位于窗口 0 的 tagWNDK 的后面,就可以通过窗口 0 的 SetWindowLongW/SetWindowLongPtrW 对窗口 1 的 dwStyle 进行修改;
  2. 构造符合上面列出条件的 fakeSpMenu
  3. 通过 SetWindowLongPtr 设置窗口 1 的 spMenufakeSpMenu
  4. 通过任意写原语取消窗口 1 的样式 WS_CHILD
  5. 调用 GetMenuBarInfo(hWND1, -3, 1, &mbi) ,按照上面的代码注释计算得到目标地址处的数值。

3.2.2 获取 EPROCESS 地址

内核地址泄露的方法其实在上面已经提到了,在调用 SetWindowLongPtr 设置窗口 1 的 spMenu 字段时,会返回旧的 spMenu 数值,该数据就是一个内核地址,但是问题在于如何得到 EPROCESS 地址。

参考资料[8] 作者提到了 spMenu 中保存了当前进程的 EPROCESS,但是并没有提到如何得知这一信息,如果单纯以 EPROCESS 作为目标,幸运的话确实能发现 EPROCESS 的位置:

// 首先在调试的时候确定了旧的 spMenu 地址为 fffff0ce808211e0

3: kd> !process 0 0 exp.exe
PROCESS ffff9d07b784c340
    SessionId: 1  Cid: 0498    Peb: a67dffa000  ParentCid: 1048
    DirBase: 153bd000  ObjectTable: ffffdf09dfef9140  HandleCount: 131.
    Image: exp.exe

3: kd> s fffff0ce808211e0 l10000000 40 c3 84 b7 07 9d ff ff
fffff0ce`85229010  40 c3 84 b7 07 9d ff ff-01 00 00 00 30 c0 0c 14  @...........0...

3: kd> dqs fffff0ce808211e0
fffff0ce`808211e0  00000000`000301a7
fffff0ce`808211e8  00000000`00000001
fffff0ce`808211f0  00000000`00000000
fffff0ce`808211f8  ffff9d07`b61ffde0
fffff0ce`80821200  fffff0ce`808211e0
fffff0ce`80821208  fffff0ce`81221890
fffff0ce`80821210  00000000`00021890
fffff0ce`80821218  00000008`00000000
fffff0ce`80821220  00000013`00000078
...

// 观察 spMenu 这里的数据,首先想要检查的肯定是位于 0x18 偏移的 ffff9d07`b61ffde0

3: kd> s ffff9d07`b61ffde0 l10000000 10 90 22 85 ce f0 ff ff
ffff9d07`b61ffee0  10 90 22 85 ce f0 ff ff-00 00 00 00 00 00 00 00  ..".............
ffff9d07`b784c848  10 90 22 85 ce f0 ff ff-b0 44 94 b8 07 9d ff ff  .."......D......

以上步骤可以确定 [spMenu + 0x18] + 0x100 的位置保存了 PEPROCESS,但是上面的步骤是我在已经知道答案的情况下进行的推导,如果一切信息不明,可能很难得到相同结论。或许网上有更多的资料,或者作者经过了更多的调试分析。

3.3 小节

最后替换当前进程令牌的过程是一个比较通用的方法,并不困难,因此这里不再赘述。到此为止就能够利用 CVE-2021-1732 漏洞实现本地提权了。

在 2.2 小节中,我们曾经说过,微软之所以会通过检查 pExtraBytes 字段是否为 0 对漏洞进行修复,是因为上面泄露的对该漏洞的利用仅仅是通过 hook 的方式调用 NtUserConsoleControl 设置 dwExtraFlag 的 0x800 标志,并将 pExtraBytes 修改为想要的偏移值。在正常情况下,如果没有对 __xxxClientAllocWindowClassExtraBytes 进行 hook,pExtraBytes 字段不会被修改,保持为 0。因此微软仅针对这一漏洞利用方式对漏洞进行了修复。

但是事实证明,该漏洞的利用方式不止一种,也就是我们接下来要介绍的 CVE-2022-21882。

4. CVE-2022-21882 漏洞分析

4.1 漏洞原理

在 2.2 小节 CVE-2021-1732 补丁存在的问题中提到,微软修复的是 win32kfull!xxxCreateWindowEx 函数,对 __xxxClientAllocWindowClassExtraBytes 调用结束之后的 pExtraBytes 字段进行了检查。但是真正有问题的是 __xxxClientAllocWindowClassExtraBytes 函数。在 CVE-2021-1732 漏洞发现的时候,只有 xxxCreateWindowEx 一个函数调用了 __xxxClientAllocWindowClassExtraBytes,但是随着系统的更新升级,win32k 中增加了新的代码,又有新的函数调用了 __xxxClientAllocWindowClassExtraBytes。检查 21 年 12 月的 win32kfull.sys 文件,会发现函数 xxxDesktopWndProcWorkerxxxMenuWindowProcxxxSBWndProcxxxSwitchWndProcxxxTooltipWndProc 都调用了 __xxxClientAllocWindowClassExtraBytes,而这些函数并没有像 xxxCreateWindowEx 一样进行检查,导致同样的漏洞发生。

4.2 补丁分析

更新后的 win32kfull 首先是做了一个相似代码的功能整合,将上述几个调用 __xxxClientAllocWindowClassExtraBytes 的函数中相同功能的代码放入了同一函数 xxxValidateClassAndSize 中,由该函数调用 __xxxClientAllocWindowClassExtraBytes。这部分修复和漏洞无关。

然后补丁在 xxxClientAllocWindowClassExtraBytes 函数结尾处加入了下面一段代码:

  v4 = KeUserModeCallback(123i64, &Length_1, 4i64, &OutputBuffer, &OutputBufferLength);
  // pass 一些不变的检查
  if ( (*(*(tagWND + 0x28) + 0xE8i64) & 0x800) != 0 )// dwExtraFlag 检查
    result = 0i64;
  else
    result = Mem_1;
  return result;

这次直接在回调函数执行完成后对 dwExtraFlag 进行了检查,防止回调函数将窗口修改成偏移模式。

4.3 漏洞利用

由于 CVE-2022-21882 和 CVE-2021-1732 的漏洞原理完全一致,两者的利用方法也相同,在这里不再赘述。

5. 总结

这篇文章的内容主要集中在 基础知识 以及 漏洞利用 上,这两处也是我在最初接触 win32k 漏洞时始终搞不清楚的地方。通过对这两个同类漏洞的学习,我对 win32k 类漏洞有了初步的认识,能够将 “基础类知识” 和 “漏洞类知识” 区分开,同时明确了此类漏洞的分析与挖掘的入手点。

但是目前我也只看了这一类漏洞,而 win32k 还有其他类型的漏洞,因此仅仅这一篇文章并不能解决所有问题,但是我希望阅读这篇文章,能够为作为 win32k 漏洞小白的你指引方向,方便之后对其他漏洞的分析。

6. 参考资料

  1. 经典内核漏洞调试笔记
  2. CVE-2022-21882 Win32k内核提权漏洞深入分析
  3. Programming Windows 5th
  4. Kernel Attacks through User-Mode Callbacks, by Tarjei Mandt
  5. CVE-2022-21882 Win32k内核提权漏洞深入分析
  6. 0DAY攻击!首次发现蔓灵花组织在针对国内的攻击活动中使用WINDOWS内核提权0DAY漏洞(CVE-2021-1732)
  7. 深入剖析CVE-2021-1732漏洞
  8. CVE-2021-1732研究及Exploit开发
  9. CVE-2022-21882: Win32k Window Object Type Confusion