7-Zip:從未初始化內存到遠程代碼執行

譯文聲明

本文是翻譯文章,文章原作者,文章來源:landave.io/

原文地址:landave.io/2018/05/7-zi


一、前言

之前我發表過一篇文章介紹了7-Zip的CVE-2017-17969以及CVE-2018-5996漏洞,後面我又繼續花了點時間分析了反病毒軟體。碰巧的是,我又發現了一個新的bug,該漏洞(與之前兩個bug一樣)最終會影響到7-Zip。由於反病毒軟體廠商還沒有發布安全補丁,因此我會在本文更新時添加受影響的產品名稱。

二、簡介

7-Zip的RAR代碼主要基於最近版本的UnRAR代碼,但代碼的高層部分已經被大量修改過。我曾經在之前的一些文章中提到過,UnRAR的代碼非常脆弱,因此,對這份代碼的改動很有可能會引入新的問題,這一點非常正常。

從抽象層面來講,這個問題可以簡單描述如下:在解碼RAR數據前,應用程序需要對RAR解碼器類的一些成員數據結構進行初始化操作,而這些初始化操作需要依賴RAR處理函數來正確配置解碼器。不幸的是,RAR處理函數無法正確過濾其輸入數據,會將錯誤的配置傳入解碼器,導致程序使用未初始化內存。

現在你可能會認為這個問題無關痛癢。不可否認的是,我第一次發現這個問題時也存在相同的看法,然而事實證明並非如此。

接下來我會詳細介紹這個漏洞,然後簡單看一下7-Zip的修復措施,最後我們來看一下如何利用這個漏洞實現遠程代碼執行。

三、漏洞分析(CVE-2018-10115)

存在問題的代碼位於solid compression處理流程中。solid compression的原理很簡單:給定一組文件(比如來自於某個文件夾的一組文件),我們可以將這些文件當成一個整體,即單獨的一個數據塊,然後對整個數據塊進行壓縮(而不是單獨壓縮每一個文件)。這樣可以達到較高的壓縮率,特別是文件數非常多或類似情況時壓縮率會更高。

在(版本5之前的)RAR格式中,solid compression的用法非常靈活:壓縮文檔中每個文件(item)都可以打上solid標記,與其他item無關。如果某個item設置了solid位,那麼解碼器在解碼這個item時並不會重新初始化其狀態,而會從前一個item的狀態繼續處理。

顯而易見的是,程序需要確保解碼器對象在一開始時(從解碼第一個item開始)就初始化其狀態。我們來看一下7-Zip中的具體實現。RAR處理器中包含NArchive::NRar::CHandler::Extract這樣一個方法,該方法在循環中通過一個變數索引遍歷所有item。在這個循環中,我們可以找到如下代碼:

Byte isSolid = (Byte)((IsSolid(index) || item.IsSplitBefore()) ? 1: 0);

if (solidStart) {

isSolid = 0;

solidStart = false;

}

RINOK(compressSetDecoderProperties->SetDecoderProperties2(&isSolid, 1));

這段代碼的主要原理是使用solidStart這個布爾(boolean)標誌,該標誌初始化為true(在循環開始前),確保在解碼第一個item時,使用isSolid==false來配置解碼器。此外,只要使用isSolid==false來調用解碼器,那麼解碼器在開始解碼前總會(重新)初始化其狀態。

這個邏輯看上去沒有問題,對吧?好吧,其實問題在於RAR支持3種不同的編碼方法(版本5除外),每個item都可以使用不同的方法進行編碼。更具體一點,這3種編碼方法中每一種都存在不同的解碼器對象。有趣的是,3種解碼器對象的構造函數中並沒有對一大部分成員進行初始化處理。這是因為對於非solid的item,其狀態總是需要重新進行初始化,並且有一個隱含的前提,那就是解碼器的調用者會確保首次調用解碼器時使用isSolid==false。然而我們可以構造如下這樣一個RAR壓縮包,打破這個假設條件:

1、第一個item使用的是v1編碼方法;

2、第二個item使用的是v2(或者v3)編碼方法,並且設置了solid位。

第一個item會導致solidStart標誌設置為false。對於第二個item,應用會創建一個新的Rar2解碼對象,然後(由於已經設置了solid標誌位)在解碼器中大部分成員未經初始化的狀態下,開始解碼過程。

乍看之下,這可能不是個大問題。然而,許多數據沒經過初始化處理可能會被惡意利用,導致出現內存損壞:

1、保存堆上緩存大小的成員變數。這些變數現在保存的大小值可能比真實的緩衝區還要大,就會出現堆緩衝區溢出現象。

2、帶有索引的數組,這些數組用來索引其他數組的讀寫操作。

3、在我之前那篇文章中討論過的PPMd狀態。這些代碼很大程度上依賴於模型狀態的正確性,然而現在這個正確性很容易就會被破壞。

很顯然,以上並沒有覆蓋所有的利用場景。

四、修復措施

實際上這個漏洞的本質是程序無法確保在第一次使用解碼器類之前正確初始化解碼器類的狀態。相反,在解碼第一個item前,程序需要依賴調用者使用isSolid==false來配置解碼器。前面我們也看到過,這麼做效果並不是特別好。

解決這個漏洞可以採用兩種不同的方法:

1、在解碼器類的構造函數中正確初始化所有的狀態。

2、在每個解碼器類中添加一個額外的boolean成員變數:solidAllowed(初始化為false)。如果solidAllowed==false,即便isSolid==true,解碼器也會遇到錯誤終止處理作業(或者設置isSolid=false)。

UnRAR貌似使用的是第一種方法,而Igor Pavlov選擇使用第二種方法來修復7-Zip。

如果你想自己修復7-Zip的某個分支,或者你對修復過程比較感興趣,那麼你可以參考這個文件,文件總結了具體的版本改動。

五、緩解漏洞利用

在介紹CVE-2017-17969以及CVE-2018-5996漏洞的上一篇文章中,我提到7-Zip在18.00(beta)版本之前缺少DEP以及ASLR機制。在那篇文章公布後不久,Igor Pavlov 就發布了7-Zip 18.01,該版本帶有/NXCOMPAT標誌,在全平台上啟用了DEP。此外,所有動態庫(7z.dll7-zip.dll以及7-zip32.dll)都帶有/DYNAMICBASE標誌以及重定位表。因此,大部分運行代碼都受到ASLR的約束。

然而,所有的主執行文件(7zFM.exe7zG.exe以及7z.exe)並沒有使用/DYNAMICBASE標誌,同時剝離了重定位表。這意味著不僅這些程序不受ASLR約束,並且我們也無法使用諸如EMET或者Windows Defender Exploit Guard之類的工具強制啟用ASLR功能。

顯然,只有當所有的模塊都正確隨機化後,ASLR才能發揮作用。我之前和Igor討論過這個問題,已經說服他在新版的7-Zip 18.05中,讓主執行程序使用/DYNAMICBASE標誌以及重定位表。目前64位版本的7-Zip仍在使用標準的非高熵版ASLR(大概是因為基礎鏡像小於4GB),但這是一個小問題,可以在未來版本中解決。

另外我想指出一點,7-Zip並不會分配或者映射其他可執行內存空間,因此可以作為Windows ACG(Arbitrary Code Guard)機制的保護目標。如果你使用的是Windows 10,我們可以在Windows Defender Security Center中添加7-Zip的主執行文件(7z.exe7zFM.exe以及7zG.exe),為其啟用保護功能(操作路徑為:App & browser control -> Exploit Protection -> Program settings)。這樣將會應用W^X策略,使代碼執行的漏洞利用過程變得更加困難。

六、編寫代碼執行利用載荷

通常情況下,我並不會花太多事件來思考如何開發武器化的利用技術。然而,如果我們想知道在給定條件下,編寫漏洞利用代碼需要花費多少精力,那麼此時我們可以考慮實際動手試一下。

我們的目標平台為打上完整更新補丁的Windows 10 Redstone 4(RS4,Build 17134.1),64位操作系統,上面運行著64位版本的7-Zip 18.01。

挑選合適的利用場景

使用7-Zip來解壓歸檔文件時,我們主要可以採用3種方法:

1、通過GUI界面打開壓縮文檔,分別提取其中的文件(比如使用拖放操作)或者使用Extract按鈕解壓整個壓縮文檔。

2、右鍵壓縮文件,在彈出的菜單種選擇「7-Zip->Extract Here」或者「7-Zip->Extract to subfolder」。

3、使用命令行版本的7-Zip進行解壓。

這三種方法都要調用不同的可執行文件(7zFM.exe7zG.exe以及 7z.exe)。這些模塊中缺乏ASLR,由於我們想利用這一點,因此我們需要關注文件提取方法。

第二種方法(通過上下文菜單解壓文件)看起來吸引力最大,原因在於這可能是人們最常使用的方法,並且通過這種方法我們可以較為精確地預測用戶的行為(不像第一種方法那樣,人們會打開壓縮文檔,但選擇提取「錯誤」的文件)。因此,我們選擇第二種方法作為目標。

利用策略

利用前面介紹的那個問題,我們可以創建一個Rar解碼器,針對(大部分)未初始化的狀態執行處理過程。我們來看一下哪個Rar解碼器可以讓我們以攻擊者期望看到的效果來破壞內存。

一種可能的方法是選擇使用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解碼器的未初始化狀態中包含uint32_t類型的數組ChSet以及NtoPl。因此,newBytePlace是攻擊者可控的一個uint32_tcurByte也是如此(有個限制條件就是最低有效位元組不能大於0xa1)。此外,bytePlace需要根據輸入流來決定,因此這個值也是攻擊者可控的一個值(但不能大於0xff)。

這樣就讓我們具有很好的讀寫利用條件。但是請注意,我們正處於64位地址空間中,所以我們不可能通過ChSet的32位偏移量來訪問Rar1解碼器對象的vtable指針(即便乘以sizeof(uint32_t)這個值)。因此,我們的目標是堆上位於Rar1解碼器之後的那個對象的vtable指針。

為此我們可以使用一個Rar3解碼器對象,與此同時我們也會使用該對象來保存我們的載荷。更具體一點,我們利用前面得到的讀寫條件將_windows指針(Rar3解碼器的一個成員變數)與同一個Rar3解碼器對象的vtable指針進行交換。_window指向的是一個4MB大小的緩衝區,該緩衝區保存著利用解碼器提取出的數據(也就是說這也是攻擊者可控的一段數據)。

我們將使用stack pivot技術(xchg rax, rsp)將某個地址填充到_window緩衝區中,然後跟著一個ROP鏈以獲得可執行的內存並執行shellcode(我們也會將這段shellcode放入_windows緩衝區中)。

在堆上放置一個替代對象

為了成功實現既定策略,我們需要完全控制解碼器的未經初始化的內存空間。大致做法就是分配大小為Rar1解碼器對象大小的一段內存空間,將所需數據寫入其中,然後在程序真正分配Rar1解碼器空間之前先行釋放掉這塊內存。

顯然,我們需要確保Rar1解碼器所分配的空間的確重用了我們先前釋放的同一塊內存區域。想實現這個目標的一種直接方法就是激活相同大小的低碎片堆(Low Fragmentation Heap,LFH),然後使用多個替代對象來噴射LFH。這種方法的確行之有效,然而由於從Windows 8開始,在LFH分配空間會被隨機化處理,因此使用這種方法再也不能讓Rar1解碼器對象與任何其他對象保持恆定的距離。因此,我們會盡量避免使用LFH,將我們的對象放置在常規堆上。整個空間分配策略大概如下所示:

1、創建大約18個待分配的空間,其大小小於Rar1解碼器對象的大小。這樣就會激活LFH,避免這類小空間分配操作摧毀我們乾淨的堆結構。

2、分配替代對象然後釋放這個對象,確保該對象被我們前面分配的空間所包圍(因此不會與其他空閑塊合併)。

3、分配Rar3解碼器(替代對象並沒有被重用,因為Rar3解碼器比Rar1解碼器要大)。

4、分配Rar1解碼器(重用替代對象)。

需要注意的是,在為Rar1解碼器分配空間時,我們無法避免先分配一些解碼器,這是因為只有通過這種方式,solidStart標誌才會被設置為false,導致下一個解碼器無法被正確初始化(見前文描述)。

如果一切按計劃運行,Rar1解碼器就會重用我們的替代對象,Rar3解碼器對象在堆上將位於Rar1解碼器對象之後,並且保持某個恆定的偏移距離。

在堆上分配並釋放

顯然,如上分配策略需要我們能夠以合理可控的方式在堆上分配空間。翻遍了RAR處理函數的所有源碼,我無法找到很多較好的方法來對默認進程堆動態分配空間,以滿足攻擊者所需的大小要求並往其中存儲攻擊者可控的數據。事實上,完成這種動態分配任務的貌似只能通過壓縮文檔item的名稱來實現。接下來我們看一下具體方法。

當程序打開某個壓縮文檔時,NArchive::NRar::CHandler::Open2方法就會讀取壓縮文檔的所有item,具體代碼如下(經過適當簡化):

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,該變數在一個堆分配的緩衝區中存儲了對應item的(ASCII)名。

不幸的是,item的名稱通過NArchive::NRar::CInArchive::ReadName來設置,代碼如下:

for (i = 0; i < nameSize && p[i] != 0; i++) {}

item.Name.SetFrom((const char *)p, i);

這裡我看到了一些困難,因為這意味著我們無法將任意位元組為所欲為地寫入緩衝區中。更具體一點,我們似乎無法寫入null(空)位元組。這一點非常糟糕,因為我們想放在堆上的替代對象中包含若干個0位元組。那麼我們該怎麼辦?讓我們來看看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位元組來結束字元串。此外,我們發現只要字元串大小大於一定值,AString就會在底層開闢一個緩衝區。這就讓我產生這樣一個想法:假設我們想把DEAD00BEEF00BAAD00這些十六進位位元組寫入堆上分配的某個緩衝區,那麼我們只需要構造一個壓縮包,其中item的文件名如下(按照列出的順序來):

DEAD55BEEF55BAAD

DEAD55BEEF

DEAD

這樣我們就能讓SetFrom幫我們寫入我們需要的所有null位元組。請注意,現在我們已經將數據中的null位元組替換成一些非零的位元組(這裡為0x55這個位元組),確保將整個字元串寫入緩衝區中。

這個方法非常好,我們可以寫入任意位元組序列,但存在兩個限制。首先,我們必須要用一個null位元組來結束這個序列;其次,在位元組序列中我們不能使用太多個null位元組,因為這樣會導致壓縮文檔過大。幸運的是,在這個場景中我們可以輕鬆繞過這些限制條件。

現在請注意我們可以使用兩種類型的分配操作:

1、分配帶有item.IgnoreItem()==true屬性的一些item。這些item不會被添加到_items列表中,因此屬於臨時item。這些分配的空間具備特殊屬性,最終會被釋放,並且我們可以(使用上述技術)往其中填充任意位元組序列(幾乎可以不受限制)。由於這些內存分配操作都是通過同一個棧分配對象item來完成,因此使用的是相同的AString對象,這類分配操作在大小上需要嚴格遞增。我們主要使用這類分配操作來將替代對象放置在堆上。

2、分配帶有item.IgnoreItem()==false屬性的一些item。這些item會被添加到_items列表中,生成對應名稱的副本。通過這種方式,我們可以獲得許多待分配的、特定大小的空間,激活LFH。需要注意的是,複製的字元串中不能包含任何null位元組,這對我們來說毫無壓力。

綜合利用上面提到的方法,我們可以構造一個壓縮文檔,滿足我們前面描述的堆分配策略。

ROP

由於7zG.exe主執行程序不具備ASLR機制,因此我們可以使用一個ROP鏈來繞過DEP。7-Zip不會去調用VirtualProtect,因此我們可以從導入表(IAT)中讀取VirtualAllocmemcpy以及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);

由於我們的工作環境為x86_64系統(其中大多數指令的編碼長度比x86系統要長),並且二進位程序也不是特別大,因此我們無法找到特別好的gadget來執行我們所需的一些操作。這並不是一個太大的難題,但會讓我們的ROP鏈看上去沒那麼完美。比如,在調用VirtualAlloc之前,為了將R9寄存器設置為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;

演示

我們的演示環境為全新安裝的Windows 10 RS4(Build 17134.1)64位系統,安裝了7-Zip 18.01 x64,利用過程如下圖所示。前文提到過,我們的利用場景使用的是右鍵菜單來提取壓縮文件,具體菜單路徑為「7-Zip->Extract Here」以及「7-Zip->Extract to subfolder」。

可靠性研究

仔細調整堆分配大小後,整個利用過程現在已經非常可靠且穩定。

為了進一步研究漏洞利用的可靠性,我編寫了一小段腳本,按照右鍵菜單釋放文件的方式重複調用7zG.exe程序來釋放我們精心構造的壓縮文檔。此外,該腳本會檢查calc.exe是否被順利啟動,並且7zG.exe進程的退出代碼是否為0。在不同的操作系統上運行這個腳本後(所有操作系統均打全最新補丁),測試結果如下:

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次利用失敗。

需要注意的是,所有的操作系統使用的都是同一個壓縮文檔。整個測試結果比較理想,可能時由於Windows 7以及Windows 10在堆的LFH實現上面有些區別,因此這兩個系統上的測試結果差別較大,其他情況下差別並不是特別大。此外,相同數量的待分配內存仍然會觸發LFH。

不可否認的是,我們很難憑經驗去判斷利用方法的可靠性。不過我認為上面的測試過程至少比單純跑幾次利用過程要靠譜得多。

七、總結

在我看來,之所以出現這個錯誤,原因在於程序設計上(部分)繼承了UnRAR的具體實現。如果某個類需要依賴它的使用者以正確方式來使用它,以避免使用未經初始化的類成員,那麼這種方式註定會以失敗告終。

經過本文的分析,我們親眼見證了如何將(乍看之下)人畜無害的錯誤轉換成可靠的、武器化的代碼執行利用方法。由於主執行程序缺乏ASLR,因此利用技術上唯一的難題就是如何在受限的RAR提取場景中精心布置堆結構。

幸運的是,新版的7-Zip 18.05不僅修復了這個漏洞,也在所有主執行文件上啟用了ASLR。

如果大家有意見或者建議,歡迎通過此頁面上的聯繫方式給我發郵件。

此外,大家也可以加入HackerNews或者/r/netsec一起來討論。

八、時間線

  • 2018-03-06 – 發現漏洞
  • 2018-03-06 – 報告漏洞
  • 2018-04-14 – MITRE為此漏洞分配了編號:CVE-2018-10115
  • 2018-04-30 – 7-Zip 18.05發布,修復了CVE-2018-10115漏洞,在可執行文件上啟用了ASLR。

九、致謝

感謝Igor Pavlov修復此漏洞並且為7-Zip部署緩解措施避免被進一步攻擊。

作者:興趣使然的小胃


推薦閱讀:

以色列初創公司推出全球最大範圍WiFi監控工具:最遠1公里
解析.DS_Store文件格式
引入機器學習前需要先弄明白這三件事
TeamViewer 13.0.5058中的許可權漏洞測試
滲透技巧——獲取Windows系統下DPAPI中的MasterKey

TAG:科技 | 計算機 | 信息安全 |