2016年12月18日 星期日

[翻譯] 程式是如何啟動的(下):ELF 二進位檔

  • 原文標題:How programs get run: ELF binaries
  • 原文網址:https://lwn.net/Articles/631631/
  • 原文作者:David Drysdale
  • 原文發表時間:2015 年 02 月 04 日
  • 譯註:
    • 根據原文使用的參考資料連結,原文應該是使用 3.18 版核心做為依據。

↓↓↓↓↓↓ 正文開始 ↓↓↓↓↓↓

本系列的上一篇描述了在使用者空間對 execve() 的呼叫之後,Linux 核心用來執行程式的一般機制。然而,該文中特別描述的格式處置器(format handler)都會推延執行過程而在內部呼叫 search_binary_handler(),這個遞迴幾乎總是終止於對 ELF 二進位檔的調用,也就是本文的目標。

ELF 格式

ELF(Executable and Linkable Format)格式是現代 Linux 系統在使用上的主要二進位檔格式,對該格式的支援實作於檔案 fs/binfmt_elf.c,而它也是一個核心處理起來稍微複雜的格式;光是主要的函式 load_elf_binary() 就超過 400 行,並且用以支援 ELF 的程式碼是支援舊式 a.out 格式程式碼的四倍多。

對一個可執行程式(而非共享函式庫或目的檔)而言,ELF 檔案在檔案起始附近總是有一個程式標頭表(program header table),就在 ELF 標頭(ELF header)之後;這張表中的每個項目都是用來提供運行該程式所需的資訊。

核心真正在乎的只有程式標頭中的三種類別;第一種是 PT_LOAD 節區(segment),描述了新程式的運行記憶體,包含了來自該可執行檔的程式碼與資料區段,以及 BSS 區段的大小,BSS 將以零填滿(因此執行檔中只存有它的長度);第二個感興趣的是一個 PT_INTERP 項目,用以辨識用來組合完整程式的執行期連結器(runtime linker),不過目前我們暫且假定這是一個靜態連結(statically linked)的 ELF 二進位檔,稍後再回過頭來看動態連結(dynamic linking);最後,核心還會從 PT_GNU_STACK 項目取得一個位元的資料,它指明程式的堆疊是否應為可執行區。

(本文僅專注於載入 ELF 程式所需的東西,並非要剖析該格式的所有細節,有興趣的讀者可以從 Wikipedia 的 ELF 文章中所連結的參考資料,或是利用工具 objdump 剖析實際的二進位檔來找到更多資訊。)

處理 ELF 二進位檔

ELF 二進位檔的載入由函式 load_elf_binary() 執掌,該函式由分析 ELF 標頭來開始,以確認該檔案看起來確實像一個有支援的 ELF 格式;處置器接著會需要整個 ELF 程式標頭,無論它是否位於被讀進 linux_binprm 之欄位 buf 的那前 128 個位元組之內,所以需要另外將其讀入某個可塗抹的空間。

現在,程式碼會對程式標頭的項目做迴圈,確認對直譯器的需求(PT_INTERP)與程式堆疊是否應為可執行區(從項目 PT_GNU_STACK)。在這些準備完成後,程式碼需要去初始化新程式的那些不自舊程式繼承的屬性,《Single UNIX Specification》第三版(SUSv3)的 exec 規格描述了大部分需要的行為。(而《The Linux Programming Interface1的表 28-4 對參與的屬性做了精采的總結)

接著,設定新程式的過程由對 flush_old_exec()呼叫做為起點,它會清理核心內涉及先前程式的狀態,舊程式的所有其他執行緒都會被砍掉,所以新程式將以單一執行緒起始,而且用於該行程(process)的訊號處理(signal-handling)資訊是不被共享的,因此該資訊可以在稍後安全的被更改。舊程式所有懸宕(pending)的 POSIX 計時器也都會被清除,而對於該程式的執行檔位置(可見於 /proc/pid/exe)亦會更新。舊程式的虛擬記憶體映射(virtual memory mapping)也接著被釋放,同時也把任何懸宕的非同步 I/O 操作(asynchronous I/O operation)都砍掉,並解除任何的 uprobe 2。最後,行程的個性(personality)會做更新以移除任何可能影響安全性的功能,也就是先前記錄在 linux_binprm 的欄位 per_clear。而處置器的主要程式碼中也會呼叫巨集 SET_PERSONALITY() 來為新的 64 位元程式適當地設立執行緒旗標(thread flag)

接下來,與其相應地,對 setup_new_exec()呼叫會為新程式設定核心的內部狀態,該函式一開始先判定是否可以產生核心傾印(core dump)3(或者說可以讓 ptrace() 附加(attach)上去 4),這對於 setuidsetgid 的程式來說預設是關閉的;此外,當在目前憑證(credential)下程式檔案是不可讀取的時候傾印也是關閉的 5。而對 __set_task_comm() 的呼叫會把當前行程任務(task)的欄位 comm 設定成原始被調用的檔案名稱的基礎檔名(basename),這欄位將被視為是執行緒的名字,在使用者空間可以藉由 prctl() 操作 PR_GET_NAMEPR_SET_NAME 來存取。而對 flush_signal_handlers() 的呼叫為新程式設定訊號處置器(signal handler),任何非 SIG_IGN 的訊號處置器都會被設成預設值 SIG_DFL(所以已忽視(ignored)的訊號會被新程式繼承 6)。最後,呼叫 do_close_on_exec() 關閉所有舊程式所有設立旗標 O_CLOEXEC 的檔案描述器(file descriptor),其他的檔案描述器會被新程式繼承。

下一步,新程式的虛擬記憶體也需要做設定,為了增進安全性(藉由協助對抗堆疊溢位攻擊(stack overflow attack)),堆疊的最高位址通常會往下挪移一個隨機的偏移植(offset)7。而一開始對 setup_arg_pages()呼叫接著會設定核心的記憶體追蹤結構,並針對堆疊的新位置調整。接著程式碼會巡迴程式檔案內所有 PT_LOAD 節區,並將它們映射到行程的位址空間,建立新程式的記憶體佈局。然後,程式碼會替程式的節區 BSS 設立相應的以零填滿的頁面。同樣地,額外的特殊頁面——例如 vDSO(virtual dynamic shared object)頁面——也要做映射,這個會由對 arch_setup_additional_pages()呼叫去做考量。此外,一個空白頁面或許也會被映射到程式位址空間的位址 0,這是為了向下相容的因素。(舊 SVr4 程式似乎假設在讀取 NULL 指標的時候會回傳 0,而非 SIGSEGV 8。)

接下來,新程式的憑證(credential)會藉著呼叫 install_exec_creds() 來設定,這個函式會讓任何運作中的 Linux Security Module(LSM)知曉憑證方面的改變(透過 LSM 鉤子(hook)中的 bprm_committing_credsbprm_committed_creds),而其內部的函式 commit_creds() 會執行賦值的動作。

為了運行新程式的最後準備,是通過呼叫函式 create_elf_tables() 來設定它堆疊(在隨機化後的新位置)的其他部分;這部分會等到下面在單獨一節做敘述。

現在,所有的準備都已完成,新程式可以啟動了。早前有篇文章解釋了核心的入口點(entry point)system_call 是如何在進入主要的核心程式碼之前,將使用者空間的 CPU 暫存器推入核心的堆疊中,以及當系統呼叫完成時,這些暫存器又如何做相應的回存。堆疊上保有受儲存暫存器的區域將被轉型(cast)成一個 pt_regs 結構,因此存起來的使用者空間的 CPU 暫存器才能為了新程式的起始,覆寫成適宜的值(零)。而對 start_thread()呼叫也將把存起來的指令指標(instruction pointer)設置成該程式(或動態連結器)的入口點,並將存起來的堆疊指標(stack pointer)設置成當前的堆疊頂端(利用 linux_binprm 的欄位 p)。處置器回傳的 0 代表了成功,而系統呼叫 execve() 也將返回使用者空間——不過,是回到一個大相逕庭的使用者空間,行程記憶體已經重新映射,而被回復的暫存器的值則是為了新程式的起始設定好的。

操弄堆疊:輔助向量(auxiliary vector)、環境變數與引數(argument)

在由通用程式碼加進去的引數與環境資訊之下,函式 create_elf_tables() 會新增更多的資訊到新程式的堆疊,形成兩個相異的區塊。一開始呼叫 arch_align_stack() 現有的堆疊位置向下捨入(round down)16 位元組的邊界,也許還會稍微往下進一步隨機化堆疊位置。

這些資訊中的第一個集合形成了 ELF 輔助向量,由一對對的 (id, 值) 所構成,描述關於被執行程式以及執行環境的有用資訊,自核心傳達給使用者空間。要建立這個向量,處置器的程式碼首先需要把任何塞不進 64 位元值的資訊壓到堆疊上 9;對於 x86_64,這包含了平台能力描述(platform capability description)(也就是字串「x86_64」)和 16 位元組的隨機資料(用以協助起始(seed)使用者空間的亂數產生器)。

接著,程式碼組合那些 (id, 值) 的配對,成為在 mm_struct 內的 saved_auxv 空間裡的輔助向量。因為一篇 Michael Kerrisk 撰寫的 LWN 文章已經描述了這個向量的內容,所以在這兒我們只提及一些些有意思的項目:

  • 在該向量中的(平台特定的)第一項是用於 x86_64 的 AT_SYSINFO_EHDR 的值;它指明了 vDSO 頁面的位置,如同在先前文章中所提及的。

  • AT_PLATFORM 的值是之前壓進堆疊的平台能力描述「x86_64」的位址。

  • AT_RANDOM 的值是之前壓進堆疊的隨機資料的位址。

  • AT_EXECFN 的值存著程式檔案名稱的位址,該名稱被壓在堆疊的最一開始(而其位址儲存在 linux_binprm 的欄位 exec),在引數與環境變數上面。

  • AT_ENTRY 的值存著程式碼節區(text segment)的入口點,換句話說,也就是程式開始執行的地方。

當輔助向量製作完成後,程式碼就開始組織新程式堆疊的剩餘部分,所需空間會先算好,然後這些項目就會從低位址往高位址做插入:

  • 最先被插入的是引數計數 argc

  • 接著被插入的是引數指標的陣列,以一個 NULL 指標做結尾,最終 main()argv 會指向這個陣列。

  • 再接著被插入的是環境變數指標的陣列,以一個 NULL 指標做結尾,最終 environ 會指向這個陣列。

  • 輔助向量放在最高的位址,就在它所需要額外參考的值的下面。

把這些湊起來,新程式的位址空間頂端會有像下面例子的內容(這個頁面有類似的例子):


    ------------------------------------------------------------- 0x7fff6c845000
     0x7fff6c844ff8: 0x0000000000000000
            _  4fec: './stackdump\0'                      <------+
      env  /   4fe2: 'ENVVAR2=2\0'                               |    <----+
           \_  4fd8: 'ENVVAR1=1\0'                               |   <---+ |
           /   4fd4: 'two\0'                                     |       | |     <----+
     args |    4fd0: 'one\0'                                     |       | |    <---+ |
           \_  4fcb: 'zero\0'                                    |       | |   <--+ | |
               3020: random gap padded to 16B boundary           |       | |      | | |
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -|       | |      | | |
               3019: 'x86_64\0'                        <-+       |       | |      | | |
     auxv      3009: random data: ed99b6...2adcc7        | <-+   |       | |      | | |
     data      3000: zero padding to align stack         |   |   |       | |      | | |
    . . . . . . . . . . . . . . . . . . . . . . . . . . .|. .|. .|       | |      | | |
               2ff0: AT_NULL(0)=0                        |   |   |       | |      | | |
               2fe0: AT_PLATFORM(15)=0x7fff6c843019    --+   |   |       | |      | | |
               2fd0: AT_EXECFN(31)=0x7fff6c844fec      ------|---+       | |      | | |
               2fc0: AT_RANDOM(25)=0x7fff6c843009      ------+           | |      | | |
      ELF      2fb0: AT_SECURE(23)=0                                     | |      | | |
    auxiliary  2fa0: AT_EGID(14)=1000                                    | |      | | |
     vector:   2f90: AT_GID(13)=1000                                     | |      | | |
    (id,val)   2f80: AT_EUID(12)=1000                                    | |      | | |
      pairs    2f70: AT_UID(11)=1000                                     | |      | | |
               2f60: AT_ENTRY(9)=0x4010c0                                | |      | | |
               2f50: AT_FLAGS(8)=0                                       | |      | | |
               2f40: AT_BASE(7)=0x7ff6c1122000                           | |      | | |
               2f30: AT_PHNUM(5)=9                                       | |      | | |
               2f20: AT_PHENT(4)=56                                      | |      | | |
               2f10: AT_PHDR(3)=0x400040                                 | |      | | |
               2f00: AT_CLKTCK(17)=100                                   | |      | | |
               2ef0: AT_PAGESZ(6)=4096                                   | |      | | |
               2ee0: AT_HWCAP(16)=0xbfebfbff                             | |      | | |
               2ed0: AT_SYSINFO_EHDR(33)=0x7fff6c86b000                  | |      | | |
    . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .        | |      | | |
               2ec8: environ[2]=(nil)                                    | |      | | |
               2ec0: environ[1]=0x7fff6c844fe2         ------------------|-+      | | |
               2eb8: environ[0]=0x7fff6c844fd8         ------------------+        | | |
               2eb0: argv[3]=(nil)                                                | | |
               2ea8: argv[2]=0x7fff6c844fd4            ---------------------------|-|-+
               2ea0: argv[1]=0x7fff6c844fd0            ---------------------------|-+
               2e98: argv[0]=0x7fff6c844fcb            ---------------------------+
     0x7fff6c842e90: argc=3

要提醒的是,儘管在堆疊的佈局中有兩個隨機化的地方(一個是記憶體頂端的位置,另一個是引數實際的值和輔助向量之間間隔的大小),新執行的程式仍可以知道所有在堆疊上的資訊都分佈在哪;暫存器 SP 告訴程式堆疊頂端(也就是最低的位址)在哪,命令列引數的陣列就從那裡往上放,並以一個 NULL 指標做結尾;接著會找到環境變數的陣列,並同樣以 NULL 指標做結尾;再接著的位址是輔助引數,以一個 AT_NULL ID 做結。而找到的這些資訊將提供引數字串、環境變數字串和輔助引數資料的位址,因此不需要有關於隨機間隔大小的資訊。

動態連結的程式

目前為止,我們都預設被執行的程式是靜態連結的,並且略過了在 ELF 程式標頭存在 PT_INTERP 項目時會觸發的步驟。然而,大部分程式是動態連結的,意指所需的共享函式庫必須在執行期被定位並連結,這件事將交給執行期連結器來做(通常是某個像 /lib64/ld-linux-x86-64.so.2 的東西 10),而連結器本身則由程式標頭的 PT_INTERP 項目來指定。

為了處理執行期連結器,ELF 處置器會先讀取 ELF 直譯器(ELF interpreter)的檔名到暫存空間,然後以 open_exec()打開執行檔,接著檔案的前 128 位元組會被讀進暫存空間 bprm->buf,取代原始程式檔案的內容,因而允許存取該直譯器程式的 ELF 標頭——該直譯器因此必須是一個 ELF 二進位檔,而不能是任何其他格式 11

在程式如同之前所述載入記憶體之後,ELF 處置器也會用 load_elf_interp()載入 ELF 直譯器程式到記憶體 12,這個程序與載入原先程式的程序相似:程式碼確認在 ELF 標頭的格式資訊把 ELF 程式標頭讀進來,把所有的 PT_LOAD 節區從檔案映射到新程式的記憶體,並且為直譯器的節區 BSS 留出空間

而程式執行的起始位址也會設成直譯器的入口點,而不再是程式本身的入口點,之後當系統呼叫 execve() 完成後,執行流程將自 ELF 直譯器開始,它會從使用者空間處理對鏈結(linkage)需求的滿足——搜尋並載入程式依賴的共享函式庫,以及解析程式的未定義符號(undefined symbol)對應到函式庫裡的正確定義。一旦鏈結的程序完結(需要比核心對 ELF 格式更深入的了解),直譯器就會在先前紀錄於輔助向量的 AT_ENTRY 值的位址,開始新程式本身的執行。

與其他架構的相容性

先前所描述的,現代的 64 位元(x86_64)Linux 系統也同時支援了兩種類型的 32 位元二進位檔:一般的 32 位元二進位檔(x86_32),以及 x32 ABI 的程式(可以使用額外的 x86_64 暫存器)。那核心是如何支援它們的呢?

提供這些格式支援的關鍵檔案是 compat_binfmt_elf.c,它只在設定項(config)CONFIG_COMPAT_BINFMT_ELF 有被選用時,才會引入核心。而這個檔案沒出現在先前介紹註冊二進位處置器的地點時的列表中,原因是這個檔案幾乎沒有自己的程式碼;相反地,它引入了 ELF 處置器 binfmt_elf.c 的主要程式碼(使用 #include),並利用前處理器(preprocessor)來重導各個內部函式與值成為相容 32 位元的版本,除了這些改變,這個格式處置器和上面描述的一般 ELF 處置器有相同的行為。

有一組的變更是在用來描述 ELF 檔案整體布局的結構(structure)上,使用 32 位元的版本;相同地,也使用用於 32 位元二進位檔的適當常數值,這能保證相容性處置器只會支援相關的 ELF 二進檔類別。特別的是,對 elf_check_arch() 的呼叫將被 compat_elf_check_arch() 版本所取代,用以同時做符合 x86_32 抑或 x32 的確認。

前處理器的改變也重導了 ELF 處置器程式碼的某些內部功能性,對巨集 SET_PERSONALITY()調用重導set_personality_ia32(),所以用於 32 位元架構的的執行緒旗標會被設定;同樣地,函式 arch_setup_additional_pages()替換成會設定 32 位元 vDSO 的版本。影響更大的是,函式 start_thread()compat_start_thread()取代,最後對應start_thread_ia32(),它會修改內部給函式 start_thread_common() 的引數,因此儲存起來的節區暫存器(segment register)會與用於 64 位元二進位檔時,有不同的初始值(而巨集 ELF_PLAT_INIT() 也會加以調整來做配合)。

結語

在 Linux 系統上,每個程式的執行都會經過 execve() 這個入口,做為核心的功能中關鍵的一塊,去理解它的細節是值得的。儘管核心原生也支持了命令稿(script)與其他機器碼格式的程式,在現代 Linux 系統上,程式的執行最終會牽涉到運行 ELF 二進位檔,ELF 是個複雜的格式,幸運的是,核心可以忽視其中的大部分——它只需要對 ELF 了解到足以將節區載入到記憶體,以及會調用使用者空間的執行期連結程式,用來達成組合一個完整運行程式的工作就行了。

↑↑↑↑↑↑ 正文結束 ↑↑↑↑↑↑

本文以 StackEdit 撰寫。

譯註:


  1. 《The Linux Programming Interface》正體中文版由基峯出版社於 2016 年 10 月出版
  2. 核心原始碼內有一份詳細的說明文件在 Documentation/trace/uprobetracer.txt
  3. 這裡所檢查的,只是核心傾印一部份可立即確認的條件,只代表了行程的可傾印屬性(dumpable),但是否真的進行傾印的動作,還有其他條件,在 man-page 有對所有條件詳盡的說明

    此外,程式即使已經開始執行,也還可透過 prctl()PR_SET_DUMPABLE 參數來調整行程的可傾印屬性。
  4. 實際上,目標行程具備可傾印屬性只是能夠使用 ptrace() 的其中一個條件,而這個條件更仔細的說法是:「如果目標行程的可傾印屬性不是『SUID_DUMP_USER』,則 ptrace() 無法對其存取,除非呼叫者具備 CAP_SYS_PTRACE 能力(capability)」,完整說明請參考 man-page
  5. 這裡所謂的「預設關閉」,是由核心內部的全域變數 suid_dumpable 來控制,它可以藉由設定 /proc 檔案系統/proc/sys/fs/suid_dumpable 來改變。
  6. 那麼,為什麼會設計這一個不符合直觀的行為呢?其實這來自於 POSIX 對訊號的行為所做的規定,在 OpenGroup 所收錄的關於 execve 行為規格的資料中,可以發現以下說明:
    …This volume of POSIX.1-2008 specifies that signals set to SIG_IGN remain set to SIG_IGN, and that the new process image inherits the signal mask … This is consistent with historical implementations, and it permits some useful functionality, such as the nohup command…
    大概說來就是歷史殘留的結果,而且因此也才能實作像是指令 nohup 這樣的功能。
  7. 在 Linux 核心中,這樣的步驟,其實是防禦機制 ASLR(Address Space Layout Randomization)的一部份,至於要關閉它,有兩種常用的方法,一種是調整 /proc 檔案系統的 /proc/sys/kernel/randomize_va_space;另一種是利用行程個性(personality)的 ADDR_NO_RANDOMIZE,關於「行程個性」,可以參考本篇翻譯好的前文中的註釋
  8. SVr4 的全稱是 System V release 4;此外,原文在此處使用了「apparently」的字眼,可能原文作者可能也並不確定,在我搜索的過程中,雖然多有人提到 SVr4 的這個現象,但始終找不到有公信力的資料,因此認定作者也是想表達不確定的語氣,所以翻譯時使用了「似乎」這個詞。
  9. 雖然這邊是說把「塞不進」的提前放到堆疊上,但並不表示如果「塞的進」就可以放了,實際上,什麼 id 的資料應該要怎麼放,都是規定好的;在制定標準時,凡是「可能」放不下的,就已經規定要另外找地方放置,例如說——堆疊。
  10. 這個檔案在使用 glibc 的 Linux 系統上,是由 glibc 來維護的。但雖然 man-page 上說這個檔案的檔名大致上都是「ld-linux.so.*」這樣的格式,不過在支援 LSB(Linux Standard Base)的系統上,這個檔名的格式則會像「ld-lsb.so.*」。

    如果想了解如何寫一個在 LSB 環境執行的程式,可以參考這篇文章
  11. 其實這裡這麼做,原因其實是由於 ELF 規格的定義,我在一份 System V generic ABI(gABI)的規格書草稿關於 ELF 的部分中,發現了以下說明
    …The interpreter itself may not require a second interpreter. An interpreter may be either a shared object or an executable file…
    這裡的意思是說,直譯器本身不能做出對另一個直譯器的需求(所以直譯器檔案的程式標頭不會出現 PT_INTERP 項目),而且直譯器本身要嘛是共享物件,要嘛是執行檔(反正都是 ELF 格式)。

    另註:關於引文中「may」的意思,此處不該解釋成「也許」之類不確定的意義,而應該作「允許」來解釋,在 Oxford Learner’s Dictionaries 中對「may」與「can」的辨析中有這麼一段說明
    May (negative may not) is used as a polite and fairly formal way to ask for or give permission…It is often used in official signs and rules…
    意思是說,「may」常在正式文書中,做為語氣較不尖銳的「准許與否」來使用。
  12. 原始程式與直譯器程式載入同一個行程,難道記憶體位置不會衝突嗎?答案是可能的。一個 ELF 直譯器在 Linux 的架構下,可以在編譯時選擇固定位址(fixed address)或是位址無關(position independent),而核心會從二進位檔中辨識這個資訊。

    選擇固定位址代表載入位址寫死在檔案裡面,這樣一來就可能跟原始程式發生位址衝突,根據 System V gABI 的規定,衝突的地方會以載入直譯器程式為優先,而直譯器執行時則有責任對衝突情況做解決,所以 Linux 在發生衝突時,會把原始程式的衝突區段取消映射(unmap),來提供給直譯器程式。

    而如果選擇位址無關的方式,則核心會自己找適當的空位來載入,無需直譯器程式擔心,所以除非特殊需求,否則做為動態連結器的直譯器程式大多選擇這種方式。

沒有留言 :

張貼留言