原文地址: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个函数,add5和add10,分别为唯一的输入参数添加5或10。这是一段很小但功能齐全的代码,我们可以很容易地把它编译成一个对象文件。
$ gcc -c obj.c
$ ls
obj.c obj.o
加载一个对象文件到进程内存中
现在我们将尝试从对象文件中导入add5和add10函数并执行它们。当我们谈论执行一个对象文件时,我们的意思是将一个对象文件作为某种库来使用。正如我们上面学到的,当我们有一个利用外部共享库的可执行文件时,_动态加载器_为我们将这些库加载到进程地址空间。然而,对于对象文件,我们必须手动进行,因为最终我们不能执行不在操作系统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.o在obj全局变量中的开头。值得注意的是,我们为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部分。但是我们有两个函数,add5和add10,还记得吗?在这个层面上,.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
让我们暂时忽略其他条目,只关注最后两行,因为它们方便地将add5和add10作为其符号名称。事实上,这就是关于我们的函数的信息。除了名称之外,符号表还为我们提供了一些额外的元数据。
Ndx列告诉我们该符号所在部分的索引。我们可以将其与上面的章节表进行交叉检查,确认这些函数确实位于.text中(索引为1的章节)。Type被设置为FUNC,证实了这些确实是函数。Size告诉我们每个函数的大小,但是这个信息在我们的环境中不是很有用。绑定 "和 "访问 "也是如此。- 最有用的信息可能是
Value。这个名字有误导性,因为在这种情况下,它实际上是一个从包含部分开始的偏移。也就是说,add5'函数只是从.text'的开头开始,而`add10'是从第15个字节及以后的位置。
所以现在我们有了如何解析ELF文件并找到我们需要的函数的全部内容。
从一个对象文件中找到并执行一个函数
鉴于我们到目前为止所学到的知识,让我们确定一个计划,如何从一个对象文件中导入和执行一个函数。
- 找到ELF章节表和
.shstrtab章节(我们以后需要.shstrtab来按名字查找章节表中的章节)。 - 找到
.symtab和.strtab部分(我们需要.strtab在.symtab中按名称查找符号)。 - 找到
.text部分并以可执行的权限将其复制到RAM中。 - 从
.symtab中找到add5和add10的函数偏移量。 - 执行
add5和add10函数。
让我们开始添加一些全局变量并执行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部分。作为提醒,我们需要。
- 在章节表中找到".text "部分的元数据。
- 分配一块内存来存放`.text'部分的副本。
- 将".text "部分实际复制到新分配的内存中。
- 使
.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完整性检查。这段代码是为了这篇文章的目的而简化的,但很可能不是为生产准备的,因为它可能被特别制作的恶意输入所利用。它只能用于教育目的!
这个帖子的完整源代码可以在这里找到。