作者:u2400@知道創宇404實驗室
時間:2019年12月19日
English version:http://www.jmbmsq.com/1104/

前言:最近在實現linux的HIDS agent, 搜索資料時發現雖然資料不少, 但是每一篇文章都各自有側重點, 少有循序漸進, 講的比較全面的中文文章, 在一步步學習中踩了不少坑, 在這里將以進程信息收集作為切入點就如何實現一個HIDS的agent做詳細說明, 希望對各位師傅有所幫助.

1. 什么是HIDS?

主機入侵檢測, 通常分為agent和server兩個部分

其中agent負責收集信息, 并將相關信息整理后發送給server.

server通常作為信息中心, 部署由安全人員編寫的規則(目前HIDS的規則還沒有一個編寫的規范),收集從各種安全組件獲取的數據(這些數據也可能來自waf, NIDS等), 進行分析, 根據規則判斷主機行為是否異常, 并對主機的異常行為進行告警和提示.

HIDS存在的目的在于在管理員管理海量IDC時不會被安全事件弄的手忙腳亂, 可以通過信息中心對每一臺主機的健康狀態進行監視.

相關的開源項目有OSSEC, OSquery等, OSSEC是一個已經構建完善的HIDS, 有agent端和server端, 有自帶的規則, 基礎的rootkit檢測, 敏感文件修改提醒等功能, 并且被包含到了一個叫做wazuh的開源項目, OSquery是一個facebook研發的開源項目, 可以作為一個agent端對主機相關數據進行收集, 但是server和規則需要自己實現.

每一個公司的HIDS agent都會根據自身需要定制, 或多或少的增加一些個性化的功能, 一個基礎的HIDS agent一般需要實現的有:

  • 收集進程信息
  • 收集網絡信息
  • 周期性的收集開放端口
  • 監控敏感文件修改

下文將從實現一個agent入手, 圍繞agent討論如何實現一個HIDS agent的進程信息收集模塊

2. agent進程監控模塊提要

2.1進程監控的目的

在Linux操作系統中幾乎所有的運維操作和入侵行為都會體現到執行的命令中, 而命令執行的本質就是啟動進程, 所以對進程的監控就是對命令執行的監控, 這對運維操作升級和入侵行為分析都有極大的幫助

2.2 進程監控模塊應當獲取的數據

既然要獲取信息那就先要明確需要什么, 如果不知道需要什么信息, 那實現便無從談起, 即便硬著頭皮先實現一個能獲取pid等基礎信息的HIDS, 后期也會因為缺少規劃而頻繁改動接口, 白白耗費人力, 這里參考《互聯網企業安全高級指南》給出一個獲取信息的基礎列表, 在后面會補全這張表的的獲取方式

數據名稱 含義
path 可執行文件的路徑
ppath 父進程可執行文件路徑
ENV 環境變量
cmdline 進程啟動命令
pcmdline 父進程啟動命令
pid 進程id
ppid 父進程id
pgid 進程組id
sid 進程會話id
uid 啟動進程用戶的uid
euid 啟動進程用戶的euid
gid 啟動進程用戶的用戶組id
egid 啟動進程用戶的egid
mode 可執行文件的權限
owner_uid 文件所有者的uid
owner_gid 文件所有者的gid
create_time 文件創建時間
modify_time 最近的文件修改時間
pstart_time 進程開始運行的時間
prun_time 父進程已經運行的時間
sys_time 當前系統時間
fd 文件描述符

2.3 進程監控的方式

進程監控, 通常使用hook技術, 而這些hook大概分為兩類:

應用級(工作在r3, 常見的就是劫持libc庫, 通常簡單但是可能被繞過 - 內核級(工作在r0或者r1, 內核級hook通常和系統調用VFS有關, 較為復雜, 且在不同的發行版, 不同的內核版本間均可能產生兼容性問題, hook出現嚴重的錯誤時可能導致kenrel panic, 相對的無法從原理上被繞過

首先從簡單的應用級hook說起

3. HIDS 應用級hook

3.1 劫持libc庫

庫用于打包函數, 被打包過后的函數可以直接使用, 其中linux分為靜態庫和動態庫, 其中動態庫是在加載應用程序時才被加載, 而程序對于動態庫有加載順序, 可以通過修改 /etc/ld.so.preload 來手動優先加載一個動態鏈接庫, 在這個動態鏈接庫中可以在程序調用原函數之前就把原來的函數先換掉, 然后在自己的函數中執行了自己的邏輯之后再去調用原來的函數返回原來的函數應當返回的結果.

想要詳細了解的同學, 參考這篇文章

劫持libc庫有以下幾個步驟:

3.1.1 編譯一個動態鏈接庫

一個簡單的hook execve的動態鏈接庫如下.
邏輯非常簡單

  1. 自定義一個函數命名為execve, 接受參數的類型要和原來的execve相同
  2. 執行自己的邏輯
#define _GNU_SOURCE
#include <unistd.h>
#include <dlfcn.h>
typedef ssize_t (*execve_func_t)(const char* filename, char* const argv[], char* const envp[]);
static execve_func_t old_execve = NULL;
int execve(const char* filename, char* const argv[], char* const envp[]) {
        //從這里開始是自己的邏輯, 即進程調用execve函數時你要做什么
    printf("Running hook\n");
    //下面是尋找和調用原本的execve函數, 并返回調用結果
    old_execve = dlsym(RTLD_NEXT, "execve");
    return old_execve(filename, argv, envp);
}

通過gcc編譯為so文件.

gcc -shared -fPIC -o libmodule.so module.c

3.1.2 修改ld.so.preload

ld.so.preload是LD_PRELOAD環境變量的配置文件, 通過修改該文件的內容為指定的動態鏈接庫文件路徑,

注意只有root才可以修改ld.so.preload, 除非默認的權限被改動了

自定義一個execve函數如下:

extern char **environ;
int execve(const char* filename, char* const argv[], char* const envp[]) {
    for (int i = 0; *(environ + i) ; i++)
    {
        printf("%s\n", *(environ + i));
    }
    printf("PID:%d\n", getpid());
    old_execve = dlsym(RTLD_NEXT, "execve");
    return old_execve(filename, argv, envp);
}

可以輸出當前進程的Pid和所有的環境變量, 編譯后修改ld.so.preload, 重啟shell, 運行ls命令結果如下
image.png

3.1.3 libc hook的優缺點

優點: 性能較好, 比較穩定, 相對于LKM更加簡單, 適配性也很高, 通常對抗web層面的入侵.

缺點: 對于靜態編譯的程序束手無策, 存在一定被繞過的風險.

3.1.4 hook與信息獲取

設立hook, 是為了建立監控點, 獲取進程的相關信息, 但是如果hook的部分寫的過大過多, 會導致影響正常的業務的運行效率, 這是業務所不能接受的, 在通常的HIDS中會將可以不在hook處獲取的信息放在agent中獲取, 這樣信息獲取和業務邏輯并發執行, 降低對業務的影響.

4 信息補全與獲取

如果對信息的準確性要求不是很高, 同時希望盡一切可能的不影響部署在HIDS主機上的正常業務那么可以選擇hook只獲取PID和環境變量等必要的數據, 然后將這些東西交給agent, 由agent繼續獲取進程的其他相關信息, 也就是說獲取進程其他信息的同時, 進程就已經繼續運行了, 而不需要等待agent獲取完整的信息表.

/proc/[pid]/stat

/proc是內核向用戶態提供的一組fifo接口, 通過偽文件目錄的形式調用接口

每一個進程相關的信息, 會被放到以pid命名的文件夾當中, ps等命令也是通過遍歷/proc目錄來獲取進程的相關信息的.

一個stat文件內容如下所示, 下面self是/proc目錄提供的一個快捷的查看自己進程信息的接口, 每一個進程訪問/self時看到都是自己的信息.

#cat /proc/self/stat
3119 (cat) R 29973 3119 19885 34821 3119 4194304 107 0 0 0 0 0 0 0 20 0 1 0 5794695 5562368 176 18446744073709551615 94309027168256 94309027193225 140731267701520 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0 94309027212368 94309027213920 94309053399040 140731267704821 140731267704841 140731267704841 140731267706859 0

會發現這些數據雜亂無章, 使用空格作為每一個數據的邊界, 沒有地方說明這些數據各自表達什么意思.

一般折騰找到了一篇文章里面給出了一個列表, 這個表里面說明了每一個數據的數據類型和其表達的含義, 見文章附錄1

最后整理出一個有52個數據項每個數據項類型各不相同的結構體, 獲取起來還是有點麻煩, 網上沒有找到輪子, 所以自己寫了一個

具體的結構體定義:

struct proc_stat {
    int pid; //process ID.
    char* comm; //可執行文件名稱, 會用()包圍
    char state; //進程狀態
    int ppid;   //父進程pid
    int pgid;
    int session;    //sid
    int tty_nr;     
    int tpgid;
    unsigned int flags;
    long unsigned int minflt;
    long unsigned int cminflt;
    long unsigned int majflt;
    long unsigned int cmajflt;
    long unsigned int utime;
    long unsigned int stime;
    long int cutime;
    long int cstime;
    long int priority;
    long int nice;
    long int num_threads;
    long int itrealvalue;
    long long unsigned int starttime;
    long unsigned int vsize;
    long int rss;
    long unsigned int rsslim;
    long unsigned int startcode;
    long unsigned int endcode;
    long unsigned int startstack;
    long unsigned int kstkesp;
    long unsigned int kstkeip;
    long unsigned int signal;   //The bitmap of pending signals
    long unsigned int blocked;
    long unsigned int sigignore;
    long unsigned int sigcatch;
    long unsigned int wchan;
    long unsigned int nswap;
    long unsigned int cnswap;
    int exit_signal;
    int processor;
    unsigned int rt_priority;
    unsigned int policy;
    long long unsigned int delayacct_blkio_ticks;
    long unsigned int guest_time;
    long int cguest_time;
    long unsigned int start_data;   
    long unsigned int end_data;
    long unsigned int start_brk;    
    long unsigned int arg_start;    //參數起始地址
    long unsigned int arg_end;      //參數結束地址
    long unsigned int env_start;    //環境變量在內存中的起始地址
    long unsigned int env_end;      //環境變量的結束地址
    int exit_code; //退出狀態碼
};

從文件中讀入并格式化為結構體:

struct proc_stat get_proc_stat(int Pid) {
    FILE *f = NULL;
    struct proc_stat stat = {0};
    char tmp[100] = "0";
    stat.comm = tmp;
    char stat_path[20];
    char* pstat_path = stat_path;

    if (Pid != -1) {
        sprintf(stat_path, "/proc/%d/stat", Pid);
    } else {
        pstat_path = "/proc/self/stat";
    }

    if ((f = fopen(pstat_path, "r")) == NULL) {
        printf("open file error");
        return stat;
    }

    fscanf(f, "%d ", &stat.pid);
    fscanf(f, "(%100s ", stat.comm);
    tmp[strlen(tmp)-1] = '\0';
    fscanf(f, "%c ", &stat.state);
    fscanf(f, "%d ", &stat.ppid);
    fscanf(f, "%d ", &stat.pgid);

    fscanf (
            f,
            "%d %d %d %u %lu %lu %lu %lu %lu %lu %ld %ld %ld %ld %ld %ld %llu %lu %ld %lu %lu %lu %lu %lu %lu %lu %lu %lu %lu %lu %lu %lu %d %d %u %u %llu %lu %ld %lu %lu %lu %lu %lu %lu %lu %d",
            &stat.session, &stat.tty_nr, &stat.tpgid, &stat.flags, &stat.minflt,
            &stat.cminflt, &stat.majflt, &stat.cmajflt, &stat.utime, &stat.stime,
            &stat.cutime, &stat.cstime, &stat.priority, &stat.nice, &stat.num_threads,
            &stat.itrealvalue, &stat.starttime, &stat.vsize, &stat.rss, &stat.rsslim,
            &stat.startcode, &stat.endcode, &stat.startstack, &stat.kstkesp, &stat.kstkeip,
            &stat.signal, &stat.blocked, &stat.sigignore, &stat.sigcatch, &stat.wchan,
            &stat.nswap, &stat.cnswap, &stat.exit_signal, &stat.processor, &stat.rt_priority,
            &stat.policy, &stat.delayacct_blkio_ticks, &stat.guest_time, &stat.cguest_time, &stat.start_data,
            &stat.end_data, &stat.start_brk, &stat.arg_start, &stat.arg_end, &stat.env_start,
            &stat.env_end, &stat.exit_code
    );
    fclose(f);
    return stat;
}

和我們需要獲取的數據做了一下對比, 可以獲取以下數據

ppid 父進程id
pgid 進程組id
sid 進程會話id
start_time 父進程開始運行的時間
run_time 父進程已經運行的時間

/proc/[pid]/exe

通過/proc/[pid]/exe獲取可執行文件的路徑, 這里/proc/[pid]/exe是指向可執行文件的軟鏈接, 所以這里通過readlink函數獲取軟鏈接指向的地址.

這里在讀取時需要注意如果readlink讀取的文件已經被刪除, 讀取的文件名后會多一個 (deleted), 但是agent也不能盲目刪除文件結尾時的對應字符串, 所以在寫server規則時需要注意這種情況

char *get_proc_path(int Pid) {
    char stat_path[20];
    char* pstat_path = stat_path;
    char dir[PATH_MAX] = {0};
    char* pdir = dir;
    if (Pid != -1) {
        sprintf(stat_path, "/proc/%d/exe", Pid);
    } else {
        pstat_path = "/proc/self/exe";
    }

    readlink(pstat_path, dir, PATH_MAX);
    return pdir;
}

/proc/[pid]/cmdline

獲取進程啟動的是啟動命令, 可以通過獲取/proc/[pid]/cmdline的內容來獲得, 這個獲取里面有兩個坑點

  1. 由于啟動命令長度不定, 為了避免溢出, 需要先獲取長度, 在用malloc申請堆空間, 然后再將數據讀取進變量.
  2. /proc/self/cmdline文件里面所有的空格和回車都會變成 '\0'也不知道為啥, 所以需要手動換源回來, 而且若干個相連的空格也只會變成一個'\0'.

這里獲取長度的辦法比較蠢, 但是用fseek直接將文件指針移到文件末尾的辦法每次返回的都是0, 也不知道咋辦了, 只能先這樣

long get_file_length(FILE* f) {
    fseek(f,0L,SEEK_SET);
    char ch;
    ch = (char)getc(f);
    long i;
    for (i = 0;ch != EOF; i++ ) {
        ch = (char)getc(f);
    }
    i++;
    fseek(f,0L,SEEK_SET);
    return i;
}

獲取cmdline的內容

char* get_proc_cmdline(int Pid) {
    FILE* f;
    char stat_path[100] = {0};
    char* pstat_path = stat_path;

    if (Pid != -1) {
        sprintf(stat_path, "/proc/%d/cmdline", Pid);
    } else {
        pstat_path = "/proc/self/cmdline";
    }

    if ((f = fopen(pstat_path, "r")) == NULL) {
        printf("open file error");
        return "";
    }
    char* pcmdline = (char *)malloc((size_t)get_file_length(f));
    char ch;
    ch = (char)getc(f);
    for (int i = 0;ch != EOF; i++ ) {
        *(pcmdline + i) = ch;
        ch = (char)getc(f);
        if ((int)ch == 0) {
            ch = ' ';
        }
    }
    return pcmdline;
}

小結

這里寫的只是實現的一種最常見最簡單的應用級hook的方法具體實現和代碼已經放在了github上, 同時github上的代碼會保持更新, 下次的文章會分享如何使用LKM修改sys_call_table來hook系統調用的方式來實現HIDS的hook.

參考文章

https://www.freebuf.com/articles/system/54263.html
http://abcdefghijklmnopqrst.xyz/2018/07/30/Linux_INT80/
https://cloud.tencent.com/developer/news/337625
https://github.com/g0dA/linuxStack/blob/master/%E8%BF%9B%E7%A8%8B%E9%9A%90%E8%97%8F%E6%8A%80%E6%9C%AF%E7%9A%84%E6%94%BB%E4%B8%8E%E9%98%B2-%E6%94%BB%E7%AF%87.md

附錄1

這里完整的說明了/proc目錄下每一個文件具體的意義是什么.
http://man7.org/linux/man-pages/man5/proc.5.html


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.jmbmsq.com/1102/