1. 背景
? ? ? ?在我們的日常工作中經常會遇到一些BUG,而且這些BUG發生在native層,也就是在我們的so共享庫中,對于這些BUG有時我們可以修改代碼,重新編譯生成SO文件,但很多情況下這些SO文件都是外部提供,在沒有源碼的情況下我們沒法修改并編譯,這時候Hook技術就有了用武之地。
? ? ? 舉個例子,在之前的一個項目中,作者使用Android插件技術調起某款應用,在使用的過程中,發現某功能無法使用,后來從對方研發那邊打聽到,他們的某個so苦文件中在調用標準C函數dlopen時,傳入了一個寫死的路徑,這個路徑類似于/data/data/com.yingyong,這是Android中應用的數據存儲的私有路徑,如果應用未安裝,該路徑也就不存在,所以也就導致了我們在使用插件技術調起對方應用時發生了錯誤。在綜合考慮解決該問題的各種方法之后,最終我們決定hook住對方應用內的so文件,替換掉內存中dlopen函數的入口地址,當對方應用so文件中調用該函數時,最終會調用到我們的代理函數,我們在代理函數中預先做些處理,然后再去調用真實的dlopen函數。基于上面所說的方法,我們解決問題的思路大概如下:
1. 首先so庫肯定會加載到進程空間中來,我們在我們的進程空間中,找到這個so庫的起始地址
2. 基于對so文件格式的理解,想辦法找到dlopen這個函數調用的入口地址,我們把這個入口地址替換掉,替換成我們自定義的hook函數的地址,同時保存原來的dlopen函數的地址。
3. 當我們把dlopen函數的入口地址替換掉之后,so文件中在調用dlopen函數時就會跑到我們的hook函數中,執行我們的代碼,我們在我們的hook函數里面對傳進來的路徑進行判斷,如果是/data/data/com.yingyong那么函數就直接return,如果是其他的路徑則跳轉到原來的dlopen函數的入口地址里面去執行。
要想達到上面所說的目的,我們有兩個障礙:
(1) 我們得熟悉so文件格式,了解so是如何被進程加載的,so在進程空間中是怎么存儲的,我們該如何從進程空間中找到dlopen函數的入口
(2)在找到dlopen函數的入口后,我們如何去修改進程空間把原來的dlopen函數的入口地址替換成我們自己寫的函數的地址
這兩個問題中,第一個問題比較復雜,我們放在后面講,先講第一個問題如何解決。
2.內存映射鏡像
要想修改內存中dlopen函數的入口地址,首先我們得先看看so在進程空間中是如何存在。
先讓我們來看一個典型的應用進程的內存鏡像:
上面的信息可以通過命令:cat /proc/pid/maps 來查看。注意,如果查看本進程,pid傳入的是字符串“self”,如果是其他進程,pid就是那個進程的進程號。通過上面這幅圖,我們可以查看到當前進程空間的內存映射情況,模塊加載情況以及虛擬地址和內存讀寫執行(rwxp)屬性等。我們以其中一行為例來解釋這里面數據的含義:
第一列“aec20000-aec2b000”表示該段數據的起始和結束地址; 第二列“r-xp”表示的是這段內存的權限, r表示只讀,w表示可寫,x表示可執行,p表示私有;第三列“00000000”表示在進程地址里面的偏移量,第四列“fe:00”是主設備號和次設備號,第五列“999598”是文件節點號inode
上面的這行有只讀和執行權限,其實熟悉虛擬機的同學就可以判斷出這就是程序內的代碼段,我們在c程序里面定義的一些函數的信息也就保存在這里,比如我們上面所說的dlopen函數的入口地址。但是現在問題來了,這段內存是只讀的,那我們怎么去把dlopen的入口地址替換成我們自定義的函數地址呢?其實linux給我們提供了一個函數mprotect,該函數可以修改內存塊的讀取權限,其定義如下:
#include?
intmprotect(void*addr,size_tlen,intprot);
參數:
addr:要修改的內存基址(必須頁面對齊,page size的倍數,一般為4K對齊)
len:大小(bytes)
prot:修改后的rwx屬性,可以有以下值:
? ? ? ?PROT_EXEC??Pages?may?be?executed.//可執行
? ? ? ?PROT_READ??Pages?may?be?read.//可讀
? ? ? ?PROT_WRITE?Pages?may?be?written.//可寫
? ? ? ?PROT_NONE??Pages?may?not?be?accessed.//不可訪問
當我們找到存放dlopen入口地址的內存塊時,首先調用mprotect函數修改該內存塊的讀取權限,然后通過memcpy函數,將該內存塊存儲的dlopen入口地址替換成我們定義的函數地址,memcpy函數的定義如下:
#include?
intmemcpy(void*pDest,void*pSour,size_tpLen);
該函數的作用就是從pSour地址開始拷貝pLen長的數據到pDest開始的內存中去。
到這里,上面的第二個問題就得到解決了 ,我們知道通過mprotect函數去修改內存塊讀寫權限,然后我們可以通過memcpy將自己寫的hook函數的地址拷貝到保存dlopen函數地址的內存塊中去,但現在的問題就是要找到dlopen函數的入口地址,這就需要我們去了解ELF文件格式了,只有了解了so文件的文件格式,我們才能寫出方法,找到存儲dlopen函數地址的地方。下面我們就來深入了解so文件格式,并著手解決第一個問題
3. ELF文件格式的理解
ELF是類Unix類系統當然也包括Android系統上的對象文件的格式(包括.so和.o類文件)。可以理解為Android系統上的exe或者dll文件格式。理解ELF文件規范,是理解Android系統上進程加載、執行的前提。
首先,你需要知道的是所謂對象文件(Object files)有三個種類:
1) 可重定位的對象文件(Relocatable file)。這是由匯編器匯編生成的 .o 文件。
2) 可執行的對象文件(Executable file)。比如我們用的QQ、微信、瀏覽器等等
3) 可被共享的對象文件(Shared object file)這些就是所謂的動態庫文件,也即 .so 文件。
一個動態庫也就是so文件要想發揮作用,必須經過兩個步驟:
a) 鏈接編輯器(link editor)拿它和其他Relocatable object file以及其他shared object file作為輸入,經鏈接處理后,生存另外的 shared object file 或者 executable file。
b) 在運行時,動態鏈接器(dynamic linker)拿它和一個Executable file以及另外一些 Shared object file 來一起處理,在Linux系統里面創建一個進程映像。
下面用一張圖來對ELF的文件格式有個總覽:
左邊是靜態視圖,而右邊則是鏈接加載時的視圖,都是同一個文件的兩種狀態。
我覺得下面這兩張圖能夠更準確的反映ELF文件的組成:
我們來解釋一下文件各部分的意義:
ELF header在文件開始處描述了整個文件的組織,
程序頭部表,后面我們叫Program header table,指出怎樣創建進程映像,含有每個program header的入口,
節區,后面我們都叫Section,提供了目標文件的各項信息(如指令、數據、符號表、重定位信息等)
節區頭部表,后面我們叫section header table,包含每一個section的入口,給出名字、大小等信息。
在我們的Android NDK環境中有一個工具arm-linux-androideabi-readelf,工具位于android-ndk-r10d/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin/下面,這個工具可以讀取并顯示so文件中的一些信息。我們以之前免安裝工程里面寫的libhook.so共享庫為例,來看看so文件各部分信息的組成:
ELF Header信息
ELF Header:顧名思義,這是所有ELF文件都有的”頭“。里面包含了ELF文件的”綱領性“信息,如CPU架構,Section header在文件中的偏移字節數,Section的個數等等,用C語言數據結構來描述就是:
/*?ELF?Header?*/
typedefstructelfhdr?{
? ? ?unsignedchare_ident[EI_NIDENT];/*?ELF?Identification?*/
? ? ?Elf32_Half??e_type;/*?object?file?type?*/
? ? ?Elf32_Half??e_machine;/*?machine?*/
? ? ?Elf32_Word??e_version;/*?object?file?version?*/
? ? ?Elf32_Addr??e_entry;/*?virtual?entry?point?*/
? ? ?Elf32_Off???e_phoff;/*?program?header?table?offset?*/
? ? ?Elf32_Off???e_shoff;/*?section?header?table?offset?*/
? ? ?Elf32_Word??e_flags;/*?processor-specific?flags?*/
? ? ?Elf32_Half??e_ehsize;/*?ELF?header?size?*/
? ? ?Elf32_Half??e_phentsize;/*?program?header?entry?size?*/
? ? ?Elf32_Half??e_phnum;/*?number?of?program?header?entries?*/
? ? ?Elf32_Half??e_shentsize;/*?section?header?entry?size?*/
? ? ?Elf32_Half??e_shnum;/*?number?of?section?header?entries?*/
?Elf32_Half??e_shstrndx;/*?section?header?table's?"sectionheader?string?table"?entry?offset?*/
}?Elf32_Ehdr;
其中e_ident的16個字節標明是個ELF文件。e_type表示文件類型,e_machine說明機器類別,e_entry給出進程開始的虛地址,即系統將控制轉移的位置, 可以理解成main函數的位置。e_phoff指出program header table的文件偏移,e_phentsize表示一個program header每一項的長度(字節數表示),e_phnum給出program header里面數據項的數目。類似的,e_shoff,e_shentsize,e_shnum分別表示section header table的文件偏移,表中每一個section項的的字節數和section的個數。e_flags給出與處理器相關的標志,e_ehsize給出ELF文件頭的長度(字節數表示)。e_shstrndx表示section header string table在文件中的偏移位置。
我們要想定位到一個具體的Section,主要關注以下幾個值:
e_shoff: Section header table的起始地址
e_shnum: ? Section header的個數
e_shstrndx: ?有一個特殊的Section Header,它里面保存的相當于一個String數組,每一項都是一個Section的名字。
Programe Header信息
一個可執行文件及其依賴的共享目標文件被完全成功地裝載到進程的內存地址空間中之后,這個可執行文件或共享目標文件中的程序頭部表(Program Header Table)就是必須存在的、不可缺少的必需品,程序頭部表是一個數組,數組中的每一個元素就稱為一個程序頭(Program Header),每一個程序頭描述一個內存段(Segment)或者一塊用于準備執行程序的信息;內存中的一個目標文件中的段包含一個或多個節;也就是ELF文件在磁盤中的一個或多個節可能會被映射到內存中的同一個段中;程序頭只對可執行文件或共享目標文件有意義,對于其它類型的目標文件,該信息可以忽略;p_type=PT_LOAD時,段的內容會被從文件中拷貝到內存中,所有PT_LOAD類型的程序頭都按照p_vaddr的值做升序排列;
Section 信息
? ? ? Section Header Table則列出了所有包含在文件中的Section區信息列表。它是一個Elf32_Shdr結構的數組,Section頭表的索引是這個數組的下標。通過Section Header Table可以定位到所有的Section。在一個so文件中會有很多的Section,我們寫的libhook.so文件里面就有22個Section,如.interp,.text, .plt, ? ?.rel.plt等等
? ? ? 可以說,Section是在ELF文件里頭用以裝載內容數據的最小容器。在ELF文件里面,每一個Section內都裝載了性質屬性都不一樣的內容,比方:
1) .text section 里裝載了可執行代碼;
2) .data section 里面裝載了被初始化的數據;
3) .bss section 里面裝載了未被初始化的數據;
4) 以 .rel 打頭的 sections 里面裝載了重定位條目,如.rel.plt里面存儲的就是外部函數的重定位信息
5) .symtab 或者 .dynsym section 里面裝載了符號信息;
6) .strtab 或者 .dynstr section 里面裝載了字符串信息;
7) 其他還有為滿足不同目的所設置的section,比方滿足調試的目的、滿足動態鏈接與加載的目的等等
下面我們再看一下Section Header的定義:
/*?Section?Header?*/
typedefstruct{
? ? ?Elf32_Word??sh_name;/*?name?-?index?into?section?header
? ? ?string?table?section?*/
? ? ?Elf32_Word??sh_type;/*?type?*/
? ? ?Elf32_Word??sh_flags;/*?flags?*/
? ? ?Elf32_Addr??sh_addr;/*?address?*/
? ? ?Elf32_Off???sh_offset;/*?file?offset?*/
? ? ?Elf32_Word??sh_size;/*?section?size?*/
? ? ?Elf32_Word??sh_link;/*?section?header?table?index?link?*/
? ? ?Elf32_Word??sh_info;/*?extra?information?*/
? ? ?Elf32_Word??sh_addralign;/*?address?alignment?*/
? ? ?Elf32_Word??sh_entsize;/*?section?entry?size?*/
}?Elf32_Shdr;
這個我們要詳細解釋一下,后面能用到:
字段:
sh_name:顧名思義,Section的名字,類型是Elf32_Word,實際上它是指向section header string table的索引值
sh_flags:類型。.dynsym的類型為DYNSYM表示該節區包含了要動態鏈接的符號等等
sh_addr:地址。該節區在內存中,相對于基址的偏移
sh_offset:偏移。表示該節區到文件頭部的字節偏移。
sh_size:節區大小
sh_link:表示與當前section有link關系的section索引,不同類型的section,其解釋不同。如上面的libc.so,其.dynsym的link為2,而2正好是.dynstr的索引,實際上就是動態符號串表的索引
sh_info:一些附加信息
sh_addralign:節區的地址對齊
sh_entsize:節區項的大小(bytes)
? ? ? 上面這么多亂七八糟的看起來很多,實際上為了解決上面的第一個問題我們只需要關注三個Section:.dynsym, ?.dynstr,.rel.plt,因為,dlopen函數是標準的c庫函數,在一個so文件中要想調用他,那么就得給這個函數生成一個重定位信息,這個信息就存儲在.rel.plt這個section里面,.rel.plt相當于一個數組,這個數組里面的每一項都存儲了一項可重定位信息。所以我們要想找到dlopen的入口地址,就得到.rel.plt中去,一個一個的去遍歷數組,找到那個dlopen相關的那一項,但是.rel.plt不會存儲一個“dlopen”的字符串,我們首先得到.dynsym這個section里面去找代表“dlopen”的那個符號信息,這個符號信息的數據結構里面也不會存儲具體的函數名稱(如“dlopen”),這些名稱會存儲在.dynstr這個section里面,所以又得去.dynstr里面去找。所以要想找到dlopen函數代表的重定位信息,首先就得遍歷.rel.plt, 對于里面的每一項通過.dynsym去.dynstr里面去找這個重定位函數的名稱是不是叫“dlopen”,如果是,那么這條重定位信息,就是代表dlopen的重定位信息,下面我們來具體想一看.rel.plt 、 .dynsym 、 .dynstr這三個節區里面代表每一項數據的數據結構:
.rel.plt:?該Section用來保存所有的可重定位信息, 對于每一條可重定位信息,可以用下面的數據結構Elf32_Rel來表示:
typedefstruct{
Elf32_Addr ?r_offset;
Elf32_Word ?r_info;
}?Elf32_Rel;
在數據結構Elf32_Rel中r_offset表示的就是在運行期間,該重定位條目相對于so基地址的偏移量,我們要找的也就是這個偏移量,然后加上so在內存中的基地址,得到的就是我們要找的存儲函數入口地址的地方;r_info是由兩個值組成的:1. 該重定位在.dynsym中的索引, 2. 該重定位的類型。 ?我們可以通過ELF32_R_SYM這個宏獲取到該重定位在.dynsym中的索引,通過ELF32_R_TYPE獲取該重定位的類型。
typedefstructelf32_sym{
? ? ?Elf32_Word????st_name;
? ? ?Elf32_Addr????st_value;
? ? ?Elf32_Word????st_size;
? ? ?unsignedcharst_info;
? ? ?unsignedcharst_other;
? ? ?Elf32_Half?????st_shndx;
}?Elf32_Sym;
其中st_name包含指向符號表字符串表(.dynstr, ?也就是dynamic symbol string table)中的索引,從而可以獲得符號名。舉個例子,對于我們要找的dlopen這個函數,就存在一個上面的Elf32_Sym對象,該對象的st_name值可能是15,這時候我們就能從.dynstr(Dynamic symble string table)里面找到第15項,這個第15項里面就存了一個字符串“dlopen”。
st_value指出符號的值,可能是一個絕對值、地址等。st_size指出符號相關的內存大小,比如一個數據結構包含的字節數等。st_info規定了符號的類型和綁定屬性,指出這個符號是一個數據名、函數名、section名還是源文件名;并且指出該符號的綁定屬性是local、global還是weak。
.dynstr:全稱叫dynamic symbol string table, 我們可以把他理解成一個字符串數組,即char* [ ];
到了這里我們的知識儲備就能夠找到運行時dlopen函數在進程空間中的地址了:
通過讀取ELF文件內容,找到.dynsym, ?.dynstr,.rel.plt這三個section,讀取這三個section的內容。然后從.rel.plt這個section中讀取到dlopen函數對應的重定位項,怎么找到這個重定位項呢,那就是遍歷.rel.plt中的所有的重定位項信息,讀取這些重定位項對應的Elf32_Rel結構對象,從Elf32_Rel的r_info字段中通過ELF32_R_SYM讀取一個index值,這個index值就是dlopen函數在.dynsym中對應的符號信息的index,通過這個index讀取dlopen這個符號對應的Elf32_Sym結構對象。而通過這個結構對象的st_name去.dynstr中去找相應的字符串,當這個字符串等于“dlopen”時,我們就知道了,剛才在.rel.plt中找到的Elf32_Rel對應的就是dlopen函數對應的重定位項,通過讀取這個重定位項的r_offset就可以知道存儲dlopen函數入口地址的地方在整個so鏡像中的偏移量,用這個偏移量加上so映射到進程中的起始地址,就得到了dlopen這個函數在我們應用進程中的絕對地址,這個地址保存的是dlopen函數的入口地址,我們把這個入口地址替換成我們函數的地址,這樣在調用dlopen函數時,就進入到我們定義的函數中了。
至此我們就完成了我們應用包含的so中的dlopen函數的hook。