程式設計的概念既已不再抽象,還有幾個關於 C 的重要概念值得認識。組合語言與電腦處理器早於高階程式語言存在,許多現代程式概念都是隨時間演化而來。
就像懂一點拉丁文能大幅提升對英文的理解,掌握低階程式概念也有助於理解高階概念。往下讀時請記住:C 程式碼必須先被編譯成機器指令,才能做任何事。
字串#
前一支程式傳給 printf() 的 "Hello, world!\n" 是個字串——技術上說,是個字元陣列(character array)。
在 C 中,陣列就是某特定資料型別的 n 個元素構成的清單。一個 20 字元的陣列,就是記憶體中 20 個相鄰的字元。陣列也被稱為緩衝區(buffer)。
// char_array.c
#include <stdio.h>
int main()
{
char str_a[20];
str_a[0] = 'H';
str_a[1] = 'e';
// ... 略 ...
str_a[12] = '!';
str_a[13] = '\n';
str_a[14] = 0;
printf(str_a);
}(GCC 的 -o 選項可指定輸出檔名:gcc -o char_array char_array.c。)
幾個關鍵細節:
- 索引從 0 開始,而非 1。
- 最後一個字元是 0,也稱為 null byte(空位元組)。
- 字元陣列宣告為 20 位元組,但只用了 12 個。
結尾的 null byte 是分隔字元,用來告訴任何處理該字串的函式「到此為止」。其餘多出來的位元組只是垃圾,會被忽略。若把 null byte 插進陣列的第五個元素,
printf()就只會印出Hello。
字串函式與 strcpy()#
逐一設定每個字元既費力、字串又用得頻繁,於是有了一組標準的字串處理函式。strcpy() 會把字串從來源複製到目的地,逐位元組走訪來源字串並複製,直到複製完 null 終止位元組才停止。
函式引數的順序與 Intel 組合語法相似:先目的地,後來源。使用字串函式需
#include <string.h>。
// char_array2.c
#include <stdio.h>
#include <string.h>
int main() {
char str_a[20];
strcpy(str_a, "Hello, world!\n");
printf(str_a);
}堆疊讓 EIP 能來回穿梭#
用 GDB 在 strcpy() 呼叫前、之中、之後設下中斷點,會發現中間那個中斷點的 EIP 位址與另外兩個不同——因為 strcpy() 的程式碼來自載入的共享函式庫。
EIP 能從主程式碼跑到
strcpy()再回來。每次呼叫函式,都會在一個叫做堆疊(stack)的資料結構上留下紀錄,讓 EIP 能穿越長串的函式呼叫鏈返回。
GDB 中用 bt(backtrace)可回溯堆疊:
(gdb) bt
#0 0xb7f076f4 in strcpy () from /lib/tls/i686/cmov/libc.so.6
#1 0x080483d7 in main () at char_array2.c:7
你可能會注意到第二次執行時
strcpy()的位址略有不同。這是 Linux 核心自 2.6.11 起預設開啟的一種漏洞防護方法,稍後會詳談。
有號、無號、long 與 short#
C 中的數值預設是**有號(signed)**的,可正可負;**無號(unsigned)**則不允許負數。既然一切終究都是記憶體,所有數值都得以二進位儲存,而無號值在二進位中最為直觀:
- 32 位元無號整數的範圍是 0(全 0)到 4,294,967,295(全 1)。
- 32 位元有號整數同樣是 32 位元,只有 2³² 種位元組合,範圍是 –2,147,483,648 到 2,147,483,647。
本質上,其中一個位元成了標記正負的旗標。正的有號值看起來和無號值相同,但負數以**二補數(two’s complement)**的方式儲存。
二補數以適合二進位加法器的形式表示負數:把二補數的負值與同樣大小的正數相加,結果會是 0。做法是先寫出正數的二進位,接著反轉所有位元,最後加 1。
用簡易程式設計師計算機 pcalc 以 8 位元為例:
reader@hacking:~/booksrc $ pcalc 0y01001001
73 0x49 0y1001001
reader@hacking:~/booksrc $ pcalc 0y10110110 + 1
183 0xb7 0y10110111
reader@hacking:~/booksrc $ pcalc 0y01001001 + 0y10110111
256 0x100 0y100000000
二進位 01001001 是正 73;反轉所有位元再加 1,得到 –73 的二補數表示 10110111。兩者相加後,原本那 8 個位元的結果是 0。(pcalc 顯示 256 是因為它不知道我們只處理 8 位元;在二進位加法器中,那個進位位元會因為已到達變數記憶體邊界而被直接丟棄。)
型別大小#
在 C 中,宣告前加上 unsigned 關鍵字即為無號;加上 long 或 short 則可延長或縮短數值變數的大小。實際大小依編譯的目標架構而異,而 C 提供 sizeof() 巨集可查詢:
reader@hacking:~/booksrc $ ./a.out
The 'int' data type is 4 bytes
The 'unsigned int' data type is 4 bytes
The 'short int' data type is 2 bytes
The 'long int' data type is 4 bytes
The 'long long int' data type is 8 bytes
The 'float' data type is 4 bytes
The 'char' data type is 1 bytes
在 x86 架構上,有號與無號整數都是 4 位元組,float 也是 4 位元組,char 只需 1 位元組。long 與 short 也可用於浮點變數。
指標#
EIP 暫存器就是一個指標(pointer):它藉由存放記憶體位址來「指向」程式執行中的當前指令。C 也使用指標的概念。
由於實體記憶體無法真的搬動,其中的資訊必須被複製;而複製大塊記憶體供不同函式或不同位置使用,在運算與記憶體兩方面都非常昂貴(複製前還得先為目的地配置空間)。
指標正是這個問題的解答:與其複製一大塊記憶體,不如傳遞那塊記憶體開頭的位址。
C 的指標可像其他變數型別一樣定義使用。x86 架構使用 32 位元定址,所以指標也是 32 位元(4 位元組)。指標以在變數名稱前加星號 * 來定義——定義的不是「該型別的變數」,而是「指向該型別資料的東西」:
// pointer.c
#include <stdio.h>
#include <string.h>
int main() {
char str_a[20]; // A 20-element character array
char *pointer; // A pointer, meant for a character array
char *pointer2; // And yet another one
strcpy(str_a, "Hello, world!\n");
pointer = str_a; // Set the first pointer to the start of the array.
printf(pointer);
pointer2 = pointer + 2; // Set the second one 2 bytes further in.
printf(pointer2); // Print it.
strcpy(pointer2, "y you guys!\n"); // Copy into that spot.
printf(pointer); // Print again.
}reader@hacking:~/booksrc $ ./pointer
Hello, world!
llo, world!
Hey you guys!
當字元陣列像這樣被引用時,它本身就是一個指標。前面把緩衝區當作指標傳給
printf()與strcpy(),靠的正是這個性質。
取址運算子 &#
用 GDB 把指標當字串檢視,會看到字串確實存在於某個位址(例如 0xbffff7e0)。但要記得:字串本身並沒有存在指標變數裡,指標裡存的只有那個記憶體位址。
要看到指標變數中實際存放的資料,必須使用取址運算子(address-of operator)——一個一元運算子,寫作變數名前的 &。使用時回傳的是該變數的位址,而非變數本身。這個運算子在 GDB 與 C 中都存在:
(gdb) x/xw &pointer
0xbffff7dc: 0xbffff7e0
(gdb) print &pointer
$1 = (char **) 0xbffff7dc
(gdb) print pointer
$2 = 0xbffff7e0 "Hello, world!\n"
指標變數本身位於 0xbffff7dc,而它的內容是位址 0xbffff7e0。
// addressof.c
#include <stdio.h>
int main() {
int int_var = 5;
int *int_ptr;
int_ptr = &int_var; // put the address of int_var into int_ptr
}解參考運算子 *#
另一個一元運算子是解參考運算子(dereference operator):它回傳指標所指位址中的資料,而非位址本身。寫法是變數名前的星號,與指標宣告類似。同樣在 GDB 與 C 中都存在:
(gdb) print *int_ptr
$5 = 5
把這些概念綜合起來:
// addressof2.c 的輸出
int_ptr = 0xbffff834
&int_ptr = 0xbffff830
*int_ptr = 0x00000005
int_var is located at 0xbffff834 and contains 5
int_ptr is located at 0xbffff830, contains 0xbffff834, and points to 5一元運算子與指標並用時,可以這樣想:取址運算子是往回走,解參考運算子是順著指標的方向往前走。
格式字串#
printf() 能印的不只是固定字串,它還能用**格式字串(format string)**以多種格式印出變數。
格式字串就是含有特殊跳脫序列的字元字串,這些序列告訴函式:在此處插入以特定格式印出的變數。前面用的 "Hello, world!\n" 技術上就是格式字串,只是不含特殊序列。
這些跳脫序列也叫格式參數(format parameters),格式字串中每有一個,函式就預期多收一個引數。每個格式參數以百分號 % 開頭,其單字元簡寫與 GDB examine 命令的格式字母非常相似。
以數值傳入的格式參數:
| 參數 | 輸出型別 |
|---|---|
%d | 十進位 |
%u | 無號十進位 |
%x | 十六進位 |
預期傳入指標的格式參數:
| 參數 | 輸出型別 |
|---|---|
%s | 字串 |
%n | 目前已寫出的位元組數 |
%s預期收到記憶體位址,會印出該位址的資料直到遇上 null byte。%n很特別,因為它其實會寫入資料:它同樣預期收到記憶體位址,並把「目前已寫出的位元組數」寫進那個位址。
範例輸出:
[A] Dec: -73, Hex: ffffffb7, Unsigned: 4294967223
[B] Dec: 31337, Hex: 7a69, Unsigned: 31337
[field width on B] 3: '31337', 10: ' 31337', '00031337'
[string] sample Address bffff870
variable A is at address: bffff86c
%d允許負值,%u不允許(它預期無號值)。變數A(值為 –73)用%u印出會變成極大的數值,因為A是以二補數儲存的負數——二補數翻轉所有位元再加 1,原本為 0 的高位元現在都變成 1。- 欄寬(field width) 選項是接在百分號後的整數,指定最小欄寬(不是最大)。輸出資料若大於欄寬就會超出;欄寬值以
0開頭則以零填補(如%08u產生00031337)。 %s用於印字串,傳入的必須是字串位址——變數string本身就是含有字串位址的指標,正好符合%s以參照傳遞的預期。
scanf() 與輸入#
格式字串被一整個家族的標準 I/O 函式使用,包括 scanf()——它基本上像 printf(),只是用於輸入而非輸出。
一個關鍵差異:
scanf()預期所有引數都是指標,所以引數必須是變數位址,而不是變數本身。可以用指標變數,或用一元取址運算子取得一般變數的位址。
// input.c 摘要
printf("Repeat how many times? ");
scanf("%d", &count);
for(i=0; i < count; i++)
printf("%3d - %s\n", i, message);格式字串用得非常頻繁,熟悉它們很有價值。此外,能夠輸出變數的值,就等於能在不動用除錯器的情況下除錯程式。某種形式的即時回饋對駭客的學習過程相當關鍵——單純印出一個變數的值,就足以促成大量的漏洞利用。
型別轉換#
型別轉換(typecasting) 只是一種暫時改變變數資料型別的方式,不論它原本如何定義。當變數被轉型成不同型別,等於告訴編譯器「就這次操作,把這個變數當作新型別看待」。語法是:
(typecast_data_type) variable// typecasting.c 摘要
c = a / b; // Divide using integers.
d = (float) a / (float) b; // Divide integers typecast as floats.
[integers] a = 13 b = 5
[floats] c = 2.000000 d = 2.600000
整數 13 除以 5 會向下取整成錯誤的 2,即使結果存進浮點變數也一樣;但把整數變數轉型成 float,就能算出正確的 2.6。
指標型別與指標運算#
型別轉換真正發光的地方,是搭配指標變數使用。指標雖然只是個記憶體位址,C 編譯器仍要求每個指標有資料型別,原因有二:
- 限制程式錯誤——整數指標應只指向整數資料,字元指標應只指向字元資料。
- 指標運算(pointer arithmetic)——整數 4 位元組,字元 1 位元組。
pointer_types.c 讓兩個指標分別走訪對應型別的陣列,即使兩者都是「加 1」,編譯器遞增位址的幅度卻不同:
[integer pointer] points to 0xbffff7f0, which contains the integer 1
[integer pointer] points to 0xbffff7f4, which contains the integer 2
...
[char pointer] points to 0xbffff810, which contains the char 'a'
[char pointer] points to 0xbffff811, which contains the char 'b'
char 只有 1 位元組,所以指向下一個 char 的指標自然只前進 1 位元組;而整數是 4 位元組,指向下一個整數就必須前進 4 位元組。
(此處使用格式參數 %p 輸出記憶體位址,它是顯示指標的簡寫,基本等同於 0x%08x。)
延伸:故意讓指標指向不相容型別會發生什麼事
pointer_types2.c 刻意讓 int_pointer 指向字元資料、char_pointer 指向整數資料。編譯器會發出警告:
pointer_types2.c:12: warning: assignment from incompatible pointer type
pointer_types2.c:13: warning: assignment from incompatible pointer type
編譯器試圖藉此預防程式錯誤,但只有編譯器(也許還有程式設計師)在乎指標的型別。在編譯後的程式碼中,指標無非就是個記憶體位址,所以即使指標指向不相容型別,編譯器仍會完成編譯——它只是警告程式設計師要有心理準備。
[integer pointer] points to 0xbffff810, which contains the char 'a'
[integer pointer] points to 0xbffff814, which contains the char 'e'
...
[char pointer] points to 0xbffff7f0, which contains the integer 1
[char pointer] points to 0xbffff7f1, which contains the integer 0
[char pointer] points to 0xbffff7f2, which contains the integer 0
[char pointer] points to 0xbffff7f3, which contains the integer 0
[char pointer] points to 0xbffff7f4, which contains the integer 2
int_pointer 雖指向只有 5 位元組的字元資料,型別仍是整數,所以每次加 1 位址就前進 4。char_pointer 則每次只前進 1,以逐位元組的方式走過 20 位元組的整數資料(五個 4 位元組整數)。逐位元組檢視 4 位元組整數時,little-endian 的位元組順序再次顯現:4 位元組值 0x00000001 在記憶體中其實存成 0x01, 0x00, 0x00, 0x00。
pointer_types3.c 用型別轉換修正這個問題:指標賦值時把資料轉型成指標的型別(避免編譯器抱怨),而做指標運算時要先轉型成正確型別讓位址遞增正確的量,再轉回指標的型別。不太好看,但能用。
void 指標#
當然,一開始就用正確的指標型別容易得多;但有時我們需要一個通用、無型別的指標。C 中的 void 指標以 void 關鍵字定義。實驗後很快能發現無型別指標的兩個特性:
- 沒有型別就無法解參考——要取出指標所指位址中的值,編譯器必須先知道那是什麼型別的資料。
- 做指標運算前必須先轉型。
這些限制相當直觀,也說明了 void 指標的主要用途:單純持有一個記憶體位址。
編譯器知道 void 指標是無型別的,所以任何型別的指標都能不經轉型存進 void 指標;但反過來,解參考 void 指標時永遠必須轉型。
既然型別由轉型負責,void 指標就真的只是個記憶體位址而已。這意味著任何大到足以裝下 4 位元組值的東西,都能像 void 指標一樣運作——pointer_types5.c 就用一個 unsigned int 來存位址(並用 sizeof() 以一般算術達成指標運算的效果)。這相當 hacky,但因為賦值與解參考時都轉型成正確的指標型別,最終結果完全相同。
關於 C 的變數,最該記住的一件事是:只有編譯器在乎變數的型別。程式編譯完成後,變數無非就是記憶體位址。這代表只要告訴編譯器把某型別轉型成想要的型別,一種型別的變數就能輕易被強迫表現得像另一種型別。
命令列引數#
許多非圖形化程式以命令列引數的形式接收輸入。與 scanf() 不同,命令列引數不需要程式開始執行後的使用者互動,效率通常更高。
在 C 中,只要在 main() 加上兩個額外引數就能存取它們:一個整數(引數數量),以及一個指向字串陣列的指標(各個引數):
// commandline.c
#include <stdio.h>
int main(int arg_count, char *arg_list[]) {
int i;
printf("There were %d arguments provided:\n", arg_count);
for(i=0; i < arg_count; i++)
printf("argument #%d\t-\t%s\n", i, arg_list[i]);
}reader@hacking:~/booksrc $ ./commandline this is a test
There were 5 arguments provided:
argument #0 - ./commandline
argument #1 - this
argument #2 - is
argument #3 - a
argument #4 - test
atoi() 與字串轉整數#
有時程式想把命令列引數當整數而非字串使用。無論如何,引數傳入時都是字串;但有標準的轉換函式可用。與單純的型別轉換不同,這些函式真的能把含有數字的字元陣列轉換成實際的整數。最常見的是 atoi()(ASCII to integer),它接受指向字串的指標,回傳它所代表的整數值。
// convert.c
if(argc < 3) // If fewer than 3 arguments are used,
usage(argv[0]); // display usage message and exit.
count = atoi(argv[2]); // Convert the 2nd arg into an integer.
段錯誤:越界存取的下場#
上面的 if 敘述確保在存取這些字串前至少有三個引數。
若程式嘗試存取不存在、或它沒有讀取權限的記憶體,程式就會崩潰。在 C 中,檢查這類狀況並在程式邏輯中處理是很重要的。
把錯誤檢查的 if 註解掉(convert2.c),引數不足時程式仍會去存取不存在的引數陣列元素:
reader@hacking:~/booksrc $ ./a.out test
Segmentation fault (core dumped)
記憶體被切成數個區段,有些記憶體位址不在程式被授權存取的區段邊界之內。當程式試圖存取越界位址,它就會崩潰,死於所謂的段錯誤(segmentation fault)。
用 GDB 檢視引數向量可以看清原因:由於引數向量是指向字串清單的指標,它其實是指向一堆指標的指標。
(gdb) x/3xw 0xbffff894
0xbffff894: 0xbffff9b3 0xbffff9ce 0x00000000
(gdb) x/s 0xbffff9b3
0xbffff9b3: "/home/reader/booksrc/a.out"
(gdb) x/s 0xbffff9ce
0xbffff9ce: "test"
(gdb) x/s 0x00000000
0x0: <Address 0x0 out of bounds>
第一個是第 0 個引數、第二個是 test、第三個是 0——越界。程式一存取這個位址就以段錯誤崩潰。
變數作用域#
另一個與 C 記憶體相關的有趣概念是**變數作用域(variable scoping)**或稱脈絡(context),特別是函式內變數的脈絡。
每個函式都有自己的一組區域變數,彼此獨立。事實上,對同一函式的多次呼叫,各自也有各自的脈絡。
[in main] i = 3
[in func1] i = 5
[in func2] i = 7
[in func3] i = 11
[back in func2] i = 7
[back in func1] i = 5
[back in main] i = 3
即使呼叫了把 i 設為 5 的 func1(),main() 裡的 i 仍是 3。最好的理解方式是:每次函式呼叫都有自己版本的變數 i。
全域變數#
變數也能有全域作用域,跨所有函式持續存在。只要定義在程式碼開頭、所有函式之外,變數就是全域的。
[in main] i = 3, j = 42
[in func1] i = 5, j = 42
[in func2] i = 7, j = 42
[in func2] setting j = 1337
[in func3] i = 11, j = 999
[back in func2] i = 7, j = 1337
[back in func1] i = 5, j = 1337
[back in main] i = 3, j = 1337
全域變數 j 在 func2() 中被寫入,變更在所有函式中持續——唯獨 func3() 例外,因為它有自己名為 j 的區域變數,此時編譯器偏好使用區域變數。
這麼多同名變數容易混淆,但記住:到頭來一切都只是記憶體。全域變數
j就存在記憶體某處、每個函式都能存取;各函式的區域變數則各自存在自己的記憶體位置,不管名字是否相同。
印出位址就一目了然:
[in main] i @ 0xbffff834 = 3
[in main] j @ 0x08049988 = 42
[in func1] i @ 0xbffff814 = 5
...
[in func3] i @ 0xbffff7d4 = 11
[in func3] j @ 0xbffff7d0 = 999
[back in func2] j @ 0x08049988 = 1337
func3() 用的 j 位於 0xbffff7d0,其他函式用的 j 位於 0x08049988——確實是不同的記憶體位址。變數 i 對每個函式來說也都是不同的位址。
堆疊框架#
在 GDB 中於 func3() 停下並執行 bt,可看到堆疊上每次函式呼叫的紀錄:
(gdb) bt full
#0 func3 () at scope3.c:7
i = 11
j = 999
#1 0x0804841d in func2 () at scope3.c:17
i = 7
#2 0x0804849f in func1 () at scope3.c:26
i = 5
#3 0x0804852b in main () at scope3.c:35
i = 3
每次呼叫函式,堆疊上就會放上一筆稱為**堆疊框架(stack frame)**的紀錄。backtrace 的每一行對應一個堆疊框架,而每個堆疊框架也含有該脈絡的區域變數。
bt full 清楚顯示區域變數 j 只存在於 func3() 的脈絡中,其他函式用的是全域版本。
static 變數#
除了全域之外,變數也可在定義前加上 static 關鍵字成為靜態變數:
- 與全域變數相似之處:在函式呼叫之間保持不變。
- 與區域變數相似之處:仍侷限在特定函式脈絡之內。
- 獨有的特點:只會被初始化一次。
[in main] static_var = 1337
[in function] var = 5
[in function] static_var = 5
[in main] static_var = 1337
[in function] var = 5
[in function] static_var = 6
...
[in main] static_var = 1337
[in function] var = 5
[in function] static_var = 9
static_var 在後續呼叫 function() 之間保留其值,因為靜態變數會保留值、而且只初始化一次。同時,由於靜態變數侷限於特定函式脈絡,main() 裡的 static_var 全程保持 1337。
印出位址證實它們是兩個不同的記憶體位置(分別是 0x804968c 與 0x8049688)。
你或許已注意到:區域變數的位址都很高(如
0xbffff814),而全域與靜態變數的位址都很低(如0x0804968c、0x8049688)。注意到這種細節並追問為什麼,正是駭客精神的基石之一——答案就在下一節「記憶體分段」。