當前位置:
首頁 > 新聞 > Pwnable.tw刷題之calc

Pwnable.tw刷題之calc

*原創作者:oO0ps,本文屬FreeBuf原創獎勵計劃,未經許可禁止轉載



受到基友的耳濡目染,最近開始入坑CTF。接受他的建議,先在pwnable.kr和pwnable.tw兩個平台上玩玩題。其中pwnable.kr建立較早,上面的題目難度從易到難,相鄰題目的難度躍動不大,但是涉及知識面較廣,網上的writeup也非常多,非常適合新手練習;pwnable.tw建立較晚,題目難度相對於前者較大,適合進階。




我是兩個平台交替著來,這個玩不下去了就換另一個。前幾天做到了pwnable.tw的第三題,著實讓我這個剛入坑的菜雞絞盡腦汁。此題的漏洞比較有意思,難度對於剛入坑的新手小白來說也可以接受,在此分享我的解題思路。


00 題目解析


題目如下圖所示:




由題目可知,這是一道關於計算器的題目。pwnable.tw上每題的flag文件都在/home/xxx/flag,其中xxx是題目的名字。我們先用題中所給的命令連接一下目標伺服器:



在出現歡迎信息後,我輸入了8*9,目標程序就返回了計算結果72。可見,這個目標程序確實具備計算功能。我們的目的是獲取伺服器上flag文件的內容,看來只能通過挖掘目標程序的弱點或漏洞來想辦法顯示flag。

點擊黃色的calc,便可將目標程序下載到本地。接下來,我們要對目標程序進行分析,看看是否存在漏洞可以在伺服器上顯示出flag文件的內容。


01 演算法分析


主函數分析


首先對程序進行靜態分析。將程序丟到IDA中,發現主函數流程較簡單,一個定時器,一個歡迎信息的輸出,以及一個calc函數。這個calc應該就是程序的核心函數了。主函數彙編代碼如下圖所示:




calc 函數分析


我們進入calc函數,重點分析該函數執行流程。函數一開始就將canary壓入棧內,作為對棧溢出攻擊的第一層保護(下圖中large gs:14h就是canary的值,被壓入棧中ebp-0xc的位置)。


canary(金絲雀)是一種簡單高效的保護棧內數據不被改寫的方式,該方法就是在棧的尾部插入一個隨機值(因為函數返回地址常在當前棧的尾部),當函數返回之時檢測canary的值是否經過了改變,以此來判斷棧溢出攻擊是否發生。然而對於本題存在的漏洞來說,這種方式還不足以保護函數的返回地址被攻擊者篡改,原因在下文我會介紹到。


壓入canary過程如下圖所示:



接下來calc函數調用_bzero將一段長度為1024位元組的數據expr清0,並調用get_expr函數接收用戶輸入的運算表達式,若表達式格式合法,則將其放到expr中去。


也就是說,expr即為運算表達式所在字元串。如下圖所示:



其中get_expr函數過濾了非法字元,只留下數字和「+,-,×,/,%」這五個運算符。該函數的具體流程在這裡就不贅述了,感興趣的朋友請自行分析。


接下來,init_pool函數的流程比較簡單,它在當前棧上分配了一段100個字(400位元組)的空間,將其內容清0。


那這段空間具體是做什麼的?


我們在下文將會介紹,請大家記住它,因為它將是我們漏洞溢出的關鍵。


萬事俱備,只欠東風。該輸入的輸入的,該分配的分配了,那誰來解釋並處理我們輸入的運算表達式呢?這個核心的工作就交由parse_expr函數來處理。init_pool和parse_expr的調用如下:



用IDA的F5功能可以更直觀地看出calc函數的調用過程:



parse_expr 函數分析


該函數主要分為兩個步驟:解析運算表達式、計算運算結果。下面我們來逐步分析一下parse_expr函數。


該函數共有兩個參數,參數一為用戶輸入的運算表達式的地址,參數二為上文提到的init_pool函數分配的一段地址空間。函數開始時同樣使用了canary的方式保護棧空間,之後又分配了100位元組的空間給一個數組operator[100],這個數組的作用是保存所有的操作符。



那麼函數究竟如何處理用戶輸入的運算表達式呢?


大家可以先自己思考一下,如果讓我們自己寫一個計算器,大概的流程應該是什麼?首先,我們肯定需要將操作數和運算符分離開,然後再通過運算符來對運算符兩邊的操作數進行相應的計算。


但是,計算機不像人一樣,當看到「100」就能馬上識別出來這是數123,而是首先把它當作字元串處理,一個字元一個字元地讀取,先讀取「1」,再讀取「2」,再讀取「3」,直到讀取到一個不是數字的字元,才把字元串「123」當成數123。這個函數的流程也是如此。


如下圖所示,首先,函數進入一個大循環,來對運算表達式的每個字元進行分析和處理。若當前字元的ascii碼值減去48

(數字0的ascii碼值)

大於9,則將其識別為運算符;若小於或等於9,則將當前字元當作數字處理(數字0~9的ascii碼值為48~57)。


疑問


ascii碼小於48的字元可不止運算符,大於48的字元也不止數字啊,難道可以任意輸入嗎?大家還記得上文中提到的get_expr函數嗎,就是它在用戶輸入的時候將不合法的字元全都過濾掉了,因此這時不會有其它非法的字元存在。

我查了ascii碼錶,「+,-,×,/,%」幾個運算符的ascii碼值都比48小啊,減去48是負數,肯定也小於9啊,為什麼會大於9?因為這時定義的差值變數是無符號整型(unsigned int)的,作為一個無符號的數,它的值將遠遠大於9。



如果當前字元為數字,那麼循環中什麼也不做,只把seq+1,進入下一個循環。如果當前字元為運算符,函數要做的第一件事情並不是解析運算符,而是將運算符前面的字元串轉化為整數保存起來。


保存在哪裡呢?


就保存在傳入parse_expr函數的參數initpool裡面。



由上圖我們可以看出,函數首先取操作數左邊的字元串,若字元串為「0」,也就是說,用戶輸入的操作數為0,那麼報錯並直接退出當前運算過程。這是該題的一個小bug,因為從數學上來說,0除了作為除數,還是可以參與運算的。但是畢竟這只是一道pwn題,因此我們就忽略了這個小bug吧。


接下來,函數將操作符左邊的操作數轉換為int型數值,並將數值保存在initpool中。但是在這裡我們要注意一個問題,從函數邏輯來看,操作數是從initpool[1]的位置開始保存的。


那麼initpool[0]是幹什麼用的呢?


從count=(*initpool)++這一條語句來看,initpool[0]應該是保存當前運算數個數的。


它相當於一個指針,每次函數要保存操作數進去的時候,先判斷initpool[0]當前的值是多少,若值為0(第一次保存操作數時),那麼就將當前操作數保存在initpool[0+1]的位置上,若值為5(當前已經保存了5個操作數,類似1+2+3+4+5這種情況),就將當前操作數保存在initpool[6]的位置上。也就是說,initpool可以理解為一個帶頭部的數組,其頭部(initpool[0])保存著當前數組中操作數的個數,而從initpool[1]往後依次保存著各個操作數。


由此可見,這個程序兩個最重要的數據結構為initpool[]和operator[],它們分別保存了操作數和操作符。


接下來為了保證輸入表達式的正確,函數對當前操作符的後一個字元進行了判斷,若後一個字元也是操作符(類似5+×7這種情況),則視當前表達式非法,退出此次運算。




下面就進入到parse_expr函數的關鍵部分:




如圖。首先判斷operator數組中operator[seqopr]這個元素的值是否為0。operator數組保存了所有的操作符,而operator[seqopr]則保存了當前所解析操作符的上一個操作符。比如「7+9-5」這樣一個運算表達式,當我們處理到「-」時,operator[seqopr]保存的就是「+」。


若operator[seqopr]為空,也就是說,當前處理的操作符為表達式的第一個操作符,那麼函數就進入else,將當前操作符保存在operator[seqopr],也就是operator[0]。若當前操作符不是表達式的第一個操作符,那麼就進入if條件。該if條件的作用,就是保存當前操作符至operator數組中,並進行之前操作符所對應的那部分運算。這裡可能比較難理解,舉個例子,比如表達式:


1+3-2


當處理運算符「+」時,由於這是該表達式的第一個運算符,函數只是將其左值「1」保存至initpool[1],並將「+」保存至operator[0],然後繼續循環。當處理到運算符「-」時,initpool中已經有兩個值,「1」和「3」,operator中也保存了一個值「+」,也就是說,此時運算場景為:



initpool[0]=2,initpool[1]=1,initpool[2]=3operator[0]="+"


它的含義是:兩個操作數1和3進行加法運算。


這時函數會首先對」1+3」這部分進行運算,然後將「-」運算符放在operator[1]中,等待著下一次運算。


下一次運算什麼時候開始呢?


別忘了表達式可是一個字元串,它的結尾是一個「0×0」,當循環處理到「0×0」的時候,就開始了「-」這部分的運算。


那麼就有同學可能會問,「1+3」的結果保存在哪兒呢?答案在eval函數中:



eval函數將計算「1+3」的結果,並將結果「4」保存在之前數值」1」所在的位置,也就是initpool[initpool[0]-1]=initpool[1]中。這樣一來,當parse_expr函數處理到最後一個字元「0×0」的時候,當前運算場景如下:


initpool[0]=2,initpool[1]=4,initpool[2]=2operator[0]="+",operator[1]="-"


此時函數會通過eval進行「4-2」的運算,並將運算結果仍然保存在initpool[1]中。也就是說,每次進入eval函數時,initpool永遠只有三個有效元素,即下標initpool[0](在eval函數中總等於2),左操作數initpool[1]和右操作數initpool[2],並將運算結果放在initpool[initpool[0]-1]=initpool[1]中。這符合一次運算的必備條件,即:


一個運算符和兩個操作數


經過parse_expr函數的多次運算,最終會將計算結果輸出給用戶:



上圖

中,ebp+var_5A0的位置為initpool[0],ebp+var_59C的位置為initpool[1],因此最後輸出的結果應為:


initpool[1+initpool[0]-1]=initpool[initpool[0]]


這時候,漏洞就出現了(敲黑板!)。


漏洞分析


在上面的分析中我們可以知道,雖然eval函數看似每次都將運算結果放在initpool[1]中,但是實際上這個下標「1」是由initpool[0]-1得到的。由於正常的運算中initpool[0]總是等於2,因此我們總能將運算結果放到initpool[1]中,並最終將initpool[1]的值作為整個運算表達式的運算結果返回給用戶。可是實際上,我們返回的是initpool[initpool[0]]的值。若我們能改變initpool[0]的值為任意值,那麼我們就有可能泄露棧上的某個位置的值,甚至能通過運算改變該位置的值。



我們知道,initpool[0]的初始值為0,那麼initpool[0]的值最開始是從哪裡改變的呢?看下面這段代碼:



這段代碼的含義是:若運算符左邊的操作數存在,那麼就將操作數放到initpool[initpool[0]+1]的位置,並將initpool[0]的值+1。


如果當前操作符左邊的操作數不存在呢?


也就是說,表達式的第一個字元就是運算符而不是操作數呢?這樣的話,initpool[0]的值在解析下一個操作符之前就還是0,而不是1,當第一次進入eval函數時,我們的運算場景就出現了一個不符合運算條件的情況:


一個運算符和僅有的一個操作數


比如我們輸入「+300」這樣一個畸形的運算表達式,當函數處理到最後一個字元「0×0」,這時的運算場景如下:


initpool[0]=1,initpool[1]=300


operator[0]="+"


eval函數中,由於initpool[*initpool - 1] = initpool[*initpool - 1] +initpool[*initpool],所以initpool[0]=initpool[0]+initpool[1]=301,最後initpool[0]自減1,因此,輸出給用戶的最終值為initpool[300]。這樣就泄露了棧上ebp-5A0h+300=ebp-1140位置里的值。結果如下圖所示:




若我們輸入形如「+300-20」,「+300+1000」,則會對棧上的值進行計算再輸出:




上圖得知,initpool[300]的值本來為0,經過計算後輸出了-20。


那麼我們究竟有沒有對initpool[300]這個位置的數修改成功呢?


我們可以做如下實

驗:




可以看出,沒有修改成功。。。大失所望。。。但是不知道大家是否記得,calc函數中每次運算的循環周期都會對initpool和表達式緩衝區s進行清0(如下圖所示),是不是因為這個原因呢?如果是這樣,我們就找一塊不在它們裡面的棧空間來計算。




由於ebp-5A0h到ebp-0Ch這段棧空間都被initpool和s覆蓋,每次循環都會被清0,因此我們找到ebp-0Ch這個4位元組棧單元來測試。該空間為initpool[357]。測試結果如下:




從圖中可以看出,修改成功了!我們成功地將

initpool[357]這個4位元組棧單元內的值覆蓋為另一個計算過的值!這是一個振奮人心的消息,

因為函數的返回地址就在它的後面,也屬於可被修改的棧單元!


回到我們開始的問題,為什麼canary不足以保證棧上數據被篡改?因為canary的位置在函數返回地址之後,而該題的漏洞允許攻擊者繞過canary直接篡改返回值,因此canary的值不變,也就不會給攻擊者進行棧溢出帶來麻煩。


我們嘗試著修改原返回值地址里的值,將其替換成其它值。我們首先要知道函數的返回地址在棧的位置,摸清該位置與initpool的起始位置的距離,這樣才能通過initpool來修改返回地址。



從上圖可以看出,當前的棧空間比較清楚明了,initpool距離當前棧的起始位置為5A0h=1440位元組,也就是1440/4=360個棧單元,而眾所周知,返回地址是在當前ebp位置的前一個位置入棧,也就是說,返回地址距離initpool的地址為361個棧單元即initpool[360]。當前棧空間如下圖:




我們可以通過輸入「+361」來泄露返回地址:




可以看到,在輸入+361後,程序返回134517913,即0×08049499。查看IDA,在main函數中,調用calc函數的下一條彙編指令為mov指令,它的地址即為0×08049499:



而當我們輸入「+361-999」的時候,該返回地址就被修改了:



這樣一來,我們的思路就很清晰了:

通過

不斷地輸入畸形運算表達式來修改棧空間內函數返回地址及其之後的值,最終實現棧溢出攻擊。


漏洞利用


由於目標系統開啟了NX,無法直接在棧上執行shellcode,而且使用objdump命令可知,該程序是完全靜態鏈接的(下圖),因此我們首先考慮的就是使用ROP技術來想辦法調用execve(「/bin/sh」)來啟動Linux shell,再通過cat命令查看flag的內容。




若想調用execve(「/bin/sh」),則需要構造一個ROP鏈來創建場景。我個人一直認為ROP是安全領域裡的一項十分有藝術性的技術,它的思路很巧妙,也能激發攻守雙方的頭腦風暴。


我們知道,在製作shellcode時,通常使用int 80h來調用某個系統函數,而int 80h這條指令,往往是通過eax寄存器的值來判斷調用哪個系統函數,且通過ebx、ecx、edx等寄存器來存放要調用的系統函數的參數。


在本題的場景中,execve函數的系統調用號為11,也就是說,我們在調用int 80h之前,需要將eax的值置為11。同時,execve函數共有三個參數,其中在這裡只有第一個參數「/bin/sh」有用,而另外兩個參數可為0。這樣一來,我們就需要構建ROP鏈,將寄存器場景變為:


eax=11


ebx=「/bin/sh」字元串的地址


ecx=0


edx=0


ROP鏈是由若干條ROP「小部件」組成的,其中每個「小部件」都是一個以「ret」指令結尾的彙編指令片段,而這些ROP鏈的位置都不在棧上,而在程序的可執行的段內(如.text段)。比如「pop eax; ret」就是一個「小部件」,它的功能是將當前棧頂的數值彈出並放入eax中,並返回到棧頂內的值指向的地址去繼續執行程序。


只要我們將每個「小部件」的地址從函數返回值處開始依次存入棧中,程序就會依次跳到每個「小部件」上執行相應的代碼,此時棧空間內的每個單元的數據就相當於程序的指明燈,告訴程序該去哪裡執行,而不會在棧上執行任何代碼。




我使用ROPgadget這個工具來生成ROP小部件,從而構建ROP鏈。為了將eax的值置為11,我找到了「pop eax; ret」(地址為0x0805c34b)這個小部件,通過將棧上值11彈出並存入eax來修改eax的值;而後,為了將edx置為0,我找到了「pop edx; ret」(

地址為

0x080701aa)這個小部件,原理相同。


最後,我通過「pop ecx; pop ebx; ret」(地址為0x080701d1)這個小部件將ecx和ebx的值置為0和「/bin/sh」字元串的地址。我們要構建的ROP鏈在棧上的情況如下:



分析清楚了要構造的場景,剩下的就靠我們通過輸入的畸形表達式來計算並設置initpool的361~370這十個棧單元。對於每一個棧單元,我們首先獲取其內的值,而後計算該值與目標值的差,最後相減即可。比如我們要將362位置上的值變為11,首先輸入「+362」得到當前362棧單元的值135184896,然後計算135184896-11=135184885,最後輸入「+362-135184885」將棧內值修改為11。



其中唯一比較麻煩的是「/bin/sh」字元串地址的獲取。它是一個棧上的地址,而我們目前暫時無法知道棧的基址。但是別忘了,在當前棧內的某個空間保存這一個棧的地址,那就是當前ebp所指向棧的基址內的值,這個值是main函數的ebp值,也就是main函數的棧基址。那麼我們只要知道main函數基址與calc函數基址的關係就可通過main函數基址計算出「/bin/sh」字元串的地址。由下圖可以看出,main函數的棧空間大小由main函數的基址決定,大小值為:


main_stack_size=main_ebp&0xFFFFFF0 - 16



目前可知「/bin/sh」字元串的地址(369)與返回地址(361)之間的距離為8,而main函數棧基址與返回值之間的距離為:


dd_mainebp_ret=main_stack_size/4 + 1


也就推得「/bin/sh」字元串的地址為:


addr_binsh=main_ebp+(8-d_mainebp_ret)*4


現在我們就可以編寫POC來對伺服器上的目標程序開展攻擊了,我的POC如下:





最終就可以獲取目標伺服器shell,並用cat命令顯示出flag啦




flag我就打碼了,小夥伴們快去尋覓吧!


*原創作者:oO0ps,本文屬FreeBuf原創獎勵計劃,未經許可禁止轉載


喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 FreeBuf 的精彩文章:

NSA工具DoublePulsar已入侵數萬Windows設備,來看你是否也在其中?
XSS利用之延長Session生命周期
開源的理念做安全:FreeBuf與HackerOne COO王寧對談安全眾測
PostgreSQL管理工具pgAdmin 4中XSS漏洞的發現和利用
威脅情報怎麼用?

TAG:FreeBuf |