简介
之前在 ELF中.got,.plt section的作用、lazy binding的实现及全局符号介入的影响 一文中提到:对于position independent code
,非static的函数调用是间接进行的,会先调用目标方法对应的一个 plt 跳板函数,然后跳板函数再跳转到对应 got 表项中真实目标函数的地址,实现目标方法的调用的。
备注:
- 对于调用当前库自己的非 static 函数,如果其 visibility 是 hidden 或者 protected 的话,是不会有上述间接流程的
- 上面没有提lazy binding的流程,因为 plt hook 跟他关系不大,在后面提
invoke original method
实现时会提一下
从上面的间接调用流程可知:如果我们把对应 got 表项中目标函数地址修改为我们的代理函数地址,那么后续的调用就会进入我们的代理函数,从而实现了hook。PLT-GOT hook 也就这么做的,下面我们看下具体实现。
ELF 加载地址
上面提到要hook某个库对某个函数的调用,就要修改它相应的got表项,修改got表项的前提是我们能找到它在内存中的地址,进而我们要先找到该库(ELF)在内存中的加载地址。
常见的查找 elf 文件加载地址的方法有:
- 借助
dl_iterate_phdr
- 解析
maps
通过dl_iterate_phdr查找elf加载地址
简单示例代码:
c++
static int dl_callback(dl_phdr_info* info, size_t size, void* data) {
auto arg = (std::pair<const char*, uint64_t>*)data;
string libName = arg->first;
string dlname = info->dlpi_name;
if (!std::equal(libName.rbegin(), libName.rend(), dlname.rbegin())) {
return 0;
}
if (dlname.size() > libName.size() && *(dlname.rbegin() + libName.size()) != '/') {
return 0;
}
arg->second = info->dlpi_addr;
return 0;
}
static uint64_t getLibLoadAddrWithDlIteratePhdr(const char* libName) {
std::pair<const char*, uint64_t> arg = {libName, 0};
dl_iterate_phdr(dl_callback, (void*)&arg);
return arg.second;
}
解析maps获取elf加载地址
elf 文件是通过 mmap
映射到内存中的,而 /proc/${pid}/maps
中包含了所有的内存映射信息,因此通过读取并解析 maps 就可以获取 elf 的加载地址。
先来看下 maps 的格式:
bash
address perms offset dev inode pathname
...
35b1800000-35b1820000 r-xp 00000000 08:02 135522 /usr/lib64/ld-2.15.so
35b1a1f000-35b1a20000 r--p 0001f000 08:02 135522 /usr/lib64/ld-2.15.so
35b1a20000-35b1a21000 rw-p 00020000 08:02 135522 /usr/lib64/ld-2.15.so
35b1c00000-35b1dac000 r-xp 00000000 08:02 135870 /usr/lib64/libc-2.15.so
35b1dac000-35b1fac000 ---p 001ac000 08:02 135870 /usr/lib64/libc-2.15.so
35b1fac000-35b1fb0000 r--p 001ac000 08:02 135870 /usr/lib64/libc-2.15.so
35b1fb0000-35b1fb2000 rw-p 001b0000 08:02 135870 /usr/lib64/libc-2.15.so
...
- 最后一列是被映射到内存的库的路径(如果是文件映射的话),通过字符串匹配可以筛选出要查找库的相关条目
- 每个库可能会有多个映射段,寻找到 offset 为 0 的那一段
- 第一列是该映射区段的起始-结束地址,起始地址即是该库的加载地址
备注: 上面提到"寻找 offset 为 0 的那一段",通常是能找到的,不过并不能保证。 通常 elf file header 会放到 第一个 loadable segment 中,因此通常 第一个 loadable segment 的file offset为0,那么mmap映射的时候,第一个map region的offset就是0,由此它的start address处就是elf file header在内存中的映射位置,方便我们解析。但是通过linker script我们可以将elf file header排除在 loadable segment之外,还可以对segment做更多调整,因此offset可能不为0。
解析ELF
上面我们拿到elf文件的加载地址后,就可以解析 elf file header 来找到 program header table的位置。然后遍历 program header table:
- 找到第一个 loadable (PT_LOAD)的segment,根据它的
p_vaddr
来计算出elf base virtual address:load_bias
,后续 elf 中各个部分在内存中的虚拟地址就需要通过load_bias
加上它们的p_vaddr
获得 - 找到
dynamic
(PT_DYNAMIC)segment,后续解析dynamic
segment 就可以知道动态符号表(.dynsym),动态字符串表(.dynstr),哈希表(.hash,.gnu.hash),重定向表等等重要信息
ELF base virtual address的计算
elf虚拟基地址的计算方法可以参考Android bionic linker加载segment的实现逻辑:ElfReader::ReserveAddressSpace & ElfReader::LoadSegments
不过更简单直接的方式是看它的注释:
c++
/**
However, in practice, segments do _not_ start at page boundaries. Since we
can only memory-map at page boundaries, this means that the bias is
computed as:
load_bias = phdr0_load_address - PAGE_START(phdr0->p_vaddr)
(NOTE: The value must be used as a 32-bit unsigned integer, to deal with
possible wrap around UINT32_MAX for possible large p_vaddr values).
And that the phdr0_load_address must start at a page boundary, with
the segment's real content starting at:
phdr0_load_address + PAGE_OFFSET(phdr0->p_vaddr)
Note that ELF requires the following condition to make the mmap()-ing work:
PAGE_OFFSET(phdr0->p_vaddr) == PAGE_OFFSET(phdr0->p_offset)
The load_bias must be added to any p_vaddr value read from the ELF file to
determine the corresponding memory address.
**/
这里明确给出了 load_bias
的计算方法:
c++
load_bias = phdr0_load_address - PAGE_START(phdr0->p_vaddr)
而且也指出后续 elf 中各个部分在内存中的虚拟地址需要通过 load_bias
加上它们的 p_vaddr
获得
解析dynamic segment
为了实现函数hook,我们就需要根据函数名找到对应的符号,因此就需要哈希表,动态字符串表,动态符号表。
为了能修改函数对应got项中的地址,就需要先找到其got项的位置,因此需要重定位表的信息。
而这些信息在 dynamic
segment 中都能获取到,解析后可以得到类似如下的结构:
c++
struct Dynamic {
// plt relocation section info (eg: .rela.plt)
ElfW(Addr) pltRelSecAddr;// DT_JMPREL
ElfW(Xword) pltRelSecSize;// DT_PLTRELSZ
// dynamic 其他部分的重定位信息 (eg: .rela.dyn)
ElfW(Addr) relSecAddr;// DT_RELA or DT_RELA,重定位表的地址
ElfW(Xword) relSecSize;// DT_RELASZ or DT_RELSZ,重定位表的大小
ElfW(Xword) relSecEntrySize;// DT_RELAENT or DT_RELENT,重定位表 表项大小
// plt 对应的 got section info (.got.plt)
ElfW(Addr) gotPltSecAddr;// DT_PLTGOT
// 动态字符串表信息 (.dynstr)
ElfW(Addr) dynstrSecAddr;// DT_STRTAB,动态字符串表地址
ElfW(Xword) dynstrSecSize;// DT_STRSZ,动态字符串表大小
// 动态符号表信息 (.dynsym)
ElfW(Addr) dynsymSecAddr;// DT_SYMTAB,动态符号表地址
ElfW(Xword) dynsymEntrySize;// DT_SYMENT,动态符号表 表项大小
// hash section addr
ElfW(Addr) hashSecAddr;
ElfW(Addr) gnuHashSecAddr;
bool isRela;
bool bindNow;
};
备注:此处忽略了 relr
类型的重定位表
查找函数符号
根据函数名,哈希表(.hash,.gnu.hash),动态符号表(.dynsym),动态字符串表(.dynstr)就可以查找到目标符号,具体查找方法可以参考:ELF 通过 Sysv Hash & Gnu Hash 查找符号的实现及对比
查找目标got项的位置
在 ELF中.got,.plt section的作用、lazy binding的实现及全局符号介入的影响 一文中提到:对于非 static 函数的调用,因为符号地址在编译时未知(外部库的符号地址未知很好理解,同一个库的非static符号地址未知是因为全局符号介入的影响),运行时由动态链接器将符号地址填入对应的got项中,这样函数调用的时候就能从got项中找到目标函数的地址以跳入执行。
那么动态链接器是怎么知道某个符号对应的got项在哪儿呢?因为有重定位项信息,我们来看个具体例子:
sql
Symbol table '.dynsym' contains 9 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
...
5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND malloc@GLIBC_2.17 (2)
...
sql
Relocation section '.rela.plt' at offset 0x440 contains 3 entries:
Offset Info Type Symbol's Value Symbol's Name + Addend
...
0000000000020008 0000000500000402 R_AARCH64_JUMP_SLOT 0000000000000000 malloc@GLIBC_2.17 + 0
...
对于aarch64架构,plt 重定位项的类型一定是:R_AARCH64_JUMP_SLOT
,其info
中有对应符号的索引,从上面的例子来看 symbol index = ELF64_R_SYM(0x0000000500000402) 正好是 5,跟上面动态符号表中的一致,而其 offset + load_bios
正是目标 got 项的位置。
一个简单实现例子:
c++
ElfW(Addr) Elf::findSymbolRelAddr(uint32_t symbolIdx) const {
auto start = (const ElfW(Rela)*)dynamic_.pltRelSecAddr;// rela for example
auto end = start + dynamic_.pltRelSecSize;
for (; start < end; ++start) {
if (ELF64_R_SYM(start->r_info) == symbolIdx) {
assert(ELF64_R_TYPE(start->r_info) == R_AARCH64_JUMP_SLOT);
return loadBias_ + start->r_offset;
}
}
return 0;
}
实现hook
写权限
上面已经找到目标 got 项的位置,因此我们向其中写入代理函数的地址,hook就完成了。
对于延迟绑定的情况来说,一般 .got.plt
所在内存确实是有写权限的,因为随着代码的执行,可能不断有函数第一次被调用,需要动态链接器去查找并写入其地址。但是如果是加载时立即绑定的情况(Android arm架构就是非延迟绑定的情况),链接器在加载动态库时就会完成所有符号的绑定,因此通常会将 .got.plt
设置为只读模式。
因此在写入代理函数地址之前,需要先判断下是否有写权限,这个可以通过读 maps 来得知。如果没有写权限的话,可以通过 mprotect
先赋予写权限:
c++
if (mprotect((void*)PAGE_START(symbolRelAddr), PAGE_SIZE, m->perms() | PROT_WRITE) != 0) {
// todo handle error
}
原函数地址
通常在代理函数里面需要调用原函数,hook 方法通常需要返回原函数的地址。那么如何获取原函数的地址呢?
- 如果是立即绑定的case,那么对应got项中的值就是原函数的地址,在写入代理函数地址前先将其读出即可
- 如果是延迟绑定的case,hook前原函数可能还没有调用过,此时got项中保存的是plt中的跳板函数地址,以跳入动态链接器的符号查找函数,这种情况可以通过
dlsym
来查找原函数地址
那么如何判断是立即绑定还是延迟绑定呢?(Android arm架构其实不用判断,都是立即绑定的)
- 如果动态库
.dynamic
section中存在DT_BIND_NOW
项,那么会立即绑定 - 如果动态库
.dynamic
section中DT_FLAGS
value设置了DT_BIND_NOW
或者DT_FLAGS_1
value设置了DF_1_NOW
的话,会立即绑定 - 如果运行时环境变量包含
LD_BIND_NOW
,会立即绑定
调用原函数
有了原函数地址后,调用原函数就简单了:将其cast成原函数类型直接调用就行。也可以定义一个简单的宏来简化使用,比如:
c++
#define INVOKE_ORIGINAL(func, addr, ...) (((decltype(func)*)addr)(__VA_ARGS__))
void* my_malloc(size_t size) {
LOGI("my_malloc invoked with size: %zu", size);
void* ptr = INVOKE_ORIGINAL(my_malloc, malloc_fun_addr, size);
LOGI("malloc res: %p", ptr);
return ptr;
}
清除指令缓存
CPU有指令缓存,我们修改got项后还需要清除一下,使得CPU重新取指:
c++
__builtin___clear_cache((char*) PAGE_START(symbolRelAddr), (char*) PAGE_END(symbolRelAddr));
Hook 所有库中某个函数的调用
有些情况下需要hook所有库中对某个函数的调用,这个时候有个麻烦的地方:有些库是在 hook 方法调用之后才被加载的,那怎么能hook到它呢?一种方法是我们在内部hook所有库的 dlopen
方法,这样后续加载库的时候我们hook框架能立马感知到,然后对新加载的库进行hook。