既然容器还是共享内核的,运行在内核中的 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)); }"