hook是一个非常有用的黑魔法
协程基于它的最大应用之一
我总结了一下hook的原理,和我遇到的hook场景
hook原理
我第一次看到hook这个词是在破解论坛上,大致意思是将指定函数替换成自己的,然后再去执行这个指定函数
从而偷梁换柱,在原有的函数上添加自己想要的功能
从设计模式上说倒是和装饰器模式非常相似
hook的核心就是让系统放弃原有代码,转而运行你的代码
破解为了达到hook的目的有非常多的手段,然而对开发者来说,通过动态链接的符号覆盖机制会简单很多
因此对开发者来说理解hook原理,就是理解动态链接原理
生成可执行文件
可执行文件的生成分两步:
这里就带来了一个问题,为什么要分两步?只有编译步骤会有什么问题?
编译将编译单位翻译成各种机器能看懂的符号,由于编译时当前编译单元引用另外一个编译单元的符号时,无法确定这个符号的地址
又因为编译是并行执行的,因此需要单独一个串行步骤:链接,去解决跨编译单元的相互引用问题,这个步骤在读取了全部符号后,将无法确定的符号地址确定后改写,也就是符号重定位 ,
符号重定位可以分为
链接时符号重定位
应用于多个.o文件(跨编译单元链接)或者是.a文件(静态链接)之间的符号重定位
静态链接本质上将所有符号全拷贝到可执行文件中去,浪费磁盘和内存
加载时符号重定位
普通模式(编译选项-fPIE
)
将静态链接时的符号重定位放到加载时来做,由于需要修改动态库符号的地址,因此动态库无法再重用,在加载时,每个进程在内存中只能对动态库的每个符号都拷贝一遍
相对静态链接而言,只节省了磁盘,依然浪费了更为宝贵的内存
地址无关代码(编译选项-fPIC
,鼎鼎大名的PIC,position
independent code )
配合延迟绑定(lazy
binding )可以极大的节省内存空间
静态链接(链接时符号重定位)实验
本文的重点是动态链接,所以对静态链接的原理不做详细介绍,只通过实验来观察静态链接的现象
静态链接的实验在gcc version 9.4.0 (Ubuntu 9.4.0-1ubuntu1~20.04.3)
下
源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 int g_share = 1 ;int g_func (int a) { g_share += a; return a * 3 ; } extern int g_share;extern int g_func (int a) ;int main () { int a = 42 ; a = g_func(a); return 0 ; }
编译目标文件:
查看a.o汇编:
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 ~ objdump -S a.o a.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <g_func>: int g_share = 1; int g_func(int a) { 0: f3 0f 1e fa endbr64 4: 55 push %rbp 5: 48 89 e5 mov %rsp,%rbp 8: 89 7d fc mov %edi,-0x4(%rbp) g_share += a; b: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 11 <g_func+0x11> 11: 8b 45 fc mov -0x4(%rbp),%eax 14: 01 d0 add %edx,%eax 16: 89 05 00 00 00 00 mov %eax,0x0(%rip) # 1c <g_func+0x1c> return a * 3; 1c: 8b 55 fc mov -0x4(%rbp),%edx 1f: 89 d0 mov %edx,%eax 21: 01 c0 add %eax,%eax 23: 01 d0 add %edx,%eax } 25: 5d pop %rbp 26: c3 retq
b那一行,加载全局变量 g_share
的值到寄存器
edx
11那一行,将参数 a
存储到寄存器 eax
中
14那一行,将全局变量 g_share
的值加上参数 a
的值,结果存储在 eax
中
16那一行,将加法结果从eax
存储回全局变量
g_share
这里代表变量g_share
的部分全部是0x0,这就是编译阶段还没确定符号地址,需要在链接的时候进行重定位
那么链接器如何知道目标文件哪些地址需要重定位,并且重定位到哪个变量呢?这里就有一个映射表项给链接器参考,叫做重定位表(relocatioin
table)
1 2 3 4 5 6 ~ readelf -r a.o Relocation section '.rela.text' at offset 0x490 contains 2 entries: Offset Info Type Sym. Value Sym. Name + Addend 00000000000d 000e00000002 R_X86_64_PC32 0000000000000000 g_share - 4 000000000018 000e00000002 R_X86_64_PC32 0000000000000000 g_share - 4
这里指出了代码段的d(也就是b那一行第三个位置)和以及代码段的18(也就是16那一行第三个位置),这两个长度为2的代码段,需要重定向为g_share地址,这里的-4会辅助链接器进行重定向计算,具体逻辑我也不清楚
接着链接可执行文件查看链接后最终结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 ~ gcc a.o main.o -g -o main ~ objdump -S main 0000000000001129 <g_func>: int g_share = 1; int g_func(int a) { 1129: f3 0f 1e fa endbr64 112d: 55 push %rbp 112e: 48 89 e5 mov %rsp,%rbp 1131: 89 7d fc mov %edi,-0x4(%rbp) g_share += a; 1134: 8b 15 d6 2e 00 00 mov 0x2ed6(%rip),%edx # 4010 <g_share> 113a: 8b 45 fc mov -0x4(%rbp),%eax 113d: 01 d0 add %edx,%eax 113f: 89 05 cb 2e 00 00 mov %eax,0x2ecb(%rip) # 4010 <g_share> return a * 3; 1145: 8b 55 fc mov -0x4(%rbp),%edx 1148: 89 d0 mov %edx,%eax 114a: 01 c0 add %eax,%eax 114c: 01 d0 add %edx,%eax } 114e: 5d pop %rbp 114f: c3 retq
可以看到1134这一行,将通过RIP相对偏移0x2ed6访问变量,RIP指向下一行指令的首地址,因此是113a
+ 0x2ed6 = 4010,同理1145 + 0x2ecb = 4010
最后看下4010指向什么:
1 2 ~ readelf -s main|grep 4010 51: 0000000000004010 4 OBJECT GLOBAL DEFAULT 23 g_share
确实是g_share,再看看它的初始值
1 2 3 4 5 ~ readelf -x .data main Hex dump of section '.data': 0x00004000 00000000 00000000 08400000 00000000 .........@...... 0x00004010 01000000 ....
4010的值为1,和预期g_share = 1一致
动态链接(加载时符号重定位)原理
通过简单的实验来理解动态链接过程,实验源码如下
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 ~ readelf -s libhello.so |grep 2018 00002018 4 OBJECT LOCAL DEFAULT 22 static_v
可以看到,在符号表中正是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 ~ readelf -x .got libhello.so Hex dump of section '.got': 1fe8 00000000 00000000 00000000 00000000 ................ 1ff8 00000000 00000000
这个段全部是0,加载到内存后,动态链接器通过重定位段的信息对其重定位来写入到got表
重定位段.rel.dyn
1 2 3 4 5 6 7 ~ readelf -x .rel.dyn libhello.so Hex dump 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) #define ELF32_R_TYPE(i) ((unsigned char)(i)) #define ELF32_R_INFO(s,t) (((s)<<8)+(unsigned char)(t)) typedef struct { Elf32_Addr r_offset; Elf32_Word r_info; } 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; Elf32_Addr st_value; Elf32_Word st_size; unsigned char st_info; unsigned char st_other; Elf32_Section st_shndx; } 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 -r
也可以很方便的看重定位信息
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]
重点是
1 2 3 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
第一段r-xp
是代码段(.text) ,可读可执行,代码段由于不可写,是整个系统共享动态链接库内容的,在发生缺页中断的时候会共享物理内存内容
第二段r--p
是只读数据段(.rodata,只读常量,包括
GOT 表)
第三段rw-p
是可读写数据段(.data 或
.bss)
第二段和第三段的数据段,是每个进程独立的内存空间,在发生缺页中断的时候会用动态链接库的数据段内容初始化这里的内存,在动态链接过程中再修改对应的GOT表内容为重定向的地址
虚拟地址加上偏移量后得到f7fd1000+00001fec=0xF7FD2FEC
,这个地址就是GOT表了,看下这个地址的内容
1 2 (gdb) x 0xF7FD2FEC 0xf7fd2fec: 0xf7fd3014
将0xf7fd3014去掉虚拟地址转换为偏移量:0xf7fd3014-f7fd1000=2014
2014
这个地址正是在刚才重定位段.dynsym
看到的初始值,说明动态链接库中代码指向的got表内容没有发生改变,还是extern_v
跨模块访问,符号覆盖下的运行时got表内容
got访问跨模块,可以分成几种情形
实际原理都一样,这里用相同符号覆盖中的动态库和可执行文件覆盖来举例
写一个可执行文件来使用刚才编译的动态库
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 ~ readelf -x .got.plt libhello.so Hex dump of section '.got.plt': 2000 081f0000 00000000 00000000 26040000 ............&...
延迟绑定的重定位段.rel.plt
逻辑和重定位段.rel.dyn是一样的
1 2 Hex dump of section '.rel.plt': 03d8 0c200000 070f0000
其中03d8地址的数据0c200000 070f0000
,从大端序看,前4字节r_offset正是0x200c,接着3字节(0f0)代表.dynsym的下标,最后1字节(7)是类型
接着看.dynsym
1 2 Hex dump 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 Hex dump of section '.dynstr': 0314 00746573 745f6675 6e63006c 6962632e .test_func.libc.
使用readelf -r
查看
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) { 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; for (i = 0 , elf_ppnt = elf_phdata; i < loc->elf_ex.e_phnum; i++, elf_ppnt++) { } if (elf_interpreter) { unsigned long interp_map_addr = 0 ; elf_entry = load_elf_interp(&loc->interp_elf_ex, interpreter, &interp_map_addr, load_bias); } else { elf_entry = loc->elf_ex.e_entry; } 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++时,会遇到这个符号覆盖的子问题
对变量来说,虽然符号已经覆盖了,但是动态库的初始化和析构都会进行两次,初始化两次相同地址只会导致泄露,析构两次相同地址那就会发生double
free了
解决办法:
在编译选项中加入-Wl,--exclude-libs,<your_static_lib>
-Wl
为gcc
传给静态链接器ld
的选项。gcc -Wl,aa,bbb,cde
在gcc传给ld时,会变为ld aaa bbb cde
这种形式。
需要注意的是这里的lib名是需要lib前缀的。
通常情况下,一个动态库链接静态库后,这个动态库也会继承静态库当中的导出符号(即非static
符号),而使用该链接选项,则可以让动态库在链接静态库后将静态库中的导出符号改为非导出符号,相当链接时为静态库中的所有函数、变量均加上了
static
。这样所有静态库符号都保存在本地,同样也不会被主程序或其他动态链接库劫持符号。
使用dlopen
使用dlopen
打开动态库,例如dlopen("libconflict.so", RTLD_LOCAL | RTLD_LAZY);
,RTLD_LAZY
同样也会保证该
so 中的符号不被劫持。
在编译选项中使用-fpie或 -fPIE
当使用选项 -fpie或
-fPIE时,生成的共享库不会为静态成员变量或全局变量在
GOT中创建对应的条目,相当于给全部变量加上了static,当然这也没法进行hook了
基于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 #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 ; } }
编译后运行
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 总结
总结
可执行文件的生成分两步:
编译将编译单位翻译成各种机器能看懂的符号,由于编译时当前编译单元引用另外一个编译单元的符号时,无法确定这个符号的地址
又因为编译是并行执行的,因此需要单独一个串行步骤:链接,去解决跨编译单元的相互引用问题,这个步骤在读取了全部符号后,将无法确定的符号地址确定后改写,也就是符号重定位
符号重定位可以分为
链接时符号重定位
应用于多个.o文件(跨编译单元链接)或者是.a文件(静态链接)之间的符号重定位
静态链接本质上将所有符号全拷贝到可执行文件中去,浪费磁盘和内存
加载时符号重定位
参考资料