笔者在前边系列文章中详细的阐述过在Docker上创建容器实例的过程,当我们在命令行运行docker run的时候,背后的原理是客户端将创建容器实例的命令发送给docker deamon,deamon负责执行必要的系统调用(比如创建新的命名空间等)来具体的创建容器进程。执行创建命名空间这样的操作需要root权限,因此我们一般会简单的表述为创建容器实例需要root权限。
但是前边的表述不是太准确,特别是在安装了Docker的机器上,用户并不是必须持有root权限才能创建容器实例,本质上用户只要加入docker组(docker group)后就有权限给Docker socket发送命令,比如发送docker run命令给socket来驱动docker deamon来创建容器实例。笔者这里需要特别强调的是将用户加入到docker group等同于root权限,因为如果用户有权限启动容器实例,就意味着用户启动了以root权限运行的容器实例,并且这个容器实例可以在启动的时候通过参数:docker run -v /:/host <镜像ID> 来挂载操作系统的根目录,通过这点相信大家能体会到“等同”的含义。
容器实例默认情况下以root权限运行会造成重大的安全风险,因此最近几年Rootless容器模式开始出现。具体来说Rootless容器模式就是让非root用户能够在操作系统上创建容器实例。从技术原理的角度看,Rootless容器模式依赖于user命名空间机制来实现,非root权限的用户ID会被映射到容器中的root账号,特别是当出现容易逃逸攻击的时候,容器中的账号逃逸出来后,在宿主机上就是普通账号,因此能够造成的危害也非常有限,因此从这一点看,Rootless容器模式对安全有巨大的提升。
咱们在上篇文章中展示过如何通过把非root账户加入到docker组中来启动容器实例,也特别强调过如果运行时是podman,并且做过docker和podman的别名,那么非root账户无法启动容器实例,背后的原因是podman的具体实现机制叫deamonless。从deamonless这个单词读者也应该能够猜到podman没有Docker机制中的Deamon进程,由于创建容器实例需要创建命名空间,因此非root账户无法直接在podman运行时上启动容器实例。
虽说Rootless这种机制听起来很“安全”,但是它也不是银弹,并不是所有的容器镜像都可以成功在rootless容器模式下启动并运行起来,原因是操作系统权限(capabilities)的实现机制和命名空间机制有点微妙。如果读者查看Linux操作系统的文档,会发现user命名空间不仅仅隔离了用户(user)和组(group),还包括内核的权限(capabilities)。大白话说就是我们可以为运行在某个特性user命名空间的进程增加或者删除某项操作系统内核访问的能力,这个能力只限于这个命名空间。对于rootless容器实例来说,当我们给rootless容器实例增加了某项内核访问权限,这个权限只局限于容器进程内,并不等同于容器实例访问宿主机上的内核资源。
笔者承认前边的这句话并没有把rootless和user命名空间之间微妙的关系说清楚,咱们还是继续举个例子说明。CAP_NET_BIND_SERVICE这个能力在Linux操作系统内核中允许进程可以使用低于1024的端口号。假设我们在Docker环境中启动一个容器实例(默认情况下容器实例以root权限运行,因此持有CAP_NET_BIND_SERVICE内核权限),并且和宿主机共享网络命名空间,那么容器实例中运行的应用程序就可以使用1-65536所有的端口号。反过来如果我们运行rootless容器,显示的赋予CAP_NET_BIND_SERVICE权限并和宿主机共享host命名空间,那么运行在容器中的相同springboot应用是无法使用低于1024的端口号,这就证实了我们前边的逻辑,内核能力只限于容器内部,无法透出到宿主机。
虽说内核能力和user命名空间这种细微的设计看似罪恶之源,但是从安全的角色,这是健壮的设计,我们不用担心通过user命名空间隔离的进程修改宿主机操作系统内核的配置,造成系统运行或者重启等故障。笔者对rootless这种模式的使用经验显示,大部分容器镜像都可以成功运行,读者的场景如果对安全要求非常高,建议考虑rootless这种模式。
rootless容器实例从宿主机的角度看,userid为普通用户,虽然从容器内部看的确以root账户运行,这种不同角度看到不同的用户账户权限会造成容器内部访问文件系统数据的问题,因此需要操作系统的文件系统类型支持文件ownership和group ownership的remap,这部分内容要延展开会非常复杂,感兴趣的读取可以自行参考相关材料。
不过rootless容器模式相比于Docker容器,略显青涩,还处于非常初级的发展阶段,目前支持比较完整的运行时包括runc和podman,docker虽说也支持,但是还处于试验阶段。从Kubernetes的角度看,目前尚未支持rootless容器这种模式,相信社区已经在努力朝着这个方向发展,感兴趣的读者可以参考Akihiro Suda等人开发的一个POC的例子,读者可以自行寻找。
安全是个全局的话题,因此运行时的问题并不是全部,笔者的经验显示大部分安全问题都和配置有关,容器以root权限运行叠加配置的风险,这是容器安全领域风险的主要来源。因此在实际的项目中,大家需要仔细分析自己的业务场景,结合本文提到的user id重写以及rootless容器模式,来规避容器实例以root权限运行的问题。
说到容器实例以root权限运行,Docker和很多其他的容器运行时都提供了容器实例启动的时候指定--privileged参数,如果你不知道这个参数,请忽略后续的信息,因为这个--privileged参数被称作是“计算机历史上最臭名昭著的参数”。这么叫的原因有两个,其一是很强大,其二是很容易被滥用造成安全风险。
由于安全总是被误解为安全专家的职责范围,因此很多时候运维人员和系统开发人员对安全的意识并不强,导致很多人对--privileged这个参数的理解是:容器实例以root权限运行,相信大家应该清楚默认情况下Docker平台下启动的容器实例都是以root权限运行这个事实,因此读者读到这里的疑问是:这个--privileged参数到底有啥用?
在继续讨论之前,大家需要了解一个技术细节,虽说默认情况下容器实例以root账号运行,但是操作系统内核的设计人员关于虚拟化这套机制也考虑了安全因素,具体来说就是容器实例运行的这个root权限也只持有部分操作系统的内核调用能力(capabilities),咱们接下来实际的例子来看看,默认情况下容器实例持有的操作系统内核权限以及携带--privileged的场景下权限清单。
笔者在自己的环境中分别运行不带--privileged和带--privileged的场景,通过capsh输出(这个工具只有在Linux操作系统有)权限能力清单,详细信息如下:
➜ source docker run --rm -it alpine sh -c 'apk add -U libcap; capsh --print | grep Current'
...
Current: = cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,
cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,
cap_audit_write,cap_setfcap+eip
➜ source docker run --rm -it --privileged alpine sh -c 'apk add -U libcap; capsh --print | grep Current'
...
Current: = cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,
cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,
cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,
cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,
cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,
cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,
cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,
cap_wake_alarm,cap_block_suspend,cap_audit_read+eip
从输出的清单大家应该很明显就能看出,携带--privileged的容器进程会持有几乎全部操作系统内核的访问权限,即便是这里边大部分容器进程都用不着。读者可能会问,既然这个--privileged参数如此具备杀伤力,为啥要引入这个参数呢?
具体来说--privileged用来实现Docker in Docker模式,自己搭建过流水线CICD的同学应该理解DID这种模式,Jenkins运行在容器中,但是我们需要在容器中来访问deamon构建容器镜像,虽然说这种模式很普遍,读者还是建议大家谨慎对待--privileged参数。如果读者的业务场景的确需要使用--privileged参数,建议做好日志记录和安全审计,并且对需要使用--privileged参数的场景要多次推敲讨论,降级这个参数造成的安全风险。
另一种思路就是给应用所需的权限,虽然说这个看起来很自然,但是由于大部分应用会依赖很多三方包,系统包,因此应用程序在静态的时候,很难完整确认程序运行所需要的所有内核权限。因此我们需要借助于Tracee这样的工具来追踪记录应用程序需要的内核权限。咱们还是通过下边的例子来说明这个工具:
在自己的Linux操作系统上首先安装这个工具(如果没有的话),接着打开两个终端,在终端1运行 docker run -it --rm nginx。
$ docker run -it --rm nginx
然后在终端2上运行tracee工具,并设置对应的参数,如下:
$ ./tracee.py -c -e cap_capable
TIME(s) UTS_NAME UID EVENT COMM PID PPID RET ARGS
125.000 c8520fe719e5 0 cap_capable nginx 6 1 0 CAP_SETGID
125.000 c8520fe719e5 0 cap_capable nginx 6 1 0 CAP_SETGID
125.000 c8520fe719e5 0 cap_capable nginx 6 1 0 CAP_SETUID
124.964 c8520fe719e5 0 cap_capable nginx 1 3500 0 CAP_SYS_ADMIN
124.964 c8520fe719e5 0 cap_capable nginx 1 3500 0 CAP_SYS_ADMIN
通过这种方式我们就能准确的知道应用程序需要的内核访问权限,然后就可以通过命令行$ docker run --cap-drop=all --cap-add=<cap1> --cap-add=<cap2> <image> ...来指定合适的权限集合。大家需要注意的是我们首先要cap-drop=all,然后再设定应用程序需要的操作系统权限。
容器默认情况下以root权限运行只是安全风险中的一环,我们在启动容器实例的使用,也可以使用-v选项来将宿主机的目录挂载到容器中,用来实现宿主机和容器之间进行数据文件的共享。虽然说这种方式很方便就可是实现数据共享,但是也存在巨大的安全隐患,并没有人阻止你把宿主机的根目录挂载到容器中,比如下边的例子。
笔者在本地启动了一个容器实例,并把宿主机(macOS的虚拟机)的根目录挂载到容器中,大家从输出的结果可以看到,从容器实例中可以看到宿主机的系统目录:
➜ source docker run -it -v /:/hostroot ubuntu bash
root@59c908045783:/# ls
bin boot dev etc home hostroot lib lib32 lib64 libx32 media mnt opt proc root run sbin srv sys tmp usr var
root@59c908045783:/# cd hostroot
root@59c908045783:/hostroot# ls
A C F I L N Q System Users W Z bin cores e g host_mnt k lib64 mnt opt proc root sbin t usr w z
Applications D G J Library O R T V X a boot d etc h i l m n p q run srv tmp v x
B E H K M P S U Volumes Y b c dev f home j lib media o private r s sys u var y
root@59c908045783:/hostroot#
从输出的信息可以看到,如果恶意攻击者攻破了容器实例,那么整个宿主机就直接暴露给黑客,他的确可以干任何想干的事情。虽然说在容器挂载宿主机的根目录属于这种场景比较牵强,但是这种的场景的变体我们日常的系统开发和运维中可能会遇到,包括:
- 在容器中挂载/etc会允许恶意攻击修改系统的用户信息,特别是/etc/passwd文件中包含了用户密码信息
- 挂载/bin文件夹会给恶意攻击者写入恶意可执行文件的机会
- 挂载宿主机的日志文件后,恶意攻击者可以通过篡改日志来抹掉攻击系统的证据
- 在Kubernetes场景下,将/var/log文件夹关在到容器中,所有用户都可以通过kubectl logs访问系统的日志信息
在Docker的场景下,docker deamon通过socket /var/run/docker.sock来接收客户端的命令,所有有权限写这个sock的客户端,理论上都可以创建容器实例。并且通过前文我们知道,deamon以root权限运行,会执行任何发过来的指令。因此我们可以将有权限访问Docker socket等同于宿主机上的root权限。
最后,我们也要对容器实例和宿主机共享命名空间的场景保持极高的警惕性,比如说我们希望容器实例访问宿主机上的进程信息,那么就需要在容器启动的时候,指定参数--pid=host。这种场景下从容器内部就可以看到所有的宿主机上的进程,plus所有的容器进程,因此我们就可以在容器实例中通过kill命令来结束某些进程,这会造成巨大的安全风险。
不过在容器之间,或者容器和宿主机之前共享某些命名空间并不总是bad idea,边车模式正是采用了共享网络命名空间的机制,来将网络相关的功能拆分到代理POD上。具体来说Service mesh边车代理负责应用程序的网络接入功能,比如HTTPS的证书管理和配置,这样就可以让应用程序更聚焦于业务逻辑处理,而边车POD负责设置TLS证书等。
另外我们也可以在边车上收集进入应用程序的请求信息,一次来作为监控,日志等平台的数据来源;笔者也见过在边车代理上做安全策略控制的场景,我们可以在边车代理上监控所有的进出流量以及关键字,以此来增强运行在容器实例中应用的安全性。
好了,这篇文章就这么多了,咱们下篇文章来介绍容器网络安全,敬请期待!