前言
假如由于调试需要,你希望原先代码中的malloc函数更换为你自己写好的malloc函数,该怎么办呢?如何对程序进行”偷梁换柱“?
打桩机制
LInux链接器有强大的库打桩机制,它允许你对共享库的代码进行截取,从而执行自己的代码。而为了调试,你通常可以在自己的代码中加入一些调试信息,例如,调用次数,打印信息,调用时间等等。本文将介绍三种打桩机制,分别在编译的不同阶段。如果你还不了解这几个阶段,建议你阅读《hello程序是如何变成可执行文件的》。
编译时打桩
编译时打桩在源代码级别进行替换。我们很容易通过#define指令来完成这件事情。首先我们定义自己的头文件mymalloc.h:1
2#define malloc(size) mymalloc(size)
void *mymalloc(size_t size)
由于在这里使用了#define指令,我们后面需要malloc的地方都会被mymalloc替代。
而mymalloc.c代码如下:1
2
3
4
5
6
7
8
9
10
11
/*打桩函数*/
void *mymalloc(size_t size)
{
void *ptr = malloc(size);
printf("ptr is %p\n",ptr);
return ptr;
}
注意第一行,我们需要在gcc中传入编译选项MYMOCK(自定义,代码与传入的一致即可)。
我们在main.c中调用它:1
2
3
4
5
6
7
8
int main()
{
char *p = malloc(64);
free(p);
return 0;
}
编译运行:1
2
3
4 gcc -DMYMOCK -c mymalloc.c
gcc -I . -o main main.c mymalloc.o
./main
ptr is 0xdbd010
编译时还使用-I参数,告诉编译器从当前目录下寻找头文件malloc.h,因此,main函数中的malloc调用将会被替换成mymalloc。而在mymalloc.c中的则使用原始的malloc函数,最终达到“偷梁换柱”的效果。
实际上你也可以通过仅仅预编译来很清楚的看到其中的变化:1
$ gcc -I . -E -o main.i main.c
查看main.i,你会发现,使用malloc的地方,都被替换成了mymalloc。
小结一下前面的步骤:
- 打桩函数内部不要打桩,即mymalloc.c中要使用原始的malloc函数,不然会造成循环调用
- 通过#define指令,将外部调用malloc的地方都替换为mymalloc
- 分开编译mymalloc.c和外部调用代码,最终链接
这种方式打桩需要能够访问源代码才能完成。
链接时打桩
顾名思义,链接时打桩是在链接时替换需要的函数。Linux链接器支持用—wrap,f的方式来进行打桩,链接时符号f解析成wrap_f,还会把real_f解析成f。什么意思呢?我们修改前面mymalloc.c的代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14//来源:公众号【编程珠玑】
//网站:https://www.yanbinghu.com
void *__real_malloc(size_t size);//注意声明
/*打桩函数*/
void *__wrap_malloc(size_t size)
{
void *ptr = __real_malloc(size);//最后会被解析成malloc
printf("ptr is %p\n",ptr);
return ptr;
}
注意将main.c中包含的malloc.h那一行去掉。
编译运行:1
2
3
4
5 gcc -DMYMOCK mymalloc.c
gcc -c main.c
gcc -Wl,--wrap,malloc -o main main.o mymalloc.o
./main
ptr is 0x95f010
我们特别关注mymalloc.c中的代码,利用链接器的打桩机制,最后在main函数中调用malloc,将会去调用wrap_malloc,而real_malloc将会被解析成真正的malloc,从而达到“偷梁换柱”的效果。
可以看到的是,这种打桩方式至少需要能够访问可重定位文件。
来源:公众号【编程珠玑】
网站:https://www.yanbinghu.com
运行时打桩
前面两种打桩方式,一种需要访问源代码,另外一种至少要访问可重定位文件。可运行时打桩没有这么多要求。运行时打桩可以通过设置LD_PRELOAD环境变量,达到在你加载一个动态库或者解析一个符号时,先从LD_PRELOAD指定的目录下的库去寻找需要的符号,然后再去其他库中寻找。
同样我们修改mymalloc.c: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//来源:公众号【编程珠玑】
//网站:https://www.yanbinghu.com
extern FILE *stdout;
/*打桩的malloc函数*/
void *malloc(size_t size)
{
static int calltimes;
calltimes++;
/*函数指针*/
void *(*realMalloc)(size_t size) = NULL;
char *error;
realMalloc = dlsym(RTLD_NEXT,"malloc");//RTLD_NEXT
if(NULL == realMalloc)
{
error = dlerror();
fputs(error,stdout);
return NULL;
}
void *ptr = realMalloc(size);
if(1 == calltimes)
{
printf("ptr is %p\n",ptr);
}
calltimes = 0;
return ptr;
}
在mymalloc.c的代码中,由于我们自己的打桩函数也叫malloc,因此我们通过运行时链接调用malloc函数,以便获取malloc的地址,而不是直接调用。并且是以RTLD_NEXT方式。
将mymalloc.c制作成动态库(动态库的制作和使用参考《库的制作与两种使用方式》):1
2
3
4
5$ gcc -DMYMOCK -shared -fPIC -o libmymalloc.so mymalloc.c -ldl
$ gcc -o main main.c //重新编译main
$ LD_PRELOAD="./libmymalloc.so"
$ ./main
Segmentation fault (core dumped)
然而非常不幸的是,最后core dumped了,我们用gdb(参考《Linux常用命令-开发调试篇》)查看调用栈:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16(gdb)bt
#0 0x00007fe0ca83518e in _IO_vfprintf_internal (
s=0x7fe0cabad620 <_IO_2_1_stdout_>, format=0x7fe0cabb26dd "ptr is %p\n",
ap=ap@entry=0x7ffcbd652058) at vfprintf.c:1267
#1 0x00007fe0ca83d899 in __printf (format=<optimised out>) at printf.c:33
#2 0x00007fe0cabb26cc in malloc () from ./mymalloc.so
#3 0x00007fe0ca8551d5 in __GI__IO_file_doallocate (
fp=0x7fe0cabad620 <_IO_2_1_stdout_>) at filedoalloc.c:127
#4 0x00007fe0ca863594 in __GI__IO_doallocbuf (
fp=fp@entry=0x7fe0cabad620 <_IO_2_1_stdout_>) at genops.c:398
#5 0x00007fe0ca8628f8 in _IO_new_file_overflow (
f=0x7fe0cabad620 <_IO_2_1_stdout_>, ch=-1) at fileops.c:820
#6 0x00007fe0ca86128d in _IO_new_file_xsputn (
f=0x7fe0cabad620 <_IO_2_1_stdout_>, data=0x7fe0cabb26dd, n=7)
at fileops.c:1331
#7 0x00007fe0ca835241 in _IO_vfprintf_internal (
我们从调用栈基本可以推断,其中有反复调用,那就是说在mymalloc.c中的malloc函数中,有的语句也调用了malloc,导致了最终的反复调用。解决这种问题有两个方法:
- 避免反复调用
- 使用不调用打桩函数的函数,即不调用其中的printf
我们采用下面这种方式来避免反复调用,开始调用时,置调用次数为1,最后置0,如果发现调用次数不为0 ,则不调用。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
extern FILE *stdout;
/*打桩的malloc函数*/
void *malloc(size_t size)
{
/*调用次数+1*/
static int calltimes;
calltimes++;
/*函数指针*/
void *(*realMalloc)(size_t size) = NULL;
char *error;
realMalloc = dlsym(RTLD_NEXT,"malloc");//RTLD_NEXT
if(NULL == realMalloc)
{
error = dlerror();
fputs(error,stdout);
return NULL;
}
void *ptr = realMalloc(size);
/*如果是第一次调用,则调用printf,否则不调用*/
if(1 == calltimes)
{
printf("ptr is %p\n",ptr);
}
calltimes = 0;
return ptr;
}
当然这样的写法在多线程中也是有问题的,如何改进?
至此,就达到了我们需要的结果:1
2./main
ptr is 0x245c010
实际上,你会发现,在设置了这个环境变量的终端下,这个打桩的动作对所有程序都生效:1
2
3
4
5
6
7$ ls
ptr is 0x1f1a040
ptr is 0x1f1a680
ptr is 0x1f1a700
ptr is 0x1f1a040
ptr is 0x1f1a060
ptr is 0x1f1a040
那么怎么取消呢:1
$ unset LD_PRELOAD
在这里也可以看到,这个机制虽然强大,同样也非常危险,因为不怀好意者可以通过这种方式恶意攻击你的程序。比如说,有个程序中checkPass的接口用来校验密码,如果这个时候使用另外一个动态库,实现自己的checkPass函数,并且设置LD_PRELOAD环境变量,就可以达到跳过密码检查的目的。
总结
怎么样,是不是觉得很神奇?尤其是最后一种方式,可以达到对任何程序进行”偷梁换柱“,对于问题的定位和程序的调试非常有帮助。但是,需要特别注意的是,采用最后一种方式打桩时,最好避免打桩函数内部还调用了打桩函数,这样会导致难以预料的后果,另外由于这种打桩机制对所有程序都有效,因此也非常危险,需要特别注意。