作者: James Forshaw
原文: 鏈接
這個月微軟修復了3個不同的IE增強保護模式EPM的沙盒跳出漏洞,這些漏洞由我(原作者,下同)在8月披露。沙盒是Project Zero(我也參加了)中最主要的關注點所在,而且這也是攻擊者是否能實施一個遠程代碼攻擊的關鍵點所在。
三個BUG都在MS14-065中修復了,你可以在 here here here 讀到文章內容。
CVE-2014-6350也許是最有趣的內容了,不是因為BUG很特別,而是因為要利用這個BUG時,使用的技術點比較非常規。它是一個讀任意內存的漏洞,但是通過COM宿主展現出了一個潛在的攻擊方式。這個博文就要深入的介紹一下如何才能實施這個漏洞。
漏洞源于增強保護模式下的IE代理進程的權限問題。這個漏洞并不會影響到舊的保護模式,原因我稍后介紹。EPM沙盒中運行著不可信的Tab進程,因為在Tab進程里運行著網頁內容。而代理進程則負責在Tab進程需要的時候給它們提供必要的權限。Tab和代理進程通過基于IPC通信的DCOM來交互。
知道Windows訪問檢查是如何工作之后,我們應該可以確定你想要從EPM沙盒中的打開代理進程時要獲得哪些權限。在AppContainer中代碼的訪問檢查比Windows用的一套機制更復雜一些。除了通常的訪問檢查之外,還有兩個獨立的用于計算DACL可以提供最大的權限的額外檢查。第一個檢查是普通針對Token中用戶和組SID的,第二個是基于Compability SID的檢查。這兩組權限進行按位和運算之后(*譯注:取交集)就是可以給用戶的最大權限(這里忽略了ACE因為它跟這個討論并無關系)。
讓我們看看代理進程的DACL,下面是一個簡化表單,第一次的訪問檢查將匹配當前用戶的SID,也就是說會給予完全控制(紅色標記處),第二次檢測則會匹配IE的Compability SID(藍色標記處),這兩個權限取并集之后,則是只有“讀、查詢”的權限了。事實上這次微軟修復的就是讀內存的權限。
我們可以調用OpenProcess來把代理進程的PID傳入,并且請求PROCESS_VM_READ權限,這樣內核會返回給沙盒內進程一個句柄。通過這個句柄就可以用ReadProcessMemory來讀取代理進程的任意內存。 不過這個函數會正確處理讀無效的內存的操作,所以不會有任何崩潰。
#!c
BOOL ReadMem(DWORD ppid, LPVOID addr, LPVOID result, SIZE_T size) {
HANDLE hProcess = OpenProcess(PROCESS_VM_READ,
FALSE,
ppid);
BOOL ret = FALSE;
if(hProcess) {
ret = ReadProcessMemory(hProcess,
addr,
result,
size,
NULL);
CloseHandle(hProcess);
}
return ret;
}
但是如果你是Win64位系統的話,從32位的Tab進程中執行此漏洞的話,事情會變的有一些復雜,因為此時Wow64(*譯注:64位子系統)會登場,你不能直接使用ReadProcessMemory來讀取64位的代理進程的內存。但是你可以使用一些例如wow64ext的模塊來繞過這個限制,但是現在我們暫時不管它。
稍等,看一下PM,為什么這里不會有問題呢?在PM中只會做一個訪問檢查,所以我們可以獲得完全控制,但是因為微軟Vista之后引入的強制健壯性檢查(IL)特性,我們無法這么做。當一個進程試圖打開另一個進程的時候,內核會首先比較訪問者的IL和目標進程的系統ACL。如果訪問進程的IL比目標進程標記的健壯級別還要低,那么訪問權限會被限制成一個可用權限的一個很小的子集(例如PROCESS_QUERY_LIMITED_INFORMATION)。這將會阻止PROCESS_VM_READ或者更危險的權限,哪怕DACL已經檢查了都是如此。
好的,所以讓我們在Process Explorer中看看這個處于EPM沙盒中運行的程序,我們可以清楚的看到它的Token是處于低健壯級別的(下圖選中部分)。
但是奇怪的是,AppContainer訪問檢查看起來像是忽略了中以下級別的任何資源。如果一個資源通過了DACL檢查,那么它就會無視IL而被授予權限。這看起來像是對包括文件、注冊表鍵的任何安全資源都有效。我不知道這個為什么要這么設計,但是看起來像是一個弱點之處,如果這里IL正確檢查了也就沒有這事兒了。
Google事件追蹤(https://code.google.com/p/google-security-research/issues/detail?id=97)提供原始的PoC提供了一個通過代理的IPC接口來讀取系統任意文件的思路。通過讀取各進程的HMAC key,然后PoC因此偽造了一個有效的Token,然后通過CShDocVwBroker::GetFileHandle來打開文件。這個對EPM很有用,因為AppContainer會阻止讀取任意文件。但是,再怎么說,這個也就只是一個讀取,而不是寫入。理想情況我們應該能完全脫離Sandbox,而不是只是泄露一些文件的內容。
看起來是一個困難的工作,但是事實上還有更多的使用各進程秘密值(per-process secrets)的方式來讓自己變的更安全的技術。一個技術就是我最愛的Windows COM(說笑的)。而且最終,只要我們能泄露宿主進程的內容,就有一個可以在許多進程中引入遠程COM服務來執行代碼的方式。
COM被Windows的多個組件使用,比如Explorer Shell,或者本地的權限服務,例如BITS。每個用例都有不同的要求和限制,例如UI需要所有的代碼都在一個線程里面跑,否則操作系統會很不爽(譯注:程序員也不爽)。另一方面,一個功能類則可能是完全的線程安全的。為了支持這些需求,COM支持了一組線程模型,這個就解輕了程序員頭上的擔子(譯注:并沒有多少)。
套間中的一個對象定義了對象中的方法是如何被調用的。有兩類套間:1、單線程套間(STA)和多線程套間(MTA)(譯注:STA的特點是套間內永遠只有一個線程運行,具體參閱《COM本質論》,MTA則如字面意思)。當考慮到這些套間是如何被調用時,我們需要定義調用者和對象的關系。因此,我們將調用者的方法稱為“客戶端”,對象為“服務端”。
客戶端的套間由傳遞給CoInitializeEx(使用CoInitialize則默認為STA)的flag來決定。服務端的套間依賴于Windows注冊表中COM對象的線程模型定義。可以有如下3個設置:Free(多線程),Apartment(單線程)、Both。如果客戶端和服務端有兼容的套間(僅僅當服務端的對象支持兩個線程模型時),那么調用該對象的函數調用就會通過對象的虛函數表直接解引用到對應的函數上。但是,在STA調用MTA或者MTA調用STA時我們需要通過某些方式來代理調用,COM通過封送處理來處理此類操作。我們可以總結成下表。
封送通過進程的序列化方法來調用服務端對象。這在STA中尤其重要,因為STA里所有東西的調用都必須在一個線程里面完成。通常這個是由Windows 消息循環來完成,如果你的程序沒有窗口或者消息循環,STA會為你創建一個(譯注:通過“隱藏的窗口”)。當一個客戶端調用一個不兼容的套間中的對象時,它其實是調用一個特別的代理(譯注:proxy,不是上方的broker)對象。這個代理對象知道每個不通的COM接口和方法,包括什么方法需要什么參數。
代理接受到參數之后,會把他們通過內建的COM封送代碼把它們序列化,然后送到服務端。服務端側通過一個調用來反封送參數,然后調用合適的方法。任何返回值都會通過同樣的方法發送到客戶端。
結果是,這個模型在進程內執行的就像使用DCOM做進程間操作一樣好。同樣的代理封送技術和派發(dispatcher)可以用在進程或者計算機間。僅有的不同是封送的參數的傳送,他不再通過單個進程的進程內操作,而是通過本地RPC、命名管道甚至基于客戶端和服務端的位置,使用TCP去做。
好吧,這就是如何在內存里泄露信息的漏洞?要理解這些,我需要再介紹一個稱作“附限制線程封送模型”(FTM)的東西,這個是和之前的表格相關的。STA客戶端調用一個兼容多線程的服務端時,看起來客戶端通過這個代理-封送的過程來做通信是很費效能的。為啥他不直接調用對象?這個就是FTM要解決的問題。
當COM對象從一個不兼容的套間引用中實例化時,它必需向調用者返回一個對象的引用。這個和普通的call時是用的同樣的封送方法。事實上,這個機制同樣適用于調用對象的方法時,參數中帶COM對象的情況。使用這個機制傳遞引用的封送者建立了一個獨特的數據流叫OBJREF。這個數據流包含有所有的客戶端需要的,能建立一個代理對象并聯系服務端的信息。這個實例是COM對象的“按引用傳遞”文法。OBJREF的例子如下:
在一些場景下,盡管可以通過值傳遞內容,比如中止代理。當原始的客戶端套間中指定對象的所有的代碼都需要重新構造時,OBJREF流可以使用按值傳遞文法傳遞。當解封送者重新構造對象時,它會創建并初始化原始對象的一個全新拷貝,而不是獲取代理。一個對象可以通過IMarshal接口實現它自己的按值傳遞文法。
通過FRM使用的這個特性可以用來“欺騙”操作系統。即通過傳遞一個OBJREF中的已經在內存中序列化的對象,而不是傳遞原始對象數據的指針。當解封送時,這個指針會被反序列化并且返回給調用者。它表現得就像是一個“偽造的代理”,而且可以允許直接向原始對象發送請求。
現在如果你覺得不舒服的話也是可以理解的。因為封送與DCOM有一些不同的地方,那么進程內COM就是一個重大的安全漏洞嗎?很幸運,不是這樣的。FTM不僅會發送指針值,還會確保封送的數據的反封送操作僅僅會在同一個進程內執行。它通過生成一個按進程的16字節隨機值,并且把他附在序列化數據之后。當反序列化時FTM會檢查這個值,看看是不是當前進程保存的這個值。如果兩個值不一樣,它會拒絕反序列化。這個操作的前提是攻擊者無法猜測或者破解這個值,因此在這個前提下FTM不會反封送任何它覺得不對的指針。但是這個威脅模型在我們可以讀取任意內存時其實并沒有用,所以我們才有了這么一個漏洞。
FTM的這個實現是通過combase.dll的CStaticMarshaler類來完成的。在win7下則是ole32.dll的CFreeMalshaler類。看看CstaticMarshaler::UnmarshallInterface的代碼,大致如下:
#!c
HRESULT CStaticMarshaler::UnmarshalInterface(IStream* pStm,
??????????????????????????????????????????? REFIID riid,
??????????????????????????????????????????? void** ppv) {
?DWORD mshlflags;
?BYTE ?secret[16];
?ULONGLONG pv;
?if (!CStaticMarshaler::_fSecretInit) {
?? return E_UNEXPECTED;
?}
?pStm->Read(&mshlflags, sizeof(mshlflags));
?pStm->Read(&pv, sizeof(p));
?pStm->Read(secret, sizeof(secret));
?if (SecureCompareBuffer(secret, CStaticMarshaler::_SecretBlock)) {
? ? *ppv = (void*)pv;
?? if ((mshlflags == MSHLFLAGS_TABLESTRONG)
? ? || (mshlflags == MSHLFLAGS_TABLEWEAK)) {
((IUnknown*)*ppv)->AddRef();
?? }
? ? return S_OK;
?} else {
? ? return E_UNEXPECTED;
?}
}
注意這個方法會檢測秘密值(secret)是否已經初始化,這可以阻止攻擊者使用一個未初始化的秘密值(也即0)。還有需要注意的是需要使用一個安全的字符比較函數以免受到針對秘密值檢查的時差攻擊。事實上這是一個非向后移植的修復。在win7上,字符串比較使用的是repe cmdsd指令,這個并不是線性時間的比較(*譯注:指非原子操作)。因此在win7上你也許可以一次旁路時差攻擊,雖然我覺得這個肯定巨費事。
最后這個結構看起來像是:
為了執行我們的代碼,我們需要在我們的com對象中調用IMarshal接口。特別是我們需要執行兩個函數,Imarshal::GetUnmarshalClass,當重構建代碼的時候,會用到它返回要使用的COM對象的CLSID。IMarshal::MarshalInterface,用來為漏洞打包合適的指針值。簡單的例子如下:
#!c
GUID CLSID_FreeThreadedMarshaller =
{ 0x0000033A, 0x0000, 0x0000,
{ 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, } };
HRESULT STDMETHODCALLTYPE CFakeObject::GetUnmarshalClass(
REFIID riid,
void *pv,
DWORD dwDestContext,
void *pvDestContext,
DWORD mshlflags,
CLSID *pCid)
{
memcpy(pCid, &CLSID_FreeThreadedMarshaller,
sizeof(CLSID_FreeThreadedMarshaller));
return S_OK;
}
HRESULT STDMETHODCALLTYPE CFakeObject::MarshalInterface(
?????? IStream *pStm,
?????? REFIID riid,
?????? void *pv,
?????? DWORD dwDestContext,
?????? void *pvDestContext,
?????? DWORD mshlflags)
{
? pStm->Write(&_mshlflags, sizeof(_mshlflags), NULL);
? pStm->Write(&_pv, sizeof(_pv), NULL);
? pStm->Write(&_secret, sizeof(_secret), NULL);
? return S_OK;
}
夠簡單了吧,我們看看怎么用它。
有了上面這些背景知識,也是時間脫離沙盒了。為了讓代碼脫離沙箱,在代理進程中執行,有三個我們需要做的事情:
獲取中介進程中FTM各進程秘密值。
構建一個假虛表和假對象指針。
封送一個對象到代理進程以執行代碼。
這個很簡單,我們知道這個值存在內存的位置,因為combase.dll在沙盒進程和代理進程中的加載地址是一樣的。盡管Vista之后引入了ASLR,系統DLL只是在啟動之后會隨機化一次,因此combase.dll會在每個進程的同一個地方被載入。這是Windows的ASLR的弱點,特別是對本地提權而言就更是如此了。但是如果你從普通的IE操作中讀取這個值的話你會發現一個問題:
很不幸FTM還沒初始化,這意味著再著急我們都利用不了。我們該怎么讓它在沙盒進程中初始化起來呢?我們只需要讓中介進程做更多的COM操作,特別是一些會引入FTM的操作。
我們可以使用打開/保存對話框,這個對話框是在Explorer Shell中實現的(shell32.dll),當然它是使用了COM的。而且它還是一個UI,因此他肯定會使用一個STA,但是會使用自由線程對象最終也會使用FTM。所以讓我們試試看,手動打開一個對話框看看效果。
干得不錯。選擇它的實際理由是因為我們可以使用IEShowSaveFileDialog API來在沙盒進程中啟動這個對話框(這個API通常由多個代理調用實現)。顯然這個會顯示一些UI出來,但是并不重要,因為對話框顯示的時候,FTM已經初始化過了,用戶已經沒啥要做的了。
現在我們可以硬編碼一些combase.dll的偏移。當然你也可以動態的在沙盒進程中初始化FTM然后通過內存搜索找到它的位置。
下一個挑戰是讓我們的假虛表進入代理進程。因為我們可以讀取到代理進程的內存,所以我們可以確定的使用代理進程的API做一些例如堆淹沒(heap flooding)的操作,但是有沒有更簡單的方法?IE代理進程和沙盒進程有一個共享的內存節,他們用它來傳遞設置和信息。這些節對沙盒進程來說有部分是可寫入的,因此我們需要做的是找到對應的中介進程的映射,然后修改成我們想要的內容。在這個的例子里,使用了\Sessions\X\BaseNamed\Objects\URLZones_user (X是Session ID,user是用戶名),雖然它映射到了代理進程,對沙盒程序也是可寫入的,但是還需要一些東西。
我們不需要暴力的去找這個節,我們需要使用PROCESS_QUERY_INFORMATION權限打開進程,然后使用VirtualQueryEx來枚舉所有映射的內存節,因為它會返回大小,所以我們可以快速的跳過沒映射的區域。然后我們可以找一個寫入區域的保護值(*譯注:canary value,用于檢測緩沖區溢出的值)來確定釋放地址。
#!c
DWORD_PTR FindSharedSection(LPBYTE section, HANDLE hProcess)
{
? // No point starting at lowest value
? LPBYTE curr = (LPBYTE)0x10000;
? LPBYTE max = (LPBYTE)0x7FFF0000;
? memcpy(§ion[0], "ABCD", 4);
? while (curr < max)
? {
??? MEMORY_BASIC_INFORMATION basicInfo = { 0 };
??? if (VirtualQueryEx(hProcess, curr,
?????????????? &basicInfo, sizeof(basicInfo)))
??? {
?????? if ((basicInfo.State == MEM_COMMIT)
????????? && (basicInfo.Type == MEM_MAPPED)
????????? && (basicInfo.RegionSize == 4096))
?????? {
????????? CHAR buf[4] = { 0 };
????????? SIZE_T read_len = 0;
????????? ReadProcessMemory(hProcess, (LPBYTE)basicInfo.BaseAddress,
??????????????????????????? buf, 4, &read_len);
????????? if (memcmp(buf, "ABCD", 4) == 0)
????????? {
???????????? return (DWORD_PTR)basicInfo.BaseAddress;
????????? }
??????? }
??????? curr = (LPBYTE)basicInfo.BaseAddress + basicInfo.RegionSize;
???? }
???? else
???? {
??????? break;
???? }
? }
? return 0;
}
決定了需要在共享內存的哪里創建虛表和假對象之后,我們應該怎么調用這個虛表?你可能會想到使用一個ROP鏈(*譯注:返回導向),但是顯然我們不需要這么做。因為所有的COM調用都使用stdcall,所以所有參數都放在了棧上,所以我們可以幾乎通過this指針來調用所有的東西。
有一個用攻擊方式是使用類似LoadLibraryW的函數,然后構建一個會加載指向相對路徑DLL的假對象。因為虛表指針并沒有任何NULLCHAR(這個導致了在64位系統上難以使用這個方式去攻擊),因此我們可以把它(虛表)從路徑中移除,這會導致它加載那個庫。為了解決這個問題,我們可以把低16位設置成任何隨機值,而且因為高16位并不在我們掌控之中,所以它幾乎不會以0結束,因為Windows的空頁保護禁止分配低64KB的地址。最后我們的假對象看起來像是:
當然,如果你查看它IUnknown接口的定義,你會發現這個對象的虛表中僅僅AddRef和Release有正確的signature。如果代理進程在對象上調用QueryInterface的話,這時候signature肯定是不正確的。在64位系統上因為傳參的方式不同,這個倒沒啥問題。但是在32位系統上這個卻會導致棧無法對齊,這不是我想要的結果。但是其實并沒事,如果這是一個問題的話肯定有解決方案,或者干脆在代理進程中調用ExitProcess就好了。但是,注入一個對象時,我們要選擇一個合適的方式,如果對象可能完全不會調用它,也就不會出現這個問題了。這就是我們接下來要做的。
最終也是一個簡單的點,因為沙盒中使用的代理進程的所有接口都使用COM,因此我們需要做的就是找到一個只調用IUnknown的指針,然后把我們的假封送對象給它。為了達到這個目的,我發現你可以請求Shell Document View的IEBrokerAttach接口,它只有如下一個函數原型:
HRESULT AttachIEFrameToBroker(IUnknown* pFrame);
為了讓我們的指針到達中介進程之前做的更完美,我們會預設好一個frame,因此當不帶pFrame對象時調用這個方法會立刻失敗。因此我們并不需要擔心會有QueryInterface會被調用,我們的漏洞代碼會在這個函數被調用之前就被執行,所以我們并不關心QueryInterface導致的問題。
所以,我們通過調用這個方法來創建我們的假對象。這將導致COM開始封送我們的代碼到OBJREF對象中。最終在IPC通道的另一頭,也就是COM開始反封送的地方停止。這將調用FTM的UnmarshalInterface方法,而且我們已經成功的找到了秘密值,因此我們可以愉快的解包我們的假對象指針。最終,這個方法會調用對象上的AddRef,這時我們也可以傳遞mshlflags到MSHLFLAGS_TABLESTRONG。這將調用LoadLibraryW,而且它的“路徑”參數是我們的假對象,這將隨機加載一個DLL到代理進程中。所有需要做的就是彈一個calc,現在任務完成。
最終,真實的服務斷函數會被調用,但是會立刻返回一個錯誤。一個漂亮的沙盒跳出,盡管它需要大量的代碼來支持。
所以我在原來的事件追蹤(https://code.google.com/p/google-security-research/issues/detail?id=97)中添加了一個新的PoC,這可以在32位Windows 8.1系統上執行攻擊(顯然你不能打MS14-065補丁)。在64位Windows 8.1上它執行的不是太好,因為中介進程是64位的,盡管Tab進程可能還是32位的。如果你想讓他在64位上運行,你需要再試一試,但是因為你可以控制RIP,所以并不是什么太難的事情。如果你想要在最新的機器上試驗,PoC中也有一個工具,SetProcessDACL,這可以修改一個程序的DACL,給它重新加一個帶讀權限的IE Compability SID。
希望這個可以給你一些對待類似漏洞的解決方法。還有,不要因為這個抱怨COM,因為這個跟它沒啥關系。這只是用來演示一個相對無害的內存任意讀取最終如何打破層層防守演變成任意代碼執行和權限提升的例子。