[Linux翻译]如何执行一个对象文件。第1部分

156 阅读12分钟

原文地址:blog.cloudflare.com/how-to-exec…

原文作者:blog.cloudflare.com/author/igna…

发布时间:2021年3月2日

本文由 简悦SimpRead 转码,原文地址 blog.cloudflare.com

有没有想过,是否有可能不通过链接来执行一个对象文件?或者使用任何对象文件作为......

在没有链接的情况下调用一个简单的函数

当我们使用高级编译的编程语言编写软件时,通常会有一些步骤将我们的源代码转化为最终可执行的二进制文件。

首先,我们的源文件由一个_编译器编译,将高级编程语言翻译成机器代码。编译器的输出是一些_object_文件。如果项目包含多个源文件,我们通常会得到同样多的对象文件。下一步是_链接器:由于不同对象文件中的代码可能相互引用,链接器负责将所有这些对象文件组装成一个大程序,并将这些引用绑定在一起。链接器的输出通常是我们的目标可执行文件,所以只有一个文件。

然而,在这一点上,我们的可执行文件可能仍然是不完整的。这些天来,Linux上的大多数可执行文件都是动态链接的:可执行文件本身并没有运行程序所需的所有代码。相反,它期望在运行时从共享库中 "借用 "部分代码来实现其部分功能。

这个过程被称为_runtime linking_:当我们的可执行文件被启动时,操作系统将调用_dynamic loader_,它应该找到所有需要的库,将其代码复制/映射到我们的目标进程地址空间,并解决我们的代码对它们的所有依赖。

关于这个整体过程的一个有趣的事情是,我们从第一步(编译源代码)直接得到了可执行的机器代码,但如果后面的任何步骤失败,我们仍然不能执行我们的程序。因此,在这一系列的博文中,我们将研究是否有可能跳过所有后面的步骤,直接从对象文件中执行机器代码。

为什么我们要执行一个对象文件?

可能有很多原因。也许我们正在编写一个开源的Linux驱动或应用程序的替代品,并想比较一些代码的行为是否相同。或者我们有一段罕见的、晦涩的程序,但我们不能链接到它,因为它是用一个罕见的、晦涩的编译器编译的。也许我们有一个源文件,但不能创建一个全功能的可执行文件,因为缺少构建时间或运行时间的依赖。恶意软件分析,来自不同操作系统的代码等--所有这些情况都可能使我们处于这样的境地,即要么无法连接,要么运行时环境不适合。

一个简单的玩具对象文件

为了本文的目的,让我们创建一个简单的玩具对象文件,这样我们就可以在实验中使用它。

obj.c:

int add5(int num)
{
    return num + 5;
}

int add10(int num)
{
    return num + 10;
}

我们的源文件只包含2个函数,add5add10,分别为唯一的输入参数添加5或10。这是一段很小但功能齐全的代码,我们可以很容易地把它编译成一个对象文件。

$ gcc -c obj.c 
$ ls
obj.c obj.o

加载一个对象文件到进程内存中

现在我们将尝试从对象文件中导入add5add10函数并执行它们。当我们谈论执行一个对象文件时,我们的意思是将一个对象文件作为某种库来使用。正如我们上面学到的,当我们有一个利用外部共享库的可执行文件时,_动态加载器_为我们将这些库加载到进程地址空间。然而,对于对象文件,我们必须手动进行,因为最终我们不能执行不在操作系统RAM中的机器代码。所以,为了执行对象文件,我们仍然需要某种包装程序。

loader.c

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

static void load_obj(void)
{
    /* load obj.o into memory */
}

static void parse_obj(void)
{
    /* parse an object file and find add5 and add10 functions */
}

static void execute_funcs(void)
{
    /* execute add5 and add10 with some inputs */
}

int main(void)
{
    load_obj();
    parse_obj();
    execute_funcs();

    return 0;
}

以上是一个独立的对象加载器程序,有一些函数作为占位符。我们将在这篇文章中实现这些函数(并增加更多)。

首先,正如我们已经建立的,我们需要将我们的对象文件加载到进程地址空间。我们可以直接将整个文件读入一个缓冲区,但这并不是非常有效。现实世界中的对象文件可能很大,但正如我们稍后将看到的,我们不需要对象的所有文件内容。所以最好是mmap文件来代替:这样,操作系统会在我们需要的时候,懒洋洋地从文件中读取我们需要的部分。让我们来实现load_obj函数。

loader.c:

...
/* for open(2), fstat(2) */
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

/* for close(2), fstat(2) */
#include <unistd.h>

/* for mmap(2) */
#include <sys/mman.h>

/* parsing ELF files */
#include <elf.h>

/* for errno */
#include <errno.h>

typedef union {
    const Elf64_Ehdr *hdr;
    const uint8_t *base;
} objhdr;

/* obj.o memory address */
static objhdr obj;

static void load_obj(void)
{
    struct stat sb;

    int fd = open("obj.o", O_RDONLY);
    if (fd <= 0) {
        perror("Cannot open obj.o");
        exit(errno);
    }

    /* we need obj.o size for mmap(2) */
    if (fstat(fd, &sb)) {
        perror("Failed to get obj.o info");
        exit(errno);
    }

    /* mmap obj.o into memory */
    obj.base = mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    if (obj.base == MAP_FAILED) {
        perror("Maping obj.o failed");
        exit(errno);
    }
    close(fd);
}
...

如果我们没有遇到任何错误,在load_obj执行后,我们应该得到内存地址,它指向我们的obj.oobj全局变量中的开头。值得注意的是,我们为obj变量创建了一个特殊的联合类型:我们稍后将解析obj.o(并提前偷看 - 对象文件实际上是ELF文件),所以将以Elf64_Ehdr(C语言中的ELF头结构)和一个字节指针(解析ELF文件需要计算从文件开始的字节偏移量)来引用该地址。

窥视对象文件内部

要使用对象文件中的一些代码,我们首先需要找到它。正如我在上面泄露的,对象文件实际上是ELF文件(与Linux可执行文件和共享库的格式相同),幸运的是,在标准的elf.h头的帮助下,它们在Linux上很容易解析,其中包括许多与ELF文件结构有关的有用定义。但是我们实际上需要知道我们在寻找什么,所以需要对ELF文件有一个高层次的理解。

ELF段和节

段(也称为程序头)和节可能是ELF文件的主要部分,通常是任何ELF教程的起点。然而,这两者之间往往存在一些混淆。不同的部分包含不同类型的ELF数据:可执行代码(我们在这篇文章中最感兴趣)、常量数据、全局变量等。另一方面,段本身不包含任何数据--它们只是向操作系统描述如何正确地将段载入RAM以使可执行文件正常工作。有些教程说 "一个段可以包括0个或更多的段",这并不完全准确:段不包含段,相反,它们只是向操作系统指出在内存中某个特定的段应该被加载,以及这个内存的访问模式是什么(读、写或执行)。

此外,对象文件根本不包含任何区段:一个对象文件并不是为了被操作系统直接加载。相反,它被认为将与其他一些代码连接,所以ELF段通常由链接器而不是编译器生成。我们可以通过使用[readelf命令](man7.org/linux/man-p…

$ readelf --segments obj.o

这个文件中没有程序头。

对象文件部分

同样的readelf命令可以用来获取我们对象文件中的所有部分。

$ readelf --sections obj.o
There are 11 section headers, starting at offset 0x268:

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         0000000000000000  00000040
       000000000000001e  0000000000000000  AX       0     0     1
  [ 2] .data             PROGBITS         0000000000000000  0000005e
       0000000000000000  0000000000000000  WA       0     0     1
  [ 3] .bss              NOBITS           0000000000000000  0000005e
       0000000000000000  0000000000000000  WA       0     0     1
  [ 4] .comment          PROGBITS         0000000000000000  0000005e
       000000000000001d  0000000000000001  MS       0     0     1
  [ 5] .note.GNU-stack   PROGBITS         0000000000000000  0000007b
       0000000000000000  0000000000000000           0     0     1
  [ 6] .eh_frame         PROGBITS         0000000000000000  00000080
       0000000000000058  0000000000000000   A       0     0     8
  [ 7] .rela.eh_frame    RELA             0000000000000000  000001e0
       0000000000000030  0000000000000018   I       8     6     8
  [ 8] .symtab           SYMTAB           0000000000000000  000000d8
       00000000000000f0  0000000000000018           9     8     8
  [ 9] .strtab           STRTAB           0000000000000000  000001c8
       0000000000000012  0000000000000000           0     0     1
  [10] .shstrtab         STRTAB           0000000000000000  00000210
       0000000000000054  0000000000000000           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)

网上有不同的教程,详细描述了最流行的ELF部分。另一个很好的参考是Linux manpages项目。它很方便,因为它描述了这两个部分的目的以及来自elf.h的C结构定义,这使得它成为解析ELF文件的一站式商店。然而,为了完整起见,下面是对ELF文件中可能遇到的最常见的部分的简短描述。

  • .text:这部分包含可执行代码(实际的机器代码,由编译器从我们的源代码创建)。这一部分是本篇文章的主要关注点,因为它应该包含我们想要使用的add5'和add10'函数。
  • .data.bss:这些部分包含全局变量和静态局部变量。区别是:.data有初始值的变量(定义为int foo = 5;),.bss只是为没有初始值的变量保留空间(定义为int bar;)。
  • .rodata:这部分包含常量数据(主要是字符串或字节数组)。例如,如果我们在代码中使用一个字符串字面(例如,用于printf'或一些错误信息),它将被存储在这里。注意,上面的输出中没有.rodata,因为我们在obj.c`中没有使用任何字符串字面或常数字节数组。
  • .symtab:这部分包含对象文件中的符号信息:函数、全局变量、常量等。它也可能包含对象文件需要的外部符号的信息,比如需要的外部库中的函数。
  • .strtab.shstrtab:包含ELF文件的打包字符串。注意,这些不是我们可能在源代码中定义的字符串(那些归入.rodata部分)。这些是描述其他ELF结构名称的字符串,比如.symtab中的符号,甚至是上表中的部分名称。ELF二进制格式的目的是使其结构紧凑,大小固定,所以所有的字符串都存储在一个地方,各自的数据结构只是在.shstrtab.strtab部分引用它们作为偏移,而不是在本地存储完整的字符串。

.symtab部分

在这一点上,我们知道我们要导入和执行的代码位于obj.o.text部分。但是我们有两个函数,add5add10,还记得吗?在这个层面上,.text部分只是一个字节包,我们怎么知道这些函数的位置呢?这就是.symtab("符号表")派上用场的地方。它是如此重要,以至于它在readelf中有自己的专门参数。

$ readelf --symbols obj.o

Symbol table '.symtab' contains 10 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS obj.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    2
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    3
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    6
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    4
     8: 0000000000000000    15 FUNC    GLOBAL DEFAULT    1 add5
     9: 000000000000000f    15 FUNC    GLOBAL DEFAULT    1 add10

让我们暂时忽略其他条目,只关注最后两行,因为它们方便地将add5add10作为其符号名称。事实上,这就是关于我们的函数的信息。除了名称之外,符号表还为我们提供了一些额外的元数据。

  • Ndx列告诉我们该符号所在部分的索引。我们可以将其与上面的章节表进行交叉检查,确认这些函数确实位于.text中(索引为1的章节)。
  • Type被设置为FUNC,证实了这些确实是函数。
  • Size告诉我们每个函数的大小,但是这个信息在我们的环境中不是很有用。绑定 "和 "访问 "也是如此。
  • 最有用的信息可能是Value。这个名字有误导性,因为在这种情况下,它实际上是一个从包含部分开始的偏移。也就是说,add5'函数只是从.text'的开头开始,而`add10'是从第15个字节及以后的位置。

所以现在我们有了如何解析ELF文件并找到我们需要的函数的全部内容。

从一个对象文件中找到并执行一个函数

鉴于我们到目前为止所学到的知识,让我们确定一个计划,如何从一个对象文件中导入和执行一个函数。

  1. 找到ELF章节表和.shstrtab章节(我们以后需要.shstrtab来按名字查找章节表中的章节)。
  2. 找到.symtab.strtab部分(我们需要.strtab.symtab中按名称查找符号)。
  3. 找到.text部分并以可执行的权限将其复制到RAM中。
  4. .symtab中找到add5add10的函数偏移量。
  5. 执行add5add10函数。

让我们开始添加一些全局变量并执行parse_obj函数。

loader.c:

...

/* sections table */
static const Elf64_Shdr *sections;
static const char *shstrtab = NULL;

/* symbols table */
static const Elf64_Sym *symbols;
/* number of entries in the symbols table */
static int num_symbols;
static const char *strtab = NULL;

...

static void parse_obj(void)
{
    /* the sections table offset is encoded in the ELF header */
    sections = (const Elf64_Shdr *)(obj.base + obj.hdr->e_shoff);
    /* the index of `.shstrtab` in the sections table is encoded in the ELF header
     * so we can find it without actually using a name lookup
     */
    shstrtab = (const char *)(obj.base + sections[obj.hdr->e_shstrndx].sh_offset);

...
}

...

现在我们有了对section表和".shstrtab "部分的引用,我们可以通过它们的名字来查询其他部分。让我们为此创建一个辅助函数。

loader.c:

...

static const Elf64_Shdr *lookup_section(const char *name)
{
    size_t name_len = strlen(name);

    /* number of entries in the sections table is encoded in the ELF header */
    for (Elf64_Half i = 0; i < obj.hdr->e_shnum; i++) {
        /* sections table entry does not contain the string name of the section
         * instead, the `sh_name` parameter is an offset in the `.shstrtab`
         * section, which points to a string name
         */
        const char *section_name = shstrtab + sections[i].sh_name;
        size_t section_name_len = strlen(section_name);

        if (name_len == section_name_len && !strcmp(name, section_name)) {
            /* we ignore sections with 0 size */
            if (sections[i].sh_size)
                return sections + i;
        }
    }

    return NULL;
}

...

使用我们新的辅助函数,我们现在可以找到.symtab.strtab部分。

loader.c:

...

static void parse_obj(void)
{
...

    /* find the `.symtab` entry in the sections table */
    const Elf64_Shdr *symtab_hdr = lookup_section(".symtab");
    if (!symtab_hdr) {
        fputs("Failed to find .symtab\n", stderr);
        exit(ENOEXEC);
    }

    /* the symbols table */
    symbols = (const Elf64_Sym *)(obj.base + symtab_hdr->sh_offset);
    /* number of entries in the symbols table = table size / entry size */
    num_symbols = symtab_hdr->sh_size / symtab_hdr->sh_entsize;

    const Elf64_Shdr *strtab_hdr = lookup_section(".strtab");
    if (!strtab_hdr) {
        fputs("Failed to find .strtab\n", stderr);
        exit(ENOEXEC);
    }

    strtab = (const char *)(obj.base + strtab_hdr->sh_offset);
    
...
}

...

接下来,让我们关注一下.text部分。我们在前面的计划中指出,仅仅在对象文件中找到.text部分是不够的,就像我们对其他部分做的那样。我们需要将其复制到RAM中的不同位置,并赋予其可执行的权限。这有几个原因,但这些是主要的。

  • 许多CPU架构要么不允许执行机器代码,机器代码是在内存中不对齐 (对于x86系统为4千字节),或者在执行机器代码时有性能上的损失。然而,ELF文件中的".text "部分不能保证被定位在页面对齐的偏移量上,因为ELF文件的磁盘版本的目的是紧凑而不是方便。
  • 我们可能需要修改.text部分的一些字节来执行重定位(在这种情况下我们不需要这样做,但会在以后的文章中处理重定位的问题)。例如,如果我们忘记使用MAP_PRIVATE标志,当映射ELF文件时,我们的修改可能会传播到底层文件并破坏它。
  • 最后,在运行时需要的不同部分,如.text.data.bss.rodata,需要不同的内存权限位:.text部分内存需要既可读又可执行,但不能写(内存既可写又可执行被认为是一种不好的安全做法)。.data.bss部分需要可读和可写,以支持全局变量,但不可执行。.rodata部分应该是只读的,因为它的目的是保存常量数据。为了支持这一点,每个部分必须分配在一个页面边界上,因为我们只能在整个页面上设置内存权限位,而不是自定义范围。因此,我们需要为这些部分创建新的、与页面对齐的内存范围,并将数据复制到那里。

要为.text部分创建一个页面对齐的副本,首先我们需要知道页面大小。许多程序通常将页面大小硬编码为4096(4千字节),但我们不应该依赖这个。虽然这对大多数x86系统来说是准确的,但其他CPU架构,如arm64,可能有不同的页面大小。所以硬编码一个页面大小可能会使我们的程序不具有可移植性。让我们找到页面大小并将其存储在另一个全局变量中。

loader.c:

...

static uint64_t page_size;

static inline uint64_t page_align(uint64_t n)
{
    return (n + (page_size - 1)) & ~(page_size - 1);
}

...

static void parse_obj(void)
{
...

    /* get system page size */
    page_size = sysconf(_SC_PAGESIZE);

...
}

...

注意,我们还添加了一个方便的函数page_align,它将把传入的数字取整到下一个页面对齐的边界。接下来,回到.text部分。作为提醒,我们需要。

  1. 在章节表中找到".text "部分的元数据。
  2. 分配一块内存来存放`.text'部分的副本。
  3. 将".text "部分实际复制到新分配的内存中。
  4. 使.text部分可执行,所以我们以后可以从它调用函数。

下面是上述步骤的实现。

loader.c:

...

/* runtime base address of the imported code */
static uint8_t *text_runtime_base;

...

static void parse_obj(void)
{
...

    /* find the `.text` entry in the sections table */
    const Elf64_Shdr *text_hdr = lookup_section(".text");
    if (!text_hdr) {
        fputs("Failed to find .text\n", stderr);
        exit(ENOEXEC);
    }

    /* allocate memory for `.text` copy rounding it up to whole pages */
    text_runtime_base = mmap(NULL, page_align(text_hdr->sh_size), PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (text_runtime_base == MAP_FAILED) {
        perror("Failed to allocate memory for .text");
        exit(errno);
    }

    /* copy the contents of `.text` section from the ELF file */
    memcpy(text_runtime_base, obj.base + text_hdr->sh_offset, text_hdr->sh_size);

    /* make the `.text` copy readonly and executable */
    if (mprotect(text_runtime_base, page_align(text_hdr->sh_size), PROT_READ | PROT_EXEC)) {
        perror("Failed to make .text executable");
        exit(errno);
    }
}

...

现在我们有了定位一个函数的地址所需的所有部件。让我们为它写一个辅助工具。

loader.c:

...

static void *lookup_function(const char *name)
{
    size_t name_len = strlen(name);

    /* loop through all the symbols in the symbol table */
    for (int i = 0; i < num_symbols; i++) {
        /* consider only function symbols */
        if (ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
            /* symbol table entry does not contain the string name of the symbol
             * instead, the `st_name` parameter is an offset in the `.strtab`
             * section, which points to a string name
             */
            const char *function_name = strtab + symbols[i].st_name;
            size_t function_name_len = strlen(function_name);

            if (name_len == function_name_len && !strcmp(name, function_name)) {
                /* st_value is an offset in bytes of the function from the
                 * beginning of the `.text` section
                 */
                return text_runtime_base + symbols[i].st_value;
            }
        }
    }

    return NULL;
}

...

最后我们可以实现execute_funcs函数,从一个对象文件中导入并执行代码。

loader.c:

...

static void execute_funcs(void)
{
    /* pointers to imported add5 and add10 functions */
    int (*add5)(int);
    int (*add10)(int);

    add5 = lookup_function("add5");
    if (!add5) {
        fputs("Failed to find add5 function\n", stderr);
        exit(ENOENT);
    }

    puts("Executing add5...");
    printf("add5(%d) = %d\n", 42, add5(42));

    add10 = lookup_function("add10");
    if (!add10) {
        fputs("Failed to find add10 function\n", stderr);
        exit(ENOENT);
    }

    puts("Executing add10...");
    printf("add10(%d) = %d\n", 42, add10(42));
}

...

让我们编译我们的加载器,并确保它按预期工作。

$ gcc -o loader loader.c 
$ ./loader 
Executing add5...
add5(42) = 47
Executing add10...
add10(42) = 52

Voila! 我们已经成功地从obj.o导入了代码并执行了它。当然,上面的例子是简化的:对象文件中的代码是独立的,没有引用任何全局变量或常量,也没有任何外部依赖性。在以后的文章中,我们将研究更复杂的代码以及如何处理这种情况。

安全考虑

处理外部输入,如解析上述磁盘中的ELF文件,应谨慎处理。在解析对象文件时,_loader.c_的代码省略了大量的边界检查和额外的ELF完整性检查。这段代码是为了这篇文章的目的而简化的,但很可能不是为生产准备的,因为它可能被特别制作的恶意输入所利用。它只能用于教育目的!

这个帖子的完整源代码可以在这里找到。


www.deepl.com 翻译