1. 容器安全
对于使用容器的用户,在运行容器的时候,在安全方面可以从这两个方面来考虑:
- 第一是赋予容器合理的 capabilities;
- 第二是在容器中以非 root 用户来运行程序。
1.1. 容器中的 privileged 权限设置
用缺省 docker run的方式启动容器后,在容器里很多操作都是不允许的,即使是在容器中以 root 用户来运行程序也不行。比如下面这个例子中,我们想在容器中运行 iptables 这个命令,来查看一下防火墙的规则,但是执行命令之后,你会发现结果输出中给出了"Permission denied (you must be root)"的错误提示,这个提示要求我们用 root 用户来运行。但是,在容器中,我们可以看到已经是以 root 用户来运行了。而这个的主要原因是,出于安全方面的考虑,容器缺省启动的时候,哪怕是容器中 root 用户的进程,系统也只允许了 15 个 capabilities,可以查看 runC spec 文档中的 security 部分(https://github.com/opencontainers/runc/blob/v1.0.0-rc92/libcontainer/SPEC.md#security)。
# docker run --name iptables -it registry/iptables:v1 bash
[root@0b88d6486149 /]# iptables -L
iptables v1.8.4 (nf_tables): Could not fetch rule set generation id: Permission denied (you must be root)
[root@0b88d6486149 /]# id
uid=0(root) gid=0(root) groups=0(root)
也可以进入到容器中,查看 init 进程 status 里的 cap 参数,查看容器中缺省的 capabilities
# docker run --name iptables -it registry/iptables:v1 bash
[root@e54694652a42 /]# cat /proc/1/status |grep Cap
CapInh: 00000000a80425fb
CapPrm: 00000000a80425fb
CapEff: 00000000a80425fb
CapBnd: 00000000a80425fb
CapAmb: 0000000000000000
针对,这种情况就是给容器加权限,有两种方式:
-
启动容器有一个"privileged"的参数,那么使用这个参数之后,在容器中就可以正常使用 iptables 了。
# docker stop iptables;docker rm iptables iptables iptables # docker run --name iptables --privileged -it registry/iptables:v1 bash [root@44168f4b9b24 /]# iptables -L Chain INPUT (policy ACCEPT) target prot opt source destination Chain FORWARD (policy ACCEPT) target prot opt source destination Chain OUTPUT (policy ACCEPT) target prot opt source destination
但是,这种方式并不推荐,因为这种方式是给容器中的进程赋予了所有的权限。而容器中的权限越高,对系统安全的威胁显然也是越大的。比如说,如果容器中的进程有了 CAP_SYS_ADMIN 的特权之后,那么这些进程就可以在容器里直接访问磁盘设备,直接可以读取或者修改宿主机上的所有文件了。
从 Docker 的代码(https://github.com/moby/moby/blob/17.03.x/daemon/exec_linux.go#L25)里,我们可以看到,如果配置了 privileged 的参数的话,就会获取所有的 capabilities。
if ec.Privileged { p.Capabilities = caps.GetAllCapabilities() }
-
所以,在容器平台上是基本不允许把容器直接设置为"privileged"的,我们需要根据容器中进程需要的最少特权来赋予 capabilities。比如,容器里需要使用 iptables。那么使用 iptables 命令,需要设置 CAP_NET_ADMIN 权限,因此我们只需要设置这个 capability 就行。
我们只要在运行 Docker 的时候,给这个容器再多加一个 NET_ADMIN 参数就可以了。
# docker run --name iptables --cap-add NET_ADMIN -it registry/iptables:v1 bash [root@cfedf124dcf1 /]# iptables -L Chain INPUT (policy ACCEPT) target prot opt source destination Chain FORWARD (policy ACCEPT) target prot opt source destination Chain OUTPUT (policy ACCEPT) target prot opt source destination
需要注意的是,目前 Kubernetes 还不支持 User Namespace,可以了解这个 pr(https://github.com/kubernetes/enhancements/pull/2101)
1.2. 容器中的用户与安全
在没有 User Namespace 的情况下,容器中用 root 用户运行。比如,用下面的命令启动一个容器,在这里,我们把宿主机上 /etc 目录以 volume 的形式挂载到了容器中的 /mnt 目录下面。然后,我们可以看一下容器中的进程"sleep 3600",它在容器中和宿主机上的用户都是 root,也就是说,容器中用户的 uid/gid 和宿主机上的完全一样。
# docker run -d --name root_example -v /etc:/mnt centos sleep 360
# docker exec -it root_example bash -c "ps -ef | grep sleep"
root 1 0 0 01:14 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 3600
# ps -ef | grep sleep
root 5473 5443 0 18:14 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 3600
这个时候,容器里 root 用户的 capabilities 虽然被限制了一些,但是在容器中,一旦有软件的漏洞,容器中的 root 用户就可以操控整个宿主机。比如,对于被挂载上来的 /etc 目录下的文件,比如说 shadow 文件,以这个 root 用户的权限还是可以做修改的。也就是说容器中的 root 用户也有权限修改宿主机上的关键文件。
# docker exec -it root_example bash[root@9c7b76232c19 /]# ls /mnt/shadow -l---------- 1 root root 586 Nov 26 13:47 /mnt/shadow[root@9c7b76232c19 /]# echo "hello" >> /mnt/shadow
虽然,在云平台上,比如说在 Kubernetes 里,我们是可以限制容器去挂载宿主机的目录的。但是,由于容器和宿主机是共享 Linux 内核的,一旦软件有漏洞,那么容器中以 root 用户运行的进程还是会有机会去修改宿主机上的文件了。比如 2019 年发现的一个 RunC 的漏洞 CVE-2019-5736(https://nvd.nist.gov/vuln/detail/CVE-2019-5736), 这导致容器中 root 用户有机会修改宿主机上的 RunC 程序,并且容器中的 root 用户还会得到宿主机上的运行权限。
针对以上这种问题,有以下三种解决方法:
-
Run as non-root user(给容器指定一个普通用户)
就是给容器指定一个普通用户 uid,比如可以在 docker 启动容器的时候加上"-u"参数,在参数中指定 uid/gid。具体的操作代码如下:
# docker run -ti --name root_example -u 6667:6667 -v /etc:/mnt centos bash bash-4.4$ id uid=6667 gid=6667 groups=6667 bash-4.4$ ps -ef UID PID PPID C STIME TTY TIME CMD 6667 1 0 1 01:27 pts/0 00:00:00 bash 6667 8 1 0 01:27 pts/0 00:00:00 ps -ef
另外一个办法,是在构建容器镜像的时候,在 Dockerfile 中为容器镜像里建立一个用户。比如,下面这个例子中。nonroot,它是一个用户名,我们用 USER 关键字来指定这个 nonroot 用户。那么,使用这个容器镜像启动的容器里缺省的进程都会以这个用户启动。这个时候就可以不用 -u 参数来指定用户了。
# cat Dockerfile FROM centos RUN adduser -u 6667 nonroot USER nonroot # docker build -t registry/nonroot:v1 . … # docker run -d --name root_example -v /etc:/mnt registry/nonroot:v1 sleep 3600 050809a716ab0a9481a6dfe711b332f74800eff5fea8b4c483fa370b62b4b9b3 # docker exec -it root_example bash [nonroot@050809a716ab /]$ id uid=6667(nonroot) gid=6667(nonroot) groups=6667(nonroot) [nonroot@050809a716ab /]$ ps -ef UID PID PPID C STIME TTY TIME CMD nonroot 1 0 0 01:43 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 3600
这个时候,再尝试修改挂载上来的 /etc 目录下的文件,发现是不能修改的。
[nonroot@050809a716ab /]$ echo "hello" >> /mnt/shadow bash: /mnt/shadow: Permission denied
但是,这种方法存在的缺陷是,容易产生冲突。因为容器中定义的 uid,其实也就是宿主机上的 uid。那么,假如多个客户在建立自己的容器镜像的时候都选择了同一个 uid 6667。那么当多个客户的容器在同一个节点上运行的时候,其实就都使用了宿主机上 uid 6667。而在一台 Linux 系统上,每个用户下的资源是有限制的,比如打开文件数目(open files)、最大进程数目(max user processes)等等。一旦有很多个容器共享一个 uid,这些容器就很可能很快消耗掉这个 uid 下的资源,这样很容易导致这些容器都不能再正常工作。
为了避免上述的冲突,容器云平台需要管理 uid,但是对 uid 的管理又会比较麻烦。
-
User Namespace(用户隔离技术的支持)
下面,我们使用 podman 启动一个容器,并进入到这个容器尝试下能否修改宿主机上的文件。
-
首先使用 podman(https://podman.io/) 启动一个容器。"--uidmap 0:2000:1000",这个是标准的 User Namespace 中 uid 的映射格式:"ns_uid:host_uid:amount"。第一个 0 是指在新的 Namespace 里 uid 从 0 开始,中间的那个 2000 指的是 Host Namespace 里被映射的 uid 从 2000 开始,最后一个 1000 是指总共需要连续映射 1000 个 uid。所以,也就是说这个容器里的 uid 0 是被映射到宿主机上的 uid 2000 的。
# podman run -ti -v /etc:/mnt --uidmap 0:2000:1000 centos bash
跟 Docker 相比,podman 不再有守护进程 dockerd,而是直接通过 fork/execve 的方式来启动一个新的容器。这种方式启动容器更加简单,也更容易维护。Podman 的命令参数兼容了绝大部分的 docker 命令行参数,用过 Docker 的同学也很容易上手 podman。
Podman 安装指南:https://podman.io/getting-started/installation
我们可以先在容器中以用户 uid 0 运行一下 sleep 这个命令,之后到宿主机上查看一下这个进程的 uid。这里我们可以看到,进程 uid 的确是 2000 了。
# id uid=0(root) gid=0(root) groups=0(root) # sleep 3600 # ps -ef |grep sleep 2000 27021 26957 0 01:32 pts/0 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 3600
-
之后在容器中,我们对容器中被挂载上来的 /etc 目录下的文件做操作,这时可以看到操作是不被允许的。
# echo "hello" >> /mnt/shadow bash: /mnt/shadow: Permission denied # id uid=0(root) gid=0(root) groups=0(root)
-
-
rootless container(以非 root 用户启动和管理容器)
在容器中以非 root 用户运行进程可以降低容器的安全风险。那么除了在容器中直接使用非 root 用户之外,社区还有一个 rootless container 的概念。rootless container 是指启动容器的时候,Docker 或者 podman 是以非 root 用户来执行的,也就是说以非 root 用户来创建、管理容器。那么,容器中也是以非 root 用户来运行进程了。
这样一来,就能进一步提升容器中的安全性,我们不用再担心因为 containerd 或者 RunC 里的代码漏洞,导致容器获得宿主机上的权限。
参考 redhat blog 里的这篇文档(https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics), 在宿主机上用 redhat 这个用户通过 podman 来启动一个容器。在这个容器中也使用了 User Namespace,并且把容器中的 uid 0 映射为宿主机上的 redhat 用户了。
$ id uid=1001(redhat) gid=1001(redhat) groups=1001(redhat) $ podman run -it ubi7/ubi bash ### 在宿主机上以redhat用户启动容器 [root@206f6d5cb033 /]# id ### 容器中的用户是root uid=0(root) gid=0(root) groups=0(root) [root@206f6d5cb033 /]# sleep 3600 ### 在容器中启动一个sleep 进程 # ps -ef |grep sleep ###在宿主机上查看容器sleep进程对应的用户 redhat 29433 29410 0 05:14 pts/0 00:00:00 sleep 3600
目前 Docker 和 podman 都支持了 rootless container,Kubernetes 对rootless container 支持的工作也在进行中(https://github.com/kubernetes/enhancements/issues/2033)。
巨人的肩膀
- 极客时间.《容器实战》