在上一篇文章中,我們介紹了程式的執行過程,提到 _start()
會呼叫 __libc_start_main()
函數,而 __libc_start_main()
會依序執行 init()
、main()
和 fini()
。然而,這些函數究竟是如何被呼叫並執行的?
本篇文章將說明 Stack 是如何幫助函數呼叫執行,並說明 main()
在執行過程中,Stack 起到了什麼作用。
本篇文章的架構如下:
main()
-Stack 用於函數呼叫main()
- Stack 用於儲存函數資料main()
執行由於 main()
是一個函數,因此 __libc_start_main()
想執行它的時候,必須要呼叫它。這時我們需要考慮到三個問題:
main()
函數?main()
函數?main()
函數執行完後,如何讓 CPU 知道從哪裡繼續執行,即如何跳回到 __libc_start_main()
呼叫 main()
的下一行位置(後續將稱呼為 Return Address)?解決方法很簡單,就是將參數的值、main()
的位址以及 Return Address 儲存起來。至於存在哪裡、以什麼順序存會依據不同的 CPU 架構有所規範,這個規範稱為呼叫慣例(Calling Convention)。CPU 會依據呼叫慣例,依指定的順序去指定的位置存取這些資料,從而解決上述三個問題。
x86-64 架構下的呼叫慣例如下:
rdi
、rsi
、rdx
、rcx
、r8
、r9
等暫存器(Registry)中,如果參數超過6個,從第7個參數開始會存放在 Stack 中,回傳值則存放在 rax
暫存器裡call
指令需要將 Return Address 推入 Stack 中,接著跳轉到目標函數位址ret
指令需要將 Return Address 從 Stack 中彈出可以看出,呼叫慣例中所有與位址相關的存取操作都依賴於 Stack。在進入具體範例之前,我們先來簡單介紹一下 Stack 的概念。
補充:暫存器(Register)是一種 CPU 的內部元件,用來暫存運算過程中的值和指令。
Stack 是一種資料結構,類似於品客洋芋片的罐子。當你往 Stack 裡添加資料時(放洋芋片),它們會依序疊在前一個資料之上,這個動作稱為推入(Push);當需要取出資料時(拿洋芋片),最上面的資料會先被彈出,這個動作稱為彈出(Pop)。可以發現越晚放進去的會越先被拿出,因此它是一種後進先出(LIFO,Last In First Out)的資料結構。
可以注意到上一章說明程式在記憶體的佈局時,Stack 的箭頭是往下,代表它是往下(低位址)生長,可以想像成倒放的洋芋片罐。
接下來,我們將透過組合語言來觀察函數呼叫的實際流程,並解釋 Stack 如何在呼叫、執行函數及函數結束時發揮作用。為了讓讀者能更具體地理解這些過程,我們會使用 Pwngdb 進行逐步演示和說明。
Pwngdb 是由 Angelboy 基於 GDB(GNU Debugger)開發的一款專為 Pwn 設計的 Debug 工具,基本操作與 GDB 相同,但進行 Pwn 分析時更加便利。
main()
-Stack 用於函數呼叫在這部分,我們將從 _start()
開始一路追蹤到 main()
,並以呼叫 main()
的過程說明 Stack 如何在函數呼叫中發揮作用。
註:
__libc_start_main()
的內部實作中,實際上是透過__libc_start_call_main()
來呼叫main()
,因此,接下來的範例會看到__libc_start_call_main()
呼叫main()
而不是直接由__libc_start_main()
呼叫。
以下是具體操作步驟:
b *_start
(Break)命令,在程式進入點設置斷點,然後使用 r
(Run) 執行程式,此時,程式會停在 _start()
的斷點處。接著使用 ni
(Next Instruction)逐步執行程式,直到抵達 _start+27
。補充:如果想跳過步驟,也可以直接透過
b *_start+27
命令將斷點設在_start+27
並執行。
_start+27
處,可以看到程式呼叫 __libc_start_main()
,我們先繼續往下執行,使用 si
(Step Into)命令進入到被呼叫的函數。b *__libc_start_call_main+120
下斷點,接著使用 c
(Continue)繼續執行,直到遇到斷點。補充 1:如果在執行
r
命令之前嘗試對__libc_start_call_main+120
設置斷點,會發現 Pwngdb 報錯,提示沒有 Symbol Table。這是因為 libc 是動態載入的。在程式啟動前,動態鏈接的函式庫還沒有載入到記憶體,因此 Debugger 無法解析相關 Symbol,這就是為什麼無法在一開始就對該函數設置斷點的原因。
補充 2:依據環境與 glibc 版本不同,讀者的函數位址的偏移量(
_start+27
的 27 即為偏移量)可能會跟上面例子有所差異,這時需要讀者自己使用ni
一步一步找到目標位址。
當程式停在最後的斷點時,可以觀察到__libc_start_call_main()
使用 call
指令來呼叫 rax
暫存器中的位址,我們可以觀察 Pwngdb 上方 REGISTERS 欄位中的 rax 值。
發現 rax
儲存一個位址, ◂— push rbp
代表著是這個位址儲存的資料,也就是 main()
的第一條指令。即代表它使用 call
指令來呼叫 main()
這個函數。
接下來,使用 si
進入 main()
,我們會觀察到兩個暫存器的變化,這些暫存器將以紅色標示。
以下是這兩個暫存器的說明:
RIP
:我們知道 CPU 在執行的過程中是一條一條往下執行的,而 CPU 如何知道下一條指令的位置就是透過 RIP
,它負責儲存下一條指令的位址。在 DISASM 欄位中 ► 指向的綠色指令代表著下一條要執行的指令,可以看到它的位址與 RIP
存的位址相同。RSP
:前面有提到 Stack 會在記憶體當中佔一個區塊,並且是往下生長。RSP
就是用於存它的。整個運作流程如下圖所示:RSP
去做存取,回到程式,我們可以注意到在還沒呼叫前 Stack 是長這樣的:call
指令呼叫函數後變成這樣:補充-如何讀 Pwngdb 的 Stack 欄位:
為了方便閱讀,Pwngdb 的 Stack 部分由下往上是高地址到低地址(將倒置的洋芋片罐轉回來)。
此外0x7fffffffd748 —▸ 0x7ffff7deac8a ◂— mov edi, eax
代表的是在0x7fffffffd748
儲存了0x7ffff7deac8a
這個位址,而這個位址上的值為◂— mov edi, eax
。
用示意圖表示如下:
由此可見,call
這個指令做了兩件事,一是將 Return Address 推入 Stack,二是跳轉進 main()
內執行函數內容。
main()
- Stack 用於儲存函數資料在上一篇文章中,我們已經介紹了全域變數儲存在哪些 Sections,但還沒提到區域變數的存放位置。區域變數是由 Stack 來管理的。由於一個程式可能會使用到許多函數,作業系統會為每個函數分配一塊專屬的 Stack 空間,稱為 Stack Frame。為了實現這個功能,編譯器會在每個函數的開頭加上一些指令,這些指令稱為 Function Prologue(函數前言)。
接續上面的過程,我們來看看 main() 函數開頭的三行指令,這三行就是 Function Prologue。
前面已經介紹過 RSP
是指向 Stack 頂端的暫存器,那麼 RBP
又是什麼呢?由於每個函數都有自己的 Stack Frame,為了讓作業系統知道 Stack Frame 的起始位置,需要一個暫存器來指向 Stack Frame 的底部(如洋芋片罐的底部)。這個暫存器就是 RBP
,它負責儲存 Stack Frame 底部的位址。
接下來我們逐一說明這三條指令:
push rbp
RBP
的值被 Push 進了 Stack。為什麼要有這條指令?當一個函數呼叫另一個函數時(例如 main()
去呼叫 printf()
),兩者都有各自的 Stack Frame。因此,在被呼叫函數執行完畢後,呼叫者需要恢復其原有的 RBP
。這就是為什麼在每個函數執行前,需要先儲存呼叫者的 RBP
,這個值通常被稱為 Saved RBP。mov rbp, rsp
RSP
的值複製給 RBP
,因此我們可以注意到現在 RBP
跟 RSP
相同,指向 Stack 中的同一個地方。現在 RBP
指向的內容即為被呼叫者的 Stack Frame 底部。sub rsp, 0x10
RSP
減掉 0x10,可以注意到一開始 RSP
的值為 0x7fffffffd740
現在變為 0x7fffffffd730
。而現在 RSP
與 RBP
之間的空間就是 Stack Frame。到目前為止,整個 Stack 的示意圖如下:
main()
執行繼續往下執行程式,我們會看到以下這兩條指令:
這兩行是 Function Epilogue(函數後語),負責將程式控制權交還給呼叫者。
leave
leave
這個指令其實是由 mov rbp, rsp
與 pop rbp
這兩個指令所組成。它首先先將 RSP
的值複製給 RBP
。RSP
指著的 Stack 頂部的值為 Saved RBP,此時再透過 pop rbp
將 Saved RBP 的值彈出並存入 RBP
,RBP
就能恢復被呼叫前的狀態。ret
ret
指令等價於 pop rip
,由於經歷過剛剛的 leave
,因此 Stack 變成這樣:RIP
的值會變成 Return Address,CPU 會依據 RIP 指向的位址繼續執行,回到呼叫函數的下一條指令。以上就是整個函數呼叫的流程,下一篇文章將會說明攻擊者會如何利用 Stack。