Hello World Odyssey - Part 1. 邂逅 ELF

6/2/2021 ELFLinker

# Hello World Odyssey - 邂逅 ELF

本篇文章將介紹 ELF 檔案格式,以及引入一些符號的概念,對於逆向工程、linker 的概念都會有所助益,也有助於我們理解 Hello world 的底層原理。

# 一、為何要了解 ELF 檔案格式 ?

# 1.1 ELF 介紹 - 這不是我認識的 hello world

ELF 中文維基百科 (opens new window)中,僅以一小段話簡介

可執行與可鏈結格式 (英語:Executable and Linkable Format,縮寫為ELF),常被稱為ELF格式,在電腦科學中,是一種用於執行檔、目的檔、共享庫和核心轉儲(core dump)的標準檔案格式。

在 Unix 體系下,可執行檔案(Executable file)跟目的檔案(Object file)都是依循著 ELF 檔案格式,一般原始程式碼變成可執行檔案的流程如下。

原本的 C 語言檔案,經過 compiler 編譯及 assembler 組譯後,得到二進位機器碼的目的檔案(Object file),之後我們會將所有引用到的函式庫以 linker 連結再一起,並且建立彼此的符號關係及進行重定位。以 hello world 檔案為例子,注意在此以靜態連結為例子。

#include <stdio.h>
int main()
{
        printf("Hello world!\n");
}
1
2
3
4
5
1
2
3
4
5
  1. Preprocessor: 首先, stdio 會經由前處理器把整段 stdio.h 的內容貼上去
  2. compiler / assembler: 利用這兩個工具編譯成機器碼ELF格式的目的檔
  3. linker: 在 hello world 的 C 語言程式,我們引用到了 printf 的函數,我們以為函數在stdio.h 內就有實現,然而當我們查 stdio.h 的檔案時,卻發現一件意外的事情。
...
extern int printf (const char *__restrict __format, ...);
...
1
2
3
1
2
3

什麼 ? 在 stdio.h 內居然只有 extern 外部引用的宣告而已,實作並不在此檔案。也就是說,我們需要讓 linker 幫我們鏈接 libc.a 的靜態函式庫,因為 printf 的實作就躲在這個函式庫裡的 printf.o 檔案中。 倘若我們不鏈接,我們直接執行,程式沒有實作只有定義宛如沒有靈魂的空殼,無法順利執行。

這裡補充一下,xxx.a 檔案是靜態函式庫的檔案,可以看成是將所有 object file 的檔案合併在一個壓縮檔。那我們怎麼知道 libc.a 有 printf 的目的檔 ? 根據 這篇stackoverflow 的問答 (opens new window),輸入指令

ar -t /usr/lib/x86_64-linux-gnu/libc.a | grep ^printf.o
1
1

ar 是一個壓縮(archieve)指令,通常我們以這個指令來打包所有的目的檔成為一個靜態函式庫,-tman ar所述,是列出所有打包的內容檔案的選項。

...
 t   Display a table listing the contents of archive, or those of the files listed in member... that are
           present in the archive.  Normally only the member name is shown; if you also want to see the modes
           (permissions), timestamp, owner, group, and size, you can request that by also specifying the v modifier.
...
1
2
3
4
5
1
2
3
4
5

由此指令,我們還真的可以發現 printf 的實作躲在這個函式庫,因此在靜態連結(static link)時,直接將 hello.o 跟 printf.o 連結再一起即可。所以我們常講的ld linker 其實是叫 link editor,最後編譯的一道手續 ld 把該有的資訊寫進可執行檔。如果是靜態連結(static link)那麼就會去找出libxxx.a的函式庫檔,如同方才所述,把想要的程式碼片段拷貝一份進可執行檔並且做重定位後, 把參照的相互引用關係寫進可執行檔,這個檔就可以執行。

陳鍾誠老師的網站 (opens new window)也寫到,

ELF可用來記錄目的檔 (object file)、執行檔 (executable file)、動態連結檔 (share object)、與核心傾印 (core dump) 檔等格式,並且支援較先進的動態連結與載入等功能。因此,ELF 格式在 UNIX/Linux 的設計上具有相當關鍵性的地位。

為了支援連結與執行等兩種時期的不同用途,ELF 格式可以分為兩種不同觀點,而目標文件的格式其實與最終的可執行文件的格式是一致的,只是沒有經過符號的鏈接過程,這點要注意。

View 工具 檔案 位置 結構
連結時期觀點 (Linking View) linker 目的檔(Object file) 在硬碟時的樣子 以分段 (Section) 為主的結構
執行時期觀點 (Execution View) loader 執行檔 (Executable File) 在記憶體時的樣子 以分區 (Segment) 為主的結構,其中,一個區通常是數個分段的組合體,像是與程式有關的段落,包含程式段、程式重定位等,在執行時期會被組合為一個分區。

執行檔中也有很多檔案格式,例如 windows 的 COFF 檔案格式,Unix 體系的 ELF 檔案格式等等,在最後一道編譯過程中,linker ld 會偷偷放一堆資訊進去可執行檔。例如尤其是當我們有好多個compile後等待連結的.o 這種可重定位(relocatable) 檔,既然這些檔裡面變數或函式名的相對位置參照自己檔案的相對位置就有一些資訊是要告訴 link editor (就是 ld) 怎樣修改section的內容去做重定位(relocate)也就是做位址的重新參照以便合成一個新的可執行檔。之後在執行時,變數、函數的位置才可以相互正確引用。

最後,ELF 檔案在 linux 世界中執行兩項神聖的任務

  1. 告訴 kernel 從 disk 上的 ELF 文件,那些內容要擺放在哪個位置,並提供動態載入程序的功能
  2. ELF 的定義的資料結構,提供理解檔案的有用訊息

# 1.2 學習 ELF 檔案格式的理由

由上一個小節,是不是開始覺得跟我們所想像的 hello world 開始不太一樣了😀, 即使我們脫去 IDE 的外殼,只用純粹的指令跟 makefile 來編譯執行程式碼,粒度仍然 太大,什麼意思 ? 別忘了 gcc 是 GNU Compiler Collection 的縮寫,gcc 是一個編譯的 toolchain,幫我們一條龍包裝編譯器、組譯器、鏈接器。直接幫我們做好成品 - 也就是我們的執行檔,隱藏中間發生的事情,封裝流程的代價就是我們容易忽略一些 細節,造成之後出現一些符號引用錯誤、鏈接錯誤等,需要多花很多功夫來學習,見招拆 招,但沒有一個系統性的學習很容易就忘記,下次遇到一樣的錯誤就要重新求救 google 搜尋引擎🙄。

  1. 功利導向 - 增加除錯效率,早點下班

    學會 ELF 檔案格式,是了解符號連結跟鏈接器的敲門磚,對於符號處理、連結相關的問題也能快速排障,減少犯錯跟求助的時間,加速工作效率。

  2. 求知慾本能導向 - 增加內功

    事實上,如同最前段所述,在一切都封裝好,一切都很便利的時代,我們往往忽略了細節,像是我們我們使用馬桶,卻不知馬桶的運作原理一樣。認識 ELF 檔案格式,就是我們開始增加內功的第一步,是對 linker 機制的尋幽訪勝,可以理解 linker, loader 的工作原理,同時學到更多符號的知識,在一切看似理所當然的世界,仍能增加一絲探索的樂趣。

這是必經之路,也是必要之惡😅。

# 1.3 分析 ELF 檔案格式兩大分析工具

  • readelf

    readelf 用來解析ELF 檔案,輸出 elf 檔案的所有資訊,我們可以從這些資訊查到各種section的資訊

  • hexdump / objdump

    hexdump 跟 objdump 用來幫我們把 ELF 檔案的機器碼,依照 ASCI code 轉成對應的字元,也可以得到該地址對應的內容,在本篇文章常常跟 readelf 交叉查看。

在本篇介紹中,會交叉查看 readelf 跟 hexdump 的結果,是不是有種會逆向工程的資安駭客的味道(四叉貓式滾動)。

# 1.4 ELF 美圖秀秀

這裡列出幾張 ELF 的圖片,先讓你們對 ELF 格式有點印象,先放寬心,欣賞幾幅美麗的圖吧,不得不說,中國那裡科技文章真的都蠻會畫圖的🤣。

不知道各位有沒有聽過很有名的 3 的法則,使用 3 的法則可以大幅提高跟聚焦人的專注力,蘋果 CEO 賈伯斯在報告簡報時,也很常使用這種技巧。那我們 ELF 檔的格式,最主要也是由三個區塊拼湊出來的

ELF File 3 Blocks
  1. ELF Header
  2. ELF Section Header Table
  3. Sections

聚焦在這三個區塊,在往後的理解可以更加迅速。在下一章節,也會聚焦在這三大點上。

# 二、ELF 檔案格式

首先,我們先準備一個檔案,我這裡命名為 elf.c,本章會在著重在這個檔案的分析。

// elf.c
unsigned long long data1 = 0xdddddddd11111111;
unsigned long long data2 = 0xdddddddd22222222;
void func1(){}
void func2(){}
1
2
3
4
5
1
2
3
4
5

之後下 gcc 指令將之編譯 但不連結,輸出 object file 的二進位檔案,接下來會不斷探討這個簡單的程式碼。

gcc -c elf.c -o elf.o
1
1

那我們先用 hexdump 來幫我們看一下內容吧 😀

沒想到短短四行程式碼,居然生出這麼巨大的內容,那是因為 在產生目的檔的過程中放入很多額外資訊,方便未來可執行、可鏈接,接下來就正式介紹 ELF 檔案格式吧。

接下來的文章,會大量引用到 elf.h 的程式碼,內有定義各種 ELF 使用到的資料結構

# 2.1 ELF Header

做為整個檔案的入口,ELF header 幫我們指出 Program table 和 Section Header table 的位置,內容包含了

  • 魔數(magic number)
  • 檔案類型和大小
  • 靜態鏈接或是動態鏈接
  • 程式進入點地址(如果是可執行檔案的話)
  • ...

我們可以先去 elf.h 的檔案標頭檔,看看怎麼定義 elf 的資料結構,順便使用 readelf 幫我們解析 elf.o 的 ELF header,我們就可以得到 ELF 表頭完整的資訊。

你可以發現,其實標頭檔的資訊並沒有很多,粗略可以說,標頭檔是用來告訴我們這個檔案的相關訊息,接下來就要介紹這些欄位的用處了。

Data Type Bytes member name description
unsigned char 16 e_ident[EI_NIDENT] Magic number and other info
Elf64_Half 2 e_type Object file type
Elf64_Half 2 e_machine Architecture
Elf64_Word 4 e_version Object file version
Elf64_Addr 8 e_entry Entry point virtual address
Elf64_Off 8 e_phoff Program header table file offset
Elf64_Off 8 e_shoff Section header table file offset
Elf64_Word 4 e_flags Processor-specific flags
Elf64_Half 2 e_ehsize ELF header size in bytes
Elf64_Half 2 e_phentsize Program header table entry size
Elf64_Half 2 e_phnum Program header table entry count
Elf64_Half 2 e_shentsize Section header table entry size
Elf64_Half 2 e_shnum Section header table entry count
Elf64_Half 2 e_shstrndx Section header string table index

這裡挑幾個重點說明

  1. e_ident: 前四個bytes 被稱作 ELF的 Magic Number。後面的 bytes 描述了ELF 內容如何解碼等信息。

  2. e_type: 描述了ELF 的類型

    type value Description
    ET_NONE 0 No file type
    ET_REL 1 Relocatable file(可重定位檔案,通常是文件名以.o结尾,目標檔案)
    ET_EXEC 2 Executable file (可執行檔案)
    ET_DYN 3 Shared object file (動態庫檔案,你用gcc編譯出的二進位往往也屬於這種類型😀)
    ET_CORE 4 core 檔案
    ET_NUM 5 表示已經定義了5種檔案類型
    ET_LOPROC 0XFF00 Processor-specific
    ET_HIPROC 0XFFFF Processor-specific
  3. e_entry: 執行入口點,如果檔案沒有入口點(不是可執行檔),這個區域保持0。

再來以下五個欄位,和我們後面的理解、計算地址偏移有很大的關係,可以稍微注意一下。

  1. e_shoff: section header table 的 offset
  2. e_ehsize: ELF header 的大小
  3. e_shentsize: section header table 的 entry size
  4. e_shnum: section header table 的 entry 數目
  5. e_shstrndx:section header string table index

那接下來,我們來交叉比對 readelfhexdump 的結果,來更加明白

在閱讀 hexdump 時,要注意是 little-endian 或是 big-endian,例如最後兩個 byte 是 Section header string table index, hexdump 的結果是 0a 00,因為是 little-endian 請把它轉換為 00 0a,得到 Section header string table index = 10。

介紹完 header 的資料結構後,承接 section header table 的三劍客是 e_shoff, e_shentsize, e_shnum,我們 由 e_shoff 可以幫我們找到 section header table 的起始地址 。另外兩個只是告訴你該表格的欄位大小跟個數而已。

再來就是劇透時間😂

e_shoff 可以讓我們直接得到 section header table 的起始地址,另外我們可以想像 Section header table 是一個一維陣列,那一維陣列的容量怎麼算? 是不是大小 x 個數, 因此 e_shentsize x e_shnum 就是我們整張 Section header table 的大小,又因為 Section header table 是放在ELF 檔案最後面的地方,因此我們可以得出

filesize=e_shoff+e_shentsize×e_shnumfilesize = e\_shoff + e\_shentsize \times e\_shnum

就可以計算出整個ELF檔案的大小了,很簡單吧,ELF 其實沒有你想像中的難,接下來就正式進入 section header table 的介紹。

# 2.2 Section Header table

Section Header table 主要作用于鏈接過程,包含靜態鏈接與動態鏈接兩種鏈接方式,記錄了各 Section 的基本資訊,包含 Section 起始位址等。因此可以透過此 table 讀取各 Section,所以在 Section header table 內的每一個欄位元素,一一對應各個 Section,可以說 Section header table就是一個索引表來記錄各個section的索引。

那我們先來簡單看一下 Section header table 的資訊吧

我們可以看到,各個 seciton 對應的 index 是什麼,最開始第 0 項的 section 全部都是 0,佔了 40 bytes,section header table 的第 1 項是 .text section,其他依此類推。所以我們的 section header table 總共有 11 項, 在先前 readelf -h elf.o 的結果中,Number of section headers 也跟我們說了總共 11 項。

這個 readelf 格式其實可讀性不是很好,因為早期的螢幕較小,所以要這樣換行呈現,所以要委屈一下你我的眼睛。

再來看原始資料結構的定義吧

typedef struct
{
    Elf64_Word	sh_name;	      //  0000001b	
    Elf64_Word	sh_type;	      //  00000001 == PROG_BITS
    Elf64_Word	sh_flags;	      //  00000006 == 02 + 04 = SHF_ALLOC | SHF_EXECINSTR	
    Elf64_Addr	sh_addr;	      //  0000000000000000
    Elf64_Off	sh_offset;	 //  0000000000000040 = 0x40 = 64 // section 起始地址	
    Elf64_Word	sh_size;        //  size = 0000000e = 14
    Elf64_Word	sh_link;		
    Elf64_Word	sh_info;		
    Elf64_Word	sh_addralign;	
    Elf64_Word	sh_entsize;		
} Elf64_Shdr;
1
2
3
4
5
6
7
8
9
10
11
12
13
1
2
3
4
5
6
7
8
9
10
11
12
13

直接拿 Section header table 第一個元素來比照,並且解釋於本篇較重要的成員。

[1]
000002f0  1b 00 00 00 01 00 00 00  06 00 00 00 00 00 00 00  |................|
00000300  00 00 00 00 00 00 00 00  40 00 00 00 00 00 00 00  |........@.......|
00000310  0e 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000320  01 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
1
2
3
4
5
1
2
3
4
5
Elf64_Word	sh_name       = 0000001b	
Elf64_Word	sh_type       = 00000001 == PROG_BITS
Elf64_Word	sh_flags      = 00000006 == 02 + 04 = SHF_ALLOC | SHF_EXECINSTR	
Elf64_Addr	sh_addr       = 0000000000000000
Elf64_Off   sh_offset     = 0000000000000040 = 0x40 = 64 // section 起始地址	
Elf64_Word	sh_size       = 0000000e = 14
Elf64_Word	sh_link       = 00000000	
Elf64_Word	sh_info       = 00000000	
Elf64_Word	sh_addralign  = 00000000
Elf64_Word	sh_entsize    = 00000001   		
1
2
3
4
5
6
7
8
9
10
1
2
3
4
5
6
7
8
9
10

這裡比較多好玩的東西可以說 ~

sh_name: 是該 Section 名字的偏移量,利用此偏移量在 shstrtab section 找到這個section 的名字,而我們所有 section 的名字,集中在 .shstrtab 這個 section 內,這裡先劇透一下,shstrtab 跟 strtab 都是所有字串 concat 再一起,換言之,就是一個超大字串,所以我們必須要用偏移量的方式去尋找我們的名字,這樣的設計我認為是出於 section header 資料結構比較好設計、統一。

首先我們同樣比對 readelf 跟 hexdump 的結果,來看這個 sh_name 跟 shstrtab section的關係,請依照 tab 順序觀看。

sh_flags: 這裡利用到一個特性,任一正整數都可以唯一表示成一種二進制,因此將此數 拆解 成不同的二進位相加就可以知道設哪些 flag 。

其實這裡也有一點線性代數的味道, 如上面的 00000006(十六進位),是由 00000010 | 00000100 (二進位)合成 8個 bits 有 8 個彼此獨立的正交向量 (00000001, 00000010, 00000100, 00001000, 00010000, 00100000, 01000000, 100000000) 因此這裡的 6 就是 00000010 | 00000100 得到的線性組合。

sh_size: 此 section 的大小 sh_offset: 這個可以推得該 section 起始位置,如下圖

因此,該 section 的範圍是 [ sh_offset, sh_offset + sh_size ] (閉區間)

sh_entsize: 如果這個 section 是表格,例如 symtab section,這是個該section 每個 entry 的大小。 截至目前為止,我們大概將ELF linking view 各自的引用關係,都講完了。

# 2.3 ELF 引用關係總結

事實上,每個 section 的意義我要留到下一篇再講,因為要跟 symbol table 做連結、一次性的探討較完整部分散。那本篇 ELF linking view 引用流程總結如下。請看我畫的美圖🤣

  1. 由 ELF header,知道這個 ELF 檔的描述資訊,其中重要的三劍客 e_shoff, e_shnum, e_shentsize

  2. 由 ELF Header 得到的三劍客

    • e_shoff 讓我們得到 section header table 的起始位置
    • e_shnum, e_shentsize 讓我們得到 section header table 每個元素大小跟個數,可以聯想到一維陣列的 大小 x 個數 又由於 section header table 在 ELF 檔案的最後,我們也可以推得檔案大小

    filesize=e_shoff+e_shentsize×e_shnumfilesize = e\_shoff + e\_shentsize \times e\_shnum

  3. section header table 每一個元素或索引對應一個 section,從section header table 選定一個元素,我們可以得知另一對三劍客 sh_name, sh_size, sh_offset

  4. 得到 setion header table 某一元素後,我們可以求得

    • sh_offset 得到 section 的起始位址
    • sh_size 得到 section 的大小
    • sh_name 得到 section 名字的偏移量,我們要去 shstrtab section 利用此偏移量從這個大字串中找 section 的名字

這就整體的流程,現在對於 ELF 的相互引用,是不是有些概念了呢 ? 下一篇我們將講述 symtab section 跟 symbol 的相關知識。

# 三、參考連結

  1. 特別感謝 yaaangmin (opens new window),讓我受益良多的影片。
  2. 詳解ELF (opens new window)
  3. ProgrammerSought (opens new window)
  4. Introduction to the ELF Format : The ELF Header (Part I) (opens new window)
  5. 陳鍾誠老師的網站 (opens new window)
  6. ELF格式文件 (opens new window)
  7. gcc與Obj檔,動態連結與ELF檔 (opens new window)
Last Updated: Tue Jul 06 2021 19:28:11 GMT+0800