如何藉助COM對Windows受保護程序進行程式碼注入(第二部分)
概述
在之前的文章中,我們討論了一種將任意程式碼注入PPL-Windows TCB程序的技術,該技術結合了我此前發現並向Microsoft報告的許多漏洞。由於一些原因,我們之前討論的技術不適用於具有較強保護的受保護程序(Protected Processes,PP)。本篇文章主要為了解決這一問題,並提供詳細資訊,說明如何在不具備管理員許可權的情況下劫持完整的PP-Windows TCB流程。本文側重於技術探討,我們嘗試是否能在一個完整的PP中執行程式碼,因為在PP中通過PPL可以做的事情並不多。
首先,我們對上一次攻擊實驗進行簡單回顧,目前我們能夠確定一個以PPL執行的程序,並且該程序暴露了一個COM服務。具體來說,該服務是“.NET執行時優化服務”(.NET Runtime Optimization Service),該服務包含在.NET框架中,並在CodeGen級別使用PPL將快取的簽名級別應用於AOT編譯的DLL,從而允許它們用於使用者模式程式碼完整性(UMCI)。通過修改COM代理配置,可能導致型別混淆的發生,從而允許我們劫持已知DLL配置,來載入任意DLL。一旦在PPL中成功執行程式碼,我就可以濫用快取簽名功能中的漏洞,來建立一個簽名,並載入到任何PPL中的DLL,從而升級到PPL-Windows TCB級別。
尋找新目標
我首先考慮對完整的PP進行漏洞利用,並藉助我們在PPL-Windows TCB上執行程式碼時獲得的額外訪問許可權。大家可能認為,可以濫用快取的已簽名DLL來繞過安全檢查,從而載入到完整的PP中。但不幸的是,核心的程式碼完整性模組忽略了完整PP的快取簽名級別。那麼,用已知DLL呢?如果我們在PPL-Windows TCB中以管理員許可權執行程式碼,那麼我們可以直接寫入已知DLL物件目錄,並嘗試讓PP載入任意DLL。然而,正如我在上一篇部落格中提到的,這個方法也不起作用,因為完整的PP忽略了已知DLL。即使確實載入了已知的DLL,我們的目標也不是通過獲得管理員許可權來將程式碼注入程序。
因此,我決定重新研究之前編寫的Shell/">PowerShell指令碼,以發現哪些可執行檔案將作為完整的PP在什麼級別執行。在Windows 10 1803上,有大量可執行檔案以PP-Authenticode級別執行,但只有4個可執行檔案以更高許可權級別啟動,如下表所示。
C:\windows\system32\GenValObj.exe(執行級別Windows) C:\windows\system32\sppsvc.exe(執行級別Windows) C:\windows\system32\WerFaultSecure.exe(執行級別Windows TCB) C:\windows\system32\SgrmBroker.exe(執行級別Windows TCB)
由於我們目前還沒有從PP-Windows級別提升到PP-Windows TCB級別的方法,無法像之前對PPL進行的操作那樣,因此在這4個可執行檔案中,只有WerFaultSecure.exe和SgrmBroker.exe這兩個檔案是我們潛在的目標。我將這兩個可執行檔案與已知的COM服務註冊相關聯,並嘗試尋找這些可執行檔案是否暴露了COM的攻擊面。回想我上次利用的.NET可執行檔案並沒有註冊其COM服務,因此我進行了一些基本的逆向工程,來尋找COM的使用。
我們發現,SgrmBroker可執行檔案似乎沒有什麼用,這是一個獨立的使用者模式應用程式的封裝,作為Windows Defender System Guard的一部分,用於實現系統的執行時環境證明(Runtime Attestation),並且不需要呼叫任何COM API。WerFaultSecure似乎也不會呼叫COM,但它可以載入COM物件。因為我瞭解到,Alex Ionescu使用我原來的COM指令碼小程式(Scriptlet)程式碼執行攻擊,通過劫持WerFaultSecure中的COM物件載入過程,成功獲取了PPL-Windows TCB級別。儘管WerFaultSecure沒有公開的服務,但如果它可以初始化COM,那麼我是否也可以濫用它來執行任意程式碼呢?要掌握COM的攻擊面,首先我們需要了解COM是如何實現程序外伺服器(Out-of-process COM Servers)和遠端處理(COM Remoting)的。
深入研究COM Remoting內部
COM客戶端和COM伺服器之間的通訊,是通過MSRPC協議進行的,該協議基於Open Group的DCE/RPC協議。對於本地通訊,是通過高階本地過程呼叫埠(ALPC)進行傳輸。對於更高級別的通訊,客戶端和伺服器之間的通訊過程如下圖所示:
為了使客戶端能夠找到伺服器的位置,該程序在RPCSS中使用DCOM啟用器(DCOM Activator)來註冊ALPC終端。該終端與伺服器的物件匯出ID(Object Exporter ID,OXID)共同註冊,後者是由RPCSS分配的64位隨機生成編號。當客戶端想要連線伺服器時,必須首先要求RPCSS將伺服器的OXID解析為RPC終端。在知道ALPC RPC終端的情況下,客戶端可以連線到伺服器,並呼叫COM物件上的方法。
OXID值可以在程序外(OOP)COM啟用結果中找到,也可以在編組後的物件引用(OBJREF)結構中找到。客戶端在RPCSS的IObjectExporter RPC介面上呼叫ResolveOxid方法。ResolveOxid的原型如下:
interface IObjectExporter { // ... error_status_t ResolveOxid( [in] handle_t hRpc, [in] OXID* pOxid, [in] unsigned short cRequestedProtseqs, [in] unsigned short arRequestedProtseqs[], [out, ref] DUALSTRINGARRAY** ppdsaOxidBindings, [out, ref] IPID* pipidRemUnknown, [out, ref] DWORD* pAuthnHint );
在原型中,我們可以看到要解析的OXID會在pOxid引數中傳遞,伺服器返回一個Dual String Bindings陣列,表示要連線到此OXID值的RPC終端。此外,伺服器還返回另外兩條資訊,一個是我們可以安全忽略的身份驗證級別提示(pAuthnHint),另一個是不應該忽略的IRemUnknown介面的IPID(pipidRemUnknown)。
IPID是一個名為介面程序ID的GUID值。它表示伺服器內部COM介面的唯一識別符號,並且需要與正確的COM物件進行通訊,因為它允許單個RPC終端通過一個連線複用多個介面。IRemUnknown介面是每個COM伺服器必須實現的預設COM介面,該介面用於查詢現有物件上的新IPID(使用RemQueryInterface),並維護遠端物件的引用計數(使用RemAddRef和RemRelease方法)。無論是否匯出實際的COM伺服器,是否可以通過解析伺服器的OXID來發現IPID,這個介面都始終存在。因此,我想知道這個介面還支援其他哪些方法,看看是否有哪些地方可以用於獲得程式碼執行。
COM執行時程式碼負責維護一個包含所有IPID的資料庫,它會在收到呼叫一個方法的請求後查詢伺服器物件。如果我們知道這個資料庫的結構,那麼就能夠發現IRemUnknown介面的實現位置,也就能解析它的方法,並找出該介面支援的其他功能。幸運的是,我使用OleViewDotNet工具,特別是PowerShell模組中的Get-ComProcess命令,完成了對資料庫格式的逆向工程。如果我們對使用COM的程序執行該命令,但實際上沒有實現COM伺服器(例如記事本notepad),就可以嘗試識別出正確的IPID。
在上圖中,我們看到實際上有兩個IPID匯出,分別是IRundown和一個Windows.Foundation介面。我們忽略Windows.Foundation,重點研究IRundown。事實上,如果我們對任何COM程序執行相同的檢查,都會發現它們也匯出了IRundown介面。這樣一來,IRundown就看起來非常“誘人”了。如果我們將ResolveMethodNames和ParseStubMethods引數傳遞給Get-ComProcess,該命令將嘗試解析介面的方法引數,並根據公共符號查詢名稱。通過解析的介面資料,我們可以將IPID物件傳遞給Format-ComProxy命令,獲得IRundown介面的基本文字描述。在經過整理後,IRundown介面如下所示:
[uuid("00000134-0000-0000-c000-000000000046")] interface IRundown : IUnknown { HRESULT RemQueryInterface(...); HRESULT RemAddRef(...); HRESULT RemRelease(...); HRESULT RemQueryInterface2(...); HRESULT RemChangeRef(...); HRESULT DoCallback([in] struct XAptCallback* pCallbackData); HRESULT DoNonreentrantCallback([in] struct XAptCallback* pCallbackData); HRESULT AcknowledgeMarshalingSets(...); HRESULT GetInterfaceNameFromIPID(...); HRESULT RundownOid(...); }
這個介面是IRemUnknown的超集,它不僅實現了RemQueryInterface等方法,還添加了一些額外的方法。真正讓我感興趣的,是其中的DoCallback和DoNonreentrantCallback方法,從名稱上來看它們似乎會執行某種型別的“回撥”。也許我們可以對這些方法進行濫用?我們使用了一些逆向工程的方法,對DoCallback進行了分析,具體如下:
struct XAptCallback { void* pfnCallback; void* pParam; void* pServerCtx; void* pUnk; void* iid; intiMethod; GUIDguidProcessSecret; }; HRESULT CRemoteUnknown::DoCallback(XAptCallback *pCallbackData) { CProcessSecret::GetProcessSecret(&pguidProcessSecret); if (!memcmp(&pguidProcessSecret, &pCallbackData->guidProcessSecret, sizeof(GUID))) { if (pCallbackData->pServerCtx == GetCurrentContext()) { return pCallbackData->pfnCallback(pCallbackData->pParam); } else { return SwitchForCallback( pCallbackData->pServerCtx, pCallbackData->pfnCallback, pCallbackData->pParam); } } return E_INVALIDARG; }
這個方法非常有趣,其中包含一個指向要呼叫的方法的指標結構和一個任意引數。如果想要呼叫任意方法,唯一的限制就是我們必須提前知道隨機生成的GUID值、程序憑據(Secret)和伺服器上下文地址。檢查每個程序的隨機值,是COM API中的常見安全模式,通常用於將功能限制在程序中的呼叫方。
那麼,DoCallback的作用是什麼?COM執行時會為每個初始化的COM建立一個新的IRundown埠。這對於不同部分之間呼叫方法來說非常重要,比如從MTA呼叫STA物件,就需要從正確的部分中呼叫相應的IRemUnknown方法。因此,開發人員在其中添加了一些方法,這些方法對於不同部分之間的呼叫是非常有效的,包括“任意呼叫”方法。它由COM執行時在內部使用,並通過CoCreateObjectInContext等方法間接公開。為防止DoCallback方法被濫用,應該檢查每個程序的憑據,並限制只有程序內部可以進行呼叫。
濫用DoCallback
現在,我們有一個原語可以在任何程序中執行任意程式碼,該程序通過呼叫DoCallback方法來初始化COM,並且該方法應該具有PP許可權。為了成功呼叫任意程式碼,我們需要知道以下4個資訊:
1、COM程序正在偵聽的ALPC埠;
2、IRundown介面的IPID;
3、初始化程序的憑據(Secret)值;
4、有效的上下文地址,理想情況下應該與GetCurrentContext在同一RPC執行緒上返回的值相同。
如果程序公開了COM伺服器,那麼獲取ALPC埠和IPID就非常容易,因為二者都將在OXID解析期間提供。不幸的是,WerFaultSecure沒有公開我們建立的COM物件,因此這是一個需要解決的問題。要提取程序憑據和上下文值,就需要讀取程序記憶體的內容。那麼另一個問題就來了,PP的一個安全特性就是阻止非PP程序從PP程序中讀取記憶體。我們接下來要解決這兩個問題。
即使擁有管理員許可權,也不允許直接從PP程序讀取記憶體。我們理論上可以載入一個驅動程式,但這樣做會完全打破PP,因此需要考慮如何在不需要核心程式碼執行的情況下完成任務。
首先,也是最簡單的,我們可以從RPCSS中提取ALPC埠和IPID。RPCSS服務不會受到保護(甚至是PPL),所以只需知道該值儲存在記憶體中的位置。對於上下文指標,我們應該能夠強制執行該位置,如果選擇32位版本的WerFaultSecure,會稍微容易一些。
提取憑據的過程則有一些困難。憑據會在可寫記憶體中被初始化,因此一旦被修改,就會在程序的工作集中結束。由於頁面(Page)沒有鎖定,所以只要記憶體條件正確,就能夠進行分頁。因此,如果我們可以強制將包含憑據的頁面分頁到磁碟上,那麼即使是來自PP程序,我們也能夠讀取。作為管理員,我們可以執行以下操作來竊取憑據:
1、確保憑據已經初始化,同時頁面已經被修改;
2、強制程序修改其工作集,確保包含憑據的修改後頁面最終被分頁到磁碟上;
3、使用NtSystemDebugControl系統呼叫建立核心記憶體崩潰轉儲檔案。崩潰轉儲可以由管理員建立,並且不啟用核心除錯,其中將包含核心中的所有實時記憶體。這一過程不會使系統崩潰。
4、解析包含憑據的頁表條目(Page Table Entry,PTE)故障轉儲,PTE應該能夠暴露分頁資料在磁碟上的頁面檔案中的位置;
5、開啟包含頁面檔案的卷,並進行讀取訪問,解析其中的NTFS結構,查詢頁面檔案,並查詢分頁資料提取憑據。
針對我們要執行的攻擊,這一過程似乎太過複雜,所以我們想嘗試另一種解決方案。
利用WerFaultSecure的原始用途
到目前為止,我一直在說WerFaultSecure是一個可以用來在PP/PPL中執行任意程式碼的程序。但是,我沒有透徹地說明為什麼這個程序最高可以在PP/PPL許可權執行。Windows錯誤報告服務(Windows Error Reporting)使用WerFaultSecure從受保護程序建立故障轉儲。所以,為了確保它能夠轉儲任何可能的使用者模式PP,它就需要在更高的PP級別許可權執行。這麼說來,我們可以讓WerFaultSecure建立自身的崩潰轉儲,並洩漏程序記憶體中的內容,從而允許我們提取需要的任意資訊。
我們之所以無法使用WerFaultSecure,是因為它在將崩潰轉儲寫入磁碟之前,就先對其進行加密。這種加密方式只能由Microsoft來解密,使用了非對稱加密來保護提供給Microsoft WER Web服務的隨機會話金鑰。除了尋找這一實現過程中的漏洞,以及對新加密方式所使用的原語進行攻擊之外,看起來似乎沒有一個更好的方法。
但是,2014年,Alex在NoSuchCon上發表了關於PPL的研究成果,並討論了他在研究WerFaultSecure如何建立加密轉儲檔案時發現的漏洞。該過程包含兩個步驟,首先匯出未加密的故障轉儲,然後加密崩潰轉儲。在這個過程中,有可能竊取到未經加密的崩潰轉儲。根據其呼叫WerFaultSecure的方式,它接受了兩個檔案控制代碼,一個用於未加密的轉儲,另一個用於加密轉儲。通過直接呼叫WerFaultSecure,可以保證未加密的轉儲永遠不會被刪除,這也就意味著我們甚至不需要進行加密過程的競態。
在這裡存在一個漏洞,該漏洞於2015年被修復(MS15-006)。在修復後,WerFaultSecure直接對故障轉儲進行加密,並且永遠不會在未加密的磁碟上結束。由此我們開始思考,是否可以從Windows 8.1上獲取存在漏洞版本的WerFaultSecure,並在Windows 10上執行。我從Microsoft網站上下載了Windows 8.1的ISO檔案,並提取了二進位制檔案,並對其進行測試,結果如下:
結果證明,從Windows 8.1中獲得的存在漏洞WerFaultSecure版本,在Windows 10上能成功以PP-Windows TCB級別執行。其原因我們還不清楚,但考慮PP的安全加固方式,所有的許可權都基於可執行檔案的簽名來判斷。由於可執行檔案的簽名仍然有效,因此作業系統會信任該檔案,從而使其在請求的保護級別中執行。我們認為,Windows中有一些方法來阻止特定的可執行檔案,但他們恐怕並不能撤銷自己的簽名證書。考慮到Microsoft已經在Windows 8升級到8.1之後,為了阻止繞過WinRT UMCI簽名的降級攻擊,添加了一個新的EKU。我們認為,在證書中,也應該儲存了一個作業系統二進位制檔案的EKU,用於表明作業系統的版本。
在參考Alex的簡報,並進行了逆向分析之後,我能夠列舉出為了執行PP轉儲,需要傳遞給WerFaultSecure程序的引數:
·/h 啟用安全轉儲模式
· /pid {pid} 指定要轉儲的程序ID
· /tid {tid} 指定要轉儲的執行緒ID
· /file {handle} 為未加密的故障轉儲指定可寫檔案的控制代碼
· /encfile {handle} 為加密的故障轉儲指定可寫檔案的控制代碼
· /cancel {handle} 為應該取消的轉儲指定事件的控制代碼
· /type {flags} 指定MIMDUMPTYPE標誌以呼叫MiniDumpWriteDump
這樣一來,我們就擁有了要完成漏洞利用所需要的一切。我們不需要管理員許可權,就可以將舊版本的WerFaultSecure作為PP-Windows TCB啟動。我們可以使用初始化的COM轉儲另一個WerFaultSecure副本,並使用故障轉儲來提取我們需要的所有信息,包括通訊所需的ALPC埠和IPID。我們不需要自行編寫崩潰轉儲解析器,因為可以使用Windows附帶的Debug Engine API。一旦我們提取了所需的所有資訊,就可以呼叫DoCallback,並呼叫任意程式碼。
組合實現程式碼注入
要完成漏洞利用,接下來還有兩個問題需要解決——如何讓WerFaultSecure啟動COM?我們呼叫什麼可以在PP-Windows TCB程序中執行任意程式碼?
我們首先來解決第一個問題,如何啟動COM。正如我之前所提到的,WerFaultSecure沒有直接呼叫任何COM方法。經過與Alex的討論,我們發現訣竅是讓WerFaultSecure轉儲AppContainer程序,這會導致對FaultRep DLL中的方法CCrashReport :: ExemptFromPlmHandling的呼叫,從而載入CLSID {07FC2B94-5285-417E-8AC3-C2CE5240B0FA},將被解析為未記錄的COM物件。重要的是,這將允許WerFaultSecure初始化COM。
但不幸的是,在設定COM遠端處理(COM Remoting)時,並沒有如預想的那樣。如果僅僅載入COM物件,並不能足以初始化IRundown介面或RPC終端。這是有道理的,如果所有COM呼叫都是在同一個部分進行編碼,那麼為什麼還要為COM初始化整個遠端處理程式碼呢?在這種情況下,即使我們可以使得WerFaultSecure載入COM物件,它也不符合設定遠端處理的條件。針對這種情況,有一種方法就是將COM註冊從程序內(In-process)類更改為OOP類。如下圖所示,首先從HKEY_CURRENT_USER查詢COM註冊,這意味著我們可以在不需要管理員許可權的情況下對它進行劫持。
不幸的是,檢視程式碼並不起作用,下面是精簡後的程式碼:
HRESULT CCrashReport::ExemptFromPlmHandling(DWORD dwProcessId) { CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); IOSTaskCompletion* inf; HRESULT hr = CoCreateInstance(CLSID_OSTaskCompletion, NULL, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&inf)); if (SUCCEEDED(hr)) { // Open process and disable PLM handling. } }
這段程式碼將標誌CLSCTX_INPROC_SERVER傳遞給CoCreateInstance。該標誌將COM執行時的查詢程式碼範圍限制為僅查詢程序內類註冊。即使我們用一個OOP類替換註冊,COM執行時也會忽略它。幸運的是,還有另一種方法,程式碼是使用帶有CoInitializeEx的COINIT_APARTMENTTHREADED標誌將當前執行緒的COM部分初始化為STA。檢視COM物件的註冊,其執行緒模型設定為“Both”。在實際中,也就意味著物件支援直接從STA或MTA呼叫。
但是,如果將執行緒模型設定為“Free”,則該物件僅支援來自MTA的直接呼叫,這意味著COM執行時必須啟用遠端處理,在MTA中建立物件(使用類似於DoCallback的方法),然後從原始部分編組到特定物件的呼叫。COM啟動遠端處理後,會初始化所有遠端功能,包括IRundown。由於我們可以劫持伺服器註冊,現在我們就只需要更改執行緒模型,這將導致WerFaultSecure啟動我們可以利用的COM遠端處理。
接下來,我們解決第二個問題。我們可以在程序中呼叫什麼,來執行任意程式碼?我們使用DoCallback呼叫的任何內容,都必須滿足以下條件,從而避免未定義的行為:
1、只需要一個指標大小的引數;
2、如果需要,只返回呼叫的較低32位作為HRESULT;
3、由於CFG機制的保護,它必須是有效的間接呼叫目標。
由於WerFaultSecure並沒有特殊的許可權,因此任何DLL匯出函式都至少應該是一個有效的間接呼叫目標。LoadLibrary明顯符合我們的標準,因為它需要一個引數,是一個指向DLL路徑的指標,並且我們並不關心返回值。我們不能載入任意DLL,這個DLL一定要具有正確的簽名,那麼我們如何來劫持已知DLL呢?
前面我提到過,PP無法從已知DLL載入,因為LdrpKnownDllDirectoryHandle全域性變數的值在程序初始化期間始終設定為NULL。當DLL載入程式檢查是否存在已知DLL時,如果控制代碼為NULL,就會立即返回。但是,如果控制代碼非空,就會執行常規檢查,就像在PPL中一樣。如果程序對映來自現有節物件的映像,那麼就不會執行其他安全檢查。因此,如果我們可以更改LdrpKnownDllDirectoryHandle全域性變數,使其指向整合到PP的目錄物件,就可以使其載入任意DLL。
最後一個難題,是找到一個匯出的函式,我們可以呼叫它來將任意值寫入全域性變數。事實證明,這比想象的要更難一些。理想的函式,是使用單個指標值作為引數,並寫入該位置的函式。經過一些試錯後,我決定使用USER32中的SetProcessDefaultLayout和GetProcessDefaultLayout。Set函式使用單個值作為其函式(實際上是在核心中,但這已經足夠了)。然後,get方法將該值寫入任意指標位置。這並不完美,因為我們可以設定並寫入的值僅限於數字0-7。但是,通過在get呼叫中偏移指標,我們可以寫入0x0?0?0?0?的形式,其中問號代表0-7之間的數值。對於這個值,只需要引用我們控制的程序的控制代碼,所以我們可以輕鬆的製作滿足要求的控制代碼。
總結
總而言之,在不具有管理員許可權的情況下,如果希望程序在PP-Windows TCB內部執行任意程式碼,我們可以執行以下操作:
1、建立一個虛假的已知DLL目錄,複製控制代碼,直到其滿足適合通過Get/SetProcessDefaultLayout寫入的模式。將控制代碼標記為可繼承。
2、在ThreadingModel設定為“Free”的情況下,為CLSID {07FC2B94-5285-417E-8AC3-C2CE5240B0FA}建立COM物件劫持。
3、在PP-Windows TCB級別,啟動Windows 10 WerFaultSecure,並從AppContainer程序請求崩潰轉儲。在建立程序期間,必須新增虛假已知DLL,以確保它能繼承到新的程序。
4、等待COM初始化,使用Windows 8.1 WerFaultSecure轉儲目標的程序記憶體。
5、解析崩潰轉儲,以發現IRundown的程序金鑰、上下文指標和IPID。
6、連線到IRundown介面,並使用DoCallback和Get/SetProcessDefaultLayout將LdrpKnownDllDirectoryHandle全域性變數修改為第1步中建立的控制代碼值。
7、再次呼叫DoCallback,來呼叫LoadLibrary,並從虛假的已知DLL中載入一個名稱。
上述操作過程適用於所有Windows 10版本,包括1809。值得注意的是,呼叫DoCallback可以用於任何能夠讀取記憶體內容並且程序已經初始化COM遠端處理的程序。例如,如果在特權COM服務中存在任意記憶體洩漏漏洞,就可以利用這一攻擊方式將任意記憶體讀取轉換為任意記憶體執行。
至此,我的一系列Windows受保護程序程式碼注入的分享就結束了。我認為,防止使用者攻擊共享資源(例如登錄檔和檔案)的程序註定都會失敗。這可能也正是Microsoft不支援PP/PPL作為安全邊界的原因。隔離的使用者模式似乎是一個更加強大的原語,但它也伴隨了額外的資源需求,PP/PPL並不是最主要的部分。我們預計,Windows 10的後續更新版本(1809版本之後)可能會嘗試通過某種方式緩解這些攻擊,但我們應該還是可以找到繞過的方法。