CTF必備技能丨Linux Pwn入門教程——格式化字符串漏洞

2021-02-16 i春秋

Linux Pwn入門教程系列分享如約而至,本套課程是作者依據i春秋Pwn入門課程中的技術分類,並結合近幾年賽事中出現的題目和文章整理出一份相對完整的Linux Pwn教程。

教程僅針對i386/amd64下的Linux Pwn常見的Pwn手法,如棧,堆,整數溢出,格式化字符串,條件競爭等進行介紹,所有環境都會封裝在Docker鏡像當中,並提供調試用的教學程序,來自歷年賽事的原題和帶有注釋的python腳本。

今天i春秋與大家分享的是Linux Pwn入門教程第七章:格式化字符串漏洞,閱讀用時約12分鐘。

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的腳本見於附件,此處不再贅述。

學習完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入門教程的相關章節,希望大家及時關注。

 課後例題和練習題非常重要,小夥伴請務必進行練習,進群後管理員會給大家發放哦。

練習題獲取入口

新來的朋友如果想要了解其他的必備技能和實用工具,可以點擊菜單欄中的入門錦囊查看相關內容:

文章素材來源於i春秋社區

i春秋官方公眾號為大家提供

前沿的網絡安全技術

簡單易懂的實用工具

緊張刺激的安全競賽

還有網絡安全大講堂

更多技能等你來解鎖

相關焦點

  • CTF必備技能丨Linux Pwn入門教程——調整棧幀的技巧
    今天i春秋與大家分享的是Linux Pwn入門教程第五章:調整棧幀的技巧,閱讀用時約12分鐘。修改esp擴大棧空間我們先來嘗試一下修改esp擴大棧空間。sub_408800字符串單參數,且參數被列印到屏幕上,可以猜測是puts。
  • CTF必備技能丨Linux Pwn入門教程——ShellCode
    首先我們把演示程序~/Openctf 2016-tyro_shellcode1/tyro_shellcode1複製到32位的Docker環境中並開啟調試器進行調試分析。與上一篇教程不同的是,這次的程序並不存在棧溢出。從F5的結果上看程序使用read函數讀取的輸入甚至都不在棧上,而是在一片使用mmap分配出來的內存空間上。
  • PWN學習指南
    Python : 有了前面的鋪墊,學python就會很快,廖雪峰的python教程,《Python編程從入門到實踐》等都不錯。彙編 : 用王爽的《彙編語言》來入門是不錯的. 不過這本書是16位彙編,入門後還得找找32位,64位彙編資料來看看。這裡說的彙編都是asm彙編.
  • linux pwn入門學習到放棄
    本文記錄菜鳥學習linux pwn入門的一些過程,詳細介紹linux上的保護機制,分析一些常見漏洞如棧溢出,堆溢出,use after free等,以及一些常見工具集合介紹等。先來學習一些關於linux方面的保護措施,作業系統提供了許多安全機制來嘗試降低或阻止緩衝區溢出攻擊帶來的安全風險,包括DEP、ASLR等。
  • Linux pwn從入門到熟練(二)
    這裡我們會介紹,如何在棧可執行而system函數以及參數沒有的情況下,如何自己布置payload進行pwn。此外,還提供了一份可以參考的pwn套路,套路熟悉了,即可慢慢轉化為熟悉。故此名曰:入門到熟練(二)。 練習題參考(利用庫函數讀取參數)所謂的入門到熟練,套路還是要有的。套路有了,就可以見招拆招。我們一步一步來。
  • 深入解析sprintf格式化字符串漏洞
    break了,php未做任何處理,直接跳過,所以導致了這個問題:沒做字符類型檢測的最大危害就是它可以吃掉一個轉義符, 如果%後面出現一個,那麼php會把\當作一個格式化字符的類型而吃掉, 最後%\(或%1$\)被替換為空因此sprintf注入,或者說php格式化字符串注入的原理為: 要明白%後的一個字符(除了%,%上面表格已經給出了)都會被當作字符型類型而被吃掉
  • Linux pwn從入門到熟練(三)
    「紙上得來終覺淺,絕知此事要躬行」——《冬夜讀書示子聿》 時間久遠,怕大家找不到從前的文章,特此給出傳送門:Linux pwn從入門到熟練(二)https://bbs.pediy.com/thread-248681.htmLinux pwn從入門到熟練
  • CTF從入門到提升(三)
    類型:Web,密碼學,pwn 程序的邏輯分析,漏洞利用windows
  • CTF入門指南 | 內附教程分享
    capture the flag 奪旗比賽類型:Web密碼學Pwn 程序的邏輯分析,漏洞利用windows、linux、小型機等Misc 雜項,隱寫,數據還原,腦洞、社會工程、與信息安全相關的大數據reverse 逆向Windows、Linux
  • 一步一步學pwntools
    本來想發發我之前CTF的writeups,不過數量有點多,而且網上也有很多質量不錯的wp,就發回之前寫的pwntools新手教程。網上純新手教程比較少,一般都是直接調用api,這篇主要是想給新手對pwntool一個更整體的認識。原文是我用英文寫的,如果翻譯的不好,請見諒。
  • 看雪CTF.TSRC 2018 團隊賽 第十四題『 你眼中的世界』 解題思路
    格式化字符串漏洞 64位格式化字符串,開了FORTIFY_SOURCE機制,有幾個特性: 1)包含%n的格式化字符串不能位於程序內存中的可寫地址。 2)當使用位置參數時,必須使用範圍內的所有參數。所以如果要使用%7$x,你必須同時使用1,2,3,4,5和6。
  • python格式化字符串研究
    前言與隊友交流時提及python的格式化字符串漏洞,這個漏洞之前接觸不多,所以寫篇文章從基礎部分仔細研究了研究。python環境是python3.7。Python3裡的格式化字符串python3中的格式化字符串主要有以下兩種形式:"test %s" % ('test')"test {0}".format('test')這兩個語句的輸出都是test test。
  • PWNHUB雙蛋賽pwn題解
    pwnhub的2道pwn題目,一道格式化字符串的題目,一道libc-2.31的堆題目。
  • 四道題看格串新的利用方式
    作者:nuoye@星盟相對於基本的%p進行leak和%n寫入,最近幾年出現了不少新的格式化字符串的利用方式,這裡以四道題為例
  • Python中的轉義字符串與格式化字符串
    # 格式化字符串# 常用佔位符# %s 格式化字符串# %d 格式化整數格式化字符串# 使用佔位符,格式化print("我喜歡%s,在%s年" %(c,n))f = 1.133格式化字符串# 轉義字符串# \\# \'
  • CTF丨Linux Pwn入門教程:針對函數重定位流程的相關測試(上)
    Linux Pwn入門教程系列分享如約而至,本套課程是作者依據i春秋Pwn入門課程中的技術分類,並結合近幾年賽事中出現的題目和文章整理出一份相對完整的Linux Pwn教程。教程僅針對i386/amd64下的Linux Pwn常見的Pwn手法,如棧,堆,整數溢出,格式化字符串,條件競爭等進行介紹,所有環境都會封裝在Docker鏡像當中,並提供調試用的教學程序,來自歷年賽事的原題和帶有注釋的python腳本。今天i春秋與大家分享的是Linux Pwn入門教程第11章:針對函數重定位流程的相關測試(上),閱讀用時約15分鐘。
  • linux-kernel-pwn qwb2018 core
    根據insmod /core.ko大概知道了存在漏洞的模塊為core.ko,是主要分析的目標。,首先是越界讀寫漏洞,棧大小只有0x40,而off可以隨意設置,因此可以通過越界讀實現canary等信息的洩露。
  • 從WordPress SQLi談PHP格式化字符串問題
    因為sprintf的問題 (vsprintf與sprintf類似) , '%s' 的前一個  '  會被吃掉, %1$'%s  被格式化為 _thumbnail_id ,最後格式化字符串出來的語句會變成
  • Python格式化字符串(格式化輸出)
    表 1 Python 轉換說明符轉換說明符解釋%d、%i轉換為帶符號的十進位整數%o轉換為帶符號的八進位整數%x、%X轉換為帶符號的十六進位整數%e轉化為科學計數法表示的浮點數(e 小寫)%E轉化為科學計數法表示的浮點數(E 大寫)%f、%F轉化為十進位浮點數%g智能選擇使用 %f 或 %e 格式%G智能選擇使用 %F 或 %E 格式%c格式化字符及其 ASCII 碼%r使用
  • CTF小白入門學習指南
    其中,題目大概有這麼幾個 web,密碼學,pwn(綜合滲透),misc(雜項),reverse(逆向),ppc(編程類)而攻防模式的比賽一般就是每一個參賽隊伍,在同一個網絡中,進行相互攻擊和防守,以發現對手伺服器的漏洞,修補和防禦己方伺服器漏洞來得分,一般比賽時間較長,而混合模式就是兩者皆有。那應該如何開始你的CTF得旅程呢?