1. 程式人生 > >Linux核心工程導論——程序:ELF檔案執行原理(2)

Linux核心工程導論——程序:ELF檔案執行原理(2)

ELF

強符號與弱符號(本小節是轉別人的)

我們經常在程式設計中碰到一種情況叫符號重複定義。多個目標檔案中含有相同名字全域性符號的定義,那麼這些目標檔案連結的時候將會出現符號重複定義的錯誤。比如我們在目標檔案A和目標檔案B都定義了一個全域性整形變數global,並將它們都初始化,那麼連結器將AB進行連結時會報錯:

1 b.o:(.data+0x0): multiple definition of `global'2 a.o:(.data+0x0): first defined here

        這種符號的定義可以被稱為強符號(Strong Symbol。有些符號的定義可以被稱為弱符號(Weak Symbol

對於C/C++語言來說,編譯器預設函式和初始化了的全域性變數為強符號,未初始化的全域性變數為弱符號。我們也可以通過GCC"__attribute__((weak))"來定義任何一個強符號為弱符號。注意,強符號和弱符號都是針對定義來說的,不是針對符號的引用。比如我們有下面這段程式:

extern int ext;
int weak;
int strong = 1;
__attribute__((weak)) weak2 = 2;
int main()
{
        return 0;
}

上面這段程式中,"weak""weak2"是弱符號,"strong""main"是強符號,而"ext"既非強符號也非弱符號,因為它是一個外部變數的引用。

針對強弱符號的概念,連結器就會按如下規則處理與選擇被多次定義的全域性符號:

· 規則1:不允許強符號被多次定義(即不同的目標檔案中不能有同名的強符號);如果有多個強符號定義,則連結器報符號重複定義錯誤。

· 規則2:如果一個符號在某個目標檔案中是強符號,在其他檔案中都是弱符號,那麼選擇強符號。

· 規則3:如果一個符號在所有目標檔案中都是弱符號,那麼選擇其中佔用空間最大的一個。比如目標檔案A定義全域性變數globalint型,佔4個位元組;目標檔案B定義globaldouble型,佔8個位元組,那麼目標檔案AB連結後,符號global8個位元組(儘量不要使用多個不同型別的弱符號,否則容易導致很難發現的程式錯誤)。

弱引用和強引用

目前我們所看到的對外部目標檔案的符號引用在目標檔案被最終連結成可執行檔案時,它們須要被正確決議,如果沒有找到該符號的定義,連結器就會報符號未定義錯誤,這種被稱為強引用(Strong Reference。與之相對應還有一種弱引用(Weak Reference,在處理弱引用時,如果該符號有定義,則連結器將該符號的引用決議;如果該符號未被定義,則連結器對於該引用不報錯。連結器處理強引用和弱引用的過程幾乎一樣,只是對於未定義的弱引用,連結器不認為它是一個錯誤。一般對於未定義的弱引用,連結器預設其為0,或者是一個特殊的值,以便於程式程式碼能夠識別。

GCC中,我們可以通過使用"__attribute__((weakref))"這個擴充套件關鍵字來宣告對一個外部函式的引用為弱引用,比如下面這段程式碼:

1 __attribute__ ((weakref)) void foo();
2 int main()
3 {
4         foo();
5 }
6 

我們可以將它編譯成一個可執行檔案,GCC並不會報連結錯誤。但是當我們執行這個可執行檔案時,會發生執行錯誤。因為當main函式試圖呼叫foo函式時,foo函式的地址為0,於是發生了非法地址訪問的錯誤。一個改進的例子是:

1 __attribute__ ((weakref)) void foo();
2 int main()
3 {
4         if (foo)
5                foo();
6 }
7 

      這種弱符號和弱引用對於庫來說十分有用,比如庫中定義的弱符號可以被使用者定義的強符號所覆蓋,從而使得程式可以使用自定義版本的庫函式;或者程式可以對某些擴充套件功能模組的引用定義為弱引用,當我們將擴充套件模組與程式連結在一起時,功能模組就可以正常使用;如果我們去掉了某些功能模組,那麼程式也可以正常連結,只是缺少了相應的功能,這使得程式的功能更加容易裁剪和組合。

      Linux程式的設計中,如果一個程式被設計成可以支援單執行緒或多執行緒的模式,就可以通過弱引用的方法來判斷當前的程式是連結到了單執行緒的Glibc庫還是多執行緒的Glibc庫(是否在編譯時有-lpthread選項),從而執行單執行緒版本的程式或多執行緒版本的程式。我們可以在程式中定義一個pthread_create函式的弱引用,然後程式在執行時動態判斷是否連結到pthread庫從而決定執行多執行緒版本還是單執行緒版本

Audit介面

編譯的時候可以給ld傳遞--audit AUDITLIB引數,如此就會建立一個DT_AUDIT sectionld.so看到這個section就會去執行glibc規定的audit介面。在特定的事件發生的時候,會去編譯指定的庫中找到指定的函式來執行。例如當程式呼叫dlopen打開了一個動態庫的時候,就會發生一個事件,從而呼叫指定庫中的 la_objopen()函式。

這個介面是用來做連線維度的統計的,更多被聯結器的開發者使用。但是開發者可以利用單獨分發的這個庫做函式實現版本的管理和替換。例如載入一個庫的時候首先呼叫了audit介面,在audit介面中,我們將要開啟的庫替換掉。

動態庫

動態庫的核心包含了兩個層次的程式碼共享:編譯成的二進位制可以不用每個二進位制檔案都包含一份動態庫的拷貝;在執行的時候,動態庫的程式碼段只需要載入一次,後面再有人用到同一個動態庫,核心就可以把動態庫程式碼段載入到的頁面直接對映到其他需要的程序,這樣程式碼段也不用載入多次(程式碼段是隻讀的)。

而載入到記憶體,由於CPU執行的時候必須要使用相對或者絕對地址,程式碼段的每個函式雖然在實體地址是一樣的,但是他們對映到每個程序記憶體空間的地址都是不一樣的。所以動態庫需要有一份符號表,記錄了其在程式碼段的偏移,然後還需要一個全域性偏移,意味著其整個符號表在程序地址空間中的偏移。

而一個使用了動態庫的可執行檔案,其內部有呼叫這些符號的程式碼,這種程式碼是無法在連結期解析出具體的地址偏移的(因為連結器根本沒有實際的連結他們),所以他們在二進位制檔案中只是一個佔位符,其地址需要在動態庫載入了之後再填充。而這種呼叫是分散在整個程式中的,所以在載入後就得去搜索找到所有的未解析符號去解析,這樣肯定是不合適的,所以就需要有一個表,記錄了所有這些沒有解析的符號。

動態庫在記憶體中只要執行到任何一行動態庫的程式碼,動態庫就可以通過偏移找到本庫內的其他符號,因為同一個庫的符號偏移,本庫內都是知道的。但是可惜的是i386不支援通過當前執行指令(PC)偏移的定址方式(如果支援就簡單了,根本什麼重分配都不需要,只需要在執行到的程式碼中使用偏移就好了)。但是x64是支援的,所以elfx64時代慢慢的可能要有點變化。

上面分別說了可執行檔案和動態庫的需求,兩者的銜接就是通過.got段和.plt.got是資料外部解析,.plt是函式外部解析。Elf檔案連結完成,將呼叫動態庫的符號放到這兩個表裡,當動態庫載入的時候,載入器要負責查詢這個表,將載入的動態庫的對應的符號所在記憶體的地址填充到可執行檔案的這兩個表,如此完成載入時候的符號繫結。同時解決了動態庫位置不固定的問題。

函式呼叫棧

X86x64提供了棧的暫存器指標,但是並不規定怎麼使用這個棧,例如引數入棧的先後順序,返回值放在哪裡,兩個呼叫之間是否要空點空間。

x86時代,常用的呼叫棧有:stdcall, thiscall, fastcall, cdecl,這幾種在對棧的使用上有區別。在x64時代,只剩下fastcall一種。例如stdcall的呼叫約定意味著:1)引數從右向左壓入堆疊,2)函式自身修改堆疊 3)函式名自動加前導的下劃線,後面緊跟一個@符號,其後緊跟著引數的尺寸。Stdcall因為早期用在pascal有此殊榮。c的預設是cdelcdecl呼叫約定的引數壓棧順序是和stdcall是一樣的,引數首先由右向左壓入堆疊。所不同的是,函式本身不清理堆疊,呼叫者負責清理堆疊。由於這種變化,C呼叫約定允許函式的引數的個數是不固定的,這也是C語言的一大特色。thiscall是為了解決面向物件的函式呼叫要預設傳輸this指標,所以是C++的預設呼叫方式,引數從右向左入棧。

fastcall使用暫存器來傳遞引數,因為在x64環境,暫存器很多,所以規定了fastcall的前4個整數和浮點都放入暫存器中,超過的部分才放入棧中。所以,使用fastcall可以顯著的加快呼叫速度。也是因此,在寫程式碼的時候,儘量使用4個以下的函式引數。fastcall也保留了cdel的靈活,由呼叫者清理棧,所以也可以做到引數不固定。但是你看你的棧可能會發現有一塊額外的空間,x64會預設的在站上分配一個備份空間,用來core dump分析的時候方便。這個空間儲存了每次發生函式呼叫的暫存器情況。如果你開了編譯器優化,這個空間一般就不會保留了。



動態庫裝載

當一個參與動態連結,其內部含有PT_DYNAMIC段,這個段裡含有.dynamic這個section.rel.plt section是用於函式重定位,.rel.dyn section是用於變數重定位。.got section儲存全域性變數偏移表,.got.plt section儲存著全域性函式偏離表。.dynsym節區包含了動態連結符號表。.plt節是過程連結表。過程連結表把位置獨立的函式呼叫重定向到絕對位置。

程式在執行的過程中,可能引入的有些C庫函式到結束時都不會執行。所以ELF採用延遲繫結的技術,在第一次呼叫C庫函式是時才會去尋找真正的位置進行繫結。但是也有設定為載入的時候就全部完成繫結(RELRO攻擊對抗技術就是這樣)。


一個應用由一個主要ELF二進位制檔案(可執行檔案)和數個動態庫構成,它們都是ELF格式。每個ELF物件由多個segments組成,每個segment則含有一個或多個sections。這些段看起來很多,但是大都非常簡單。每一個段基本只儲存一種型別的資料,例如.dynstr裡面就是有字串。每一個段我們都要將其理解為一個表,而不是一個結構體陣列。這個表裡面放的都是相同結構的資料,並且基本上只有一兩種資料。結構體的概念更多是橫向的,一個結構體可能包含多個表。

例如.rel.plt中有待解析外部符號的樁函式每個elf要訪問外部符號的時候,首先進入到.rel.plt中對應的樁函式,這個樁函式會進入對應的.got.plt中的條目載入對應的外部符號,並且把符號地址存放在.got.plt中,這樣以後再訪問的時候.rel-plt中的樁函式就可以直接從.got.plt中拿。這就是惰性載入的原理。

而每個.rel.plt中條目都指向一個.dynsym條目,每個.dynsym條目都指向一個.dynstr條目。.dynstr裡面只有字串,而.dynsym中儲存的資料有這個符號的虛地址(在沒有執行的時候,虛地址自然為0)和符號的型別和繫結型別,如下圖:

 

Elf檔案中有兩種section:可分配和不可分配的,可分配就是在執行時會被載入到記憶體的,不可分配的就是給偵錯程式用的,在執行的時候沒用。Strip程式可以把這些執行期無用的東西刪除,使得檔案更小。有兩種符號表:.symtab and .dynsym.dynsym.symtab的子集,.dynsyn是執行期需要的,而.symtab除錯需要的。Strip可以把.symtab去掉。



elf安全性

當有了root許可權之後,核心了無祕密。大部分人即使獲得了root許可權,能看到的東西也不多,其實linux已經提供了,只是大家沒有找到檢視的方法。

例如我們可以檢視任何實體記憶體的內容,方法是通過開啟/dev/mem裝置,然後mmap到你的程式,直接讀取就好了。我們也可以檢視任何的核心資料(不只是procsys檔案系統暴漏出來的資訊),方法是開啟/proc/kmem裝置,然後直接讀取

我們每個程序可以檢視自己的所有可讀記憶體,方法是使用/proc/<pid>/mem,你可能cat這個檔案永遠是錯誤,因為不是所有的記憶體都被程序映射了,尤其是檔案開始的位置,所以需要根據/proc/<pid>/mmaps檔案找到具體的檔案對映的模式,然後seek到對應的偏移才能讀。這種需求基本沒有,因為既然是我們自己的程序,我們在程式內部自然也就可以完全讀取了。

由於/proc/<pid>/mem的許可權是隻有自己可讀,所以其他程序如果想要讀取的話就必須要ptrace到這個程序。但是root是可以讀到的,但是程式仍然必須要暫停才能讀。直接讀記憶體不太好(競態),使用gcore命令可以穩定的將整個記憶體匯出到檔案。

核心後面增加的CONFIG_STRICT_DEVMEMCONFIG_IO_STRICT_DEVMEM等特性逐漸的對/dev/mem檔案的訪問記憶體能力進行限制,所以新版的核心已經不是那麼容易的訪問記憶體了。

可執行棧

最早的elf攻擊就是shellcode放到棧上,也正式因為如此,這種方法最先被防禦。現在一般的棧都沒有可執行許可權,但是控制這個可執行許可權的是elf檔案本身的section,所以如果有elf檔案的修改許可權這也就不是問題。用gcc編譯可以用-z execstack開啟棧的執行許可權,或者使用execstack的shell命令。

Gccc支援一個擴充套件,就是函式的巢狀定義。而這個定義是通過將巢狀的函式程式碼放在棧中執行的,這就要求這種棧是有可執行許可權的。而如果程式碼中沒有使用這個功能,棧的可執行許可權就會開啟,所以如果程式碼的安全性要求比較高,就不要使用巢狀函式。預設情況下,編譯器都是在棧對應的section,例如GNU_STACK上關閉可執行許可權的。如果對於這個檔案有修改的許可權就能打破這個封鎖。

還有一個是編譯器新增的保護,在棧之間加上空隙,這個空隙是沒有對映記憶體的,如果這個空隙被訪問了,那麼就是segment fault。如此很多注入的shellcode就會導致程式崩潰,從而注入失敗。或者是這個選項會加入gcc指定的字元,如果你修改了它,他執行的時候的檢查函式(__stack_chk_failed)就會呼叫失敗,導致程式退出。後面一種叫做stack canary。

Return to libc

return-to-libc 攻擊是一種電腦安全攻擊。這種攻擊方式一般應用於緩衝區溢位中,其堆疊中的返回地址被替換為另一條指令的地址,並且堆疊的一部分被覆蓋以提供其引數。這允許攻擊者呼叫現有函式而無需注入惡意程式碼到程式中。 名叫libc的共享庫提供了類UNIX作業系統中的C執行時支援。儘管攻擊者可以讓程式碼返回到任意位置,但絕大多數情況下的目標都是libc。這是因為libc總是會被連結到程式中,並且它提供了對攻擊者而言一些相當有用的函式(如system()呼叫可以只附加一個引數即執行外部程式)。這即是儘管返回地址可以指向另一個完全不同的區域,但這種攻擊仍被稱為return-to-libc的原因。

這種攻擊必須要知道system()系統呼叫的具體地址,而現在的libc一般是作為動態庫載入到記憶體中,地址是隨機的。所以一般需要首先探測這個地址。探測的方法是使用ld.so這個動態狀態器。因為程式要執行必須要裝載並解析動態庫,而這個工作就是由ld.so完成的。所以這是一個現成的入口。

通常這種攻擊方法實在棧上執行程式碼不可用時候才會使用的,現在一般的機器都在棧對應的section上設定了NX bit,這個位可以防止棧資料被執行,可以通過elf檔案的GNU_STACK這個section的預設RW屬性改為RWE屬性來使得棧上可以執行程式碼,如果棧上可以執行程式碼,return to libc就顯得多此一舉了。


不固定位置編譯

ASLR可以把動態庫載入到隨機的記憶體地址,這樣就可以增加攻擊者的除錯難度。但是可執行檔案自己在大部分情況卻是有固定的開始執行地址的,這就給攻擊者提供了方便。但是仍然有辦法讓這個地址隨機,就是PIEPosition Independent Executable),這個可以把二進位制編譯成位置無關的檔案,而是由核心來完成這個位置無關的隨機化過程。所以這個特性需要核心支援。而還有一個需求是要求位置無關的,就是動態庫和.o這種編譯生成的中間程式碼,這幾種所用的技術和思想都是類似的。

如果我們使用-fpic引數,就可以生成位置無關的動態庫,而如果我們使用-fpie引數,就可以生成位置無關的可執行檔案。這兩者在使用上的差別很大,一個是用來給別人載入的,一個是用來直接執行的,但是這兩者是技術上差別很小,有兩個主要的差別:-fpic生成的檔案加上一個PT_INTERP段和一些啟動程式碼,就比較像-fpie生成位置無關程序。而兩者甚至可以用同樣的啟動程式碼。-fpic由於用來生成動態連結庫,所以符號不能直接解析到找到的符號,甚至可以允許找不到符號,動態庫本身允許引用外部的庫,所以在編譯自己的時候不需要連結外部的庫,只需要把它使用到的外部的庫函式放入PLT表,連結的時候或者載入的時候解析就好,而可執行程式要求所有的符號立即解析,並且不允許有解析不了的符號。

上面的程式是非pie的,可以看到所有的地址都是絕對地址,第一個LOAD指明在二進位制檔案的開頭到0x16f88位元組要載入到記憶體的0x08048000地址,而二進位制檔案偏移的0x016f88往後的0x01543位元組要載入到記憶體的0x0805ff88位置。所以,這是一個位置固定的可執行程式。

ASLR的主要目的是為了防止shellcode,他能夠讓編碼在特定位置的shellcode無法被找到執行。要注意的是,ASLR是一個核心端技術,也就是堆疊等的記憶體亂序是由核心完成的。linux核心預設都是開啟的這個技術,echo 0 > /proc/sys/kernel/randomize_va_space 就可以關掉,同樣也是如此開啟的。但是有很輕鬆的辦法可以不需要提權就能繞過ASLR,setarch `uname -m` -R /bin/bash 這個命令將設定bash啟動的時候不使用ASLR。

這個技術還有一個問題就是一個程式啟動時候可能棧地址是隨機的,但是當這個程式由其他的程式啟動的時候,這個棧的地址就有了規律,例如使用execl介面呼叫啟動這個程式。

RELRO

我們可以發現elf入侵的主要思路是在本來不是程式碼的地方注入程式碼。RELRO這個segment的出現就是為了讓一部分的區域變成只讀的。例如.ctors ,.dtors,.jcr等section都是經常會被放到這個段裡面。不同於棧的不可執行屬性是在核心端保證實施的,這個技術的只讀設定是存在於使用者端的,是編譯器和載入器共同完成的。

二進位制分析工具

Batbinary Analysis Tool),BitBlazeangrCodeSonar(商用),bapexecstack, setarch

X64

到了x64時代,System ABI for x86_64被大量使用,而這種ABI使得前面的大部分攻擊手法的難度都提高了很多,以往的暫存器利用或者棧利用都變得沒那麼容易。但是沒那麼容易是相對於x32發展了這麼多年形成的成熟的技術套件而言的,隨著時間的推移,x64的攻擊也會逐漸成熟。沒有絕對的防禦,只有難度的提高。