<menu id="guoca"></menu>
<nav id="guoca"></nav><xmp id="guoca">
  • <xmp id="guoca">
  • <nav id="guoca"><code id="guoca"></code></nav>
  • <nav id="guoca"><code id="guoca"></code></nav>

    編譯鏈接過程(預處理、編譯、匯編、鏈接)

    引言

    CTF pwn考察的知識點多數在于底層的內存和操作系統細節,需要參賽選手對內存管理相當熟悉,如我們常說的house of內容。

    現如今的高級語言幾乎都是運行在虛擬機或者解釋器之上,虛擬機和解釋器相當于為高級語言或者腳本語言提供了一個中間層,隔離了與操作系統之間進行交互的細節,這為我們減少了很多與系統底層打交道的麻煩,大大提高了的開發效率。例如Java語言和Java虛擬機JVN, 以太坊區塊鏈的Solidity語言和EVM以太坊合約虛擬機。
    我們平常對底層接觸較少,因此,本文為了讓讀者可以對源代碼如何編譯到二進制可執行程序有一個整體的了解,將會從以下幾個方面介紹一下程序編譯,鏈接和裝載的基本原理。

    1. 首先我們介紹一下不同的 CPU 體系結構和不同的操作系統對應的可執行文件的格式。
    2. 然后以幾個簡單的 C 程序為例子,介紹一下編譯器和鏈接器對程序源代碼所做的處理。
    3. 最后我們看一下程序執行的時候,裝載器對程序的處理以及操作系統對其的支持。

    CPU體系結構和操作系統

    我們現在大部分接觸到的 PC 機或者服務器使用的 CPU 都是 X86_64 指令集體系結構,這是一種基于 CISC(復雜指令集體系結構)。CPU指令集類型中除了 CISC,還有另外一種 RISC 類型的 CPU 體系結構,也就是簡單指令集體系結構, 這些指令集有的會被做成虛擬機封裝在開發環境中。例如nervos的CKB區塊鏈項目的CKB-VM虛擬機,其采用的架構就是RISC-V精簡指令集架構。

    所謂的二進制程序,其實都是有一條一條的 CPU 指令組成,二進制程序執行的過程中,也是由 CPU 把這些指令 load 到指令流中一條一條執行。不同的 CPU 體系結構的指令集是不一樣的,指令的長度和組成都有區別。

    程序編譯成二進制之后,運行的時候是從 main 函數開始執行的。但是這個程序是怎么樣 dump 到內存中的,執行流又是如何準確的定位到 main 函數的地址的。其實這些工作都是操作系統替我們做的。我們可以大膽想一下,main 函數之前操作系統都需要做哪些工作。首先,操作系統肯定要分配一塊虛擬地址空間;然后系統需要把二進制程序中的代碼和數據 load 到這個地址空間中,隨后系統會根據某種特定的文件格式,找到其中某一個特定的位置(初始化段),做一些程序運行前的初始化工作,比如環境變量初始化和全局變量的處理,然后開始執行我們的 main 函數。這里的“某種特定的文件格式”就是為什么二進制程序不能跨平臺運行的原因。

    每種操作系統都有自己的標準,每一種操作系統有自己的二進制文件格式
    操作系統把二進制可執行程序load到內存中之后,會根據默認的這種格式尋找各種數據,比如代碼段,數據段和初始化段。所以說 Windows 下面的 exe 可執行文件,lib 靜態庫,dll 動態庫是不可以直接運行在 Linux 系統下面的;MacOS 下面的 Mach-O 可執行文件,靜態鏈接庫(a庫),動態鏈接庫(so庫)也是不能夠直接放在 Linux 系統下面運行的。反之亦然,Linux 下面的 ELF 可執行文件,靜態鏈接庫(a庫),動態鏈接庫(so庫)同樣不能夠在 Window 系統下面運行。

    源代碼編譯

    剩下的部分主要集中在 gcc/g++ 如何形成一個 Linux 認識的 elf 可執行文件的。

    源文件.c

    首先給出一個程序代碼示例

    int g_a = 1;            //定義有初始值全局變量
    int g_b;                //定義無初始值全局變量
    static int g_c;         //定義全局static變量
    extern int g_x;         //聲明全局變量
    extern int sub();   //函數聲明
    
    int sum(int m, int n) {     //函數定義
        return m+n;
    }
    int main(int argc, char* argv[]) {
        static int s_a = 0;     //局部static變量
        int l_a = 0;                //局部非static變量
        sum(g_a,g_b);
        return 0;
    }

    首先區分一下聲明和定義的概念。在 C 語言程序中,我們可以聲明一個變量和或者一個函數,也可以定義一個變量或者函數。這兩個的區別如下:

    • 聲明一個全局變量或者函數是告訴編譯器,在當前的源文件中可能會用到這個變量或者調用這個函數,但是這個變量或者函數不在當前文件中定義,而會在其他的某個文件中定義,請編譯器編譯本文件的時候不要報錯。
    • 定義一個變量是告訴編譯器在生成的目標文件中預留一個空間,如果變量有初始值,請編譯器在目標文件中保存這個初始值。
    • 定義一個函數是請編譯器在這個文件的目標文件中生成這個函數的二進制代碼。

    然后我們需要來看一下 C 源碼程序中的變量類型和函數類型,最基本的 C 程序(不使用 C++ 功能)是比較簡單的,我們可以聲明定義變量和局部變量,全局變量和局部變量可以聲明為 static 變量和非 static 變量。除此之外,我們可以通過 malloc 動態申請變量。他們的區別如下:

    • 非 static 全局變量表示這個變量存在于程序執行的整個生命周期,同時可以被本源碼文件之外的其他文件訪問到。
    • static 全局變量表示這個變量存在于程序執行的整個生命周期,但是只能被本源碼文件的函數訪問到。
    • 非 static 局部變量表示,這個變量只在本變量所在的函數的執行上下文中存在(實際上這種變量是在函數執行棧的函數棧幀中)
    • static 局部變量其實屬于全部變量的范疇,它存在于程序執行的整個生命周期,但是作用域被局限在定義這個變量的代碼塊中(大括號包含的范圍)
    • 動態申請的變量表明這個變量是在運行過程中,由函數動態的從進程的地址空間中申請一塊空間,并使用這個空間存儲數據。

    對于函數而言,我們同樣可以定義 static 和非 static 的函數,區別如下:

    • 非 static 函數定義表明這是一個全局的函數,可以被本源碼文件的其他文件訪問到。
    • static 函數限制了本函數只能被本源碼文件的函數調用。

    目標文件

    這一部分我們來看一下編譯器的實際做了什么,首先將源代碼文件編譯成目標文件
    gcc -c a.c -o a.o && nm a.o

    編譯鏈接過程(預處理、編譯、匯編、鏈接)
    這里用nm命令查看目標文件的symbol信息。這里的.o文件也是二進制文件,但是沒有經過鏈接等操作,和可執行程序還是有區別的。
    第一列為變量所在的段的相對地址,不同的段可以有相同的地址,這并不沖突。

    第二列為所在段的類型。例如D為Data數據段,T為Text代碼段。第三段為變量名稱。從這其實可以看出,編譯器其實并不復雜,只是按照特定的規則將特定的數據放入特定的位置,方便后續鏈接。

    目標文件的鏈接

    編譯鏈接過程(預處理、編譯、匯編、鏈接)

    可以看到,我們的全局變量g_x和sub()函數在二進制文件中并沒有發現,這是因為編譯器進行優化,自動過濾掉了這兩個”extern”變量,在某些編譯器版本中,或許會報錯。還有,在之前的.o文件中我們查看的變量地址從相對地址變成了絕對抵制

    總結一下鏈接器實現的基本功能

    1. 對各個目標文件中沒有定義的變量,在其他目標文件中尋找到相關的定義。
    2. 把不同目標文件中生成的同類型的段進行合并。
    3. 對不同目標文件中的變量進行地址重定位。

    總結

    細節上來說,Linux下C源代碼程序到可執行二進制程序可以分為4個階段,預編譯->編譯(匯編opcode)->編譯(生成.o文件)->鏈接(鏈接.o和庫文件生成可執行二進制文件)
    從應試做題的角度來說,后面兩個過程非常重要,我們需要熟練掌握其規則。
    下一章我們將講解鏈接過程中的動態鏈接和PLT與GOT,這是CTFpwn的重點。

    參考

    1. 俞甲子,石凡,潘愛民: 程序員的自我修養
    2. http://www.lurklurk.org/linkers/linkers.html
    3. John Levine: Linkers and Loaders
    4. 高級語言的編譯:鏈接及裝載過程介紹 美團技術團隊

    本文章首發在 網安wangan.com 網站上。

    上一篇 下一篇
    討論數量: 0
    只看當前版本


    暫無話題~
    亚洲 欧美 自拍 唯美 另类