x86 反彙編/函式和棧幀
在執行環境中,函式通常使用“棧幀”來訪問函式引數和自動區域性函式變數。棧幀的理念是,每個子程式都可以獨立於其在棧中的位置執行,並且每個子程式都可以像棧頂一樣執行。
當呼叫函式時,將在當前esp位置建立一個新的棧幀。棧幀就像棧上的一個分割槽。來自之前函式的所有項都在棧中更上一層,不應修改。當前函式可以訪問從棧幀到棧頁面末尾的整個棧。當前函式始終可以訪問棧的“頂部”,因此函式不需要考慮其他函式或程式的記憶體使用情況。
| 此程式碼示例使用 MASM 語法 |
對於許多編譯器來說,標準函式入口序列是以下程式碼片段(X 是函式中使用的所有自動區域性變數的總大小,以位元組為單位)
push ebp
mov ebp, esp
sub esp, X
例如,這裡是一個 C 函式程式碼片段及其生成的彙編指令(使用 ABI 標準生成的程式碼將不會有 "sub esp, 12" 指令,因為存在紅色區域)
void MyFunction()
{
int a, b, c;
...
_MyFunction:
push ebp ; save the value of ebp
mov ebp, esp ; ebp now points to the top of the stack
sub esp, 12 ; space allocated on the stack for the local variables
這意味著可以透過引用 ebp 來訪問區域性變數。考慮以下 C 程式碼片段和相應的彙編程式碼
a = 10;
b = 5;
c = 2;
mov [ebp - 4], 10 ; location of variable a
mov [ebp - 8], 5 ; location of b
mov [ebp - 12], 2 ; location of c
這一切看起來都很好,但是在此設定中ebp的作用是什麼?為什麼要儲存 ebp 的舊值,然後將 ebp 指向棧頂,然後在下一條指令中更改 esp 的值?答案是函式引數。
考慮以下 C 函式宣告
void MyFunction2(int x, int y, int z)
{
...
}
它生成以下彙編程式碼
_MyFunction2:
push ebp
mov ebp, esp
sub esp, 0 ; no local variables, most compilers will omit this line
這與預期完全一致。那麼,ebp到底做了什麼,函式引數儲存在哪裡?答案是在我們呼叫函式時找到的。
考慮以下 C 函式呼叫
MyFunction2(10, 5, 2);
這將建立以下彙編程式碼(使用稱為 CDECL 的從右到左呼叫約定,稍後解釋)
push 2
push 5
push 10
call _MyFunction2
注意:請記住,call x86 指令基本等同於
push eip + 2 ; return address is current address + size of two instructions
jmp _MyFunction2
事實證明,函式引數全部透過棧傳遞!因此,當我們將棧指標 (esp) 的當前值移入ebp時,我們將 ebp 直接指向函式引數。當函式程式碼推入和彈出值時,ebp 不會受到 esp 的影響。請記住,推入基本上會執行以下操作
sub esp, 4 ; "allocate" space for the new stack item
mov [esp], X ; put new stack item value X in
這意味著首先將返回值,然後將ebp的舊值放入棧中。因此 [ebp] 指向 ebp 舊值的位置,[ebp + 4] 指向返回值,[ebp + 8] 指向第一個函式引數。以下是對此時棧的(粗略)表示
: : | 2 | [ebp + 16] (3rd function argument) | 5 | [ebp + 12] (2nd argument) | 10 | [ebp + 8] (1st argument) | RA | [ebp + 4] (return address) | FP | [ebp] (old ebp value) | | [ebp - 4] (1st local variable) : : : : | | [ebp - X] (esp - the current stack pointer. The use of push / pop is valid now)
在當前函式執行期間,棧指標值可能會發生變化。特別是當
- 將引數傳遞給另一個函式時;
- 使用偽函式“alloca()”時。
[FIXME: 當將引數傳遞給另一個函式時,esp 變化不是問題。當該函式返回時,esp 將恢復到其舊值。那麼為什麼 ebp 在這裡有幫助呢?這需要更好的解釋。(真正的解釋在這裡,實際上不需要 ESP:https://learn.microsoft.com/en-us/archive/blogs/larryosterman/fpo)] 這意味著esp的值不能可靠地用於確定(使用適當的偏移量)特定區域性變數的記憶體位置。為了解決這個問題,許多編譯器使用ebp暫存器的負偏移量來訪問區域性變數。這使我們能夠假設始終使用相同的偏移量來訪問相同的變數(或引數)。因此,ebp 暫存器被稱為幀指標或 FP。
標準退出序列必須撤消標準入口序列所做的操作。為此,標準退出序列必須按以下順序執行以下任務
- 透過將esp恢復到其舊值,刪除區域性變數的空間。
- 將ebp的舊值恢復到其舊值,該值位於棧頂。
- 使用ret命令返回呼叫函式。
例如,以下 C 程式碼
void MyFunction3(int x, int y, int z)
{
int a, b, c;
...
return;
}
將建立以下彙編程式碼
_MyFunction3:
push ebp
mov ebp, esp
sub esp, 12 ; sizeof(a) + sizeof(b) + sizeof(c)
;x = [ebp + 8], y = [ebp + 12], z = [ebp + 16]
;a = [ebp - 4] = [esp + 8], b = [ebp - 8] = [esp + 4], c = [ebp - 12] = [esp]
mov esp, ebp
pop ebp
ret 12 ; sizeof(x) + sizeof(y) + sizeof(z)
通常,逆向工程師會遇到沒有設定標準棧幀的子程式。在檢視不以標準序列開頭的子程式時,需要考慮以下幾點
當子程式開始使用未初始化暫存器中的資料時,這意味著子程式期望外部函式在被呼叫之前將資料放入該暫存器。某些呼叫約定在暫存器中傳遞引數,但有時編譯器不會使用標準呼叫約定。
在 C 中,函式可以選擇使用static關鍵字宣告,如下所示
static void MyFunction4();
static關鍵字使函式僅具有區域性作用域,這意味著任何外部函式都無法訪問它(它嚴格地屬於給定程式碼檔案內部)。當最佳化編譯器看到一個僅由呼叫引用的靜態函式(沒有透過函式指標的引用)時,它“知道”外部函式不可能與靜態函式互動(編譯器控制對函式的所有訪問),因此編譯器不會費心將其標準化。
某些 Windows 函式以如上所述的方式設定常規棧幀,但以看似毫無意義的行開頭
mov edi, edi;
此指令被組裝成 2 個位元組,用作將來函式補丁的佔位符。總的來說,這樣的函式可能看起來像這樣
nop ; each nop is 1 byte long
nop
nop
nop
nop
FUNCTION: ; <-- This is the function entry point as used by call instructions
mov edi, edi ; mov edi,edi is 2 bytes long
push ebp ; regular stack frame setup
mov ebp, esp
如果需要在不重新載入應用程式的情況下替換這樣的函式(或者在核心補丁的情況下重新啟動機器),則可以透過插入指向替換函式的跳轉來實現。一個短跳轉指令(可以跳轉 +/- 127 位元組)需要 2 位元組的儲存空間 - 正好是 “mov edi,edi” 佔位符提供的空間。跳轉到任何記憶體位置,在本例中為指向我們的替換函式,需要 5 個位元組。這些由函式之前的 5 個無操作位元組提供。如果這樣修補的函式被呼叫,它將首先向後跳轉 5 個位元組,然後進行一個長跳轉到替換函式。補丁後,記憶體可能看起來像這樣
LABEL:
jmp REPLACEMENT_FUNCTION ; <-- 5 NOPs replaced by jmp
FUNCTION:
jmp short LABEL ; <-- mov edi has been replaced by short jump backwards
push ebp
mov ebp, esp ; <-- regular stack frame setup as before
在開頭使用 2 位元組 mov 指令而不是直接放置 5 個 nop 的原因是,為了防止在補丁過程中出現損壞。如果指令指標當前指向其中任何一個指令,那麼替換 5 個單獨的指令將存在風險。另一方面,使用單個 mov 指令作為佔位符可以保證補丁可以作為一個原子操作完成。
區域性靜態變數不能在堆疊上建立,因為變數的值在函式呼叫之間會保留。我們將在後面的章節中討論區域性靜態變數和其他型別的變數。