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 遠比 0xc3 或 11000011 好記也好懂。
與 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 比較;jle 是 jump 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 | 單一位元組 |
h | halfword,2 位元組 |
w | word,4 位元組 |
g | giant,8 位元組 |
「word」這個詞有時也指 2 位元組值,這點容易混淆。本書中 word 與 DWORD 都指 4 位元組值;若指 2 位元組值,會稱為 short 或 halfword。
Little-endian:位元組順序的陷阱#
仔細看會發現怪事:逐位元組檢視時前兩個位元組是 0xc7、0x45,但在同一個位址檢視 halfword 時卻顯示 0x45c7——位元組反過來了。四位元組的 word 顯示為 0x00fc45c7,而逐位元組看則是 0xc7、0x45、0xfc、0x00。
這是因為 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>: retcmp把i與 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:
lea是 Load Effective Address 的縮寫,把EBP-4這個位址載入 EAX 暫存器。inc把該位址(現在存在 EAX 裡)所指的值加 1。
最後 jmp 0x804838b 是無條件跳躍,直接把 EIP 設回比較指令的位址,形成迴圈。
對照完整反組譯與原始碼,你應該能指出哪段 C 程式碼被編譯成哪些機器指令:
cmp/jle/jmp/lea/inc構成 for 迴圈,中間的mov/call則是迴圈內的printf()呼叫。
程式執行會不斷跳回比較指令、執行 printf()、遞增計數器,直到 i 等於 10。此時條件跳躍 jle 不再成立,指令指標繼續走到無條件跳躍指令,離開迴圈並結束程式。