C 的語法既已熟悉、基礎概念也解釋過了,真正動手寫 C 就不是什麼大跳躍。幾乎每種作業系統與處理器架構都有 C 編譯器,但本書一律使用 Linux 與 x86 處理器:Linux 是人人都能取得的免費作業系統,x86 則是地球上最普及的消費級處理器。

駭客的本質是實驗,所以最好準備一個 C 編譯器邊讀邊跑。隨書 Live CD 可在任何 x86 電腦上開機進入 Linux 環境,且不會更動既有作業系統。

第一支程式#

firstprog.c 是一段簡單的 C 程式碼,會印出十次 “Hello, world!":

#include <stdio.h>

int main()
{
  int i;
  for(i=0; i < 10; i++)        // Loop 10 times.
  {
    puts("Hello, world!\n");   // put the string to the output.
  }
  return 0;                    // Tell OS the program exited without errors.
}
  • C 程式的主要執行從名符其實的 main() 函式開始。
  • 雙斜線 // 之後的文字是註解,編譯器會忽略。
  • 第一行 #include <stdio.h> 告訴編譯器納入標準輸入/輸出(I/O)函式庫 stdio 的標頭檔。

這個標頭檔位於 /usr/include/stdio.h,定義了若干常數與標準 I/O 函式庫中對應函式的函式原型。因為 main() 用到 printf(),使用前就需要它的函式原型——而該原型(以及其他許多原型)正包含在 stdio.h 中。C 的強大有很大一部分來自它的可擴充性與函式庫。

GNU 編譯器套件(GCC,GNU Compiler Collection) 是免費的 C 編譯器,會把 C 翻譯成處理器看得懂的機器語言,輸出一個可執行的二進位檔,預設名為 a.out

reader@hacking:~/booksrc $ gcc firstprog.c
reader@hacking:~/booksrc $ ls -l a.out
-rwxr-xr-x 1 reader reader 6621 2007-09-06 22:16 a.out
reader@hacking:~/booksrc $ ./a.out
Hello, world!
Hello, world!
...(共十次)

更大的圖像#

以上都是入門程式設計課會教的東西——基礎,但必要。多數入門課只教怎麼讀寫 C。精通 C 很有用,也足以讓你成為不錯的程式設計師,但那只是大圖像的一塊拼圖。

多數程式設計師由上而下學語言,從沒看見大圖像。駭客的優勢,來自知道大圖像裡所有零件如何彼此互動。

在程式設計的領域裡看見大圖像,只需認清一件事:C 程式碼是要被編譯的。程式碼在被編譯成可執行二進位檔之前,什麼也做不了。

「把 C 原始碼當成程式本身」是一個常見誤解——而駭客每天都在利用這個誤解。

二進位檔 a.out 的指令是以機器語言寫成的,那是 CPU 能理解的基本語言。編譯器被設計來把 C 翻譯成各種處理器架構的機器語言:此處是 x86 家族,此外還有 Sparc(Sun 工作站)與 PowerPC(Intel 之前的 Mac)。每種架構有不同的機器語言,編譯器扮演的就是中介的角色。

只要編譯出來的程式能動,一般程式設計師只關心原始碼。但駭客明白:真正在現實世界被執行的,是編譯後的程式。對 CPU 運作方式理解得越深,就越能操縱在其上運行的程式。

用 objdump 看見機器碼#

GNU 開發工具包含 objdump,可用來檢視編譯後的二進位檔。以下是 main() 被翻譯成的機器碼(AT&T 語法):

reader@hacking:~/booksrc $ objdump -D a.out | grep -A20 main.:
08048374 <main>:
 8048374:       55                      push   %ebp
 8048375:       89 e5                   mov    %esp,%ebp
 8048377:       83 ec 08                sub    $0x8,%esp
 804837a:       83 e4 f0                and    $0xfffffff0,%esp
 804837d:       b8 00 00 00 00          mov    $0x0,%eax
 8048382:       29 c4                   sub    %eax,%esp
 8048384:       c7 45 fc 00 00 00 00    movl   $0x0,0xfffffffc(%ebp)
 804838b:       83 7d fc 09             cmpl   $0x9,0xfffffffc(%ebp)
 804838f:       7e 02                   jle    8048393 <main+0x1f>
 8048391:       eb 13                   jmp    80483a6 <main+0x32>
 8048393:       c7 04 24 84 84 04 08    movl   $0x8048484,(%esp)
 804839a:       e8 01 ff ff ff          call   80482a0 <printf@plt>
 804839f:       8d 45 fc                lea    0xfffffffc(%ebp),%eax
 80483a2:       ff 00                   incl   (%eax)
 80483a4:       eb e5                   jmp    804838b <main+0x17>
 80483a6:       c9                      leave
 80483a7:       c3                      ret

十六進位與記憶體位址#

每個位元組以**十六進位(hexadecimal)**表示,那是 base-16 的數字系統:用 0–9 表示 0 到 9,再用 A–F 表示 10 到 15。這個表示法很方便,因為一個位元組含 8 個位元、共有 256(2⁸)種可能值,恰好可用 2 個十六進位數字描述。

最左邊那些以 0x8048374 開頭的十六進位數是記憶體位址。機器語言指令的位元必須放在某處,那個某處就叫記憶體(memory)——記憶體不過是一堆以位址編號的暫存位元組。

就像街上一排各有門牌的房子,記憶體可以想成一排各有位址的位元組。每個位元組都能透過位址存取,而 CPU 正是這樣取得構成程式的機器語言指令。

較舊的 Intel x86 處理器使用 32 位元定址(2³² ≈ 42.9 億個位址),較新的則使用 64 位元(2⁶⁴ ≈ 1.84 × 10¹⁹ 個位址)。64 位元處理器可在 32 位元相容模式下快速執行 32 位元程式碼。

組合語言只是助憶符號#

清單中間的十六進位位元組就是 x86 的機器語言指令——當然,這些十六進位值只是 CPU 所理解的二進位 1 與 0 的表示法。但 0101010110001001111001011000001111101100111100001… 除了處理器之外對誰都沒用,所以機器碼以十六進位位元組顯示,且每條指令獨立一行,像把段落拆成句子。

話說回來,十六進位位元組本身也沒多有用——這就是組合語言登場之處。最右邊的指令就是組合語言,它其實只是對應機器語言指令的一組助憶符號(mnemonics)ret 遠比 0xc311000011 好記也好懂。

與 C 等編譯語言不同,組合語言指令與機器語言指令是一對一的直接關係。因此每種處理器架構有不同的機器語言指令,也就各自有不同形式的組合語言。

AT&T 語法 vs. Intel 語法#

機器語言指令究竟如何表示,純粹是慣例與偏好問題。理論上你可以自創 x86 組合語法,但多數人使用兩大類型之一:AT&T 語法Intel 語法

  • 前面的輸出是 AT&T 語法——Linux 幾乎所有反組譯工具都預設用它,很容易從滿眼的 %$ 前綴認出來。
  • 加上 -M intel 選項就能讓 objdump 輸出 Intel 語法。
同一段程式碼的 Intel 語法輸出
reader@hacking:~/booksrc $ objdump -M intel -D a.out | grep -A20 main.:
08048374 <main>:
 8048374:       55                      push   ebp
 8048375:       89 e5                   mov    ebp,esp
 8048377:       83 ec 08                sub    esp,0x8
 804837a:       83 e4 f0                and    esp,0xfffffff0
 804837d:       b8 00 00 00 00          mov    eax,0x0
 8048382:       29 c4                   sub    esp,eax
 8048384:       c7 45 fc 00 00 00 00    mov    DWORD PTR [ebp-4],0x0
 804838b:       83 7d fc 09             cmp    DWORD PTR [ebp-4],0x9
 804838f:       7e 02                   jle    8048393 <main+0x1f>
 8048391:       eb 13                   jmp    80483a6 <main+0x32>
 8048393:       c7 04 24 84 84 04 08    mov    DWORD PTR [esp],0x8048484
 804839a:       e8 01 ff ff ff          call   80482a0 <printf@plt>
 804839f:       8d 45 fc                lea    eax,[ebp-4]
 80483a2:       ff 00                   inc    DWORD PTR [eax]
 80483a4:       eb e5                   jmp    804838b <main+0x17>
 80483a6:       c9                      leave
 80483a7:       c3                      ret

本書採用作者認為更易讀的 Intel 語法

不論用哪種表示法,處理器懂的命令都相當簡單:一個運算,有時再加上描述目的地與/或來源的引數。這些運算不外乎搬動記憶體、做些基本數學,或中斷處理器讓它去做別的事。到頭來,電腦處理器真正能做的就只有這些。

正如數百萬本書都是用相對少量的字母寫成的,用相對少量的機器指令也能組合出無限多種可能的程式。

x86 處理器與暫存器#

處理器還有一組自己的特殊變數,稱為暫存器(registers)。多數指令都用這些暫存器讀寫資料,所以理解處理器的暫存器,是理解指令的必要前提。

背景:x86 家族的由來

8086 CPU 是第一顆 x86 處理器,由 Intel 開發製造;之後同家族陸續有更先進的 80186、80286、80386、80486。若你記得 80、90 年代人們談論的「386」「486」處理器,指的就是這些。

GNU 開發工具也包含一個叫 GDB 的除錯器。除錯器讓程式設計師能單步執行編譯後的程式、檢查程式記憶體、檢視處理器暫存器。

從沒用過除錯器觀察程式內部運作的程式設計師,就像十七世紀從沒用過顯微鏡的醫生。而除錯器比顯微鏡更強大:它能從所有角度觀察執行過程、暫停它,並在過程中改變任何東西。

用 GDB 檢視程式開始前的處理器暫存器狀態:

reader@hacking:~/booksrc $ gdb -q ./a.out
(gdb) break main
Breakpoint 1 at 0x804837a
(gdb) run
Breakpoint 1, 0x0804837a in main ()
(gdb) info registers
eax            0xbffff894       -1073743724
ecx            0x48e0fe81       1222704769
edx            0x1      1
ebx            0xb7fd6ff4       -1208127500
esp            0xbffff800       0xbffff800
ebp            0xbffff808       0xbffff808
esi            0xb8000ce0       -1207956256
edi            0x0      0
eip            0x804837a        0x804837a <main+6>
eflags         0x286    [ PF SF IF ]
cs             0x73     115
ss             0x7b     123
ds             0x7b     123
es             0x7b     123
fs             0x0      0
gs             0x33     51

main() 設下中斷點(breakpoint),執行就會停在我們的程式碼被執行之前。

各暫存器的角色:

  • EAX、ECX、EDX、EBX——通用暫存器(general purpose registers),分別稱為 Accumulator(累加)、Counter(計數)、Data(資料)、Base(基底)。用途多樣,但主要充當 CPU 執行機器指令時的暫時變數。
  • ESP、EBP、ESI、EDI——同樣是通用暫存器,但有時被稱為指標與索引:Stack Pointer(堆疊指標)、Base Pointer(基底指標)、Source Index(來源索引)、Destination Index(目的索引)。前兩個之所以叫指標,是因為它們存放 32 位元位址,實質上指向記憶體中的某個位置——它們對程式執行與記憶體管理相當重要,稍後會深入討論。後兩個技術上也是指標,常用來指向資料讀寫的來源與目的;有些載入與儲存指令會用到它們,但多數情況可視為單純的通用暫存器。
  • EIP——指令指標暫存器(Instruction Pointer),指向處理器正在讀取的當前指令。就像孩子讀書時用手指著每個字,處理器就是用 EIP 當手指來讀取每條指令。這個暫存器非常重要,除錯時會大量用到。
  • EFLAGS——由若干位元旗標組成,用於比較與記憶體分段。實際記憶體被切成數個不同的區段(稍後討論),這些暫存器負責追蹤。多數情況可以忽略,因為很少需要直接存取。

組合語言的閱讀方式#

由於本書使用 Intel 語法,工具必須配合設定。在 GDB 中輸入 set disassembly intel(簡寫 set dis intel)即可;把這行指令放進家目錄的 .gdbinit 檔案,就能讓 GDB 每次啟動都自動套用:

reader@hacking:~/booksrc $ echo "set dis intel" > ~/.gdbinit

Intel 語法的組合指令一般遵循這個格式:

operation <destination>, <source>

目的地與來源可以是暫存器、記憶體位址或數值。運算通常是直覺的助憶符號:mov 把值從來源搬到目的地、sub 相減、inc 遞增,依此類推。例如下面兩條指令會把 ESP 的值搬進 EBP,再把 ESP 減 8:

8048375:        89 e5                 mov   ebp,esp
8048377:        83 ec 08              sub   esp,0x8

也有控制執行流程的運算:cmp 用來比較數值,而基本上任何 j 開頭的運算都是「跳到程式碼的另一處」(依比較結果而定):

804838b:        83 7d fc 09           cmp   DWORD PTR [ebp-4],0x9
804838f:        7e 02                 jle   8048393 <main+0x1f>
8048391:        eb 13                 jmp   80483a6 <main+0x32>

先把位於 EBP-4 的 4 位元組值與 9 比較;jlejump if less than or equal to 的簡寫,若該值小於等於 9 就跳到 0x8048393,否則往下走到無條件跳躍指令,跳往 0x80483a6

這三條指令合起來就構成了一個 if-then-else 控制結構——高階語言的控制流程,在機器碼層次就是「比較 + 條件跳躍 + 無條件跳躍」。

用 GDB 單步走過整支程式#

GCC 的 -g 旗標可加入額外除錯資訊,讓 GDB 能存取原始碼。設下中斷點並執行後,EIP 指向的是 main() 反組譯結果中的某條指令,而在它之前的那些指令稱為函式序言(function prologue)——由編譯器產生,用來為 main() 其餘的區域變數配置記憶體。

C 之所以需要宣告變數,部分原因正是為了協助建構這段程式碼。除錯器知道這段是自動產生的,聰明到會直接跳過它。

檢查記憶體:examine 命令#

GDB 提供 x(examine 的簡寫)直接檢視記憶體。

檢查記憶體是任何駭客的關鍵技能。多數駭客攻擊很像魔術——除非你懂手法與誤導,否則看起來就是神奇。魔術與駭客都一樣:只要往對的地方看,把戲就一目了然。這也是好魔術師不重複同一個把戲的原因之一。

但有了 GDB 這樣的除錯器,程式執行的每個面向都能被確定性地檢視、暫停、單步執行,並依需要重複多次。

x 需要兩個引數:要檢視的記憶體位置,以及如何顯示。顯示格式用單一字母簡寫,前面可選擇性地加上要檢視的項目數量:

格式字母顯示方式
o八進位
x十六進位
u無號十進位(base-10)
t二進位
c依 ASCII 表查出字元
s整串字元資料
i反組譯成組合語言指令
(gdb) x/o 0x8048384
0x8048384 <main+16>:    077042707
(gdb) x/x $eip
0x8048384 <main+16>:    0x00fc45c7
(gdb) x/u $eip
0x8048384 <main+16>:    16532935
(gdb) x/t $eip
0x8048384 <main+16>:    00000000111111000100010111000111

除錯器允許直接引用暫存器,所以 $eip 等同於 EIP 當下的值。上面四個數值其實是同一個東西的四種進位表示。

單位大小也能用尾綴字母指定:

大小字母大小
b單一位元組
hhalfword,2 位元組
wword,4 位元組
ggiant,8 位元組

「word」這個詞有時也指 2 位元組值,這點容易混淆。本書中 word 與 DWORD 都指 4 位元組值;若指 2 位元組值,會稱為 short 或 halfword。

Little-endian:位元組順序的陷阱#

仔細看會發現怪事:逐位元組檢視時前兩個位元組是 0xc70x45,但在同一個位址檢視 halfword 時卻顯示 0x45c7——位元組反過來了。四位元組的 word 顯示為 0x00fc45c7,而逐位元組看則是 0xc70x450xfc0x00

這是因為 x86 處理器以 little-endian 位元組順序儲存數值:最低位的位元組先存。若四個位元組要被解讀成單一數值,就必須反序使用。

GDB 夠聰明,知道數值如何儲存,所以檢視 word 或 halfword 時會自動反轉位元組。用命令列計算機 bc 驗證:若以錯誤順序解讀,會得到 3343252480 這個離譜的值;正確順序才是 16532935。

給定架構的位元組順序是個必須留意的重要細節。雖然多數除錯工具與編譯器會自動處理,但遲早你會親手直接操作記憶體

追蹤 for 迴圈的機器碼#

nexti(next instruction)讓處理器讀取 EIP 指向的指令、執行它,並把 EIP 前進到下一條。

第一條指令 mov DWORD PTR [ebp-4],0x0 把 0 搬進 EBP-4 的記憶體位置——那正是 C 變數 i 的所在(i 宣告為整數,在 x86 上佔 4 位元組)。執行前該處只有隨機垃圾值,執行後被歸零。

接著的幾條指令一起看更有意義:

0x804838b <main+23>:   cmp    DWORD PTR [ebp-4],0x9
0x804838f <main+27>:   jle    0x8048393 <main+31>
0x8048391 <main+29>:   jmp    0x80483a6 <main+50>
0x8048393 <main+31>:   mov    DWORD PTR [esp],0x8048484
0x804839a <main+38>:   call   0x80482a0 <printf@plt>
0x804839f <main+43>:   lea    eax,[ebp-4]
0x80483a2 <main+46>:   inc    DWORD PTR [eax]
0x80483a4 <main+48>:   jmp    0x804838b <main+23>
0x80483a6 <main+50>:   leave
0x80483a7 <main+51>:   ret
  • cmpi 與 9 比較(結果實際存在 EFLAGS 暫存器裡)。
  • jle 依比較結果決定是否跳躍:若 i ≤ 9 就跳到 0x8048393(迴圈本體)。
  • 否則落到 jmp,無條件跳到函式尾端的 0x80483a6(離開迴圈)。

字串藏在哪裡:ASCII 與 printf 的引數#

進入迴圈本體後,mov DWORD PTR [esp],0x8048484 把位址 0x8048484 寫入 ESP 所指的記憶體。0x8048484 有什麼特別?檢視該處會發現位元組落在可列印 ASCII 範圍內:

(gdb) x/6cb 0x8048484
0x8048484:      72 'H' 101 'e' 108 'l' 108 'l' 111 'o' 32 ' '
(gdb) x/s 0x8048484
0x8048484:       "Hello, world!\n"

ASCII 是把鍵盤上所有字元(以及一些不在鍵盤上的)對應到固定數字的公認標準。Unix 系統上輸入 man ascii 即可查表。檢視記憶體久了,這類視覺模式會越來越容易辨認。

這串資料正是 printf() 的引數——把字串位址搬進 ESP 所指位置,就是在為函式呼叫做準備。下一條 call 指令實際呼叫 printf(),螢幕上就出現了 Hello, world!

遞增計數器並跳回#

0x804839f <main+43>:     lea    eax,[ebp-4]
0x80483a2 <main+46>:     inc    DWORD PTR [eax]

這兩條指令就是把變數 i 加 1:

  • leaLoad Effective Address 的縮寫,把 EBP-4 這個位址載入 EAX 暫存器。
  • inc 把該位址(現在存在 EAX 裡)所指的值加 1。

最後 jmp 0x804838b 是無條件跳躍,直接把 EIP 設回比較指令的位址,形成迴圈。

對照完整反組譯與原始碼,你應該能指出哪段 C 程式碼被編譯成哪些機器指令:cmpjlejmpleainc 構成 for 迴圈,中間的 movcall 則是迴圈內的 printf() 呼叫。

程式執行會不斷跳回比較指令、執行 printf()、遞增計數器,直到 i 等於 10。此時條件跳躍 jle 不再成立,指令指標繼續走到無條件跳躍指令,離開迴圈並結束程式。