一、驅動文件感染

1.1. phrack原文

http://phrack.org/issues/61/10.html

--[ 1 - Introduction

rootkis是一種可以隱藏文件、進程,以及可以實現其它很多事情的內核模塊(驅動),最初的rootkis,都是直接利用insmod加載,這樣很容易通過lsmod被發現。目前,已經有很多隱藏內存模塊的技術,比如Plaguez提出的方法,以及Adore Rootkit中使用的更加刁鉆的方法,幾年后,又出現了通過/dev/kmem,修改內核內存鏡像的方法,最后,還出現了靜態捆綁內核文件的方法(見:2.內核文件感染),這個方法解決了一個重要的問題:系統reboot后,rootkis還是加載的。

本節將介紹一種新的內核模塊隱藏技術,并且也能在系統reboot之后,還是加載的。稍后將會基于linux 2.4.x內核環境(實驗也適用于其它使用elf格式的系統),演示這種感染技術。不過,由于內核模塊是elf格式的,所以閱讀后續內容之前,先要熟悉elf格式(http://www.skyfree.org/linux/references/ELF_Format.pdf),并且要重點理解其符號表,之后才好明白,向正常內核模塊注入感染代碼的原理。

--[ 2 - ELF Basis

ELF(Executable and Linking),是Linux操作系統中可執行文件的格式,接下來僅僅介紹一下,感染內核模塊,需要了解的部分:當鏈接2個elf文件時,需要知道這2個文件中包含哪個符號,每個elf文件(比如內核模塊),都有2個包含符號信息的節區,下面就是對這2個節區的介紹。

額外補充

有些elf文件,下一步要由靜態ld處理(比如.o、.ko文件),或由動態ld處理(比如.so文件),有些elf文件,加載到內存就可以直接運行(比如可執行文件、內核文件),而不管哪一種elf文件類型,都要為下一步處理,提供足夠的信息,所以,elf文件類型不同,具體的組成內容是有區別的。

另外,首次學習elf格式時,很容易被大量的結構體,搞的眼花繚亂,這里先放一張示意圖,展示了部分重要結構體之間的聯系,用于輔助理解后續內容:

比如:init_module()函數體文件內偏移的查找過程為:

① 根據elf頭部中的e_shoff成員,找到節區頭表;

② 遍歷節區頭表,找到.symtab節區頭部(類型為SHT_SYMTAB);

③ 根據.symtab節區頭部中的sh_offset成員,找到.symtab節區內容,根據sh_link成員,找到符號名稱所在節區(.strtab);

④ 遍歷.symtab節區(即Elf32_Sym[]數組),根據Elf32_Sym的sh_link成員,在.strtab節區中找到符號名稱;

⑤ 如果稱號名稱為”init_module”,根據Elf32_Sym的st_shndx和st_value成員,最終找到由init_module()函數編譯出來的機器碼,在.text節區中的某個位置。

----[ 2.1 - The .symtab section

.symtab節區(符號表)的內容,是一個供鏈接器使用的Elf32_Sym結構數組,Elf32_Sym結構定義在內核的/usr/include/elf.h頭文件:

typedef struct
{
  Elf32_Word    st_name;    /* Symbol name (string tbl index) */
  Elf32_Addr    st_value;    /* Symbol value */
  Elf32_Word    st_size;    /* Symbol size */
  unsigned char    st_info;    /* Symbol type and binding */
  unsigned char    st_other;    /* Symbol visibility */
  Elf32_Section    st_shndx;    /* Section index */
} Elf32_Sym;

◆st_name

st_name代表符號名稱的節內偏移(符號名稱所在節區,存于.symtab節區頭的sh_link成員中)。

◆st_value

符號本質上就是程序中的變量和函數名,也就是一塊數據或指令所在空間的”代號”,它是上層語言誕生的基石,相應的,也必須被編譯鏈接過程,映射為所在空間的位置,程序才能運行,因為cpu只能通過地址訪問內存。

st_value成員正是用于,將符號一步步映射為加載地址:(1) 在重定位文件中(未經鏈接),只要不是.bss節區中的變量,就代表映射空間所在節內的偏移(這時,st_shndx成員為所在節區在節區表中的下標),否則代表占用空間的字節對齊數(未初始化的全局變量,不需要記錄初始值,所以在elf文件中不占有空間,相應的,st_value暫時也就沒有位置可指,只能為將來加載進內存的地址,記錄對齊節字數);(2) 在可執行文件和共享庫中(已鏈接),代表占用空間的加載地址(根據文件加載地址+符號的文件偏移,共同推算)。

◆st_size

符號所占空間的大小,根據變量類型確定,比如int類型占4字節,如果是函數,則為函數被編譯為機器碼后的大小。

◆st_info

用于記錄符號屬性,比如全局/局部、變量/函數等。

◆st_shndx

代表符號占用空間所在節區,st_shndx只在st_value表示節內偏移時有意義,當輔助ld推算出符號最終的加載地址后,就不再需要了,另外,如果符號占用空間所在節區為.bss,則st_shndx值為SHN_COMMON(0xfff2),這是為了避免,與真正存在于elf文件的節區下標沖突。

----[ 2.2 - The .strtab section

.strtab節區的內容,是一個字符串表,包含很多以’\0’字符結尾的字符串,通過修改.strtab節區,可以將指定函數,修改為其它名稱,原文9.1節提供了ElfStrChange程序,所以就沒有說明原理(不過對照”ELF Basis”一節中的”額外補充”內容,還是很好理解的),只是提醒修改時要遵守一個原則:新名稱不能比舊名稱長,否則就會覆蓋舊名稱后面的’\0’字符,以及后面的其它字符串。

這里再額外補充一點:假設elf文件中既有aabb符號,又有bb符號,.strtab中可能會讓它們共享一個”aabb\0”字符串,所以如果這種情況將”bb”改掉,會導致”aabb”符號無效。

稍后的內容就會介紹,具體如何修改內核模塊的函數名,并且最終實現向一個內核模塊,注入另一個內核模塊。

--[ 3 - Playing with loadable kernel modules

接下來先分析內核模塊動態加載的代碼,用于進一步理解內核模塊感染的原理。

----[ 3.1 - Module Loading

內核模塊是通過應用程序insmod加載鏈接的(高版本內核已經將鏈接功能移到了內核代碼中,不過文中實驗環境,使用的是Linux 2.4.x內核),所以需要分析insmod.c(代碼見原文):

◆init_module()函數

(1) 定義并填充module結構變量,需要關注的是,module結構的init和cleanup成員,它們分別會被設置為,內核模塊的init_module()和cleanup_module()函數地址;

(2) 調用obj_find_symbol()函數,遍歷加載內核模塊的符號表,查找init_module符號,傳給obj_symbol_final_value()進一步獲取函數地址;

(3) 調用obj_find_symbol()函數,遍歷加載內核模塊的符號表,查找cleanup_module符號,傳給obj_symbol_final_value()進一步獲取函數地址;

(4) module結構變量填充好之后,調用sys_init_module()函數,將內核模塊加載到內核空間。

◆sys_init_module()函數

(1) 調用copy_from_user()函數,將內核模塊文件內容,從用戶空間,拷貝到內核空間;

(2) 執行module結構變量的init函數指針,即加載內核模塊的init_module()函數。

----[ 3.2 - .strtab modification

前面已經說過,內核模塊的初始化函數名稱”init_module”,位于.strtab節區,如果將其改為內核模塊中的另外一個函數名,比如”evil_module”,就能使內核模塊加載時,調用evil_module()函數,而不是init_module()函數。

原文提供了一個簡單的驅動程序test.c,包含init_module()、evil_module()、cleanup_module()三個函數,然后將test.c編譯為test.o,并通過objdump查看其符號表,主要關注init_module、evil_module兩個符號(用于修改后進行對比)。

這時,就可以按照以下順序,修改.strtab節區了:

rename

1) init_module ------> dumm_module

2) evil_module ------> init_module

先將”init_module”改為”dumm_module”(避免和后續新的init_module重名),再將”evil_module”改為”init_module”(頂替),這些都可以通過原文9.1節提供的ElfStrChange程序進行修改:

./elfstrchange test.o init_module dumm_module

./elfstrchange test.o evil_module init_module

再用objdump查看符號表,包含的就是dumm_module、init_module兩個符號了,并且init_module的文件偏移,為原來evil_module的文件偏移(0x14)。這是因為,只是”init_module”在.strtab節區中的位置變了,導致它對應的Elf32_Sym位置也變了,而這個位置上正是原來evil_module對應的Elf32_Sym,它的內容并沒有改變,通過它的st_shndx和st_info成員,仍然得到0x14偏移值。

----[ 3.3 - Code injection

以上實驗,證明了確實可以將evil_module(),偽裝成init_module()函數,然而,如果能從外部,向內核模塊注入evil_module()函數,那就更好了,好在這個目的可以利用ld命令輕松實現。

為了證實這一點,原文將上個實驗中的test.c,劃分為2份驅動代碼,original.c包含init_module()和cleanup_module()函數,inject.c包含inje_module()函數,然后分別編譯為original.o和inject.o,再利用ld的-r選項進行部分鏈接,最終展示了,得到的仍然是一個.o文件,并且通過objdump查看,同時包含init_module、cleanup_module、inje_module三個符號。這是因為內核模塊本質是.o文件(即使后綴為.ko),可以與其它.o文件部分鏈接,拼接成一個.o文件(比如當前實驗中的evil.o),并且和通過常規方式編譯生成的內核模塊(比如上個實驗中的test.o),沒有任何區別,也可以加載到內核。所以說,向已有內核模塊,注入額外的代碼,并不是什么難題。

----[ 3.4 - Keeping stealth

通常,都是對正常使用的內核模塊進行感染,然后,將init_module()改為注入函數后,啟動了感染者的功能,卻喪失了內核模塊本身的功能,這就很容易被發現做了手腳,不過,也有辦法保留內核模塊原本的功能,因為.strtab節區被修改之后,init_module()函數名被改為dumm_module,所以只要在注入的evil_module()函數中,調用一下dumm_module(),實際執行的就是真正的init_module()。

--[ 4 - Real life example

修改init_module()的方法,也完全適用于修改cleanup_module(),因此,以下展示將一個完整的內核模塊(著名的Adore rootkit),注入到聲卡驅動(i810_audio.o),處理過程其實非常簡單:

----[ 4.1 - Lkm infecting mini-howto

1) 修改adore.c代碼:

① 在init_module()函數中,添加dumm_module()函數調用

② 在cleanup_module()函數中,添加dummcle_module()函數調用

③ 將init_module()函數名,修改為evil_module

④ 將cleanup_module函數名,修改為evclean_module

2) 執行make編譯adore

3) 使用"ld -r"部分鏈接adore.o和i810_audio.o,作為新的i810_audio.o

4) 按如下方式,使用elfstrchange修改.strtab節區:

replace

init_module ------> dumm_module

evil_module ------> init_module (內部調用dumm_module)

cleanup_module ------> evclean_module

evclean_module ------> cleanup_module (內部調用evclean_module)

5) 加載并測試新的i810_autio.o

原文9.2節提供了lkminfect.sh自動腳本,不過步驟1)中的前面2步,需要手動完成。

----[ 4.2 - I will survive (a reboot)

感染內核模塊加載之后,有2個選擇,并且各有利弊:

◆將感染內核模塊,放進/lib/modules目錄,替換正常的內核模塊,這可以保證reboot后,感染功能仍會加載,但是,這樣也有可能會被類似Tripwire的HIDS(主機入侵檢測系統)檢測到,不過,內核模塊既不是可執行文件,也不是suid文件,所以除非將HIDS檢測級別調到最高,否則不會被檢測。

◆insmod之后,刪掉感染內核模塊,保持/lib/modules目錄不變,這樣,reboot之后,感染功能就消失了,但也不會被HIDS通過監測文件變化檢測到。

二、內核文件感染

2.1. phrack原文

http://phrack.org/issues/60/8.html

--[ 1 - Introduction

本文介紹了一種方法,可以將Linux內核模塊文件(LKM),捆綁到內核文件(linuxz),并且可以在系統reboot時被加載。相比insmod或者/dev/kmem實現的內核后門,這種方式更隱蔽,附件提供了具體的實現代碼(依賴/boot/System.map文件),已經基于redhat 7.2進行安裝和測試。

--[ 2 - Get kernel from the image

vmlinuz是一個統稱,包括zImage和bzImage兩種形式,是指vmlinux原始內核文件經過壓縮之后,與其它功能合成的文件(詳細原理,可以參考《Linux內核情景分析》第10章:系統的引導和初始化)。了解vmlinuz內核文件的結構,一方面為了從中剝出vmlinux文件,進而捆綁LKM,另一方面為了將捆綁后的vmlinux,再轉回vmlinuz文件。

根據/usr/src/linux/arch/i386/boot/Makefile可以看出:zImage文件(bzImage與之相似),是根據bootsect、setup、compressed/vmlinux.out三個文件合成的。其中,bootsect、setup分別由bootsect.s、setup.s匯編鏈接生成,compressed/vmlinux.out是通過objcopy命令,根據compressed/vmlinux文件生成。

根據/usr/src/linux/arch/i386/boot/compressed/Makefile可以看出:compressed/vmlinux文件(注意:頂層目錄中的vmlinux才是原始內核文件),是由piggy.o、head.o和misc.o鏈接而成。其中,misc.c中定義了decompress_kernel()函數,head.S會調用該函數,解壓內核文件,解壓過程中會引用input_len和input_data兩個符號,它們定義在piggy.o文件中,piggy.o只包含.data節區:

piggy.o:        $(SYSTEM)
    tmppiggy=_tmp_$$$$piggy; \
    rm -f $$tmppiggy $$tmppiggy.gz $$tmppiggy.lnk; \
    $(OBJCOPY) $(SYSTEM) $$tmppiggy; \
    gzip -f -9 < $$tmppiggy > $$tmppiggy.gz; \
    echo "SECTIONS { .data : { input_len = .; \
    LONG(input_data_end - input_data) input_data = .; \
    *(.data) input_data_end = .; }}" > $$tmppiggy.lnk;
    $(LD) -r -o piggy.o -b binary $$tmppiggy.gz -b elf32-i386 -T \
    $$tmppiggy.lnk;
    rm -f $$tmppiggy $$tmppiggy.gz $$tmppiggy.lnk

文章沒有說明$(SYSTEM)的值,但是根據完整的Makefile(比如:https://elixir.bootlin.com/linux/2.4.0/source/arch/i386/boot/compressed/Makefile),就會發現:SYSTEM = $(TOPDIR)/vmlinux(即頂層目錄中的vmlinux),也就是說,piggy.o是根據原始內核文件生成的,具體過程為:

① 執行objcopy,將原始內核文件中的部分內容(-R移除.note和.comment節區,-S移除調試信息),拷貝到$tmppiggy臨時文件;

② 將$tmppiggy文件,壓縮為$tmppiggy.gz;

③ 生成鏈接腳本$tmppiggy.lnk:

SECTIONS {
  .data : {
    input_len = .;
    LONG(input_data_end - input_data)
    input_data = .;
    *(.data)
    input_data_end = .;
  }
}

顯然,根據$tmppiggy.lnk鏈接生成的文件,只包含.data節區,并且.data節區包含一個LONG值,以及所有參與鏈接的文件的.data節區集合,其中,LONG值保存了.data節區集合的長度,所在地址用input_len符號記錄,.data節區集合的地址,用input_data符號記錄,misc.c中可以通過extern引用這兩個變量,就是因為它們在.data節區中存在。

④ 執行ld,鏈接生成piggy.o:

-b binary $$tmppiggy.gz:將整個$tmppiggy.gz文件內容,添加到.data節區集合;

-T $$tmppiggy.lnk:按照$tmppiggy.lnk的指示進行鏈接。

結論:

編譯生成原始vmlinux -> 壓縮從原始vmlinux中objcopy出來的內容,作為.data節區,生成piggy.o -> 鏈接piggy.o、head.o和misc.o,生成compressed/vmlinux -> 根據compressed/vmlinux,objcopy得到compressed/vmlinux.out -> 鏈接bootsect、setup和compressed/vmlinux.out,生成zImage。

--[ 3 - Allocate some space in image to us

加載內核時,會把緊接著內核文件的內存區域,作為.bss節區的存儲空間,會被head.S中的代碼,全部清為0(.bss節區的內容初始一定為全0,所以elf文件中只會記錄起始和結束位置),因此,不能直接將LKM捆綁在vmlinux文件后面,否則加載到內存后,內容很快就會被擦除掉,而是先要追加一段與.bss節區同等大小的內容(文中稱為”dummy data”),再追加LKM。

除此以外,還要解決另外一個問題,先看一下內容啟動過程中執行的代碼:

圖中鼠標選中部分,估計是作者復制錯了位置,以下根據linux-2.4.0內核代碼截取(https://elixir.bootlin.com/linux/2.4.0/source/arch/i386/kernel/setup.c#L598):

圖中的代碼,將_end(即.bss節區的結束地址)賦值給了init_mm.brk和start_pfn,其中,start_pfn的值是個頁面號,代表可供內核動態分配的第一個頁面。顯然,這個值需要調整,跳過緊接在.bss節區后面的LKM內容,否則內核的動態分配+寫操作,會將加載到內存中的LKM覆蓋掉。

下圖是從《Linux內核情況分析》截取:

--[ 4 - Relocate the symbol in module file

本節主要表達,.o文件中的很多地址值,在編譯階段是無法確定的,所以編譯器會在.o文件中,留下重定項信息,供鏈接器在后期確認后加以修改。關于重定位原理,我在這篇文章中深入介紹過:32位elf格式中的10種重定位類型(https://bbs.kanxue.com/thread-246373.htm)。

然而,捆綁的LKM,是我們自己追加到vmlinux內核文件后面的,不是通過ld程序鏈接進去的,所以LKM中的重定項,也需要我們自己處理。

--[ 5 - Make it autorun when reboot

解決以上所有問題之后,還得找個時機,觸發捆綁LKM的加載,也就是調用LKM的init_module()函數。jbtzhm提供的方法是,再在dummy data與捆綁LKM之間,添加如下指令塊:

然后,再修改內核的系統調用表,具體來說,就是將SYS_getpid系統調用號對應的表項(因為reboot時,該系統調用一定會被執行,很多程序(比如init)都會調用getpid()函數),修改為init_code[]的地址,進入init_code[]后,就會在真正調用sys_getpid()之前,悄悄先加載捆綁的LKM。

另外,這段指令中涉及的地址,要根據init_code[]的實際存放位置,以及具體的內核符號表,才能確定(具體見附件中kpatch.c的實現)。

--[ 6 - Possible solutions

根據內核文件感染的原理可以看出,感染過程需要獲取__bss_start、_end、sys_call_table這些符號的地址,并且本文提供的感染工具,是根據/boot/System.map文件獲取的,但是刪除/boot/System.map文件,并不能防御感染,因為Silvio展示過直接從內核獲取內核符號地址的方法,所以,最終要通過檢查內核文件內容是否有變化,檢查是否被感染。

--[ 7 - Conclusion

[2]~[5]節,介紹了感染的大體流程,至于實現過程中要解決的一些具體問題,只是拋出,并未給出答案,所以附件中提供了一份基于redhat 7.2環境開發和測試的感染程序,后續通過分析這份代碼,就能明白完整的思路了。

--[ 8 - References

參考文檔鏈接。

--[ 9 - Appendix: The implementation

附件需要通過以下步驟,還原成kpatch.tgz文件,然后解壓:

① 創建xx.txt,并將[begine, end]區間的內容,復制進去;

② uudecode xx.txt # 還原kpatch.tgz(如果沒有uudecode命令,要先安裝sharutils);

③ tar zxvf kpatch.tgz # 解壓得到kpatch目錄,里面包含kpatch.c等代碼文件。

2.2. kpatch代碼分析

常規情況下,LKM都是在加載時,才會由insmod和內核,使其跟vmlinux內容進行鏈接,捆綁的LKM顯然要盡早完成鏈接,否則,等到可以觸發insmod執行的時候,LKM可能早已被其它應用程序,通過執行getpid()系統調用執行過了,而那個時候還沒有完成鏈接,就會導致系統崩潰。所以,kpatch程序的主要部分,就是一個鏈接器。

既然是鏈接器,就要對LKM中的很多內容進行修改,比如根據重定項,將目標位置修改為全局變量運行時的內存地址等,因此,kpatch代碼中會有很多賦值語句。為了防止看代碼的時候犯暈,有必要事先明確:kpatch是對自己read()的LKM內核進行修改,修改值是在reboot后運行時使用,所以賦值語句的左值位置和右值范圍,一定如下圖所示:

另外,要執行LKM,就要訪問LKM的.text、.data等節區,加載到內存后,LKM所在的這塊內存,是由誰分配的?

分析kpatch代碼的時候就會看到,kpatch會修改vmlinux中的一條mov語句(源操作數為start_pfn),將其目的操作數改大,從而將LKM的大小也包含進去,這樣,內核啟動階段,就會分配這塊內存,而LKM是某個應用程序執行getpid()系統調用時觸發執行的,這個時候內核顯然已經啟動完成,換句話說,這塊內存也已經分配了(頁目錄表中有映射)。

下圖是從《Linux內核情況分析》截取:

這就相當于欺騙內核,分配更多的保留頁面,但實際能用到的并沒有變多,也就是說內核并不會對LKM所在內存區域執行寫操作,因此LKM的內容也不會糟到破壞。除此之外,在”Allocate some space in image to use”一節中還提到,修改start_pfn,還會將LKM移到內核的堆區之外,所以可以說是一舉兩得。

kpatch.c及其調用關系:

程序中的0xC0100000值,包含2部分:0xC0000000是內核空間的地始虛擬地址,0x100000來自鏈接腳本,是內核文件加載的起始地址。

下圖是從《Linux內核情況分析》截取: