遗留问题:把 struct mm
放到 file->private_date
中,是否有可能导致泄漏?
情况简述
XXX(xxxxxxxx)2021-07-29 14:32
@XX 有修改引入引起这边容器AT大面积失败
-
问题引入补丁:bfb819ea20ce (“proc: Check /proc/$pid/attr/ writes against file opener”)
-
修补补丁:
- 591a22c14d3f (“proc: Track /proc/$pid/attr/ opener mm_struct”)
,在修复原问题的基础上引入了新问题。
- 问题现场:Launchpad ,Ubuntu
- Ubuntu 工程师向上游反馈:“Regression when writing to
/proc/<pid>/attr/
” - 合入时的社区讨论:邮件序列
- 94f0b2d4a1d0 (“proc: only require mm_struct for writing”)
,修复上一个修补补丁引入的后续问题。
- 问题反馈 + 社区讨论:邮件序列
- 591a22c14d3f (“proc: Track /proc/$pid/attr/ opener mm_struct”)
,在修复原问题的基础上引入了新问题。
背景探究
-
问题补丁 要解决的潜在安全问题被称为 Confused Deputy 。
-
/proc/$pid/attr/
主要为安全模块所用,用于获取或设置各进程的安全相关的属性。 -
补丁代码涉及
file->f_cred
和current_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 andlxc-stop
to stop the container.lxc-attach
andlxc-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 相关,本次就暂不深究了。
参考资料
- LXC:
- LXC > Getting started (unprivileged v.s. privileged containers)
- Github repo of LXC
- Containers-LXC , Ubuntu
- AppArmor Interfaces , AppArmor’s Wiki
- proc(5)
(关于
/proc/[pid]/attr
) - clone(2)
(关于
CLONE_PARENT
) - pid_namespaces(7)
- Difference between Real User ID, Effective User ID and Saved User ID
- lxc-attach(1)
-
详情请参考 pid_namespaces(7) 中的“setns(2) and unshare(2) semantics”一节。 ↩︎
-
与本问题关联不大,因此并未深究。 ↩︎
-
关于这部分的详情请参考 pid_namespaces(7) 中的“/proc and PID namespaces”一节。 ↩︎