作者:Alex Lonescu
題目:Sheep Year Kernel Heap Fengshui: Spraying in the Big Kids’ Pool
地址: http://www.alex-ionescu.com/?p=231
前幾天看到Twitter上推薦的一篇Alex lonescu寫的博文,是關于兩個新的內核堆噴射技術的,感覺很有啟發,這個大牛寫文章有點隨意,向來不太好翻譯,這里試翻譯一下,文章中的噴射技術本人已經做過驗證,在32位win7以及xp下都是可用的。
典型的“任意地址寫任意數據”類型內核漏洞的利用技術通常需要依靠兩種方法,一種是修改某些內核空間的數據結構,這種方法由于Windows內核地址空間隨機分配(KASLR)機制,在本地是容易做到的;另一種方法是程序重定向到可控的用戶態地址空間,并以ring0權限執行可控代碼。
上文說到的第二種方法比較容易實現,因為不需要考慮內核空間數據的改變,并且可以在一個進程中完成完全的指令控制,常用的技術包括修改tagWND或者HAL Dispatch Table等等。
但是,由于管理模式執行保護技術(SMEP)(也稱為Intel操作系統防護)的存在,這種技術不再可靠,一個直接的用戶地址空間已經不再可用,因此,其他的可靠替代技術就必不可少了。
一種可能的替代技術是通過ROP編程令SMEP的強制保護失效(改變CR4寄存器中的相關位),這種方法實現時需要保證棧是可控的。這種方法此前已經在一些論文和演講稿中提出過。
另一種可能的替代方法是在以內存頁為單位的空間內關閉SMEP,這種方法是通過在頁級的轉換映射入口(translation mapping entries)處做出相應的修改,將用戶態的頁標為內核態的頁來實現的。這種技術也已經在至少一篇演講中被探討過,而且,如果被采用,我的一位朋友也將在2015年的SyScan演講中講到這種技術。此外,如果被采用,另一種不同版本的技術也將在2015年的INFILTRATE中講到,將講述這種不同技術的正是在下。
最后還有一種替代方法在理論上應該是可行的。這種方法通過跳轉(通過指針或回調函數表)至一處已存在的函數執行(從而令SMEP失效,同時也繞過KASLR),同時又有某種方法令攻擊者能夠獲取到程序的控制權(并非通過ROP),當然迄今為止還沒有人找到過這樣一個已存在的函數。這樣的方法應該是一種面向跳轉的編程方法(JOP)。
盡管有上述如此多的技術方法,它們仍然是利用用戶地址空間承載主要攻擊載荷的(當然這一點并無問題)。那么,是不是還應該考慮利用內核地址空間來攻擊的可能性呢?利用內核地址空間來承載攻擊載荷天然就不需再考慮ROP或者破壞PTE(頁表入口點)來令SMEP失效的問題。
很明顯,這種技術要求可執行攻擊載荷的函數已經在內核空間中存在,或者我們有辦法將其帶入到內核中。例如在棧/池溢出的情況下,這種攻擊方法就要求載荷在攻擊發生時就應該已經布置好,并且也已經具備通常獲取代碼執行能力的手段。這種攻擊在“遠程—遠程”攻擊時尤其常見。
那么對于本地攻擊者(遠程—本地)最喜歡的“任意地址寫任意數據”漏洞呢?如果我們擁有用戶態下代碼執行的能力來執行“任意地址寫任意數據”,那么很明顯,我們可以不斷地用利用“任意地址寫任意數據”來在我們選中的地址空間重復填入攻擊載荷數據,但這也將帶來如下幾個問題:
“任意地址寫任意數據”本身也許會不可靠,或者破壞鄰接數據,而這將導致將代碼填入內存變得很難操作。
由于需要考慮KASLR以及無執行頁保護(Kernel NX)的問題,往哪里寫入代碼也許是不那么容易確定的。在Windows平臺上,這個問題盡管不是那么難解決,但仍然算是一個技術障礙。
本篇博客將介紹兩種新的技術(至少我認為是新的),一個將其命名為通用內核空間堆噴射技術(會產生可執行的內核地址空間),另一個是通用內核空間堆地址發現技術,用以繞過KASLR。
精通Windows堆管理(稱為“池”)的高手肯定了解,有兩種不同的堆分配機制(如果你特別較真,也可以認為是三種):一種是“正常”池分配(包括使用lookaside鏈表的分配方法,該方法與正常池分配略有不同);另一種是“大池”分配。
少于一個內存頁大小的分配通常使用“正常”池分配,也就是說或者X86下小于4080字節(8字節用作池頭部,8字節分給初始的空閑塊),或者X64下小于4064字節(16字節用于池頭部,16字節分給初始的空閑塊)將使用“正常”池分配。這種分配機制下,地址跟蹤、內存映射以及地址分配計數等操作是由池管理器自身正常的內存處理機制來完成的,由池頭部將所有的信息鏈接在一起。
至于“大池”分配機制,在分配的內存空間多于一個頁面時使用,同時也用于要求Cache對齊的池內存分配(無論其分配大小),因為cache對齊就必然會占用至少一整個頁的大小。
因為沒有預留頭部空間,這些“大池”中的內存頁是通過“大池索引表”(nt!PoolBigPageTable)來索引跟蹤的;而用來確認池空間擁有者的池標識同樣也沒有保存在頭部(因為根本就沒有頭部),也同樣是保存在PoolBigPageTable中。表的每一個入口點都用一個POOL_TRACKER_BIG_PAGES結構表示,在公開符號表中記錄如下:
lkd> dt nt!_POOL_TRACKER_BIG_PAGES
+0x000 Va : Ptr32 Void
+0x004 Key : Uint4B
+0x008 PoolType : Uint4B
+0x00c NumberOfBytes : Uint4B
需要注意的是,上表中虛擬地址(Va)實際上是被虛擬地址和表示是否空閑的標識位按位與過以后的值,話句話說,其實上表中與過后的Va其實能夠表示兩個真正的虛擬地址,這兩個地址只可能處于兩種狀態,要么一個空閑,要么兩個都空閑,不可能出現兩個都不空閑的情況。下面的WinDBG腳本可以將當前所有的“大池”分配的內存塊信息打印出來。
#!c
r? @$t0 = (nt!_POOL_TRACKER_BIG_PAGES*)@@(poi(nt!PoolBigPageTable))
r? @$t1 = *(int*)@@(nt!PoolBigPageTableSize) / sizeof(nt!_POOL_TRACKER_BIG_PAGES)
.for (r @$t2 = 0; @$t2 < @$t1; r? @$t2 = @$t2 + 1)
{
r? @$t3 = @$t0[@$t2];
.if (@@(@$t3.Va != 1))
{
.printf "VA: 0x%p Size: 0x%lx Tag: %c%c%c%c Freed: %d Paged: %d CacheAligned: %d\n", @@((int)@$t3.Va & ~1), @@(@$t3.NumberOfBytes), @@(@$t3.Key >> 0 & 0xFF), @@(@$t3.Key >> 8 & 0xFF), @@(@$t3.Key >> 16 & 0xFF), @@(@$t3.Key >> 24 & 0xFF), @@((int)@$t3.Va & 1), @@(@$t3.PoolType & 1), @@(@$t3.PoolType & 4) == 4
}
}
為什么“大池”的分配如此令人感興趣?因為它不像“小池”分配那樣可以共享頁面,也不像“小池”分配那樣很難在調試中跟蹤(在不導出整個池的情況下),“大池”分配的內存其實是很容易被枚舉出來的。容易到什么地步,一個非公開的NtQuerySystemInformation?API函數(又一個繞過KASLR的)就有一個專門用于導出大池分配內存信息的類。這個類不僅包含了內存的大小、標識、類型,還包含了內核態的虛擬地址!
如之前講到的,該API函數的執行并不需要額外的權限,但需注意的是在Windows 8.1下,該API還是被限制使用了,僅低相關調用(如Metro應用/沙箱應用)才可以使用。
下面這一小段代碼就是用來枚舉所有“大池”分配的內存塊信息的:
#!c
//
// Note: This is poor programming (hardcoding 4MB).
// The correct way would be to issue the system call
// twice, and use the resultLength of the first call
// to dynamically size the buffer to the correct size
//
bigPoolInfo = RtlAllocateHeap(RtlGetProcessHeap(),
0,
4 * 1024 * 1024);
if (bigPoolInfo == NULL) goto Cleanup;
?
res = NtQuerySystemInformation(SystemBigPoolInformation,
bigPoolInfo,
4 * 1024 * 1024,
&resultLength);
if (!NT_SUCCESS(res)) goto Cleanup;
?
printf("TYPE ADDRESS\tBYTES\tTAG\n");
for (i = 0; i < bigPoolInfo->Count; i++)
{
printf("%s0x%p\t0x%lx\t%c%c%c%c\n",
bigPoolInfo->AllocatedInfo[i].NonPaged == 1 ?
"Nonpaged " : "Paged ",
bigPoolInfo->AllocatedInfo[i].VirtualAddress,
bigPoolInfo->AllocatedInfo[i].SizeInBytes,
bigPoolInfo->AllocatedInfo[i].Tag[0],
bigPoolInfo->AllocatedInfo[i].Tag[1],
bigPoolInfo->AllocatedInfo[i].Tag[2],
bigPoolInfo->AllocatedInfo[i].Tag[3]);
}
?
Cleanup:
if (bigPoolInfo != NULL)
{
RtlFreeHeap(RtlGetProcessHeap(), 0, bigPoolInfo);
}
很明顯,能夠讀取到這些內核態內存地址是非常有用的,但僅僅內存塊地址可讀還不夠,那么如何才能進一步做到控制內存塊中的數據呢? 你會注意到前面講到的技術中,有幾種是可以令用戶態下的攻擊者分配內核對象的(如,APC reserve對象),這類的內核對象有幾個域是用戶可控的,并且有一個用于獲取內核態內存地址的API函數。我們這里基本上也是要做同樣的事情,但是不僅僅是控制內核對象的幾個域,我們的目標是找到一個可以完全控制內核對象所有數據的用戶API,且需要該API調用時能夠觸發一個“大池”分配。
這種尋找并非如聽起來那么難,任何時候一個內核態元素的空間分配超過0X01中所述大小限度(大于一個內存頁,即4K左右)時,“大池”分配就會被觸發。因此,這個問題的難度就降低為尋找一個可以造成內核態空間分配超過4K的用戶態API,且分配的數據也得是可控的。而因為Windows XP SP2以后版本的操作系統被強制內核空間不可執行,所以這種空間分配也必須要能產生可執行的內存才能符合我們的要求。
(也就是說這樣的尋找必須滿足三個條件:觸發“大池”分配、數據可控、分配的空間可執行)
怎樣能滿足這樣的條件……呃,兩個很簡單的方法會立即出現在你腦中:
創建一個本地Socket套接字并監聽,用另外一個線程連接該套接字,然后發出一個寫操作(寫的數據要超過4K),但不要讀。這就將導致WinSock的輔助功能驅動(AFD.SYS)在內核態下為Socket數據分配內存地址,該驅動也是著名的另一個“歇菜的”驅動。由于Windows網絡棧函數都處于DISPATCH_LEVEL(IRQL 2)層,是無法分頁的,AFD會觸發一個非分頁的內存塊分配,而這一條對我們來說尤其有用!因為除了Windows 8及更高版本外,其他的Windows平臺下非分頁內存都是可執行的!
創建一個命名管道,然后發出一個寫操作(同樣數據大于4K),且不要讀。這也將導致命名管道文件系統(NPFS.SYS)為管道數據分配一塊非分頁的內存塊。(原因同樣是因為NPFS緩沖區操作位于DISPATCH_LEVEL)。
總體上說,第二種方法是更簡單的,只需要幾行代碼就可以完成,并且與Socket操作相比管道操作更加隱蔽。需要著重指出的是NPFS會在我們自己的緩沖區前面加上一個包含其自身內聯頭部的前綴,該前綴被稱為DATA_ENTRY。NPFS頭部的大小會隨版本不同而略有差異(XP-、2003、Windows 8+各有不同)。
我已經找到一種最省力的方法來實現偏移的處理,可以通過在用戶態緩沖區中安排好相應的偏移,而省去考慮最終內核態載荷頭部的偏移問題。記住一點,這里所談到的技術其關鍵是分配一個大于一頁的內存,以觸發“大池”分配。
下面這段小程序已經完善地考慮了上述的所有問題和需求,執行后能夠產生我們預計的效果。
#!c
UCHAR payLoad[PAGE_SIZE - 0x1C + 44];
?
//
// Fill the first page with 0x41414141, and the next page
// with INT3's (simulating our payload). On x86 Windows 7
// the size of a DATA_ENTRY is 28 bytes (0x1C).
//
RtlFillMemory(payLoad, PAGE_SIZE - 0x1C, 0x41);
RtlFillMemory(payLoad + PAGE_SIZE - 0x1C, 44, 0xCC);
?
//
// Write the data into the kernel
//
res = CreatePipe(&readPipe,
&writePipe,
NULL,
sizeof(payLoad));
if (res == FALSE) goto Cleanup;
res = WriteFile(writePipe,
payLoad,
sizeof(payLoad),
&resultLength,
NULL);
if (res == FALSE) goto Cleanup;
?
//
// extra code goes here...
//
?
Cleanup:
CloseHandle(writePipe);
CloseHandle(readPipe);
我們已經知道的是NPFS讀取數據緩沖區的池標識是“NpFr”(你可以用WinDBG的!pool和!poolfind命令來查找)。因此我們就可以將該標識硬編碼進我們的程序段,并通過該標識找到我們所期待的裝載有攻擊載荷的內核態虛擬地址,而為對付地址隨機分配機制的老的KASLR繞過的代碼就不再需要了。
記住“分頁 vs 非分頁”標識是被按位與到虛擬地址中(與之前我們所說的標識空閑或已占用的位不同)的,因此我們就可以將其標出來,同時也要考慮池頭部的對齊問題(對齊是強制執行的,即使對于“大池”分配也一樣)。下面的顯示NpFr標識內存塊地址的程序段,適用于X86平臺的Windows:
#!c
//
// Based on pooltag.txt, we're looking for the following:
// NpFr - npfs.sys - DATA_ENTRY records (r/w buffers)
//
for (entry = bigPoolInfo->AllocatedInfo;
entry < (PSYSTEM_BIGPOOL_ENTRY)bigPoolInfo +
bigPoolInfo->Count;
entry++)
{
if ((entry->NonPaged == 1) &&
(entry->TagUlong == 'rFpN') &&
(entry->SizeInBytes == ALIGN_UP(PAGE_SIZE + 44,
ULONGLONG)))
{
printf("Kernel payload @ 0x%p\n",
(ULONG_PTR)entry->VirtualAddress & ~1 +
PAGE_SIZE);
break;
}
}
下圖是WinDBG中的截圖證明。
看吧!將程序打包成一個簡單的“kmalloc”幫助函數,然后你也可以分配可執行的且已知地址的內核態內存空間了。那么這種分配最多可以多大?根據我做過的實驗,128MB是完全沒有問題的,但是因為這種分配是非分頁的內存,你還必須考慮你的RAM內存是否足夠大。這里的鏈接指向一段部署了該分配功能的例子代碼(沒有鏈接)。
使用這種技術的另一個好處是,你不僅可以得到所分配空間的虛擬地址,還可以得到該空間的實地址!作為我首先發現并首次應用在我的“meminfo tool”中的未公開Superfetch 系列API函數之一(該API現在已經被SysInternals移植到其RAMMap utility工具中),調用后內存管理器會直接返回所分配內存的池標識、虛擬地址以及物理地址。
下圖是RAMMap的截圖,展示了另一個已分配載荷的虛擬地址以及實地址(注意下圖中有0x1000的差異是因為命令行PoC代碼使指針產生了一個頁的偏移,如代碼中所寫:加了一個PAGE_SIZE)。
該技術的此次完整爆出,有幾點額外的說明會令其在2015年變得不那么sexy—這也是我為什么不選擇8年前第一次偶爾發現它時,而是選擇今天將其爆出的原因:
從Windows 8開始,非分頁內存不再允許執行。本文的方法仍然可以用來分配內存,但是代碼的運行將需要繞過內存的無執行頁保護(NX)機制。因此本文提出的方法就只是將SMEP繞過問題轉換為內核態NX繞過問題。
Windows 8.1下,獲取大池入口點及地址的API只在低相關調用下有效。這就極大降低了本地—遠程攻擊中的可用性,因為低相關調用一般是通過沙盒應用(如Flash、IE、Chrome等等)或Metro 容器加載。
當然,也存在一些相應的方法來解決上述問題,如沙盒逃逸就常用于本地—遠程攻擊,因此上述問題2)就有解決的余地。至于上述問題1),一些聰明的研究者也已經指出NX并沒有在所有地方都完整部署,比如,分配的Session池空間,在新版本的Windows下仍然是可執行的,當然僅限X86(32位)系統上可執行。我把這個如何在擴展Windows版本中實現此技術作為練習留給讀者完成(小提示:系統中有一種叫做“Big Session Pool”的池)。
那么,64位的Windows或者更新版本的Windows 10下怎么辦呢?看起來本文提到的這種技術在這些系統上是失效的了---|-是這樣的嗎!?內核態下所有的內存空間都已經NX了嗎?或者還有沒有別的猥瑣方法來分配到可執行的內存空間,并且得到其地址?當2022年到來,Windows14發布后,我必將立刻在Blog中回答這些個相關問題。