一、前言
之前我發(fā)表過一篇文章介紹了7-Zip的CVE-2017-17969以及CVE-2018-5996漏洞,后面我又繼續(xù)花了點時間分析了反病毒軟件。碰巧的是,我又發(fā)現(xiàn)了一個新的bug,該漏洞(與之前兩個bug一樣)最終會影響到7-Zip。由于反病毒軟件廠商還沒有發(fā)布安全補丁,因此我會在本文更新時添加受影響的產(chǎn)品名稱。
二、簡介
7-Zip的RAR代碼主要基于最近版本的UnRAR代碼,但代碼的高層部分已經(jīng)被大量修改過。我曾經(jīng)在之前的一些文章中提到過,UnRAR的代碼非常脆弱,因此,對這份代碼的改動很有可能會引入新的問題,這一點非常正常。
從抽象層面來講,這個問題可以簡單描述如下:在解碼RAR數(shù)據(jù)前,應用程序需要對RAR解碼器類的一些成員數(shù)據(jù)結(jié)構(gòu)進行初始化操作,而這些初始化操作需要依賴RAR處理函數(shù)來正確配置解碼器。不幸的是,RAR處理函數(shù)無法正確過濾其輸入數(shù)據(jù),會將錯誤的配置傳入解碼器,導致程序使用未初始化內(nèi)存。
現(xiàn)在你可能會認為這個問題無關(guān)痛癢。不可否認的是,我第一次發(fā)現(xiàn)這個問題時也存在相同的看法,然而事實證明并非如此。
接下來我會詳細介紹這個漏洞,然后簡單看一下7-Zip的修復措施,最后我們來看一下如何利用這個漏洞實現(xiàn)遠程代碼執(zhí)行。
三、漏洞分析(CVE-2018-10115)
存在問題的代碼位于solid compression處理流程中。solid compression的原理很簡單:給定一組文件(比如來自于某個文件夾的一組文件),我們可以將這些文件當成一個整體,即單獨的一個數(shù)據(jù)塊,然后對整個數(shù)據(jù)塊進行壓縮(而不是單獨壓縮每一個文件)。這樣可以達到較高的壓縮率,特別是文件數(shù)非常多或類似情況時壓縮率會更高。
在(版本5之前的)RAR格式中,solid compression的用法非常靈活:壓縮文檔中每個文件(item)都可以打上solid標記,與其他item無關(guān)。如果某個item設(shè)置了solid位,那么解碼器在解碼這個item時并不會重新初始化其狀態(tài),而會從前一個item的狀態(tài)繼續(xù)處理。
顯而易見的是,程序需要確保解碼器對象在一開始時(從解碼第一個item開始)就初始化其狀態(tài)。我們來看一下7-Zip中的具體實現(xiàn)。RAR處理器中包含NArchive::NRar::CHandler::Extract這樣一個方法,該方法在循環(huán)中通過一個變量索引遍歷所有item。在這個循環(huán)中,我們可以找到如下代碼:
Byte isSolid = (Byte)((IsSolid(index) || item.IsSplitBefore()) ? 1: 0);
if (solidStart) {
isSolid = 0;
solidStart = false;
}
RINOK(compressSetDecoderProperties->SetDecoderProperties2(&isSolid, 1));
這段代碼的主要原理是使用solidStart這個布爾(boolean)標志,該標志初始化為true(在循環(huán)開始前),確保在解碼第一個item時,使用isSolid==false來配置解碼器。此外,只要使用isSolid==false來調(diào)用解碼器,那么解碼器在開始解碼前總會(重新)初始化其狀態(tài)。
這個邏輯看上去沒有問題,對吧?好吧,其實問題在于RAR支持3種不同的編碼方法(版本5除外),每個item都可以使用不同的方法進行編碼。更具體一點,這3種編碼方法中每一種都存在不同的解碼器對象。有趣的是,3種解碼器對象的構(gòu)造函數(shù)中并沒有對一大部分成員進行初始化處理。這是因為對于非solid的item,其狀態(tài)總是需要重新進行初始化,并且有一個隱含的前提,那就是解碼器的調(diào)用者會確保首次調(diào)用解碼器時使用isSolid==false。然而我們可以構(gòu)造如下這樣一個RAR壓縮包,打破這個假設(shè)條件:
1、第一個item使用的是v1編碼方法;
2、第二個item使用的是v2(或者v3)編碼方法,并且設(shè)置了solid位。
第一個item會導致solidStart標志設(shè)置為false。對于第二個item,應用會創(chuàng)建一個新的Rar2解碼對象,然后(由于已經(jīng)設(shè)置了solid標志位)在解碼器中大部分成員未經(jīng)初始化的狀態(tài)下,開始解碼過程。
乍看之下,這可能不是個大問題。然而,許多數(shù)據(jù)沒經(jīng)過初始化處理可能會被惡意利用,導致出現(xiàn)內(nèi)存損壞:
1、保存堆上緩存大小的成員變量。這些變量現(xiàn)在保存的大小值可能比真實的緩沖區(qū)還要大,就會出現(xiàn)堆緩沖區(qū)溢出現(xiàn)象。
2、帶有索引的數(shù)組,這些數(shù)組用來索引其他數(shù)組的讀寫操作。
3、在我之前那篇文章中討論過的PPMd狀態(tài)。這些代碼很大程度上依賴于模型狀態(tài)的正確性,然而現(xiàn)在這個正確性很容易就會被破壞。
很顯然,以上并沒有覆蓋所有的利用場景。
四、修復措施
實際上這個漏洞的本質(zhì)是程序無法確保在第一次使用解碼器類之前正確初始化解碼器類的狀態(tài)。相反,在解碼第一個item前,程序需要依賴調(diào)用者使用isSolid==false來配置解碼器。前面我們也看到過,這么做效果并不是特別好。
解決這個漏洞可以采用兩種不同的方法:
1、在解碼器類的構(gòu)造函數(shù)中正確初始化所有的狀態(tài)。
2、在每個解碼器類中添加一個額外的boolean成員變量:solidAllowed(初始化為false)。如果solidAllowed==false,即便isSolid==true,解碼器也會遇到錯誤終止處理作業(yè)(或者設(shè)置isSolid=false)。
UnRAR貌似使用的是第一種方法,而Igor Pavlov選擇使用第二種方法來修復7-Zip。
如果你想自己修復7-Zip的某個分支,或者你對修復過程比較感興趣,那么你可以參考這個文件,文件總結(jié)了具體的版本改動。
五、緩解漏洞利用
在介紹CVE-2017-17969以及CVE-2018-5996漏洞的上一篇文章中,我提到7-Zip在18.00(beta)版本之前缺少DEP以及ASLR機制。在那篇文章公布后不久,Igor Pavlov 就發(fā)布了7-Zip 18.01,該版本帶有/NXCOMPAT標志,在全平臺上啟用了DEP。此外,所有動態(tài)庫(7z.dll、7-zip.dll以及7-zip32.dll)都帶有/DYNAMICBASE標志以及重定位表。因此,大部分運行代碼都受到ASLR的約束。
然而,所有的主執(zhí)行文件(7zFM.exe、7zG.exe以及7z.exe)并沒有使用/DYNAMICBASE標志,同時剝離了重定位表。這意味著不僅這些程序不受ASLR約束,并且我們也無法使用諸如EMET或者Windows Defender Exploit Guard之類的工具強制啟用ASLR功能。
顯然,只有當所有的模塊都正確隨機化后,ASLR才能發(fā)揮作用。我之前和Igor討論過這個問題,已經(jīng)說服他在新版的7-Zip 18.05中,讓主執(zhí)行程序使用/DYNAMICBASE標志以及重定位表。目前64位版本的7-Zip仍在使用標準的非高熵版ASLR(大概是因為基礎(chǔ)鏡像小于4GB),但這是一個小問題,可以在未來版本中解決。
另外我想指出一點,7-Zip并不會分配或者映射其他可執(zhí)行內(nèi)存空間,因此可以作為Windows ACG(Arbitrary Code Guard)機制的保護目標。如果你使用的是Windows 10,我們可以在Windows Defender Security Center中添加7-Zip的主執(zhí)行文件(7z.exe、7zFM.exe以及7zG.exe),為其啟用保護功能(操作路徑為:App & browser control -> Exploit Protection -> Program settings)。這樣將會應用W^X策略,使代碼執(zhí)行的漏洞利用過程變得更加困難。
六、編寫代碼執(zhí)行利用載荷
通常情況下,我并不會花太多事件來思考如何開發(fā)武器化的利用技術(shù)。然而,如果我們想知道在給定條件下,編寫漏洞利用代碼需要花費多少精力,那么此時我們可以考慮實際動手試一下。
我們的目標平臺為打上完整更新補丁的Windows 10 Redstone 4(RS4,Build 17134.1),64位操作系統(tǒng),上面運行著64位版本的7-Zip 18.01。
挑選合適的利用場景
使用7-Zip來解壓歸檔文件時,我們主要可以采用3種方法:
1、通過GUI界面打開壓縮文檔,分別提取其中的文件(比如使用拖放操作)或者使用Extract按鈕解壓整個壓縮文檔。
2、右鍵壓縮文件,在彈出的菜單種選擇“7-Zip->Extract Here”或者“7-Zip->Extract to subfolder”。
3、使用命令行版本的7-Zip進行解壓。
這三種方法都要調(diào)用不同的可執(zhí)行文件(7zFM.exe、7zG.exe以及 7z.exe)。這些模塊中缺乏ASLR,由于我們想利用這一點,因此我們需要關(guān)注文件提取方法。
第二種方法(通過上下文菜單解壓文件)看起來吸引力最大,原因在于這可能是人們最常使用的方法,并且通過這種方法我們可以較為精確地預測用戶的行為(不像第一種方法那樣,人們會打開壓縮文檔,但選擇提取“錯誤”的文件)。因此,我們選擇第二種方法作為目標。
利用策略
利用前面介紹的那個問題,我們可以創(chuàng)建一個Rar解碼器,針對(大部分)未初始化的狀態(tài)執(zhí)行處理過程。我們來看一下哪個Rar解碼器可以讓我們以攻擊者期望看到的效果來破壞內(nèi)存。
一種可能的方法是選擇使用Rar1解碼器,其NCompress::NRar1::CDecoder::HuffDecode方法包含如下代碼:
int bytePlace = DecodeNum(…);
// some code omitted
bytePlace &= 0xff;
// more code omitted
for (;;)
{
curByte = ChSet[bytePlace];
newBytePlace = NToPl[curByte++ & 0xff]++;
if ((curByte & 0xff) > 0xa1)
CorrHuff(ChSet, NToPl);
else
break;
}
ChSet[bytePlace] = ChSet[newBytePlace];
ChSet[newBytePlace] = curByte;
return S_OK;
這一點非常有用,因為Rar1解碼器的未初始化狀態(tài)中包含uint32_t類型的數(shù)組ChSet以及NtoPl。因此,newBytePlace是攻擊者可控的一個uint32_t,curByte也是如此(有個限制條件就是最低有效字節(jié)不能大于0xa1)。此外,bytePlace需要根據(jù)輸入流來決定,因此這個值也是攻擊者可控的一個值(但不能大于0xff)。
這樣就讓我們具有很好的讀寫利用條件。但是請注意,我們正處于64位地址空間中,所以我們不可能通過ChSet的32位偏移量來訪問Rar1解碼器對象的vtable指針(即便乘以sizeof(uint32_t)這個值)。因此,我們的目標是堆上位于Rar1解碼器之后的那個對象的vtable指針。
為此我們可以使用一個Rar3解碼器對象,與此同時我們也會使用該對象來保存我們的載荷。更具體一點,我們利用前面得到的讀寫條件將_windows指針(Rar3解碼器的一個成員變量)與同一個Rar3解碼器對象的vtable指針進行交換。_window指向的是一個4MB大小的緩沖區(qū),該緩沖區(qū)保存著利用解碼器提取出的數(shù)據(jù)(也就是說這也是攻擊者可控的一段數(shù)據(jù))。
我們將使用stack pivot技術(shù)(xchg rax, rsp)將某個地址填充到_window緩沖區(qū)中,然后跟著一個ROP鏈以獲得可執(zhí)行的內(nèi)存并執(zhí)行shellcode(我們也會將這段shellcode放入_windows緩沖區(qū)中)。
在堆上放置一個替代對象
為了成功實現(xiàn)既定策略,我們需要完全控制解碼器的未經(jīng)初始化的內(nèi)存空間。大致做法就是分配大小為Rar1解碼器對象大小的一段內(nèi)存空間,將所需數(shù)據(jù)寫入其中,然后在程序真正分配Rar1解碼器空間之前先行釋放掉這塊內(nèi)存。
顯然,我們需要確保Rar1解碼器所分配的空間的確重用了我們先前釋放的同一塊內(nèi)存區(qū)域。想實現(xiàn)這個目標的一種直接方法就是激活相同大小的低碎片堆(Low Fragmentation Heap,LFH),然后使用多個替代對象來噴射LFH。這種方法的確行之有效,然而由于從Windows 8開始,在LFH分配空間會被隨機化處理,因此使用這種方法再也不能讓Rar1解碼器對象與任何其他對象保持恒定的距離。因此,我們會盡量避免使用LFH,將我們的對象放置在常規(guī)堆上。整個空間分配策略大概如下所示:
1、創(chuàng)建大約18個待分配的空間,其大小小于Rar1解碼器對象的大小。這樣就會激活LFH,避免這類小空間分配操作摧毀我們干凈的堆結(jié)構(gòu)。
2、分配替代對象然后釋放這個對象,確保該對象被我們前面分配的空間所包圍(因此不會與其他空閑塊合并)。
3、分配Rar3解碼器(替代對象并沒有被重用,因為Rar3解碼器比Rar1解碼器要大)。
4、分配Rar1解碼器(重用替代對象)。
需要注意的是,在為Rar1解碼器分配空間時,我們無法避免先分配一些解碼器,這是因為只有通過這種方式,solidStart標志才會被設(shè)置為false,導致下一個解碼器無法被正確初始化(見前文描述)。
如果一切按計劃運行,Rar1解碼器就會重用我們的替代對象,Rar3解碼器對象在堆上將位于Rar1解碼器對象之后,并且保持某個恒定的偏移距離。
在堆上分配并釋放
顯然,如上分配策略需要我們能夠以合理可控的方式在堆上分配空間。翻遍了RAR處理函數(shù)的所有源碼,我無法找到很多較好的方法來對默認進程堆動態(tài)分配空間,以滿足攻擊者所需的大小要求并往其中存儲攻擊者可控的數(shù)據(jù)。事實上,完成這種動態(tài)分配任務的貌似只能通過壓縮文檔item的名稱來實現(xiàn)。接下來我們看一下具體方法。
當程序打開某個壓縮文檔時,NArchive::NRar::CHandler::Open2方法就會讀取壓縮文檔的所有item,具體代碼如下(經(jīng)過適當簡化):
CItem item;
for (;;)
{
// some code omitted
bool filled;
archive.GetNextItem(item, getTextPassword, filled, error);
// some more code omitted
if (!filled) {
// some more code omitted
break;
}
if (item.IgnoreItem()) { continue; }
bool needAdd = true;
// some more code omitted
_items.Add(item);
}
CItem類有一個AString類型的成員變量Name,該變量在一個堆分配的緩沖區(qū)中存儲了對應item的(ASCII)名。
不幸的是,item的名稱通過NArchive::NRar::CInArchive::ReadName來設(shè)置,代碼如下:
for (i = 0; i < nameSize && p[i] != 0; i++) {} item.Name.SetFrom((const char *)p, i); 這里我看到了一些困難,因為這意味著我們無法將任意字節(jié)為所欲為地寫入緩沖區(qū)中。更具體一點,我們似乎無法寫入null(空)字節(jié)。這一點非常糟糕,因為我們想放在堆上的替代對象中包含若干個0字節(jié)。那么我們該怎么辦?讓我們來看看AString::SetFrom: void AString::SetFrom(const char *s, unsigned len) { if (len > _limit)
{
char *newBuf = new char[len + 1];
delete []_chars;
_chars = newBuf;
_limit = len;
}
if (len != 0)
memcpy(_chars, s, len);
_chars[len] = 0;
_len = len;
}
如你所見,這個方法總是會以一個null字節(jié)來結(jié)束字符串。此外,我們發(fā)現(xiàn)只要字符串大小大于一定值,AString就會在底層開辟一個緩沖區(qū)。這就讓我產(chǎn)生這樣一個想法:假設(shè)我們想把DEAD00BEEF00BAAD00這些十六進制字節(jié)寫入堆上分配的某個緩沖區(qū),那么我們只需要構(gòu)造一個壓縮包,其中item的文件名如下(按照列出的順序來):
DEAD55BEEF55BAAD
DEAD55BEEF
DEAD
這樣我們就能讓SetFrom幫我們寫入我們需要的所有null字節(jié)。請注意,現(xiàn)在我們已經(jīng)將數(shù)據(jù)中的null字節(jié)替換成一些非零的字節(jié)(這里為0x55這個字節(jié)),確保將整個字符串寫入緩沖區(qū)中。
這個方法非常好,我們可以寫入任意字節(jié)序列,但存在兩個限制。首先,我們必須要用一個null字節(jié)來結(jié)束這個序列;其次,在字節(jié)序列中我們不能使用太多個null字節(jié),因為這樣會導致壓縮文檔過大。幸運的是,在這個場景中我們可以輕松繞過這些限制條件。
現(xiàn)在請注意我們可以使用兩種類型的分配操作:
1、分配帶有item.IgnoreItem()==true屬性的一些item。這些item不會被添加到_items列表中,因此屬于臨時item。這些分配的空間具備特殊屬性,最終會被釋放,并且我們可以(使用上述技術(shù))往其中填充任意字節(jié)序列(幾乎可以不受限制)。由于這些內(nèi)存分配操作都是通過同一個棧分配對象item來完成,因此使用的是相同的AString對象,這類分配操作在大小上需要嚴格遞增。我們主要使用這類分配操作來將替代對象放置在堆上。
2、分配帶有item.IgnoreItem()==false屬性的一些item。這些item會被添加到_items列表中,生成對應名稱的副本。通過這種方式,我們可以獲得許多待分配的、特定大小的空間,激活LFH。需要注意的是,復制的字符串中不能包含任何null字節(jié),這對我們來說毫無壓力。
綜合利用上面提到的方法,我們可以構(gòu)造一個壓縮文檔,滿足我們前面描述的堆分配策略。
ROP
由于7zG.exe主執(zhí)行程序不具備ASLR機制,因此我們可以使用一個ROP鏈來繞過DEP。7-Zip不會去調(diào)用VirtualProtect,因此我們可以從導入表(IAT)中讀取VirtualAlloc、memcpy以及exit的地址,寫入如下ROP鏈:
// pivot stack: xchg rax, rsp;
exec_buffer = VirtualAlloc(NULL, 0x1000, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
memcpy(exec_buffer, rsp+shellcode_offset, 0x1000);
jmp exec_buffer;
exit(0);
由于我們的工作環(huán)境為x86_64系統(tǒng)(其中大多數(shù)指令的編碼長度比x86系統(tǒng)要長),并且二進制程序也不是特別大,因此我們無法找到特別好的gadget來執(zhí)行我們所需的一些操作。這并不是一個太大的難題,但會讓我們的ROP鏈看上去沒那么完美。比如,在調(diào)用VirtualAlloc之前,為了將R9寄存器設(shè)置為PAGE_EXECUTE_READWRITE,我們需要使用如下gadget鏈:
0x40691e, #pop rcx; add eax, 0xfc08500; xchg eax, ebp; ret;
PAGE_EXECUTE_READWRITE, #value that is popped into rcx
0x401f52, #xor eax, eax; ret; (setting ZF=1 for cmove)
0x4193ad, #cmove r9, rcx; imul rax, rdx; xor edx, edx; imul rax, rax, 0xf4240; div r8; xor edx, edx; div r9; ret;
演示
我們的演示環(huán)境為全新安裝的Windows 10 RS4(Build 17134.1)64位系統(tǒng),安裝了7-Zip 18.01 x64,利用過程如下圖所示。前文提到過,我們的利用場景使用的是右鍵菜單來提取壓縮文件,具體菜單路徑為“7-Zip->Extract Here”以及“7-Zip->Extract to subfolder”。
可靠性研究
仔細調(diào)整堆分配大小后,整個利用過程現(xiàn)在已經(jīng)非常可靠且穩(wěn)定。
為了進一步研究漏洞利用的可靠性,我編寫了一小段腳本,按照右鍵菜單釋放文件的方式重復調(diào)用7zG.exe程序來釋放我們精心構(gòu)造的壓縮文檔。此外,該腳本會檢查calc.exe是否被順利啟動,并且7zG.exe進程的退出代碼是否為0。在不同的操作系統(tǒng)上運行這個腳本后(所有操作系統(tǒng)均打全最新補丁),測試結(jié)果如下:
1、Windows 10 RS4(Build 17134.1)64位:100,000次利用中有17次利用失敗。
2、Windows 8.1 64位:100,000次利用中有12次利用失敗。
3、Windows 7 SP1 64位:100,000次利用中有90次利用失敗。
需要注意的是,所有的操作系統(tǒng)使用的都是同一個壓縮文檔。整個測試結(jié)果比較理想,可能時由于Windows 7以及Windows 10在堆的LFH實現(xiàn)上面有些區(qū)別,因此這兩個系統(tǒng)上的測試結(jié)果差別較大,其他情況下差別并不是特別大。此外,相同數(shù)量的待分配內(nèi)存仍然會觸發(fā)LFH。
不可否認的是,我們很難憑經(jīng)驗去判斷利用方法的可靠性。不過我認為上面的測試過程至少比單純跑幾次利用過程要靠譜得多。
七、總結(jié)
在我看來,之所以出現(xiàn)這個錯誤,原因在于程序設(shè)計上(部分)繼承了UnRAR的具體實現(xiàn)。如果某個類需要依賴它的使用者以正確方式來使用它,以避免使用未經(jīng)初始化的類成員,那么這種方式注定會以失敗告終。
經(jīng)過本文的分析,我們親眼見證了如何將(乍看之下)人畜無害的錯誤轉(zhuǎn)換成可靠的、武器化的代碼執(zhí)行利用方法。由于主執(zhí)行程序缺乏ASLR,因此利用技術(shù)上唯一的難題就是如何在受限的RAR提取場景中精心布置堆結(jié)構(gòu)。
幸運的是,新版的7-Zip 18.05不僅修復了這個漏洞,也在所有主執(zhí)行文件上啟用了ASLR。
如果大家有意見或者建議,歡迎通過此頁面上的聯(lián)系方式給我發(fā)郵件。
此外,大家也可以加入HackerNews或者/r/netsec一起來討論。
八、時間線
2018-03-06 – 發(fā)現(xiàn)漏洞
2018-03-06 – 報告漏洞
2018-04-14 – MITRE為此漏洞分配了編號:CVE-2018-10115
2018-04-30 – 7-Zip 18.05發(fā)布,修復了CVE-2018-10115漏洞,在可執(zhí)行文件上啟用了ASLR。
九、致謝
感謝Igor Pavlov修復此漏洞并且為7-Zip部署緩解措施避免被進一步攻擊。