:云原生是一套技术体系和方法论。云原生(CloudNative)由2个词组成,云(Cloud)和原生(Native)。云(Cloud)是指应用位于云端,而不是传统的数据中心;原生(Native)是指应用在设计之初就以云环境为设计初衷,原生为云而设计,并以高质量的状态运行在云上。充分利用和发挥云平台的弹性和分布式优势。具有代表性的云原生技术包括容器、服务网格(ServiceMesh)、微服务(MicroService)、不可变基础设施和声明式API。关于云原生的更多介绍,请参考文末链接1。云原生安全技术沙箱(安全视图)作者将“云原生安全”抽象成一个技术沙箱,如上图所示。从下往上看,底层是从硬件安全(可信环境)到主机安全。将容器编排技术(Kubernetes等)想象成云上的“操作系统”,负责自动部署、扩展和管理应用程序。在其之上由微服务、ServiceMesh、容器技术(Docker等)、容器镜像(仓库)组成。它们相互补充,并在这些技术之上构建云原生安全性。容器安全的另一层抽象可以看作是构建时安全(Build)、部署时安全(Deployment)和运行时安全(Runtime)。美团内部的镜像安全由容器镜像分析平台保障。它以规则引擎的形式运行监管容器镜像。默认规则支持对镜像中的dockerfile、可疑文件、敏感权限、敏感端口、基础软件漏洞、业务软件漏洞、CIS和NIST最佳实践进行检查,提供风险趋势,同时保证部分构建时安全.在云原生架构下,通过容器编排技术(如Kubernetes)部署容器。部署安全也与上面提到的容器编排的安全重叠。HIDS负责运行安全管控(参考分布式HIDS集群架构设计,文末链接2)。本文讨论的范畴也是运维安全之一,主要针对容器逃逸风险作为一种模式(本文中,如无特殊说明,容器均指Docker)。对于安全实施指南,我们将其分为三个阶段:1.攻击前:裁剪攻击面,减少暴露于外界的攻击面(本文涉及的场景关键词:隔离)。2.攻击时:降低攻击成功率(本文涉及场景关键词:强化)。3、攻击后:减少攻击者在攻击成功后可以获得的有价值的信息和数据,增加留后门的难度。近年来,数据中心的基础架构逐渐从传统的虚拟化(例如:KVM+Qemu架构)转向容器化(Kubernetes+Docker架构),但逃避始终是这两种架构下企业需要面对的。它是容器最严重的安全问题,也是容器风险中最具代表性的安全问题。笔者将以容器逃逸为切入点,从攻击者(容器逃逸)到防御者(缓解容器逃逸)的角度来讲解容器安全实践,以缓解容器风险。容器风险容器提供了一种将应用程序的代码、配置和依赖项打包到单个对象中的标准方法。容器建立在两项关键技术之上,即Linux命名空间和LinuxCgroups。命名空间创建一个近乎隔离的用户空间,并为应用程序提供系统资源(文件系统、网络堆栈、进程和用户ID)。Cgroup对CPU、内存、设备和网络等硬件资源实施限制。容器和虚拟机的区别在于,虚拟机模拟了一个硬件系统,每个虚拟机可以在独立的环境中运行操作系统。Hypervisor模拟CPU、内存、存储、网络资源等,这些硬件可以被多个VM多次共享。容器攻击面容器中有7个攻击面:LinuxKernel、Namespace/Cgroups/Aufs、Seccomp-bpf、Libs、LanguageVM、UserCode、Container(Docker)engine。作者以容器逃逸为风险模型,提取出三个攻击面:1.Linux内核漏洞2.容器本身3.部署(配置)不安全1.Linux内核漏洞容器的内核与宿主机内核共享,使用Namespace而Cgroups这两种技术都是将容器内部的资源与宿主机隔离开来,所以Linux内核的一个漏洞就可以导致容器逃逸。KernelPrivilegeEscalationVSContainerEscape——Linux内核提权通用方法信息收集收集所有对exploit编写有帮助的信息。比如:内核版本,你需要判断被攻击的内核是什么版本?该内核版本启用了哪些加固配置?你还需要知道写shellcode时会调用哪些内核函数?这时候就需要查询内核符号表来获取函数地址。还可以从内核中得到一些有助于编写和使用的地址信息、结构信息等。触发阶段触发相关漏洞,控制RIP,劫持内核代码路径,简而言之,获得在内核中任意执行代码的能力。整理shellcode在编写内核漏洞利用代码时,我们需要找一块内存来存放我们的shellcode。这块内存至少要满足两个条件:第一:触发漏洞时我们要劫持的代码路径必须保证代码路径能到达存放shellcode的内存。第二:这块内存可以执行。也就是说,这块存放shellcode的内存是有可执行权限的。第一阶段执行:获得比当前用户更高的权限。一般我们直接获取root权限。毕竟是linux里面的最高权限,就是执行我们的shellcode。第二:为保证内核的稳定性,不能因为需要提权而破坏原内核的代码路径、内核结构、内核数据等,导致内核崩溃。在这种情况下,即使获得root权限也没有多大意义。总之,收集有助于编写exploit的信息,然后触发漏洞执行特权代码,达到提权的效果。简单的容器逃逸模型(ContainerEscapeModel)容器逃逸和内核提权只有细微的差别,需要突破命名空间的限制。将高权限命名空间分配给漏洞利用进程的task_struct。这部分的详细技术细节超出了本文的范围。笔者会抽空再写一篇关于容器逃逸的技术文章,详细介绍相关技术细节。经典DirtyCoW的作者以DirtyCoW漏洞来说明Linux漏洞导致的容器逃逸。漏洞虽然老,但是太经典了。写到这里,我不禁要问:这么多年来,国内外各大厂商现有机器的DirtyCow漏洞修复率如何?Linux内核的内存子系统处理私有只读内存映射的写时复制(Copy-on-write)。On-Write,CoW)机制发现争用冲突。非特权本地用户可以利用此漏洞获得对其他只读内存映射的写入访问权限,从而增加他们在系统上的特权,称为脏牛漏洞。DirtyCoW漏洞逃逸这里的思路和上面的思路不同,使用了OverwritevDSO技术。vDSO(VirtualDynamicSharedObject)是内核为了减少内核空间和用户空间的频繁切换,提高系统调用效率而设计的一种机制。它映射到内核空间和每个进程的虚拟内存中,包括那些以root权限运行的进程。可以通过调用不需要上下文切换的系统调用来加快此步骤(定位vDSO)。vDSO在用户空间映射到R/X,在内核空间映射到R/W。这允许我们在内核空间修改它,然后在用户空间执行它。并且由于容器是与宿主内核共享的,所以可以直接使用该技术对容器进行逃逸。使用步骤如下:1.获取vDSO地址,在新版glibc中直接调用getauxval()函数即可获取。2、通过vDSO地址找到clock_gettime()函数地址,查看是否可以劫持。3.创建监听套接字。4.触发漏洞,DirtyCoW是内核内存管理系统实现CoW时产生的漏洞。通过条件竞争,把握合适的时机,利用CoW的特性,将文件的只读映射到写入。子进程不断检查是否写入成功。父进程创建两个线程,ptrace_thread线程向vDSO写入shellcode。madvise_thread线程释放vDSO映射空间,影响ptrace_thread线程的CoW进程,创建条件竞争。当条件触发时,写入即可成功。5、执行shellcode,等待宿主机返回rootshell,成功后恢复原来的vDSO数据。2、容器本身先简单看一下Docker架构图:Docker架构图(图片来自网络,如有侵权,联系删除)Docker本身由docker(dockerclient)和dockerd(dockerdaemon)组成。但是从Docker1.11开始,Docker不再是简单的通过dockerdameon启动,而是集成了很多组件,包括containerd、runc等。Dockerclient是docker的客户端程序,用于向dockerd发送用户请求。dockerd其实调用的是containerd的api接口。containerd是dockerd和runc之间的中间通信组件,主要负责容器运行和镜像管理。Containerd向上为dockerd提供gRPC接口,让dockerd屏蔽下面的结构变化,保证原有接口向后兼容;向下,通过containerd-shim和runc的组合创建并运行容器。更多相关内容请参考文末链接4、5、6。了解了这些之后,我们就可以结合自己的安全经验,找到能够导致逃逸这些组件之间的通信方式和依赖关系的漏洞。下面我们以docker中runc组件产生的漏洞来说明容器本身的漏洞导致的逃逸。CVE-2019-5736:runc–容器突破漏洞使用文件系统描述符时,runc存在漏洞。该漏洞可导致特权容器被利用,导致容器逃逸并访问主机文件系统;攻击者还可以使用恶意图像,或修改正在运行的容器内的配置来利用此漏洞。攻击方式一:(此方式需要有特权的容器)正在运行的容器被入侵,系统文件被恶意篡改==>宿主机运行dockerexec命令在容器中创建新进程==>宿主机runc是被恶意程序替换==>当宿主执行dockerrun/exec命令时,触发恶意程序的执行;攻击方式二:(该方式不需要特权容器)dockerrun命令启动恶意修改镜像==>宿主机runc被恶意程序替换==>宿主机运行dockerrun/exec命令触发执行恶意程序;当runc在容器中执行新程序时,攻击者可以诱使它执行恶意程序。通过用自定义二进制文件替换容器内的目标二进制文件来实现指向runc二进制文件。例如,如果目标二进制文件是/bin/bash,这可以将#!/proc/self/exe替换为指定解释器的可执行脚本;因此,在执行/bin/bash的容器内,/proc/self/exe的目标将被执行,将目标指向runc二进制文件。然后攻击者可以继续写入/proc/self/exe目标,试图覆盖主机上的runc二进制文件。这里需要使用O_PATH标志打开/proc/self/exe文件描述符,然后通过带有O_WRONLY标志的/proc/self/fd/重新打开二进制文件,使用单独进程连续写入。写入成功后,runc将退出。3、不安全的部署(配置)在实践中,我们经常会遇到这种情况:不同的业务会根据自己的业务需求,有自己的一套配置,但是这套配置没有得到有效的控制和审计,使得内部环境变得复杂多样,无形中增加了很多风险点。例如最常见的:1.特权容器或以root权限运行容器。2.Capability配置不合理(权限过大的Capability)。面对特权容器,只要在容器中执行一条命令,就很容易在宿主机上留下后门。特权容器问题在美团内部得到有效遏制。这部分业界给出了最佳实践,从主机配置、Dockerd配置、容器镜像、Dockerfile、容器运行时等方面保证安全。详情请见文末链接10。同时,Docker正式将其作为自动化工具实现。(见文末链接11)。安全实践为了解决上一节中描述的容器逃逸问题,下面的讨论将从隔离(securecontainer)和加固(securekernel)两个角度展开。1.安全容器安全容器的技术本质其实就是隔离。gVisor和KataContainer是具有代表性的实现方式。当然,学术界目前正在探索基于英特尔SGX的安全容器。简单来说,gVisor在用户态和内核态之间抽象出一层,封装成API,有点像用户态内核,从而实现隔离;KataContainer采用轻量级虚拟机隔离,不同于传统的VM类似,但实现了目前Kubernetes加Docker架构的无缝集成。下面我们看看gVisor和KataContainer的异同点。Case1:gVisorgVisor是一个用Golang编写的用户态内核,或者沙箱技术,主要实现大部分的系统调用。它在应用程序和内核之间运行,在它们之间提供隔离。gVisor用于谷歌云计算平台的AppEngine、CloudFunctions和CloudML。gVisor在运行时由多个沙箱组成,这些沙箱进程共同覆盖一个或多个容器。通过拦截所有从应用程序到主机内核的系统调用,并使用用户空间中的Sentry来处理它们,gVisor可以充当来宾内核,而无需进行虚拟化硬件转换。可以看成是vmm和guestkernel之间的纽带,或者是seccomp的加强版。gVisor架构图(图片来自网络,如有侵权将被删除)案例二:KataContainerKataContainer的ContainerRuntime是通过hypervisor和硬件虚拟化实现的,就像一个虚拟机。所以像这个KataContainer这样的每一个Pod都是一个轻量级的虚拟机,拥有完整的Linux内核。所以KataContainer可以像VM一样提供强隔离,但是因为它的优化和性能设计,它有媲美容器的敏捷性。KataContainer架构图(图片来源网络,如有侵权将被删除)。KataContainer在主机上有一个kata-runtime来启动和配置新的容器。对于KataVM中的每个容器,在主机上都有一个对应的KataShim。KataShim接收来自客户端(例如:docker或kubectl)的API请求,并通过VSock将请求转发给KataVM内部的代理。Kata容器进一步优化以减少VM启动时间。使用QEMU的轻量级版本NEMU,大约80%的设备和包被删除。VM-Templating创建正在运行的KataVM实例的克隆,并将它们与其他新创建的KataVM共享,从而减少启动时间和GuestVM内存消耗。热插拔功能允许VM使用最少的资源(例如:CPU、内存、virtio块)启动,并在以后请求时添加额外的资源。在gVisorVSKataContainer之间,我更倾向于选择gVisor,因为gVisor在设计上比KataContainer更轻,但是gVisor的性能问题始终是暂时无法逾越的障碍。综合两者的优缺点,KataContainer会更适合目前的企业。总的来说,安全容器技术仍然需要探索,以解决企业内部不同基础设施所面临的挑战。2.安全内核众所周知,Android由于厂商不同而维护自己的Android版本,并且由于Android内核态代码来自上游的Linux内核,所以当上游内核出现漏洞时,安全补丁会被推送给Google,然后从谷歌发给各大厂商,最终发给终端用户。安卓生态系统的碎片化和非常长的补丁周期,使得终端用户的安全在这个过程中始终处于“空窗期”。将注意力重新放在Linux上,它也有类似的问题。1.内核面临的问题TheVulnerabilityLifeCycle(漏洞生命周期)内核补丁当安全漏洞被披露时,通常是由漏洞发现者通过Redhat、OpenSuse、Debian等社区报告或直接提交给上游相关子系统维护者。面对企业内部多个不同大内核版本和内核定制,从上游代码反向移植相关补丁,针对不同版本做相关hotfix。定制内核需要二次开发补丁,然后升级生产环境内核或hotfix内核。不仅修复周期过长,而且在修复过程中促进人员之间的沟通也是有成本的,从而延长了漏洞周期。危险期,没有漏洞防护。内核版本碎片化内核版本碎片化是任何一个具有一定规模的公司都无法避免的问题。由于技术日新月异,不断迭代,基础设施上的技术栈需要更新版本的内核功能来支持,导致内核版本随着时间的推移出现碎片化。碎片化问题的存在,使得安全补丁的推送遇到了极大的挑战。补丁本身需要有针对性的适配,包括不同版本的内核,以及测试验证。碎片化使得维护成本非常高。最重要的是,由于维护工作量大,测试补丁的时间将不可避免地被拉长。也就是说,暴露在攻击者面前的危险期变长,被攻击的可能性大大增加。内核版本定制也是一样,因为不同公司的基础设施和要求不同,导致定制内核的问题。对于定制化的内核,简单的合并上游内核的补丁是不可能的,需要对补丁进行一定的本地化以适配定制化的内核。这会延长危险期。解决方案我们使用安全功能来防御和检测某种类型的漏洞或某种类型的利用。例如SLAB_FREELIST_HARDENED实时检测doublefree类型漏洞,防御overwritefreelist链表,性能损失仅0.07%(参考上游内核源码,commitid:2482ddec)。当所有安全特性完成后,在漏洞上报和漏洞补丁及时推送到生产环境之前,无需关心漏洞细节即可防御。当然,应该应用安全补丁。这里主要解决安全补丁最终落入生产环境时,“空窗期”没有防御漏洞和漏洞利用的问题。同时,我们也可以在一定程度上检测到0day。和防御能力。实施策略1.Linux主线版本已经加入的安全特性。如果公司内核支持该特性,则选择启用配置,对启用前后的内核进行性能测试,分析安全特性原理,行业数据,并给出真实世界的攻击案例(自己写一个exploit来证明it),将报告结论反馈给内核团队,由内核团队进行评估,综合安全团队和内核团队的意见,最终评估实施。2.已经合并到Linux主线版本但没有合并到Redhat的安全特性可以从Linux内核主线版本移植。在这方面,代码质量得到了保证。同时,社区也做了性能测试,合并到公司的核心会重新测试。3、未合并到Linux内核主线版本,移植自Grsecurity/PaX。在Grsecurity/PaX的众多安全特性中,评估选择,选择代码改动少、回报高的安全特性,优先移植,比如改动少的内核代码可以有效解决某类漏洞。例如,dirtycow的全面修复可能需要1-2年的时间。加上一定的防备功能,就算不修,也能防御。后内核故事的最后,分享一下作者眼中的理想状态。当然,我们要根据实际情况“因地制宜”,在不同阶段做出不同的权衡和选择。我们把内核团队当成一个社区,我们把代码提交给他们,就像linux内核社区有RFC(RequestforComment),patchreview等,无异议后会合并到公司的内核中。先选择实用的安全特性和少量代码,移植、实现、落地。更少的代码意味着对内核代码的改动更少,出现问题的可能性更小,稳定性更高,性能损失更低。一年完成几个安全功能,不多,就1-2个。对于内核态的加固,小心谨慎。比如国外公司G的数据中心内核版本发布大概需要6-7个月。做性能和稳定性测试。加强某项安全特性后,使用0day或Nday验证防御效果,基于该内核运行的业务稳定,性能损失在可接受或可控范围内。每个安全功能都需要进行技术审查。为了保证代码质量,找实际的高吞吐、高并发、低延迟的服务器进行小规模的灰度测试。无争议后,推送给内核团队。最后,也可以直接将安全特性的代码提交给Linux内核社区。如果代码有不足之处,也可以与社区合作解决,并合并到Linux内核的主线代码中,从侧面推动实现。
