在有了一些漏洞利用知识后,回过头来再看这个洞。在这个洞中,攻击者可以通过在 HMAssignmentUnlock 中提前释放 SBTrack 来达到 double free 的效果,那么如何利用这个漏洞呢?
回顾之前的漏洞利用知识,我们至少需要有改变内存内容的能力,这样才能通过 gdi 对象来构成一个任意写原语(bitmap extend abuse),但是如何通过这样的 bug 来影响内存的内容?
本文的利用的目标环境为 win10 x64 1703
double free 转 uaf
思路来源于 [下篇]从补丁 diff 到 EXP–CVE-2018-8453 漏洞分析与利用,原文中作者的利用环境为 x86,本文将模仿原作者的思路在 x64 下尝试编写 exp。
在 1703 中,微软对 bitmap 开启了 type isolation,因此我们使用 palette 来构造任意读写原语。
在 sbtrack 第一次被释放后,如果我们可以在 sbtrack 的位置分配到一个 palette,那么这个 palette 会在 sbtack 第二次被释放的时候被释放掉,这时我们就有了一个可以读写一块未分配 chunk 的原语。那么如果能在这个未分配 chunk 的末尾部分再次分配一个 palette 对象,这时我们的这个原语就可以控制新 palette 对象头部的 ppalColor,通过改写该指针,我们就可以获得一个完整任意内存读写的原语。
但是现实没有这么美好,首先要解决的是结构体大小问题。sbtrack 的大小为 0x68,而 palette 大小是动态分配的,大小为 4 * cColors + 0x90
,根本放不到 sbtrack 的内存里面。关于这一点,可以通过 pool chunk 合并的机制,当 sbtrack 释放后,紧接着释放 sbtrack 后面的 chunk,这时 windows 会将这两块 chunk 合并为一块,这样我们就可以构造出一个可以容纳 palette 对象的块。因此需要保证在紧邻着 sbtrack 之后有一个我们能控制的 chunk(并且足够大),这样在 sbtrack 第一次被释放后,我们可以释放该块,让前后块合并。
如果我们只是随便创建了一个 sbtrack,那么这个 sbtrack 可能会使用 freelist 中的 chunk,因此我们首先要消耗完 freelist 中的内存。
当 freelist 用尽后,如果再有分配请求,windows 则会分配新的页面。值得注意的是,当新页面分配后,第一个对象会放在这个页的头部,而第二个对象则会放在这个页的尾部,并且之后的对象分配也是从尾部向低地址增长。所以在 freelist 用尽后,我们先分配一个 chunk,这时候系统会调拨一个新页,之后再分配第二个 chunk,第二个 chunk 会放在新页的尾部,第一个 chunk 与第二个 chunk 之间的空间我们留给 sbtrack:
+------------------------+ +------------------------+
| | | |
| | | |
| allocated | | allocated |
| | | |
| | | |
|------------------------| -free-> |------------------------|
| sbtrack | | |
|------------------------| | |
| | | free |
| allocated | | |
| | | |
+------------------------+ +------------------------+
sbtrack 第一次释放后,释放 sbtrack 之后挨着的 chunk,使两个 chunk 合并,之后在 free 的这个位置申请一个 palette 对象
+------------------------+ +------------------------+
| | | |
| | | |
| allocated | | allocated |
| | | |
| | | |
|------------------------| free-> |------------------------|
| | | |
| | | |
| palette | | free |
| | | |
| | | |
+------------------------+ +------------------------+
之后由于 doublefree 漏洞,该 palette 会被再次释放,此时我们的 palette 就会指向一块悬空的内存,至此我们就有了一个读写一块内存的原语。
+------------------------+
| |
| |
| allocated |
| |
| |
|------------------------|
|------------------------|
| palette*n |<- overwrite header
|------------------------|
| palette |
| |
+------------------------+
之后在这块悬空的内存上分配许多小 palette,再通过刚刚的原语操作小 palette 的头部,使其可以越界读写,通过 bitmap extend abuse 类似的手法最终可以获得一个任意内存读写的原语。
Hot/Cold Page? Defer Free!
思路是这么个思路,接下来来实操一下:
为了让 sbtrack 能被分配一页的中间位置,我们需要计算出 sbtrack 所占用的内存大小 `(0x68+0x1f)&~(0xf)`,也就是 0x80。为了能让内核为我们分配新页,而不使用 freelist 中的 chunk,我们先需要大量分配 chunk,用光 freelist。新分配的每页布局要保证 `chunk1+chunk2+0x80==0x1000`,这里我让 chunk1 为 0x810, chunk2 为 0x770
1: kd> !pool @rax
Pool page fffff5b644c33820 region is Paged session pool
fffff5b644c33000 size: 810 previous size: 0 (Allocated) Usac Process: ffffb8092dc5e080
*fffff5b644c33810 size: 80 previous size: 810 (Allocated) *Usst Process: ffffb8092dc5e080
Owning component : Unknown (update pooltag.txt)
fffff5b644c33890 size: 770 previous size: 80 (Allocated) Usac Process: ffffb8092dc5e080
然后释放的时候就出问题了,发现两个释放的 chunk 并没有合成为一个大 chunk
0: kd> !pool fffff5b644c33000
Pool page fffff5b644a398a0 region is Paged session pool
fffff5b644c33000 size: 810 previous size: 0 (Allocated) Usac Process: ffffb8092dc5e080
fffff5b644c33810 size: 80 previous size: 810 (Free ) Usst
*fffff5b644c33890 size: 770 previous size: 80 (Free ) *Usac
Pooltag Usac : USERTAG_ACCEL, Binary : win32k!_CreateAcceleratorTable
第一反应是开了 defer free,看一下 ExpPoolFlags,根据 xp 代码中的定义,如果开启 defer free 则 0x200 的位为 1
0: kd> ?dwo(nt!ExpPoolFlags)
Evaluate expression: 256 = 00000000`00000100
未开启
没办法,只能去逆一下 ExFreePoolWithTag 的流程,找到合并 chunk 的逻辑,根据 trace 结果对比发现
if ( (ExpPoolFlags & 0x100) == 0 ) // 0x100 EX_SEPARATE_HOT_PAGES_DURING_BOOT
// 如果未开启 hot/cold page separation
{
_InterlockedIncrement((volatile signed __int32 *)(v35 + 0x80));
_InterlockedExchangeAdd64((volatile signed __int64 *)(v35 + 0x98), pool_size3);
v74 = (struct _FAST_MUTEX *)(v35 + 8);
if ( CheckType2 )
{
ExAcquireFastMutex(v74);
PsBoostThreadIo(KeGetCurrentThread(), 0i64);
}
else
{
KeAcquireInStackQueuedSpinLock((PKSPIN_LOCK)v74, &LockHandle);
}
//merge next chunk
// ...
//merge previous chunk
}
pending_freedepth = pool_desc3->PendingFreeDepth;
if ( pending_freedepth >= 0x20 )
{
if ( pending_freedepth >= 0x100 )
{
bMultiThreaded = 1;
}
else
{
if ( pool_desc3->ThreadsProcessingDeferrals )
goto LABEL_65;
bMultiThreaded = 0;
}
ExDeferredFreePool((int *)pool_desc3, bMultiThreaded);
}
LABEL_65:
pool_header->ProcessBilled = (unsigned __int64)pool_header ^ ExpPoolQuotaCookie;
_m_prefetchw(&pool_desc3->PendingFrees);
pending_free_next = (signed __int64)pool_desc3->PendingFrees.Next;
do
{
*(_QWORD *)pool = pending_free_next;
v45 = pending_free_next;
pending_free_next = _InterlockedCompareExchange64(
(volatile signed __int64 *)&pool_desc3->PendingFrees,
pool,
pending_free_next);
}
while ( pending_free_next != v45 );
_InterlockedIncrement(&pool_desc3->PendingFreeDepth);
return;
当 ExpPoolFlags 0x100 位置 1 时,内核是不会直接合并相邻的 chunk 的,这个在代码中为 EX_SEPARATE_HOT_PAGES_DURING_BOOT ,也就是指 hot/cold page separation 这个机制。
Kernel Pool Exploitation on Windows 7 中有提到过这个机制
The flag is set during system boot-up to increase speed and reduce memory footprint. A timer (set in nt! ExpBootFinishedTimer) turns off hot/cold page separation 2 minutes after boot.
xp 代码中也有体现
#if defined (NT_UP)
if (MmNumberOfPhysicalPages < 32 * 1024) {
LARGE_INTEGER TwoMinutes;
//
// Set the flag to disable lookasides and use hot/cold page
// separation during bootup.
//
ExSetPoolFlags (EX_SEPARATE_HOT_PAGES_DURING_BOOT);
//
// Start a timer so the above behavior is disabled once bootup
// has finished.
//
KeInitializeTimer (&ExpBootFinishedTimer);
KeInitializeDpc (&ExpBootFinishedTimerDpc,
(PKDEFERRED_ROUTINE) ExpBootFinishedDispatch,
NULL);
TwoMinutes.QuadPart = Int32x32To64 (120, -10000000);
KeSetTimer (&ExpBootFinishedTimer,
TwoMinutes,
&ExpBootFinishedTimerDpc);
}
#endif
在 win10 1703 (x64) 中,设置该 flag 的代码位于 InitializePagedPool 中
NumberOfPhysicalPages = MmGetNumberOfPhysicalPages(0i64);
if ( NumberOfPhysicalPages >= 0x1FC00 && MmSpecialPoolTag == v14 )
{
LODWORD(NumberOfPhysicalPages) = MmIsVerifierEnabled(&VerifierFlags);
if ( (NumberOfPhysicalPages & 0x80000000) != 0i64 )
_InterlockedOr(&ExpPoolFlags, 0x100u);
}
正常情况下,只要内存够大,是会开启的,并且没有上面说的两分钟之后关闭的代码逻辑。
再研究一下这个机制的作用吧(来源于 xp 代码):
-
如果开启该机制,那么在分配 paged pool 时就不使用 lookaside list。
-
在内核分配 paged pool 时,如果分配的 pool 为 hot allocation,那么内核会从 pool index 为 1 的 paged pool 中分配内存,而如果分配的 pool 为 cold allocation,那么内核会从最后一个 paged pool 分配内存。
-
在释放时,如果开启了该机制,那么是不会将 small block 释放到 lookaside list 中的。
这么看来,这个机制似乎与合并 chunk 关系并不太大,主要用于决定分配时是否使用 lookaside list 以及决定分配的 pool 的位置。并且我们继续回顾 ExFreePoolWithTag 的代码,分析当 ExpPoolFlags 0x100 置 1 的情况,会发现走的竟然是 defer free 的逻辑,继续在 ExAllocatePoolWithTag 找相关逻辑,也没有找到上面所说的关于是否使用 lookaside list 的部分。根据上面所有的发现,我认为这个 hot/cold page separation 机制可能在 win7 之后已经废弃了,而 0x100 这个位已经被用于 EX_DELAY_POOL_FREES
这个 mask,EX_DELAY_POOL_FREES
原来的 0x200 可能也被用作他用。
好了,现在我们知道,问题出在 defer free 上,我们释放了 3000 个 accelerator table,按理说怎么也应该释放并合并 sbtrack 之后的那一块了,还需要继续调试。
既然在 defer free 的时候没有合并,那么首先断到 sbtrack 后面的那个 chunk 被释放的时刻,然后找到该 chunk 的 pool descriptor,在 ExDeferredFreePool 下条件断点,判断 descriptor 是否相同就可以 trace defer free 的整个流程了。
想法是非常完美的,但是实践的时候出了大问题。在 ExFreePoolWithTag 下断的时候发现无论如何也断不到 sbtrack 后面的 chunk 被释放的时候。等到所有的 accelerator table 都释放完再断下来,发现那个 chunk 已经被释放了。研究了整个下午,但还是不知道为什么断不下来,最终发现是 windows 内核调试机制可能有条件竞争的问题,这个问题在给虚拟机设置两个核心使用软件断点的情况下最为明显,3000 次释放,最终只能断下来 100 多次;当双核使用硬件条件断点的时候,会有条件失效、条件断点忽略条件直接断下来的情况,单核使用硬件断点也会有这种情况,但是次数少了很多。另外使用条件断点这么筛速度很慢,因此用 windbg 条件断点去定位目标 chunk 被释放的时刻是不太现实的。
把这个问题跟群里兄弟们讲了一下,鸭鸭跟我说应该直接搞 inline hook,我觉得非常有道理,所以准备用 hook 解决。
#include <ntddk.h>
#include "HookLib.h"
#pragma comment(lib,"HookLib.lib")
decltype(&ExFreePoolWithTag) OriginExFreePoolWithTag = nullptr;
EXTERN_C void __fastcall ExDeferredFreePool(void* desc, int bMultiThreaded);
EXTERN_C void* MiSessionPoolVector();
EXTERN_C void* target_pool = nullptr;
EXTERN_C void* target_eprocess = nullptr;
EXTERN_C decltype(&ExDeferredFreePool) ExDeferredFreePoolAddr = (decltype(&ExDeferredFreePool))0xfffff8012d902010;
EXTERN_C decltype(&ExDeferredFreePool) OriginExDeferredFreePool = nullptr;
void __fastcall ExDeferredFreePool(void* desc, int bMultiThreaded)
{
if (IoGetCurrentProcess() == target_eprocess && desc == MiSessionPoolVector())
{
__debugbreak();
}
return OriginExDeferredFreePool(desc, bMultiThreaded);
}
template <typename Fn>
Fn hookFunc(Fn fn, Fn handler)
{
return static_cast<Fn>(hook(fn, handler));
}
VOID
HandlerExFreePoolWithTag(
_Pre_notnull_ __drv_freesMem(Mem) PVOID P,
_In_ ULONG Tag
)
{
if (target_pool == P)
{
__debugbreak();
target_eprocess = IoGetCurrentProcess();
if (OriginExDeferredFreePool == nullptr)
OriginExDeferredFreePool = hookFunc(ExDeferredFreePoolAddr, ExDeferredFreePool);
}
return OriginExFreePoolWithTag(P, Tag);
}
VOID OnDriverUnload(IN PDRIVER_OBJECT pDriverObject)
{
UNREFERENCED_PARAMETER(pDriverObject);
unhook(OriginExFreePoolWithTag);
unhook(OriginExDeferredFreePool);
DbgPrint("Driver unload routine triggered!\n");
}
EXTERN_C NTSTATUS DriverEntry(
PDRIVER_OBJECT DriverObject,
PUNICODE_STRING RegistryPath
)
{
OriginExFreePoolWithTag = hookFunc(ExFreePoolWithTag, HandlerExFreePoolWithTag);
DriverObject->DriverUnload = OnDriverUnload;
return STATUS_SUCCESS;
}
大致逻辑就是先设置 ExFreePoolWithTag 的 hook,如果释放的地址是我们目标的内存,那么断下来。然后设置 ExDeferredFreePool 的 hook,捕获当前进程的下一次对 session pool 的 defer free 操作。
用 inline hook 的方式成功 trace 了整个 defer free 的流程,然后开始一通分析,发现 defer free 合并 chunk 的逻辑是目标块前后 chunk 的 pool type 为 0 才合并,但是此时 sbtrack 的 pool type 为 0x2d,因此未合并。
那么 sbtrack 被释放的时候为什么 type 没有设为 0?按理说 defer free 后 sbtrack 的 pooltype 会被设为 0,继续定位并追踪了一下 sbtrack 的释放流程,发现 sbtrack 并没有走 defer free 的流程,而是被扔进 lookaside list 里了,所以 type 不为 0。
当 block size 小于等于 ExpSessionPoolSmallLists(w1703 中是 0x15)的时候就会考虑放进 lookaside list 中,这里我们的 sbtrack block size 大小为 8.
kd> dd nt!ExpSessionPoolSmallLists L1
fffff801`2da65194 00000015
if ( is_SESSION_POOL_MASK && is_BASE_POOL_TYPE_MASK2 == 1 )
{
if ( block_size2 <= ExpSessionPoolSmallLists )
{
v39 = ExpSessionPoolLookaside - 0x80 + ((unsigned __int64)block_size2 << 7);
goto LABEL_46;
}
LABEL_62:
pool_desc3 = pool_desc2;
goto LABEL_63;
}
知道了问题的原因,那么就好解决问题了。既然 sbtrack 是被扔进了 lookaside list 中导致 chunk 没法合并,那么我们可以事先释放一下与 sbtrack 相同大小的 accelerator table,提前填满 lookaside list,这样 sbtrack 就会被正常释放掉了。
kd> g
SBTrack: ffffde90c50d2820
Next Chunk: ffffde90c50d28a0
Pool page ffffde90c50d2820 region is Paged session pool
ffffde90c50d2000 size: 810 previous size: 0 (Allocated) Usac Process: ffffb28fd6566080
*ffffde90c50d2810 size: 80 previous size: 810 (Allocated) *Usst Process: ffffb28fd6566080
Owning component : Unknown (update pooltag.txt)
ffffde90c50d2890 size: 770 previous size: 80 (Allocated) Usac Process: ffffb28fd6566080
win32kfull!xxxSBTrackInit+0x72:
ffffdec2`f9811dea 488bd8 mov rbx,rax
kd> g
Break instruction exception - code 80000003 (first chance)
myexp!fnDWORDHook+0xd8:
0033:00007ff6`df2f4408 cc int 3
kd> !pool ffffde90c50d2820
Pool page ffffde90c50d2820 region is Paged session pool
ffffde90c50d2000 size: 810 previous size: 0 (Allocated) Usac Process: ffffb28fd6566080
*ffffde90c50d2810 size: 7f0 previous size: 810 (Free) *Usst
Owning component : Unknown (update pooltag.txt)
至此,这个困扰我两周的问题终于解决了。
NtGdiSetLinkedUFIs
回到我们最开始的思路,尝试用悬空的 palette 对象句柄操作内存,最终会发现还是不行,会得到一个 page fault。
kd> k
# Child-SP RetAddr Call Site
00 ffffce01`fe163e28 fffff801`2d87ff22 nt!DbgBreakPointWithStatus
01 ffffce01`fe163e30 fffff801`2d87f7d2 nt!KiBugCheckDebugBreak+0x12
02 ffffce01`fe163e90 fffff801`2d7ef0d7 nt!KeBugCheck2+0x922
03 ffffce01`fe1645a0 fffff801`2d7fa3a9 nt!KeBugCheckEx+0x107
04 ffffce01`fe1645e0 fffff801`2d7f9b3c nt!KiBugCheckDispatch+0x69
05 ffffce01`fe164720 fffff801`2d7f54ad nt!KiSystemServiceHandler+0x7c
06 ffffce01`fe164760 fffff801`2d690284 nt!RtlpExecuteHandlerForException+0xd
07 ffffce01`fe164790 fffff801`2d68f063 nt!RtlDispatchException+0x404
08 ffffce01`fe164e80 fffff801`2d7fa482 nt!KiDispatchException+0x143
09 ffffce01`fe165540 fffff801`2d7f8957 nt!KiExceptionDispatch+0xc2
0a ffffce01`fe165720 fffff801`2d6c722d nt!KiPageFault+0x217
0b ffffce01`fe1658b0 ffffdec2`f99ee08d nt!ExReleasePushLockExclusiEx+0x1d
0c ffffce01`fe165910 ffffdec2`f96bf14b win32kbase!HmgShareLockCheck+0x2dd
0d ffffce01`fe165990 ffffdec2`f96be828 win32kfull!EPALOBJ::EPALOBJ+0x1b
0e ffffce01`fe1659c0 ffffdec2`f96be677 win32kfull!GreGetPaletteEntries+0x28
0f ffffce01`fe165a10 fffff801`2d7f9f13 win32kfull!NtGdiDoPalette+0xb7
10 ffffce01`fe165a90 00007ff8`75b51964 nt!KiSystemServiceCopyEnd+0x13
11 000000ca`718ff748 00007ff8`756bd197 win32u!NtGdiDoPalette+0x14
12 000000ca`718ff750 00007ff8`76524d71 gdi32full!GetPaletteEntries+0x17
13 000000ca`718ff790 00007ff7`174d5ecc GDI32!GetPaletteEntriesStub+0x41
根据调用栈可以看到问题在 ExReleasePushLockExclusiveEx,一通逆向发现问题是因为在 pool 释放后,pool manager 会在 pool 中写上 SINGLE_LIST_ENTRY 这个结构,这样就破坏了原本 palette 对象中 gdi header 结构的 hHmgr 字段,这个字段用于表示该对象的内核句柄。HmgShareLockCheck 会通过这个 hHmgr 来定位内核对象的锁,由于这个 hHmgr 被破坏了因此最终找到的锁也不对,于是在释放锁的时候造成了 page fault。
直接使用悬空 palette 的思路行不通了,因为在对象释放后 palette 的结构会被破坏。而我们需要一个不受释放操作影响、再被释放后依旧能操作自己的对象(并且必须得是 session page pool 中的)。好在在这个坑作者已经踩过了,还专门挖出来一个手法用于操作内存,那就是 NtGdiSetLinkedUFIs:
BOOL NtGdiSetLinkedUFIs(
HDC hdc,
PUNIVERSAL_FONT_ID pufiLinks,
ULONG uNumUFIs
)
{
BOOL bRet = TRUE;
UNIVERSAL_FONT_ID pufiQuickLinks[QUICK_LINKS];
PUNIVERSAL_FONT_ID pufi = NULL;
//...
if (uNumUFIs > QUICK_LINKS)
{
if (!BALLOC_OVERFLOW1(uNumUFIs,UNIVERSAL_FONT_ID))
{
pufi = (PUNIVERSAL_FONT_ID)
PALLOCNOZ(uNumUFIs * sizeof(UNIVERSAL_FONT_ID),'difG');
}
//...
}
else
{
pufi = pufiQuickLinks;
}
//...
RtlCopyMemory(pufi,pufiLinks,
sizeof(UNIVERSAL_FONT_ID)*uNumUFIs);
XDCOBJ dco(hdc);
if (dco.bValid())
{
bRet = dco.bSetLinkedUFIs(pufi, uNumUFIs);
dco.vUnlockFast();
}
//...
}
BOOL XDCOBJ::bSetLinkedUFIs(PUNIVERSAL_FONT_ID pufis, UINT uNumUFIs)
{
pdc->dclevel.bTurnOffLinking = (uNumUFIs) ? FALSE : TRUE;
// If pufi hasn't been initialized or it is too small, reinitialize it.
if(!pdc->dclevel.pufi || (uNumUFIs > pdc->dclevel.uNumLinkedFonts))
{
if(pdc->dclevel.pufi && (pdc->dclevel.pufi != pdc->dclevel.aQuickLinks))
{
VFREEMEM(pdc->dclevel.pufi);
pdc->dclevel.pufi = NULL;
}
if(uNumUFIs < QUICK_UFI_LINKS)
{
pdc->dclevel.pufi = pdc->dclevel.aQuickLinks;
}
else
{
if(!(pdc->dclevel.pufi = (PUNIVERSAL_FONT_ID)
PALLOCMEM(sizeof(UNIVERSAL_FONT_ID) * uNumUFIs,'ddaG')))
{
WARNING("GDI: XDCOBJ::bSetLinkedUFIs of of memory\n");
pdc->dclevel.uNumLinkedFonts = 0;
return(FALSE);
}
}
pdc->dclevel.uNumLinkedFonts = uNumUFIs;
}
memcpy(pdc->dclevel.pufi, pufis, sizeof(UNIVERSAL_FONT_ID) * uNumUFIs);
return(TRUE);
}
根据代码可以看到,如果对于每个 hdc 句柄如果第一次调用,只要 nNumUFIs 大于 QUICK_UFI_LINKS (值为 4)这个宏,那么就会申请一块任意大小的内存空间,而如果第二次调用,那么则可以直接实现对于该块内存写。
作者在文中提到,还有个 NtGdiGetLinkedUFIs 函数可以用于读内存,但是我大致浏览代码没有看懂如何用这个函数去读取 NtGdiSetLinkedUFIs 后的内存,这个函数名字跟我所想的实际功能并不一样。
好了,现在我们可以使用 NtGdiSetLinkedUFIs 分配并写一块内存,那么如何通过这个函数去影响 palette?由于我们没有读的能力,如果直接覆盖整个 palette 头,那么就会出现与之前一样的问题, hHmgr 字段会被破坏导致 palette 无法使用。因此我们需要排布内存,让 NtGdiSetLinkedUFIs 所分配的内存位置刚好对准 palette 的 cEntries 字段。
+------------------------+ +------------------------+
| | | |
| | | |
| allocated | | allocated |
| | | |
| | | |
|------------------------| |------------------------|
|------------------------- ----> | |
| | | |
| pufi | | palette |
| | | |
| | | |
+------------------------+ +------------------------+
cEntries 字段在 palette 对象的 0x1c 位置,在我们之前设计的内存布局中,最后整个 palette 的大小是 0x7f0,那么可以计算出这个 pufi 的内存大小应该是 0x7d4,但是由于 NtGdiSetLinkedUFIs 在 x64 中需要 8 字节对齐,整个 pool 是 0x10 对齐,所以整个 pufi 的大小设为 0x7e0,减去 pool header 部分,pufi 为 0x7d0,在 pufi 对象+c 的位置就是需要覆盖的 cEntries 字段。这么算下来,尽管不会覆盖掉 hHmgr 字段,但是仍然有 8 字节的对象头和 4 字节的 palette flag 是会被覆盖的,对象头部分被覆盖的位置存储的是 PW32THREAD 指针;另一方面,NtGdiSetLinkedUFIs 是有最小写入限制的,当 uNumUFIs 小于 QUICK_UFI_LINKS (4) 的时候会使用 quick ufi link,因此使用 NtGdiSetLinkedUFIs 必须至少写入 0x20 大小的内容,这意味着 cEntries 字段的后面一两个字段都会被覆盖,不过应该问题不大。
写代码实践了一下又发现不行崩溃了,重新申请的 pool 必须得跟第二次释放的 pool 在同一个位置,之前没想到这块,也就是说整个 pufi 的大小必须得是 0x7f0,而 palette 得是 0x800。那么需要把上面那块 0x810 大小的占位块释放掉,让整个页被回收,然后再重新申请。但是 0x800 还有一个问题就是当页面被回收后重新分配的时候一个页会被两块 0x800 的占位块直接占满,所以重新设计一下内存布局:
- top 0x910
- sbtrack 0x80
- buttom 0x670
- pufi 大小 0x6f0
- 重新申请的占位块 top 0x900
- palette 0x700
又写了代码,又发现不行,bugcheck 了。打印一下栈:
0: kd> k
# Child-SP RetAddr Call Site
00 ffff8b81`37f7bb58 fffff802`64287f22 nt!DbgBreakPointWithStatus
01 ffff8b81`37f7bb60 fffff802`642877d2 nt!KiBugCheckDebugBreak+0x12
02 ffff8b81`37f7bbc0 fffff802`641f70d7 nt!KeBugCheck2+0x922
03 ffff8b81`37f7c2d0 fffff802`642023a9 nt!KeBugCheckEx+0x107
04 ffff8b81`37f7c310 fffff802`64201b3c nt!KiBugCheckDispatch+0x69
05 ffff8b81`37f7c450 fffff802`641fd4ad nt!KiSystemServiceHandler+0x7c
06 ffff8b81`37f7c490 fffff802`64098284 nt!RtlpExecuteHandlerForException+0xd
07 ffff8b81`37f7c4c0 fffff802`64097063 nt!RtlDispatchException+0x404
08 ffff8b81`37f7cbb0 fffff802`64202482 nt!KiDispatchException+0x143
09 ffff8b81`37f7d270 fffff802`64200734 nt!KiExceptionDispatch+0xc2
0a ffff8b81`37f7d450 ffffa0e0`cd8620fc nt!KiGeneralProtectionFault+0xf4
0b ffff8b81`37f7d5e8 ffffa0e0`ce7521f6 win32kbase!HMAssignmentUnlock+0xc
0c ffff8b81`37f7d5f0 ffffa0e0`ce752ca7 win32kfull!xxxSBTrackInit+0x47e
0d ffff8b81`37f7d6d0 ffffa0e0`ce598d93 win32kfull!xxxSBWndProc+0xa57
回头看了一眼之前分析的代码,释放 sbtrack 前首先有三个对 sbtrack 中的字段的 unlock 操作,由于我在 NtGdiSetLinkedUFIs 的时候给内存全设为了 0x77,那么在对 lock 减一的时候自然就导致了内存访问异常。
….!? 等一下!对 lock 减一?内存访问异常?这不就代表我们现在有了一个任意地址减一的原语吗?好吧,这其实是之后我要写的第二个利用手法,现在先跳过这个,继续延续原作者的思路。
修复这个异常方法也很简单,内存全置 0 就行了。
至此可以成功获得一个可以越界读写的 palette
0: kd> !dppal ffffa088`c5ad5910
EPALOBJ structure at 0xffffa088c5ad5910:
--------------------------------------------------
FLONG flPal 0x501
ULONG cEntries 0xffffffff
ULONG ulTime 0x0
HDC hdcHead 0x0000000000000000
HDEVPPAL hSelected 0x0000000000000000
ULONG cRefhpal 0x0
ULONG cRefRegular 0x0
PTRANSLATE ptransFore 0x0000000000000000
PTRANSLATE ptransCurrent 0x0000000000000000
PTRANSLATE ptransOld 0x0000000000000000
PPALETTE ppalColor 0xffffa088c5ad5998
PAL_ULONG apalColor 0x0000000000000000
--------------------------------------------------
有了越界的 palette 之后就很简单了,在重新申请占位块的时候不用加速表而是使用 palette 占位,这样这个越界 palette 后面就会紧跟着放这一个 palette 给我们操作,再通过覆写 apalColorTable 指针就可以获得任意读写的原语。
这里还有一个小坑,gdi handle 的一个句柄上限是 win32kbase! gProcessHandleQuota
当句柄数量大于这个那么对象会创建失败。具体判断的位置在 win32kbase! HmgSetOwner
中,创建 palette 时候调用栈如下:
1: kd> k
# Child-SP RetAddr Call Site
00 ffff8b81`39559a60 ffffa0e0`ce63cdf3 win32kbase!GreSetPaletteOwner+0x1c
01 ffff8b81`39559a90 fffff802`64201f13 win32kfull!NtGdiCreatePaletteInternal+0xe3
02 ffff8b81`39559b00 00007ffa`d38d2504 nt!KiSystemServiceCopyEnd+0x13
所以在创建 manager 和 worker palette 的实际最好是漏洞利用开始前,在漏洞利用开始后会创建大量 gdi 对象导致 manager 和 worker 创建不成功。
Gdi Handle Manager
但是现在在开始提权前,还有另一个问题,就是现在这个 pdc->dclevel. pufi
是悬空的,一旦程序结束,内核释放这个东西又会造成 double free,所以我们要把这个字段置 0,或者也可以直接从句柄表中清除这个对象。那么现在的问题就变成了如果根据 hdc 句柄找到 dc object。
研究了一下 NtGdiSetLinkedUFIs 的流程可以看到内核通过 HmgLock
函数将句柄转换为对象,该函数讲句柄转换为 index 作为索引从 gpentHmgr 中找到 PENTRY,然后再通过 PENTRY 找到原始的对象。
POBJ pobj = (POBJ)NULL;
UINT uiIndex = (UINT)HmgIfromH(hobj);
PENTRY pentry = &gpentHmgr[uiIndex];
但这么简洁明了的逻辑早已是不复存在的美好的 xp 时代了,win10 的逻辑比这个复杂得多,pentry 不再能找到 gdi 对象,取而代之的是一个 LOOKUP_ENTRY,这也是本思路的原作者当时没有选用这个方法而是选择将 gdi 对象转换为一个普通对象 (accelerator table) 去防止 double free 的原因。但是沉下心,我们可以的!
研究一下 dc 对象删除的逻辑,发现如果 v12 [0]
为 0 的情况下是不会去做删除 dc 的操作的。
__int64 __fastcall bDeleteDCInternal(HDC a1, int a2, int a3, int a4)
{
unsigned int v8; // ebx
int v9; // esi
unsigned int v10; // eax
__int64 v12[5]; // [rsp+20h] [rbp-28h] BYREF
v8 = 0;
v9 = 0;
DCOBJ::DCOBJ((#775 *)v12, a1);
if ( v12[0] && ((v10 = HmgQueryLock(a1), a2) || a3 || a4 || v10 <= 1) )
{
v8 = bDeleteDCInternalWorker((struct #600 *)v12, a2, a3, a4);
if ( !v8 && !a2 )
v9 = 1;
}
else
{
EngSetLastError(0xAAu);
}
XDCOBJ::vUnlockNoNullSet((#600 *)v12);
if ( v9 )
v8 = UserReleaseDC(a1);
return v8;
}
那么这个 v12 到底是什么呢?它本身是一个 xdcobj 对象,通过函数 void __fastcall XDCOBJ:: vLock ( #600 *this, HDC hObj)
进行初始化,函数内部会将一个 PLOOKUP_ENTRY (v5
) 赋值到 v12 [0]
的位置。
HandleEntryDirectory = *((_QWORD *)gpHandleManager + 2);
MaxHandle = *(_DWORD *)(HandleEntryDirectory + 0x808);
if ( v9 >= MaxHandle + ((*(unsigned __int16 *)(HandleEntryDirectory + 2) + 0xFFFF) << 0x10) )
goto LABEL_51;
if ( v9 >= MaxHandle )
{
v12 = ((v9 - MaxHandle) >> 0x10) + 1;
if ( (v9 - MaxHandle) >> 0x10 == 0xFFFFFFFE )
goto LABEL_51;
}
else
{
v12 = 0;
}
v13 = *(_QWORD *)(HandleEntryDirectory + 8i64 * v12 + 8);
if ( v12 )
v9 = v9 - (v12 << 0x10) - MaxHandle + 0x10000;
if ( v9 < *(_DWORD *)(v13 + 0x14) )
{
v5 = *(_QWORD *)(*(_QWORD *)(**(_QWORD **)(v13 + 0x18) + 8 * ((unsigned __int64)v9 >> 8))
+ 0x10i64 * (unsigned __int8)v9
+ 8);
}
*(_QWORD *)this = v5;
那么思路便清晰了:
- 获取 win32kbase 基址
- 获取 gpHandleManager 地址
- 根据算法一通计算算出 lookupentry 的位置
- 置 0
到此为止,现在我们终于有了一个完美的内核任意读写原语。
提权
踩了无数坑,终于来到了提权的步骤了。相比于之前的各种阻碍,提权真的是简单了很多,只有三步:
- 找到 system 进程的 EPROCESS
- 找到自己进程的 EPROCESS
- 将自己进程的 EPROCESS 的 token 替换为 system 进程的 token
system 的 EPROCESS 在内核中是导出的,然后可以通过 activelinkprocess 链表遍历所有进程的 EPROCESS,进而找到自己进程的 EPROCESS,最后简单替换一下 token 就可以了。
纪念一下自己的第一个提权 exp。
本以为在分析完漏洞后编写利用应该是很快很轻松的事,却没想到过程居然如此坎坷。并且即便是分析了漏洞的成因、编写了漏洞的 exp,对于如何挖掘这个漏洞,我觉得仍然离我十分遥远。同时在利用上,提权只是开始,能做到从 ring3 直接打到内核才应该是终极目标。