hook的妙用

hook是一个非常有用的黑魔法

协程基于它的最大应用之一

我总结了一下hook的原理,和我遇到的hook场景

hook原理

我第一次看到hook这个词是在破解论坛上,大致意思是将指定函数替换成自己的,然后再去执行这个指定函数

从而偷梁换柱,在原有的函数上添加自己想要的功能

从设计模式上说倒是和装饰器模式非常相似

hook的核心就是让系统放弃原有代码,转而运行你的代码

破解为了达到hook的目的有非常多的手段,然而对开发者来说,通过动态库机制会简单很多

因此对开发者来说理解hook原理,就是理解动态链接原理

动态链接原理

可执行文件的生成分两步:

  • 编译:对每个编译单元(C/C++文件)进行编译后,就会生成可重定位文件

  • 链接:对所有可重定位文件链接以后,就会生成可执行文件

这里就带来了一个问题,为什么要分两步?只有编译步骤会有什么问题?

编译将编译单位翻译成各种机器能看懂的符号,由于编译时当前编译单元引用另外一个编译单元的符号时,无法确定这个符号的地址

又因为编译是并行执行的,因此需要单独一个串行步骤:链接,去解决跨编译单元的相互引用问题,这个步骤在读取了全部符号后,将无法确定的符号地址确定后改写,也就是符号重定位

符号重定位可以分为

  • 链接时符号重定位
    • 应用于多个.o文件(跨编译单元链接)或者是.a文件(静态链接)之间的符号重定位
    • 静态链接本质上将所有符号全拷贝到可执行文件中去,浪费磁盘和内存
  • 加载时符号重定位(动态链接)
    • 普通模式
      • 将静态链接时的符号重定位放到加载时来做,由于需要修改动态库符号的地址,因此动态库无法再重用,在加载时,每个进程在内存中只能对动态库的每个符号都拷贝一遍
      • 相对静态链接而言,只节省了磁盘,依然浪费了更为宝贵的内存
    • 地址无关代码(鼎鼎大名的PIC,position independent code
      • 配合延迟绑定(lazy binding)可以极大的节省内存空间

通过简单的实验来理解地址无关代码的动态链接过程

实验

实验源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int extern_v = 1;
static int static_v = 2;

void test_extern_v() {
extern_v = 3;
}
void test_static_v() {
static_v = 4;
}
void func() {
}
void test_func() {
func();
}

编译成动态库

gcc -m32 -fPIC -shared hello.c -o libhello.so

然后就可以查看汇编代码了

objdump -S libhello.so

static的符号访问

static的变量或者函数,由于static限制了他的作用域不能跨文件,因此在编译期间就可以确定他的地址

关注实验代码的static的变量访问部分

1
2
3
4
5
static int static_v = 2;

void test_static_v() {
static_v = 4;
}

以下是汇编代码

1
2
3
4
5
6
7
8
9
10
11
~ objdump -S libhello.so
0000058c <test_static_v>:
58c: 55 push %ebp
58d: 89 e5 mov %esp,%ebp
58f: e8 41 00 00 00 call 5d5 <__x86.get_pc_thunk.ax>
594: 05 6c 1a 00 00 add $0x1a6c,%eax
599: c7 80 18 00 00 00 04 movl $0x4,0x18(%eax)
5a0: 00 00 00
5a3: 90 nop
5a4: 5d pop %ebp
5a5: c3

这里看到执行static_v的赋值操作前,先调用了__x86.get_pc_thunk.ax,这是做什么的?

__x86.get_pc_thunk.ax

1
2
3
000005d5 <__x86.get_pc_thunk.ax>:
5d5: 8b 04 24 mov (%esp),%eax
5d8: c3 ret

这里是因为x86不允许相对地址调用(x64不存在这个限制),为了获取当前的绝对地址,需要利用函数调用时压栈的小特性

1
2
3
4
5
call foo 指令相当于
push %eip
mov foo %eip
ret 指令相当于
pop %eip

调用时,会把调用者的下一条指令的地址(当前的eip)压栈保存,所以进入get_pc_thunk.xx时,栈顶esp就是调用者的下一条指令地址。

因此mov (%esp),%eax将栈顶的内容写入寄存器eax,从而得到返回后下一条指令的地址(0x594)

在get_pc_thunk返回后对寄存器eax的内容add了0x1a6c,随后对寄存器eax+0x18偏移量的地址进行访问,也就是0x1a6c+0x594+0x18 = 0x2018

那么地址0x2018是什么呢

1
2
~ objdump -t libhello.so
00002018 l O .data 00000004 static_v

可以看到,在data区正是static_v变量

非static的符号访问

某个编译单元由于引用的符号可能位于某个动态库中,只有在运行期间才能获取到符号地址,因此引入了got(global offset table)表,got表在加载时,由动态链接器进行初始化写入需要的符号地址

然而即使是同一个编译单元内的符号,在做地址无关代码的动态库编译后,也会使用got表来进行访问,我认为这主要是为了实现插件式的动态链接需求,对跨模块的同名符号来说,先进入全局符号表的总是会覆盖后进入的,具体的说:

  • 当可执行文件和动态库的符号同名时,可执行文件的符号会覆盖动态库符号
  • 当动态库之间的符号同名时,覆盖顺序取决于链接时的动态库链接顺序(-la -lb,则a覆盖b)

关注实验代码的非static的变量访问部分

1
2
3
4
5
int extern_v = 1;

void test_extern_v() {
extern_v = 3;
}

以下是汇编代码

1
2
3
4
5
6
7
8
9
10
11
~ objdump -S libhello.so
00000570 <test_extern_v>:
570: 55 push %ebp
571: 89 e5 mov %esp,%ebp
573: e8 5d 00 00 00 call 5d5 <__x86.get_pc_thunk.ax>
578: 05 88 1a 00 00 add $0x1a88,%eax
57d: 8b 80 ec ff ff ff mov -0x14(%eax),%eax
583: c7 00 03 00 00 00 movl $0x3,(%eax)
589: 90 nop
58a: 5d pop %ebp
58b: c3 ret

可以看到,比起static的符号访问,在add和movl中间多了一步mov -0x14(%eax),%eax

0x1a88+0x578-0x14 = 0x1fec

这个地址位于got表中

1
2
3
4
~ objdump -s libhello.so
Contents of section .got:
1fe8 00000000 00000000 00000000 00000000 ................
1ff8 00000000 00000000

这个段全部是0,加载到内存时,动态链接器通过重定位段的信息对其重定位来写入到got表

重定位段.rel.dyn

使用objdump -s libhello.so观察重定位段,可以看到如下信息

1
2
3
4
5
6
Contents of section .rel.dyn:
0390 fc1e0000 08000000 001f0000 08000000 ................
03a0 10200000 08000000 e81f0000 06010000 . ..............
03b0 ec1f0000 06060000 f01f0000 06020000 ................
03c0 f41f0000 06030000 f81f0000 06040000 ................
03d0 fc1f0000 06050000

这一段数据对应Elf32_Rel结构体

1
2
3
4
5
6
7
8
9
#define ELF32_R_SYM(i)    ((i)>>8)  // 获得高24位,表示在符号表中的偏移 R_SYMBOL
#define ELF32_R_TYPE(i) ((unsigned char)(i)) //获得低8位,表示重定位类型
#define ELF32_R_INFO(s,t) (((s)<<8)+(unsigned char)(t)) //通过R_SYM和Type重组info

typedef struct
{
Elf32_Addr r_offset; /* Address */
Elf32_Word r_info; /* Relocation type and symbol index */
} Elf32_Rel;

其中03b0地址的数据ec1f0000 06060000,从大端序看,前4字节r_offset正是0x1fec,接着3字节(060)代表.dynsym的下标,最后1字节(6)是类型

接着看.dynsym

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Contents of section .dynsym:
0184 00000000 00000000 00000000 00000000 ................
0194 1c000000 00000000 00000000 20000000 ............ ...
01a4 52000000 00000000 00000000 22000000 R..........."...
01b4 01000000 00000000 00000000 20000000 ............ ...
01c4 61000000 00000000 00000000 20000000 a........... ...
01d4 38000000 00000000 00000000 20000000 8........... ...
01e4 7a000000 14200000 04000000 11001600 z.... ..........
01f4 a5000000 1c200000 00000000 10001600 ..... ..........
0204 b8000000 20200000 00000000 10001700 .... ..........
0214 75000000 70050000 1c000000 12000c00 u...p...........
0224 91000000 b6050000 1f000000 12000c00 ................
0234 ac000000 1c200000 00000000 10001700 ..... ..........
0244 10000000 e0030000 00000000 12000900 ................
0254 16000000 dc050000 00000000 12000d00 ................
0264 83000000 8c050000 1a000000 12000c00 ................
0274 96000000 a6050000 10000000 12000c00 ................

这一段数据对应Elf32_Sym结构体

1
2
3
4
5
6
7
8
9
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 under glibc>=2.2 */
Elf32_Section st_shndx; /* Section index */
} Elf32_Sym;

因此ec1f0000对应.dynsym第7行7a000000 14200000

st_value是这个符号的初始值,也就是0x2014

st_name的值为0x7a,这是他在.dynstr段的偏移量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Contents of section .dynstr:
0284 005f5f67 6d6f6e5f 73746172 745f5f00 .__gmon_start__.
0294 5f696e69 74005f66 696e6900 5f49544d _init._fini._ITM
02a4 5f646572 65676973 74657254 4d436c6f _deregisterTMClo
02b4 6e655461 626c6500 5f49544d 5f726567 neTable._ITM_reg
02c4 69737465 72544d43 6c6f6e65 5461626c isterTMCloneTabl
02d4 65005f5f 6378615f 66696e61 6c697a65 e.__cxa_finalize
02e4 005f4a76 5f526567 69737465 72436c61 ._Jv_RegisterCla
02f4 73736573 00746573 745f6578 7465726e sses.test_extern
0304 5f760074 6573745f 73746174 69635f76 _v.test_static_v
0314 00746573 745f6675 6e63006c 6962632e .test_func.libc.
0324 736f2e36 005f6564 61746100 5f5f6273 so.6._edata.__bs
0334 735f7374 61727400 5f656e64 00474c49 s_start._end.GLI
0344 42435f32 2e312e33 00 BC_2.1.3.

因此0x7a对应.dynstr第8行的第10个字符开始直到0,也就是extern_v

(题外话,这里可以看到extern_v是字符串test_extern_v的一部分,也是非常巧妙的设计了)

当然用readelf也可以很方便的看重定位信息

1
2
3
4
5
6
~ readelf -r libhello.so
Relocation section '.rel.dyn' at offset 0x390 contains 9 entries:
Offset Info Type Sym.Value Sym. Name
...
00001fec 00000606 R_386_GLOB_DAT 00002014 extern_v
...

同模块访问的运行时got表内容

来看一下运行时的got表内容

首先看下操作系统给进程分配的虚拟地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
~ cat /proc/16221/maps
08048000-08049000 r-xp 00000000 00:2c 501122906 /root/test/main
08049000-0804a000 r--p 00000000 00:2c 501122906 /root/test/main
0804a000-0804b000 rw-p 00001000 00:2c 501122906 /root/test/main
f7e11000-f7e12000 rw-p 00000000 00:00 0
f7e12000-f7fbf000 r-xp 00000000 08:04 73940234 /lib32/libc-2.23.so
f7fbf000-f7fc0000 ---p 001ad000 08:04 73940234 /lib32/libc-2.23.so
f7fc0000-f7fc2000 r--p 001ad000 08:04 73940234 /lib32/libc-2.23.so
f7fc2000-f7fc3000 rw-p 001af000 08:04 73940234 /lib32/libc-2.23.so
f7fc3000-f7fc6000 rw-p 00000000 00:00 0
f7fd1000-f7fd2000 r-xp 00000000 00:2c 501122312 /root/test/libhello.so
f7fd2000-f7fd3000 r--p 00000000 00:2c 501122312 /root/test/libhello.so
f7fd3000-f7fd4000 rw-p 00001000 00:2c 501122312 /root/test/libhello.so
f7fd4000-f7fd5000 rw-p 00000000 00:00 0
f7fd5000-f7fd7000 r--p 00000000 00:00 0 [vvar]
f7fd7000-f7fd9000 r-xp 00000000 00:00 0 [vdso]
f7fd9000-f7ffc000 r-xp 00000000 08:04 73940220 /lib32/ld-2.23.so
f7ffc000-f7ffd000 r--p 00022000 08:04 73940220 /lib32/ld-2.23.so
f7ffd000-f7ffe000 rw-p 00023000 08:04 73940220 /lib32/ld-2.23.so
fffdd000-ffffe000 rw-p 00000000 00:00 0 [stack]

虚拟地址加上偏移量后得到

f7fd1000+00001fec=0xF7FD2FEC

看下这个地址的内容

1
2
(gdb) x 0xF7FD2FEC
0xf7fd2fec: 0xf7fd3014

将0xf7fd3014去掉虚拟地址转换为偏移量

0xf7fd3014-f7fd1000=2014

这个地址正是在刚才重定位段.dynsym看到的初始值

跨模块访问符号覆盖下的运行时got表内容

got访问跨模块,可以分成几种情形

  • extern访问跨模块符号
  • 相同符号覆盖
    • 动态库和可执行文件覆盖
    • 动态库之间覆盖

实际原理都一样,这里用相同符号覆盖中的动态库和可执行文件覆盖来举例

写一个可执行文件来使用刚才编译的动态库

1
2
3
4
5
6
7
8
9
10
11
12
extern void test_extern_v();

int extern_v = 0;

#include <stdio.h>

int main() {
printf("%d\n", extern_v);
test_extern_v();
printf("%d\n", extern_v);
return 0;
}

然后编译执行

1
2
3
4
~ gcc -m32 main.c -lhello -L. -o main
~ LD_LIBRARY_PATH=./ ./main
0
3

可以看到,extern_v被初始化成了可执行文件中的0,而不再是动态库中的1了

此时的got表地址内容

1
2
(gdb) x 0xF7FD2FEC
0xf7fd2fec: 0x0804a024

看一下main的符号表

1
2
3
4
5
6
~ readelf -s main
Symbol table '.dynsym' contains 15 entries:
Num: Value Size Type Bind Vis Ndx Name
...
8: 0804a024 4 OBJECT GLOBAL DEFAULT 26 extern_v
...

动态库got表内容对应的正式可执行文件的符号,而不再是动态库自己的符号了

延迟绑定

针对非static的函数符号来说,专门做了一个延迟绑定的优化

使得只有函数被用到时才会进行got表重定位,不再是动态链接器初始化时就进行重定位了

这可以大大节省内存开销,这里没有对变量进行延迟绑定优化,大概是因为非static全局变量的符号数量大大少于非static函数符号吧

关注实验代码中关于函数调用的部分

1
2
3
4
5
void func() {
}
void test_func() {
func();
}

以下是汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
~ objdump -S libhello.so
00000410 <func@plt-0x10>:
410: ff b3 04 00 00 00 pushl 0x4(%ebx)
416: ff a3 08 00 00 00 jmp *0x8(%ebx)
41c: 00 00 add %al,(%eax)
...

00000420 <func@plt>:
420: ff a3 0c 00 00 00 jmp *0xc(%ebx)
426: 68 00 00 00 00 push $0x0
42b: e9 e0 ff ff ff jmp 410 <_init+0x30>

000005a6 <func>:
5a6: 55 push %ebp
5a7: 89 e5 mov %esp,%ebp
5a9: e8 27 00 00 00 call 5d5 <__x86.get_pc_thunk.ax>
5ae: 05 52 1a 00 00 add $0x1a52,%eax
5b3: 90 nop
5b4: 5d pop %ebp
5b5: c3 ret

000005b6 <test_func>:
5b6: 55 push %ebp
5b7: 89 e5 mov %esp,%ebp
5b9: 53 push %ebx
5ba: 83 ec 04 sub $0x4,%esp
5bd: e8 13 00 00 00 call 5d5 <__x86.get_pc_thunk.ax>
5c2: 05 3e 1a 00 00 add $0x1a3e,%eax
5c7: 89 c3 mov %eax,%ebx
5c9: e8 52 fe ff ff call 420 <func@plt>
5ce: 90 nop
5cf: 83 c4 04 add $0x4,%esp
5d2: 5b pop %ebx
5d3: 5d pop %ebp
5d4: c3 ret

可以看到test_func没有直接访问func,而是访问的func@plt

func@plt跳转到了寄存器%ebx中的地址

这个地址的内容是0x1a3e+5c2+0xc=200c

变量符号位于.got段中,而延迟绑定的函数符号位于.got.plt段中

1
2
3
~ objdump -s libhello.so
Contents of section .got.plt:
2000 081f0000 00000000 00000000 26040000 ............&...
延迟绑定的重定位段.rel.plt

逻辑和重定位段.rel.dyn是一样的,因此只列命令不多解释了

1
2
3
~ objdump -S libhello.so
Contents of section .rel.plt:
03d8 0c200000 070f0000

其中03d8地址的数据0c200000 070f0000,从大端序看,前4字节r_offset正是0x200c,接着3字节(0f0)代表.dynsym的下标,最后1字节(7)是类型

接着看.dynsym

1
2
Contents of section .dynsym:
0274 96000000 a6050000 10000000 12000c00

因此0x200c对应.dynsym第16行96000000 a6050000

st_value是这个符号的初始值,也就是0x5a6

st_name的值为0x96,这是他在.dynstr段的偏移量

因此0x96对应.dynstr第10行的第6个字符开始直到0,也就是func

1
2
Contents of section .dynstr:
0314 00746573 745f6675 6e63006c 6962632e .test_func.libc.

使用readelf查看

1
2
3
4
~ readelf -r libhello.so
Relocation section '.rel.plt' at offset 0x3d8 contains 1 entries:
Offset Info Type Sym.Value Sym. Name
0000200c 00000f07 R_386_JUMP_SLOT 000005a6 func
同模块访问的运行时got表内容

同样看下运行时got内容,在test_func上断点,然后查看got表内容

将got地址200c加上libhello.so的虚拟地址得到

f7fd1000+200c=F7FD300C

1
2
3
4
5
6
7
8
(gdb) b test_func
Breakpoint 1 at 0x8048480

(gdb) r
Breakpoint 1, 0xf7fd1573 in test_func () from ./libhello.so

(gdb) x 0xF7FD300C
0xf7fd300c: 0xf7fd1426

可以看到,在执行test_func前,他的值指向0xf7fd1426,去掉libhello.so的虚拟地址得到426

正是func@plt的第二句

1
2
3
4
00000420 <func@plt>:
420: ff a3 0c 00 00 00 jmp *0xc(%ebx)
426: 68 00 00 00 00 push $0x0
42b: e9 e0 ff ff ff jmp 410 <_init+0x30>

因此这里相当于什么也没有做,继续执行42b,执行完42b后,got地址的内容改变了

1
2
(gdb) x 0xF7FD300C
0xf7fd300c: 0xf7fd15a6

5a6正是func符号的地址

42b做的显然就是为函数符号延迟绑定got表内容,深挖的话会发现这里执行了_dl_runtime_resolve(link_map_obj, reloc_arg)函数

这个函数的第一个参数是动态库的模块id,通过该参数在.dynamic取到.dynstr, .dynsym, .rel.plt的地址

第二个参数是语句426中push进去的在重定位表(.rel.plt)中的偏移量(0x0)

从而获取在到.dynsym的信息,然后获取到在.dynstr中的信息(func),得到了符号字符串

此时可以在全局符号表中找到对应的符号地址(5a6),填入记录在.rel.plt中对应的got表(200c)中,并调用解析的函数

运行时动态链接过程

got访问跨模块时,相同符号覆盖的规则显得有点难以理解,这是由运行时动态链接决定的,因此理解其过程非常重要了

在bash执行进程时,首先进行fork,然后是exec,其中调用load_elf_binary,就可以开始正式的进行动态链接了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
static int load_elf_binary(struct linux_binprm *bprm)
{
// 遍历elf的程序头
for (i = 0; i < loc->elf_ex.e_phnum; i++) {

// 如果存在解释器头部
if (elf_ppnt->p_type == PT_INTERP) {
//
// 读入解释器名
retval = kernel_read(bprm->file, elf_ppnt->p_offset,
elf_interpreter,
elf_ppnt->p_filesz);
...
}
}

// 为进程分配用户态堆栈,并塞入参数和环境变量
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
executable_stack);
current->mm->start_stack = bprm->p;

// 将elf文件映射进内存
for(i = 0, elf_ppnt = elf_phdata;
i < loc->elf_ex.e_phnum; i++, elf_ppnt++) {
}
// 如果该程序需要动态链接,则elf_interpreter指针不为空,
//并指向对应的 ld文件.内核则加载此文件,由该文件进行动态链接,并最终跳入程序头文件中制定的入口点
if (elf_interpreter) {
unsigned long interp_map_addr = 0;

// 调用一个装入动态链接程序的函数 此时elf_entry指向一个动态链接程序的入口
elf_entry = load_elf_interp(&loc->interp_elf_ex,
interpreter,
&interp_map_addr,
load_bias);
// ...
} else {
// elf_entry是可执行程序的入口
elf_entry = loc->elf_ex.e_entry;
// ....
}

// 修改保存在内核堆栈,但属于用户态的eip和esp
start_thread(regs, elf_entry, bprm->p);
retval = 0;
}

在准备好堆栈后,再加载完elf的各section到到执行视图的segment以后,就来到了load_elf_interp这个动态链接运行的正式入口

这个动态链接器之前由遍历elf的程序头时的读入解释器名(retval = kernel_read(bprm->file, elf_ppnt->p_offset, elf_interpreter, elf_ppnt->p_filesz);)提供。

动态链接器会先进行自举,完成自己got表的重定位,这个过程无法调用任何的系统函数

随后就根据elf文件的.dynamic字段,获取到.dynstr, .dynsym, .rel.plt字段,查询到该模块还依赖了哪些动态库,随后广度遍历的进行载入

先被载入的符号会放入全局符号表中,后被载入的相同符号会被忽略掉

这就解释了为什么相同符号进行覆盖时,可执行文件的优先级最高,因为可执行文件的符号载入先于任何动态库

随后动态库的符号优先级,就取决于编译时的链接顺序(或者设置LD_PRELOAD强行运行在其他动态库最前面)

在符号解析和重定位完成以后,就对动态库进行初始化(调用.init段)

随后开始正式运行程序

整个过程可以通过LD_DEBUG=all ./main观察到

动态链接的坑

符号覆盖问题

这个问题在上一节运行时动态链接过程中已经讨论过了

double free

当使用C++时,会遇到这个符号覆盖的子问题

对变量来说,虽然符号已经覆盖了,但是动态库的初始化和析构都会进行两次

对C++来说,变量的析构会执行析构函数,这就导致了double free

如果严格遵守谷歌的C++开发规范,不使用非POD类型的全局变量,就可以避免这个问题

基于dlsym的hook

根据上一节的动态链接原理,可以看到有很多方式可以通过修改elf的符号表来进行hook

但是作为开发者来说就不需要这么复杂了,在可执行文件中定义一个和动态库中相同的符号,就可以覆盖动态库符号,执行自己希望的函数

但是只是执行自己的函数显然是不满足hook的需求的,“装饰器模式”才是最终的需求,还需要执行回原来的函数

这时dlsym就登场了,man dlsym看下他的参数

There are two special pseudo-handles that may be specified in handle: RTLD_DEFAULT Find the first occurrence of the desired symbol using the default shared object search order. The search will include global symbols in the executable and its dependencies, as well as symbols in shared objects that were dynamically loaded with the RTLD_GLOBAL flag.

RTLD_NEXT Find the next occurrence of the desired symbol in the search order after the current object. This allows one to provide a wrapper around a function in another shared object, so that, for example, the definition of a function in a preloaded shared object (see LD_PRELOAD in ld.so(8)) can find and invoke the "real" function provided in another shared object (or for that matter, the "next" definition of the function in cases where there are multiple layers of preloading).

在默认的库查找顺序下,RTLD_DEFAULT是用于查找第一个符号匹配的函数地址,RTLD_NEXT是用于查找第二个符号匹配的函数地址

通过这种方式,可以找回被覆盖的符号,从而进行执行

实验代码如下

1
2
3
4
5
6
7
8
9
10
11
12
#define _GNU_SOURCE //定义这个宏才可以使用RTLD_NEXT
#include <dlfcn.h>

void func() {
void (*my_func)() = (void(*)())(dlsym(RTLD_NEXT, "func"));
return my_func();
}

int main() {
func();
return 0;
}

然后进行编译

1
gcc -m32 main.c -o main -Wl,--no-as-need -lhello -ldl -L. 

现在可以反编译看下代码内容了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
~ objdump -S main
0804857b <func>:
804857b: 55 push %ebp
804857c: 89 e5 mov %esp,%ebp
804857e: 83 ec 18 sub $0x18,%esp
8048581: 83 ec 08 sub $0x8,%esp
8048584: 68 50 86 04 08 push $0x8048650
8048589: 6a ff push $0xffffffff
804858b: e8 d0 fe ff ff call 8048460 <dlsym@plt>
8048590: 83 c4 10 add $0x10,%esp
8048593: 89 45 f4 mov %eax,-0xc(%ebp)
8048596: 8b 45 f4 mov -0xc(%ebp),%eax
8048599: ff d0 call *%eax
804859b: c9 leave
804859c: c3 ret

可以看到,需要调用的函数在寄存器eax中,gdb看下里面的内容

1
2
3
4
5
6
(gdb) set env LD_LIBRARY_PATH=.
(gdb) b *func+0x1E
Breakpoint 1 at 0x8048599
(gdb) r
(gdb) i registers eax
eax 0xf7fd15a6 -134408794

没错了,正是libhello.so这个动态库的符号

基于hook的应用

基于hook的异常定位

工作中遇到一个需求,是一个不知道哪里抛出的bad_function_call异常被rpc框架抓住了,rpc框架不可能帮你打印全部的异常堆栈

因此可以hook异常符号__cxa_throw,指定bad_function_call才打印堆栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <iostream>
#include <dlfcn.h>
#include <execinfo.h>
#include <typeinfo>
#include <string>
#include <memory>
#include <cxxabi.h>
#include <cstdlib>
#include <functional>

using namespace std;

namespace {
void * last_frames[100];
size_t last_size;
std::string exception_name;
std::string demangle(const char *name) {
int status;
std::unique_ptr<char,void(*)(void*)> realname(abi::__cxa_demangle(name, 0, 0, &status), &std::free);
return status ? "failed" : &*realname;
}
}
extern "C" {
void __cxa_throw(void *ex, void *info, void (*dest)(void *)) {
exception_name = demangle(reinterpret_cast<const std::type_info*>(info)->name());
if (exception_name == "std::bad_function_call") {
last_size = backtrace(last_frames, sizeof last_frames/sizeof(void*));
backtrace_symbols_fd(last_frames, last_size, 2);
}
static void (*const rethrow)(void*,void*,void(*)(void*)) __attribute__ ((noreturn)) = (void (*)(void*,void*,void(*)(void*)))dlsym(RTLD_NEXT, "__cxa_throw");
rethrow(ex,info,dest);
}
}
void foo() {
std::function<void()> f;
f();
}
int main() {
try {
foo();
}
catch (...) {
std::cerr << "Caught a: " << exception_name << std::endl;
// print to stderr
}
}

编译后运行

1
2
3
4
5
6
7
8
./out.exe(__cxa_throw+0x83)[0x402970]
/usr/lib/x86_64-linux-gnu/libstdc++.so.6(+0x9c630)[0x7f284898f630]
./out.exe(_ZNKSt8functionIFvvEEclEv+0x21)[0x402eff]
./out.exe(_Z3foov+0x30)[0x402a34]
./out.exe(main+0xe)[0x402a86]
/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xf0)[0x7f2848028840]
./out.exe(_start+0x29)[0x402729]
Caught a: std::bad_function_call

使用addr2line打印行号

1
2
~ addr2line 0x402a34 -e out.exe 
/root/cpp_test/exception.cpp:35

题外话:虽然backtrace_symbols_fd是可以重入的,但是backtrace内部使用了malloc,malloc有锁,因此是不可重入的,如果是malloc抛出的异常进行backtrace,会导致死锁

基于hook的内存统计

定义了一个MemoryUsedHolder结构,hook了malloc和free,从而简单的测试出来数据结构使用多少内存,是否存在内存泄露

以上一篇博文的红黑树实现来进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <stdio.h>
#include <dlfcn.h>
#include <malloc.h>

struct MemoryUsedHolder {
static int64_t memoryUsed;
static bool isStart;
MemoryUsedHolder() {
isStart = true;
memoryUsed = 0;
}
void print() {
isStart = false;
cout << LOGV(memoryUsed) << endl;
isStart = true;
}
~MemoryUsedHolder() {
isStart = false;
if (memoryUsed) {
cout << "leak" << LOGV(memoryUsed) << endl;
}
memoryUsed = 0;
}
};

int64_t MemoryUsedHolder::memoryUsed = 0;
bool MemoryUsedHolder::isStart = false;

void* malloc(size_t sz) {
static auto my_malloc = (void* (*)(size_t))dlsym(RTLD_NEXT, "malloc");
auto ptr = my_malloc(sz);
if (MemoryUsedHolder::isStart) {
MemoryUsedHolder::memoryUsed += malloc_usable_size(ptr);
}
return ptr;
}
void free(void *ptr) {
static auto my_free = (void (*)(void*))dlsym(RTLD_NEXT, "free");
if (MemoryUsedHolder::isStart) {
MemoryUsedHolder::memoryUsed -= malloc_usable_size(ptr);
}
return my_free(ptr);
}
...
void testInsertPerformance() {
SeqGenerator seq(allCount);
{
Timer timer("Tree insert");
MemoryUsedHolder h;
Tree t;
for (auto &v : seq.insertSeq_) {
t.insert(v);
}
h.print();
}
{
Timer timer("set insert");
MemoryUsedHolder h;
set<int> s;
for (auto &v : seq.insertSeq_) {
s.insert(v);
}
h.print();
}
}
...

执行后输出

1
2
3
4
|memoryUsed=39991080|
Tree insert: Cost 1063 ms
|memoryUsed=39991080|
set insert: Cost 1868 ms

可以看到,测试结果没有泄露,内存消耗和stl相同

题外话:malloc返回的指针是对齐后的数据长度,需要malloc_usable_size才能知道申请的大小,因此不止是free,malloc也需要使用malloc_usable_size,最早在这里裁了坑,以为哪里有泄露,因此重新搞了gperftools检查内存泄露才确定是对malloc_usable_size的使用有问题,记录在c++ 分析 gperftools 总结

参考资料