為什麼要懂 x86#

x86 是一個超過三十年歷史卻異常長壽的架構,今天絕大多數桌機與筆電都在跑它,此外也出現在 Mac、遊戲主機甚至智慧型手機上。它由 Intel 於 1978 年隨 8086 CPU 推出,多年來 Intel 與 AMD 等廠商大幅提升其效能,從 16 位元運算走到 32 位元、再到 64 位元。現代架構除了處理器指令與程式設計慣用手法之外,幾乎已經和原始的 8086 沒什麼共通點——也正因為歷史悠久,x86 架構非常複雜。

指令集架構#

談 CPU 如何執行機器碼時,通常會談指令集架構(instruction set architecture, ISA)。ISA 定義機器碼如何運作、如何與 CPU 及電腦其他部分互動。對 ISA 有實用等級的理解,是有效逆向的關鍵。

ISA 定義了程式可用的機器語言指令集,每一條機器語言指令都用一個助憶符號指令(mnemonic instruction) 來表示。助憶符號替指令命名,並決定它的參數(運算元,operands)如何呈現。

常見 x86 助憶符號指令對照表
指令說明
MOV destination, source把 source 的值搬到 destination
ADD destination, value把整數值加到 destination
SUB destination, value從 destination 減去整數值
CALL address呼叫指定位址的副常式
JMP address無條件跳到指定位址
RET從先前的副常式返回
RETN size從先前的副常式返回,並將堆疊遞增 size
Jcc address若 cc 所示條件成立,跳到指定位址
PUSH value將值推入目前堆疊,並遞減堆疊指標
POP destination將堆疊頂端彈出到 destination,並遞增堆疊指標
CMP valuea, valueb比較 valuea 與 valueb,設定對應旗標
TEST valuea, valueb對兩值做位元 AND,設定對應旗標
AND destination, value對 destination 與 value 做位元 AND
OR destination, value對 destination 與 value 做位元 OR
XOR destination, value對 destination 與 value 做位元 XOR
SHL destination, N將 destination 左移 N 位元(左為高位)
SHR destination, N將 destination 右移 N 位元(右為低位)
INC destination將 destination 加 1
DEC destination將 destination 減 1

助憶符號指令依運算元數量分成三種形式:

運算元數量形式範例
0NAMEPOPRET
1NAME inputPUSH 1CALL func
2NAME output, inputMOV EAX, EBXADD EDI, 1

x86 指令在組合語言中有兩種常見寫法:Intel 語法AT&T 語法。Intel 語法由 Intel 提出,本章一律採用;AT&T 語法則常見於 Unix-like 系統的開發工具。兩者差異之一是運算元順序,例如「把 1 加到 EAX 暫存器」寫成:

ADD EAX, 1      ; Intel 語法
addl $1, %eax   ; AT&T 語法

讀反組譯輸出時務必先確認是哪一種語法。運算元順序相反,會讓你把「搬進去」讀成「搬出來」,整段邏輯的理解就全反了。

CPU 暫存器#

CPU 有若干暫存器(registers) 用來極快速地暫存目前的執行狀態。在 x86 中,每個暫存器以兩到三個字元的標籤表示。理解處理器支援的各類暫存器很重要,因為它們各司其職,也決定了指令如何運作。

x86 暫存器分成四大類:通用、記憶體索引、控制、選擇器。

圖表 6-2:32 位元 x86 的主要暫存器

通用暫存器#

通用暫存器(general purpose registers)(EAX、EBX、ECX、EDX)用來暫存非特定用途的運算值,例如加減法的結果。它們大小為 32 位元,但指令可以透過簡單的命名慣例存取其 16 位元與 8 位元版本:例如 EAX 的 16 位元版本是 AX,8 位元版本則是 AH 與 AL。

圖表 6-3:EAX 通用暫存器及其較小的組成暫存器

記憶體索引暫存器#

記憶體索引暫存器(memory index registers)(ESI、EDI、ESP、EBP、EIP)大致上也是通用的,但 ESP 與 EIP 例外:

  • ESP:供 PUSH、POP 指令以及副常式呼叫使用,指出目前堆疊基底所在的記憶體位置。雖然你可以拿 ESP 做索引堆疊以外的事,但通常很不明智,因為部分指令會隱含依賴它的值,可能造成記憶體毀損或非預期行為。
  • EIP:不能當一般通用暫存器直接存取,因為它指出下一條指令要從哪個記憶體位址讀取。唯一改變 EIP 的方式是使用 CALL、JMP、RET 這類控制指令。

控制暫存器:EFLAGS#

在逆向討論中最重要的控制暫存器是 EFLAGS。它包含一系列布林旗標,表示指令執行的結果,例如上一次運算的結果是否為 0。這些布林旗標是 x86 條件分支的實作基礎:例如兩值相減結果為 0 時,EFLAGS 的 Zero 旗標會設為 1,不適用的旗標則設為 0。EFLAGS 也包含重要的系統旗標,例如中斷是否啟用。注意並非所有指令都會影響 EFLAGS。

位元名稱說明
0Carry flag上一次運算是否產生進位位元
2Parity flag上一次運算最低有效位元組的同位
6Zero flag上一次運算結果是否為 0;用於比較運算
7Sign flag上一次運算的正負號;實際上就是結果的最高有效位元
11Overflow flag上一次運算是否溢位

選擇器暫存器#

選擇器暫存器(selector registers)(CS、DS、ES、FS、GS、SS)以「指出某個特定記憶體區塊」的方式定址記憶體,實際讀寫用的真實記憶體位址會在 CPU 內部表格中查得。

選擇器暫存器通常只出現在作業系統專屬的操作中。例如在 Windows 上,FS 暫存器用來存取儲存目前執行緒控制資訊的記憶體。

記憶體存取的特性#

  • 小端序(little endian):記憶體以小端序位元組順序存取,也就是最低有效位元組存放在最低的記憶體位址。
  • 不要求對齊:在對齊架構(如 SPARC)上,所有主記憶體讀寫都必須對齊到運算大小——例如讀取 32 位元值就必須從 4 的倍數位址讀取,讀取未對齊位址會產生錯誤。x86 則允許你在任意記憶體位址讀寫,不論是否對齊。
  • 指令可直接吃記憶體位址:不同於 ARM 這類用專屬指令在暫存器與主記憶體間載入/儲存的架構,許多 x86 指令可以直接把記憶體位址當運算元。

x86 支援相當複雜的記憶體定址格式:每個記憶體位址參照可包含一個基底暫存器、一個索引暫存器、索引的乘數(1 到 8 之間)以及一個 32 位元偏移量。以下這條 MOV 指令同時用上四種:

MOV EAX, [ESI + EDI * 8 + 0x50]   ; Read 32-bit value from memory address

看到方括號 [ ] 就代表「這是記憶體位址參照」,而不是值本身。這個小細節在讀結構體或陣列存取時特別關鍵。

程式流程#

程式流程(program flow),或稱控制流程(control flow),是程式決定要執行哪些指令的方式。x86 有三種主要的程式流程指令:

  • 副常式呼叫(subroutine calling):把程式流程導向一段指定的指令序列。由 CALL 完成,它會把 EIP 改成副常式的位置,同時把「下一條指令的記憶體位址」放到目前堆疊上,作為任務完成後的返回點。返回則用 RET,它把 EIP 改成堆疊頂端的位址——也就是 CALL 先前放進去的那個。
  • 條件分支(conditional branches):讓程式碼能依先前的運算結果做決定。CMP 比較兩個運算元(可能是兩個暫存器)並計算 EFLAGS 的對應值;它的內部做法是把一個值減去另一個、據此設定 EFLAGS,然後丟棄結果。TEST 做的事一樣,只是把減法換成 AND。EFLAGS 算好之後即可執行條件分支,跳往的位址取決於 EFLAGS 的狀態:例如 JZ 會在 Zero 旗標被設起時跳躍(比如 CMP 比較的兩值相等),否則等同無動作。
  • 無條件分支(unconditional branches):由 JMP 實作,單純無條件跳到目標位址,沒有其他名堂。

EFLAGS 不是只有 CMP 和 TEST 會動到,算術與其他指令同樣會設定它。例如 SHL 把目標值由低位往高位移動指定位元數時也會影響旗標。追蹤條件跳躍時,要往回找的是「最後一個真正設定旗標的指令」,未必是最接近的那條 CMP。