跳轉到內容

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)。

華夏公益教科書