- 原文標題: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 Interface》1的表 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),這對於 setuid
或 setgid
的程式來說預設是關閉的;此外,當在目前憑證(credential)下程式檔案是不可讀取的時候傾印也是關閉的 5。而對 __set_task_comm()
的呼叫會把當前行程任務(task)的欄位 comm
設定成原始被調用的檔案名稱的基礎檔名(basename),這欄位將被視為是執行緒的名字,在使用者空間可以藉由 prctl()
操作 PR_GET_NAME
與 PR_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_creds
和 bprm_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
會指向這個陣列。輔助向量放在最高的位址,就在它所需要額外參考的值的下面。
把這些湊起來,新程式的位址空間頂端會有像下面例子的內容(這個頁面有類似的例子):
------------------------------------------------------------- 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 撰寫。
譯註:
- 《The Linux Programming Interface》正體中文版由基峯出版社於 2016 年 10 月出版。
↩ - 核心原始碼內有一份詳細的說明文件在
Documentation/trace/uprobetracer.txt
。
↩ - 這裡所檢查的,只是核心傾印一部份可立即確認的條件,只代表了行程的可傾印屬性(dumpable),但是否真的進行傾印的動作,還有其他條件,在 man-page 有對所有條件詳盡的說明。
此外,程式即使已經開始執行,也還可透過prctl()
的PR_SET_DUMPABLE
參數來調整行程的可傾印屬性。
↩ - 實際上,目標行程具備可傾印屬性只是能夠使用
ptrace()
的其中一個條件,而這個條件更仔細的說法是:「如果目標行程的可傾印屬性不是『SUID_DUMP_USER
』,則ptrace()
無法對其存取,除非呼叫者具備CAP_SYS_PTRACE
能力(capability)」,完整說明請參考 man-page。
↩ - 這裡所謂的「預設關閉」,是由核心內部的全域變數
suid_dumpable
來控制,它可以藉由設定/proc
檔案系統的/proc/sys/fs/suid_dumpable
來改變。
↩ - 那麼,為什麼會設計這一個不符合直觀的行為呢?其實這來自於 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
這樣的功能。
↩ - 在 Linux 核心中,這樣的步驟,其實是防禦機制 ASLR(Address Space Layout Randomization)的一部份,至於要關閉它,有兩種常用的方法,一種是調整
/proc
檔案系統的/proc/sys/kernel/randomize_va_space
;另一種是利用行程個性(personality)的ADDR_NO_RANDOMIZE
,關於「行程個性」,可以參考本篇翻譯好的前文中的註釋。
↩ - SVr4 的全稱是 System V release 4;此外,原文在此處使用了「apparently」的字眼,可能原文作者可能也並不確定,在我搜索的過程中,雖然多有人提到 SVr4 的這個現象,但始終找不到有公信力的資料,因此認定作者也是想表達不確定的語氣,所以翻譯時使用了「似乎」這個詞。
↩ - 雖然這邊是說把「塞不進」的提前放到堆疊上,但並不表示如果「塞的進」就可以不放了,實際上,什麼 id 的資料應該要怎麼放,都是規定好的;在制定標準時,凡是「可能」放不下的,就已經規定要另外找地方放置,例如說——堆疊。
↩ - 這個檔案在使用 glibc 的 Linux 系統上,是由 glibc 來維護的。但雖然 man-page 上說這個檔案的檔名大致上都是「
ld-linux.so.*
」這樣的格式,不過在支援 LSB(Linux Standard Base)的系統上,這個檔名的格式則會像「ld-lsb.so.*
」。
如果想了解如何寫一個在 LSB 環境執行的程式,可以參考這篇文章。
↩ - 其實這裡這麼做,原因其實是由於 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」常在正式文書中,做為語氣較不尖銳的「准許與否」來使用。
↩ - 原始程式與直譯器程式載入同一個行程,難道記憶體位置不會衝突嗎?答案是可能的。一個 ELF 直譯器在 Linux 的架構下,可以在編譯時選擇固定位址(fixed address)或是位址無關(position independent),而核心會從二進位檔中辨識這個資訊。
選擇固定位址代表載入位址寫死在檔案裡面,這樣一來就可能跟原始程式發生位址衝突,根據 System V gABI 的規定,衝突的地方會以載入直譯器程式為優先,而直譯器執行時則有責任對衝突情況做解決,所以 Linux 在發生衝突時,會把原始程式的衝突區段取消映射(unmap),來提供給直譯器程式。
而如果選擇位址無關的方式,則核心會自己找適當的空位來載入,無需直譯器程式擔心,所以除非特殊需求,否則做為動態連結器的直譯器程式大多選擇這種方式。
↩
沒有留言 :
張貼留言