作者:evilpan
原文鏈接: https://evilpan.com/2020/08/09/elf-inside-out/
本文為作者投稿,Seebug Paper 期待你的分享,凡經采用即有禮品相送!
投稿郵箱:paper@seebug.org
本文介紹了ELF的基本結構和內存加載的原理,并用具體案例來分析如何通過ELF特性實現HIDS bypass、加固/脫殼以及輔助進行binary fuzzing。
前言
作為一個安全研究人員,ELF可以說是一個必須了解的格式,因為這關系到程序的編譯、鏈接、封裝、加載、動態執行等方方面面。有人就說了,這不就是一種文件格式而已嘛,最多按照SPEC實現一遍也就會了,難道還能復雜過FLV/MP4?曾經我也是這么認為的,直到我在日常工作時遇到了下面的錯誤:
$ r2 a.out
Segmentation fault
作為一個開源愛好者,我的radare2經常是用master分支編譯的,經過在github中搜索,發現radare對于ELF的處理還有不少同類的問題,比如issue#17300以及issue#17379,這還只是近一個月內的兩個open issue,歷史問題更是數不勝數。
總不能說radare的開發者不了解ELF吧?事實上他們都是軟件開發和逆向工程界的專家。不止radare,其實IDA和其他反編譯工具也曾出現過各類ELF相關的bug。
說了那么多,只是為了引出一個觀點: ELF既簡單也復雜,值得我們去深入了解。網上已經有了很多介紹ELF的文章,因此本文不會花太多篇幅在SPEC的復制粘貼上,而是結合實際案例和應用場景去進行說明。
ELF 101
ELF的全稱是Executable and Linking Format,這個名字相當關鍵,包含了ELF所需要支持的兩個功能——執行和鏈接。不管是ELF,還是Windows的PE,抑或是MacOS的Mach-O,其根本目的都是為了能讓處理器正確執行我們所編寫的代碼。
大局觀
在上古時期,給CPU運行代碼也不用那么復雜,什么代碼段數據段,直接把編譯好的機器碼一把梭燒到中斷內存空間,PC直接跳過來就執行了。但隨著時代變化,大家總不能一直寫匯編了,即便編譯器很給力,也會涉及到多人協作、資源復用等問題。這時候就需要一種可拓展(Portable)的文件標準,一方面讓開發者(編譯器/鏈接器)能夠高效協作,另一方面也需要系統能夠正確、安全地將文件加載到對應內存中去執行,這就是ELF的使命。

從大局上看,ELF文件主要分為3個部分:
- ELF Header
- Section Header Table
- Program Header Table
其中,ELF Header是文件頭,包含了固定長度的文件信息;Section Header Table則包含了鏈接時所需要用到的信息;Program Header Table中包含了運行時加載程序所需要的信息,后面會進行分別介紹。
ELF Header
ELF頭部的定義在elf/elf.h中(以glibc-2.27為例),使用POD結構體表示,內存可使用結構體的字段一一映射,頭部表示如下:
#define EI_NIDENT (16)
typedef struct
{
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf32_Half e_type; /* Object file type */
Elf32_Half e_machine; /* Architecture */
Elf32_Word e_version; /* Object file version */
Elf32_Addr e_entry; /* Entry point virtual address */
Elf32_Off e_phoff; /* Program header table file offset */
Elf32_Off e_shoff; /* Section header table file offset */
Elf32_Word e_flags; /* Processor-specific flags */
Elf32_Half e_ehsize; /* ELF header size in bytes */
Elf32_Half e_phentsize; /* Program header table entry size */
Elf32_Half e_phnum; /* Program header table entry count */
Elf32_Half e_shentsize; /* Section header table entry size */
Elf32_Half e_shnum; /* Section header table entry count */
Elf32_Half e_shstrndx; /* Section header string table index */
} Elf32_Ehdr;
注釋都很清楚了,挑一些比較重要的來說。其中e_type表示ELF文件的類型,有以下幾種:
- ET_NONE: 未知類型
- ET_REL: 可重定向類型(relocatable),通常是我們編譯的
*.o文件 - ET_EXEC: 可執行類型(executable),靜態編譯的可執行文件
- ET_DYN: 共享對象(shared object),動態編譯的可執行文件或者動態庫
*.so - ET_CORE: coredump文件
e_entry是程序的入口虛擬地址,注意不是main函數的地址,而是.text段的首地址_start。當然這也要求程序本身非PIE(-no-pie)編譯的且ASLR關閉的情況下,對于非ET_EXEC類型通常并不是實際的虛擬地址值。
其他的字段大多數是指定Section Header(e_sh)和Program Header(e_ph)的信息。Section/Program Header Table本身可以看做是數組結構,ELF頭中的信息指定對應Table數組的位置、長度、元素大小信息。最后一個e_shstrndx表示的是section table中的第e_shstrndx項元素,保存了所有section table名稱的字符串信息。
Section Header
上節說了section header table是一個數組結構,這個數組的位置在e_shoff處,共有e_shnum個元素(即section),每個元素的大小為e_shentsize字節。每個元素的結構如下:
typedef struct
{
Elf32_Word sh_name; /* Section name (string tbl index) */
Elf32_Word sh_type; /* Section type */
Elf32_Word sh_flags; /* Section flags */
Elf32_Addr sh_addr; /* Section virtual addr at execution */
Elf32_Off sh_offset; /* Section file offset */
Elf32_Word sh_size; /* Section size in bytes */
Elf32_Word sh_link; /* Link to another section */
Elf32_Word sh_info; /* Additional section information */
Elf32_Word sh_addralign; /* Section alignment */
Elf32_Word sh_entsize; /* Entry size if section holds table */
} Elf32_Shdr;
其中sh_name是該section的名稱,用一個word表示其在字符表中的偏移,字符串表(.shstrtab)就是上面說到的第e_shstrndx個元素。ELF文件中經常使用這種偏移表示方式,可以方便組織不同區段之間的引用。
sh_type表示本section的類型,SPEC中定義了幾十個類型,列舉其中一些如下:
- SHT_NULL: 表示該section無效,通常第0個section為該類型
- SHT_PROGBITS: 表示該section包含由程序決定的內容,如
.text、.data、.plt、.got - SHT_SYMTAB/SHT_DYNSYM: 表示該section中包含符號表,如
.symtab、.dynsym - SHT_DYNAMIC: 表示該section中包含動態鏈接階段所需要的信息
- SHT_STRTAB: 表示該section中包含字符串信息,如
.strtab、.shstrtab - SHT_REL/SHT_RELA: 包含重定向項信息
- ...
雖然每個section header的大小一樣(e_shentsize字節),但不同類型的section有不同的內容,內容部分由這幾個字段表示:
- sh_offset: 內容起始地址相對于文件開頭的偏移
- sh_size: 內容的大小
- sh_entsize: 有的內容是也是一個數組,這個字段就表示數組的元素大小
與運行時信息相關的字段為:
- sh_addr: 如果該section需要在運行時加載到虛擬內存中,該字段就是對應section內容(第一個字節)的虛擬地址
- sh_addralign: 內容地址的對齊,如果有的話需要滿足
sh_addr % sh_addralign = 0 - sh_flags: 表示所映射內容的權限,可根據
SHF_WRITE/ALLOC/EXECINSTR進行組合
另外兩個字段sh_link和sh_info的含義根據section類型的不同而不同,如下表所示:

至于不同類型的section,有的是保存符號表,有的是保存字符串,這也是ELF表現出拓展性和復雜性的地方,因此需要在遇到具體問題的時候查看文檔去進行具體分析。
Program Header
program header table用來保存程序加載到內存中所需要的信息,使用段(segment)來表示。與section header table類似,同樣是數組結構。數組的位置在偏移e_phoff處,每個元素(segment header)的大小為e_phentsize,共有e_phnum個元素。單個segment header的結構如下:
typedef struct
{
Elf32_Word p_type; /* Segment type */
Elf32_Off p_offset; /* Segment file offset */
Elf32_Addr p_vaddr; /* Segment virtual address */
Elf32_Addr p_paddr; /* Segment physical address */
Elf32_Word p_filesz; /* Segment size in file */
Elf32_Word p_memsz; /* Segment size in memory */
Elf32_Word p_flags; /* Segment flags */
Elf32_Word p_align; /* Segment alignment */
} Elf32_Phdr;
既然program header的作用是提供用于初始化程序進程的段信息,那么下面這些字段就是很直觀的:
- p_offset: 該segment的數據在文件中的偏移地址(相對文件頭)
- p_vaddr: segment數據應該加載到進程的虛擬地址
- p_paddr: segment數據應該加載到進程的物理地址(如果對應系統使用的是物理地址)
- p_filesz: 該segment數據在文件中的大小
- p_memsz: 該segment數據在進程內存中的大小。注意需要滿足
p_memsz>=p_filesz,多出的部分初始化為0,通常作為.bss段內容 - p_flags: 進程中該segment的權限(R/W/X)
- p_align: 該segment數據的對齊,2的整數次冪。即要求
p_offset % p_align = p_vaddr。
剩下的p_type字段,表示該program segment的類型,主要有以下幾種:
- PT_NULL: 表示該段未使用
- PT_LOAD: Loadable Segment,將文件中的segment內容映射到進程內存中對應的地址上。值得一提的是SPEC中說在program header中的多個PT_LOAD地址是按照虛擬地址遞增排序的。
- PT_DYNAMIC: 動態鏈接中用到的段,通常是RW映射,因為需要由
interpreter(ld.so)修復對應的的入口 - PT_INTERP: 包含interpreter的路徑,見下文
- PT_HDR: 表示program header table本身。如果有這個segment的話,必須要在所有可加載的segment之前,并且在文件中不能出現超過一次。
- ...
在不同的操作系統中還可能有一些拓展的類型,比如PT_GNU_STACK、PT_GNU_RELRO等,不一而足。
小結
至此,ELF文件中相關的字段已經介紹完畢,主要組成也就是Section Header Table和Program Header Table兩部分,整體框架相當簡潔。而ELF中體現拓展性的地方則是在Section和Segment的類型上(s_type和p_type),這兩個字段的類型都是ElfN_Word,在32位系統下大小為4字節,也就是說最多可以支持高達2^32 - 1種不同的類型!除了上面介紹的常見類型,不同操作系統或者廠商還能定義自己的類型去實現更多復雜的功能。
程序加載
在新版的ELF標準文檔中,將ELF的介紹分成了三部分,第一部分介紹ELF文件本身的結構,第二部分是處理器相關的內容,第三部分是操作系統相關的內容。ELF的加載實際上是與操作系統相關的,不過大部分情況下我們都是在GNU/Linux環境中運行,因此就以此為例介紹程序的加載流程。
Linux中分為用戶態和內核態,執行ELF文件在用戶態的表現就是執行execve系統調用,隨后陷入內核進行處理。
內核空間
內核空間對execve的處理其實可以單獨用一篇文章去介紹,其中涉及到進程的創建、文件資源的處理以及進程權限的設置等等。我們這里主要關注其中ELF處理相關的部分即可,實際上內核可以識別多種類型的可執行文件,ELF的處理代碼主要在fs/binfmt_elf.c中的load_elf_binary函數中。
對于ELF而言,Linux內核所關心的只有Program Header部分,甚至大部分情況下只關心三種類型的Header,即PT_LOAD、PT_INTERP和PT_GNU_STACK。以3.18內核為例,load_elf_binary主要有下面操作:
- 對ELF文件做一些基本檢查,保證
e_phentsize = sizeof(struct elf_phdr)并且e_phnum的個數在一定范圍內; - 循環查看每一項program header,如果有PT_INTERP則使用
open_exec加載進來,并替換原程序的bprm->buf; - 根據
PT_GNU_STACK段中的flag設置棧是否可執行; - 使用
flush_old_exec來更新當前可執行文件的所有引用; - 使用
setup_new_exec設置新的可執行文件在內核中的狀態; setup_arg_pages在棧上設置程序調用參數的內存頁;- 循環每一項
PT_LOAD類型的段,elf_map映射到對應內存頁中,初始化BSS; - 如果存在interpreter,將入口(elf_entry)設置為interpreter的函數入口,否則設置為原ELF的入口地址;
install_exec_creds(bprm)設置進程權限等信息;create_elf_tables添加需要的信息到程序的棧中,比如ELF auxiliary vector;- 設置
current->mm對應的字段;
從內核的處理流程上來看,如果是靜態鏈接的程序,實際上內核返回用戶空間執行的就是該程序的入口地址代碼;如果是動態鏈接的程序,內核返回用戶空間執行的則是interpreter的代碼,并由其加載實際的ELF程序去執行。
為什么要這么做呢?如果把動態鏈接相關的代碼也放到內核中,就會導致內核執行功能過多,內核的理念一直是能不在內核中執行的就不在內核中處理,以避免出現問題時難以更新而且影響系統整體的穩定性。事實上內核中對ELF文件結構的支持是相當有限的,只能讀取并理解部分的字段。
用戶空間
內核返回用戶空間后,對于靜態鏈接的程序是直接執行,沒什么好說的。而對于動態鏈接的程序,實際是執行interpreter的代碼。ELF的interpreter作為一個段,自然是編譯鏈接的時候加進去的,因此和編譯使用的工具鏈有關。對于Linux系統而言,使用的一般是GCC工具鏈,而interpreter的實現,代碼就在glibc的elf/rtld.c中。
interpreter又稱為dynamic linker,以glibc2.27為例,它的大致功能如下:
- 將實際要執行的ELF程序中的內存段加載到當前進程空間中;
- 將動態庫的內存段加載到當前進程空間中;
- 對ELF程序和動態庫進行重定向操作(relocation);
- 調用動態庫的初始化函數(如.preinit_array, .init, .init_array);
- 將控制流傳遞給目標ELF程序,讓其看起來自己是直接啟動的;
其中參與動態加載和重定向所需要的重要部分就是Program Header Table中PT_DYNAMIC類型的Segment。前面我們提到在Section Header中也有一部分參與動態鏈接的section,即.dynamic。我在自己解析動態鏈接文件的時候發現,實際上 .dynamic section中的數據,和PT_DYNAMIC中的數據指向的是文件中的同一個地方,即這兩個entry的s_offset和p_offset是相同。每個元素的類型如下:
typedef struct
{
Elf32_Sword d_tag; /* Dynamic entry type */
union
{
Elf32_Word d_val; /* Integer value */
Elf32_Addr d_ptr; /* Address value */
} d_un;
} Elf32_Dyn;
d_tag表示實際類型,并且d_un和d_tag相關,可能說是很有拓展性了:) 同樣的,標準中定義了幾十個d_tag類型,比較常用的幾個如下:
- DT_NULL: 表示_DYNAMIC的結尾
- DT_NEEDED: d_val保存了一個到字符串表頭的偏移,指定的字符串表示該ELF所依賴的動態庫名稱
- DT_STRTAB: d_ptr指定了地址保存了符號、動態庫名稱以及其他用到的字符串
- DT_STRSZ: 字符串表的大小
- DT_SYMTAB: 指定地址保存了符號表
- DT_INIT/DT_FINI: 指定初始化函數和結束函數的地址
- DT_RPATH: 指定動態庫搜索目錄
- DT_SONAME: Shared Object Name,指定當前動態庫的名字(logical name)
- ...
其中有部分的類型可以和Section中的SHT_xxx類型進行類比,完整的列表可以參考ELF標準中的Book III: Operating System Specific一節。
在interpreter根據DT_NEEDED加載完所有需要的動態庫后,就實現了完整進程虛擬內存映像的布局。在尋找某個動態符號時,interpreter會使用廣度優先的方式去進行搜索,即先在當前ELF符號表中找,然后再從當前ELF的DT_NEEDED動態庫中找,再然后從動態庫中的DT_NEEDED里查找。
因為動態庫本身是位置無關的(PIE),支持被加載到內存中的隨機位置,因此為了程序中用到的符號可以被正確引用,需要對其進行重定向操作,指向對應符號的真實地址。這部分我在之前寫的關于GOT,PLT和動態鏈接的文章中已經詳細介紹過了,因此不再贅述,感興趣的朋友可以參考該文章。
實際案例
有人也許會問,我看你bibi了這么多,有什么實際意義嗎?呵呵,本節就來分享幾個我認為比較有用的應用場景。
Interpreter Hack
在滲透測試中,紅隊小伙伴們經常能拿到目標的后臺shell權限,但是遇到一些部署了HIDS的大企業,很可能在執行惡意程序的時候被攔截,或者甚至觸發監測異常直接被藍隊拔網線。這里不考慮具體的HIDS產品,假設現在面對兩種場景:
- 目標環境的可寫磁盤直接mount為noexec,無法執行代碼
- 目標環境內核監控任何非系統路徑的程序的執行都會直接告警
不管什么樣的環境,我相信老紅隊都有辦法去繞過,這里我們運用上面學到的ELF知識,其實有一種更為簡單的解法,即利用interpreter。示例如下:
$ cat hello.c
#include <stdio.h>
int main() {
return puts("hello!");
}
$ gcc hello.c -o hello
$ ./hello
hello!
$ chmod -x hello
$ ./hello
bash: ./hello: Permission denied
$ /lib64/ld-linux-x86-64.so.2 ./hello
hello!
$ strace /lib64/ld-linux-x86-64.so.2 ./hello 2>&1 | grep exec
execve("/lib64/ld-linux-x86-64.so.2", ["/lib64/ld-linux-x86-64.so.2", "./hello"], 0x7fff1206f208 /* 9 vars */) = 0
/lib64/ld-linux-x86-64.so.2本身應該是內核調用執行的,但我們這里可以直接進行調用。這樣一方面可以在沒有執行權限的情況下執行任意代碼,另一方面也可以在一定程度上避免內核對execve的異常監控。
利用(濫用)interpreter我們還可以做其他有趣的事情,比如通過修改指定ELF文件的interpreter為我們自己的可執行文件,可讓內核在處理目標ELF時將控制器交給我們的interpreter,這可以通過直接修改字符串表或者使用一些工具如patchelf來輕松實現。
對于惡意軟件分析的場景,很多安全研究人員看到ELF就喜歡用ldd去看看有什么依賴庫,一般ldd腳本實際上是調用系統默認的ld.so并通過環境變量來打印信息,不過對于某些glibc實現(如glibc2.27之前的ld.so),會調用ELF指定的interpreter運行,從而存在非預期命令執行的風險。
當然還有更多其他的思路可以進行拓展,這就需要大家發揮腦洞了。
加固/脫殼
與逆向分析比較相關的就是符號表,一個有符號的程序在逆向時基本上和讀源碼差不多。因此對于想保護應用程序的開發者而言,最簡單的防護方法就是去除符號表,一個簡單的strip命令就可實現。strip刪除的主要是Section中的信息,因為這不影響程序的執行。去除前后進行diff對比可看到刪除的section主要有下面這些:
$ diff 0 1
1c1
< There are 35 section headers, starting at offset 0x1fdc:
---
> There are 28 section headers, starting at offset 0x1144:
32,39c32
< [27] .debug_aranges PROGBITS 00000000 00104d 000020 00 0 0 1
< [28] .debug_info PROGBITS 00000000 00106d 000350 00 0 0 1
< [29] .debug_abbrev PROGBITS 00000000 0013bd 000100 00 0 0 1
< [30] .debug_line PROGBITS 00000000 0014bd 0000cd 00 0 0 1
< [31] .debug_str PROGBITS 00000000 00158a 000293 01 MS 0 0 1
< [32] .symtab SYMTAB 00000000 001820 000480 10 33 49 4
< [33] .strtab STRTAB 00000000 001ca0 0001f4 00 0 0 1
< [34] .shstrtab STRTAB 00000000 001e94 000145 00 0 0 1
---
> [27] .shstrtab STRTAB 00000000 00104d 0000f5 00 0 0 1
其中.symtab是符號表,.strtab是符號表中用到的字符串。
僅僅去掉符號感覺還不夠,熟悉匯編的人放到反編譯工具中還是可以慢慢還原程序邏輯。通過前面的分析我們知道,ELF執行需要的只是Program Header中的幾個段,Section Header實際上是不需要的,只不過在運行時動態鏈接過程會引用到部分關聯的區域。大部分反編譯工具,如IDA、Ghidra等,處理ELF是需要某些section信息來構建程序視圖的,所以我們可以通過構造一個損壞Section Table或者ELF Header令這些反編譯工具出錯,從而干擾逆向人員。
當然,這個方法并不總是奏效,逆向人員可以通過動態調試把程序dump出來并對運行視圖進行還原。一個典型的例子是Android中的JNI動態庫,有的安全人員對這些so文件進行了加密處理,并且在.init/.initarray這些動態庫初始化函數中進行動態解密。破解這種加固方法的策略就是將其從內存中復制出來并進行重建,重建的過程可根據segment對section進行還原,因為segment和section之間共享了許多內存空間,例如:
$ readelf -l main1
...
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame
03 .init_array .fini_array .dynamic .got .got.plt .data .bss
04 .dynamic
05 .note.ABI-tag .note.gnu.build-id
06 .eh_frame_hdr
07
08 .init_array .fini_array .dynamic .got
在Section to Segment mapping中可以看到這些段的內容是跟對應section的內容重疊的,雖然一個segment可能對應多個section,但是可以根據內存的讀寫屬性、內存特征以及對應段的一般順序進行區分。
如果程序中有比較詳細的日志函數,我們還可以通過反編譯工具的腳本拓展去修改.symtab/.strtab段來批量還原ELF文件的符號,從而高效地輔助動態調試。
Binary Fuzzing
考慮這么一種場景,我們在分析某個IoT設備時發現了一個定制的ELF網絡程序,類似于httpd,其中有個靜態函數負責處理輸入數據。現在想要單獨對這個函數進行fuzz應該怎么做?直接從網絡請求中進行變異是一種方法,但是網絡請求的效率太低,而且觸達該函數的程序邏輯也可能太長。
既然我們已經了解了ELF,那就可以有更好的辦法將該函數抽取出來進行獨立調用。在介紹ELF類型的時候其實有提到,可執行文件可以有兩種類型,即可執行類型(ET_EXEC)和共享對象(ET_DYN),一個動態鏈接的可執行程序默認是共享對象類型的:
$ gcc hello.c -o hello
$ readelf -h hello | grep Type
Type: DYN (Shared object file)
而動態庫(.so)本身也是共享對象類型,他們之間的本質區別在于前者鏈接了libc并且定義了main函數。對于動態庫,我們可以通過dlopen/dlsym獲取對應的符號進行調用,因此對于上面的場景,一個解決方式就是修改目標ELF文件,并且將對應的靜態函數導出添加到dynamic section中,并修復對應的ELF頭。
這個思想其實很早就已經有人實現了,比如lief的bin2lib。通過該方法,我們就能將目標程序任意的函數抽取出來執行,比如hugsy就用這個方式復現了Exim中的溢出漏洞(CVE-2018-6789),詳見Fuzzing arbitrary functions in ELF binaries(中文翻譯)。
總結
本文主要介紹了32位環境下ELF文件的格式和布局,然后從內核空間和用戶空間兩個方向分析了ELF程序的加載過程,最后列舉了幾個依賴于ELF文件特性的案例進行具體分析,包括dynamic linker的濫用、程序加固和反加固以及在二進制fuzzing中的應用。
ELF文件本身并不復雜,只有三個關鍵部分,只不過在section和segment的類型上保留了極大的拓展性。操作系統可以根據自己的需求在不同字段上實現和拓展自己的功能,比如Linux中通過dymamic類型實現動態加載。但這不是必須的,例如在Android中就通過ELF格式封裝了特有的.odex、 .oat文件來保存優化后的dex。另外對于64位環境,大部分字段含義都是類似的,只是字段大小稍有變化(Elf32->Elf64),并不影響文中的結論。
參考鏈接
- Linux Foundation Referenced Specifications
- Executable and Linkable Format (ELF)
- Tool Interface Standard (TIS) Executable and Linking Format (ELF) Specification Version 1.2
- elf(5) - format of Executable and Linking Format (ELF) files
- How programs get run: ELF binaries
- 深入了解GOT,PLT和動態鏈接
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1289/
暫無評論