简介

ELF(Executable and Linkable Format)是一种用于存储和描述可执行文件、共享库和对
象文件的二进制文件格式。这种格式广泛应用于各种类 UNIX 和类 UNIX 操作系统,一般格式

可执行二进制文件、共享目标文件(.o)、共享库/共享对象(.so)、内核模块(.ko)和固
件(.bin,包含嵌入在 ELF 中的程序或应用程序特定的代码和数据)。

ELF 格式的主要特点如下:

  • 结构简单:ELF 格式的基本结构包括 ELF 头、程序头表、程序段表和数据段表等。这使
    得 ELF 格式的结构简单易懂,可以方便地处理和分析
  • 可移性:ELF 格式支持跨平台和跨架构的可移性。这使得 ELF 格式可以在不同的操作系
    统和架构上运行,并且可以方便地进行链接和共享库
  • 灵活性:ELF 格式提供了丰富的扩展机制,可以用于支持各种特定的功能和需求。这使
    得 ELF 格式可以满足各种应用程序和库的需求
  • 可扩展性:ELF 格式支持动态链接和加载,可以用于加载和链接动态库。这使得 ELF 格
    式可以支持动态链接和加载,并且可以方便地进行动态库的更新和维护

ELF格式的主要组成部分包括以下几部分:

  • ELF头:ELF头包含了整个ELF文件的基本信息,如文件类型、机器架构、操作系统ABI版本、程序入口点等
  • 程序头表:程序头表包含了每个程序段的基本信息,如段名称、段类型、段大小、段地址等。程序头表可以包含任意数量的程序头,用于描述程序中的各种段
  • 程序段表:程序段表包含了每个程序段的基本信息,如段名称、段类型、段大小、段地址等。程序段表可以包含任意数量的程序段,用于描述程序中的各种段
  • 数据段表:数据段表包含了每个数据段的基本信息,如段名称、段类型、段大小、段地址等。数据段表可以包含任意数量的数据段,用于描述程序中的各种数据段

ELF格式的详细信息可以参考ELF文件格式规范,如《System V Application Binary Interface》和《UNIX System V Release 4》等。

在这篇文章中,我们将了解 ELF二进制文件感染。

ELF 二进制文件感染

将恶意代码插入到 ELF 二进制文件 中通常被称为 “ELF 二进制文件感染”。高质量的 ELF 二进制文件感染通常涉及使用特定的感染算法,这些算法针对 ELF 文件的不同用例。

例如,感染一个动态链接或静态链接的可执行文件时,可以使用感染算法,例如 文本段填充(Text Segment Padding) 或者将 PT_NOTE 段转换为 PT_LOAD 段。这些操作通常在 32 位或 64 位的 Intel 架构上进行(我们主要关注 x86_64 和 x86 架构)。

然而,如果要感染一个共享对象(库),使用文本段填充或将 PT_NOTE 段转换为 PT_LOAD 段会给恶意代码的执行带来障碍。这是因为大多数共享对象没有直接的入口点(动态/运行时链接器和加载器是一个例外)。相反,共享库通过动态链接器(ld-linux-.so.)在链接器识别依赖关系时被映射到进程的镜像中。

解决这个问题的一种可能的方法是在共享库中劫持一个导出符号。我们可以在 .dsym 节中找到想要的函数的符号,并将其值(地址)改为恶意代码地址。然后,当一个链接到共享库的应用程序调用被劫持的符号对应的函数时,就会执行恶意代码。

以下面代码为例:

编写hello.h文件如下:

void func1();
void func2();

编写main.c文件如下:

#include "hello.h"
int main() {
        func1();
}

编写hello.c文件如下:

#include <stdio.h>
#include "hello.h"

void func1() {
        printf("hello,I'm func1.\n");
}
void func2() {
        printf("Hello,I'm func2.\n");
}

然后编译 hello.c 来生成共享库:

┌──(root㉿kali)-[~/elf]
└─# gcc -c hello.c -o hello.o -fPIC
┌──(root㉿kali)-[~/elf]
└─# ls
hello.c  hello.h  hello.o  main.c
┌──(root㉿kali)-[~/elf]
└─# vim hello.o 
┌──(root㉿kali)-[~/elf]
└─# gcc -shared hello.o -o hello.so

将main.c编译并动态链接到 hello.so,如下所示:

┌──(root㉿kali)-[~/elf]
└─# gcc main.c /root/elf/hello.so -o hello

运行生成的可执行文件:

┌──(root㉿kali)-[~/elf]
└─# ./hello          
hello,I'm func1.

我们可以使用radare2检查 hello.so 的导出:

┌──(root㉿kali)-[~/elf]
└─# radare2 -w hello.so 
Cannot determine entrypoint, using 0x00001050.
Warning: run radare2 with -e bin.cache=true to fix relocations in disassembly
[0x00001050]> iE
[Exports]

nth paddr      vaddr      bind   type size lib name
――――――――――――――――――――――――――――――――――――――――――――――
6   0x00001109 0x00001109 GLOBAL FUNC 22       func1
7   0x0000111f 0x0000111f GLOBAL FUNC 22       func2

[0x00001050]>

由此,我们可以看到符号 func1 的值为 0x00001109,func2的值为0x0000111f。 这些值分别对应于 func1 和 func2 的地址。 我们可以通过运行objdump -d hello.so命令来验证:

我们需要做的就是使用 radare2 将 func1 的值修改为 func2 的值。但首先,我们必须找到 .dsymtab 部分。 运行命令readelf -S hello.so将打印出节头表。可以使用输出中的地址字段来帮助我们在 radare2 中找到它以进行更改。

可以看到上图中[ 3] .dynsym 的节头表条目为.dynsm,接下来需要在radare2中寻找这个地址:

上面我们可以看到 .dynsym 的十六进制转储。 如果查看偏移线 0x00000318便会看到一个熟悉的地址“0911000000000000”,这是 func1 符号值和 func1 地址的little-endian版本。

现在我们成功地找到了想要覆盖的地址的开头,接下来我们需要用 func2 符号值修改那里的值:

至此,我们已经通过符号劫持成功将执行重定向到 func2。

相对重定位中毒/劫持

如果我们的目标二进制文件是大型软件的一部分,那么我们在劫持请求处理功能插入逻辑时,可以通过插入代码来搜索 HTTP 请求中的一个魔术数字,且该数字会标识一个“客户端”,该客户端会访问我们自定义的后门功能。这种感染方式使我们能够通过与常规的 HTTP 流量混合在一起。然而,这种方法的局限性在于,我们需要一个 ELF 二进制文件来调用与导出和劫持的符号相关联的函数。

因此,让我们看看如何通过在链接到受感染的共享对象时运行 ELF 二进制文件来实现代码执行。

为了演示这种技术,我们首先将“dummy”程序上的动态链接库作为目标,编写apache.c文件内的代码如下:

#include <stdio.h>
__attribute__((constructor)) void msg(int argc, char **argv) {
        printf("hello,I'm from msg() constructor.\n");
}

__attribute__((constructor)) void second() {
        printf("hello,I'm from second() constructor.\n");
}

void not_called() {
        puts("Don't call me.\n");
}

int main() {
        puts("Hello,I'm from main.\n");
        return 0;
}

这个程序有两个带有构造函数属性的函数。构造函数属性将导致用它们标记的已定义函数按照定义的顺序在main函数之前执行。最后,有一个在正常情况下不应该被访问或执行的 not_called函数。
使用gcc apache.c -o apache命令生成可执行文件并查看结果:

使用nm命令(列出二进制文件中的符号)并通过grep来筛选msg函数将产生其在程序中的位置:nm apache | grep msg。 然后,我们使用objdump反汇编二进制文件,通过反汇编二进制文件和函数来验证位置:

历史上,ELF和 ABI(应用程序二进制接口) 标准处理了二进制文件中的构造函数例程的执行,这些例程位于二进制文件的 .ctors 和 .init 部分。然而,在标准的后续版本中,涉及构造函数执行的机制被 .init_array 和动态标签项 DT_INIT_ARRAY(动态标签项是动态链接器/加载器用于动态链接的二进制文件的一部分)所取代。这个数组包含了函数指针的条目,每个指向一个在 main 函数之前执行的构造函数例程。我们可以再次使用 objdump 查看这些条目:

这里需要忽略“反汇编”部分,因为.init_array不包含指令,但是在objdump中使用-D标志将导致所有部分都被反汇编。相反,应该关注十六进制操作码输出,可以在偏移量 0x3dc8 处看到39 11,这与我们从nm输出中获得的msg函数和构造函数的值相同,但是按照little-endian字节顺序排列。
接下来将其中一个函数指针覆盖为我们的 not_called 函数的偏移量。使用radare2以写入模式 (-w) 加载二进制文件并分析所有标志(-A):

获取.init_array部分的地址(使用vaddr字段,因为radare2模拟在内存中加载二进制文件):

然后我们将它打印出十六进制转储以验证我们是否处于我们需要的位置:

接着获取 not_called 函数的偏移量,并按照little-endian字节顺序写入这个偏移量。最后,我们重新运行二进制文件,看看是否成功执行了 not_called 函数:

可以看到not_called函数没有执行,而msg函数和构造函数也执行了,即使覆盖了该条目。我们需要使用gdb和 GEF插件来分析发生了什么。

安装gdb:

apt install gdb

安装gef:

wget -O ~/.gdbinit-gef.py -q https://gef.blah.cat/py
echo source ~/.gdbinit-gef.py >> ~/.gdbinit

从这里开始,我们运行二进制文件,执行将在我们设置的断点处暂停,这样我们就可以通过在 gdb 中使用 maintenance info sections 命令来获取 .init_array 的虚拟地址:

我们获取起始地址并加上 8(如果还记得我们在radare2会话中的操作,感兴趣的入口距离 .init_array 的起始位置有 8 个字节)。然后,我们设置一个监视点,以便在入口处发生任何写操作时暂停执行:

从输出的结果中,我们可以得出结论:
init_array 中的任何偏移量都将在runtime处被覆盖。

其次,覆盖 init_array 中的偏移量发生在 dynamic/runtime 链接器和加载器的代码中。之前我们提到共享对象会映射到进程的地址空间中,dynamic/runtime 链接器和加载器也不例外。在内核创建进程的映像后,它会将信息放入进程的内存中(特别是堆栈区域),放入称为辅助向量的结构中,并将执行权转移到 dynamic/runtime 链接器和加载器,然后(dynamic/runtime 链接器和加载器)将使用这些信息进一步填充进程映像,以获取成功执行所需的代码和数据。

Dynamic 链接器执行的一个关键任务(特别是在 PIE 二进制文件中)是执行重定位,即根据重定位记录中的数据进行计算,有时在特定位置进行计算(对于使用隐式附加值的 REL 重定位结构),然后在内存中修补二进制文件(有时称为“热修补”)。正如你所想象的那样,在使用 ASLR(地址空间布局随机化)的系统上,基地址(二进制文件在运行时映射/加载的内存地址)对于编译器、链接编辑器(ld)以及共享对象来说是未知的,因此共享对象必须是位置无关的,并依赖于动态链接器来“解析”偏移量为绝对地址(使用程序的基地址),以便其他二进制文件链接到共享对象时。

为了应对这种行为,我们需要更好地理解相对重定位(Relative Relocations),这是动态链接器的众多重定位类型之一。执行 LD_DEBUG=reloc,statistics /root/elf/apache 命令,可以查看到动态链接器打印的重定位活动,需要注意的是 dynamic/runtime 链接器和加载器遵循 LD_DEBUG 标志,在执行到任何构造函数之前打印出关于程序执行的请求信息:

现在我们可以查看重定位条目,以查看.init_array中发生的情况。运行readelf -r apache命令,前五个重定位条目(相对重定位)是我们所感兴趣的,它们的类型是 R_X86_64_RELATIVE,其中最后一列为附加的一部分值。值为 0x1139 的附加值是我们的msg函数和构造函数的偏移量。在同一行的偏移列中,可以看到一个虚拟偏移量(0x3dc8),我们可以尝试在runtime中预期重定位:

R_X86_64_RELATIVE 的计算方式是 B + A,为 runtime 映射的二进制地址(B)加上附加值字段的值(A)。计算结果将被写入内存中的指定虚拟偏移量(0x000000003dc8,位于 .init_array 部分的定义内存区域之内)由 dynamic 链接器执行。因此,如果我们修改了 msg 函数的重定位记录的附加值字段,需要将其设置为 not_called 的偏移量,那么我们可以让 dynamic 链接器将 not_called 当作构造函数执行。

接下来需要修改 msg 函数和构造函数的重定位条目,以执行我们的 not_called 函数。需要从重新加载二进制文件到 radare2 开始,然后定位到 rela.dyn 部分,以寻找到该部分的起始位置,并读取条目的十六进制转储输出:

每个条目占用 24 字节,因此我们前进 24 字节以越过第一个条目,然后再前进 16 字节到达附加值字段:

然后将not_called函数的偏移量写入addend字段,最后重新运行可执行文件,可以看到执行成功:

我们现在可以在不修改入口点的情况下执行恶意代码,而是通过修改重定位记录来让 dynamic/runtime 链接器和加载器来完成我们的工作。这个过程称为相对重定位中毒/劫持。

我们现在可以针对任何使用相对重定位的 ELF 二进制文件进行操作,包括标准可执行文件和库(共享对象)。因此,曾经用于针对标准 ELF 可执行文件的二进制感染方法(例如 PT_NOTE 到 PT_LOAD 和 文本段填充),现在也可以应用于 ELF 共享对象可执行文件。

任何与被感染的共享库链接的 ELF 二进制文件都将在二进制文件的执行上下文中执行恶意代码。

限制

相对重定位中毒/劫持存在一些限制。例如,并非所有的相对重定位都与可执行代码相关联。有些与数据对象相关。以 readelf 使用查看“Hello World”程序的输出为例,它是动态链接到 libc 的。运行 readelf 应用程序使用并-s选项,来查找符号并将其输出传递给 grep,以将符号与其偏移量匹配。
我们可以看到从重定位记录的打印输出中收集到的前两个偏移量具有 FUNC 符号类型(在 elf.h 中定义为 STT_FUNC),这表示该符号与函数或可执行代码相关联。最后一次使用偏移量 0x4010 运行 readelf,显示该偏移量的类型为 OBJECT,这告诉我们该重定位与数据相关,我们则需要避免劫持这些条目。

解决方案有两种,一种是检查偏移量是否在 .init_array 部分内,因为该部分仅包含函数指针,并且只包含指向代码的条目。另一种解决方案则需要检查符号表,以确保关联的类型是 STT_FUNC 或 FUNC(readelf)。然而,这种方法仍旧存在一个缺点,即二进制文件通常会在动态链接的二进制文件中删除其 .symtab,以减小文件大小。静态编译和链接的二进制文件(ELF 类型 ET_EXEC)不使用相对重定位(R_X86_64_RELATIVE),因此相对重定位中毒/劫持将无法生效。