浅析程序的装载和运行
作者:互联网
1 ELF 文件格式
1.1 ELF
ELF(Executable and Linkable Format)是一种对象文件的格式,是一种用于二进制文件、可执行文件、目标代码、共享库和核心转储格式文件。类似于 Windows 的 PE,ELF 是 Linux 主要的可执行文件的格式。
ELF 文件由 4 部分组成,分别是 ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。如下图所示1,就是一个典型的 ELF 文件。
实际上,一个文件中不一定包含全部内容,而且它们的位置也未必如同所示这样安排,只有 ELF 头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。
- ELF header: 描述整个文件的组织。
- Program Header Table: 描述文件中的各种segments,用来告诉系统如何创建进程映像的。
- sections 或者 segments:segments是从运行的角度来描述elf文件,sections是从链接的角度来描述elf文件,也就是说,在链接阶段,我们可以忽略program header table来处理此文件,在运行阶段可以忽略section header table来处理此程序(所以很多加固手段删除了section header table)。从图中我们也可以看出,segments与sections是包含的关系,一个segment包含若干个section。
- Section Header Table: 包含了文件各个section的属性信息。
1.2 示例
以最简单的一个 C 程序为例
#include <stdio.h>
#include <unistd.h>
void add(int a, int b)
{
printf("%d + %d = %d\n", a, b, a + b);
}
int main(int argc, char const *argv[])
{
add(1, 2);
sleep(1);
return 0;
}
普通的一条 gcc 编译命令
gcc test.c -o test
实际上包括以下几个阶段
- 预处理 gcc -E,进行编译之前的预处理操作。
- 编译 gcc -S,编译成汇编文件。
- 汇编 as,将编译的文件汇编成机器码。
- 链接 ld,将不同的目标文件链接在一起。
每个过程不加以叙述,这不是本次讲解的重点。
使用 readelf --segments
查看 ELF 文件的段(其实就是读取 Program Header Table)
lys@ubuntu:~/Documents/workspace$ readelf --segments test -W
Elf file type is DYN (Shared object file)
Entry point 0x1060
There are 11 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x000268 0x000268 R 0x8
INTERP 0x0002a8 0x00000000000002a8 0x00000000000002a8 0x00001c 0x00001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x0005a0 0x0005a0 R 0x1000
LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x00021d 0x00021d R E 0x1000
LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x000180 0x000180 R 0x1000
LOAD 0x002de8 0x0000000000003de8 0x0000000000003de8 0x000250 0x000258 RW 0x1000
DYNAMIC 0x002df8 0x0000000000003df8 0x0000000000003df8 0x0001e0 0x0001e0 RW 0x8
NOTE 0x0002c4 0x00000000000002c4 0x00000000000002c4 0x000044 0x000044 R 0x4
GNU_EH_FRAME 0x002014 0x0000000000002014 0x0000000000002014 0x000044 0x000044 R 0x4
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
GNU_RELRO 0x002de8 0x0000000000003de8 0x0000000000003de8 0x000218 0x000218 R 0x1
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
03 .init .plt .plt.got .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .dynamic .got .got.plt .data .bss
06 .dynamic
07 .note.gnu.build-id .note.ABI-tag
08 .eh_frame_hdr
09
10 .init_array .fini_array .dynamic .got
使用 readelf --sections
查看 ELF 文件的节
lys@ubuntu:~/Documents/workspace$ readelf --sections test -W
There are 30 section headers, starting at offset 0x39b0:
Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 0] NULL 0000000000000000 000000 000000 00 0 0 0
[ 1] .interp PROGBITS 00000000000002a8 0002a8 00001c 00 A 0 0 1
[ 2] .note.gnu.build-id NOTE 00000000000002c4 0002c4 000024 00 A 0 0 4
[ 3] .note.ABI-tag NOTE 00000000000002e8 0002e8 000020 00 A 0 0 4
[ 4] .gnu.hash GNU_HASH 0000000000000308 000308 000024 00 A 5 0 8
[ 5] .dynsym DYNSYM 0000000000000330 000330 0000c0 18 A 6 1 8
[ 6] .dynstr STRTAB 00000000000003f0 0003f0 00008a 00 A 0 0 1
[ 7] .gnu.version VERSYM 000000000000047a 00047a 000010 02 A 5 0 2
[ 8] .gnu.version_r VERNEED 0000000000000490 000490 000020 00 A 6 1 8
[ 9] .rela.dyn RELA 00000000000004b0 0004b0 0000c0 18 A 5 0 8
[10] .rela.plt RELA 0000000000000570 000570 000030 18 AI 5 23 8
[11] .init PROGBITS 0000000000001000 001000 000017 00 AX 0 0 4
[12] .plt PROGBITS 0000000000001020 001020 000030 10 AX 0 0 16
[13] .plt.got PROGBITS 0000000000001050 001050 000008 08 AX 0 0 8
[14] .text PROGBITS 0000000000001060 001060 0001b1 00 AX 0 0 16
[15] .fini PROGBITS 0000000000001214 001214 000009 00 AX 0 0 4
[16] .rodata PROGBITS 0000000000002000 002000 000012 00 A 0 0 4
[17] .eh_frame_hdr PROGBITS 0000000000002014 002014 000044 00 A 0 0 4
[18] .eh_frame PROGBITS 0000000000002058 002058 000128 00 A 0 0 8
[19] .init_array INIT_ARRAY 0000000000003de8 002de8 000008 08 WA 0 0 8
[20] .fini_array FINI_ARRAY 0000000000003df0 002df0 000008 08 WA 0 0 8
[21] .dynamic DYNAMIC 0000000000003df8 002df8 0001e0 10 WA 6 0 8
[22] .got PROGBITS 0000000000003fd8 002fd8 000028 08 WA 0 0 8
[23] .got.plt PROGBITS 0000000000004000 003000 000028 08 WA 0 0 8
[24] .data PROGBITS 0000000000004028 003028 000010 00 WA 0 0 8
[25] .bss NOBITS 0000000000004038 003038 000008 00 WA 0 0 1
[26] .comment PROGBITS 0000000000000000 003038 00001d 01 MS 0 0 1
[27] .symtab SYMTAB 0000000000000000 003058 000630 18 28 45 8
[28] .strtab STRTAB 0000000000000000 003688 00021b 00 0 0 1
[29] .shstrtab STRTAB 0000000000000000 0038a3 000107 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
.got 与 .got.plt
ELF 将 GOT 拆分成了两个表,叫 .got 和 .got.plt。GOT(Global Offset Table)全局偏移表,提供对共享函数库的访问入口,由动态链接器在运行时修改2。
- .got: 全局变量的引用地址
- .got.plt: 外部函数的引用地址
备注:在 Ubuntu 16.04 以及 Kali 2019 上,编译的程序是有 .got.plt 节的,但是在 Ubuntu 18.04 及以上版本时,并没有 .got.plt 节,原先 .got.plt 合并成 .got。这可能是高版本 gcc 或者链接器、操作系统等进行了相应的修改。
bss、data 与 rodata
- bss:未初始化或者初始化为 0 的全局变量或静态变量,权限为读写。
- data:已经初始化的全局变量或静态变量,权限为读写。
- rodata:常量、字符串。
.symtab 与 .dynsym
- .symtab 保存了所有的符号信息(ElfN_Sym 类型)。
- .dynsym 保存了引用来自外部文件符号的全局符号,如 printf 这样的库函数
.dynsym 是 .symtab 的子集,那么为啥需要重合的 .dynsym 节呢?这是因为 .symtab 在运行时,并不会装载到内存,而 .dynsym 是运行时必须的,需要被载入内存。
上图中 .dynsym 标记为 A(Access),即这个节会装载到虚拟空间中。
.ctors与 .dtors
这是在 C++ 中,我们会关注的两个节,即构造器和析构器。这两个节保存了指向析构函数和析构函数的指针,构造函数是在 main 函数执行前需要执行的代码;析构函数是在 main 函数之后需要执行的代码。
2 程序的装载
什么是程序的装载?就是说可执行文件按照文件中的段映射(加载)到虚拟地址空间。所以与装载紧密相关的是 ELF 中的 Program Header Table 以及 Segments。
ELF 哪些段会被映射到虚拟地址空间呢?
LOAD 类型的 segment 是真正需要被装载的。而 IDA 能够解析出的正是这些段。上图中,有一个明显不一样的地方,在 0x002de8
的地方,文件偏移与虚拟地址出现了偏差,这说明 ELF 文件并非是将整体直接加载到内存中,也并非是文件偏移了多少,在内存中就偏移了多少。
这一进步体现在 IDA 十六进制视图中,地址出现了断层,非连续,说明 IDA 不会解析完整的 ELF,而是读取那些会被加载至内存(虚拟地址空间)的部分,也就是 LOAD 类型的段。
Linux 将进程虚拟地址空间中的一个段叫做虚拟内存区域(VMA,Virutal Memory Area)。对于相同权限的节,合并到一起,形成一个段进行映射。如下图所示
左侧是 /proc/*/maps
进程虚拟地址空间的内存映射,右侧是 IDA (Ctrl + s)读取的 ELF 程序头表,实际上也是反应了 ELF 文件需要加载进内存的部分。
由于可执行文件在装载时实际上时被映射的虚拟空间,所以可执行文件很多时候又被叫做映像文件(image)3
程序加载的基地址=映像基地址(image base),由于开启了地址随机化,每次运行并不是固定的,这里是 0x0000555555554000
值得注意的一点是,上图中绿色部分,IDA 解析出的节的权限可写,而实际的虚拟空间中,只读,这一点值得深思?
3 运行
3.1 main 函数启动之前的一些操作
main 函数只是用户代码的入口,它会由系统库去调用,在 main 函数之前,系统库会做一些初始化工作,比如分配全局变量的内存,初始化堆、线程等,当 main 函数执行完后,会通过 exit()
函数做一些清理工作。
编译器为可执行文件增加了一个启动例程4,ELF 头部的入口地址就指向该启动例程,然后在启动例程中有下面一句:__libc_start_main@plt 通过它调用 C 库的 _libc_start_main,再调用我们的 main。
由于 main 函数是被启动例程调用的,所以从 main 函数 return 时仍返回到启动例程中,main 函数的返回值被启动例程得到,如果将启动例程表示成等价的 C 代码(实际上启动例程一般是直接用汇编写的),则它调用main函数的形式是:exit(main(argc, argv))
;
3.2 实际验证
使用 gdb 调试,在程序即将退出的时候,我们发现函数调用栈如下
说明 main 函数运行之前的流程应该是这样的
_start(启动例程)
+
|
v
__libc_start_main
+
|
v
main
调用栈已经处于退出的状态,给出的地址并非是函数实际开始的地址,而是上个函数结束时的返回地址。例如 _start
函数
$ python -c "print '0x%x' % (0x508a - 0x4000)"
0x108a
该地址指向的是 _start
函数中的 hlt
指令,代表 __libc_start_main
退出时,_start
即将要执行的指令。
0000000000001060 <_start>:
1060: 31 ed xor ebp,ebp
1062: 49 89 d1 mov r9,rdx
1065: 5e pop rsi
1066: 48 89 e2 mov rdx,rsp
1069: 48 83 e4 f0 and rsp,0xfffffffffffffff0
106d: 50 push rax
106e: 54 push rsp
106f: 4c 8d 05 9a 01 00 00 lea r8,[rip+0x19a] # 1210 <__libc_csu_fini>
1076: 48 8d 0d 33 01 00 00 lea rcx,[rip+0x133] # 11b0 <__libc_csu_init>
107d: 48 8d 3d f4 00 00 00 lea rdi,[rip+0xf4] # 1178 <main>
1084: ff 15 56 2f 00 00 call QWORD PTR [rip+0x2f56] # 3fe0 <__libc_start_main@GLIBC_2.2.5>
108a: f4 hlt
108b: 0f 1f 44 00 00 nop DWORD PTR [rax+rax*1+0x0]
4 总结
本次只是个人在分析maps虚拟空间时,发现可执行文件总是会分几个区映射,进而产生了疑问:一个二进制程序,到底是哪些部分会映射到内存中?《程序员的自我修养》早就告诉我们答案了,但是之前理解的还是很肤浅,看书只能让人有个大概的印象,很多东西只有亲自实践才能体会到背后的原理。
标签:00,plt,PROGBITS,程序,ELF,装载,got,main,浅析 来源: https://blog.csdn.net/song_lee/article/details/110091840