目錄
- 概述
- 簡單示例
- ELF文件格式初探
- 裝載、動態鏈接與重定位
- PLT與GOT
- 如何定位基址?
- 如何修改呢?
- 解析基址和偏移
- 思考和小結
概述
我們日常開發中編寫的C/C++代碼經過NDK
進行編譯和鏈接之後,生成的動態鏈接庫或可執行文件都是ELF
格式的,它也是Linux
的主要可執行文件格式。我們今天就要藉助一個示例來理解一下android平臺下native層hook的操作和原理,不過在這之前,我們還是要先了解一下ELF相關的內容。
簡單示例
這裏給了一段示例代碼:寫入一段文本到文件中去。
爲了簡單起見,後面的都是以armeabi-v7a
爲例
void writeText(const char *path, const char *text) {
FILE *fp = NULL;
if ((fp = fopen(path, "w")) == NULL) {
LOG_E("file cannot open");
}
//寫入數據
fwrite(text, strlen(text), 1, fp);
if (fclose(fp) != 0) {
LOG_E("file cannot be closed");
}
}
輸出目標共享庫:libnative-write.so
,這個共享庫的作用是寫入一段文本,我們今天的目標就是對這個目標共享庫
的fwrite函數
進行hook操作。
ELF文件格式初探
ELF文件有兩種視圖形式:鏈接視圖
和執行視圖
鏈接視圖:可以理解爲目標文件的內容視圖
執行視圖:可以理解爲目標文件的內存視圖
文件頭(elf_header)
文件頭部定義了魔數
,以及指向節頭表SHT(section_header_table)
和程序頭表PHT(program_header_table)
的偏移
。
節頭表SHT(section_header_table)
ELF文件在鏈接視圖
中是 以節(section)
爲單位來組織和管理各種信息。
.dynsym
:爲了完成動態鏈接,最關鍵的還是所依賴的符號和相關文件的信息。爲了表示動態鏈接這些模塊之間的符號導入導出關係,ELF有一個叫做動態符號表(Dynamic Symbol Table)的段用來保存這些信息。
.rel.dyn
:實際上是對數據引用的修正,它所修正的位置位於.got
以及數據段。
.rel.plt
:是對函數引用的修正,它所修正的位置位於.got
。
.plt
:程序鏈接表(Procedure Link Table),外部調用的跳板。
.text
:爲代碼段,也是反彙編處理的部分,以機器碼的形式存儲。
.dynamic
:描述了模塊動態鏈接相關的信息。
.got
:全局偏移表(Global Offset Table),用於記錄外部調用的入口地址。
.data
: 數據段,保存的那些已經初始化了的全局靜態變量和局部靜態變量。
程序頭表PHT(program_header_table)
ELF文件在執行視圖
中是 以段(Segment)
爲單位來組織和管理各種信息。
所有類型爲 PT_LOAD
的段(segment)
都會被動態鏈接器(linker)
映射(mmap)
到內存中。
裝載、動態鏈接與重定位
1、裝載
這個很好理解,我們在使用一個動態庫內的函數時,都要先對其進行加載,在android
中,我們通常是使用System.loadLibrary
的方式加載我們的目標共享庫,它的內部實現其實也是調用系統內部linker
中的dlopen、dlsym、dlclose函數
完成對目標共享庫的裝載。
2、動態鏈接
動態鏈接的基本思想是把程序按照模塊拆分成各個相對獨立部分,在程序運行時纔將它們鏈接在一起形成一個完整的程序,而不是像靜態鏈接一樣把所有的程序模塊都鏈接成一個個單獨的可執行文件。
當共享庫被裝載的時候,動態鏈接器linker
會將共享庫裝載到進程的地址空間,並且將程序中所有未決議的符號綁定到相應的動態鏈接庫中,並進行重定位工作。
3、重定位
共享庫需要重定位的主要原因是導入符號的存在。動態鏈接下,無論是可執行文件或共享對象,一旦它依賴於其他共享對象,也就是說有導入的符號時(比如fwrite函數
),那麼它的代碼或數據中就會有對於導入符號的引用。在編譯時這些導入符號的地址未知,在運行時才確定,所以需要在運行時將這些導入符號的引用修正,即需要重定位。
動態鏈接的文件中,有專門的重定位表
分別叫做.rel.dyn
和.rel.plt
:
arm-linux-androideabi-readelf -r libnative-write.so
R_ARM_GLOB_DAT
和R_ARM_JUMP_SLOT
是ARM
下的重定位方式,這兩個類型的重定位入口表示,被修正的位置只需要直接填入符號的地址即可。比如我們看fwrite函數
這個重定位入口,它的類型爲R_ARM_JUMP_SLOT
,它的偏移爲0x0002FE0
,它實際上位於.got
中。
PLT與GOT
前面的過程裝載->動態鏈接->重定位
完成之後,目標共享庫的基址
已經確定了,當我們調用某個函數時(比如fwrite
函數),調用函數並不是直接調用原始fwrite函數的函數地址
,它會先經過PLT程序鏈接表(Procedure Link Table)
,跳轉至GOT全局偏移表(Global Offset Table)
獲取目標函數fwrite函數
的全局偏移,這時候就可以通過基址+偏移
的方式定位真實的fwrite
函數地址了,目前android平臺大部分CPU架構
是沒有提供延遲綁定(Lazy Binding)
機制的(只有MIPS架構支持延遲綁定
),所有外部過程引用
都在映像執行之前解析。
PLT
:程序鏈接表(Procedure Link Table),外部調用的跳板,在ELF文件中以獨立的段存放,段名通常叫做".plt"
GOT
:全局偏移表(Global Offset Table),用於記錄外部調用的入口地址,段名通常叫做".got"
前面的內容都是一些概念性的內容,比較枯燥,接下來會以writeText函數
爲入口,一步一步查看我們最終的目標函數fwrite
的地址。
從.dynsym開始
.dynsym
:上面也說到了,這個節
裏只保存了與動態鏈接相關的符號導入導出關係。
arm-linux-androideabi-readelf -s libnative-write.so
我們可以看到目標的writeText函數
在0x705
的地方,我們再看下對應的反彙編代碼:
arm-linux-androideabi-objdump -D libnative-write.so
這裏會看到我們自己的writeText函數
通過BLX(相對尋址)指令
走到fwrite@plt
裏面,簡化上面的圖:
從上面的簡圖中,我們可以看到,當執行我們的代碼段.text
中的writeText函數
的時候,內部會通過BLX相對尋址
的方式進入.plt節
,計算程序計數器 PC 的當前值
跳轉進入.got節
。
00000668 <fwrite@plt>:
668: e28fc600 add ip, pc, #0, 12 //由於ARM三級流水,PC = 0x668 + 0x8;
66c: e28cca02 add ip, ip, #8192 ; 0x2000 // ip = ip + 0x2000
670: e5bcf970 ldr pc, [ip, #2416]! ; 0x970 // pc = ip + 0x970
以上三條指令執行完,從0x668 + 0x8 + 0x2000 + 0x970 = 0x2FE0
位置取值給PC
,通過LDR
完成間接尋址的跳轉。因此在.got(全局符號表)
中偏移爲0x2FE0
的位置就是目標函數fwrite
的偏移了。
可以看到,當我們通過libnative-write.so
共享庫中的writeText函數
調用libc
中的導入函數fwrite
的時候,還是經歷了一些曲折的過程
,這裏的過程,指的就是經過PLT
和GOT
的跳轉,到達我們最終的真實的導入函數的地址
。
更快速的找到目標函數的偏移
前面也提到過動態鏈接重定位表
中的.rel.plt
是對函數引用
的修正,它所修正的位置位於.got
。我們最終都是要通過.got
確定目標函數的偏移,因此這裏我們可以用readelf
直接看到fwrite函數
的偏移
通過如下可以查看ELF中需要重定位的函數,我們看下fwrite()函數
。
arm-linux-androideabi-readelf -r libnative-write.so
可以看到我們從libc庫
中的導入函數fwrite
,這個偏移和我們剛纔計算的偏移是一致的都是:0x2FE0
如何定位基址?
我們首先來看基址的獲取,這裏要用到linux系統的一些特性
# 進程的虛擬地址空間
cat /proc/<pid>/maps
上圖已經列舉出了我們的應用加載的一些so庫,左邊標記紅色的地址就是各個so庫的基址
#在進程ID爲32396的進程中加載的幾個庫中
libhook-simple.so庫的基址爲:0xD40D8000
libnative-hook.so庫的基址爲:0xD411B000
libnative-write.so庫的基址爲:0xD414F000
因此我們實際需要hook的函數fwrite
的地址爲:
addr = base_addr + 0x2FE0
如何修改呢?
通過前面的分析,我們已經拿到目標函數fwrite()
的地址指針了,理論上只要朝這個地址寫入我們目標函數的地址就可以了?
並不是!!!
注意:
1、目標函數的地址很可能沒有寫權限,因此需要提前調整目標函數地址的權限
2、由於ARM有緩存指令集,hook之後可能會不成功,讀取的是緩存中的指令,因此這裏需要清除一下指令緩存
這時候我們就需要用到linux中的函數:
//調整目標內存區域的權限
int mprotect(void* __addr, size_t __size, int __prot);
//清除緩存指令
__builtin___clear_cache(void * __page_start,void * __page_end)
操作如下:
//調整寫權限
mprotect((void *) PAGE_START(addr), PAGE_SIZE, PROT_READ | PROT_WRITE);
//朝目標函數的地址寫新的地址
*(void **) addr = hook_fwrite;
//清除指令緩存
__builtin___clear_cache((void *) PAGE_START(addr), (void *) PAGE_END(addr));
完整的hook操作:
#include <stdio.h>
#include <malloc.h>
#include <string.h>
#include <inttypes.h>
#include <sys/mman.h>
#include "hook_simple.h"
#include "logger.h"
#define PAGE_START(addr) ((addr) & PAGE_MASK)
#define PAGE_END(addr) (PAGE_START(addr) + PAGE_SIZE)
size_t hook_fwrite(const void *buf, size_t size, size_t count, FILE *fp) {
LOG_D("hook fwrite success");
//這裏插入一段文本
const char *text = "hello ";
fwrite(text, strlen(text), 1, fp);
return fwrite(buf, size, count, fp);
}
/**
* 直接硬編碼的方式進行
* hook演示操作
* @param env
* @param obj
* @param jSoName
*/
void Java_com_feature_hook_NativeHook_hookSimple(JNIEnv *env, jobject obj, jstring jSoName) {
const char *soName = (*env)->GetStringUTFChars(env, jSoName, 0);
LOG_D("soName=%s", soName);
char line[1024] = "\n";
FILE *fp = NULL;
uintptr_t base_addr = 0;
uintptr_t addr = 0;
// 1. 查找自身對應的基址
if (NULL == (fp = fopen("/proc/self/maps", "r"))) return;
while (fgets(line, sizeof(line), fp)) {
if (NULL != strstr(line, soName) &&
sscanf(line, "%"PRIxPTR"-%*lx %*4s 00000000", &base_addr) == 1)
break;
}
fclose(fp);
LOG_D("base_addr=0x%08X", base_addr);
if (0 == base_addr) return;
//2. 基址+偏移=真實的地址
addr = base_addr + 0x2FE0;
LOG_D("addr=0x%08X", addr);
//注意:調整寫權限
mprotect((void *) PAGE_START(addr), PAGE_SIZE, PROT_READ | PROT_WRITE);
//替換目標地址
*(void **) addr = hook_fwrite;
//注意:清除指令緩存
__builtin___clear_cache((void *) PAGE_START(addr), (void *) PAGE_END(addr));
}
可以看到這裏已經成功完成了hook操作
看了上面的例子,大家覺得native-hook複雜嗎?看上去不復雜?那如果讓你來設計一個類似於xHook
的庫,你能直接在框架裏硬編碼0x2FE0
嗎?,當然不行,因此需要一個通用的邏輯來定位具體的偏移
和基址
才行,接下來我們重點來看下偏移
和基址
如何通過通用的代碼來動態確定
解析基址和偏移
我們接下來要做的重要的工作是在運行期間,動態定位目標共享庫中的基址
和偏移
。
這裏主要如下幾個步驟:
1、獲取目標so庫的基址
基址很好確定:
void *get_module_base(pid_t pid, const char *module_name) {
FILE *fp;
long addr = 0;
char filename[32] = "\n";
char line[1024] = "\n";
LOG_D("pid=%d ", pid);
if (pid < 0) {
snprintf(filename, sizeof(filename), "/proc/self/maps");
} else {
snprintf(filename, sizeof(filename), "/proc/%d/maps", pid);
}
// 獲取指定pid進程加載的內存模塊信息
fp = fopen(filename, "r");
while (fgets(line, sizeof(line), fp)) {
if (NULL != strstr(line, module_name) &&
sscanf(line, "%"PRIxPTR"-%*lx %*4s 00000000", &addr) == 1)
break;
}
fclose(fp);
return (void *) addr;
}
我們只需要讀取自身進程的/proc/self/maps
就可以獲取當前進程裝載的模塊信息,這個不算複雜。
2、保存原始的調用地址
當我們自己的共享庫完成對目標共享庫的hook操作
之後,要保證功能正常運行,需要先保存原始的函數調用地址。
3、解析ELF文件頭部
這裏先根據魔數
來確定是否爲ELF文件格式,而且文件頭部裏實際已經指明瞭SHT
和PHT
的偏移信息了
4、根據(基址 + e_phoff)確定程序頭表PHT(Program Header Table)的地址
上圖中的這個e_phoff
的值是指向程序頭表PHT
的偏移,0x34 = 52
5、遍歷程序頭表PHT(Program Header Table)
看上面的圖示,程序頭表PHT內的元素是個數組,但是我們目前只關心類型爲PT_DYNAMIC(指定動態鏈接信息)
的項,獲取對應的p_vaddr
6、根據(基址+p_vaddr
)確定.dynamic段
的地址,遍歷dynamic link table
接着遍歷出d_tag=DT_JMPREL
類型的項的d_val
值,這個值是指向重定位表
的偏移,不要疑惑下圖中的偏移是0x2E7C
,爲什麼下面Start
卻是0x1E7C
,剛纔也說了ELF文件有兩種視圖,一個鏈接視圖
,一個執行視圖,下面的圖是鏈接視圖
,但我們最終要以執行視圖裏的結果爲準。
7、根據(基址+d_val)確定重定位表的地址,接下來我們遍歷函數名稱對比即可找到目標函數的偏移
參考下面這張圖吧
也就是說上面的那麼多步驟,實際目的就是確定運行期間的目標共享庫中的重定位表
的地址。
實際應用
筆者只是藉助一個示例來理解基於PLT/GOT
進行hook操作
的原理,實際項目中,我們完全可以藉助這種方案對目標共享庫中的malloc
,free
進行hook操作
,在沒有源碼的情況下,以此來分析第三方共享庫中可能存在的內存泄露
問題。
具體可以看看:LoliProfiler
的實現。
思考
Q:比如我要hook
我當前應用中的malloc函數
,是否只對某個共享庫
進行hook即可?
A:並不是
,每一個共享庫
都有它自己的PLT/GOT表
,因此需要對每個共享庫
都要進行hook操作
才行。
Q:我在共享庫中通過dlopen、dlsym
的方式調用系統導入函數
,這中方式可以被hook住嗎?
A:不可以
,上面的整個內容其實都是基於PLT/GOT表
定位目標函數進行hook操作
,而dlopen、dlsym
是目標共享庫在運行期間,動態定位導入函數
,這種方式並不生效。
小結
其實hook操作
本身的技術原理並不複雜,但是要針對android平臺下
的共享庫
進行hook操作
,僅僅只瞭解hook操作
是不夠的,可以看到上面大部分的內容其實是在跟ELF文件周旋
,要結合它的加載、動態鏈接、重定位過程
,才能更好的理解基於PLT/GOT
的hook原理
,由於筆者能力有限,在部分細節的描述可能不全面或者會有偏差,歡迎指正!
項目地址
參考
《程序員的自我修養:鏈接、裝載與庫》
https://github.com/iqiyi/xHook/
https://www.cnblogs.com/goodhacker/p/9306997.html