关于 ELF 格式文件的笔记(三)

636 阅读9分钟

关于 ELF 格式文件的笔记(三)

我在前两篇文章中介绍了 C 语言的基本编译过程,ELF 文件基本格式和如何通过 ELF 中的 Section 调用一个内部方法等内容,还没有读过前两篇文章的同学建议先读一下:
关于 ELF 格式文件的笔记(一)
关于 ELF 格式文件的笔记(二)

书接上回,本篇文章主要介绍如何调用动态链接库( .so 库)中的方法,有一说一这部分确实比较复杂,我也看了很多大佬的文章,有的也是晦涩难懂,如果文中有错的地方还希望你能够指出来😂,那么开始今天的内容吧。

基础知识

饥饿加载( Eager Binding )和懒加载( Lazy Binding

我们前面知道我们想要调用某个 .so 库中的方法时,我们需要去查找对应方法在内存中的地址,拿到方法的地址后我们才能够继续执行,饥饿加载和懒加载就是两种不同的加载方法的时机。

饥饿加载

在我们的程序加载到内存时,系统就会解析 ELF 中的各种 Section 信息,为他们分配内存等等,其中还包含 .so 库的信息,同时还会解析我们程序中用到的 .so 中的符号信息(通常是方法和全局变量),然后通过 linker (ld.so) 在我们依赖的 .so 库中去查找这些符号的地址,然后存放到 GOT(后面会介绍) 中,当程序调用这些方法的时候就可以直接跳转到对应的地址。Android 中加载 .so 库就是用的这种方式。

懒加载

在程序加载到内存时,是不会加载 .so 中对应符号的地址,只有当调用该符号后才会去从对应 .so 中去加载地址,当下次再使用该符号时就不用再去加载,而直接用上次加载成功的缓存地址。在 Linux 中默认就是使用的这种加载方式。

相关的 Section

.got

全称是 Global Offset Table,简称 GOT,它记录了用到的 .so 的符号中的地址。

.got.plt

可以理解为他也是 GOT 的一部分,当请求的符号没有加载时,会跳转到 PLT 中通过 linker 去加载对应的符号地址然后写入到 GOT 中,.got 中也包括 .got.plt 的地址。

.plt

全称是 Procedure Linkage Table,简称 PLT,翻译成中文也就叫做程序链接表,.text 中请求 .so 中的符号时,都是指向它的,它会去从 GOT 中拿地址,如果 GOT 中没有拿到地址,它就会通过 linker 去查询对应的符号的地址,然后写入到 GOT 中。

以下是反编译后的 .plt 的汇编代码:

main:     文件格式 elf32-i386


Disassembly of section .plt:

00001030 <__libc_start_main@plt-0x10>:
    1030:	ff b3 04 00 00 00    	push   0x4(%ebx)
    1036:	ff a3 08 00 00 00    	jmp    *0x8(%ebx)
    103c:	00 00                	add    %al,(%eax)
	...

00001040 <__libc_start_main@plt>:
    1040:	ff a3 0c 00 00 00    	jmp    *0xc(%ebx)
    1046:	68 00 00 00 00       	push   $0x0
    104b:	e9 e0 ff ff ff       	jmp    1030 <_init+0x30>

00001050 <puts@plt>:
    1050:	ff a3 10 00 00 00    	jmp    *0x10(%ebx)
    1056:	68 08 00 00 00       	push   $0x8
    105b:	e9 d0 ff ff ff       	jmp    1030 <_init+0x30>

.text 代码段中调用 puts 方法就会指向上面 .plt0x1050 的地址,1050: ff a3 10 00 00 00 jmp *0x10(%ebx) (跳转的地址都是 00 00 00 还需要 .rel.plt 重定向) 这段地址就是跳转到 GOT 中,如果这里查询到地址就结束了,可以供 .text 使用了;但是如果没有查询到还会继续执行后续的指令,然后会跳转到 Ox1030,也就是 .plt 的开头,那段指令会请求 linker (ld.so) 去查询需要的符号的地址,这个地址会存在 GOT 中,后续再使用到该符号就不用 linker 再去查询了,linker 如何查询的对于我们来说就是一个黑盒了。

.plt.got

它也算是 PLT 的一部分,和 .got 不同的是,如果查询 GOT 失败,它不会调用 linker 去查询,也就是它有信心不用再去查询。
下面是一个 .plt.got

main:     文件格式 elf32-i386


Disassembly of section .plt.got:

00001060 <__cxa_finalize@plt>:
    1060:	ff a3 18 00 00 00    	jmp    *0x18(%ebx)
    1066:	66 90                	xchg   %ax,%ax

我们看看 0x1060 那段代码,只有一段 jmp 命令,那段是跳转到 GOT 中查询的命令,而没有跳转到 PLT 去通过 linker 查询的指令。

.rel.plt

.plt 中的重定向表。

重定位节 '.rel.plt' at offset 0x3c4 contains 2 entries:
 偏移量     信息    类型              符号值      符号名称
00003fe4  00000107 R_386_JUMP_SLOT   00000000   __libc_start_main@GLIBC_2.34
00003fe8  00000407 R_386_JUMP_SLOT   00000000   puts@GLIBC_2.0

.rel.dyn

看上去是动态链接的全局变量的重定向表,具体我还有点没看懂。

重定位节 '.rel.dyn' at offset 0x384 contains 8 entries:
 偏移量     信息    类型              符号值      符号名称
00003ed8  00000008 R_386_RELATIVE   
00003edc  00000008 R_386_RELATIVE   
00003ff8  00000008 R_386_RELATIVE   
00004004  00000008 R_386_RELATIVE   
00003fec  00000206 R_386_GLOB_DAT    00000000   _ITM_deregisterTM[...]
00003ff0  00000306 R_386_GLOB_DAT    00000000   __cxa_finalize@GLIBC_2.1.3
00003ff4  00000506 R_386_GLOB_DAT    00000000   __gmon_start__
00003ffc  00000606 R_386_GLOB_DAT    00000000   _ITM_registerTMCl[...]

.dynsym

动态链接相关的符号表:

Symbol table '.dynsym' contains 8 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000     0 FUNC    GLOBAL DEFAULT  UND _[...]@GLIBC_2.34 (2)
     2: 00000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterT[...]
     3: 00000000     0 FUNC    WEAK   DEFAULT  UND [...]@GLIBC_2.1.3 (3)
     4: 00000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.0 (4)
     5: 00000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
     6: 00000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMC[...]
     7: 00002004     4 OBJECT  GLOBAL DEFAULT   16 _IO_stdin_used

.dynamic

动态链接相关的信息:

Dynamic section at offset 0x2ee0 contains 27 entries:
  标记        类型                         名称/值
 0x00000001 (NEEDED)                     共享库:[libc.so.6]
 0x0000000c (INIT)                       0x1000
 0x0000000d (FINI)                       0x11e0
 0x00000019 (INIT_ARRAY)                 0x3ed8
 0x0000001b (INIT_ARRAYSZ)               4 (bytes)
 0x0000001a (FINI_ARRAY)                 0x3edc
 0x0000001c (FINI_ARRAYSZ)               4 (bytes)
 0x6ffffef5 (GNU_HASH)                   0x1ec
 0x00000005 (STRTAB)                     0x28c
 0x00000006 (SYMTAB)                     0x20c
 0x0000000a (STRSZ)                      166 (bytes)
 0x0000000b (SYMENT)                     16 (bytes)
 // ...

其中还包含了我们需要的共享库:libc.so.6

Debug 验证从 PLT 获取方法地址

从前面的了解我们也知道 PLT / GOT 加载 .so 中的方法地址是发生在运行时,我们想要通过 ELF 文件更加直观地看到这个加载过程是不行的,所以我们得写一个程序跑起来,然后通过 debug 打断点来验证我们上面学习到的知识。 我使用的 Debug 工具是 gdbpwndbg);我使用的操作系统是 Ubuntu 22.04;构建的测试程序是 x86 32 位程序。

让我们回到最初的起点,以下就是我们的测试代码:

#include <stdio.h>

int main() {
    printf("Hello World!\n");
    return 0;
}

不知道这个 Hello Wrold! 是多少人的第一个程序😄,这个时候你可能会疑惑,上面的代码有调用某个 .so 库的方法吗?哈哈,当然有,printf 就是一个系统库的方法,这个系统库是 libc.so,而 printf 对应的方法就是 puts
我们可以在 .dynamic 中找到依赖的库:

Dynamic section at offset 0x2ee0 contains 27 entries:
  标记        类型                         名称/值
 0x00000001 (NEEDED)                     共享库:[libc.so.6]
 // ...

我们可以在 .dynsym 中找到我们用到的动态链接库中的符号:

Symbol table '.dynsym' contains 8 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     // ...
     4: 00000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.0 (4)
     // ...

上面的 puts@GLIBC_2.0 (4) 就是我们 printf 方法的符号,我们这里看到表示目标 Section 编号的 Ndx 写的是 UND,也就是没有定义,表示需要从 .so 中动态加载,上一篇文章中我讲到的本地方法的符号对应的 Ndx 就是我们的 .text,方法地址是可以直接拿到的。

好了,不说废话了,开始今天的实验了。

通过 pwndbg -q [可执行文件] 进入 pwndbg 的控制台:

tans@tans-MS-7B89:~/Desktop/plt_32$ pwndbg -q main
Reading symbols from main...
(No debugging symbols found in main)
Cannot convert between character sets `UTF-32' and `UTF-8'
pwndbg: loaded 147 pwndbg commands and 42 shell commands. Type pwndbg [--shell | --all] [filter] for a list.
pwndbg: created $rebase, $ida GDB functions (can be used with print/break)
------- tip of the day (disable with set show-tips off) -------
Pwndbg resolves kernel memory maps by parsing page tables (default) or via monitor info mem QEMU gdbstub command (use set kernel-vmmap-via-page-tables off for that)

进入控制台后通过 disassemble main 指令来反编译 main() 函数:

pwndbg> disassemble main
Dump of assembler code for function main:
   0x0000119d <+0>:	lea    ecx,[esp+0x4]
   0x000011a1 <+4>:	and    esp,0xfffffff0
   0x000011a4 <+7>:	push   DWORD PTR [ecx-0x4]
   0x000011a7 <+10>:	push   ebp
   0x000011a8 <+11>:	mov    ebp,esp
   0x000011aa <+13>:	push   ebx
   0x000011ab <+14>:	push   ecx
   0x000011ac <+15>:	call   0x11d9 <__x86.get_pc_thunk.ax>
   0x000011b1 <+20>:	add    eax,0x2e27
   0x000011b6 <+25>:	sub    esp,0xc
   0x000011b9 <+28>:	lea    edx,[eax-0x1fd0]
   0x000011bf <+34>:	push   edx
   0x000011c0 <+35>:	mov    ebx,eax
   0x000011c2 <+37>:	call   0x1050 <puts@plt>
   0x000011c7 <+42>:	add    esp,0x10
   0x000011ca <+45>:	mov    eax,0x0
   0x000011cf <+50>:	lea    esp,[ebp-0x8]
   0x000011d2 <+53>:	pop    ecx
   0x000011d3 <+54>:	pop    ebx
   0x000011d4 <+55>:	pop    ebp
   0x000011d5 <+56>:	lea    esp,[ecx-0x4]
   0x000011d8 <+59>:	ret
End of assembler dump.

这个 dump 和我们使用 objdump 反编译的结果没有什么两样,我们注意一下 +37 的位置,这里就是调用 puts 方法的地方,我们看到它指向的地址是 0x1050 也就是 .plt 的地址,后面还有提示。我们再看看 .plt 中的内容:

main:     文件格式 elf32-i386


Disassembly of section .plt:

00001030 <__libc_start_main@plt-0x10>:
    1030:	ff b3 04 00 00 00    	push   0x4(%ebx)
    1036:	ff a3 08 00 00 00    	jmp    *0x8(%ebx)
    103c:	00 00                	add    %al,(%eax)
	...

00001040 <__libc_start_main@plt>:
    1040:	ff a3 0c 00 00 00    	jmp    *0xc(%ebx)
    1046:	68 00 00 00 00       	push   $0x0
    104b:	e9 e0 ff ff ff       	jmp    1030 <_init+0x30>

00001050 <puts@plt>:
    1050:	ff a3 10 00 00 00    	jmp    *0x10(%ebx)
    1056:	68 08 00 00 00       	push   $0x8
    105b:	e9 d0 ff ff ff       	jmp    1030 <_init+0x30>

0x1050 就是 puts 方法对应的地址处理指令。

我们继续在 call 指令的位置打一个断点,通过 break *main+37 的指令打断点,然后通过 r 命令让程序跑起来。

pwndbg> break *main+37
Breakpoint 1 at 0x11c2
pwndbg> r
Starting program: /home/tans/Desktop/plt_32/main 
warning: Unable to find libthread_db matching inferior's thread library, thread debugging will not be available.

Breakpoint 1, 0x565561c2 in main ()

断点所在位置对应的虚拟内存中的地址是 0x565561c2

然后使用 si 命令,让其执行下一条指令:

pwndbg> si
0x56556050 in puts@plt ()

我们看到和我们想的一样,跳转到 PLT 里面去了,对应的虚拟内存地址是 0x56556050

我们通过 x/4i $pc 查看当前程序计数器中指向的内容:

pwndbg> x/4i $pc
=> 0x56556050 <puts@plt>:	jmp    DWORD PTR [ebx+0x10]
   0x56556056 <puts@plt+6>:	push   0x8
   0x5655605b <puts@plt+11>:	jmp    0x56556030

哈哈,我们看到和我们在 ELF 中看到的指令一样,只是地址被换成了虚拟内存中的地址,第一个 jmp 就是跳转到 GOT 的,而第二个就是跳转到 PLT 头部,然后请求 ld.so 执行地址查询。

继续使用 si 执行下一条指令。

pwndbg> si
0xf7c732a0 in puts () from /lib/i386-linux-gnu/libc.so.6

诶诶诶诶,怎么就进入到了 libc.so 的地址中,地址是 0xf7c732a0,讲道理我们这是第一次执行 puts() 方法啊,应该是懒加载才对啊,这时按照我想的是应该执行 PLT 中的下一条指令然后跳转到 ld.so 中执行查询操作才对啊。😂,不管了,继续看看。

通过 info symbol $pc 指令看看当前的程序计数器所在位置的信息:

pwndbg> info symbol $pc
puts in section .text of /lib/i386-linux-gnu/libc.so.6

看到在 libc.so.6.text Sectionputs 方法上。

通过 x/i4 $pc 看看 puts() 方法中的内容:

pwndbg> x/4i $pc
=> 0xf7c732a0 <puts>:	endbr32
   0xf7c732a4 <puts+4>:	push   ebp
   0xf7c732a5 <puts+5>:	mov    ebp,esp
   0xf7c732a7 <puts+7>:	push   edi

也没啥好看的了,就只是 puts() 方法中的指令了,然后他执行完就会把我们的 Hello World! 打控制台上了。

最终我们的实验测试没有看到 ld.so 动态加载 .so 符号地址的过程,我使用了 x86_64,也自己写了一个 .so 库,都没有看到这个过程,不知道是不是我的 ld.so 的配置是饥饿加载。

最后

我的实验中没有看到 ld.so 动态加载 .so 地址的过程,只看到了从 PLT 中拿已经加载好地址的过程,可能是我自己电脑的环境配置问题(你也可以自己试试😄)。我的篇文章是讲得比较浅显的,我看到国外很多大佬的文章,讲 GOT / PLT 是非常复杂的,我自己头都看大了,等以后对汇编有一定的熟悉后再来看这个问题可能会更加容易。

参考文章

systemoverlord.com/2017/03/19/…