跳轉到內容

x86 反彙編/函式和棧幀

來自華夏公益教科書,開放的書籍,開放的世界

函式和棧幀

[編輯 | 編輯原始碼]

在執行環境中,函式通常使用“棧幀”來訪問函式引數和自動區域性函式變數。棧幀的理念是,每個子程式都可以獨立於其在棧中的位置執行,並且每個子程式都可以像棧頂一樣執行。

當呼叫函式時,將在當前esp位置建立一個新的棧幀。棧幀就像棧上的一個分割槽。來自之前函式的所有項都在棧中更上一層,不應修改。當前函式可以訪問從棧幀到棧頁面末尾的整個棧。當前函式始終可以訪問棧的“頂部”,因此函式不需要考慮其他函式或程式的記憶體使用情況。

標準入口序列

[編輯 | 編輯原始碼]

對於許多編譯器來說,標準函式入口序列是以下程式碼片段(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。

標準退出序列

[編輯 | 編輯原始碼]

標準退出序列必須撤消標準入口序列所做的操作。為此,標準退出序列必須按以下順序執行以下任務

  1. 透過將esp恢復到其舊值,刪除區域性變數的空間。
  2. ebp的舊值恢復到其舊值,該值位於棧頂。
  3. 使用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)

非標準棧幀

[編輯 | 編輯原始碼]

通常,逆向工程師會遇到沒有設定標準棧幀的子程式。在檢視不以標準序列開頭的子程式時,需要考慮以下幾點

使用未初始化的暫存器

[編輯 | 編輯原始碼]

當子程式開始使用未初始化暫存器中的資料時,這意味著子程式期望外部函式在被呼叫之前將資料放入該暫存器。某些呼叫約定在暫存器中傳遞引數,但有時編譯器不會使用標準呼叫約定。

"static" 函式

[編輯 | 編輯原始碼]

在 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 指令作為佔位符可以保證補丁可以作為一個原子操作完成。

區域性靜態變數

[編輯 | 編輯原始碼]

區域性靜態變數不能在堆疊上建立,因為變數的值在函式呼叫之間會保留。我們將在後面的章節中討論區域性靜態變數和其他型別的變數。

華夏公益教科書