既然容器还是共享内核的,运行在内核中的 eBPF 程序自然也能够跟踪和分析容器中的应用程序。但由于容器利用 Linux 的 namespace 机制进行了隔离,其跟踪和分析方法又跟直接运行在主机内的进程有些不同。

以跟踪恶意程序的执行为例,为了躲避安全监控,很多恶意程序并不是在容器一开始启动的时候就运行了恶意进程,而是先启动一个正常程序,之后再创建新的恶意进程。这种场景特别容易出现在容器安全漏洞被恶意软件侵入的场景。

跟踪系统调用 execve。比如,执行下面的 bpftrace 命令,就可以跟踪新创建的进程:

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve { printf("%-6d %-8s", pid, comm); join(args->argv);}'

打开一个新终端,执行一条 ls 命令,然后你就会看到如下的输出:

8964   bash    ls --color=auto

启动一个 Ubuntu 容器:

# -it表示进入容器终端,--rm表示终端关闭后自动清理容器
docker run -it --rm --name bash --hostname bash ubuntu:impish

在容器中执行 ls 命令,忽略容器启动过程中的进程跟踪信息(Docker 在启动容器过程中也会执行大量的命令),你会看到跟刚才类似的输出:

9018   bash    ls --color=auto

这个输出跟刚才在主机中执行 ls 后的结果是一样的,只根据这个输出,我们显然没法区分 ls 是不是运行在容器。

虽然所有容器都是共享内核的,但不同的容器之间还是通过命名空间进行了隔离。你可以使用 lsns 命令来查询容器或者主机的命名空间。比如,在刚才的容器终端中执行 lsns 命令,就可以看到如下的输出:

NS TYPE   NPROCS PID USER COMMAND
4026531834 time        2   1 root bash
4026531835 cgroup      2   1 root bash
4026531837 user        2   1 root bash
4026532530 mnt         2   1 root bash
4026532531 uts         2   1 root bash
4026532532 ipc         2   1 root bash
4026532533 pid         2   1 root bash
4026532535 net         2   1 root bash

在内核中,进程的基本信息都保存在 task_struct 结构体中,其中也包括了包含命名空间信息的  nsproxy 结构体。nsproxy 结构体的定义如下所示:

struct nsproxy {
  atomic_t count;
  struct uts_namespace *uts_ns;
  struct ipc_namespace *ipc_ns;
  struct mnt_namespace *mnt_ns;
  struct pid_namespace *pid_ns_for_children;
  struct net        *net_ns;
  struct time_namespace *time_ns;
  struct time_namespace *time_ns_for_children;
  struct cgroup_namespace *cgroup_ns;
};

为了区分一个进程是属于容器还是主机,我们可以在跟踪结果中输出 PID 命名空间和 UTS 命名空间中的主机名。

bpftrace 内置了表示进程结构体的 curtask,因而对前面的 bpftrace 脚本,我们可以进行下面的改进:

tracepoint:syscalls:sys_enter_execve {
  /* 1. 获取task_struct结构体 */
  $task = (struct task_struct *)curtask;
  /* 2. 获取PID命名空间 */
  $pidns = $task->nsproxy->pid_ns_for_children->ns.inum;
  /* 3. 获取主机名 */
  $cname = $task->nsproxy->uts_ns->name.nodename;
  /* 4. 输出PID命名空间、主机名和进程基本信息 */
  printf("%-12ld %-8s %-6d %-6d %-8s", (uint64)$pidns, $cname, curtask->parent->pid, pid, comm); join(args->argv);
}

这段代码中的具体内容含义如下:

  • 第 1 处,把内置变量 curtask 转换为我们想要的 task_struct 结构体;
  • 第 2 处,从进程信息的 nsproxy 中读取 PID 命名空间编号;
  • 第 3 处,从进程信息的 nsproxy 中读取 UTS 命名空间的主机名(也就是在容器中执行 hostname 命令后的输出);
  • 第 4 处你已经非常熟悉了,就是把刚才获取的信息输出,以便我们观察。

在运行之前,还需要给它引入相关数据结构定义的头文件:

#include <linux/sched.h>
#include <linux/nsproxy.h>
#include <linux/utsname.h>
#include <linux/pid_namespace.h>

同时,由于输出的内容比较多,为了便于理解,你还可以在脚本运行开始的时候输出一个表头,表示每个输出的含义:

BEGIN {
  printf("%-12s %-8s %-6s %-6s %-8s %s\n", "PIDNS", "CONTAINER", "PPID", "PID", "COMM", "ARGS");
}

把头文件引入和改进后的 bpftrace 脚本保存到 execsnoop-container.bt 文件中,然后打开一个新终端,运行下面的命令来执行:

sudo bpftrace execsnoop-container.bt

接下来,分别在容器终端和主机终端中执行一个 ls 命令,就可以得到如下的输出:

PIDNS        CONTAINER PPID   PID    COMM     ARGS
# 容器ls命令跟踪结果
4026532533   bash     41046  41335  bash    ls --color=auto

# 主机ls命令跟踪结果
4026531836   ubuntu.localdomain 40958  41356  bash    ls --color=auto

在输出中,容器 ls 命令跟踪结果中的 PID 命名空间 4026532533 跟上述容器中 lsns 结果是一致的,而主机名 bash 也跟运行容器时设置的 --hostname name 一致,因而我们很容易区分这条 ls 命令的来源。

只要理解了容器的基本原理,在跟踪过程中加入容器的基本信息,容器内外进程的跟踪和分析并没有本质的区别。

实际上,用户态进程的跟踪也是一样的,唯一需要注意的就是找到容器内二进制文件的正确路径。虽然容器文件系统在不同的 mount 命令空间中,但对于每个进程来说,Linux 都在 /proc/[pid]/root 处创建了一个链接。因而,容器内的文件就可以通过 /proc/[pid]/root 在主机中访问。

可以执行下面的命令,查询容器的 PID,进而再查询 bash 的 uprobe 列表:

# 查询容器进程在主机命名空间中的PID
PID=$(docker inspect -f '{{.State.Pid}}' bash)

# 查询uprobe
sudo bpftrace -l "uprobe:/proc/$PID/root/usr/bin/bash:*"

# 跟踪bash:readline的结果
sudo bpftrace -e "uretprobe:/proc/$PID/root/usr/bin/bash:readline { printf(\"User %d executed %s in container\n\", uid, str(retval)); }"