常用的hook方式主要有导入表hook、导出表hook和inline hook三种。
一,导入表hook
首先需要了解延时重定位的过程,下面这张图很好的解释了整个过程
即程序调用目标函数时,函数地址才会被运行时函数解析出来写到got表中。
导入表hook就是基于修改got实现对目标函数hook的,同时导入表hook也是基于ptrace注入的,前面在总结注入的时候有个案例,将包含hello函数的so注入到target进程中,这里需要将包含修改got表的函数注入到target中;
got表的修改,原理很简单,主要是got表定位的过程有很多方法,可以根据自己喜好,通过dlopen函数打开so文件,返回的是
solist
信息,这里可以直接解析出so文件的section中的字符串表的索引,然后通过对比section的名字去确定got位置,因为我们注入的时机已经加载过目标函数了,所以目标函数的地址是已经重定位过的,可以直接调用目标函数从而获取目标函数在内存中的地址,然后去got表中对比,命中后直接修改即可。
这里有个想法没有实际验证过,就是如果我们注入的实际更早一些,got表没有进行重定位,我们可以通过动态加载段去找到动态字符串和hash表,然后计算出目标函数在got表中的地址,然后直接修改该地址应该也是可以的。
这样的hook原理实际上是有很大局限性的:
1,比如,目标函数不在目标进程中的时候,目标进程通过dlopen去打开其他so,然后dlsym调用目标函数的时候,我们对目标进程的got表修改是无效的;
2,对于so内部自定义的函数也是无法修改的;
3,修改got表后影响的是整个目标进程,无法精确到hook某次调用;
二,导出表hook
符号表中每个符号的结构如下:
typedef struct elf32_sym{
Elf32_Word st_name;
Elf32_Addr st_value;
Elf32_Word st_size;
unsigned char st_info;
unsigned char st_other;
Elf32_Half st_shndx;
} Elf32_Sym;
其中的st_info中包含type字段,用STB_GLOBAL、STB_LOCAL和STB_WEAK等字段来标识是全局符号还是本地符号。
然后来看下linker加载so的过程,http://androidxref.com/4.4.4_r1/xref/bionic/linker/linker.cpp
源码中可以看到,linker将so加载到内存之后,会符号进行重定位,通过检测符号中的st_info字段判断外部符号还是本地符号,如果是外部符号就会去解析
NEEDED
获取外部符号的地址,
if (reloc == sym_addr) {
Elf32_Sym *src = soinfo_do_lookup(NULL, sym_name, &lsi, needed);
返回Elf32_Sym
,从这个Elf32_Sym
中的st_value
字段得到函数的虚地址。
typedef struct {
Elf32_Word st_name; /* Symbol name (.strtab index) */
Elf32_Word st_value; /* value of symbol */
Elf32_Word st_size; /* size of symbol */
Elf_Byte st_info; /* type / binding attrs */
Elf_Byte st_other; /* unused */
Elf32_Half st_shndx; /* section index of symbol */
} Elf32_Sym;
那么修改NEEDED
中的这个符号 st_value 字段,即可实现导出表 HOOK。
流程如下:以 libc.so 中的 unlink 函数为例
- 注入 zygote 进程;
dlopen libc.so
,找到unlink
符号;- 解析此符号,得到其
st_value
地址;- 修改此地址的值为:
NewFunc – BaseAddr(libc.so 加载的基地址)
。
第四步中之所以要改成偏移地址而不是绝对地址是是因为st_value
保存的本身就是是偏移地址;
导出表 HOOK的局限性就在于只能HOOK导出的符号。
三,inline hook
inline hook的原理是在在汇编指令层做修改,下面这两张图很好的解释了具体操作,其中第二张更详细一些:
一,汇编指令构造:
说到跳转指令我们首先想到的是B
指令,但是在32位arm中,地址占4字节,B
指令跳转范围是有限的,因此我们还可以使用LDR
指令将立即数赋值给pc寄存器的方式:
其中修改后的第二条指令是目标函数的地址,这是是默认识别成了指令,实际并没有这条汇编指令,
修改后的第一条指令ldr是其实原指令应该是
ldr pc,[pc,#-4]
这样就看出了ldr指令寻址其实是通过第一个寄存器参数的偏移来寻址的,并不是直接跳转到某地址,这就解释了为什么mov、b、bl、ldr等指令寻址是有范围的了,这里为什么是#-4
而不是```#4``,这与汇编指令的三级流水线相关,执行第一条指令的时候实际上pc指向的第三条指令,所以是减4而不是加4。
在构造汇编指令时可以使用https://armconverter.com/将汇编代码转化成16进制,https://onlinedisassembler.com/odaweb/将16进制转换成汇编代码。
实现代码为:
// 设置bit[0]的值为1
#define SET_BIT0(addr) (addr | 1)
// 设置bit[0]的值为0
#define CLEAR_BIT0(addr) (addr & 0xFFFFFFFE)
// 测试bit[0]的值,若为1则返回真,若为0则返回假
#define TEST_BIT0(addr) (addr & 1)
if (TEST_BIT0(item->target_addr)) {
int i;
i = 0;
if (CLEAR_BIT0(item->target_addr) % 4 != 0) {
((uint16_t *) CLEAR_BIT0(item->target_addr))[i++] = 0xBF00; // NOP
}
((uint16_t *) CLEAR_BIT0(item->target_addr))[i++] = 0xF8DF;
((uint16_t *) CLEAR_BIT0(item->target_addr))[i++] = 0xF000; // LDR.W PC, [PC]
((uint16_t *) CLEAR_BIT0(item->target_addr))[i++] = item->new_addr & 0xFFFF;
((uint16_t *) CLEAR_BIT0(item->target_addr))[i++] = item->new_addr >> 16;
}
二,inst1指令修正
上面再构造跳转指令的时候覆盖了两条指令,所以在目标函数运行结束后还是需要恢复的,但是,如果要恢复的这两条指令是包含PC寄存器的,那么需要注意,此时的pc寄存器的值与原环境中的pc寄存器的值是不一样的,需要针对不同的指令做不同的修复。
首先,对涉及pc寄存器操作的指令进行分类:
static int getTypeInArm(uint32_t instruction)
{
if ((instruction & 0xFE000000) == 0xFA000000) {
return BLX_ARM;
}
if ((instruction & 0xF000000) == 0xB000000) {
return BL_ARM;
}
if ((instruction & 0xF000000) == 0xA000000) {
return B_ARM;
}
if ((instruction & 0xFF000FF) == 0x120001F) {
return BX_ARM;
}
if ((instruction & 0xFEF0010) == 0x8F0000) {
return ADD_ARM;
}
if ((instruction & 0xFFF0000) == 0x28F0000) {
return ADR1_ARM;
}
if ((instruction & 0xFFF0000) == 0x24F0000) {
return ADR2_ARM;
}
if ((instruction & 0xE5F0000) == 0x41F0000) {
return LDR_ARM;
}
if ((instruction & 0xFE00FFF) == 0x1A0000F) {
return MOV_ARM;
}
return UNDEFINE;
}
1, 若是B系列指令,包括b、bx、bl、blx
等指令,
if (type == BLX_ARM || type == BL_ARM || type == B_ARM || type == BX_ARM) {
uint32_t x;
int top_bit;
uint32_t imm32;
uint32_t value;
if (type == BLX_ARM || type == BL_ARM) {
trampoline_instructions[trampoline_pos++] = 0xE28FE004; // ADD LR, PC, #4
}
trampoline_instructions[trampoline_pos++] = 0xE51FF004; // LDR PC, [PC, #-4]
if (type == BLX_ARM) {
x = ((instruction & 0xFFFFFF) << 2) | ((instruction & 0x1000000) >> 23);
}
else if (type == BL_ARM || type == B_ARM) {
x = (instruction & 0xFFFFFF) << 2;
}
else {
x = 0;
}
top_bit = x >> 25;
imm32 = top_bit ? (x | (0xFFFFFFFF << 26)) : x;
if (type == BLX_ARM) {
value = pc + imm32 + 1;
}
else {
value = pc + imm32;
}
trampoline_instructions[trampoline_pos++] = value;
}
2,若是LDR、ADR、MOV等指令,同样首先解析指令,得到value
,然后用于构造修复指令,代码如下:
else if (type == ADD_ARM) {
int rd;
int rm;
int r;
rd = (instruction & 0xF000) >> 12;
rm = instruction & 0xF;
for (r = 12; ; --r) {
if (r != rd && r != rm) {
break;
}
}
trampoline_instructions[trampoline_pos++] = 0xE52D0004 | (r << 12); // PUSH {Rr}
trampoline_instructions[trampoline_pos++] = 0xE59F0008 | (r << 12); // LDR Rr, [PC, #8]
trampoline_instructions[trampoline_pos++] = (instruction & 0xFFF0FFFF) | (r << 16);
trampoline_instructions[trampoline_pos++] = 0xE49D0004 | (r << 12); // POP {Rr}
trampoline_instructions[trampoline_pos++] = 0xE28FF000; // ADD PC, PC
trampoline_instructions[trampoline_pos++] = pc;
}
else if (type == ADR1_ARM || type == ADR2_ARM || type == LDR_ARM || type == MOV_ARM) {
int r;
uint32_t value;
r = (instruction & 0xF000) >> 12;
if (type == ADR1_ARM || type == ADR2_ARM || type == LDR_ARM) {
uint32_t imm32;
imm32 = instruction & 0xFFF;
if (type == ADR1_ARM) {
value = pc + imm32;
}
else if (type == ADR2_ARM) {
value = pc - imm32;
}
else if (type == LDR_ARM) {
int is_add;
is_add = (instruction & 0x800000) >> 23;
if (is_add) {
value = ((uint32_t *) (pc + imm32))[0];
}
else {
value = ((uint32_t *) (pc - imm32))[0];
}
}
}
else {
value = pc;
}
trampoline_instructions[trampoline_pos++] = 0xE51F0000 | (r << 12); // LDR Rr, [PC]
trampoline_instructions[trampoline_pos++] = 0xE28FF000; // ADD PC, PC
trampoline_instructions[trampoline_pos++] = value;
}
3,理论上其他指令也可能用到pc寄存器操作,但实际中没有,这类我们不做考虑,故默认其他的指令都不涉及pc寄存器操作,直接将原指令写入即可,但是如果遇到bug分析的时候不要忘了这一点。
4,上面讲的是ARM指令环境下的hook,但是系统中还会遇到thumb指令,对于thumb指令的构造和修复,原理与arm相同,只是有些地方需要注意:
- Thumb 模式并没有能表跳转任意地址的指令,只能切换到 ARM 状态再进行跳转。
- 为了尽可能的 减少替换的指令数,状态切换应尽快,这里采用
BX PC
。- ARM 指令是 4 字节对齐,
BX PC
状态切换时,必须保证跳转到的地址为4字节对齐。- 由于 T2 指令占 4 字节,如果被替换指令的最后一条为T2指令,且T2指令的前2字节处于被替换指令中,而后2字节未处于其中时,也是需要将后2字节归入被替换的指令中作为一个整体。
有了上面的分析,指令的替换流程如下:
- 判定其实地址是否 4 字节对齐,如果不为 4 字节对齐,则
BX PC
之前构造 NOP 指令 - 由于预取 2 条指令,
BX PC
之后2字节填充 NOP - 构造
ARM LDR
指令,占4字节。其后4字节存放跳转绝对地址 - 判定被替换指令最后2字节是否为T2指令的前2两字节,如果是,则还需把之后2字节加入替换指令中,后2字节用
Thumb NOP
填充。
参考:
项目源码:Ele7enxxh大神的源码,github收藏地址
TK大神的《SO Hook 技术汇总》
游戏安全实验室的Android平台inline hook实现