扫码领资料
获网安教程
来Track安全社区投稿~
赢千元稿费!还有保底奖励~(https://bbs.zkaq.cn)
容器化环境的风险
尽管容器为应用提供了隔离的运行时环境,但这种隔离往往被高估了。容器封装了依赖项并确保一致性,但由于它们共享宿主系统的内核,因此引入了安全风险。
根据入侵评估、SOC 咨询和事件响应服务的过程中积累的经验,我们反复看到与容器可见性不足相关的问题。许多组织将监控容器化环境的重点放在运行状态而非安全威胁上。一些组织缺乏正确配置日志的专业知识,而另一些则依赖于不支持有效监控运行中容器的技术栈。
这类可见性受限的环境对威胁猎人和事件响应人员来说往往极具挑战,因为很难明确区分容器内运行的进程与直接在主机上执行的进程。这种模糊性使得确定攻击的真实起源变得困难,即攻击究竟是始于被攻陷的容器,还是直接从主机发起的。
本文的目的是解释如何仅使用基于主机的执行日志来还原运行中容器的执行链,帮助威胁猎人和事件响应人员确定入侵的根本原因。
容器是如何创建并运行的
要有效调查容器化环境中的安全事件并开展威胁狩猎,必须了解容器的创建方式及其运行机制。与作为独立操作系统运行的虚拟机不同,容器是共享宿主操作系统内核的用户空间隔离环境。它们依赖于命名空间、控制组(cgroups)、联合文件系统、Linux 权限以及其他 Linux 功能来进行资源管理和隔离。
由于这一架构,容器内的每一个进程实际上都是在主机上运行的,只是处于一个独立的命名空间中。威胁猎人和事件响应人员通常依赖基于主机的执行日志来回溯查看已执行的进程和命令行参数。这使他们能够分析缺乏专用容器化环境监控解决方案的网络。然而,某些日志配置可能会缺失关键属性,例如命名空间、cgroups 或特定系统调用。在这种情况下,我们可以通过从主机的视角理解运行中容器的进程执行链,来弥补日志属性缺失所带来的可见性差距,而不必完全依赖那些可能缺失的日志字段。
容器创建流程概览
终端用户通过命令行工具(如 Docker CLI、kubectl 等)来创建和管理容器。在后台,这些工具与一个引擎通信,该引擎用于与高级容器运行时进行交互,最常见的包括 containerd 或 CRI-O。这些高级容器运行时会调用底层容器运行时(如最常用的 runc)来执行与 Linux 操作系统内核交互的核心操作。此类交互将分配 cgroups、命名空间及其他 Linux 功能,以根据高级运行时提供的 bundle 来创建或终止容器。而高级运行时本身则基于用户提供的参数。
bundle 是一个自包含的目录,按照开放容器计划(OCI)运行时规范定义了容器的配置,主要包括以下内容:
-
1. 一个 rootfs 目录,作为容器的根文件系统。它是通过提取并合并容器镜像中的多个层构建的,通常使用 OverlayFS 等联合文件系统。 -
2. 一个 config.json 文件,描述符合 OCI 运行时规范的配置,定义了创建容器所需的进程、挂载点以及其他配置项。
需要注意的是 runc 的运行模式,因为它支持两种模式:前台模式和分离(后台)模式。根据所选择的模式,生成的进程树可能有所不同。在前台模式中,runc 作为容器进程的父进程长时间运行在前台,主要用于处理标准输入输出,以便终端用户能与运行中的容器进行交互。
使用 runc 前台模式创建容器的进程树
然而,在分离(后台)模式下,不会存在长期运行的 runc 进程。runc 在创建容器后会立即退出,由调用它的进程负责处理标准输入输出。在大多数情况下,这个调用进程是 containerd 或 CRI-O。
如下图所示,当我们使用 runc 执行一个分离模式的容器时,runc 会创建容器并立即退出。因此,容器进程的父进程就是宿主机的 PID 1(即 systemd 进程)。
使用 runc 分离模式创建容器的进程树
然而,如果我们使用例如 Docker CLI 创建一个分离模式的容器,会发现容器进程的父进程并不是 PID 1,而是一个 shim 进程!
使用 Docker CLI 分离模式创建容器的进程树
在现代架构中,高级和底层容器运行时之间的通信是通过一个 shim 进程进行代理的。这使得容器可以在不依赖高级容器运行时的情况下独立运行,即使高级运行时崩溃或重启,容器依然可以持续运行。shim 进程还负责管理容器进程的标准输入输出,使得用户可以通过如 docker exec -it <container>
等命令在之后连接到正在运行的容器。此外,shim 进程还可以将 stdout 和 stderr 重定向到日志文件中,用户可以直接通过文件系统查看这些日志,或使用 kubectl logs <pod> -c <container>
等命令进行查看。
当使用 Docker CLI 创建一个分离模式的容器时,高级容器运行时(如 containerd)会启动一个 shim 进程,该进程调用 runc(作为底层容器运行时)以分离模式创建容器。之后,runc 会立即退出。为了避免容器进程成为孤儿进程或被重定向到 PID 1(就像我们手动执行 runc 时那样),shim 进程会显式将自己设置为 subreaper,以在 runc 退出后收养这些容器进程。
Linux subreaper 进程 是一个被指定的父进程,用于接管其链条中的孤儿子进程(而不是交由 init 处理),从而使它能够管理和清理整个进程树。
容器在创建后会被重新归属于 shim 进程
这一机制已在最新的 V2 版本 shim 中实现,并且是现代 containerd 实现中的默认行为。
shim 进程在创建过程中将自身设置为 subreaper 进程
例如,当我们查看 containerd-shim-runc-v2 进程的帮助信息时,可以看到它接受容器 ID 作为命令行参数,并将其称为 任务的 ID。
shim 进程的帮助信息
我们可以通过检查正在运行的 containerd-shim-runc-v2 进程的命令行参数,并将其与正在运行的容器进行对比来确认这一点。
shim 进程通过命令行参数接收相关容器的 ID
到目前为止,我们已经成功地从主机的角度识别出容器进程。在现代架构中,通常会看到以下两种进程之一作为容器化进程的父进程:
-
• 在分离模式(detached mode)下,是 shim 进程; -
• 在前台(交互式)模式下,是 runc 进程。
我们还可以通过 shim 进程的命令行参数来判断该进程属于哪个容器。
从主机视角观察容器的进程树
尽管追踪 shim 进程的子进程有时能快速定位问题,但实际操作往往没有听起来那么简单,尤其是在 shim 进程与恶意进程之间存在大量子进程的情况下。此时,我们可以采取自底向上的方法,从恶意进程出发,逐层向上追踪其父进程,直到找到 shim 进程,以确认该进程是否是在运行中的容器内执行的。接下来就是选择哪个进程的行为需要进一步检查,以识别潜在的恶意或可疑活动。
由于容器通常以最小依赖运行,攻击者往往依赖 shell 访问来直接执行命令,或安装其恶意程序所需的缺失依赖。因此,容器中的 shell 进程是检测中的关键关注点。但这些 shell 的行为究竟如何?让我们更深入地了解容器化环境中的关键 shell 进程之一。
BusyBox 和 Alpine 如何执行命令?
本文重点关注基于 BusyBox 的容器行为。我们还以基于 Alpine 的容器为例,说明其作为镜像基础依赖 BusyBox 实现许多核心 Linux 工具,从而保持镜像的轻量化。为了演示方便,依赖其他工具的 Alpine 镜像不在本文讨论范围内。
BusyBox 为许多常用的 UNIX 工具提供了简化替代版本,将它们集成到一个小型可执行文件中。这使得可以创建体积显著减小的轻量级容器镜像。但 BusyBox 可执行文件到底是如何工作的呢?
BusyBox 自行实现了系统工具,称为 applet(小程序)。每个 applet 都用 C 语言编写,存放在源码的 busybox/coreutils/ 目录中。例如,UNIX 中的 cat 工具 有一个自定义实现,名为 cat.c。运行时,BusyBox 会创建一个 applet 表,将 applet 名称映射到对应的函数。该表用于根据命令行参数决定执行哪个 applet。这个机制定义在 appletlib.c 文件 中。
appletlib.c 文件片段
当执行的命令调用了一个非默认 applet 的已安装工具时,BusyBox 会依赖 PATH 环境变量来确定该工具的位置。一旦路径被确定,BusyBox 会将该工具作为 BusyBox 进程的子进程启动。这个动态执行机制对于理解基于 BusyBox 的容器内命令执行的工作原理至关重要。
Applet/程序执行逻辑
既然我们已经清楚了解了 BusyBox 二进制文件的工作原理,接下来让我们探讨它在容器内运行时的表现。例如,当你在这类容器中执行 sh
命令时,会发生什么?
在 BusyBox 和 Alpine 容器中,执行 sh
命令以访问 shell 时,实际上并不是调用一个名为 sh 的独立二进制文件,而是直接执行 BusyBox 二进制文件本身。我们可以通过比较 /bin/sh
和 /bin/busybox
的 inode(使用 ls -li
)来验证,在 BusyBox 容器中 /bin/sh
已被 BusyBox 替代,二者具有相同的 inode 号。还可以打印它们的 MD5 哈希值来确认它们是相同的,并且执行 /bin/sh --help
时,输出的帮助信息页首会显示 BusyBox 的标识。
在基于 BusyBox 的容器中,/bin/sh 被 /bin/busybox 替代
另一方面,在 Alpine 容器中,/bin/sh 是指向 /bin/busybox 的符号链接。这意味着当你运行 sh
命令时,实际上执行的是符号链接指向的 BusyBox 可执行文件。可以通过执行 readlink -f /bin/sh
并观察输出结果来确认这一点。
在基于 Alpine 的容器中,/bin/sh 是指向 /bin/busybox 的符号链接。
因此,在基于 BusyBox 或 Alpine 的容器内,所有的 shell 命令要么由 BusyBox 进程直接执行,要么作为 BusyBox 进程的子进程启动。这些进程运行在宿主操作系统的隔离命名空间中,既实现了必要的容器化,又能共享宿主机的内核。
从威胁狩猎的角度来看,宿主操作系统上出现非标准的 shell 进程(比如这里的 BusyBox)应该引起进一步调查。为什么在 Debian 或 RedHat 系统上会运行 BusyBox shell 进程?结合之前的结论,如果观察到 runc 或 shim 作为 BusyBox 进程的前置进程,我们就可以确认该 shell 是在容器内执行的。这一认知不仅适用于 BusyBox 进程,也适用于任何在运行容器内执行的进程。掌握这些知识对于利用宿主机执行日志有效判断可疑行为的来源至关重要。
一些安全工具,比如 卡巴斯基容器安全,专门设计用于监控容器活动并检测可疑行为。还有一些工具,比如 Auditd,基于预配置的规则,在内核层面提供丰富的日志记录,捕获系统调用、文件访问和用户活动。然而,这些规则往往没有针对容器化环境进行优化,使得区分宿主机和容器内的活动更加复杂。
调查价值
在调查执行日志时,威胁猎人和事故响应人员可能会忽略 Linux 机器上的某些活动,认为它们是正常操作的一部分。然而,同样的活动如果发生在运行中的容器内,则应引起警惕。例如,在宿主机上安装 Docker CLI 可能是正常行为,但在容器内就不然。最近,在一次 入侵评估项目中,我们发现了一个加密货币挖矿活动,威胁行为者在运行中的容器内安装了 Docker CLI,以便轻松与 dockerd API 通信。
确认 docker.io 安装发生在运行中的容器内
在这个例子中,我们通过追踪进程链检测到 Docker CLI 在容器内的安装。随后,我们确定了执行该命令的来源,并通过检查 shim 进程的命令行参数,确认了该命令是在具体哪个容器中执行的。
在另一次调查中,我们发现了一个有趣的事件:进程名为 systemd,但进程可执行文件路径却是 /.redtail。为确定该进程的来源,我们采用了相同的方式,追踪其父进程链。
确定可疑事件发生的容器
我们还可以利用的另一个有趣事实是,Docker 容器总是由 runc 进程作为低级容器运行时创建的。runc 的帮助信息展示了用于创建、运行或启动容器的命令行参数。
runc 帮助信息
监控这些事件有助于威胁猎人和事件响应人员识别目标容器的 ID,并检测任何异常的入口点。容器的入口点是其主进程,通常由 runc 生成。下方截图展示了通过追踪具有可疑命令行参数的入口点而检测到的恶意容器创建的示例。在此案例中,命令行包含一个恶意的 Base64 编码命令。
追踪可疑的容器入口点
结论
由于容器在部署和依赖封装上的便利性,容器化环境现已成为大多数组织网络的一部分。然而,由于对容器隔离的普遍误解,安全团队和决策者往往忽视了它们。这导致当容器被攻破时,安全团队缺乏足够的知识或工具来支持响应活动,甚至无法进行有效的监控或检测。
声明:⽂中所涉及的技术、思路和⼯具仅供以安全为⽬的的学习交流使⽤,任何⼈不得将其⽤于⾮法⽤途以及盈利等⽬的,否则后果⾃⾏承担。所有渗透都需获取授权!
如果你是一个网络安全爱好者,欢迎加入我的知识星球:zk安全知识星球,我们一起进步一起学习。星球不定期会分享一些前沿漏洞,每周安全面试经验、SRC实战纪实等文章分享,微信识别二维码,即可加入。
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论