对于分布式系统来说,我们通常会使用跨越网络的服务来完成业务逻辑的处理,比如订单中心的saveOrder接口需要将订单数据持久化到阿里云上的DRDS分布式数据库中,将订单保存完成的消息发发送给RocketMQ消息队列来通知下游系统等。为了访问数据库服务,我们通常需要一对用户名和密码,或者授权的Token,通常我们称之为credentials。本质上这些credentials信息主要目的是保护我们的隐私数据不被未授权的用户访问,窃取,因此如何确保这些数据的安全就显得尤为重要。
比如遵守最基本的安全规范,笔者在系列文章的第一篇就提到过多个基本的安全规范,其中最小权限(least privilege)应该是最被大家熟知的,在上边描述的场景中,最小权限的含义是隐私铭感数据只被授权的用户或者组件使用(这里的“只被”就是least privilege原则的体现)。
credentials类信息最基本的安全属性就是机密性,只对持有访问权限的用户开放。举个例子,我们可以通过加密来讲数据库访问用户名和密码加密。不过加密需要秘钥,因此这个秘钥需要在所有允许访问的实体之间共享,包括服务器端。除了这种data at rest的加密手段,当数据被访问的时候,也就是从存储介质被读出并返回给应用程序的过程中,我们需要通过data in transit加密手段来抵御MIMA攻击(man in the middle attack)。
对加密机制不是太了解的同学很容易犯“加密后,整个世界就太平”的错误,这很容易理解,我们将机密数据加密后,可以安全的传递给任何相关方而不会造成泄漏或者窃取。即便是中间人攻击截取到了隐私数据,由于没有秘钥,也无法获取原始的数据。但是如果数据的接收端需要读取原始数据,密文肯定不行,需要对数据解密,那么接收端解密数据的秘钥从哪里来呢?我们可以简单的说在通过另外一个秘钥来安全的传输解密数据的秘钥,那么这个“另外一个秘钥”又如何确保安全的传输和分发呢?
随着参与信息交互的parties越来越多,很容易出现秘钥泄漏,因此我们需要秘钥revoke(回收,下线)的机制,来在泄漏发生后,让系统能够马上将泄漏的秘钥失效,来最大限度的保护数据安全。当然revoke机制在日常的运营中也是非常需要的,比如员工离职,转岗都需要有机制能够revoke他们持有的账户,来确保信息安全。
对于安全级别要求高的场合,安全规范规定必须使用short-lived秘钥,通过缩短秘钥的生命周期来缩小攻击面。比如当秘钥被窃取后,可能攻击者还没有来得及使用秘钥窃取数据,秘钥的有效期就过了。不过“人肉”的方式来进行秘钥替换不太可取,运维成本太高,因此大部分成熟的系统中都是借助于软件来定期更换访问秘钥,来缩短攻击面,提升体统的安全性。
从秘钥生命周期的角度看,我们不应该让应用程序的生命周期和秘钥的声明周期之间有强依赖,我们必须能够在不重新发布应用程序新版本的情况下,更新秘钥信息。相信强依赖的场景大家都很熟悉,在代码中硬编码秘钥信息,证书信息,然后将应用程序打包部署,这是一种典型的将秘钥的声明周期和应用程序生命周期强绑定的案例。
当秘钥被产生后,特别是生产环境,需要访问秘钥的用户数量远比访问应用程序使用这些秘钥的用户数量低,因为我们必须确保秘钥被产生后,不应该被不相关的人查看,更不能被修改。在系统中需要针对这些隐私信息设计完整的审计日志机制,这样当出现异常的时候,我们就可以尽快行动来分析对秘钥的访问是否在预期之内。缺少对秘钥信息访问审计是笔者接触过最多的安全问题,等隐私数据被泄漏后才来分析是否有日志记录了某些异常行为,大概率为时已晚。
秘钥信息的访问者中不仅限于真实的用户,也包含服务或者应用系统,由于咱们讨论的是容器安全,因此容器也需要这些秘钥数据来访问外部服务,数据库服务等。因此如何把秘钥等信息安全的暴露给容器实例使用,是我们这篇文章要解决的核心问题。
笔者之前通过多篇文章详细介绍过容器的隔离性,以及这种隔离性的脆弱性,因此从安全的角度,我们要安全的把秘钥给容器实例也不是太容易,从笔者过往的经验看,可行的手段包括但不限于:
- 将秘钥信息包含在容器镜像中,也就是在docker build的时候将秘钥信息写入到镜像的root文件系统
- 在镜像文件中定义环境变量
- 在容器启动的时候,通过YAML文件来配置环境变量来初始化环境变量
- 容器启动的时候从外部服务拉取配置信息
- 容器实例挂载外部的存储介质(比如和宿主机共享目录来读取配置信息)
虽然笔者列出来这五种方式,但是需要强调的是,前两种方式(文件和环境变量)非常不可取,原因很明显,我们在镜像构建的时候就确定了敏感数据,会造成如下问题:
- 如果敏感数据被保存在代码中,那么任何有代码访问权限的同学都可以看到这些隐私数据。笔者在华南某头部客户的项目上处理过一个非常常见的场景,开发同学讲秘钥进行加密后,放在源代码中。虽然这看起来很安全,因为访问源代码的同学是无法获取秘钥信息,但是你有没有考虑过,代码在使用这个秘钥的时候,解密秘钥从哪里来呢?答案是写在代码中。
- 将秘钥信息硬编码到容器镜像中另外明显的问题是无法进行秘钥定期更换,因为更换秘钥就意味着更换镜像版本。虽然说就改一行代码,但是造成的线上稳定性风险会远远大于只更改一行代码,因为没有人可以保证重新编译打包的代码不加带任何其他的修改。笔者在华南某头部客户的项目上,100多人的研发团队,你其实很难控制每个版本不夹带私货,因此解耦秘钥和应用程序的生命周期才是正道。
笔者还遇到过开发人员说为了提升开发的效率,把秘钥写到源代码中,后边通过技术债来重构,实际上这种思路非常危险。因为大部分这样的技术债要么被忽略,要么在backlog中的优先级非常低,低到几乎就等同于不存在。
如果将秘钥信息静态保存在镜像中不可取,自然而然的想法就是动态加载,我们通过客户端工具从代码中动态加载秘钥信息,虽然说这种方式技术上可行,但是从笔者的经验看,这种方式在真实场景中用的并不多。原因是通常我们需要安全的链路来传输秘钥数据,因此我们最少需要使用X.509格式的证书(certificate),而维护和管理这些证书有会涉及到安全和运维。不过随着行业的不断发展,相信大家一定听过边车模式,服务网格,我们可以借助于服务网格提供的control plane来管理服务和服务之间的mTLS需要的证书,因此通过代码加载证书这种方式应该还会有继续存在的场景。
接着我们来讨论一下通过环境变量来传递秘钥信息给容器实例这种方式,虽然说这种方式看起来很自然,也很有效,但是读过笔者关于Kubernetes存储机制系列文章的同学,应该对笔者的结论:优先使用数据卷来提供配置信息给容器实例,对敏感隐私数据来说更应该如此的观点记忆犹新。背后的逻辑如下:
- 很多系统在crash的时候,会“自动”将系统运行的环境信息dump到日志中,这就包括了环境变量信息,而日志系统很多时候并没有非常严格的权限访问控制,因此环境变种中的这些敏感信息(比如数据库访问账户和密码)会不经意的被泄漏。
- 在容器化部署的场景下, 我们通过docker inspec可以看到容器的所有环境变量信息,无论是在build阶段的镜像文件,还是在正在运行的容器实例。对于有管理员权限的用户,通过docker inspec会被动的访问到所有环境变量中的信息,无论是否敏感。
咱们通过一个具体的例子来说明一下,笔者在自己的机器上启动了一个vagrant操作系统实例,安装docker后拉取了nginx镜像,通过docker inspect命令就可以访问到所有的环境变量信息,如下所示:
vagrant@vagrant:~$ docker image inspect --format '{{.Config.Env}}' nginx
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NGINX_VERSION=1.17.6 NJS_VERSION=0.3.7 PKG_RELEASE=1~buster]
接着咱们启动一个nginx实例,看看容器实例是否也这么脆弱把所有的环境变量信息通过inspec返回?
首先启动一个nginx容器实例,并设置了环境变量
vagrant@vagrant:~$ docker run -e SECRET_ENV=qiwanghan --rm -d nginx
12bcf3c571268f697f1e562a49e8d545d78aae65b0a102d2da78596b655e2f9a
然后我们来inspec一下,看看我们在启动时候设置的环境变量内否被轻易获取:
vagrant@vagrant:~$ docker container inspect --format '{{.Config.Env}}' 12bcf
[SECRET_ENV=qiwanghan PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NGINX_VERSION=1.17.6 NJS_VERSION=0.3.7 PKG_RELEASE=1~buster]
熟悉云原生架构模式的同学应该对Heroku团队总结的12-factor不陌生,这个最佳实践中的确鼓励(encourage)我们通过环境变量来传递配置信息,因此你会发现很多早期的容器化部署大量使用环境变量传递配置信息,包括秘钥这样的敏感数据。如果读者面对的是这种场景,那么建议采用如下的风险应对方案来降低信息泄露的风险:
- 应用程序的日志被输出之前,通过过滤器来过滤掉敏感数据
- 或者通过边车模式,init Container来从外部读取敏感数据
另外必须着重强调的点是,环境变量包含的配置信息在容器实例创建的时候被生成,因此在容器的生命周期中无法修改,如果秘钥比如数据库密码被修改,那么就需要重启容器实例才能生效,我们无法从外部在不重启容器实例的情况下来更换秘钥。
基于笔者多年的实践经验,将秘钥暴露给容器实例最佳的方式就是通过挂载数据卷,并且通过挂载内存型文件系统,敏感数据被加载到容器实例后,仍然会保证足够的安全性。并且由于文件是从宿主机被挂载到容器实例,当我们更新了宿主机上的秘钥后,容器中的数据可以自动被更新,这就意味着我们可以在不重启容器的情况下来更替秘钥,可运维性得到了极大的提升。
咱们稍微将如何暴露秘钥数据给容器实例稍微做一些扩展,在K8S的场景下,由于K8S原生的支持secret对象类型,因此咱们前边描述的安全方案基本都适用,详细说明如下:
- 在Kubernetes中,敏感数据secret对象和应用程序实例无任何耦合,因此我们对secret对象的管理可以有自己的机制和生命周期。
- secret对象在etcd默认情况下不是加密存储,不过我们可以通过encryptionconfiguration,sealsecret以及其他机制很容易实现encrypted at rest。
- secret下不同的组件之间传递采用加密的形式,比如API Server和etcd之间。
- secret可以通过环境变量或者数据卷的形式被暴露给容器实例,特别是对于数据卷的形式,secret被挂载为内存型文件系统,来提升安全性。
- 我们可以设置Kubernetes RBAC(基于角色的权限控制机制)来实现secret被写入而不被其他用户和实体读取的场景(比如生产环境)。
基于笔者过去几年的经验看,对于云原生的应用程序,我们一般需要阿里云提供的KMS这样的托管服务来提供安全的秘钥存储和交互,阿里云KMS可以带来如下的好处:
- 完善的秘钥定期更换机制,减轻安全性较高场景下的运维压力
- 帮助企业建立完善的,标准化的秘钥管理机制,极大的提升了企业的整体安全
文章的最后,笔者还是要再次强调安全没有一劳永逸,咱们无论是把秘钥以数据卷还是环境变量暴露给容器实例,本质上对运行容器的root用户可见。举个例子,如果秘钥数据在磁盘上(通过挂载数据卷的方式暴露),那么root用户是可以通过罗列挂载到容器的内存型文件系统来获取隐私数据,如下边例子所示。
root@vagrant:/$ mount -t tmpfs
...
tmpfs on /var/lib/kubelet/pods/f02a9901-8214-4751-b157-d2e90bc6a98c/volumes/kuber
netes.io~secret/coredns-token-gxsqd type tmpfs (rw,relatime)
tmpfs on /var/lib/kubelet/pods/074d762f-00ed-48e6-a22f-43fc673df0e6/volumes/kuber
netes.io~secret/kube-proxy-token-bvktc type tmpfs (rw,relatime)
tmpfs on /var/lib/kubelet/pods/e1bad0db-8c0b-4d7b-8867-9fc019de258f/volumes/kuber
netes.io~secret/default-token-h2x8p type tmpfs (rw,relatime)
...
通过查看挂载的文件夹的名字(我们通常会把秘钥写到以secret,key,credentials命名的文件夹中),不难找到隐私数据并获取。对于环境变量这种方式,对root用户就更简单了。比如我们启动了一个ubuntu操作系统并设置了环境变量:
vagrant@vagrant:~$ docker run --rm -it -e SECRET=qiwangyue ubuntu sh
$ env
...
SECRET=qiwangyue
...
我们很容易通过env命令打印出所有的环境变量数据,不过这还不是最糟糕的。咱们在前边的文章中介绍过如何获取运行在容器实例中进程的ID,比如:
vagrant@vagrant:~$ ps -C sh
PID TTY TIME CMD
19312 pts/0 00:00:00 sh
由于进程的信息都在文件夹/proc下,因此我们不难在这个文件夹下找到和环境变量相关的信息:
vagrant@vagrant:~$ sudo cat /proc/19312/environ
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binHOSTNAME=2cc99c98ba5aTERM=xtermSECRET=qiwangyueHOME=/root
从上边的例子中可以看出,我们设置的环境变量信息其实都存储在操作系统的文件夹中,很容易被有相应权限的用户获取到。读者可能会问,为啥不加密存储呢?其实加存储只是将问题推到另外一个缓解,我们如何在容器中安全的获取解密用的秘钥?因此本质上问题还是没有被解决。
因此笔者强烈建议阅读这篇文章的同学,如果自己负责某个核心应用的开发和运维,那么请马上评估自己的环境,首先解决容器默认以root运行的这种情况,原因很简单:容器中的root等同于宿主机上的root,容器实例被攻破就等同于这台机器以及运行在这台机器上的所有应用被攻破,如果你不想因此而被炒鱿鱼,请马上行动。
好了,今天这篇文章的内容就这么多了,咱们下篇文章继续讨论容器的运行安全,这是很多同学很熟悉的容器安全领域,敬请期待!