CVE-2018-8453 分析

由于是第一次接触 win32k,先准备一点前置知识

相关函数

HMAssignmentUnlock

实际上会调 HMUnlockObject,HMUnlockObject 实际是个宏

    ( (--((PHEAD) pobj)->cLockObj == 0) ? HMUnlockObjectInternal (pobj) : pobj )

先对 obj->cLockObj 进行减一,减一后如果等于 0 则调用 HMUnlockObjectInternal

PVOID HMUnlockObjectInternal (
    PVOID pobj)
{
    PHE phe;

    //如果handle entry没有被标记上HANDLEF_DESTROY则返回
    phe = HMPheFromObject(pobj);
    if (!(phe->bFlags & HANDLEF_DESTROY))
        return pobj;

    //如果该对象正在destroy的过程中同样返回
    if (phe->bFlags & HANDLEF_INDESTROY)
        return pobj;

    HMDestroyUnlockedObject(phe);
    return NULL;
}

HMDestroyUnlockedObject 是销毁对象的部分

VOID HMDestroyUnlockedObject (
    PHE phe)
{
    //把对象标记为正在destroy的过程中
    phe->bFlags |= HANDLEF_INDESTROY;

    //调用destroy函数
    (*gahti[phe->bType].fnDestroy)(phe->phead);
}

HMAssignmentUnlock 简单来说就是会将对象的锁减一,如果锁的数量减为 0,则调用该对象的销毁函数

xxxDestroyWindow

一堆检查后调用 xxxFreeWindow

xxxFreeWindow

如果一个还有其他对象对这个窗口存在引用,那么就不会真正释放这个窗口,同时把这个窗口的默认窗口过程改为默认窗口过程;如果没有其他引用了,那么就真正释放这个窗口(HMFreeObject

VOID xxxFreeWindow (
    PWND pwnd,
    PTL  ptlpwndFree)
{
//...
    /*
     * Try to free the object. The object won't free if it is locked - but
     * it will be marked for destruction. If the window is locked, change
     * it's wndproc to xxxDefWindowProc().
     *
     * HMMarkObjectDestroy() will clear the HANDLEF_INDESTROY flag if the
     * object isn't about to go away (so it can be destroyed again!)
     */
    if (HMMarkObjectDestroy(pwnd)) {

        /*
         * Delete the window's property list. Wait until now in case some
         * thread keeps a property pointer around across a callback.
         */
        if (pwnd->ppropList != NULL) {
            DeleteProperties(pwnd);
        }

        pti->cWindows--;

        /*
         * Since we're freeing the memory for this window, we need
         * to unlock the parent (which is the desktop for zombie windows).
         */
        Unlock(&pwnd->spwndParent);

        ThreadLockDesktop(pti, pwnd->head.rpdesk, &tlpdesk, LDLT_FN_FREEWINDOW);
        HMFreeObject(pwnd);
        ThreadUnlockDesktop(pti, &tlpdesk, LDUT_FN_FREEWINDOW);
        return;
    }

    /*
     * Turn this into an object that the app won't see again - turn
     * it into an icon title window - the window is still totally
     * valid and useable by any structures that has this window locked.
     */
    pwnd->lpfnWndProc = xxxDefWindowProc;
    //...
}

xxxSBTrackInit

首先分配一块内存,存放 SBTrack 结构体,然后做一些初始化。然后一些杂七杂八的操作后会去调用 xxxSBTrackLoop。在 xxxSBTrackLoop 返回后再释放 SBTrack 这块内存

xxxSBTrackLoop

消息循环中接收消息,根据 SBTrack 中的函数指针去调用回调 会通过 xxxDispatchMessage 调用 fnDWORD 回调

xxxEndScroll

会释放 SBTrack 结构体

KeUserModeCallback

用户回调函数

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

第一个参数为 apinumber,是 peb 中 KernelCallbackTable 的一个 index

dt nt!_PEB @$peb -y Kernel

不同系统 apinumber 是有差异的,需要根据系统适配

漏洞分析

通过网上各种文章,带上调试,整理出了漏洞的流程

  1. 注册窗口类并产生一个主窗口,以主窗口为父窗口再创建一个滚动条子控件,hook fnDWORD、xxxClientAllocWindowClassExtraBytes
  2. 向滚动条发送 WM_LBUTTONDOWN 消息,这时滚动条回调函数 xxxSBWndProc 会调用 xxxSBTrackInit 来实现滚动条的鼠标跟随并且创建结构体 SBTrack 来保存鼠标位置,之后会调用 xxxSBTrackLoop 循环获取鼠标消息
  3. xxxSBTrackLoop 循环会调用 fnDWORD 回调函数来回到 R3,这时在 fnDWORD 中释放之前创建的主窗口
    00 fffff882`c4892338 ffffc69c`3c42e146     nt!KeUserModeCallback
    01 fffff882`c4892340 ffffc69c`3c42e5b0     win32kfull!SfnDWORD+0x226
    02 fffff882 `c4892450 ffffc69c` 3c62e19a     win32kfull! xxxDispatchMessage+0x240
    03 fffff882 `c4892500 ffffc69c` 3c62f052     win32kfull! xxxSBTrackLoop+0x1be
    
  4. 在释放过程中,如果窗口存在扩展结构,则会调用 xxxClientFreeWindowClassExtraBytes 函数释放扩展空间,也就是会调用之前 hook 的函数。
  5. 此时在 xxxClientFreeWindowClassExtraBytes 中设置之前创建的父窗口的 fnid,由 0x8000 改为 0x82a1,同时创建一个新的滚动条,并对其调用 SetCapture。继续返回执行 xxxFreeWindow
  6. 在 xxxFreeWindow 中,此时由于还在 xxxSBTrackLoop 中,因此滚动条还有对主窗口的引用,所以这时 xxxFreeWindow 不会真的释放主窗口,xxxFreeWindow 执行结束,返回到 xxxSBTrackLoop。
  7. 由于主窗口处于正在释放的状态,xxxSBTrackLoop 结束,这时解除对主窗口的引用,导致主窗口的真正释放,再次进入 xxxFreeWindow。
  8. xxxFreeWindow 中代码判断了 fnid 值,决定要不要调用 fnDWORD,由于之前设置了 fnid ,再次进入 fnDWORD 回调,在 fnDWORD 中向新的滚动条发送窗口消息 WM_CANCLEMODE 间接调用 xxxEndScroll 提前释放 SBTrack 占用的内存。
  9. 在 xxxSBTrackLoop 结束后, xxxSBTrackInit 会释放 SBTrack 占用的内存,而这时 SBTrack 已被释放了,造成 double free。

看起来这个漏洞只是由于 xxxendscroll 与 sbtrackinit 同时释放了 sbtrack 结构体导致的,但是单单整理完流程,其实对这个漏洞还是处于一种不清不楚的状态。仍旧有许多疑问,下面就是一些我的问题以及研究的结果。

sbtrackinit 与之后发送消息的窗口并非是同一个窗口,为什么用两个 scrollbar 可以成功?

尽管两个窗口是不同的,但是 xxxEndScroll 是从当前线程的线程信息中取 pSBTrack,而程序是单线程的,在 xxxSBTrackInit 中,会将创建的 SBTrack 结构体存到线程信息中去,也就是新滚动条释放的 SBTrack 实际上就是原来旧滚动条创建的 SBTrack,两个 scrollbar 是共用一个 SBTrack 结构体的,代码中也可以看到 pwnd 实际上是一个 PTHROBJHEAD(thread object),而上述操作都是在同一个线程中进行的,自然是一个 SBTrack。

#define _GETPTI(p)      (((PTHROBJHEAD)p)->pti)
#define GETPTI(p)       _GETPTI(p)
#define PWNDTOPSBTRACK(pwnd) (((GETPTI(pwnd)->pSBTrack)))
void xxxSBTrackInit(
    PWND pwnd,
    LPARAM lParam,
    int curArea,
    UINT uType)
{
//...
PWNDTOPSBTRACK(pwnd) = pSBTrack;
//...
}
void xxxEndScroll(
    PWND pwnd,
    BOOL fCancel)
{
    UINT oldcmd;
    PSBTRACK pSBTrack;
    CheckLock(pwnd);
    UserAssert(!IsWinEventNotifyDeferred());

    pSBTrack = PWNDTOPSBTRACK(pwnd);
//...
}

为什么要用两个 scrolbar?为什么用一个 scrollbar 不行?

在向旧 scrollbar 发送消息时,该 scrollbar 句柄已经不可用,user32! ValidateHwnd 返回 0 导致发送消息失败。那么什么导致了该句柄不可用呢?在第一次调用我们的 fnDWORD hook 函数时,我们调用了 destroywindow 函数,该函数会调用 xxxFreeWIndow,在 xxxFreeWindow 中,会继续调用 xxxFW_DestroyAllChildren,该函数会遍历该窗口的所有子窗口,取消其父子关系,并对其子窗口调用 xxxFreeWindow。xxxFreeWindow 会将句柄标记为 Destroy 状态,因此旧 scrollbar 作为原有窗口的子窗口,在这时句柄已经不可用了(但没有完全释放,因为此时我们还在 xxxSBTrackLoop 中)。

既然漏洞的关键在于释放两次 SBtrack,那为什么不能在第一次 fnDWORD 回调中直接向自己发送消息调用 xxxEndScroll 释放 SBTrack?

void xxxEndScroll (
    PWND pwnd,
    BOOL fCancel)
{
//...
    Unlock(&pSBTrack->spwndSB);
    Unlock(&pSBTrack->spwndSBNotify);
    Unlock(&pSBTrack->spwndTrack);
    UserFreePool(pSBTrack);
    PWNDTOPSBTRACK(pwnd) = NULL;
}   
void xxxSBTrackInit(
    PWND pwnd,
    LPARAM lParam,
    int curArea,
    UINT uType)
{
//...
 if (pSBTrack->fCtlSB) {
        /*
         * This is a scroll bar control.
         */
        Lock(&pSBTrack->spwndSB, pwnd);
        pSBTrack->fTrackVert = ((PSBWND)pwnd)->fVert;
        Lock(&pSBTrack->spwndSBNotify, pwnd->spwndParent);
        wDisable = ((PSBWND)pwnd)->wDisableFlags;
        pSBCalc = &((PSBWND)pwnd)->SBCalc;
        pSBTrack->nBar = SB_CTL;
    } else {
    //...
    }  
//...
 xxxSBTrackLoop(pwnd, lParam, pSBCalc);

      // After xxx, re-evaluate pSBTrack
    REEVALUATE_PSBTRACK(pSBTrack, pwnd, "xxxTrackLoop");
    if (pSBTrack) {
        Unlock(&pSBTrack->spwndSBNotify);
        Unlock(&pSBTrack->spwndSB);
        Unlock(&pSBTrack->spwndTrack);
        UserFreePool(pSBTrack); 
        PWNDTOPSBTRACK(pwnd) = NULL;
    }
}

可以看到在 xxxEndScroll 函数中,在释放 sbtrack 后,会把通过 PWNDTOPSBTRACK 宏把线程信息中的 SBTrack 字段置 0。而在 xxxSBTrackInit 中,首先通过 REEVALUATE_PSBTRACK 宏从线程信息中重新获取 SBTrack 指针,如果为 0 则不进行释放,说白了就是在释放前有一个检查。因此调用 xxxEndScroll 的时机就非常重要。在释放 pSBTrack 前,有三个 unlock 操作,实际上调用的是 HMAssignmentUnlock,如果此时 unlock 的对象引用计数减为 0,那么就会调用该对象的销毁函数进行释放。 第一个 Unlock(&pSBTrack->spwndSBNotify); 这个 pSBTrack->spwndSBNotify 实际上是 scrollbar 的父窗口,因此,我们可以操纵父窗口引用计数,让此时引用计数为 0 调用 xxxfreewindow。

为什么要设置 fnid?fnid 在这个漏洞流程中起到了什么作用?

设置 fnid 是为了要能在 xxxFreeWindow 中获取执行代码的机会。这个漏洞中在 xxxFreeWindow 有两次不同的回调,一个是 xxxClientAllocWindowClassExtraBytes,另一个就是 fnDWORD。其中前者的回调机会只有一次,在调用前会将 v19+0x128 这个位置置空,这个位置也就是 pExtractByte 的位置,因此在第一次调用 DestroyWindow 的时候,该窗口的扩展字节已经被清空了,所以在第二次 xxxFreeWindow 的时候就没有这个回调了,因此 fnDWORD 是唯一的机会,而设置 fnid 就是为了能通过判断,顺利调用 fnDWORD。

//xxxFreewindow部分代码
//调用xxxClientAllocWindowClassExtraBytes的逻辑
  v19 = *((_QWORD *)a1 + 5);
  v20 = *(_QWORD *)(v19 + 0x128);
  if ( v20 && v20 != -1 )
  {
    if ( (*(_DWORD *)(v19 + 0xE8) & 0x800) != 0 )
    {
      RtlFreeHeap(*(PVOID *)(*((_QWORD *)a1 + 3) + 128i64), 0, (PVOID)(v20 + *(_QWORD *)(*((_QWORD *)a1 + 3) + 128i64)));
      *(_QWORD *)(*((_QWORD *)a1 + 5) + 0x128i64) = 0i64;
    }
    else
    {
      *(_QWORD *)(v19 + 0x128) = 0i64;
      if ( (*(_DWORD *)(PsGetCurrentProcess(v19, v18, v16, v17) + 0x304) & 0x40000008) == 0
        && (*(_DWORD *)(gptiCurrent + 480i64) & 1) == 0 )
      {
        xxxClientFreeWindowClassExtraBytes(a1, v20);
      }
    }
  }
//调用fnDWORD的逻辑
 v15 = *((_QWORD *)a1 + 5);
  v18 = 0x3FFFi64;
  v16 = *(unsigned __int16 *)(v15 + 42); //v16 是fnid
  v17 = 672i64;
  LOWORD(v18) = v16 & 0x3FFF;
  if ( ((unsigned __int16)v16 & 0x3FFFu) >= 0x29A && (v16 & 0x4000) == 0 )
  {
    if ( (unsigned __int16)v18 <= 0x2A0u )
    {
      ((void (__fastcall *)(struct tagWND *, __int64, _QWORD))mpFnidPfn[((_BYTE)v16 + 6) & 0x1F])(a1, 112i64, 0i64);
    }
    else if ( (unsigned __int16)v18 <= 0x2AAu && (*(_DWORD *)(gptiCurrent + 480i64) & 1) == 0 )
    //pti+480 是 TIF_flags
    {
      SfnDWORD((unsigned int)a1, 112u, 0i64, 0i64, 0i64, *(_QWORD *)(gpsi + 8i64 * (unsigned __int16)v18 - 4608));
    }
    *(_WORD *)(*((_QWORD *)a1 + 5) + 42i64) |= 0x4000u;
    v15 = *((_QWORD *)a1 + 5);
  }

进入 fnDWORD 分支的条件为,fnid>0x2a0&&fnid<=0x2aa 并且 fnid 的 0x4000 这位不为 1,而在正常情况下,fnid 的值 0x8000 (FNID_DELETED_BIT),因此只要通过调用 NtUserSetWindowFNID 设置 fnid 为符合条件的值即可。

为什么要在 xxxClientFreeWindowClassExtraBytes 回调中设置 fnid? 在 destroywindow 前设置 fnid 是否可行?

如果在 destroywindow 前设置 fnid,那么在 destroywindow 调用 xxxFreeWindow 的时候,函数将直接进入调用 fnDWORD 的分支,调用结束后再给 fnid 设置上 0x4000 这个标志位,而进入该分支的条件就是 fnid 的 0x4000 标志位没有被设置,因此在第二次通过 HMAssignmentUnlock 调用 xxxFreeWindow 的时候就不会再次进入这个分支了。

 LOWORD(v18) = v16 & 0x3FFF;
  if ( ((unsigned __int16)v16 & 0x3FFFu) >= 0x29A && (v16 & 0x4000) == 0 )
  {
    if ( (unsigned __int16) v18 <= 0x2A0u )
    {
      ((void (__fastcall *)(struct tagWND *, __int64, _QWORD)) mpFnidPfn[((_BYTE) v16 + 6) & 0x1F])(a1, 0x70i64, 0i64);
    }
    else if ( (unsigned __int16) v18 <= 0x2AAu && (*(_DWORD *)(gptiCurrent + 0x1E0i64) & 1) == 0 )
    {
      SfnDWORD ((unsigned int) a1, 0x70u, 0i64, 0i64, 0i64, *(_QWORD *)(gpsi + 8i64 * (unsigned __int16) v18 - 0x1200));
    }
    *(_WORD *)(*((_QWORD *)a1 + 5) + 0x2Ai64) |= 0x4000u;
    v15 = *((_QWORD *)a1 + 5);
  }

那么应该在什么时机设置设置这个标志位呢?在 pwnd->cLock==1 同时 SBTrackLoop 返回之前,并且 fnid 没有被设置 0x4000 的时候能设置好 fnid 即可实现触发漏洞。回头再看一下整个流程:

  1. xxxSBTrackLoop 给了我们一次用户回调的机会
  2. 为了使主窗口 pwnd->cLock==1,只留有 scrollbar 的引用,我们调用 DestroyWindow
  3. DestroyWindow 调用 xxxFreeWindow 有两个回调机会,按顺序分别是 fnDWORD 和 xxxClientFreeWindowClassExtraBytes。

为了能在第二次 xxxFreeWindow 时触发 xxxEndScroll,我们需要保证在第一次不触发某一个回调,把这个回调留在第二次。由于我们有设置 fnid 的能力,可以让 fnDWORD 在第二次回调触发,而 xxxClientFreeWindowClassExtraBytes 又刚好在 fnDWORD 的后面,因此在这个回调中设置 fnid,正好可以不触发第一次 xxxFreeWindow 中的 fnDWORD 回调,而在第二次 xxxFreeWindow 中触发。

总结

首先,对于同一个结构体,两个不同的功能点都会对其做释放操作,即 xxxSBtrackInit 与 xxxEndScroll 都会释放当先线程信息中的 SBTrack。

但是仅有这两个功能点是不够的,在这两个功能点释放前后都有对 SBTrack 指针做校验,因此第二点就在于时机。在 xxxSBTrackInit 校验 SBTrack 指针后,内存释放前,代码对 SBTrack 中的某些成员做了 HMAssignmentUnlock 操作,配合 fnid 的设置,这给了攻击者操作的时机,有了回到 usermode callback 的机会去调用另外一个释放功能点,造成 double free。

修复

patch 中对 NtUserSetWindowFNID 增加了一个校验,多了一个 IsWindowBeingDestroyed 函数调用,判断该窗口 fnid 是否已经被赋上标志位 0x8000 (FNID_DELETED_BIT),也就是说当我们 Destroy 一个窗口后,无法再对该窗口设置 fnid 值了。

如果是这种修复方式行不行呢?

    REEVALUATE_PSBTRACK (pSBTrack, pwnd, "xxxTrackLoop");
    if (pSBTrack) {
        void* spwndSBNotify, spwndSB, spwndTrack;
        spwndSBNotify = pSBTrack->spwndSBNotify;
        spwndSB = pSBTrack->spwndSB;
        spwndTrack = pSBTrack->spwndTrack;
        UserFreePool(pSBTrack);
        PWNDTOPSBTRACK(pwnd) = NULL;
        Unlock(&spwndSBNotify);
        Unlock(&spwndSB);
        Unlock(&spwndTrack);
    }

这种修复没有解决一个问题,就是攻击者在 HMAssignmentUnlock 的时候仍然拥有回到用户回调的能力,在其他组件中依旧可能会有这种 unlock 然后 free ojbect 的写法,因此这种方法治标不治本。而修复 NtUserSetWindowFNID 的方式则让攻击者失去了在通过 HMAssignmentUnlock 回到用户回调的机会。


很庆幸现在有 xp 的代码作为参考,通过源代码能快速了解模块的内部,对 wink32 的了解也增进了不少。在分析这个漏洞的时候时不时冒出来一些绕过这个补丁或者挖相近漏洞的思路,但是一一验证后无一所获。离出洞最近的一次是看到了 xxxClientAllocWindowClassExtraBytes 与 xxxSetWindowLong 的时候想出来一个思路可以内核任意写,那时候感觉自己真的要挖出 0day 了,但事实总是不尽人意的,一方面这个位置的洞已经被人挖出来过了,并且已经有了 patch,另一方面自己的思路实际上与真正的那个洞也有一点区别,只是利用点一样,但是没有想到靠类型混淆+oob 来实现任意写,所以离真的挖出洞还是差了点,下次再分析漏洞的话就分析这个 cve 吧。