上篇文章有提到 Pwn 是指利用程式漏洞來入侵並控制程式,最終取得系統(如個人電腦、伺服器、網路設備等)的控制權的過程。為了更好地理解漏洞的成因及其利用方式,在正式開始 Pwn 之前,我們需要先了解程式如何從原始碼變成可執行檔,並最終在系統上執行起來的(本文以 Linux 系統為例)。
本篇文章主要是說明 C 原始碼是如何經歷一系列過程,最後成為一份可執行檔的。
文章架構如下:
註:本系列文章中所提及的環境,若無特別說明,皆以 x86-64 架構的 Linux 系統為準。
首先,我們來看一個簡單的 C 程式 hello.c
:
#include <stdio.h>
int main() {
if (!printf("Hello, World!\n")) {
return -1;
}
return 0;
}
我們希望這段程式碼能讓電腦在螢幕上輸出 Hello, World!。要達成這個目標,首先需要將人類可讀的程式碼轉換為電腦可理解的機器語言。為此,我們可以使用 GCC(GNU Compiler Collection)這套編譯工具。
通常,我們會使用以下命令來生成執行檔 hello
:
gcc -o hello hello.c
在生成執行檔的過程中,會經歷四個主要步驟:
在深入探討每個階段之前,我們先來分析這段 C 程式的基本意圖。
在程式中,我們定義了一個 main
函數,這是程式的入口點(Entry point)。在程式完成一些必要的初始化後,main
函數是第一個被呼叫的函數。進入 main
函數後,程式會依次執行其內部的指令。在這裡,我們使用了 printf
函數來輸出 Hello, World!。
在使用函數前,需要先宣告該函數,以告知編譯器函數的名稱、接受的參數類型及其回傳值類型。然而,我們在這段程式碼中並未看到 printf
的宣告,但可以看到 #include <stdio.h>
這一行指令。這表示我們的 C 程式碼包含了標頭檔 stdio.h
。標頭檔讓使用者可以重複使用某函數,不需要每次用到某函數時都必須自己實作。
而 stdio.h
是 C 標準函式庫(C Standard Library)其中一個標頭檔,負責定義所有標準輸入輸出的函數,我們可以在他的原始碼中看到 printf 的宣告。
最後,程式若能順利輸出訊息,main
函數會回傳 0,表示成功;若輸出失敗,則回傳 -1。
補充:
函式庫(Library)是一組預先編寫好的函數集合,開發者可以在自己的程式中重複使用這些函數。以 printf 為例,它的實作就是定義在標準 C 函式庫中。函式庫主要分為靜態函式庫(Static Library)和共享函式庫(Shared Library)兩種類型。
正如前文提到的,printf
的宣告包含在 stdio.h
這個標頭檔中。預處理的主要工作是將這些包含的標頭檔展開,即將標頭檔中定義的所有函數宣告和巨集展開到原始碼中,生成一個名為 .i
的中間檔案。此外,預處理還會處理所有以 #
開頭的預處理指令,例如 #define
定義的巨集、#include
包含的其他檔案、#ifdef
和 #endif
的條件編譯等。註解也會在這個階段被移除。
預處理完成後,GCC 進入編譯階段,將經過預處理的 C 程式碼轉換為組合語言。
組合語言長的如上圖所示,每一行組合語言指令通常對應於一條機器指令(Machine Instructions),能直接操作 CPU 的元件(如暫存器、記憶體等)。編譯後的組合語言程式碼通常被儲存為 .s
檔案,準備在下一階段進行組譯。
補充:電腦的運作原理簡單來說就是 CPU 不斷的去讀取一行一行的指令來執行,這些指令即為機器指令(Machine Instructions),它們都是以都是二進位(0 和 1)形式來表示的。
組合語言雖然比高階語言更接近機器指令,但電腦無法直接執行。電腦只能理解以二進位(0 和 1)形式表示的機器碼(機器語言)。因此,在組譯階段,組譯器(Assembler)會將組合語言程式碼(.s
檔)轉換為機器碼,生成一個名為目標檔案(Object File)的 .o
檔。這份檔案除了有被轉換過後的機器碼還有一個符號表(Symbol Table)。
所謂的符號(Symbol)代表的是程式中函數或變數的名稱,舉例來說現在有隻程式 symbol.c
如下圖左方。注意到我們使用了三個函數 main
、func
、printf
,以及兩個變數 a
、b
,列出來的這五個東西就是所謂的符號(Symbol),即下圖右方。
其中 B
代表未初始化的全域變數,D
代表已初始化的全域變數,T
代表已經定義過的函數,意思是我們自己實作的函數如 func
和 main
。而U
則是代表未定義過的函數,這意味著 printf
不是我們自己在程式中定義的函數,而是來自外部函式庫的實作。這類函數稱為外部函數。
而符號表的主要用途是幫助在後續的連結階段中,讓連結器(Linker)能夠將這些外部函數與對應的函式庫連結起來,確保程式可以正常調用外部函數。
在連結階段,連結器(Linker)負責將上述未定義的符號與對應的函式庫進行連結。這個過程可以透過兩種方式完成:靜態連結和動態連結。
最終,連結器生成一個可執行檔案,該檔案可以直接在作業系統上執行。
下一篇文章將會說明執行檔是如何在 Linux 系統上執行起來的。