库打桩机制-偷梁换柱

前言

假如由于调试需要,你希望原先代码中的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
#ifdef MYMOCK //只有MYMOCK编译选项是,这段代码才会编译进去
#include<stdio.h>
#include<stdlib.h>
/*打桩函数*/
void *mymalloc(size_t size)
{
void *ptr = malloc(size);
printf("ptr is %p\n",ptr);
return ptr;
}
#endif

注意第一行,我们需要在gcc中传入编译选项MYMOCK(自定义,代码与传入的一致即可)。

我们在main.c中调用它:

1
2
3
4
5
6
7
8
#include<stdio.h>
#include"malloc.h"
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
#ifdef MYMOCK //只有MYMOCK编译选项是,这段代码才会编译进去
#include<stdio.h>
#include<stdlib.h>
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;
}
#endif

注意将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
#ifdef MYMOCK //只有MYMOCK编译选项是,这段代码才会编译进去
#define _GNU_SOURCE //这行特别注意加上
#include<stdio.h>
#include<stdlib.h>
#include<dlfcn.h>
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;
}
#endif

在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
#ifdef MYMOCK //只有MYMOCK编译选项是,这段代码才会编译进去
#define _GNU_SOURCE //这行特别注意加上
#include<stdio.h>
#include<stdlib.h>
#include<dlfcn.h>
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;
}
#endif

当然这样的写法在多线程中也是有问题的,如何改进?

至此,就达到了我们需要的结果:

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环境变量,就可以达到跳过密码检查的目的。

总结

怎么样,是不是觉得很神奇?尤其是最后一种方式,可以达到对任何程序进行”偷梁换柱“,对于问题的定位和程序的调试非常有帮助。但是,需要特别注意的是,采用最后一种方式打桩时,最好避免打桩函数内部还调用了打桩函数,这样会导致难以预料的后果,另外由于这种打桩机制对所有程序都有效,因此也非常危险,需要特别注意。

守望 wechat
关注公众号[编程珠玑]获取更多原创技术文章
出入相友,守望相助!