x86 反彙編/分支
計算機科學教授告訴他們的學生要避免使用跳轉和 **goto** 指令,以避免眾所周知的“義大利麵條程式碼”。不幸的是,組合語言只有跳轉指令來控制程式流程。本章將探討許多人避之不及的主題,並將嘗試展示如何將組合語言的“義大利麵條”翻譯成高階語言中更熟悉的控制結構。具體來說,本章將重點介紹 **如果-則-否則** 和 **開關** 分支指令。
讓我們考慮一個使用虛擬碼的通用 **if** 語句,以及其使用跳轉的等效形式
if (condition) then do_action; if not (condition) then goto end; do_action; end: |
![]() |
這段程式碼做什麼?用英語來說,程式碼檢查條件,只有在條件為 *false* 時才執行跳轉。考慮到這一點,讓我們比較一些實際的 C 程式碼及其組合語言翻譯
if(x == 0)
{
x = 1;
}
x++;
|
mov eax, $x
cmp eax, 0
jne end
mov eax, 1
end:
inc eax
mov $x, eax
|
請注意,當我們翻譯成組合語言時,我們需要 *否定* 跳轉的條件,因為——正如我們上面所說——我們只有在條件為假時才跳轉。為了重建高階程式碼,只需再次否定該條件即可。
如果你不注意,否定比較可能會很棘手。以下是正確的雙重形式
| 指令 | 意思 |
|---|---|
| JNE | **J**ump if **n**ot **e**qual |
| JE | **J**ump if **e**qual |
| JG | **J**ump if **g**reater |
| JLE | **J**ump if **l**ess than or **e**qual |
| JL | **J**ump if **l**ess than |
| JGE | **J**ump if **g**reater or **e**qual |
以下是一些示例。
mov eax, $x //move x into eax
cmp eax, $y //compare eax with y
jg end //jump if greater than
inc eax
mov $x, eax //increment x
end:
...
由以下 C 語句生成
if(x <= y)
{
x++;
}
如您所見,只有當 x **小於或等於** y 時,x 才會遞增。因此,如果它大於 y,它將不會像彙編程式碼中那樣遞增。同樣,C 程式碼
if(x < y)
{
x++;
}
生成以下彙編程式碼
mov eax, $x //move x into eax
cmp eax, $y //compare eax with y
jge end //jump if greater than or equal to
inc eax
mov $x, eax //increment x
end:
...
在 C 程式碼中,只有當 x **小於** y 時,x 才會遞增,因此彙編程式碼現在在它大於或等於 y 時跳轉。這種事情需要練習,因此我們將嘗試在本節中包含大量示例。
現在,讓我們看一下更復雜的情況:**如果-則-否則** 指令。
if (condition) then do_action else do_alternative_action; if not (condition) goto else; do_action; goto end; else: do_alternative_action; end: |
![]() |
現在,這裡發生了什麼?與之前一樣,if 語句只有在條件為假時才跳轉到 else 子句。但是,我們還必須在“then”子句的末尾安裝一個 *無條件* 跳轉,這樣我們就不會直接在之後執行 else 子句。
現在,這裡是一個實際 C 如果-則-否則的示例
if(x == 10)
{
x = 0;
}
else
{
x++;
}
它被翻譯成以下彙編程式碼
mov eax, $x
cmp eax, 0x0A ;0x0A = 10
jne else
mov eax, 0
jmp end
else:
inc eax
end:
mov $x, eax
如您所見,新增一個無條件跳轉可以為我們的條件新增一個額外的選項。
**開關-情況** 結構在組合語言中看起來非常複雜,因此我們將檢查幾個示例。首先,請記住,在 C 中,在 switch 語句中通常會使用幾個關鍵字。以下是一個回顧
- 開關
- 此關鍵字測試引數,並啟動 switch 結構
- 情況
- 這會建立一個標籤,執行將切換到該標籤,具體取決於引數的值。
- 斷開
- 此語句跳轉到 switch 塊的末尾
- 預設
- 這是執行跳轉到的標籤,僅當它與任何其他條件不匹配時才進行跳轉
假設我們有一個通用的 switch 語句,但在末尾有一個額外的標籤,如下所示
switch (x)
{
//body of switch statement
}
end_of_switch:
現在,每個 **break** 語句將立即被以下語句替換
jmp end_of_switch
但是,其餘的語句會變成什麼?case 語句可以解析為任意數量的任意整數值。我們如何測試它?答案是我們使用“開關表”。以下是一個簡單的 C 示例
int main(int argc, char **argv)
{ //line 10
switch(argc)
{
case 1:
MyFunction(1);
break;
case 2:
MyFunction(2);
break;
case 3:
MyFunction(3);
break;
case 4:
MyFunction(4);
break;
default:
MyFunction(5);
}
return 0;
}
當我們使用 **cl.exe** 編譯它時,我們可以生成以下列表檔案
tv64 = -4 ; size = 4
_argc$ = 8 ; size = 4
_argv$ = 12 ; size = 4
_main PROC NEAR
; Line 10
push ebp
mov ebp, esp
push ecx
; Line 11
mov eax, DWORD PTR _argc$[ebp]
mov DWORD PTR tv64[ebp], eax
mov ecx, DWORD PTR tv64[ebp]
sub ecx, 1
mov DWORD PTR tv64[ebp], ecx
cmp DWORD PTR tv64[ebp], 3
ja SHORT $L810
mov edx, DWORD PTR tv64[ebp]
jmp DWORD PTR $L818[edx*4]
$L806:
; Line 14
push 1
call _MyFunction
add esp, 4
; Line 15
jmp SHORT $L803
$L807:
; Line 17
push 2
call _MyFunction
add esp, 4
; Line 18
jmp SHORT $L803
$L808:
; Line 19
push 3
call _MyFunction
add esp, 4
; Line 20
jmp SHORT $L803
$L809:
; Line 22
push 4
call _MyFunction
add esp, 4
; Line 23
jmp SHORT $L803
$L810:
; Line 25
push 5
call _MyFunction
add esp, 4
$L803:
; Line 27
xor eax, eax
; Line 28
mov esp, ebp
pop ebp
ret 0
$L818:
DD $L806
DD $L807
DD $L808
DD $L809
_main ENDP
讓我們逐步分析它。首先,我們看到第 10 行設定了我們的標準堆疊幀,它還儲存了 ecx。它為什麼要儲存 ecx?掃描整個函式,我們從未看到相應的“pop ecx”指令,因此似乎該值從未恢復過。事實上,編譯器根本沒有儲存 ecx,而是隻是在堆疊上保留了空間:它正在建立一個區域性變數。然而,原始的 C 程式碼沒有任何區域性變數,所以也許編譯器只需要一些額外的臨時空間來儲存中間值。為什麼編譯器不執行更常見的“sub esp, 4”命令來建立區域性變數?**push ecx** 只是一個更快的指令,它做同樣的事情。這個“臨時空間”透過 ebp 的 *負偏移量* 引用。**tv64** 在列表的開頭定義為具有值 -4,因此每次呼叫“tv64[ebp]”都是對這個臨時空間的呼叫。
關於函式本身,我們需要注意到一些事情
- 標籤 $L803 是 end_of_switch 標籤。因此,每個“jmp SHORT $L803”語句都是一個 **break**。這可以透過逐行比較 C 程式碼來驗證。
- 標籤 $L818 包含一個硬編碼的記憶體地址列表,這些地址在這裡是程式碼段中的標籤!請記住,標籤解析為指令的記憶體地址。這必須是我們謎題的重要部分。
為了解決這個謎題,我們將深入研究第 11 行
mov eax, DWORD PTR _argc$[ebp]
mov DWORD PTR tv64[ebp], eax
mov ecx, DWORD PTR tv64[ebp]
sub ecx, 1
mov DWORD PTR tv64[ebp], ecx
cmp DWORD PTR tv64[ebp], 3
ja SHORT $L810
mov edx, DWORD PTR tv64[ebp]
jmp DWORD PTR $L818[edx*4]
此序列執行以下偽 C 操作
if( argc - 1 >= 4 )
{
goto $L810; /* the default */
}
label *L818[] = { $L806, $L807, $L808, $L809 }; /* define a table of jumps, one per each case */
//
goto L818[argc - 1]; /* use the address from the table to jump to the correct case */
原因如下...
mov eax, DWORD PTR _argc$[ebp]
mov DWORD PTR tv64[ebp], eax
mov ecx, DWORD PTR tv64[ebp]
sub ecx, 1
mov DWORD PTR tv64[ebp], ecx
argc 的值被移入 eax。eax 的值然後立即被移入臨時空間。臨時空間的值然後被移入 ecx。這聽起來像是將同一個值移入許多不同位置的一種非常複雜的方式,但請記住:我關閉了最佳化。然後,ecx 的值被減 1。為什麼編譯器沒有使用 **dec** 指令?也許該語句是一個通用語句,在本例中它恰好有一個引數為 1。我們不知道確切的原因,我們只知道這一點
- eax = “臨時儲存區”
- ecx = eax - 1
最後,最後一行將 ecx 的新減 1 值 *移回臨時儲存區*。非常低效。
cmp DWORD PTR tv64[ebp], 3
ja SHORT $L810
臨時儲存區的值與值 3 進行比較,如果 *無符號* 值大於 3(4 或更多),執行將跳轉到標籤 $L810。我如何知道該值是無符號的?我知道是因為 **ja** 是一個無符號條件跳轉。讓我們回顧一下原始的 C 程式碼 switch
switch(argc)
{
case 1:
MyFunction(1);
break;
case 2:
MyFunction(2);
break;
case 3:
MyFunction(3);
break;
case 4:
MyFunction(4);
break;
default:
MyFunction(5);
}
請記住,臨時儲存區包含值 (argc - 1),這意味著此條件只有在 argc > 4 時才會觸發。當 argc 大於 4 時會發生什麼?函式將進入預設條件。現在,讓我們看一下接下來的兩行
mov edx, DWORD PTR tv64[ebp]
jmp DWORD PTR $L818[edx*4]
edx 獲取了暫存區的值(argc - 1),然後發生了一個非常奇怪的跳轉:執行跳轉到由值(edx * 4 + $L818)指向的位置。什麼是 $L818?我們現在來研究一下。
$L818:
DD $L806
DD $L807
DD $L808
DD $L809
$L818 是程式碼段中指向程式碼段指標列表的指標。這些指標都是 32 位值(DD 是一個雙字)。讓我們回到我們的跳轉語句
jmp DWORD PTR $L818[edx*4]
在這個跳轉中,$L818 不是偏移量,而是基址,edx*4 是偏移量。如前所述,edx 包含(argc - 1)的值。如果 argc == 1,我們跳轉到 [$L818 + 0],即 $L806。如果 argc == 2,我們跳轉到 [$L818 + 4],即 $L807。明白了嗎?快速檢視標籤 $L806、$L807、$L808 和 $L809,我們就能看到我們期望看到的內容:來自上面原始 C 程式碼的 case 語句的主體。每個 case 語句都呼叫函式“MyFunction”,然後中斷,然後跳轉到 switch 塊的末尾。
再次強調,學習的最好方法是實踐。因此,我們將透過一個簡短的例子來解釋三元運算子。考慮以下 C 程式碼程式
int main(int argc, char **argv)
{
return (argc > 1)?(5):(0);
}
cl.exe 生成了以下彙編清單檔案
_argc$ = 8 ; size = 4
_argv$ = 12 ; size = 4
_main PROC NEAR
; File c:\documents and settings\andrew\desktop\test2.c
; Line 2
push ebp
mov ebp, esp
; Line 3
xor eax, eax
cmp DWORD PTR _argc$[ebp], 1
setle al
dec eax
and eax, 5
; Line 4
pop ebp
ret 0
_main ENDP
第 2 行設定了一個堆疊幀,第 4 行是一個標準的退出序列。沒有區域性變數。很明顯,第 3 行是我們想要檢視的地方。
指令“xor eax, eax”只是將 eax 設定為 0。有關該行的更多資訊,請參閱關於 不直觀的指令 的章節。cmp 指令測試三元運算子的條件。setle 函式是一組 x86 函式之一,它們像條件移動一樣工作:如果 argc <= 1,則 al 獲取值 1。這不是我們想要的完全相反嗎?在這種情況下,是的。讓我們看看當 argc = 0 時會發生什麼:al 獲取值 1。al 被遞減(al = 0),然後 eax 與 5 進行邏輯與運算。5 & 0 = 0。當 argc == 2(大於 1)時,setle 指令不會做任何事情,eax 仍然為零。然後 eax 被遞減,這意味著 eax == -1。什麼是 -1?
在 x86 處理器中,負數以二進位制補碼形式儲存。例如,讓我們看看以下 C 程式碼
BYTE x;
x = -1;
在這段 C 程式碼的最後,x 將具有值 11111111:全是 1!
當 argc 大於 1 時,setle 將 al 設定為零。遞減此值會將 eax 中的每個位設定為邏輯 1。現在,當我們執行邏輯與運算時,我們得到
...11111111 &...00000101 ;101 is 5 in binary ------------ ...00000101
eax 獲取值 5。在這種情況下,這是一種間接的方法,但作為逆向分析師,你需要注意這些東西。
為了參考,以下是上面相同三元運算子的 GCC 彙編輸出
_main:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
xorl %eax, %eax
andl $-16, %esp
call __alloca
call ___main
xorl %edx, %edx
cmpl $2, 8(%ebp)
setge %dl
leal (%edx,%edx,4), %eax
leave
ret
請注意,GCC 生成的程式碼與 cl.exe 生成的程式碼略有不同。但是,堆疊幀的設定方式相同。還要注意,GCC 沒有給我們行號或程式碼中的其他提示。三元運算子行發生在指令“call __main”之後。讓我們在此處突出顯示該部分
xorl %edx, %edx
cmpl $2, 8(%ebp)
setge %dl
leal (%edx,%edx,4), %eax
同樣,xor 用於快速將 edx 設定為 0。Argc 與 2(而不是 1)進行比較,如果 argc 大於或等於,則 dl 被設定。如果 dl 被設定為 1,則其後的leal 指令將直接將值 5 移動到 eax(因為 lea (edx,edx,4) 表示 edx + edx * 4,即 edx * 5)。

