安全告警和监测中使用的 Linux 进程和会话模型
Linux 进程模型(在 Elastic 中提供)允许用户编写十分具有针对性的告警规则,并更深入地了解其 Linux 服务器和桌面上具体正在发生什么。
在本篇博文中,我们将会提供有关 Linux 进程模型的背景信息,此模型是 Linux 工作负载表示方式的一个重要方面。
Linux 遵循 1970 年代的 Unix 进程模型,从 setsid() 系统调用通过早期 POSIX 文档被引入的时刻加以判断,此模型在 1980 年代使用会话的概念进行了强化。
Linux 进程模型对于记录计算机工作负载(正在运行哪些程序)和编写应对这些事件的规则而言,都是良好的抽象。它针对谁在什么时候在哪个服务器上完成了什么操作,提供了清晰的表示,以用于告警、合规和威胁猎捕目的。
捕获进程创建、特权升级和生命跨度能够提供有关下列内容的深入洞察:应用程序和服务的实施方式,以及其程序执行的正常模式。一旦确定了正常执行模式,就可以编写规则来在出现异常执行模式时发送告警。
有了详细的进程信息,就可以编写十分具有针对性的告警规则,进而减少误报和告警疲劳。它还允许将 Linux 会话划分为下列类型之一:
- 启动时开始的自动服务(例如 cron)
- 提供远程访问的服务(例如 sshd)
- 交互式(像人类一样)远程访问(例如通过 ssh 启动的 bash 终端)
- 非交互式远程访问(例如 Ansible 通过 ssh 安装软件)
这些类型允许制定和审查非常精准的规则。例如,用户可以审查选定时间范围内特定服务器上的所有交互式会话。
本文描述了 Linux 进程模型的工作方式,以及它如何有助于编写针对工作负载事件的告警和响应规则。在理解容器以及组成容器的命名空间和 cgroup 时,理解 Linux 进程模型也是至关重要的第一步。
对比进程模型捕获与系统调用日志
捕获会话模型在新进程、新会话、退出进程等方面的变化比捕获用于实施这些变化的系统调用更简单、更清晰。Linux 有大约 400 个系统调用,而且这些调用一旦发布之后便不再对其进行重构。这一方法保留了稳定的应用程序二进制接口 (ABI),这意味着多年前为在 Linux 上运行而编写的程序今天仍能够继续在 Linux 上运行,而无需从源代码层面开始对其进行重新构建。
添加了新的系统调用来改善功能或安全性,而不是重新构建既有的系统调用(进而避免了损坏 ABI)。最终结果就是将按时间排序的系统调用和其参数列表映射到其执行的逻辑操作时,用户需要大量专业知识。此外,通过使用在内核和用户空间之间进行映射的内存,较新的系统调用(例如 io_uring)使得在无需额外系统调用的情况下读取和写入文件和套接字成为可能。
与之相反,进程模型不仅稳定(从 1970 年代以来变化不大),而且仍很全面地覆盖了在系统上完成的操作(包括文件访问、联网和其他逻辑运算)。
进程形成:init 是启动之后的第一个进程
在 Linux 内核启动的时候,它会创建一个名为“init 进程”的特殊进程。进程代表了一个或多个程序的执行过程。init 进程的进程 ID (PID) 始终为 1,并且由用户 ID 为 0 的用户 (root) 执行。大部分现代 Linux 分发版本会使用 systemd 作为其 init 进程的可执行程序。
init 的工作是启动已配置的服务,例如数据库、网络服务器,以及远程访问服务(例如 sshd)。这些服务通常会封装在其自身会话内;通过将每项服务的所有进程都分组到单一会话 ID (SID) 下,这能够简化启动和结束服务。
远程访问(例如通过 SSH 协议连接至 sshd 服务)会为访问用户创建一个新的 Linux 会话。此会话最初会执行远程用户所请求的程序(通常是一个交互式 shell),且相关的进程会拥有相同的 SID。
创建进程的机制
每个进程(init 进程除外)都有单一的父进程。每个进程都有一个 PPID,即其父进程的进程 ID(如为 init,则为 0/无父进程)。如果某个父进程退出时并未同时终止子进程,则会为子进程重新寻找父进程。
重新寻找父进程时,通常会挑选 init 作为新的父进程;对于这些被收养的子进程,init 在它们退出时有特殊代码来清理它们。如果没有这些收养和清理代码,这些孤儿子进程就会成为“僵尸”进程(不是开玩笑!)。它们会一直游荡,直到它们的父进程来收割它们,这样父进程就可以检查它们的退出代码——退出代码可以表示子程序是否成功完成了其任务。
“容器”(尤其是 pid 命名空间)的到来,使得指派 init 之外的进程作为“次级收割者”(愿意领养孤儿进程的进程)的能力成为了必需。通常次级收割者是容器中的第一个进程。之所以这么做,是因为容器中的进程并不能“看到”祖先 pid 命名空间中的进程(即如果父进程在祖先 pid 命名空间的话,它们的 PPID 值根本没有意义。)
为了创建子进程,父进程会通过 fork() 或 clone() 系统调用来克隆自身。在 fork/clone 之后,执行过程会立即同时在父进程和子进程中继续(忽略 vfork() 和 clone() 的 CLONE_VFORK 选项),但会根据来自 fork()/clone() 的返回代码值沿着不同的代码路径完成。
您看到的没错:一个 fork()/clone() 系统调用会在两个不同的进程中提供返回代码!因为父进程会收到子进程的 PID 作为其返回代码,子进程则会收到 0,所以父进程和子进程的共同代码可以基于那个值创建分支。为提高效率而使用多线程父进程和“写入时复制”内存时,有一些克隆方面的微小差异,但是在这里并不需要详细解释。子进程会继承父进程的内存状态,以及其打开的文件、网络套接字以及控制终端(如有)。
通常,父进程会捕获子进程的 PID 以监测其生命周期(参看上面的收割过程)。子进程的行为取决于曾克隆自身的程序(它会基于来自 fork() 的返回代码提供一个需要遵循的执行路径)。
网络服务器(诸如 nginx)可能会克隆自身,创建一个子进程来处理 http 连接。在像这样的情况下,子进程不会执行新程序,但会在同一程序中简单运行一个不同的代码路径以处理此种情况下的 http 连接。回想一下,来自 clone 或 fork 的返回值会告诉子进程,让它知道它是子进程,以便它能选择此代码路径。
无论何时输入命令,可能来自 ssh 会话的交互式 shell 进程(例如有控制终端的 bash、sh、fish、zsh 等中的一个)会克隆自身。在子进程中的代码路径调用 execve() 系统调用或类似命令来在该进程内运行不同的程序之前,子进程(其仍然运行来自父进程/shell 的代码路径)会完成大量工作:为 IO 重定向创建文件描述符,设置进程组,等等。
如果您向您的 shell 中键入 ls,它会对您的 shell 进行分岔,上面所描述的设置是由 shell/子进程来完成的,然后会执行 Is 程序(通常来自 /cn/usr/bin/ls 文件)来将该进程的内容替换为 Is 的机器代码。本篇有关执行 shell 作业控制的文章提供了有关 shell 和进程组的内在工作机制的深入洞察。
需要注意的重要一点是,进程可以不止一次调用 execve(),因此工作负载捕获数据模型必须也能够处理此种情况。这意味着一个进程在退出之前可能会变为许多不同的程序 — 而不仅仅是其父进程程序后面选择性地跟一个程序。查看 shell exec 内置命令了解在 shell 中这样做的一种方法(亦即使用同一进程中的其他程序来替换 shell 程序)。
在进程中执行程序的另一个方面是,在执行新程序之前,某些打开的文件的描述符(被标记为 close-on-exec(执行时关闭)的那些)可能会被关闭,同时其他的仍可能继续供新程序使用。回想一下,一个单独的 single fork()/clone() 调用会在两个进程(父进程和子进程)中提供返回代码。execve() 系统调用也很奇怪,因为成功的 execve() 在其成功时并没有返回代码,而这又是因为它会引发新程序的执行;所以除非 execve() 失败,否则并没有任何地方可以返回。
创建新会话
Linux 目前会通过单一系统调用 setsid() 来创建新会话,而 setsid() 会由成为新会话领导进程的进程加以调用。此系统调用通常是执行该进程中其他程序之前所运行的已克隆子进程代码路径的一部分(即它是由父进程的代码规划的,而且也包含在父进程的代码中)。会话中的所有进程会共有一个相同的 SID,此 SID 与被称为 setsid() 的进程(也被称作会话领导进程)的 PID 相同。换言之,会话领导进程就是 PID 与其 SID 匹配的任何进程。会话领导进程的退出会触发其直接子进程组的终止。
创建新的进程组
Linux 使用进程组来识别在会话内共同协作的一组进程。它们都会拥有相同的 SID 和进程组 ID (PGID)。PGID 是进程组领导进程的 PID。进程组领导进程并没有特殊状态;它退出时不会对进程组内的其他成员造成影响,而且进程组内的其他成员仍会继续保留相同的 PGID——尽管带有此 PID 的进程已经不复存在。
请注意,即使使用 pid-wrap(在繁忙系统上重新使用最近使用的 pid),Linux 内核仍能确保在进程组的所有成员都退出之前,不会重新使用已退出进程组领导进程的 pid(亦即无论如何它们的 PGID 都不会意外指向新进程)。
对于诸如下面的 shell 管道命令而言,进程组很有价值:
cat foo.txt | grep bar | wc -l
这会为三个不同的程序(cat、grep 和 wc)创建三个进程,并通过管道将其相连。即使对于诸如 Is 等单一程序命令,shell 也会创建新的进程组。进程组的目的是允许将信号定向到一组进程,并识别一组进程(前台进程组);此进程组已获得许可,拥有其会话的控制终端(如有)的完整读写访问权限。
换言之,您的 shell 中的 control-C 会向前台进程组中的所有进程发送中断信号(负 PGID 值作为信号的 pid 目标,以便区分组和进程组领导进程自身)。控制终端关联可确保:从终端读取输入的进程不会彼此竞争并造成问题(来自非前台进程组的终端输出可能会获得许可)。
用户和组
如上面所述,init 进程的用户 ID 为 0 (root)。每个进程都有一个关联的用户和组,这些可用来限制对系统调用和文件的访问权限。用户和组拥有数字 ID,而且可能有一个关联的名称,例如 root 或 ms。根用户是可以执行任何操作的超级用户,只有在出于安全原因有绝对必要的情况下才能使用。
Linux 内核只关心 ID。名称是可选项,由文件 /etc/passwd 和 /etc/group 提供以方便人类使用。Name Service Switch (NSS) 允许使用来自 LDAP 和其他目录的用户和组对这些文件进行扩展(如果您想看到 /cn/etc/passwd 和NSS 所提供用户的组合,请使用 getent passwd)。
每个进程都可能有与其相关联的数个用户和组(真实、有效、已保存和补充组)。查看手册 7 凭据了解更多信息。
由于容器(其根文件系统由容器映像定义)的使用量增加,这增加了缺失 /cn/etc/passwd 和 /cn/etc/group 或缺少可能正在使用的某些用户名称或组 ID 的可能性。由于 Linux 内核不关心这些名称,只关心 ID,所以这并不会造成问题。
总结
Linux 进程模型提供了一种精准且简约的方式来表示服务器工作负载,而这又相应地能够允许编写和审查十分具有针对性的告警规则。在您的浏览器中以易于理解的方式针对每个会话呈现进程模型,这能够提供有关您服务器工作负载的出色洞察。
您可开始使用 Elastic Cloud 的 14 天免费试用版。或者,您也可以免费下载 Elastic Stack 的自管型版本。
了解更多
Linux 手册页面是特别好的信息来源。下面的手册页面详细介绍了上面所描述的 Linux 进程模型: