gitbook/容器实战高手课/docs/327107.md

247 lines
14 KiB
Markdown
Raw Normal View History

2022-09-03 22:05:03 +08:00
# 20 | 容器安全2在容器中我不以root用户来运行程序可以吗
你好,我是程远。
在[上一讲](https://time.geekbang.org/column/article/326253)里我们学习了Linux capabilities的概念也知道了对于非privileged的容器容器中root用户的capabilities是有限制的因此容器中的root用户无法像宿主机上的root用户一样拿到完全掌控系统的特权。
那么是不是让非privileged的容器以root用户来运行程序这样就能保证安全了呢这一讲我们就来聊一聊容器中的root用户与安全相关的问题。
## 问题再现
说到容器中的用户user你可能会想到在Linux Namespace中有一项隔离技术也就是User Namespace。
不过在容器云平台Kubernetes上目前还不支持User Namespace所以我们先来看看在没有User Namespace的情况下容器中用root用户运行会发生什么情况。
首先,我们可以用下面的命令启动一个容器,在这里,我们把宿主机上/etc目录以volume的形式挂载到了容器中的/mnt目录下面。
```shell
# docker run -d --name root_example -v /etc:/mnt centos sleep 3600
```
然后,我们可以看一下容器中的进程"sleep 3600"它在容器中和宿主机上的用户都是root也就是说容器中用户的uid/gid和宿主机上的完全一样。
```shell
# 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被限制了一些但是在容器中对于被挂载上来的/etc目录下的文件比如说shadow文件以这个root用户的权限还是可以做修改的。
```shell
# 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
```
接着我们看看后面这段命令输出,可以确认在宿主机上文件被修改了。
```shell
# tail -n 3 /etc/shadow
grafana:!!:18437::::::
tcpdump:!!:18592::::::
hello
```
这个例子说明容器中的root用户也有权限修改宿主机上的关键文件。
当然在云平台上比如说在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给容器指定一个普通用户
我们如果不想让容器以root用户运行最直接的办法就是给容器指定一个普通用户uid。这个方法很简单比如可以在docker启动容器的时候加上"-u"参数在参数中指定uid/gid。
具体的操作代码如下:
```shell
# 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用户这样操作以后容器里缺省的进程都会以这个用户启动。
这样在运行Docker命令的时候就不用加"-u"参数来指定用户了。
```shell
# 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目录下的文件? 显然,现在不可以修改了。
```shell
[nonroot@050809a716ab /]$ echo "hello" >> /mnt/shadow
bash: /mnt/shadow: Permission denied
```
那么是不是只要给容器中指定了一个普通用户,这个问题就圆满解决了呢?其实在云平台上,这么做还是会带来别的问题,我们一起来看看。
由于用户uid是整个节点中共享的那么在容器中定义的uid也就是宿主机上的uid这样就很容易引起uid的冲突。
比如说多个客户在建立自己的容器镜像的时候都选择了同一个uid 6667。那么当多个客户的容器在同一个节点上运行的时候其实就都使用了宿主机上uid 6667。
我们都知道在一台Linux系统上每个用户下的资源是有限制的比如打开文件数目open files、最大进程数目max user processes等等。一旦有很多个容器共享一个uid这些容器就很可能很快消耗掉这个uid下的资源这样很容易导致这些容器都不能再正常工作。
要解决这个问题必须要有一个云平台级别的uid管理和分配但选择这个方法也要付出代价。因为这样做是可以解决问题但是用户在定义自己容器中的uid的时候他们就需要有额外的操作而且平台也需要新开发对uid平台级别的管理模块完成这些事情需要的工作量也不少。
### 方法二User Namespace用户隔离技术的支持
那么在没有使用User Namespace的情况对于容器平台上的用户管理还是存在问题。你可能会想到我们是不是应该去尝试一下User Namespace?
好的我们就一起来看看使用User Namespace对解决用户管理问题有没有帮助。首先我们简单了解一下[User Namespace](https://man7.org/linux/man-pages/man7/user_namespaces.7.html)的概念。
User Namespace隔离了一台Linux节点上的User IDuid和Group IDgid它给Namespace中的uid/gid的值与宿主机上的uid/gid值建立了一个映射关系。经过User Namespace的隔离我们在Namespace中看到的进程的uid/gid就和宿主机Namespace中看到的uid和gid不一样了。
你可以看下面的这张示意图应该就能很快知道User Namespace大概是什么意思了。比如namespace\_1里的uid值是0到999但其实它在宿主机上对应的uid值是1000到1999。
还有一点你要注意的是User Namespace是可以嵌套的比如下面图里的namespace\_2里可以再建立一个namespace\_3这个嵌套的特性是其他Namespace没有的。
![](https://static001.geekbang.org/resource/image/64/9c/647a11a38498128e0b00a48931e2f09c.jpg)
我们可以启动一个带User Namespace的容器来感受一下。这次启动容器我们用一下[podman](https://podman.io/)这个工具而不是Docker。
跟Docker相比podman不再有守护进程dockerd而是直接通过fork/execve的方式来启动一个新的容器。这种方式启动容器更加简单也更容易维护。
Podman的命令参数兼容了绝大部分的docker命令行参数用过Docker的同学也很容易上手podman。你感兴趣的话可以跟着这个[手册](https://podman.io/getting-started/installation)在你自己的Linux系统上装一下podman。
那接下来,我们就用下面的命令来启动一个容器:
```shell
# podman run -ti -v /etc:/mnt --uidmap 0:2000:1000 centos bash
```
我们可以看到其他参数和前面的Docker命令是一样的。
这里我们在命令里增加一个参数,"--uidmap 0:2000:1000"这个是标准的User Namespace中uid的映射格式"ns\_uid:host\_uid:amount"。
那这个例子里的"0:2000:1000"是什么意思呢?我给你解释一下。
第一个0是指在新的Namespace里uid从0开始中间的那个2000指的是Host Namespace里被映射的uid从2000开始最后一个1000是指总共需要连续映射1000个uid。
所以,我们可以得出,**这个容器里的uid 0是被映射到宿主机上的uid 2000的。**这一点我们可以验证一下。
首先我们先在容器中以用户uid 0运行一下 `sleep` 这个命令:
```shell
# id
uid=0(root) gid=0(root) groups=0(root)
# sleep 3600
```
然后就是第二步到宿主机上查看一下这个进程的uid。这里我们可以看到进程uid的确是2000了。
```shell
# 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
```
第三步我们可以再回到容器中仍然以容器中的root对被挂载上来的/etc目录下的文件做操作这时可以看到操作是不被允许的。
```shell
# echo "hello" >> /mnt/shadow
bash: /mnt/shadow: Permission denied
# id
uid=0(root) gid=0(root) groups=0(root)
```
好了通过这些操作以及和前面User Namespace的概念的解释我们可以总结出容器使用User Namespace有两个好处。
**第一它把容器中root用户uid 0映射成宿主机上的普通用户。**
作为容器中的root它还是可以有一些Linux capabilities那么在容器中还是可以执行一些特权的操作。而在宿主机上uid是普通用户那么即使这个用户逃逸出容器Namespace它的执行权限还是有限的。
**第二对于用户在容器中自己定义普通用户uid的情况我们只要为每个容器在节点上分配一个uid范围就不会出现在宿主机上uid冲突的问题了。**
因为在这个时候我们只要在节点上分配容器的uid范围就可以了所以从实现上说相比在整个平台层面给容器分配uid使用User Namespace这个办法要方便得多。
这里我额外补充一下前面我们说了Kubernetes目前还不支持User Namespace如果你想了解相关工作的进展可以看一下社区的这个[PR](https://github.com/kubernetes/enhancements/pull/2101)。
### 方法三rootless container以非root用户启动和管理容器
前面我们已经讨论了在容器中以非root用户运行进程可以降低容器的安全风险。除了在容器中使用非root用户社区还有一个rootless container的概念。
这里rootless container中的"rootless"不仅仅指容器中以非root用户来运行进程还指以非root用户来创建容器管理容器。也就是说启动容器的时候Docker或者podman是以非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用户了。
```shell
$ 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 进程
```
```shell
# ps -ef |grep sleep ###在宿主机上查看容器sleep进程对应的用户
redhat 29433 29410 0 05:14 pts/0 00:00:00 sleep 3600
```
目前Docker和podman都支持了rootless containerKubernetes对[rootless container支持](https://github.com/kubernetes/enhancements/issues/2033)的工作也在进行中。
## 重点小结
我们今天讨论的内容是root用户与容器安全的问题。
尽管容器中root用户的Linux capabilities已经减少了很多但是在没有User Namespace的情况下容器中root用户和宿主机上的root用户的uid是完全相同的一旦有软件的漏洞容器中的root用户就可以操控整个宿主机。
**为了减少安全风险业界都是建议在容器中以非root用户来运行进程。**不过在没有User Namespace的情况下在容器中使用非root用户对于容器云平台来说对uid的管理会比较麻烦。
所以我们还是要分析一下User Namespace它带来的好处有两个。一个是把容器中root用户uid 0映射成宿主机上的普通用户另外一个好处是在云平台里对于容器uid的分配要容易些。
除了在容器中以非root用户来运行进程外Docker和podman都支持了rootless container也就是说它们都可以以非root用户来启动和管理容器这样就进一步降低了容器的安全风险。
## 思考题
我在这一讲里提到了rootless container不过对于rootless container的支持还存在着不少的难点比如容器网络的配置、Cgroup的配置你可以去查阅一些资料看看podman是怎么解决这些问题的。
欢迎你在留言区提出你的思考和疑问。如果这一讲对你有帮助,也欢迎转发给你的同事、朋友,一起交流学习。