作者: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的使命。

view.png

從大局上看,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_linksh_info的含義根據section類型的不同而不同,如下表所示:

type.png

至于不同類型的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_STACKPT_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_LOADPT_INTERPPT_GNU_STACK。以3.18內核為例,load_elf_binary主要有下面操作:

  1. 對ELF文件做一些基本檢查,保證e_phentsize = sizeof(struct elf_phdr)并且e_phnum的個數在一定范圍內;
  2. 循環查看每一項program header,如果有PT_INTERP則使用open_exec加載進來,并替換原程序的bprm->buf;
  3. 根據PT_GNU_STACK段中的flag設置棧是否可執行;
  4. 使用flush_old_exec來更新當前可執行文件的所有引用;
  5. 使用setup_new_exec設置新的可執行文件在內核中的狀態;
  6. setup_arg_pages在棧上設置程序調用參數的內存頁;
  7. 循環每一項PT_LOAD類型的段,elf_map映射到對應內存頁中,初始化BSS;
  8. 如果存在interpreter,將入口(elf_entry)設置為interpreter的函數入口,否則設置為原ELF的入口地址;
  9. install_exec_creds(bprm)設置進程權限等信息;
  10. create_elf_tables添加需要的信息到程序的棧中,比如ELF auxiliary vector
  11. 設置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產品,假設現在面對兩種場景:

  1. 目標環境的可寫磁盤直接mount為noexec,無法執行代碼
  2. 目標環境內核監控任何非系統路徑的程序的執行都會直接告警

不管什么樣的環境,我相信老紅隊都有辦法去繞過,這里我們運用上面學到的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),并不影響文中的結論。

參考鏈接


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1289/