关于精灵的长篇故事。 ELF 文件解释。第1部分
简介
ELF(可执行和可链接格式)二进制格式通常用于类 Unix 系统中。它包含可执行指令以及加载它所需的所有依赖项、分配内存和执行所需的元数据。这些文件包含代码本身、此代码使用的静态数据以及执行它所需的所有元数据。
是否可以在没有这些文件结构的 Linux 环境中执行某些代码?
Linux 环境中的每个程序都是使用execve系统调用来启动的,该系统调用检查二进制文件的解释器路径,它是 ELF 文件元数据的一部分,因此只有指令的二进制文件不会被执行,所以答案是 NO。它可以由其他代码加载和执行,但需要先加载此代码。
当内核检测到 ELF 文件时,它会运行 Dynamic Loader,通常为ld.so。
在几篇博文中,我想介绍 ELF 格式并解释加载过程。我已经在 Rust](https://github.com/BielosX/drow)中编写了[ELF 加载器,因此请查看它以获取更多详细信息。
您可以在此处找到 ELF64 规范
简单示例
好的,让我们从使用NASM为 amd64 架构编写的简单汇编代码开始。它使用Linux 系统调用所以它不需要任何库。
bits 64
section .text
global _start
%define WRITE 1
%define EXIT 60
%define STDOUT 1
_start:
mov rax, WRITE
mov rdi, STDOUT
lea rsi, msg
mov rdx, msg_len
syscall
mov rax, EXIT
mov rdi, 0
syscall
section .rodata
msg: db 'Hello World!', 0, 10
msg_len: equ $ - msg
您可以使用以下方法构建它:
nasm -f elf64 -o hello.o hello.asm
ld -o hello hello.o
对于那些不讲汇编的人,从一开始的简短解释:
第一行通知这是 64 位架构。第二行通知汇编器,它下面的所有内容都是.text部分内容。本节是 ELF 结构的一部分,它包含可执行代码。下一行将_start符号定义为全局符号,这意味着它将保存在 ELF 文件中并且对链接器可见。_start是GNU LInker使用的默认程序条目,您可以将其更改为其他任何值,例如my_entry,然后使用以下命令运行链接器:
ld -o hello --entry my_entry hello.o
标签_start:开始程序代码。它根据AMD64 ABI加载带有系统参数的正确寄存器,寄存器 RDI、RSI 和 RDX 用于传递第一个、第二个和第三个函数参数。 RAX 寄存器用于选择合适的核函数。函数号_1_代表write个系统调用,函数号_60_代表exit个系统调用。write函数的第一个参数是文件描述符。在 Linux 环境中,每个进程从执行开始就打开了三个文件描述符:
0:标准输入
1:标准输出
2:标准错误
还需要将一些字符串定义为write输入。它可以在称为.rodata的其他 ELF 部分中完成,将放入该部分的所有内容都将不可执行也不可写。
所以这两行:
msg: db 'Hello World!', 0, 10
msg_len: equ $ - msg
声明.rodata部分应包含 ASCII 字符串“Hello World\0\n”。msg_len计算消息长度,它将由 NASM 计算,最终的 ELF 文件将包含值 13。因为write函数需要一个指向数据缓冲区的指针LEA指令用于加载字符串的有效地址。
ELF64结构
标题
输出文件格式以名为_Elf Header_的结构开头,它以_16_字节的ELF文件标识符开头,其中前_3_字节是编码“ELF”字符串的ASCII字符。其他 13 字节包含有关版本的信息,Endianess以及验证此 ELF 文件是否可以在我们的架构上运行所需的其他信息。
其他重要的 Elf Header 字段是:
-
entry:它包含程序入口点的内存地址,对于我们的代码来说它是
_start地址。 -
phoff:从 ELF 文件开头到 Program Header 元数据的偏移量
-
phentsize:单个程序头条目的大小(以字节为单位)
-
phnum:程序头条目数
-
shoff:从 ELF 文件开头到节标题的偏移量
-
shentsize:单个节标题的大小
-
shnum:节标题的数量
您可以使用以下命令显示 Elf64 标头:
readelf -h hello
节
Section 包含我们 ELF 文件中的数据或代码(除了 section 为空的特殊情况,它只包含在加载过程中应该分配多少内存的信息。它通常用于BSS section)。
应该加载节的位置在节头中定义,它包含动态加载程序应该加载节的内存地址以及内存保护信息(EXECUTABLE/READ/WRITE)。
Elf64 Header 包含到第一个 Section Header 的偏移量、标题的数量和单个标题条目的大小。
我的 ELF64 标头是:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x401000
Start of program headers: 64 (bytes into file)
Start of section headers: 8504 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 3
Size of section headers: 64 (bytes)
Number of section headers: 6
Section header string table index: 5
因此,第一个 Section Header 位于 ELF 文件开头的 8504 字节处。
您可以显示所有部分标题:
readelf -S hello
对我来说是:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000401000 00001000
0000000000000025 0000000000000000 AX 0 0 16
[ 2] .rodata PROGBITS 0000000000402000 00002000
000000000000000e 0000000000000000 A 0 0 4
[ 3] .symtab SYMTAB 0000000000000000 00002010
00000000000000c0 0000000000000018 4 4 8
[ 4] .strtab STRTAB 0000000000000000 000020d0
0000000000000038 0000000000000000 0 0 1
[ 5] .shstrtab STRTAB 0000000000000000 00002108
0000000000000029 0000000000000000 0 0 1
如您所见,.text部分将在地址 0x401000 处加载,它与 Elf64 Header 的 entry 字段中定义的相同。所以.text部分的开头实际上是我们程序的入口。我们程序的指令从文件偏移量0x1000开始,因为这是在.text节头中定义的偏移量。
注意:此程序代码必须加载到 0x401000 和数据部分 0x402000 否则它将中断。它会发生,因为它依赖于位置。稍后我将介绍Position Independent Code,通过使用这种方法,我将能够在 (address + offset) 处加载我的部分。
程序头
您可以将节一一加载到内存中,但 ELF 文件将这些节聚合到程序头中定义的可加载段中。此数据结构的偏移量在 Elf64 标头字段 phoff 中定义。
您可以使用以下方式显示此数据:
readelf -l hello
对我来说,它看起来像:
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x00000000000000e8 0x00000000000000e8 R 0x1000
LOAD 0x0000000000001000 0x0000000000401000 0x0000000000401000
0x0000000000000025 0x0000000000000025 R E 0x1000
LOAD 0x0000000000002000 0x0000000000402000 0x0000000000402000
0x000000000000000e 0x000000000000000e R 0x1000
Section to Segment mapping:
Segment Sections...
00
01 .text
02 .rodata
类型 LOAD 表示该段应加载到指定地址的进程内存中。每个条目还包含文件偏移量、段应加载到的内存地址和内存保护信息。其他类型有:
-
NULL:未使用的条目
-
DYNAMIC:动态链接表,我们的代码不使用外部库,所以没有这样的条目。
-
INTERP:有关 ELF 解释器的信息。对于我们的代码,将使用默认的。
-
注意:注意部分
如您所见,.text部分是具有 READ 和 EXECUTION 权限的第二段的一部分,而.rodata是仅具有 READ 权限的第三段的一部分。
在我们的例子中,一个一个地加载 Sections 或使用 Program Header 没有区别,因为 Program Hader 的每个条目只包含一个部分,对于具有多个部分的较大 ELF 文件,一个 Program Header 通常包含多个部分。
这是用 C 编写的简单 Hello World 的程序头列表:
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000002d8 0x00000000000002d8 R 0x8
INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000630 0x0000000000000630 R 0x1000
LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000
0x0000000000000161 0x0000000000000161 R E 0x1000
LOAD 0x0000000000002000 0x0000000000002000 0x0000000000002000
0x00000000000000b4 0x00000000000000b4 R 0x1000
LOAD 0x0000000000002de8 0x0000000000003de8 0x0000000000003de8
0x0000000000000248 0x0000000000000250 RW 0x1000
DYNAMIC 0x0000000000002df8 0x0000000000003df8 0x0000000000003df8
0x00000000000001e0 0x00000000000001e0 RW 0x8
NOTE 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000040 0x0000000000000040 R 0x8
NOTE 0x0000000000000378 0x0000000000000378 0x0000000000000378
0x0000000000000044 0x0000000000000044 R 0x4
GNU_PROPERTY 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000040 0x0000000000000040 R 0x8
GNU_EH_FRAME 0x0000000000002014 0x0000000000002014 0x0000000000002014
0x0000000000000024 0x0000000000000024 R 0x4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x0000000000002de8 0x0000000000003de8 0x0000000000003de8
0x0000000000000218 0x0000000000000218 R 0x1
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
03 .init .plt .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.property
08 .note.gnu.build-id .note.ABI-tag
09 .note.gnu.property
10 .eh_frame_hdr
11
12 .init_array .fini_array .dynamic .got
如您所见,一个段扩展到多个部分。
正在加载
那么我们如何才能将 Program Header 中定义的 section 或 segment 加载到内存中并执行代码呢?使用mmap函数代表内存映射,它是系统调用之一,glibc 也为 C 语言提供了一个包装器。它的定义如下:
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
首先我们需要使用open函数打开我们的 ELF 文件并获得一个文件描述符,然后使用mmap我们可以将它的一部分(或整个文件)映射到我们进程的地址空间。mmap函数的第一个参数是虚拟地址,我们可以使用它来正确加载我们的节或段,因此如果例如.text的节头要求它在 0x41000 处加载,我们可以使用此参数来请求它。第二个参数是要映射的字节数,也可以从 Section Header 或 Program Header 中获取。 prot 参数定义映射内存空间的内存保护,可用值为:
-
保护_READ
-
保护_WRITE
-
保护_执行
-
保护_无
您可以使用“|”将它们组合在一起运算符,例如:
PROT_READ | PROT_EXEC
意味着这个映射的内存可以被读取或执行,如果你试图写入这个段你会得到一个错误。
所以 prot 可以很容易地从 Section Header 或 Program header 中选择。对于Section Header,它在sh_flags 字段中编码,对于Program Header,它是p_flags,当然这些值可能与mmap所需的值不直接匹配,因此您需要先转换它们。
参数 flags 非常重要,因为它应该设置为
MAP_PRIVATE | MAP_FIXED
-
MAP_PRIVATE:每次写入映射内存都不会影响ELF文件
-
MAP_FIXED:addr 参数中提供的地址应该被准确解释。所以映射到地址_0x40000_应该总是映射到_0x40000_,否则一些
mmap实现可能会省略addr参数并使用任何地址。
fd 参数是打开的 ELF 文件的文件描述符,offset 是此文件中的字节偏移量。
注意:实现可能要求 addr 是[Page Size](https://en.wikipedia.org/wiki/Page_(computer_memory)的倍数,可能会发生某些部分的虚拟地址定义在例如 0x41002,然后您应该对齐通过从地址和文件偏移量中减去剩余部分并将剩余部分添加到部分长度,将其添加到页面大小。例如,如果某个部分的虚拟地址是 0x41002,文件偏移量是 0x1002,段大小是 100,页面大小是 4096 (0x1000),那么 addr 参数应该为 0x41000,offset 应为 0x1000,length 应为 102。
堆栈
应该分配堆栈以让代码使用一些局部变量,我们的代码没有使用它,但值得一提。可以使用malloc(内存分配)函数分配堆栈。 请记住堆栈向下增长!,所以如果您从malloc收到一些地址,您应该向您的进程提供(address + stack_size)。
执行
好的,我们的部分已加载。接下来是什么?现在您可以直接跳转到存储在 ELF Header 的 entry 字段中的地址,或者使用clone函数。
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
/* pid_t *parent_tid, void *tls, pid_t *child_tid */ );
第一个参数是一个函数指针,因此您可以转换从 entry 字段获取的地址。第二个是堆栈指针,记得提供(stack_beginning + stack_size),对于 flags 我使用:
CLONE_VM | SIGCHLD
在调用进程和新创建的进程之间共享地址空间,并在终止期间向父进程发送信号。clone返回子进程标识符,因此您只需调用waitpid。
接下来呢?
我介绍了使用仅使用系统调用的代码加载简单 ELF 文件的过程。在下一集中,我将尝试解释如何加载共享库。
更多推荐




所有评论(0)