「棧」(Stack)是一種受限的線性表,其只允許在線性表指定的一端(稱之為棧頂)進行數據的插入操作與刪除操作,因此通常數據的插入操作被形象地稱呼為“將數據壓入棧中”(push),而相應的數據刪除的操作則也被稱為“將數據從棧上彈出”(pop)——雖然在筱懿明剛開始學英文的時候,push(推)與pull(拉)這兩個詞是相互配對的,但是現在問他 push 的反義詞是什麽,他腦海裡浮現出的第一個答案則毫不猶豫地會是“pop”。
棧這一數據結構有著諸多的應用場景,例如括號匹配、逆波蘭表達式的解析等,不過這一次筱懿明所要面對的並不是以往刷算法題時所遇到的那一個棧,而是在「函數調用過程」當中的一個基礎結構——
「棧幀」(stack frame)。
在介紹這個概念之前我們得先引入一個新的概念:「程序運行時棧」——當然,這玩意如果要展開來講的話需要涉及到相當多的計組、OS等知識,至少在當前階段我們的主人公筱懿明是無法直接理解好在一個進程運行的背後的所有原理的,但是至少在經歷了那麽多天的從互聯網上獲取知識的過程之後,目前他可以理解的是:
①我們常說的“運行內存”指的是「Dynamic Random Memory」,也就是我們常說的“內存條”所實際呈現出來的東西,這是一種是與 CPU 直接交換數據的內部存儲器。由於其需要通電才能保持數據的存儲,因此 DRAM 屬於一種「易失性存儲器」( memory)。
②程序都是在運行時被動態地裝載到內存當中的,一個運行中的程序實體被稱之為一個「進程」(process),每個進程各自「獨立地」佔有內存中的一小塊區域,稱之為一個進程的「地址空間」(address space)。
③進程的地址空間按照類型的不同分為不同的「段」(segment),例如「text段」用來存放進程運行時需要用到的代碼,「data段」用來存放已經初始化過後的全局變量,「bss段」用來存放未初始化的全局變量。
④一個可以被操作系統載入到內存當中進行運行的文件稱之為「可執行文件」(executable file),可執行文件有兩種主流格式:「PE」(Portable Executable)與「ELF」(Executable and Linkable Format)。其中在類 UNIX 系統上運行的程序主要是 ELF 格式的。
⑤ELF格式文件主要提供了兩種視圖:「鏈接視圖」與「執行視圖」,而我們常說的可執行文件通常是執行視圖的 ELF 文件,其由三部分組成——一個ELF Header +一個Programme Header Table +多個 Segment,在不同的 Segment(段)當中存放著不同類型的數據,例如「.text段」用來存放進程運行時需要用到的代碼,「.data段」用來存放已經初始化過後的全局變量,「.bss段」用來存放未初始化的全局變量。當操作系統想要將一個可執行 ELF 文件加載到內存當中運行時,其首先會讀取 ELF Header,獲取到 Programme Header Table 的信息,再根據 Programme Header Table 來逐個讀取文件中的各個不同類型的段,為各個段分配內存並將各個段的數據從文件中拷貝到內存當中,最後從 ELF Header 當中所指定的程序入口點開始運行。
⑥進程運行時有兩個段是不存在於 ELF 文件當中的——「棧」(Stack)與「堆」(Heap),這兩個段應當由操作系統完成開辟的過程,其中「棧」是操作系統在運行新進程時都會自動分配的一塊內存,用來存放函數運行時的信息、臨時變量等數據,而「堆」則需要程序手動通過 brk 與 sbrk 這兩個系統調用完成開辟的過程,堆內存常被用作於進行動態的內存分配的工作。堆通常由低地址向高地址進行增長,而棧通常由高地址向低地址進行增長。
⑦x86架構下有兩個與棧相關的寄存器——「sp」(stack pointer)與「bp」(base pointer),其中 sp 寄存器永遠指向「棧頂」,而 bp 寄存器則指向「棧基址」——也就是「棧底」。在 64 位指令集中,這兩個寄存器同樣被擴展為 64 位的寄存器:rsp 與 rbp。
以上便是筱懿明目前所能理解的關於進程運行的幾乎全部的知識了,這還是他這些天各種在互聯網上查找資料才大致形成的一個模糊的框架,但這已經足夠他去理解「棧幀」這一概念。
「棧幀」其實是一個邏輯上的概念,通常而言,我們將程序運行時「sp寄存器」與「bp寄存器」所包含起來的一塊位於「棧段」上的內存區域稱之為一個「棧幀」,其用來存放當前所運行的函數所需要的一切信息——函數內的臨時變量、返回地址等。
每個函數有著其獨立的棧幀,存放著屬於該函數自己的數據,例如我們有如下 C 代碼:
void foo(void)
{
int another_simple_val;
// some variables
// do something
return ;
}
void a_simple_func(void)
{
int a_simple_val;
// some variables
// do something
foo();
// do something else
當我們在運行 a_simple_func()這個函數時,其棧應當形如如下形式(這裡我們先不管 rbp 寄存器所指向的是什麽數據,後面再解析):
一些其他變量←rsp
a_simple_val
一些其他變量
一些其他數據←rbp
當我們運行到調用函數 foo()的指令時,程序首先會將下一條指令的地址壓入到棧上:
下一條指令的返回地址←rsp
一些其他變量
a_simple_val
一些其他變量
一些其他數據←rbp
接下來會將當前的 rbp 的值壓入棧中:
原來的 rbp 的值←rsp
下一條指令的返回地址
一些其他變量
a_simple_val
一些其他變量
一些其他數據←rbp
接下來會將 rsp 的值給到 rbp,即此時這兩個寄存器指向同一個位置:
原來的 rbp 的值←rsp←rbp
下一條指令的返回地址
一些其他變量
a_simple_val
一些其他變量
一些其他數據
最後 rsp 再向低地址進行增長,為 foo 函數中的臨時變量開辟空間:
一些其他變量←rsp
another_simple_val
一些其他變量
原來的 rbp 的值←rbp
下一條指令的返回地址
一些其他變量
a_simple_val
一些其他變量
一些其他數據
一些其他數據
此時 rsp 與 rbp 之間的這一塊區域便是 foo 函數的「棧幀」。
而當 foo 函數運行結束時, rbp 的值又會被重新給到 rsp,此時程序為 foo 函數臨時開辟的棧幀便被“回收”了:
原來的 rbp 的值←rsp←rbp
下一條指令的返回地址
一些其他變量
a_simple_val
一些其他變量
一些其他數據
一些其他數據
之後程序會從棧上彈出之前儲存的原先的 rbp 的值,給到 rbp 寄存器,此時 rbp 便重新指回 a_simple_func()的棧底:
下一條指令的返回地址←rsp
一些其他變量
a_simple_val
一些其他變量
更古老的 rbp 的值←rbp
最後再將之前存放在棧上的調用 foo()的下一條指令的返回地址從棧上彈出,給到「指令指針寄存器 rip」,程序便能恢復a_simple_func()的繼續執行,此時的棧幀重新變回了 a_simple_func()的棧幀:
一些其他變量←rsp
a_simple_val
一些其他變量
更古老的 rbp 的值←rbp
這便是函數運行時棧幀的基本概念——當然,在這個過程當中仍然缺失了很多細節,不過這是目前我們的主人公筱懿明所能理解的最大限度的概念。而有了這個概念,筱懿明也終於明白了那一句曾經在他的夢境當中出現的奇怪的話語的真正含義——
「不要使用帶有安全隱患的 gets()函數!」
為什麽不要使用 gets()函數?為什麽 gets()函數帶有安全隱患?這是因為 gets()函數並不會限制用戶輸入的數據的「量」的大小,即「用戶可以輸入任意長度的數據」。那麽這會造成什麽樣的一個問題?舉個例子,我們想要讓用戶通過 gets()讀入一個字符串,而這個字符串剛好位於棧上:
一些其他變量←rsp
char str[0x100]←我們想要讓用戶讀入數據的位置
一些其他變量
更古老的 rbp 的值←rbp
假如用戶老老實實地輸入預期中的數據,那自然不會發生什麽問題,但若是用戶輸入了一些預期外的數據呢?例如說114514個字符‘A’,由於棧是從高地址向低地址增長的,但數據的存儲通常是由低地址向高地址,那麽此時的棧幀便會變成這個樣子:
一些其他變量←rsp
0x100個‘A’←我們想要讓用戶讀入數據的位置
很多個‘A’
很多個‘A’←rbp
此時該函數的棧幀便會被破壞掉,當函數需要用到其內部的臨時變量時,其會發現這些臨時變量的數據全都是字符‘A’——但這還不是最壞的情況,當位於棧上的返回地址被覆蓋掉時,在函數運行結束要返回時,其仍然會從棧上原先的位置取出返回地址——此時該位置已經全都被覆蓋為了字符‘A’,因此程序會獲得一個「0xAAAAAAAAAAAAAAAA」的返回地址(假如是64位程序),並嘗試跳轉到該位置繼續運行——但這通常不是一個有效的用戶空間地址,因為用戶地址空間被限制在「0x7fffffffffff」往下的 128 TB 空間內,因此程序最終會觸發缺頁異常,內核中對應的 handler 最終會將該程序給 kill 掉——我們最後所能看到的便是程序因為「 Fault」而掛掉。
但是從一個攻擊者——一個「黑客」的視角來看呢?我們可以將這個返回地址覆蓋為我們預期當中的一個有效的具有可執行權限的內存地址,當函數運行結束並返回時,其從棧上所取出的地址便是我們所覆寫上的惡意地址,並最終跳轉到那個位置去繼續執行,此時我們便成功地改寫了程序的執行流。至於怎麽跳轉、跳轉到哪、跳轉去做什麽,那這是下一步該思考的事情。但毫無疑問的是,「棧溢出」這個漏洞為一個黑客提供了一份精美的大餐——「改寫程序執行流」的權限、
而如果這個漏洞發生在遠程服務器上——例如某個帳戶系統的登入界面中,黑客便能直接改寫登入程序的執行流,這意味著他獲得了通過存在漏洞的登入程序直接控制遠程服務器的可能。
因此,當筱懿將目光重新放回到 GeekerCTF 的第一道 Pwn 題目的 main 函數的逆向結果當中的時候,他注意到——
①字符串 v4 是一個位於 main 函數的棧上的臨時變量。
②在 main 函數中使用 gets()讀取用戶的輸入到字符串 v4 當中,而 gets()函數並不限制讀入的數據的量的大小。
③若是我們能夠輸入合適長度的字符串,覆蓋掉 main 函數的棧幀上的返回地址,便能在 main 函數返回時劫持他的執行流!
那麽覆蓋為什麽東西的地址呢?筱懿明此時心中早已經有了答案——「backdoor()函數」。之前的他並不理解這一個本身只有一句 system(“/bin/sh“)的函數究竟有什麽用途,但現在他知道了——「/bin/sh」這個程序是類 UNIX 操作系統下傳統的用戶和計算機的交互界面,可以解析用戶輸入的命令並執行,也就是我們所俗稱的「命令行界面」。
“只要我通過 gets()帶來的棧溢出漏洞執行 backdoor(),便能執行「/bin/sh」,最終獲取到服務器的控制權!”筱懿明心想。
解法已經有了,backdoor 函數的地址又能直接從 IDA 當中獲取,那麽接下來就是編寫攻擊腳本了,在筱懿明尋找資料的過程中,他發現了一個非常好用的 Python 庫——「pwntools」,在其中封裝好了很多常用的工具,雖然目前這個庫的文檔暫時只有英文版本,但是對於高考英語140+的筱懿明而言,配合上各大翻譯軟件的幫助對他來說想要讀懂文檔的內容並非一件特別困難的事情,而對於有著編程基礎的他而言 Python 這一門“非常簡單的腳本語言”也是很容易就能掌握基本用法了,因此他最後編寫出如下的攻擊腳本:
from pwn import *
backdoor_addr = 0x40125a
payload = b'A'* 118+ p64(backdoor_addr)
p = remote('', 25136)# p = process('./pwn')
p.sendline(payload)
p.interactive()
由於已經在本地測試過並成功地“打通”了, 因此最後打遠程的過程非常順利,在他在 Kali Linux 的終端當中敲下「python3 pwn.py」後沒過幾秒,一行提示文字帶著一個紅色的「$」符號便出現在他眼前——這是 pwntools 中在運行 interactive()函數後會額外出現的一個符號,於是他在命令行界面中敲下——
[*] Switching to interactive mode
$ whoami
ctf
$ ls
$ cat ./
geekerctf{Y0u_Kn0w_h0w_7o_PWN!}
$
望著眼前出現的 ,筱懿明突然感覺自己變成了那個在月球上踏出第一步的男人——對整個GeekerCTF而言,解出一道200分的 Pwn 分區的第一道入門題似乎不算什麽,但對他而言,這無疑是打開了一扇新的大門——
真正的「黑客之路」的大門。
筱懿明快速選中字符串,複製,將之提交到了比賽平台上,一個熟悉的提示框浮現在他的眼前——
「Correct.」
至此,筱懿明終於解開了 GeekerCTF 中 Pwn 分區的第一道入門題,完成了“從0到1的壯舉”,同時還意味著他把明天要交的高數作業給忘在了腦後。
“總感覺好像忘了點什麽,不管了,該睡大覺了。”