“7/28 XX 版本容器 at 大面积失败问题”分析报告

遗留问题:把 struct mm 放到 file->private_date 中,是否有可能导致泄漏?

情况简述

XXX(xxxxxxxx)2021-07-29 14:32

@XX 有修改引入引起这边容器AT大面积失败

背景探究

  • 问题补丁 要解决的潜在安全问题被称为 Confused Deputy

  • /proc/$pid/attr/ 主要为安全模块所用,用于获取或设置各进程的安全相关的属性。

  • 补丁代码涉及 file->f_credcurrent_real_cred(),这就涉及 Linux 中的 cred 概念。

    • task->real_cred v.s. task->cred

      这里用了 real_cred 而不是 cred,不太确定原因。

    • f_cred , and its “which” and “where”:

       1/* Call Path 1 */
       2alloc_file(const struct path *path, int flags, const struct file_operations *fop)
       3    alloc_empty_file(flags, current_cred())
       4        __alloc_file(flags, cred)
       5            struct file *f;
       6            // ...
       7            f = kmem_cache_zalloc(filp_cachep, GFP_KERNEL);
       8            // ...
       9            f->f_cred = get_cred(cred);
      10
      11/* Call Path 2 */
      12(all sorts of interface)
      13    dentry_open(const struct path *path, int flags, const struct cred *cred)
      14        struct file *f;
      15        // ...
      16        f = alloc_empty_file(flags, cred);
      17            (same as above)
      

问题描述

根据 Ubuntu 工程师给上游社区的反馈 可以得知,从外部向一个运行中的容器内创建一个进程是一件比较麻烦的事情,涉及到如下的 IPC 通信过程:

 1/* 
 2 * IPC mechanism: (X is receiver)
 3 *   initial process        transient process   attached process
 4 *        X           <---  send pid of
 5 *                          attached proc,
 6 *                          then exit
 7 *    send 0 ------------------------------------>    X
 8 *                                              [do initialization]
 9 *        X  <------------------------------------  send 1
10 *   [add to cgroup, ...]
11 *    send 2 ------------------------------------>    X
12 *	                                            [set LXC_ATTACH_NO_NEW_PRIVS]
13 *        X  <------------------------------------  send 3
14 *   [open LSM label fd]
15 *    send 4 ------------------------------------>    X
16 *                                              [set LSM label]
17 *   close socket                                 close socket
18 *                                                run program
19 */

这里需要一个中间进程(“transient process”),它的作用仅仅是创建(真正附着在容器内运行的)附着进程(“attached process”),并将其 pid 发送给初始进程。这样做的其中一个主要原因在于 pid namespace 的创建方式:某进程用 setns()unshare() 创建新 pid ns 后,改变的是其随后创建的子进程的 pid ns,而并不改变其本身的 pid ns1。初始进程作为父进程,由于涉及 cgroup 和 user ns 的原因2不方便切换 pid ns,因此先创建一个子进程切换到容器的 pid ns,然后再创建孙进程。此时孙进程就正确地处于容器的 pid ns 中了。

随后邮件 提到,出问题的地方是在上图的最后一步:当 LSM profile 文件的 fd 被传递回附着进程后,附着进程对其进行写操作被拒绝,返回 EPERM

深入分析

结合 Launchpad 上提供的原始错误日志 ,可以确定出错场景为 LXC 容器,并可从中找到出问题的地方。

1attach lxc-attach-test DEBUG    start - start.c:__lxc_start:2038 - Unknown exit status for container "lxc-attach-test" init 9
2attach lxc-attach-test TRACE    network - network.c:lxc_restore_phys_nics_to_netns:3306 - Moving physical network devices back to parent network namespace
3attach lxc-attach-test INFO     error - error.c:lxc_error_set_and_log:33 - Child <54580> ended on signal (9)

向前回溯可以确定,上述传递文件描述符(fd)的过程在 attach.c 的 lxc_attach() 中;2. 此过程与 AppArmor 有关。

1attach lxc-attach-test TRACE    attach - attach.c:lxc_attach:1351 - Opened LSM label file descriptor 6
2attach lxc-attach-test TRACE    attach - attach.c:do_attach:732 - Received LSM label file descriptor 6 from parent
3attach lxc-attach-test NOTICE   utils - utils.c:lxc_setgroups:1472 - Dropped additional groups
4attach lxc-attach-test TRACE    apparmor - lsm/apparmor.c:apparmor_process_label_set_at:1151 - Changing AppArmor profile on exec not supported
5attach lxc-attach-test INFO     apparmor - lsm/apparmor.c:apparmor_process_label_set_at:1164 - Set AppArmor label to "lxc-container-default-cgns"
6attach lxc-attach-test TRACE    attach - attach.c:do_attach:787 - Set AppArmor LSM label to "lxc-container-default-cgns"
7attach lxc-attach-test TRACE    attach - attach.c:lxc_attach:1361 - Sent LSM label file descriptor 6 to child

LXC 源码仓 中找到 attach.c 文件lxc_attach() 函数很长,定位到以下代码:

 1/* Open LSM fd and send it to child. */
 2if (attach_lsm(options) && ctx->lsm_label) {
 3	__do_close int fd_lsm = -EBADF;
 4	bool on_exec;
 5
 6	on_exec = options->attach_flags & LXC_ATTACH_LSM_EXEC ? true : false;
 7	fd_lsm = ctx->lsm_ops->process_label_fd_get(ctx->lsm_ops, attached_pid, on_exec);
 8	if (fd_lsm < 0)
 9		goto close_mainloop;
10
11	TRACE("Opened LSM label file descriptor %d", fd_lsm);
12
13	/* Send child fd of the LSM security module to write to. */
14	if (!sync_wake_fd(ipc_sockets[0], fd_lsm)) {
15		SYSERROR("Failed to send lsm label fd");
16		goto close_mainloop;
17	}
18
19	TRACE("Sent LSM label file descriptor %d to child", fd_lsm);
20}

上面代码中的 process_label_fd_get() 回调 lsm/apparmor.c 中的 apparmor_process_label_fd_get()

 1static int __apparmor_process_label_open(struct lsm_ops *ops, pid_t pid, int o_flags, bool on_exec)
 2{
 3	// . . .
 4	/* first try the apparmor subdir */
 5	ret = snprintf(path, LXC_LSMATTRLEN, "/proc/%d/attr/apparmor/current", pid);
 6	// . . .
 7
 8	labelfd = open(path, o_flags);
 9	if (labelfd >= 0)
10		return labelfd;
11	else if (errno != ENOENT)
12		goto error;
13
14	/* fallback to legacy global attr directory */
15	ret = snprintf(path, LXC_LSMATTRLEN, "/proc/%d/attr/current", pid);
16	// . . .
17
18	labelfd = open(path, o_flags);
19	if (labelfd >= 0)
20		return labelfd;
21	// . . .
22}

可以确定传递的 fd 为 /proc/[pid]/attr/apparmor/current。根据代码上下文,可以确定其中的 [pid] 为附着进程 pid。

解决方案

第一个修补补丁 的逻辑很简单:将基于 cred 的比较改为基于 mm 的比较。注意:此处的比较是“浅比较”:仅比较指针的值,而不是(从语义的层次)比较结构体中的实质内容。

之所以要选择使用 mm_struct,相信也是与这个出错用例有关:在传递 LSM profile 时,附着进程刚被 fork() 出来而又尚未 execve(),此时附着进程的 mm 被拷贝后还未被重置。而 cred 则不然:PID 作为 cred 的一部分,其在 fork() 之后已经改变(参考 credentials(7) )。fork() 中的相关代码整理如下,可见在创建线程(CLONE_THREAD)时,只是用拷贝来的 p->cred 设置完 p->real_cred 并增加引用计数后就返回了;而在创建进程时,则会创建一份新的 cred 并随后进行替换。

 1/* kernel/fork.c */
 2SYSCALL_DEFINE0(fork)
 3  kernel_clone()
 4    copy_process()
 5
 6/* -------- relevant detail of copy_process() -------- */
 7copy_process(...)
 8	struct task_struct *p;
 9	// ...
10	retval = copy_creds(p, clone_flags);
11/* kernel/cred.c */
12		struct cred *new;
13		// ...
14		if (/*...*/ &&
15			clone_flags & CLONE_THREAD
16		    ) {
17			p->real_cred = get_cred(p->cred);
18			get_cred(p->cred);
19			// ...
20			return 0;
21		}
22		new = prepare_creds();
23			struct cred *new;
24			// ...
25			new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
26			// ...
27			return new;
28		// ...
29		p->cred = p->real_cred = get_cred(new);

第二个修补补丁 的 commit message 已经将要修复的问题以及解决方案描述清楚,在此不作其他补充。

拓展延伸

lxc-attach

根据 Ubuntu Server 对几个 LXC 命令的描述

You can now use lxc-ls to list containers, lxc-info to obtain detailed container information, lxc-start to start and lxc-stop to stop the container. lxc-attach and lxc-console allow you to enter a container, if ssh is not an option.

lxc-attach 的作用是“进入”容器,即在容器内创建一个进程并运行它。根据代码逻辑,这个所谓的“进入”实际上是以下过程:

 1/*
 2 *                   ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
 3 *                                         init of
 4 *                   │                    Container          │
 5 *                                          ┌───┐
 6 *                   │           attach     │   │            │
 7 *                           ┌─────────────►│ D │
 8 *                   │       │    (2)       │   │            │
 9 *                           │              └───┘
10 *                   │       │                               │
11 *                       Transient         Attached
12 * lxc-attach        │    Process          Process           │
13 *    ┌───┐                ┌───┐            ┌───┐
14 *    │   │    fork  │     │   │    fork    │   │  execve    │
15 *    │ A ├───────────────►│ B ├───────────►│ C ├──────────►
16 *    │   │    (1)   │     │   │    (3)     │   │   (5)      │
17 *    └───┘                └─┬─┘            └───┘
18 *                   │       │                               │
19 *                        (4)│die
20 *                   │       ▼                               │
21 *
22 *                   └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
23 *                           same namespaces, cgroups ...
24 */

lxc-attach v.s. lxc-start

lxc-start 是创建容器的 init 进程。该进程的存在(往往)承载了与容器相关的 namespace 和 cgroup 的存在。

lxc-attach 则是基于容器 init 进程的存在,先创建一个临时进程,通过各种方法让该进程进入容器的 namespace 和 cgroup,然后再创建真正执行用户命令的子进程。

LXC 中的这段 IPC 通信

阶段一

为什么要做上述的这段 IPC 通信呢?关于这个问题我找到了一段 16 年的讨论记录 ,以及其中附带的一份 LXC 的补丁合入版本 )。从这些资料中可知,之前是直接传递 /proc 的 fd 给附着进程:

 1int lxc_attach(const char* name, const char* lxcpath, lxc_attach_exec_t exec_function, void* exec_payload, lxc_attach_options_t* options, pid_t* attached_process)
 2{
 3	// . . .
 4	procfd = open("/proc", O_DIRECTORY | O_RDONLY);
 5	// . . .
 6
 7	/* now create the real child process */
 8	{
 9		struct attach_clone_payload payload = {
10			.ipc_socket = ipc_sockets[1],
11			.options = options,
12			.init_ctx = init_ctx,
13			.exec_function = exec_function,
14			.exec_payload = exec_payload,
15			.procfd = procfd
16		};
17		/* We use clone_parent here to make this subprocess a direct child of
18		 * the initial process. Then this intermediate process can exit and
19		 * the parent can directly track the attached process.
20		 */
21		pid = lxc_clone(attach_child_main, &payload, CLONE_PARENT);
22	}
23	// . . .
24}

附着进程通过 openat 打开其相应的 LSM profile 文件进行修改:

 1static int attach_child_main(void* data)
 2{
 3	// . . .
 4	if (lsm_set_label_at(procfd, on_exec, init_ctx->lsm_label) < 0) {
 5		// . . .
 6	}
 7	// . . .
 8}
 9
10int lsm_set_label_at(int procfd, int on_exec, char* lsm_label) {
11	// . . .
12	if (on_exec) {
13		labelfd = openat(procfd, "self/attr/exec", O_RDWR);
14	}
15	else {
16		labelfd = openat(procfd, "self/attr/current", O_RDWR);
17    }
18	// . . .
19}

后来发现有安全问题,因此才改成了只传递 /proc/[pid]/attr/apparmor/current 的 fd。

阶段二

可是这没有从根本上回答我们的问题:为什么需要用 IPC 传递 fd。上述的修改只是传了个不同的 fd。于是继续探究下去,竟然发现是 CVE-2015-1334问题现场 及其补丁:5c3fcae78b63 (“CVE-2015-1334: Don’t use the container’s /proc during attach”) )!原先的做法是:由于此时附着进程已经切换了 pid ns,其挂载的 proc 文件系统下的 /proc/[pid] 目录们会有相应的改变3。因此要让附着进程重新挂载 proc 文件系统,才能找到属于自己的 /proc/[pid]/attr/current 并设置 LSM profile。同样是由于安全漏洞的原因,才进行的修改。

后续

从代码的注释来看,其实 IPC 通信在更早的时候已经存在了,不过其目的与 cgroup 相关,本次就暂不深究了。

参考资料


  1. 详情请参考 pid_namespaces(7) 中的“setns(2) and unshare(2) semantics”一节。 ↩︎

  2. 与本问题关联不大,因此并未深究。 ↩︎

  3. 关于这部分的详情请参考 pid_namespaces(7) 中的“/proc and PID namespaces”一节。 ↩︎


Search Paths for Dynamic Linking & Loading
基于 BusyBox 快速制作内核验证环境