关于 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
方法就会指向上面 .plt
的 0x1050
的地址,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
工具是 gdb
(pwndbg);我使用的操作系统是 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
Section
的 puts
方法上。
通过 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
是非常复杂的,我自己头都看大了,等以后对汇编有一定的熟悉后再来看这个问题可能会更加容易。