程式設計的概念既已不再抽象,還有幾個關於 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 關鍵字即為無號;加上 longshort 則可延長或縮短數值變數的大小。實際大小依編譯的目標架構而異,而 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 位元組。longshort 也可用於浮點變數。

指標#

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 編譯器仍要求每個指標有資料型別,原因有二:

  1. 限制程式錯誤——整數指標應只指向整數資料,字元指標應只指向字元資料。
  2. 指標運算(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

全域變數 jfunc2() 中被寫入,變更在所有函式中持續——唯獨 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。

印出位址證實它們是兩個不同的記憶體位置(分別是 0x804968c0x8049688)。

你或許已注意到:區域變數的位址都很高(如 0xbffff814),而全域與靜態變數的位址都很低(如 0x0804968c0x8049688)。

注意到這種細節並追問為什麼,正是駭客精神的基石之一——答案就在下一節「記憶體分段」。