最后防线: Linux进程实时监控
2022-11-24 10:19:20 Author: debugeeker(查看原文) 阅读量:21 收藏

做Linux主机入侵检测系统,对进程监控是一个难点,要做不遗漏,也要做不影响系统性能,是非常困难。

在现代操作系统中,任何攻击行为都是借助进程这个执行单元来进行,检测攻击行为往往是对进程监控,检测是否存在异常行为。

命令方式

基本上,使用Linux的人都会用ps来获取进程信息。如果是获取所有进程,往往是

ps -ef

ps axu

如果是放在主机入侵检测系统实现,往往会使用fork/execvpopensystem之类的API调用ps命令,来获取命令的结果。这种方式非常简单容易上手,却存在问题:

  • 调用频次的考虑:太过频繁,会消耗大量系统性能,如果在生产环境机器使用,会影响业务; 频次太低,很多进程活动无法监控到。
  • 调用命令的隐患:任何一个命令在启动时,都要加载一大堆依赖的so,如果某些so不存在,命令是执行不了。如果命令执行完之后出现异常,成为僵尸进程,就会消耗大量系统句柄,导致后面一些业务进程无法启动。
  • 错误的处理:由于是调用命令,命令获取数据是否异常,无法得知,对这种错误无法处理,也会导致有大量无效数据。
  • 数据量的考虑:每次调用都是采集当前系统所有的进程,大量冗余数据,需要做不少过滤工作,否则会导致数据暴增。

读取proc文件系统

按照Unix哲学一切皆文件ps命令肯定是读取某些文件来获取这些信息。在《Unix环境高级编程》这本书都提到过ps的实现,是读取proc文件系统的。使用strace ps可以看到,ps就是读取proc文件系统的。

取一条ps的结果来对照一下proc文件系统能够获取的内容

UID         PID   PPID  C STIME TTY          TIME CMD
root       1326   1151  0 Feb02 ?        00:00:00 /sbin/dhclient -d -q -sf /usr/libexec/nm-dhcp-helper -pf /var/run/dhclient-ens33.pid -lf /var/lib/NetworkManager/dhclient-b8281210-bced-41a8-ba17-025e1d24054a-ens33.lease -cf /var/lib/NetworkManager/dhclient-ens

从上面信息可以看到启动进程的用户是root, 进程ID是1326,进程父ID是1151, cpu(C)使用率为0,启动时间(STIME)是2月2日,时间为0, 命令行是/sbin/dhclient -d -q -sf /usr/libexec/nm-dhcp-helper -pf /var/run/dhclient-ens33.pid -lf /var/lib/NetworkManager/dhclient-b8281210-bced-41a8-ba17-025e1d24054a-ens33.lease -cf /var/lib/NetworkManager/dhclient-ens

看一下1326这个进程在proc文件系统的内容:

[[email protected] ~]# stat /proc/1326
  File: ‘/proc/1326’
  Size: 0               Blocks: 0          IO Block: 1024   directory
Device: 3h/3d   Inode: 22643       Links: 9
Access: (0555/dr-xr-xr-x)  Uid: (    0/    root)   Gid: (    0/    root)
Context: system_u:system_r:dhcpc_t:s0
Access: 2021-02-02 18:44:15.878000578 +0800
Modify: 2021-02-02 18:44:15.878000578 +0800
Change: 2021-02-02 18:44:15.878000578 +0800
 Birth: -

是2021年2月2日18:44:15分钟启动的,用户是root,组也是root。

[[email protected] ~]# cat /proc/1326/cmdline
/sbin/dhclient-d-q-sf/usr/libexec/nm-dhcp-helper-pf/var/run/dhclient-ens33.pid-lf/var/lib/NetworkManager/dhclient-b8281210-bced-41a8-ba17-025e1d24054a-ens33.lease-cf/var/lib/NetworkManager/dhclient-ens33.confens33

命令行也是和ps结果一样。

[[email protected] ~]# cat /proc/1326/status
Name:   dhclient
Umask:  0022
State:  S (sleeping)
Tgid:   1326
Ngid:   0
Pid:    1326
PPid:   1151

可以看到进程父ID是1151,进程在睡眠状态,所以使用率和使用时间是0.

而且通过阅读proc的手册知道,从proc文件系统还可以得到进程很多信息:

  1. cpu使用量

  2. 内存使用量

  3. 句柄数量和信息

  4. 线程数量和信息

  5. 端口和网络数据信息

  6. 命名空间信息

  7. 库加载信息

  8. 环境变量信息

  9. 文件系统加载信息

上面的操作,如果是在主机入侵检测系统里实现,就可以通过opendir/readdir/closedir, open/read/close, readlink/realpath之类的API实现。这样的好处:

  • 不会产生僵尸进程
  • 可以对错误情况很好处理
  • 大大减少了采集的数据量:通过记录每次扫描时间,可以只对新创建的进程采集

但还是没有解决采集频率的问题,会存在消耗系统性能或遗漏采集的情况。这就需要进程的实时监控了。

实时监控方式

要对进程实时监控,最好的方式肯定是直接和内核通讯,能够注册一个监听器,每当有新进程创建就立马收到一个事件。那Linux有没有这样机制?

在Linux的socket家族里,除了常用的ip socket, unix socket外,还有其它socket,其中有一个叫netlink socket,用于用户态和内核态通信的,这是Linux 2.0内核就以ioctl方式出现,在2.2内核才以socket方式出现,而且在Linux 2.6.14内核里面,netlink有一种新的特性加入,叫Kernel Connector, 这种特性可以用来实时获取进程启动和退出的事件。

通过这种方式能够获得事件,事件的定义可以看/usr/include/linux/cn_proc.h


struct proc_event {
 enum what {
  /* Use successive bits so the enums can be used to record
   * sets of events as well
   */

  PROC_EVENT_NONE = 0x00000000,
  PROC_EVENT_FORK = 0x00000001,
  PROC_EVENT_EXEC = 0x00000002,
  PROC_EVENT_UID  = 0x00000004,
  PROC_EVENT_GID  = 0x00000040,
  PROC_EVENT_SID  = 0x00000080,
  PROC_EVENT_PTRACE = 0x00000100,
  PROC_EVENT_COMM = 0x00000200,
  /* "next" should be 0x00000400 */
  /* "last" is the last process event: exit,
   * while "next to last" is coredumping event */

  PROC_EVENT_COREDUMP = 0x40000000,
  PROC_EVENT_EXIT = 0x80000000
 } what;
 __u32 cpu;
 __u64 __attribute__((aligned(8))) timestamp_ns;
  /* Number of nano seconds since system boot */
 union { /* must be last field of proc_event struct */
  struct {
   __u32 err;
  } ack;

  struct fork_proc_event {
   __kernel_pid_t parent_pid;
   __kernel_pid_t parent_tgid;
   __kernel_pid_t child_pid;
   __kernel_pid_t child_tgid;
  } fork;

  struct exec_proc_event {
   __kernel_pid_t process_pid;
   __kernel_pid_t process_tgid;
  } exec;

  struct id_proc_event {
   __kernel_pid_t process_pid;
   __kernel_pid_t process_tgid;
   union {
    __u32 ruid; /* task uid */
    __u32 rgid; /* task gid */
   } r;
   union {
    __u32 euid;
    __u32 egid;
   } e;
  } id;

  struct sid_proc_event {
   __kernel_pid_t process_pid;
   __kernel_pid_t process_tgid;
  } sid;

  struct ptrace_proc_event {
   __kernel_pid_t process_pid;
   __kernel_pid_t process_tgid;
   __kernel_pid_t tracer_pid;
   __kernel_pid_t tracer_tgid;
  } ptrace;

  struct comm_proc_event {
   __kernel_pid_t process_pid;
   __kernel_pid_t process_tgid;
   char           comm[16];
  } comm;

  struct coredump_proc_event {
   __kernel_pid_t process_pid;
   __kernel_pid_t process_tgid;
  } coredump;

  struct exit_proc_event {
   __kernel_pid_t process_pid;
   __kernel_pid_t process_tgid;
   __u32 exit_code, exit_signal;
  } exit;

 } event_data;
};

事件类型,作用,触发条件如下:

类型作用触发条件
PROC_EVENT_FORK进程fork事件,返回进程id,内核进程id,进程父id,内核进程父id系统调用fork,vfork
PROC_EVENT_EXEC进程exec事件,返回进程id,内核进程id系统调用execl, execlp, execle, execv, execvp, execvpe
PROC_EVENT_UID,PROC_EVENT_GID进程id事件,返回进程id,内核进程id,uid和gid或euid,egid系统调用setuid,seteuid,setreuid ,setgid,setegid,setregid
PROC_EVENT_SID进程sid事件,返回进程id,内核进程id系统调用setsid
PROC_EVENT_PTRACE进程被调试事件,进程id,内核进程id,调试器进程id,调试器内核进程id系统调用ptrace
PROC_EVENT_COMM对进程属性操作的事件,返回进程id,内核进程id,进程名称系统调用prctl
PROC_EVENT_COREDUMP进程coredump的事件,返回进程id,内核进程id各种异常信号
PROC_EVENT_EXIT进程退出事件 ,返回进程id,内核进程id,退出码,退出信号异常信号,被杀死,异常退出或正常退出

在上面,只需要关注PROC_EVENT_FORK, PROC_EVENT_EXEC, PROC_EVENT_EXIT就可以获取进程启动和退出事件。其它事件不用理会。

PROC_EVENT_COMM的事件虽然可以获取进程名称,但需要调用prctl,在内核里会对进程结构加锁,在高并发场景使用可能会造成事故。在鹅厂曾经造成3500多台机器宕机。幸好只是测试机器,负责这个功能的兄弟说,那两周时间半夜惊醒都是一身冷汗。获取进程名称可以通过读取proc文件系统来获取。

PROC_EVENT_COREDUMP虽然可以获取coredump事件,但PROC_EVENT_EXIT的退出信号里也有,而且可以更清楚知道是哪个信号导致。

了解上面的内容,接下来就说实时监控的代码

  1. 创建绑定socket,并且向内核注册监听器
static int setup_netlink( )
{
    int rc;
    int nl_sock;
    struct sockaddr_nl sa_nl;
    register_msg_t nlcn_msg;

    nl_sock = socket(PF_NETLINK, SOCK_DGRAM, NETLINK_CONNECTOR);
    if (nl_sock == -1)
    {
        error( "Can't open netlink socket");
        return -1;
    }

    sa_nl.nl_family = AF_NETLINK;
    sa_nl.nl_groups = CN_IDX_PROC;
    sa_nl.nl_pid = getpid();

    rc = bind(nl_sock, (struct sockaddr *)&sa_nl, sizeof(sa_nl));
    if (rc == -1)
    {
        error( "Can't bind netlink socket");
        close(nl_sock);
        return -1;
    }

    // create listener
    memset(&nlcn_msg, 0sizeof(nlcn_msg));
    nlcn_msg.nl_hdr.nlmsg_len = sizeof(nlcn_msg);
    nlcn_msg.nl_hdr.nlmsg_pid = getpid();
    nlcn_msg.nl_hdr.nlmsg_type = NLMSG_DONE;

    nlcn_msg.cn_msg.id.idx = CN_IDX_PROC;
    nlcn_msg.cn_msg.id.val = CN_VAL_PROC;
    nlcn_msg.cn_msg.len = sizeof(enum proc_cn_mcast_op);

    nlcn_msg.cn_mcast = PROC_CN_MCAST_LISTEN ;

    rc = send(nl_sock, &nlcn_msg, sizeof(nlcn_msg), 0);
    if (rc == -1)
    {
        error("can't register to netlink");
        close( nl_sock );
        return -1;
    }

    proc_monitor.proc_fd = nl_sock;
    return 0;
}

  1. 监听事件并处理
int proc_linux_process(proc_t *proc)
{
    int rc;
    event_msg_t proc_msg;
    fd_set readfds;
    int max_fd = proc_monitor.proc_fd + 1;
    struct timeval tv;

    tv.tv_sec = 5;
    tv.tv_usec = 0;

    while (1)
    {
        FD_ZERO(&readfds);
        FD_SET(proc_monitor.proc_fd, &readfds);

        rc = select(max_fd, &readfds, NULLNULL, &tv);
        if (0 == rc)
        {
            handle_temp_process();
            tv.tv_sec = 5;
            tv.tv_usec = 0;
            continue;
        }
        if (-1 == rc)
        {
            if (errno == EINTR)
            {
                continue;
            }
            error("failed to listen to netlink socket, errno=(%d:%m)", errno);
            return rc;
        }
        if (FD_ISSET(proc_monitor.proc_fd, &readfds))
        {
            rc = recv(proc_monitor.proc_fd, &proc_msg, sizeof(proc_msg), 0);
            if (rc > 0)
            {
                switch (proc_msg.proc_ev.what)
                {
                case proc_event::PROC_EVENT_FORK:
                    handle_process_fork(&proc_msg);
                    tv.tv_sec = 1;
                    tv.tv_usec = 1000;
                    break;
                case proc_event::PROC_EVENT_EXEC:
                    handle_process_exec(&proc_msg );
                    break;
                case proc_event::PROC_EVENT_EXIT:
                    handle_process_exit(&proc_msg );
                    break;
                default:
                    break;
                }
            }
            else if (rc == -1)
            {
                if (errno == EINTR)
                {
                    continue;
                }
                error("failed to received from netlink socket, errno=(%d:%m)", errno);
            }
        }
    }

    return 0;
}

通过netlink方式,可以实时监控进程启动再配合读取proc文件系统,可以很好地监控进程,不会有所遗漏,解决了之前那种采集频率的问题,还可以对进程异常退出有实时告警。曾经在一台1C4G的K8S管理服务器上测试,这个功能的cpu占用大概是0.3%,高峰时有3%。效果非常理想。

美中不足:对于存活寿命小于1s的短进程,很多时候无法获取进程名称,因为proc文件系统的该进程pid文件夹一创建就关闭了。而且,在频繁创建短进程的场景下,会先收到进程退出事件才收到进程创建事件,需要额外做一个筛选列表。


文章来源: http://mp.weixin.qq.com/s?__biz=MzU4NjY0NTExNA==&mid=2247486808&idx=1&sn=c40cbed29128071533c79c450d1c44db&chksm=fdf9664dca8eef5beb234e6643dc2b3dd96011cc9b6e2e8f58ad9bf11a6ac34e6577a453ffef#rd
如有侵权请联系:admin#unsafe.sh