Linux Pwn入門教程系列分享如約而至,本套課程是做者依據i春秋Pwn入門課程中的技術分類,並結合近幾年賽事中出現的題目和文章整理出一份相對完整的Linux Pwn教程。html
教程僅針對i386/amd64下的Linux Pwn常見的Pwn手法,如棧,堆,整數溢出,格式化字符串,條件競爭等進行介紹,全部環境都會封裝在Docker鏡像當中,並提供調試用的教學程序,來自歷年賽事的原題和帶有註釋的python腳本。python
課程回顧>>linux
Linux Pwn入門教程第一章:環境配置docker
今天i春秋與你們分享的是Linux Pwn入門教程第七章:格式化字符串漏洞,閱讀用時約15分鐘。
printf函數中的漏洞
printf函數族是一個在C編程中比較經常使用的函數族。一般來講,咱們會使用printf([格式化字符串],參數)的形式來進行調用,例如:
char s[20] = 「Hello world!\n」; printf(「%s」, s);
然而,有時爲了省事也會寫成:
char s[20] = 「Hello world!\n」; printf(s);
事實上,這是一種很是危險的寫法。因爲printf函數族的設計缺陷,當其第一個參數可被控制時,攻擊者將有機會對任意內存地址進行讀寫操做。
利用格式化字符串漏洞實現任意地址讀
首先咱們來看一個本身寫的簡單例子~/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」
使用格式化字符串漏洞任意寫
雖然咱們能夠利用格式化字符串漏洞達到任意地址讀,可是咱們並不能直接經過讀取來利用漏洞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的腳本見於附件,此處再也不贅述。
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)
這回就能夠了。
使用格式化字符串漏洞使程序無限循環
從上面的兩個例子咱們能夠發現,之因此能成功利用格式化字符串漏洞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,可用的腳本一樣見於附件。
一些和格式化字符串漏洞相關的漏洞緩解機制
在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函數的存在。
以上是今天的內容,你們看懂了嗎?後面咱們將持續更新Linux Pwn入門教程的相關章節,但願你們及時關注。