一旦理解 C 程式設計的基本概念,其餘就相當容易了。

檔案存取#

在 C 中存取檔案主要有兩種方式:檔案描述元(file descriptors)檔案串流(filestreams)。前者使用一組低階 I/O 函式,後者則是建立在低階函式之上、更高階的緩衝式 I/O。有人認為檔案串流函式較易撰寫,但檔案描述元更直接——本書聚焦於使用檔案描述元的低階 I/O 函式。

這本書背面的條碼代表一個數字。因為這個數字在書店的其他書中是唯一的,收銀員掃描它就能在店家資料庫中查到這本書的資訊。檔案描述元也是這樣一個用來引用已開啟檔案的數字。

四個常用的檔案描述元函式:

函式用途引數
open()開啟檔案以讀取/寫入,回傳檔案描述元檔名指標、一系列指定存取模式的預定義旗標
close()關閉檔案檔案描述元
read()讀取檔案描述元、資料指標、位元組數
write()寫入檔案描述元、資料指標、位元組數

這些函式若發生錯誤都會回傳 –1。回傳的檔案描述元只是個整數值,但在已開啟的檔案之間是唯一的。

simplenote.c 是一支使用檔案描述元的簡單筆記程式,把命令列引數當作筆記附加到 /tmp/notes 檔尾:

// 開啟檔案
fd = open(datafile, O_WRONLY|O_CREAT|O_APPEND, S_IRUSR|S_IWUSR);
if(fd == -1)
   fatal("in main() while opening file");
// 寫入資料
if(write(fd, buffer, strlen(buffer)) == -1)
   fatal("in main() while writing buffer to file");
// 關閉檔案
if(close(fd) == -1)
   fatal("in main() while closing file");

兩個新出現的標準函式:strlen() 接受字串並回傳其長度(與 write() 搭配,因為它需要知道要寫幾個位元組);perror()print error 的簡寫,在 fatal() 中用來在離開前印出額外的錯誤訊息。

存取模式旗標#

open() 用到的旗標定義在 fcntl.hsys/stat.h。存取模式至少要用以下三者之一:

  • O_RDONLY:唯讀
  • O_WRONLY:唯寫
  • O_RDWR:可讀可寫

這些旗標可用位元 OR 運算子與其他選用旗標結合,常用的有:

  • O_APPEND:把資料寫到檔尾
  • O_TRUNC:若檔案已存在,截斷成 0 長度
  • O_CREAT:檔案不存在時建立它

位元運算與旗標的原理#

位元運算用 OR、AND 這類標準邏輯閘來組合位元:兩個位元進入 OR 閘時,只要其一為 1 結果就是 1;進入 AND 閘則必須兩者皆為 1 結果才是 1。完整的 32 位元值可用這些運算子對每個對應位元做邏輯運算。

fcntl_flags.c 印出各旗標的實際值與二進位表示:

O_RDONLY   : 0     : 00000000 00000000 00000000 00000000
O_WRONLY   : 1     : 00000000 00000000 00000000 00000001
O_RDWR     : 2     : 00000000 00000000 00000000 00000010

O_APPEND   : 1024  : 00000000 00000000 00000100 00000000
O_TRUNC    : 512   : 00000000 00000000 00000010 00000000
O_CREAT    : 64    : 00000000 00000000 00000000 01000000

O_WRONLY|O_APPEND|O_CREAT : 1089 : 00000000 00000000 00000100 01000001

這些旗標的值都對應到單一個位元,所以能用 OR 邏輯結合而不破壞任何資訊。只要每個旗標都是「只有唯一位元被打開」的數字,對它們做位元 OR 的效果就等同於相加:1 + 1024 + 64 = 1089。

但這個技巧只在所有位元都唯一時才成立。

檔案權限#

open() 的存取模式用了 O_CREAT,就需要額外一個引數定義新建檔案的權限。這個引數使用 sys/stat.h 中定義的位元旗標,同樣可用位元 OR 結合:

旗標意義
S_IRUSR / S_IWUSR / S_IXUSR使用者(擁有者)的讀/寫/執行權限
S_IRGRP / S_IWGRP / S_IXGRP群組的讀/寫/執行權限
S_IROTH / S_IWOTH / S_IXOTH其他人(任何人)的讀/寫/執行權限
速成:Unix 檔案權限

每個檔案都有一個擁有者與一個群組,可用 ls -l 顯示:

reader@hacking:~/booksrc $ ls -l /etc/passwd simplenote*
-rw-r--r-- 1 root   root   1424 2007-09-06 09:45 /etc/passwd
-rwxr-xr-x 1 reader reader 8457 2007-09-07 02:51 simplenote
-rw------- 1 reader reader 1872 2007-09-07 02:51 simplenote.c

讀、寫、執行權限可分別對三個欄位開關:user(使用者)、group(群組)、other(其他)ls -l 輸出的最前面就是這三組:先是 user 的讀寫執行,接著三個字元是 group,最後三個是 other。r 代表讀、w 代表寫、x 代表執行、- 代表關閉。

每個權限對應一個位元旗標:讀是 4(二進位 100)、寫是 2(010)、執行是 1(001)。由於每個值只含唯一位元,位元 OR 的結果等同於相加。用 chmod 指令即可設定:

reader@hacking:~/booksrc $ chmod 731 simplenote.c
reader@hacking:~/booksrc $ ls -l simplenote.c
-rwx-wx--x 1 reader reader 1826 2007-09-07 02:51 simplenote.c
reader@hacking:~/booksrc $ chmod ugo-wx simplenote.c
reader@hacking:~/booksrc $ ls -l simplenote.c
-r-------- 1 reader reader 1826 2007-09-07 02:51 simplenote.c
reader@hacking:~/booksrc $ chmod u+w simplenote.c
  • chmod 731:user 得到 7(4+2+1,讀寫執行)、group 得到 3(2+1,寫與執行)、other 只有 1(執行)。
  • chmod ugo-wx:從 user、group、other 減去寫與執行權限。
  • chmod u+w:給 user 寫入權限。

simplenote 程式的 open() 使用 S_IRUSR|S_IWUSR,代表 /tmp/notes 建立時只有擁有者可讀寫。

使用者 ID 與 setuid#

Unix 系統上每個使用者都有唯一的使用者 ID,可用 id 指令顯示。

使用者 ID 為 0 的 root 就像管理員帳號,對系統有完整存取權。su 可切換使用者(以 root 執行時不需密碼),sudo 則讓單一指令以 root 身分執行。LiveCD 上的 sudo 為了簡便已設定成不需密碼。

單一使用者的情境沒問題,但很多時候多個使用者需要存取同一份檔案的特定部分。例如 /etc/passwd 含有系統上每個使用者的帳號資訊(包括預設登入 shell),而 chsh 指令允許任何使用者更改自己的登入 shell——它必須能修改 /etc/passwd,但只限於當前使用者帳號那一行

Unix 對此的解答是 set user ID(setuid)權限

setuid 是個可用 chmod 設定的額外檔案權限位元。當帶有這個旗標的程式被執行時,它會以該檔案擁有者的使用者 ID 身分執行。

reader@hacking:~/booksrc $ ls -l /usr/bin/chsh /etc/passwd
-rw-r--r-- 1 root root  1424 2007-09-06 21:05 /etc/passwd
-rwsr-xr-x 1 root root 23920 2006-12-19 20:35 /usr/bin/chsh

ls 輸出中的 s 就表示 setuid 旗標已設定。由於這個檔案由 root 擁有且設了 setuid,任何使用者執行它時程式都會以 root 身分執行。chsh 的程式邏輯被設計成只允許寫入 /etc/passwd 中對應執行者的那一行——即使程式實際上是以 root 身分在跑。

真實 UID 與有效 UID#

這意味著執行中的程式同時有真實使用者 ID(real user ID)有效使用者 ID(effective user ID),可分別用 getuid()geteuid() 取得:

reader@hacking:~/booksrc $ ./uid_demo
real uid: 999
effective uid: 999
reader@hacking:~/booksrc $ sudo chown root:root ./uid_demo
reader@hacking:~/booksrc $ sudo chmod u+s ./uid_demo
reader@hacking:~/booksrc $ ls -l uid_demo
-rwsr-xr-x 1 root root 6825 2007-09-07 05:32 uid_demo
reader@hacking:~/booksrc $ ./uid_demo
real uid: 999
effective uid: 0

單純把擁有者改成 root 還不夠——兩個 ID 仍都是 999。加上 chmod u+s 開啟 setuid 後,reader 執行 uid_demo有效使用者 ID 變成 0(root),程式因此能以 root 身分存取檔案。這正是 chsh 能讓任何使用者更改自己登入 shell 的機制。

多使用者筆記程式#

同樣的技巧可用在多使用者筆記程式上。notetaker.c 修改自 simplenote,額外記錄每則筆記原作者的使用者 ID,資料檔改存到更持久的 /var/notes

userid = getuid(); // Get the real user ID.

// Writing data
if(write(fd, &userid, 4) == -1) // Write user ID before note data.
   fatal("in main() while writing userid to file");
write(fd, "\n", 1); // Terminate line.

if(write(fd, buffer, strlen(buffer)) == -1) // Write note.
   fatal("in main() while writing buffer to file");
write(fd, "\n", 1); // Terminate line.

由於 write() 的來源引數需要指標,這裡對整數值 userid 使用 & 運算子取得其位址。

同時,ec_malloc()fatal() 這些好用的函式被抽出到獨立的 hacking.h 檔中。

在 C 中,#include 的檔名以 <> 包住時,編譯器會到 /usr/include/ 等標準 include 路徑尋找;以引號包住時,編譯器會在當前目錄尋找。所以同目錄下的 hacking.h 可用 #include "hacking.h" 納入。

編譯後設為 setuid root,執行結果:

reader@hacking:~/booksrc $ sudo hexdump -C /var/notes
00000000  e7 03 00 00 0a 74 68 69  73 20 69 73 20 61 20 74  |.....this is a t|
00000010  65 73 74 20 6f 66 20 6d  75 6c 74 69 75 73 65 72  |est of multiuser|
00000020  20 6e 6f 74 65 73 0a                              | notes.|
reader@hacking:~/booksrc $ pcalc 0x03e7
        999              0x3e7          0y1111100111

因為 little-endian 架構,整數 999 的 4 個位元組在十六進位中呈現反序(e7 03 00 00)。

notesearch:只顯示自己的筆記#

一般使用者要讀到筆記資料,需要一支對應的 setuid root 程式。notesearch.c 讀取筆記資料,只顯示與該使用者 ID 相符的筆記,並可選擇性地接受一個搜尋字串引數。

int find_user_note(int fd, int user_uid) {
   int note_uid=-1;
   unsigned char byte;
   int length;

   while(note_uid != user_uid) {   // Loop until a note for user_uid is found.
      if(read(fd, &note_uid, 4) != 4) // Read the uid data.
         return -1; // If 4 bytes aren't read, return end of file code.
      if(read(fd, &byte, 1) != 1) // Read the newline separator.
         return -1;

      byte = length = 0;
      while(byte != '\n') { // Figure out how many bytes to the end of line.
         if(read(fd, &byte, 1) != 1)
            return -1;
         length++;
      }
   }
   lseek(fd, length * -1, SEEK_CUR); // Rewind file reading by length bytes.
   printf("[DEBUG] found a %d byte note for user id %d\n", length, note_uid);
   return length;
}

這裡檔名改用 #define 定義在頂端,而非使用 heap 記憶體。另外 lseek() 用來倒回檔案的讀取位置:lseek(fd, length * -1, SEEK_CUR) 告訴程式從目前位置往前移動 length * -1 個位元組——結果是負數,所以位置往回退 length 個位元組。

不同使用者執行時,各自只能看見自己的筆記:

jose@hacking:/home/reader/booksrc $ ./notesearch
[DEBUG] found a 24 byte note for user id 501
This is a note for jose
-------[ end of note data ]-------

即使 notetakernotesearch 都是 suid root、對 /var/notes 有完整讀寫權,notesearch 的程式邏輯阻止了當前使用者檢視他人的筆記。這與 /etc/passwd 存放所有使用者資訊、卻由 chshpasswd 等程式限制各人只能改自己那份,是完全相同的模式。

結構#

有時多個變數應該被歸在一起、當成一個東西看待。在 C 中,結構(struct) 就是能包含許多其他變數的變數。許多系統函式與函式庫都使用結構,所以理解結構是使用這些函式的前提。

以時間函式使用的 tm 結構為例(定義於 /usr/include/time.h):

struct tm {
     int      tm_sec;      /* seconds */
     int      tm_min;      /* minutes */
     int      tm_hour;     /* hours */
     int      tm_mday;     /* day of the month */
     int      tm_mon;      /* month */
     int      tm_year;     /* year */
     int      tm_wday;     /* day of the week */
     int      tm_yday;     /* day in the year */
     int      tm_isdst;    /* daylight saving time */
};

定義之後,struct tm 就成了可用的變數型別。

三種存取結構元素的方式#

hour   = current_time.tm_hour; // Direct access
minute = time_ptr->tm_min;     // Access via pointer
second = *((int *) time_ptr);  // Hacky pointer access
  1. 直接存取:用結構變數時,在變數名後加上點與元素名稱,如 current_time.tm_hour
  2. 透過指標:指向結構的指標很常用,因為傳一個 4 位元組的指標遠比傳整個資料結構有效率。結構指標太常見,所以 C 內建了一種不需解參考就能存取元素的方法——看起來像向右箭頭的 ->,如 time_ptr->tm_min
  3. 駭客式的指標存取*((int *) time_ptr)

第三種方法為什麼會成立?記住,到頭來一切都只是記憶體。 由於 tm_sec 定義在 tm 結構的開頭,那個整數值也就位在結構記憶體的開頭。time_ptr 被從 tm 結構指標轉型成整數指標,再解參考取出該位址的資料——於是取回的正是結構中 tm_sec 的整數值。

time_example2.c 進一步傾印結構的位元組,證明 tm 結構的元素在記憶體中緊鄰彼此;結構中更後面的元素也能靠對指標位址做加法直接存取:

bytes of struct located at 0xbffff7f0
18 00 00 00 16 00 00 00 04 00 00 00 09 00 00 00
08 00 00 00 6b 00 00 00 00 00 00 00 fb 00 00 00
00 00 00 00 00 00 00 00 28 a0 04 08
int_ptr @ 0xbffff7f0 : 24
int_ptr @ 0xbffff7f4 : 22
int_ptr @ 0xbffff7f8 : 4

雖然結構記憶體可以這樣存取,但這麼做假設了結構中變數的型別,也假設變數之間沒有任何填補(padding)。由於結構元素的資料型別本來就存在結構裡,用正規方法存取結構元素要容易得多。

函式指標#

指標不過是含有記憶體位址、並被賦予一個描述它指向何物的資料型別。指標通常用於變數,但它也能用於函式

// funcptr_example.c
int main() {
   int value;
   int (*function_ptr) ();

   function_ptr = func_one;
   printf("function_ptr is 0x%08x\n", function_ptr);
   value = function_ptr();
   printf("value returned was %d\n", value);

   function_ptr = func_two;
   printf("function_ptr is 0x%08x\n", function_ptr);
   value = function_ptr();
   printf("value returned was %d\n", value);
}
function_ptr is 0x08048374
This is function one
value returned was 1
function_ptr is 0x0804838d
This is function two
value returned was 2

偽亂數#

由於電腦是確定性機器(deterministic machines),它們不可能產生真正的隨機數。但許多應用需要某種形式的隨機性,於是有了**偽亂數產生器(pseudo-random number generator)**函式。

這些函式能從一個種子(seed)數字出發,產生看似隨機的數字序列;但用同一個種子就能再次產生完全相同的序列。確定性機器無法產生真正的隨機,但只要偽亂數函式的種子值不為人知,序列看起來就是隨機的。

  • 產生器必須先用 srand() 以某個值播種。
  • 之後 rand() 會回傳 0 到 RAND_MAX 之間的偽亂數。
  • 這些函式與 RAND_MAX 定義在 stdlib.h

要讓後續每次執行程式都維持偽隨機性,隨機器每次都必須以不同的值播種。常見做法是用 time() 回傳的 epoch 秒數當種子。

srand(time(0));

printf("random values from 0 to RAND_MAX\n");
for(i=0; i < 8; i++)
   printf("%d\n", rand());
printf("random values from 1 to 20\n");
for(i=0; i < 8; i++)
   printf("%d\n", (rand()%20)+1);

注意這裡用取餘數運算子把值限制在 1 到 20 之間。

綜合演練:機率遊戲#

本節最後一支程式是一組機率遊戲,用上了前面討論過的許多概念:

  • 以偽亂數產生器函式提供機率元素。
  • 三個不同的遊戲函式,透過單一個全域函式指標呼叫。
  • 結構保存玩家資料,並存進檔案。
  • 多使用者檔案權限與使用者 ID讓多位玩家各自維護自己的帳號資料。
// Custom user struct to store information about users
struct user {
   int uid;
   int credits;
   int highscore;
   char name[100];
   int (*current_game) ();
};

主選單依玩家的選擇設定 player.current_game 函式指標,再由 play_the_game() 統一呼叫:

else if (choice < 4) {          // Otherwise, choice was a game of some sort.
   if(choice != last_game) {    // If the function ptr isn't set
      if(choice == 1)           // then point it at the selected game
         player.current_game = pick_a_number;
      else if(choice == 2)
         player.current_game = dealer_no_match;
      else
         player.current_game = find_the_ace;
      last_game = choice;       // and set last_game.
   }
   play_the_game();             // Play the game.
}

三個遊戲分別是:

  • Pick a Number:花 10 點遊玩,猜 1 到 20 之間的數字,猜中贏得 100 點的頭獎。
  • No Match Dealer:可押注全部點數。莊家發出 16 個 0 到 99 的隨機數,若其中沒有任何重複就翻倍。
  • Find the Ace:可押注全部點數。發出三張牌(兩張皇后、一張 A),選中 A 就贏得押注;選牌後會揭露其中一張皇后,此時可以改選另一張牌加碼押注

由於這是會寫入 /var 目錄的多使用者程式,它必須是 suid root。

Find the Ace 這個遊戲是**條件機率(conditional probability)**原理的示範:儘管違反直覺,改變你的選擇會把找到 A 的機率從 33% 提升到 50%。許多人難以理解這個事實——這正是它違反直覺之處。

駭客的祕密,就在於理解這類鮮為人知的真相,並運用它們產生看似魔法的結果。