【經典回顧系列】 一步一步教你漏洞挖掘之Windows SMB Ghost CVE-2020-0796(三)
接上文:
漏洞利用之遠程命令執行(二)
0x01 實現任意地址讀
遠程協議的信息泄露很難實現,關鍵是漏洞能夠破壞或改寫響應報文,利用協議中返回數據的相關函數將信息發回給用戶。而目前來看,我們原始的漏洞只能破壞請求報文的數據結構,值得慶幸的是我們通過該漏洞已經獲取了任意地址寫的能力。正常情況下,我們會利用任意地址寫來改寫一些關鍵數據結構,來破壞響應數據結構,實現信息泄露,問題是我們無法得知往哪寫。
注意到當用戶建立新的SMB連接時,在`Smb2ExecuteNegotiateReal`中會申請用于響應的數據結構(與請求包一樣的`SRVNET_BUFFER_HDR`,其中的MDL結構中包含一個重要的用于存放返回數據的物理內存地址),該函數最終也是用`SrvNetAllocateBuffer`來申請內存:

幸運的是`SrvNetAllocateBuffer`在內存分配時系統使用了`Lookaside` 列表(參考前面`SrvNetAllocateBuffer`的代碼),這類似于一種緩存,可以加快內存的申請和釋放速度(`ExAllocatePoolWithTag`及`ExFreePoolWithTag`都會花費大量時間),該列表中的內存塊在申請和釋放時都不會對內容進行初始化(對于這一點,個人覺得會存在內存未初始化的風險)。
經過初步分析,利用`SrvNetAllocateBuffer`申請的內存,只有最終進入`SrvNetAllocateBufferFromPool`才會對內存進行初始化操作,否則利用`Lookaside`獲取的內存將保持已有的數據,后面在SMB 協商階段將被直接使用。因此,如果恰好能夠從`Lookaside` 列表申請到之前釋放的經過精心構造的數據結構,就能使`Nogotiation`的返回包讀取任意地址的內容,發生信息泄露。
現在我們來捋一下思路,要實現任意地址讀,可以通過以下幾個步驟來實現:
- 在本地準備好偽造的MDL結構,重點是將物理地址字段配置為想要讀取的地址
- 利用任意寫,在`SystemSharedPage`(固定地址0xfffff78000000000)中寫入一個偽造的MDL結構
- 利用緩沖區溢出,將`SRVNET_BUFFER_HDR+0x38`的指針改為偽造的MDL地址,關閉連接將釋放內存
- 發起正常的SMB 協商請求,如果`SRVNET_BUFFER_HDR`復用成功,將返回指定物理地址處的數據
- 利用PML4泄露內存頁表內容,通過虛實轉換實現任意地址讀原語
(1)偽造MDL結構
關于MDL的具體結構,微軟有對應的文檔:
MDL (wdm.h)
https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ns-wdm-_mdl
也有相關的使用研究:
Windows MDL原理總結
https://www.cnblogs.com/jack204/archive/2011/12/25/2300983.html
struct _MDL { struct _MDL *Next; // 0x0 CSHORT Size; // 0x48 CSHORT MdlFlags; // 0x5018 ULONG Processor; // 0x0 PVOID MappedSystemVa; // 0xfffff78000000800 PVOID StartVa; // 0xfffff78000000000 ULONG ByteCount; // 0x258 ULONG ByteOffset; // PFN_NUMBER physMem[]; // 頁表項 pfn} MDL, *PMDL;
計算物理地址對應的頁表項:
pfn = (phys_addr & 0xFFFFFFFFFFFFF000) >> 12
(2)將偽造的MDL寫入SystemSharedPage
為了減少其他數據的干擾,我們將MDL盡量存放在`SystemSharedPage`的后端。
phys_addr = 0x222222 # 計劃讀取的目標物理地址pmdl_mapva = SystemSharedPage+0x800 # MDL結構體中的MappedSystemVapmdl_va = SystemSharedPage+0x900 # MDL結構的存放位置fake_mdl = MDL(pmdl_mapva, phys_addr).raw_bytes()write_primitive(args.ip, args.port, fake_mdl, pmdl_va)
發送攻擊數據包后,在`SystemSharedPage`中成功寫入了偽造的MDL:

(3)篡改請求數據結構中的pMDL指針
在寫原語中,我們利用了兩次溢出實現了任意地址寫(第一次溢出將目標地址改為指定值,第二次溢出將指定數據寫入指定位置)。這里我們并不需要第二次溢出,只要在第一次溢出時直接將pMDL篡改為指定值就行。
該過程看似直接,其實這里有一個問題:由于pMDL位于`SRVNET_BUFFER_HDR`的后面,如果直接使用之前的溢出方法,將導致整個`SRVNET_BUFFER_HDR`都被覆蓋(其中包括一個內存指針`header->pNonPagedPoolAddr`),這將導致后續的處理發生異常(釋放時調用`ExFreePoolWithTag`)。
【解決該問題】我們需要跳過`SRVNET_BUFFER_HDR`,直接溢出篡改pMDL。因為`SmbCompressionDecompress`中會將解壓的數據拷貝至`UserBuffer + Offset`處,所以我們只要將Offset設置為pMDL所在偏移就可以實現。
第二個問題:如果繼續執行,在溢出點3進行內存拷貝時,`SRVNET_BUFFER_HDR`依然會被破壞,導致`header->pNonPagedPoolAddr`被篡改,最終`ExFreePoolWithTag`異常。
【解決該問題】我們要避免程序流程進入溢出點3,那就只能使`SmbCompressionDecompress`出錯,后面直接退出:
NTSTATUS Status = SmbCompressionDecompress(...);if (Status < 0 || FinalCompressedSize != Header->OriginalSize) { SrvNetFreeBuffer(Alloc); return STATUS_BAD_DATA;}// ...// 避免執行至溢出點3
從IDA中簡單分析`SmbCompressionDecompress`,判斷只要在解壓縮過程中發生異常,應該就能夠使其返回錯誤值:

我們嘗試在構造壓縮數據時,故意將后面改為錯誤結構,在數據解壓過程中觸發異常,導致`RtlDecompressBufferEx2`返回錯誤,而`RtlDecompressBufferEx2`在發生錯誤前,仍然會將已經解壓成功的數據拷貝至目標位置。利用這個特性我們能夠篡改`SRVNET_BUFFER_HDR`中的pMDL指針,同時又保證程序不會破壞`header->pNonPagedPoolAddr`。
def compress_evil(buf, chunk_size=0x1000): out = b"" while buf: chunk = buf[:chunk_size] compressed = _compress_chunk(chunk) # 正常壓縮過程 flags = 0xB000 # 始終標記為壓縮狀態 header = struct.pack(', flags | (len(compressed)-1)) out += header + compressed buf = buf[chunk_size:] out += struct.pack(', 0x1337) # 破壞 "next" 塊 return out
# 該函數能夠篡改`SRVNET_BUFFER_HDR`中指定偏移處的內容# offset = 0x38 時指向pMDLdef write_srvnet_buffer_hdr(ip, port, data, offset): sock = reconnect(ip, port) smb_negotiate(sock) sock.recv(1000) compr_data = compress_evil(data) # 構造有問題的壓縮數據 dummy_data = b"\x33"*(0x1100 + offset) smb_compress(sock, compr_data, 0xFFFFEFFF, dummy_data) sock.close()
馬上測試一下,我們在發送攻擊數據包前,先看一下`SRVNET_BUFFER_HDR`中的內容:

然后在`SmbCompressionDecompress`函數返回后再看一下`SRVNET_BUFFER_HDR`中的內容:

可以看到`SRVNET_BUFFER_HDR->pMDL`已經被篡改為`0xfffff78000000900`,就是我們自己偽造的MDL結構,其他數據內容都保持不變。返回值為`0xc0000242`,繼續執行后將釋放內容并返回,從而避免進入溢出點3導致系統崩潰。
(4)再次建立SMB連接,嘗試任意物理內存讀取
再次建立正常的SMB連接就行,根據前面所說,在協商階段會申請一個`ResponseBuffer`:

系統會嘗試用`Lookaside`表查詢可用的內存塊,如何幸運的話(實際測試發現并不一定每次都成功,但是攻擊過程并不會導致目標崩潰,因此可以反復嘗試)應該能夠申請到上次連接釋放的`SRVNET_BUFFER_HDR`,就像下面的情況:

直接繼續執行,我們就能收到包含泄露的內存數據的響應包了,可以對比下wireshark和實際內存的內容:


綜上4步,任意物理內存讀取達成!
(5)泄露內存頁表,實現任意虛擬地址讀
首先我們先研究下Windows的內存機制:
Windows的內存機制
https://blog.csdn.net/ratonsea/article/details/106842622
在64位操作系統上的內存分頁使用4級頁表,將物理頁面映射到虛擬頁面,它們分別是PML4(也就是PXE)、PDPT、PD和PT。控制寄存器CR3包含當前進程PML4表的(物理)內存基地址。
【注】這里由于Windbg Preview不支持`!vtop`等命令,只能切換回老的Windbg!!!

根據 Ricera Security 的研究,PML4似乎并沒有實現隨機化,我在vmware的虛擬機中查看寄存器cr3的值,確實如其所說為固定值:`0x1ad000`,需要注意的是針對不同的引導方式PML4會有不同取值:`0x1aa000(BIOS)`或者`0x1ad000(UEFI)`。根據這點,我們能夠通過dump頁表信息,實現虛擬地址和物理地址的轉換,最終將虛擬地址的讀取轉換成物理地址的讀取。
在實際的利用代碼中,Ricerca Security使用了一種似乎更為通用的方式。從物理地址`0x1000`處開始進行搜索,通過比對特征數值來尋找PML4地址和HAL堆地址:
# Use lowstub jmp bytes to signature searchLOWSTUB_JMP = 0x1000600E9# ...index = 0x1000buff = read_physmem_primitive(ip, port, index)entry = struct.unpack(", buff[0:8])[0] & 0xFFFFFFFFFFFF00FFif entry == LOWSTUB_JMP: PML4 = struct.unpack(", buff[0xA0:0xA8])[0] print("[+] PML4 at %lx" % PML4) PHAL_HEAP = struct.unpack(", buff[0x78:0x80])[0] & 0xFFFFFFFFF0000000 print("[+] base of HAL heap at %lx" % PHAL_HEAP) return
但是實際測試過程中Ricerca Security的利用代碼一直未能成功。測試發現對部分物理地址讀取會超時(原因有待分析),自己對代碼做了一些修改(需要增加對`sock.recv`的異常處理,防止程序超時退出),終于成功泄露PML4的物理地址:

后面就是要利用PML4的地址來獲取各級頁表內容,實現內存轉換。但是在內存讀取過程中依然存在讀取失敗的問題:就是上面提到的讀取特定物理地址失敗的問題,比如讀取PML4的地址`0x1ad000`時返回的數據只有6個`0x00`:

分析問題原因:通過跟蹤數據包的構造和發送過程,系統最終通過`MmMapLockedPagesSpecifyCache`函數根據MDL將物理地址映射為虛擬地址。

bp nt!MmMapLockedPagesSpecifyCachebp srvnet!SrvNetSendData ".if(poi(@rdx+8)==0xfffff78000000900){}.else{gc;}"bp nt!MiFillSystemPtes+0x29d
初步分析是`MmMapLockedPagesSpecifyCache`返回NULL導致的問題,這一點非常奇怪,比如我讀0x1000處就能夠map成功,而對于0x2000就不行。
進一步在`MmMapLockedPagesSpecifyCache`中分別調用了`MiReservePtes`和`MiFillSystemPtes`兩個函數,其中`MiReservePtes`申請一個虛擬地址用于內存映射,`MiFillSystemPtes`實現物理內存映射(虛擬地址保存在rdi)。
調試發現`MiFillSystemPtes`會對映射的內存頁進行檢查,如果地址位于頁表本身所在地址范圍,將導致內存映射失敗。(IDA中的地址與調試器中的不一樣,應該是頁表地址隨機化造成的)。



也就是說頁表空間中的所有地址都無法通過`MmMapLockedPagesSpecifyCache`進行映射(系統版本:Windows10 1909 18363.418)。

【問題】目前還沒有找到解決方案。未完待續!!
0x02 突破DEP防護
通過任意地址寫,修改內存頁對應的PTE表項,清除NX bit位,使目標內存頁改為可執行。
0x03 截獲程序控制流(控制RIP)
利用任意地址寫,篡改內存中的某個指針,進入內存態shellcode。
0x04 突破控制流防護(CFG)
用戶態CFG可能會攔截shellcode的執行,可以在內核態中patch `ntdll!LdrpValidateUserCallTarget`來繞過。
補充
【說明】ricerca security的研究人員在srv2模塊中發現了一個函數`Srv2SetResponseBufferToReceiveBuffer`,該函數將請求數據結構直接賦值給響應數據結構,也就是說我們破壞的請求數據結構將直接影響響應數據。此外利用IDA可以發現,`srv2!Smb2SetError`函數會調用`srv2!Srv2SetResponseBufferToReceiveBuffer`,也就是說當srv2.sys想發送錯誤消息時就會調用該函數。但是在漏洞利用過程中,似乎并不需要利用這點。
void Srv2SetResponseBufferToReceiveBuffer( SRV2_WORKITEM *workitem) { ... workitem->psbhResponse = workitem->psbhRequest; ...}
寫在最后
此處,CVE-2020-0796漏洞的分析告一段落了,其實還有很多問題沒有搞清楚,有些許遺憾,值得進一步深入研究,有志同道合的小伙伴可以一起探討。