南京大学蒋炎岩老师在可执行文件和加载中讲到可以使用环境变量LD_PRELOAD对libc的函数进行"override"。Linux Manual中对LD_PRELOAD的介绍如下:

LD_PRELOAD

A list of additional, user-specified, ELF shared objects to be loaded before all others. This feature can be used to selectively override functions in other shared objects.

The items of the list can be separated by spaces or colons, and there is no support for escaping either separator. The objects are searched for using the rules given under DESCRIPTION. Objects are searched for and added to the link map in the left-to-right order specified in the list.

In secure-execution mode, preload pathnames containing slashes are ignored. Furthermore, shared objects are preloaded only from the standard search directories and only if they have set-user-ID mode bit enabled (which is not typical).

Within the names specified in the LD_PRELOAD list, the dynamic linker understands the tokens $ORIGIN, $LIB, and $PLATFORM (or the versions using curly braces around the names) as described above in Rpathtoken expansion. (See also the discussion of quoting under the description of LD_LIBRARY_PATH.)

There are various methods of specifying libraries to be preloaded, and these are handled in the following order:

  1. The LD_PRELOAD environment variable.

  2. The --preload command-line option when invoking the dynamic linker directly.

  3. The /etc/ld.so.preload file (described below).

利用LD_PRELOAD这个环境变量,我们可以将一些我们感到不满意的库函数替换成我们自己实现的函数,或者是对库函数进行插装,来获取程序对库函数的调用信息。下面这个程序演示了如何对库函数进行插桩:

malloc_override.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
// By GPT-4

#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>

void* malloc(size_t size) {
static void* (*real_malloc)(size_t) = NULL;
if (!real_malloc) {
real_malloc = dlsym(RTLD_NEXT, "malloc");
}

void* ptr = real_malloc(size);
fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);
return ptr;
}

void free(void* ptr) {
static void (*real_free)(void*) = NULL;
if (!real_free) {
real_free = dlsym(RTLD_NEXT, "free");
}

fprintf(stderr, "free(%p)\n", ptr);
real_free(ptr);
}

编译该程序为动态链接库:

1
gcc -shared -fPIC -o malloc_override.so malloc_override.c -ldl

执行命令

1
LD_PRELOAD=./malloc_override.so ls

可以看到命令行的输出:

1
2
3
4
5
6
7
8
9
10
malloc(0x563533a0a2d0)
malloc(0x563533a0a4b0)
malloc(0x563533a0a530)
free(0x563533a0a4b0)
free(0x563533a0a530)
....
a.out compile.sh malloc_override.c malloc_override.so prac prac.c
free(0x563533a14f40)
free((nil))
free(0x563533a14f10)

hooking实现的原理比较简单,我们使用LD_PRELOAD预先加载了我们自己编译的动态库,malloc和free这两个符号的地址就已经确定下来了,之后/lib64/ld-linux-x86-64.so.2加载libc时,libc中的malloc和free不会再覆盖之前我们已经确定的符号。此后ls程序调用malloc和free,就是调用我们自己的动态库中的malloc和free了。

因为我们的目的是对malloc和free进行插桩,所以我们只是在上面做了一层包装,最终还是需要调用libc中的malloc和free,但是这该如何实现呢?使用函数指针即可。real_mallocreal_free指向的是libc中malloc和free的位置,这两个函数指针的地址是通过dlsym来获取的。需要注意的是,dlsym的参数中有一个是RTLD_NEXT,表示下一个符号的位置。因为我们使用LD_PRELOAD提前加载了我们的动态库,因此libc中的malloc和free在我们的动态库符号的下一级,也就是第二个符号。

利用同样的方法,可以对其他的libc库函数进行插桩。

在研究过程中,我发现一个比较有趣的问题,如果将fprintf的参数从stderr换成stdout之后,再使用相同的命令,会出现segmentation fault:

1
2
❯ LD_PRELOAD=./malloc_override.so ls                               
[1] 6870 segmentation fault LD_PRELOAD=./malloc_override.so ls --color=tty

fprintf(stdout, ...) 换成printf 之后也是同样的情况。我百思不得其解,我用了下面这样一个测试程序来测试动态库:

prac.c

1
2
3
4
5
6
7
#include <stdlib.h>
#include <stdio.h>

int main() {
int *p = (int *)malloc(77);
printf("%p\n", p);
}

执行以下命令:

1
2
3
4
5
❯ gcc prac.c
❯ LD_PRELOAD=./malloc_override.so ./a.out
malloc(77) = 0x5607b75222a0
malloc(1024) = 0x5607b7522300
0x5607b75222a0

我的程序中只出现了一次malloc,但是在hooking中却出现了两次malloc,那一定是printf这个函数调用了malloc。这也与我们的常识符合,stdout是有一个buffer的,每次的输出都会先输出到buffer中,等到出现换行符时,再将输入打印到终端。

如果禁用stdout的buffer呢?会出现什么结果?修改后的malloc_override.c如下:

malloc_override.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ...
void* malloc(size_t size) {
setbuf(stdout, NULL);
if (!real_malloc) {
real_malloc = dlsym(RTLD_NEXT, "malloc");
}

void* ptr = real_malloc(size);
fprintf(stdout, "malloc(%zu) = %p\n", size, ptr);
return ptr;
}

void free(void* ptr) {
setbuf(stdout, NULL);
if (!real_free) {
real_free = dlsym(RTLD_NEXT, "free");
}

fprintf(stdout, "free(%p)\n", ptr);
real_free(ptr);
}

再执行同样的命令,发现不再出现segmentation fault,可以正常运行。

所以最终来看,是stdout buffer的问题。

为了探究出现segmentation fault的原因,将malloc_override.csetbuf(stdout, NULL)删除,并使用fprintf(stderr, ...)输出函数调用信息:

malloc_override.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//...
void* malloc(size_t size) {
fprintf(stderr, "call malloc(%ld)\n", size);
if (!real_malloc) {
real_malloc = dlsym(RTLD_NEXT, "malloc");
}

void* ptr = real_malloc(size);
fprintf(stdout, "malloc(%zu) = %p\n", size, ptr);

return ptr;
}

void free(void* ptr) {
fprintf(stderr, "call free(%p)\n", ptr);
if (!real_free) {
real_free = dlsym(RTLD_NEXT, "free");
}

fprintf(stdout, "free(%p)\n", ptr);
real_free(ptr);
}

编译动态库之后重新执行命令:

1
2
3
❯ gcc -shared -fPIC -g -o malloc_override.so malloc_override.c -ldl
❯ gcc prac.c
❯ LD_PRELOAD=./malloc_override.so ./a.out

命令行输出如下:

1
2
3
4
5
6
7
8
9
call malloc(77)
call malloc(1024)
call malloc(1024)
call malloc(1024)
...
call malloc(1024)
call malloc(1024)
call malloc(1024)
[1] 7034 segmentation fault LD_PRELOAD=./malloc_override.so ./a.out

能看出是stack overflow导致segmentation fault,那么即可推断原因:使用fprintf或者printf向 stdout输出内容时调用malloc申请buffer,此后malloc又再次调用 fprintf 向stdout输出内容,循环递归导致stack overflow,最终segmentation fault。

但是将libc替换为musl,重新编译prac.c,程序却能够正常运行:

1
2
3
4
5
❯ musl-gcc prac.c
❯ LD_PRELOAD=./malloc_override.so ./a.out
call malloc(64)
malloc(64) = 0x7f90a75e38f0
0x7f90a75e38f0

查看musl的内部实现:

stdio.h

1
#define stdout (&__stdout_FILE)

stdout.c

1
2
3
4
5
6
7
8
9
10
11
12
static unsigned char buf[BUFSIZ+UNGET];
hidden FILE __stdout_FILE = {
.buf = buf+UNGET,
.buf_size = sizeof buf-UNGET,
.fd = 1,
.flags = F_PERM | F_NORD,
.lbf = '\n',
.write = __stdout_write,
.seek = __stdio_seek,
.close = __stdio_close,
.lock = -1,
};

可以看到,在musl中,stdout的buffer是在静态数据区,调用printf或者fprintf(stdout, ...)时不会调用malloc,因此也不会出现上述stack overflow的情况。