作者:u2400@知道创宇404实验室
时间:2019年12月19日

原文:https://paper.seebug.org/1102/

前言:最近在实现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进程监控的目的

在Linxu操作系统中几乎所有的运维操作和***行为都会体现到执行的命令中, 而命令执行的本质就是启动进程, 所以对进程的监控就是对命令执行的监控, 这对运维操作升级和***行为分析都有极大的帮助

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 
#include 
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命令结果如下
Linux HIDS agent 概要和用户态 HOOK(一)_第1张图片

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