ELF动态链接(ELF Dynamical link)

ELF Dynamical link

对于静态链接的缺点

  • 对于一些可以共用的库,每个程序在磁盘上都有一份代码副本,加载到内存也是这样,造成了空间的浪费

  • 静态链接的程序更新起来麻烦,需要重新编译并发送给用户

动态链接

思路:把程序分为不同的模块,不再是在编译时进行链接,在程序运行时再链接,这个工作由动态链接器来完成

这里说的不在编译时做链接,其实还是有一个整合的过程的,比如说我们在主程序里调用了printf,如果按照静态编译的话就是把静态库libc.a里的printf.o模块和主程序模块合并,并做重定位工作,动态链接的话其实也会把printf符号信息从动态库里放到主程序模块,但是不做重定位,重定位是留到运行时去做,可以理解为动态链接库给了主程序一个借条,运行时找动态链接库去要,hh

一些共用的模块逐渐就形成了动态链接库.so

so,程序=主模块+动态链接库

我们把动态链接库映射到内存一次,其他程序再加载时需要用到动态链接库,就不用再从磁盘加载一份了,直接建个页目录映射到这一块内存

地址无关

gcc -fPIC -share -o xx.so x.c 可以生成动态链接库

-share

-share时指示生成动态链接库

动态链接要去把不同的共享模块做映射,映射到的虚拟地址空间是随机的这个要求我们共享模块是地址无关的,就是不管映射到哪里都可以执行

这让我想起,之前说开了pie保护的程序的elf heade里的e_type为什么是ET_DYN共享目标文件,而不是ET_EXEC可执行文件的原因

https://grxer.github.io/2023/03/19/23-2-21-elf/

因为开了pie地址我们程序映射的虚拟地址要随机化,就要求我们的可执行程序也是地址无关的,合情合理

地址无关,一种方法是借鉴我们静态链接时方法,就是把链接搬到了运行时模块地址确定后,遍历重定位表去给绝对引用做重定位,因为要去修改指令所以没有办法做到多进程共享代码,这也是只加-share不加-fpic 时使用的重定位方法

-fPIC

-fPIC指示生成位置无关代码(Postive Independent Code, PIC)

static int a;
extern int b;
extern void ext(void);
void bar(){
a=1;
b=2;
}
void foo(){
bar();
ext();
}
grxer@Ubuntu16 /m/h/s/l/chapter7> gcc -m32 -fPIC -shared -o xxx.so test.c
grxer@Ubuntu16 /m/h/s/l/chapter7> file xxx.so
xxx.so: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), dynamically linked, BuildID[sha1]=9a4e584b71c83ff881f77f68b5483d63929ca339, not stripped
grxer@Ubuntu16 /m/h/s/l/chapter7> checksec xxx.so
[*] '/mnt/hgfs/share/link-load-library-code/chapter7/xxx.so'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled

>>> 模块内的过程调用和跳转

foo里调用bar()

000003f0 <bar@plt>:
3f0: ff a3 0c 00 00 00 jmp *0xc(%ebx)
3f6: 68 00 00 00 00 push $0x0
3fb: e9 e0 ff ff ff jmp 3e0 <_init+0x30>
00000576 <foo>:
588: e8 63 fe ff ff call 3f0 <bar@plt>
58d: e8 6e fe ff ff call 400 <ext@plt>
592: 90 nop

模块内指令与指令之间的距离不变,直接按不变的相对地址来寻址就可以地址无关, 0x58d-0x19d(fffffe63)=0x3f0,这里直接用了plt表,先把他理解为就是那个函数地址吧,plt表后面再说

模块内原本可以不采用plt表和got表,直接利用偏移就可以做到地址无关,采用plt和got表是为了防止多个模块中全局符号(模块内的函数,全局变量等)出现重复定义的情况

在动态链接过程中是按最先在某个模块里发现的符号来的,万一是原本对本模块里的符号引用被其他模块里的符号绑定到了,那就是模块间的关系,只能利用got表来做PIC

>>>模块内数据访问

void bar(){
550: 55 push %ebp
551: 89 e5 mov %esp,%ebp
553: e8 41 00 00 00 call 599 <__x86.get_pc_thunk.ax>
558: 05 a8 1a 00 00 add $0x1aa8,%eax
a=1;
55d: c7 80 1c 00 00 00 01 movl $0x1,0x1c(%eax)
564: 00 00 00

指令段和数据段之间的距离是固定的,知道指令地址就可以找到模块内数据

指令地址使用这个 553: e8 41 00 00 00 call 599 <__x86.get_pc_thunk.ax>来获取的,这也是一个一个模块内调用的例子,0x558+0x41=0x599

00000599 <__x86.get_pc_thunk.ax>:
599: 8b 04 24 mov (%esp),%eax
59c: c3 ret

call会把下一条指令地址压栈,就是0x558,然后把0x558赋值给eax,指令地址就保存到了eax

add $0x1aa8,%eax,然后eax=0x1aa8+0x558=0x2000

55d: c7 80 1c 00 00 00 01 movl $0x1,0x1c(%eax)

0x2000再加上0x1c 0x201c是什么呢?

readelf -S  xxx.so
There are 33 section headers, starting at offset 0x1964:

Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[23] .bss NOBITS 00002018 001018 000008 00 WA 0 0 4
grxer@Ubuntu16 /m/h/s/l/chapter7> readelf -s xxx.so
Symbol table '.symtab' contains 67 entries:
Num: Value Size Type Bind Vis Ndx Name
40: 0000201c 4 OBJECT LOCAL DEFAULT 23 a

正好是在未初始化的全局变量和局部静态变量的.bss段的a,也符合我们在静态链接时的分析

>>>模块间数据访问

敲黑板了,模块间才是重点,这个时候相对地址已经不存在了,而是在数据段的开始处设立了一个叫做全局偏移量表(Global Offset Table, GOT)的指针数组(pwn手最爱哈哈)

汇编器为GOT中的每一个项生成一个重定位项,类似于我们静态链接的数据段的重定位表.rel.data,动态链接里叫做.rel.dyn用来修正数据引用,两种表数据结构一样

动态链接器在把引用数据所在模块的加载地址确定后,把正确地址写入他的got,就完成了重定位,同时由于这个数据段是进程私有的,也就实现了内存里的代码段可以多个进程共享

grxer@Ubuntu16 /m/h/s/l/chapter7> readelf -S  xxx.so
There are 33 section headers, starting at offset 0x1964:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[20] .got PROGBITS 00001fe8 000fe8 000018 04 WA 0 0 4

grxer@Ubuntu16 /m/h/s/l/chapter7> readelf -r xxx.so
Relocation section '.rel.dyn' at offset 0x358 contains 9 entries:
Offset Info Type Sym.Value Sym. Name
00001fec 00000206 R_386_GLOB_DAT 00000000 b
void bar(){
550: 55 push %ebp
551: 89 e5 mov %esp,%ebp
553: e8 41 00 00 00 call 599 <__x86.get_pc_thunk.ax>
558: 05 a8 1a 00 00 add $0x1aa8,%eax
a=1;
55d: c7 80 1c 00 00 00 01 movl $0x1,0x1c(%eax)
564: 00 00 00
b=2;
567: 8b 80 ec ff ff ff mov -0x14(%eax),%eax
56d: c7 00 02 00 00 00 movl $0x2,(%eax)
}

同样是利用固定偏移找到got表,eax在前面已经算过是0x2000,再-0x14正好是0x1FEC 正好对应b的got表的位置,往它里面的指针写入值就行了

关于获取pc值,我在资料上还看到一种直接call下一条指令的方式

000357:e8 00 00 00 00    call 00035c
00035c:5b popl %ebx

比call getpc的函数少了ret,速度和空间都有提升

64位程序都是采用rip直接加偏移,32位程序不知道为什么不直接用eip寄存器里的值加偏移

0000000000001139 <bar>:
1139: f3 0f 1e fa endbr64
113d: 55 push %rbp
113e: 48 89 e5 mov %rsp,%rbp
1141: c7 05 e9 2e 00 00 01 movl $0x1,0x2ee9(%rip) # 4034 <a>
1148: 00 00 00
114b: 48 8b 05 86 2e 00 00 mov 0x2e86(%rip),%rax # 3fd8 <b>
1152: c7 00 02 00 00 00 movl $0x2,(%rax)
1158: 90 nop
1159: 5d pop %rbp
115a: c3 ret

>>>模块间过程调用和跳转

对于模块间的调用等,也可以往got表里写入函数地址,就像上面一样,ELF把got分为了.got和.got.plt。 其中.got用来保存全局变量引用的地址,.got.plt用来保存函数引用的地址

类似于我们静态链接的代码段的重定位表.rel.text,动态链接里.rel.plt对函数引用的修正,两种表数据结构一样

[Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
[21] .got.plt PROGBITS 00002000 001000 000014 04 WA 0 0 4
Relocation section '.rel.plt' at offset 0x3c0 contains 2 entries:
Offset Info Type Sym.Value Sym. Name
00002010 00000507 R_386_JUMP_SLOT 00000000 ext

但是在实现的过程中每次程序启动时动态链接器都要对got表做修正,但是被修正的大量函数我们程序使用的只是一小部分,就出现了延迟绑定(lazy binding)的方法,在程序用到时在进行动态链接修正got,是借助过程链接表(Procedure Linkage Table, PLT)[代码段]和got[数据段]配合来实现的

got.plt前三项是特殊值

  • 第一项是.dynamic段的装载地址
  • 第二项是动态链接的标识信息 link_map的地址
  • 第三项是动态链接器的做动态链接的函数入口 _dl_runtime_resolve

.dynamic

.dynamic段保存了动态链接器所需要的基本信息

/* Dynamic section entry.  */

typedef struct
{
Elf32_Sword d_tag; /* Dynamic entry type */
union
{
Elf32_Word d_val; /* Integer value */
Elf32_Addr d_ptr; /* Address value */
} d_un;
} Elf32_Dyn;

image-20230506083401085

地址是在文件里的偏移

grxer@Ubuntu16 /m/h/s/l/chapter7> readelf -d  xxx.so

Dynamic section at offset 0xf04 contains 24 entries:
Tag Type Name/Value
0x00000001 (NEEDED) Shared library: [libc.so.6]
0x0000000c (INIT) 0x3d0
0x0000000d (FINI) 0x5cc
0x00000019 (INIT_ARRAY) 0x1ef8
0x0000001b (INIT_ARRAYSZ) 4 (bytes)
0x0000001a (FINI_ARRAY) 0x1efc
0x0000001c (FINI_ARRAYSZ) 4 (bytes)
0x6ffffef5 (GNU_HASH) 0x138
0x00000005 (STRTAB) 0x27c
0x00000006 (SYMTAB) 0x17c
0x0000000a (STRSZ) 179 (bytes)
0x0000000b (SYMENT) 16 (bytes)
0x00000003 (PLTGOT) 0x2000
0x00000002 (PLTRELSZ) 16 (bytes)
0x00000014 (PLTREL) REL
0x00000017 (JMPREL) 0x3c0
0x00000011 (REL) 0x370
0x00000012 (RELSZ) 80 (bytes)
0x00000013 (RELENT) 8 (bytes)
0x6ffffffe (VERNEED) 0x350
0x6fffffff (VERNEEDNUM) 1
0x6ffffff0 (VERSYM) 0x330
0x6ffffffa (RELCOUNT) 3
0x00000000 (NULL) 0x0

每个模块外的函数都有一个.rel.plt表项,每个表项大小都是16字节,前两项特殊

  • 第一项
    • pushq *GOT[1];jmpq *GOT[2]
    • 因为每一个函数延迟绑定时都需要执行上面这两条指令,为了减少重复代码就把他们放到了固定位置,供调用
  • 第二项是系统启动函数__libc_start_main来初始化环境

剩下的都是jmp *got[id]; push id;jump plt[0];的16字节指令

image-20230506013722990

  1. 第一次调用函数是进入函数所在plt表条目
  2. 第一条plt指令跳到对应got表存储地址,由于刚开始每个GOT条目都指向对应PLT条目的第二条指令,故回到plt条目中
  3. 将函数id压入栈,跳到plt[0]条目
  4. plt[0]把got[1]压入栈通过gotp[2]调用动态链接器,动态链接器确定函数真实地址并填入got条目
  5. 再次回到该函数调用这次及以后可以直接通过plt表找到函数真实地址

看个实际例子,害,还是经典的hello world,看printf是如何lazy binding的

image-20230506014213830

image-20230506014305987

jmp到printf got表项里的值

image-20230506014448462

没有做重定位时默认就是下一条指令,把函数id压栈

jmp到0x4003f0执行

image-20230506015302033

随后jmp到0x601010里存的动态链接的函数入口_dl_runtime_resolve_xsavec

image-20230506015707950

跳到printf的同时往got里写入真实地址

image-20230506015613569

下次就可以直接跳过去了

动态链接器

.interp

interp段规定了可执行程序的动态链接器

grxer@Ubuntu16 /m/h/s/l/chapter7> readelf -p .interp  a.out
String dump of section '.interp':
[ 0] /lib/ld-linux.so.2

/lib/ld-linux.so.2是一个符号链接,这样我们更新动态库的时候只需要把符号链接链接到新库上,而不用更改程序本体

grxer@Ubuntu16 /m/h/s/l/chapter7> ll  /lib/ld-linux.so.2
lrwxrwxrwx 1 root root 25 Apr 22 2021 /lib/ld-linux.so.2 -> i386-linux-gnu/ld-2.23.so*

动态链接器也是一个特殊共享文件,而且他是可执行的

grxer@Ubuntu16 /m/h/s/l/chapter7> file /lib/i386-linux-gnu/ld-2.23.so*
/lib/i386-linux-gnu/ld-2.23.so: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), dynamically linked, BuildID[sha1]=2bea6d29e841fecacc546b9b5ab802bd061e8b29, stripped

他是动态链接的,但是他不能依赖如何库(所以在ldd进行分析的时候,会把他分析为静态链接),在重定位之前不能调用任何放在got里的任何东西,挺夸张的,毕竟自己定义的函数也会被放到got里

grxer@Ubuntu16 /m/h/s/l/chapter7> ldd /lib/i386-linux-gnu/ld-2.23.so*
statically linked

关于ldd

ldd其实是一段shell脚本

grxer@Ubuntu22 ~/D/s/i/nemu (pa1)> file /usr/bin/ldd
/usr/bin/ldd: Bourne-Again shell script, ASCII text executable

他会尝试从下面几个ld去解析文件

RTLDLIST="/lib/ld-linux.so.2 /lib64/ld-linux-x86-64.so.2 /libx32/ld-linux-x32.so.2"

ld 会有如下的选项,用来模拟加载找到依赖,但不会执行文件

--list                list all dependencies and how they are resolved

所以我们下面会得到相同的东西,程序开了pie所以加载地址会不同

grxer@Ubuntu22 ~/D/s/i/nemu (pa1)> ldd a.out
linux-vdso.so.1 (0x00007ffde81e7000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f68d5200000)
/lib64/ld-linux-x86-64.so.2 (0x00007f68d54ab000)
grxer@Ubuntu22 ~/D/s/i/nemu (pa1)> /lib64/ld-linux-x86-64.so.2 --list ./a.out
linux-vdso.so.1 (0x00007fff141e7000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007efd08400000)
/lib64/ld-linux-x86-64.so.2 (0x00007efd08773000)

他也会被随机映射到内存,所以他也会有个重定位的过程,这个过程由他自己完成,叫做自举

image-20230506091935404

所以对于动态链接的程序,操作系统在完成程序映射到进程空间后,不会再像静态链接的程序那样把控制权给到程序的入口地址,而是给到动态链接器的自举代码入口,接下来就是根据dynamic里保存的信息去做符号的整合和重定位,如果动态库有.init或.finit段会去先执行这些段里的指令(例如c++里的全局对象的构造和析构函数),随后才把控制权转移到可执行文件的入口函数

所以我们可以把程序直接交给动态链接器,也可以跑起来

grxer@Ubuntu22 ~/D/s/i/nemu (pa1)> /lib64/ld-linux-x86-64.so.2  ./a.out
hello⏎

动态符号表 .dynsym

.symtab一般保存了所有符号,.dynsym只保存了动态链接的符号,数据结构用的是一样的,.dynstr保存了动态符号字符串表

grxer@Ubuntu16 /m/h/s/l/chapter7> readelf -s xxx.so

Symbol table '.dynsym' contains 16 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab
2: 00000000 0 NOTYPE GLOBAL DEFAULT UND b
3: 00000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.1.3 (2)
4: 00000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
5: 00000000 0 NOTYPE GLOBAL DEFAULT UND ext
6: 00000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses
7: 00000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable
8: 00002018 0 NOTYPE GLOBAL DEFAULT 22 _edata
9: 00000570 50 FUNC GLOBAL DEFAULT 12 bar
10: 00002020 4 OBJECT GLOBAL DEFAULT 23 x
11: 000005a2 35 FUNC GLOBAL DEFAULT 12 foo
12: 00002024 0 NOTYPE GLOBAL DEFAULT 23 _end
13: 00002018 0 NOTYPE GLOBAL DEFAULT 23 __bss_start
14: 000003d0 0 FUNC GLOBAL DEFAULT 9 _init
15: 000005cc 0 FUNC GLOBAL DEFAULT 13 _fini

显示的运行时链接

#include <dlfcn.h>
#include <stdio.h>
#define SETUP_STACK \
i = 2; \
while (++i < argc - 1) { \
switch (argv[i][0]) { \
case 'i': \
asm volatile("push %0" ::"r"(atoi(&argv[i][1]))); \
esp += 4; \
break; \
case 'd': \
atof(&argv[i][1]); \
asm volatile( \
"subl $8,%esp\n" \
"fstpl (%esp)"); \
esp += 8; \
break; \
case 's': \
asm volatile("push %0" ::"r"(&argv[i][1])); \
esp += 4; \
break; \
default: \
printf("error argument type"); \
goto exit_runso; \
} \
}
#define RESTORE_STACK asm volatile("add %0,%%esp" ::"r"(esp))
int main(int argc, char* argv[]) {
void* handle;
char* error;
int i;
int esp = 0;
void* func;
handle = dlopen(argv[1], RTLD_NOW);
if (handle == 0) {
printf("Can't find library: %s\n", argv[1]);
return -1;
}
func = dlsym(handle, argv[2]);
if ((error = dlerror()) != NULL) {
printf("Find symbol %s error: %s\n", argv[2], error);
goto exit_runso;
}
switch (argv[argc - 1][0]) {
case 'i': {
int (*func_int)() = func;
SETUP_STACK;
int ret = func_int();
RESTORE_STACK;
printf("ret = %d\n", ret);
break;
}
case 'd': {
double (*func_double)() = func;
SETUP_STACK;
double ret = func_double();
RESTORE_STACK;
printf("ret = %f\n", ret);
break;
}
case 's': {
char* (*func_str)() = func;
SETUP_STACK;
char* ret = func_str();
RESTORE_STACK;
printf("ret = %s\n", ret);
break;
}
case 'v': {
void (*func_void)() = func;
SETUP_STACK
func_void();
RESTORE_STACK;
printf("ret = void");
break;
}
} // end of switch
exit_runso:
dlclose(handle);
}
grxer@Ubuntu16 /m/h/s/l/chapter7> gcc -m32 get.c -ldl
get.c: In function ‘main’:
get.c:8:46: warning: implicit declaration of function ‘atoi’ [-Wimplicit-function-declaration]
asm volatile("push %0" ::"r"(atoi(&argv[i][1]))); \
^
get.c:47:13: note: in expansion of macro ‘SETUP_STACK’
SETUP_STACK;
^
get.c:12:17: warning: implicit declaration of function ‘atof’ [-Wimplicit-function-declaration]
atof(&argv[i][1]); \
^
get.c:47:13: note: in expansion of macro ‘SETUP_STACK’
SETUP_STACK;
^
grxer@Ubuntu16 /m/h/s/l/chapter7>
./a.out /lib/i386-linux-gnu/libm-2.23.so sin d2.0 d
ret = 0.909297

由于库里有非常多的函数,我们不可能为每一个函数都定于一个函数指针,这段代码有意思的点在于他把返回值的情况都列了出来,然后调用空函数前自己去传参,调用后自己去平栈,其中定义了全局变量esp来记录需要平栈的大小