C語言函式呼叫棧
棧溢位(stack overflow)是最常見的二進位制漏洞,在介紹棧溢位之前,我們首先需要了解函式呼叫棧。
函式呼叫棧是一塊連續的用來儲存函式執行狀態的記憶體區域,呼叫函式(caller)和被呼叫函式(callee)根據呼叫關係堆疊起來。棧在記憶體區域中從高地址向低地址生長。 每個函式在棧上都有自己的棧幀,用來存放區域性變數、函式引數等資訊。當caller呼叫callee時,callee對應的棧幀就會被開闢,當呼叫結束返回caller時,callee對應的棧幀就會被銷燬。
下圖展示了棧幀的結構。在32位程式中,暫存器ebp指向棧幀的底部,用來儲存當前棧幀的基址,在函式執行過程中不變,可以用來索引函式引數和區域性變數的位置。暫存器esp指向棧幀的頂部,當棧生長時,esp的值減少(向低地址生長)。暫存器eip用於儲存下一條指令的地址。在64位程式中,三個暫存器分別為rbp、rsp和rip。
當函式呼叫發生時,首先需要儲存caller的狀態,以便函式呼叫結束後進行恢復,然後建立callee的狀態。具體來說:
-
如果是32位程式,將傳給callee的引數按照逆序依次壓入caller的棧幀中;如果是64位程式,將傳給callee的引數按照逆序依次傳入暫存器r9、r8、rcx、rdx、rsi、rdi,如果引數的個數超過了6個,將其餘引數壓入caller的棧幀中。如果callee不需要引數,則這一步驟省略。
-
將caller呼叫callee後的下一條指令的地址壓入棧中,作為callee的返回地址,這樣,當函式返回後可以正常執行接下來的指令。
-
將當前ebp暫存器的值壓入棧中,這是caller棧幀的基址,將ebp更新為當前的esp。
-
將callee的區域性變數壓入棧中。
-
函式呼叫結束後,就是上面過程的逆過程,callee棧幀中資料會出棧,恢復到caller棧幀狀態。
上面的第1步由caller完成,第2步在caller執行call指令時完成,第3、4步由callee完成。
下面看一個具體的例子,callerStack.c程式碼如下:
// callerStack.c
// C語言函式呼叫棧
# include <stdio.h>
int func(int arg1, int arg2, int arg3, int arg4, int arg5, int arg6, int arg7, int arg8)
{
int loc1 = arg1 + 1;
int loc2 = arg8 + 8;
return loc1 + loc2;
}
int main(void)
{
int ret = func(1, 2, 3, 4, 5, 6, 7, 8);
return 0;
}
用命令gcc -m32 callerStack.c -o callerStack32
生成32位程式,用gdb反彙編,得到的結果如下:
(這裡額外說一下,如果是在64位機器上執行上述命令可能會報錯: fatal error: bits/libc-header-start.h: No such file or directory #include <bits/libc-header-start.h>
,需要安裝multilib庫:sudo apt install gcc-multilib
)
0x565561dd <main> endbr32
0x565561e1 <main+4> push ebp ; 將ebp入棧,儲存caller的基址,esp -= 4
0x565561e2 <main+5> mov ebp, esp ; 將ebp更新為當前的esp
0x565561e4 <main+7> sub esp, 0x10 ; esp -= 0x10
0x565561e7 <main+10> call __x86.get_pc_thunk.ax <__x86.get_pc_thunk.ax> ; 沒看懂
0x565561ec <main+15> add eax, 0x2df0 ; 沒看懂
0x565561f1 <main+20> push 8 ; 引數入棧,esp -= 4
0x565561f3 <main+22> push 7
0x565561f5 <main+24> push 6
0x565561f7 <main+26> push 5
0x565561f9 <main+28> push 4
0x565561fb <main+30> push 3
0x565561fd <main+32> push 2
0x565561ff <main+34> push 1
0x56556201 <main+36> call func <func> ; 呼叫func,返回地址入棧
0x56556206 <main+41> add esp, 0x20 ; 恢復棧頂
0x56556209 <main+44> mov dword ptr [ebp - 4], eax ; eax存放func的返回值
0x5655620c <main+47> mov eax, 0
0x56556211 <main+52> leave
0x56556212 <main+53> ret
0x565561ad <func> endbr32
0x565561b1 <func+4> push ebp ; 將ebp入棧,儲存caller的基址,esp -= 4
0x565561b2 <func+5> mov ebp, esp ; ebp更新為當前的esp
0x565561b4 <func+7> sub esp, 0x10 ; esp -= 0x10
0x565561b7 <func+10> call __x86.get_pc_thunk.ax <__x86.get_pc_thunk.ax> ; 沒看懂
0x565561bc <func+15> add eax, 0x2e20 <func+15> ; 沒看懂
0x565561c1 <func+20> mov eax, dword ptr [ebp + 8] ; 取出arg1(值為1),放入eax中
0x565561c4 <func+23> add eax, 1 ; arg1 + 1
0x565561c7 <func+26> mov dword ptr [ebp - 8], eax ; 計算結果(區域性變數loc1)放入棧中
0x565561ca <func+29> mov eax, dword ptr [ebp + 0x24] ; 取出arg8(值為8),放入eax中
0x565561cd <func+32> add eax, 8 ; arg8 + 8
0x565561d0 <func+35> mov dword ptr [ebp - 4], eax ; 計算結果(區域性變數loc8)放入棧中
0x565561d3 <func+38> mov edx, dword ptr [ebp - 8]
0x565561d6 <func+41> mov eax, dword ptr [ebp - 4]
0x565561d9 <func+44> add eax, edx ; eax = eax (loc8) + edx (loc1),函式返回值存放在eax中
0x565561db <func+46> leave ; mov esp, ebp pop ebp
0x565561dc <func+47> ret ; pop eip
以上就是C語言函式的呼叫過程以及棧的情況,但是我還有幾點疑問沒有弄清楚,記錄一下:
-
為什麼在函式剛開始的地方
sub esp, 0x10
,從後面的程式碼來看,開闢的空間用於存放區域性變數,那為什麼不是在區域性變數定義的時候將區域性變數的值入棧,再移動esp呢?而是一次性先esp -= 0x10
,這樣不會帶來空間的浪費嗎? -
call __x86.get_pc_thunk.ax
是什麼意思? -
add eax, 0x2e20
有什麼作用?
參考資料
星盟安全團隊課程:https://www.bilibili.com/video/BV1Uv411j7fr
CTF競賽權威指南(Pwn篇)(楊超 編著,吳石 eee戰隊 審校,電子工業出版社)
https://www.cnblogs.com/xuyaowen/p/libc-header-start.html