云计算时代容器安全综述-容器的支柱之隔离Isolation(上)

咱们在介绍容器安全模型的时候,介绍过安全边界的概念,安全边界也被称作是可信边界,本质上就是通过对整个系统划分为不同的安全区域,不同区域对安全的要求不同,因此会设置不同的访问权限要求。安全边界对于容器来说,主要体现在容器实例相互之间“隔离”上,要理解这种带引号的隔离,我们就需要对容器的工作原理有全面的了解。有了对原理的准确把握之后,我们才能深刻的理解围绕在容器实例(容易进程实例)周边的安全边界到底是什么。

读者如果亲自运行过命令:docker exec imageid bash(注意:不是所有的容器进项都有bash工具,比如alphine linux镜像默认就没有bash工具)来启动容器并登陆进去的经验,那么一定在登陆进去后,会马上得出容器实例从内部看,不就是一台虚拟机嘛的感慨。而得出这个结论的主要原因就是如果我们从容器内部运行ps命令,输出结果中只罗列了运行在容器实例中的进程。并且如果你好奇心足够强,从容器内部探索,你会惊讶的发现容器实例不光有自己的网络设备,还有根文件系统,并且这个文件系统和宿主机看起来没有任何关系。

另外通过上篇文章(控制组cgroups)的学习,我们还可以通过cgroup来控制容器使用的内存和CPU资源,因此不管从哪个角度看,容器实例就是一台活脱脱的安装了Linux操作系统的虚拟机啊。不过我们经常说,大部分事物本质都比看起来要复杂,而对于容器进程来说,虽说表面上看起来和虚拟机无二,但是从工作原理上看,两者有比较大的区别,这也是我们这篇文章的核心,希望大家读完这篇文章,能够下次站在客户面前,把容器和虚拟机的故事说清楚,最为重要的是,从安全的角度,容器这种看起来被隔离的方式,存在的安全风险。

笔者经常在不同的场合反复提的一句话:理解容器的核心有两个,第一个是深刻的理解容器不是虚拟机这个事实,第二个是基于这个事实能够说清楚容器和虚拟机到底有哪些异同,以及这种异同对安全造成影响。特别是第二点,我们在设计容器部署方案的时候,大部分针对虚拟机的安全手段依然有效(这个很容易理解,因为容器还是要运行在机器上啊,机器在云计算场景下就是虚拟机和物理机,咱统称为宿主机,针对宿主机的安全手段已经非常成熟,并且在容器化部署场景下依然有效),并且由于容器化部署增加了抽象层,因此额外增加的抽象需要我们设计额外的针对容器的安全方案。

数学读物里对三角形的描述通常冠以稳定性特征,咱日常生活中使用三角形的例子比比皆是,而对于容器来说,我们把namespace,cgroups和chroot这三个构成容器的基础也称作是三个支柱,这三个Linux操作系统提供的能力是容器机制的基石,理解容器的工作原理,本质上就是理解这三个操作系统机制,以及它们如何协作共同支持容器实例的运行,特别是隔离性。而隔离性是安全边界的基础,因此理解这三个支柱和他们之间的关系是理解我们如何保护容器应用的核心和本质。

Linux操作系统提供的这三个特性并不是专门为容器设计的,容器的概念其实就是虚拟化的概念,整个计算机的发展史,就是一部虚拟化技术的发展史,读者如果是计算机专业的同学,碰巧也学过计算机体系结构这门课的话,应该见过或者听过计算机分为多个层这样的概念。其实分层这种设计方法在计算机体系结构或者软件架构设计中运用的已经非常成熟了,比如我们很多时候解决异构系统,异构数据交互这类问题的时候,就是增加一层。构成容器的三个基石首次出现在Linux操作系统内核中的版本并不相同,因此大部分和Linux操作系统内核相关的书籍对这三个概念的介绍都很直接。但是如笔者所说,这三个相互独立的技术共同沟通了容器的基石,因此理解这三个如何协作是理解容器工作原理的本质,并且这部分也非常复杂,是很多容器安全漏洞的发源地。

中国有句古话,合久必分,分久必合,在系统设计和项目管理的领域,笔者最新对这句话有个新的感悟。事物都是向前发展的,无论是自然界的万物生长,还是人的能力和认知,其实都是在不断的发展。创新和物种的进化其实考的就是“分”,在细分领域有突破性的研究成果(分),然后带动整个行业逐步过渡(和)到下个稳定状态,借用汉书礼乐志中的名言,阴阳五行,周而复始。对于容器这种在操作系统上的抽象,也需要先从分开始来讨论,笔者认为命名空间是分,而cgroups和chroot是和,共同组成了容器,这种云计算时代企业级系统部署的事实标准。那么我就话不多说,先从Linux操作系统提供的命名空间(namespace)开始聊起吧。

基于上篇文章的讨论,如果我们说cgroups控制运行在Linux操作系统上的每个进程能够使用多少硬件资源,那么namespace就从控制着运行在操作系统的进程能够看到那些资源。具体的原理是通过将进程加入到不同的命名空间中,来限制进程的资源可见性。

操作系统上的namespace功能首次出现在名为Plan 9的操作系统上,那个年代的大多数操作系统并没有命名空间namespace的概念,也就是说整个操作系统上只有一个默认的命名空间。特别是在Unix操作系统上,虽说允许用户挂载文件系统到某个目录,但是文件系统挂载到Unix操作系统后,所有的文件都属于同一个默认的系统级别的“命名空间”(如果非要给安个名字的话)。

回到Plan 9操作系统提供的namespace功能上,具体来说Plan 9系统上的每个进程都隶属于一个进程组,而进程组控制了组内进程可见的文件和路径,因此这种进程组类型的抽象,和今天我们看到的namespace比较类似,并且每个进程组都可以单独的挂载文件系统,进程组之间对这些挂载的文件不可见,形成了隔离边界。

Linux操作系统首次引入命名空间机制是2002年发布的2.4.19内核版本,并且当时只支持mount命名空间,随着技术的发展和操作系统的成熟,以及业界对“分”提供的隔离性需求越来越多,当前最新版本的Linux内容提供了对以下7类明空空间的支持:

1,UTS(Unix分时系统)命名空间。坦白讲这个命名空间从名称的角度看起来很复杂,实际上UTS当前最主要的目的就是给进程提供独享的hostname和domain name而已

2,Process IDs命名空间,为进程提供独享的进程ID,也称作PID命名空间

3,Mount命名空间

4,Netowrk命名空间

5,User和Group IDs命名空间

6,IPC命名空间

7,cgroups命名空间

读者需要注意的是上边罗列的这7个命名空间是当前操作系统已经支持的,可以预见的是未来Linux内核会加入更多的命名空间,比如说time。这也意味着运行在同一台宿主机上的多个容器实例之间,共享了time,如果我们允许某个容器实例通过系统调用修改了宿主机的时间,那么所有运行在这台机器上的容器实例都会受到影响。从安全的角度看,这是非常大的潜在风险,因此大部分容器实例(进程)应该关闭修改系统时间的权限。

我们可以在操作系统上为每种类型创建多个命名空间,并且运行在Linux操作系统上的进程(也包括容器进程)只能加入某种类型的一个命名空间中。操作系统在启动的时候,会默认生成所有类型的命名空间,如下在笔者的机器上输出所示:

vagrant@vagrant:~$ lsns

NS TYPE NPROCS PID USER COMMAND

4026531835 cgroup 3 28459 vagrant /lib/systemd/systemd --user

4026531836 pid 3 28459 vagrant /lib/systemd/systemd --user

4026531837 user 3 28459 vagrant /lib/systemd/systemd --user

4026531838 uts 3 28459 vagrant /lib/systemd/systemd --user

4026531839 ipc 3 28459 vagrant /lib/systemd/systemd --user

4026531840 mnt 3 28459 vagrant /lib/systemd/systemd --user

4026531992 net 3 28459 vagrant /lib/systemd/systemd --user

从上边的输出可以看到,操作系统在启动的时候,会默认为每种我们前边提到的类型生成对应的命名空间,这看起来和我们的预期很符合。但是如果你仔细读一下lsns这个命令的man page,你会发现文档说lsns直接从/proc路径读取系统配置的命名空间信息,并且对于非root用户,返回的列表可能不完整,我们赶紧以root用户运行一下这个命令:sudo lsns,输出的结果会多很多,特别是在运行了很多容器实例的机器上。

注:如果你需要通过lsns来查看操作系统上的命名空间列表,请务必以root用户运行,因为只有通过root账户才能得到完整的命名空间列表。

了解了命名空间的基础知识后,接下来我们来通过实际的例子看看,如何通过命名空间来创建容器这样的隔离环境。我们先从UTS命名空间开始,咱前边介绍过UTS主要是解决容器进程的hostname和domain names的独立性。具体来说,将进程加入到独享的UTS命名空间,我们就可以独立于宿主机来给进程设置不同的hostname(主机名)和域名配置信息。

在自己的Linux系统上运行hostname会返回机器的主机名,对于容器进程来说,启动的时候会给设置一串随机的字符串作为主机名,比如我们通过docker run --rm -it --name hello ubuntu bash启动容器并bash进去后,运行hostname就会返回主机名,如下是在笔者的机器上运行输出的结果:

➜  vault docker run --rm -it --name hello ubuntu bash

root@595bbf596497:/# hostname

595bbf596497

大家需要注意的是,从上边的输出可以看到,即便是我们通过--name给容器设置了名称hello,但是hostname并没有采用--name传递的参数。由于容器进程运行在自己的UTS命名空间中,因此容器进程可以和宿主机有不同的主机名。我们可以通过使用unshare命令来在Linux操作系统上创建有自己独享UTS命名空间的进程。

Linux操作系统的文档上对unshare的介绍是:运行和parent进程不共享命名空间的应用程序进程。具体来说,当我们在Linux操作系统上运行应用程序的时候,内核会创建一个新的进程并将可执行程序在新进程中加载运行起来,这里的关键是新的进程是在parent(父进程)的上下文(context)中运行起来,其中就包括了命名空间,我们一般把新创建的进程称作是子进程(child)。

而unshare这个命令具体的功能是,让child(子进程)和parent(父进程)不共享命名空间,大白话说的意思就是,即便是从parent进程中启动了子进程,但是子进程不和父进程共享命名空间上下文信息,虽然默认情况下会继承,但是我们可以在启动进程后进行修改,从而不影响父进程的命名空间。

我们来实际在Linux操作系统上验证一下。我们首先通过unshare命令来启动一个sh进程,然后验证sh进程继承了父进程bash的hostname信息,然后通过hostname <新的hostname名称>来修改进程的hostname信息,验证sh进程和base进程可以持有不同的命名空间信息,在笔者的机器上操作输出如下:

vagrant@vagrant:~$ sudo unshare --uts sh

$ hostname

vagrant

$ hostname yunpan-host

$ hostname

yunpan-host

$ exit

vagrant@vagrant:~$ hostname

vagrant

读者需要注意的是在进程sh中运行hostname的时候,虽然输出的也是vagrant,但是其实这个值是从虚拟机vagrant集成过来的,本质上sh已经运行在新的UTS命名空间中,我们通过后边修改sh的hostname就能看到,vagrant虚拟机的bash和新启动的进程sh运行在不同的UTS命名空间中。

注:如果大家对上边的这个结果不是很放心,或者你属于那种眼见为实的人类高质量技术人员,可以在退出sh进程之前,在另外一个窗口上验证宿主机vagrant上hostname还是vagrant,没有被改变。

通过上边UTS命名空间的例子,笔者希望大家能够体会到,命名空间给运行在同一台机器上(相同的内核)的多个进程提供了相互之间的资源隔离,比如hostname。但是由于这些进程(无论是容器化应用进程还是普通的进程)底层是相同的Linux内核,因此从安全的角度,我们需要关注存在的安全风险,笔者会在后续专门的文章中详细介绍这种场景下存在的安全风险以及应对方案。

接着我们来讨论一下PID命名空间,如我们前边简单的介绍,PID命名空间给每个进程提供了独享的进程ID序列。咱们可以在自己的机器上运行命令:docker run --rm -it --name yunpan-test ubuntu bash来创建一个新的容器,并bash进去,在新的ubuntu机器中,运行ps -eaf,从输出的结果中,我们可以看到只有这台ubuntu机器中运行的进程,bash和ps进程。在笔者的机器上输出如下:

➜  vault docker run --rm -it --name yunpan-test ubuntu bash

root@8127d08e32fc:/# ps -eaf

UID        PID  PPID  C STIME TTY          TIME CMD

root        1    0  0 04:56 pts/0    00:00:00 bash

root        13    1  0 04:56 pts/0    00:00:00 ps -eaf

从ps -eaf命令的输出可以看到,docker容器进程已经运行在新的PID命名空间中了,因为无论是bash还是ps进程的ID都很小,如果这些进程在宿主机上,那么可以肯定的是一定比这些数字大。验证了Docker启动新的容器进程会新建PID命名空间后,接下来我们看看这个机制背后的工作原理。

我们的目标是通过unshare来模拟新建pid命名空间这样的背后机制,在linux机器上运行sudo unshare --pid sh命令,然后在sh窗口中运行whoami,输出正常结果后再次运行whoami,whoami,ls进程,你会发现报错,输出结果如下:

vagrant@vagrant:~$ sudo unshare --pid sh

$ whoami

root

$ whoami

sh: 2: Cannot fork

$ whoami

sh: 3: Cannot fork

$ ls

sh: 4: Cannot fork

读者如果在自己的机器上尝试过上边的操作,肯定会很疑惑的问,到底发生了什么?为啥我们能够成功运行第一个命令whoami,但是后续的命令报fork的错误?这个就很有意思了,因为这个错误本质上揭示了容器进程运行背后的一些原理。我们先从输出的错误消息入手,大家可以看到后续出错的命令输出的进程id在自增并且是从1开始(不难猜测1号进程一定是第一个运行成功的whoami)。

虽然不是很成功,但是从进程的ID从1开始自增,我们起码已经验证了上边通过unshare --pid sh启动的sh进程运行在自己的PID命名空间中。但是只能运行成功第一个whoami进程总归没有啥用,咱们接下来分析这个问题。

从错误信息看命令报cannot fork的错误,那么我们来查看一下unshare命令的fork参数具体是啥意思。Linux操作系统unshare命令的man page上说,指定fork选项后,child(子进程,也就是这里sh进程)进程会作为unshare进程子进程运维,而不是直接从当前进程运行。这句话光从文字上看不是并不是很清晰,咱们来另外一个窗口运行命令ps -fa来验证一下,在笔者的机器上输出如下:

vagrant@vagrant:~$ ps fa

PID TTY STAT TIME COMMAND

...

30345 pts/0 Ss 0:00 -bash

30475 pts/0 S 0:00 \_ sudo unshare --pid sh

30476 pts/0 S 0:00    \_ sh

可以看到sh进程并不是unshare进程的子进程,而是sudo进程的子进程,那么我们来结束刚才的进程,并通过命令sudo unshare --pid --fork sh来重新启动一个sh进程,这次我们在sh进程中就可以多个命令了。

注:读者可能会非常疑惑的看着上边的文字,绞尽脑汁的想为啥呢?这个地方最主要的区别在于unshare,笔者会在后续的文章中详细介绍原理,大家先记住就行。

那么我们的sh运行在了新的PID命名空间后,是不是就只能看到运行在sh进程中的部分进程呢?赶紧在刚才的窗口上运行一下ps或者ps -eaf,你会发现结果有超出了你的预期。这两个命令输出的进程列表显然是宿主机的,并没有隔离啊,为啥呢?

我们还是继续翻开ps命令的man page,手册文档上写的很清楚,ps命令在运行的时候,会读取操作系统的虚拟文件夹/proc,我们可以在宿主机操作系统上运行ls /pro命令,返回的结果和ps命令返回的结果一致。并且我们从ls /proc返回的结果中可以看到很多以数字命名的文件夹。这些文件夹中包含了进程运行的很多重要的信息,比如/proc//exe是个连接文件,指向具体的可执行文件,如下所示:

vagrant@vagrant:~$ ls -l /proc/28441/exe

lrwxrwxrwx 1 vagrant vagrant 0 Oct 10 13:32 /proc/28441/exe -> /bin/bash

虽然说我们已经解决了PID命名空间的问题(本质上运行在新的PID命名空间中的进程有自己的进程编号序列),但是由于ps总是读取/proc下的虚拟文件,因此我们还需要继续探索来让ps只返回在容器进程中启动的进程清单。而这应该不难理解,我们需要在进程启动的时候,拷贝一份/proc目录,这样新的进程就基于这个文件夹来返回ps的结果。

由于proc目录属于操作系统的根目录,因此为了让ps只返回自己启动的进程的清单,我们需要改变新启动的容器进程的root目录,也叫change the root directory。如果读者有过docker的使用经验,应该知道我们可以将宿主机的某个文件夹挂载到容器中,这样容器就可以访问这个挂载文件夹中的以及子目录的所有数据文件。或者从容器的角度来看,只能看到宿主机文件系统的一部分,而不是全部。

在Linux操作系统上,我们可以通过chroot来改变进程的root目录,本质上这个命令就是将执行chroot命令的当前进程的root directory移动到制定的位置。当chroot命令执行完成后,我们就只能看到制定目录“中”以及“下”的数据文件,如下图所示:


《图1.1 chroot根本性的改变了进程对文件系统的入口点视图》

咱们还是来验证一下,在自己的机器上创建一个新的文件夹yunpan_root,然后使用命令sudo chroot yunpan_root,或者运行 yunpan_root ls,输出的结果如下:

vagrant@vagrant:~$ mkdir yunpan_root

vagrant@vagrant:~$ sudo chroot new_root

chroot: failed to run command ‘/bin/bash’: No such file or directory

vagrant@vagrant:~$ sudo chroot new_root ls

chroot: failed to run command ‘ls’: No such file or directory

从上边的输出来看,貌似不是很成功,为啥会报错呢?通过返回的错误信息看是找不到具体的执行文件,这和我们在系统上安装了某些工具,然后为更新bash的环境变量,或者为source当前窗口返回的错误完全一样啊,你应该可以猜测到由于我们改变了进程的root位置,因此在新的文件夹中并没有这些对应可执行文件,从而造成了这错误。

因为当我们change root之后,我们需要保证这些命令啊,配置文件啊都存在并且能够被访问到,这就是为什么我们要只做容器镜像的原因。我们说容器的镜像打包了不光是应用程序的代码,也打包了操作系统运行的环境,当容器被启动的之后,这些多层的文件会被联合挂载到相同的路径,不难猜测就是"/“。

然后作为整个容器进程的root目录,由于大部分应用都是基于某个linux操作系统的文件夹结构来制作镜像文件,因此当容器启动后,即便是被change root后,然后接受sh,ls等命令。接下来我们来下载一个轻量级的Linux系统,然后通过chroot来验证一下是否如我们上边的内容所说。

在自己的机器上下载alphine操作系统并解压缩,如下的操作过程所示:

vagrant@vagrant:~$ mkdir alpine

vagrant@vagrant:~$ cd alpine

vagrant@vagrant:~/alpine$ curl -o alpine.tar.gz http://dl-cdn.alpinelinux.org/alpine/v3.10/releases/x86_64/alpine-minirootfs-3.10.0-x86_64.tar.gz

vagrant@vagrant:~/alpine$ tar xvf alpine.tar.gz

如果我们通过ls来罗列这个文件夹中的内容,大家一定会觉得非常熟悉,因为这就是一个典型的Linux操作系统根目录文件夹结构。接着我们来验证最激动人心的一个步骤了,chroot到这个叫alphine的文件夹,并且执行ls命令,输出如下:

vagrant@vagrant:~$ sudo chroot alpine ls

bin etc lib mnt proc run srv tmp var dev home media opt root sbin sys usr

废了一番功夫之后,我们终于可以成功的chroot了,但是这里有个细节需要大家注意。每个进程都会有自己的环境变量,而当我们通过上边的命令运行ls的时候,环境变量中的PATH信息也会被继承到新的进程环境中,由于bin目录在旧环境和新环境的位置相同,因此我们可以直接运行ls。如果我们尝试运行sudo chroot alpine bash,你会发现报错,因为alpine版本的bin目录中并没有bash可执行文件。

注:运行sudo chroot后只有子进程(也就是运行ls命令的进程)的root目录发生了变化,而我们登陆操作系统的bash进程的root目录并未发生变化。当这个子进程技术后,控制会恢复到bash进程。

注:读者感兴趣可以下载一个ubuntu操作系统的根目录文件夹,chroot进去试试,ubuntu版本的操作系统包含了bash命令。

总结一下就是chroot就是字面意思,改变进程的根文件系统的目录结构,并且当进程修改了根目录后,就只能看到新的根目录中的文件以及文件夹中的数据。

注:了解Linux操作系统的同学应该还知道有个叫pivot_root的系统调用,chroot和pivot_root只是实现的具体细节不一致,产生的效果完全相同。但是从安全的视角,pivot_root会更优一些,主要原因是通过pivot_root对进程的root目录修改后,老的目录会自动无法被访问,安全性会高很多。

深入了解了namespace和chroot的原理之后,我们接下来将两者结合起来,你会看到Docker具体是如何在给每个新启动进程设置新的PID命名空间,并且还能让ps返回进程内启动的子进程清单。

咱们在自己的机器上先启动一个新的sh进程,运行命令:sudo unshare --pid --fork chroot alpine sh,然后为了让ps能够读取到sh进程专属的/proc信息,我们还需要挂载一个类型为proc的pseudo文件系统。通过命令mount -t proc proc proc挂载了proc类型的文件系统后,我们就可以让ps返回期望的结果了,在笔者的机器上输出如下:

vagrant@vagrant:~$ sudo unshare --pid --fork chroot alpine sh

/ $ ls

bin etc lib mnt proc run srv tmp var dev home media opt root sbin sys usr

/ $ mount -t proc proc proc

/ $ ps

PID USER TIME COMMAND

1 root 0:00 sh

6 root 0:00 ps

不容易啊,通过输出的进程ID可以看到,我们在进程中终于按照期望的方式返回了进程清单。好了,咱们今天这篇文章的内容就这么多了,咱们下篇文章继续讨论几个关键的命名空间:mount,network,cgroup以及user,敬请期待!

你可能感兴趣的:(云计算时代容器安全综述-容器的支柱之隔离Isolation(上))