1. 可执行目标文件
  2. 加载可执行文件
  3. 动态链接库
  4. 位置无关代码
  5. 库打桩 – 编译时打桩
  6. 库打桩 – 链接时打桩
  7. 库打桩 – 运行时打桩

可执行目标文件

链接的过程就是将可重定位目标文件生成可执行目标文件, 来看一下可执行目标文件的结构:

只读内存段(代码段) ELF头
程序头部表
.init
.text
.rodata
读/写内存段(数据段) .data
.bss
不加载到内存中的符号表和调试信息 .symtab
.debug
.line
.strtab
描述文件结构的节头部表

可执行文件与可重定位目标文件有点类似, 都有ELF头, 可执行文件的ELF头还包括程序的入口点, 也就是第一条指令的地址.

.text .rodata .data 这些节都和可重定位目标文件中的节相似, 但是其中的地址都已经被链接器编排完毕.

在linux下针对例子程序 prog 执行 objdump -x 查看文件头:

Program Header:
1    LOAD off    0x0000000000000000 vaddr 0x0000000000400000 paddr 0x0000000000400000 align 2**21
2         filesz 0x00000000000006d4 memsz 0x00000000000006d4 flags r-x
3    LOAD off    0x0000000000000e10 vaddr 0x0000000000600e10 paddr 0x0000000000600e10 align 2**21
4         filesz 0x000000000000021c memsz 0x0000000000000220 flags rw-

这里只选取了LOAD开头的两部分.

off表示可执行文件中的偏移, vaddr和paddr表示内存地址, align表示对齐. filez表示目标文件中的段大小, memsz 表示内存中的段大小. 这里是6d4. flags表示权限, 第一个LOAD的部分权限是读和执行, 说明是代码段.

1和2行合起来表示开始于内存区域0x400000, 长度是6d4长度的代码段, 可执行可读. 这个6d4大小的区域内, 存放着之前文件结构里的前五个内容, 也就是代码段的内容.

3到4行合起来表示开始于内存区域0x600e10, 用目标文件偏移量0xe10开始的21C长度的数据去初始化, 这其实是.data节的内容. 可以发现实际内存区域是220, 220-21C = 4个字节的长度, 是留给.bss段的.

对齐是什么含义呢, 2**21这里表示2的21次方, 写成16进制就是 0x200000. 对于每一个段开始的地址, 都必须符合 段地址 mod 对齐 = 段偏移量(off) mod 对齐. 换句话说, 也就是:

vaddr 内存地址, 必须等于 off 加上 align 的整数倍. 所以代码段的偏移 = off + align * 2 = 0x400000; 数据段的地址 = e10 + 3* align = 600e10, 这是一种优化, 根据虚拟内存来定的原则.

加载可执行文件

知道了可执行文件的内容, 再来看看如何实际执行呢?

启动可执行文件的时候, shell 会调用加载器程序来将可执行文件的代码和数据复制到内存中, 然后跳转到这个程序的第一条指令或者入口点来执行程序. 这个过程就叫做加载.

Linux 86-64的代码段固定是0x400000, 然后向上增长, 其后的下一个符合align对齐要求的是数据段. 所以数据段和代码段之间可能会有因为对齐产生的间隙. 运行时的堆在数据段之后, 在调用malloc库之后会往上增长. 堆后边的区域为共享模块保留.

用户的栈则不是正向来安排的, 而是从最大的地址 2e48-1 往下增长. 所以给一个程序的最大空间, 就是2e48个字节. 栈再往上是内核内存, 用户代码无法见到.

在之前学习缓冲区溢出的时候, 遇到了随机存放区域, 这里还会涉及到随机地址分配, 但是这几个区域的相对位置是不会改变的. 这也是为什么链接器能够工作的原因, 如果每次这些区域的距离都不同, 那静态编译好的可执行文件是无法执行的.

加载和分配完所有内存之后, 加载器跳转到程序的入口点, 是一个特殊的_start函数, 对于所有的C语言编译出来的文件, 都是一样的. 这个函数会调用系统启动函数 __libc_start_main, 系统启动函数会找到用户编写的main函数执行, 在用户程序结束之后, 会获取用户程序的返回值, 还可以在需要的时候, 将控制交还给内核.

实际上的加载过程涉及到进程和虚拟内存分配, 上边只说了分配内存的原则和启动时候的流程, 真正的加载过程, 是把可执行文件的片映射到内存的片, 并不会直接复制数据, 到CPU引用到了实际的数据, 才会去复制.

动态链接共享库

所谓动态链接共享库, 就是没有被编译到可执行目标文件中, 而是在运行的时候, 加载到任意的内存地址, 并和一个在内存中运行的程序链接起来的目标文件.

这个过程叫做动态链接, 是由一个动态链接器执行的. 共享链接库在Linux的后缀名是.so, 在Windows里叫做.dll.

如果要创建动态链接库, 就需要使用 gcc -fpic -shared -o libvector.so addvec.c mulvec.c, 这样执行之后, 就会生成一个动态链接库 libvector.so.

然后根据动态链接库, 编译一下使用动态链接库的可执行文件 gcc -o prog2 main2.c ./libvector.so, 编译之后的prog2可执行文件在执行的时候, 就会去找动态链接库加载, 然后运行.

现在的prog2和之前-static编译的不同, 不会将任何libvector.so中的内容复制进去, 只是复制了一些重定位和符号表信息, 用来在运行时解析libvector.so (其实C程序编译的时候默认也会加载C标准库的动态链接库libc.so).

现在运行prog2的时候, 加载过程和之前有所不同:

  1. 加载器在加载prog2的时候, 发现其中有一个节叫做.interp, 表明这个可执行文件使用了动态链接库, 这个节包含了动态连接器的路径(因为动态连接器本身也是一个共享库), 加载器不会在加载完成之后叫控制权交给main函数, 而是交给了动态链接器.
  2. 动态链接器先把其中使用到的libc.so的指令和数据弄到某个内存区域
  3. 动态连接器把libvector.so的指令和数据也弄到另一个内存区域
  4. 将所有对libc.so和libvector.so的符号引用重定位到刚才配置的内存区域
  5. 将控制权转移给main函数, 此时共享库的位置就固定了, 在程序执行完毕之前不会改变.

上边是在运行之前加载的情况, 还可以在运行的时候加载. Linux系统提供了动态链接器的一些函数. 这些暂且了解一下. 先来看看-fpic如何生成位置无关代码的.

位置无关代码

如果要编译共享库, 必须使用 -fpic 代码, 编译出来的共享库可以放在内容任意位置, 无需链接器修改. 这样很多个进程可以共享同一个代码段的同一个实例.

在刚才的静态编译中, 对于所有符号的引用, 都能够直接在已经发现的目标文件中获得, 因此可以使用PC相对寻址直接去定位. 但是共享库并不知道符号到底放在哪里, 这需要利用一些技巧.

还记得刚才说的, 无论何处加载一个目标程序, 其代码段和数据段的距离是不变的. 所以运行起来之后, 代码段和数据段的距离是一个常量, 利用这个特性, 知道了代码段的开始位置, 就知道了共享库的位置.

加上 -fpic 的时候, 本来的.data段开始的地方, 被增加了一个全局偏移量表 (Global Offect Table, GOT), 为库中的每个全局变量或者函数, 都添加了一个8字节的条目, 对应每个条目生成一个重定位记录.

动态链接器就依赖这个GOT表, 在加载程序的时候, 重定位GOT表中的每个条目, 使其包含目标的正确地址.

这个GOT表实际上就是存放着一个一个变量距离表里条目的偏移量. 在将代码段放入到内存的时候, 动态链接器直接根据偏移量加上去就可以了.

对于调用数据段来说, 这个方式很简单, 就是将本来解析的引用地址直接加上GOT表中的常量就可以了. 但是对于函数(过程), 一般采取延迟绑定的技术, 即将GOT中重定位的记录, 推迟到第一次调用函数的时候.

延迟绑定是因为如果预先对所有的数据和过程进行解析, 可能实际用到的只有很小一部分, 因此采用了GOT和过程链接表(Procedure Linkage Table, PLT)两个数据结构来实现延迟绑定.

GOT放在数据段前, 而PLT是放置在代码段前的.

PLT的结构是一个数组, 每个条目16字节, PLT[0]是一个特殊条目, 跳转到动态链接器中. PLT[1]调用系统启动函数__libc_start_main. 从PLT[2]开始的条目调用用户代码调用的函数.

这段简单看了看, 其实本质上就是第一次运行的时候GOT表对应的值还无法确定, 先跳转到PLT中, 再到动态链接器, 然后确定好GOT的表中的值, 再次执行的时候就可以快速定位到地址了.

库打桩 – 编译时打桩

库打桩是指可以截获对于共享库函数的调用, 取而代之执行自己的代码. 这样提供了对于调用库函数的追踪, 甚至可以替换成任意其他代码.

要实现库打桩, 需要针对要调用的库, 创建一个包装函数, 原型与要调用的目标函数一致. 然后使用特殊的机制, 在调用库函数的时候, 实际上会调用这个包装函数, 然后就可以为所欲为了.

包装函数一般用一个头文件替换成自己的函数, 外加一个包装函数. 包装函数如下:

#ifdef COMPILETIME
#include <stdio.h>
#include <malloc.h>

void *mymalloc(size_t size){
    void *ptr = malloc(size);
    printf("malloc(%d)=%p\n", (int)size, ptr);
    return ptr;
}

void myfree(void *ptr){
    free(ptr);
    printf("free(%p)\n", ptr);
}


#endif

自定义的头文件如下, 名字要和malloc.h一样:

#define malloc(size) mymalloc(size)
#define free(ptr) myfree(ptr)

void *mymalloc(size_t size);

void myfree(void *ptr);

最后是主程序 int.c:

#include <stdio.h>
#include <malloc.h>

int main(){
    int *p = malloc(32);
    free(p);
    return 0;
}

将这三个文件放到同一个目录下边, 然后依次执行:

//用标准的malloc.h来编译出mymalloc.o
gcc -DCOMPILETIME -c mymalloc.c

//打桩
gcc -I. -o intc int.c mymalloc.o

这里的关键是 -I. 参数, 表示在库路径中寻找malloc.h之前, 先在当前目录中寻找malloc.h, 就找到了自定义的malloc.h. 这样编译出的文件使用的就是我们自己的包装函数.

此时运行intc文件, 就会使用包装过后的函数. 即使将intc复制走, 也已经链接好了, 依然使用的是包装后的函数. 这个是编译时打桩, 这种打进去的桩已经写死了, 不能够更换了.

库打桩 – 链接时打桩

链接时打桩就不能采取上边的源文件了, 因为这个符号引用直接就在编译的时候固定死了. Linux的静态链接器支持–wrap f 选项, 表示将符号 f 的引用解析成 __wrap_f, 同时把__real_f解析成f.

为此需要一个新的包装函数:

//mymalloc.c
#ifdef LINKTIME
#include <stdio.h>

void *__real_malloc(size_t size);
void __real_free(void *ptr);

void *__wrap_malloc(size_t size){
    void *ptr= __real_malloc(size);
    printf("malloc(%d)=%p\n", (int) size, ptr);
    return ptr;
}

void __wrap_free(void *ptr){
    __real_free(ptr);
    printf("free(%p)\n", ptr);
}
#endif

看着很扭曲, 其实意思就是: __real_开头的符号, 被直接当做一个原始的符号写在原处, 也就是相当于调用标准库函数. 在函数名称那里则换成了__wrap_, 表示这个函数是包装函数, 不是真的函数.

由于是链接, 可以无需头文件, 把新的 mymalloc.c 和 int.c 放入到同一个目录中, 然后运行:

// 编译生成可重定位目标文件 mymalloc.o
gcc -DLINKETIME -c mymalloc.c

//编译int.c为int.o
gcc -c int.c

//将mymalloc.o 和 int.o 链接起来, 要指出其中的 malloc 符号和 free 符号使用了特殊的标记:
gcc -Wl,--wrap,malloc -Wl,--wrap,free -o int1 int.o mymalloc.o

注意最后一条命令, -Wl表示其后的逗号会被替换成空格, 一个-Wl选项对应一个设置内容. 这样链接好之后, 内部做的工作就是用__wrap_标出名字的自己编写的函数代替了同名的要调用的库函数, 而__real_开头的真库函数被包装在了我们的函数内部.

链接时打桩的好处就是更加灵活, 无需使用头文件来偷换函数, 直接在链接的时候就链接到了包装函数上.

库打桩 – 运行时打桩

编译的时候必须要源代码才行, 链接的时候必须要目标文件. 但针对一个可执行文件, 对其源代码和目标文件都不知道, 要如何才能打桩呢?

这依赖于动态链接器的LD_PRELOAD环境变量.

一个可执行文件在调用动态链接库的时候, 根据前边的学习可以知道, 一定要通过动态连接器来在第一次调用的时候绑定地址.

猫腻就出在动态连接器上, 会到LD_PRELOAD环境变量中去搜索库, 如果能找到, 就不会到其他库和标准库里搜索, 所以只要知道可执行文件需要哪些动态链接库, 就可以偷换掉实际执行的动态链接库. 这就是运行时打桩.

为了运行时打桩, 需要创建一个自己的动态链接库, 源文件还是叫mymalloc.c:

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

void *malloc(size_t size){
    void *(*mallocp)(size_t size);
    char *error;

    mallocp = dlsym(RTLD_NEXT, "malloc");
    if((error = dlerror())!=NULL){
        fputs(error, stderr);
        exit(1);
    }

    char *ptr = mallocp(size);
    printf("malloc(%d)=%p\n", (int) size, ptr);
    return ptr;
}

void free(void *ptr){
    void (*freep)(void *) = NULL;
    char *error;

    if(!ptr){
        return ;
    }

    freep = dlsym(RTLD_NEXT, "free");
    if ((error = dlerror()) != NULL) {
        fputs(error, stderr);
        exit(1);
    }

    freep(ptr);
    printf("free(%p)\n", ptr);

}
#endif

相比链接时打桩还需要修改函数名来标识真函数还是包装函数, 共享库打桩直接编写所需要的同名函数, 在内部调用系统函数dlsym, 到系统的库里去加载对应的库函数指针, 然后通过函数指针调用库函数的功能.

将这个新的 mymalloc.c 和 int.c 放入同一个目录, 然后执行:

//编译动态链接库
gcc -DRUNTIME -shared -fpic -o mymalloc.so mymalloc.c -ldl

//编译 int.c
gcc -o intr int.c

此时有了可执行文件 intr 和动态链接库 mymalloc.so. 回想一下int.c中的内容, 直接使用了标准的头文件和库函数名称.

如果直接执行intr, 可以成功执行, 但不会有结果, 因为intr使用的动态链接库是标准库. 现在修改一下运行环境来执行intr:

LD_PRELOAD="./mymalloc.so" ./intr

此时就能够看到输出:

malloc(32)=0x1681010
free(0x1681010)

这就成功的在运行时打桩了. 运行时打桩, 其实就是通过动态链接器偷天换日了而已.