张新义,武泽慧,贾 琼,陈志浩
1(数学工程与先进计算国家重点实验室,郑州 450001) 2(北京计算机技术及应用研究所,北京 100000)
近年来,云计算技术蓬勃发展,云计算技术依赖于共享计算资源来实现按需扩展,从而节约和降低了运营和维护成本.在云计算中,容器因其开发操作方便和资源利用率高被认为是向云中部署微服务和应用程序的标准,成为了当前的主要趋势.云原生计算基金会发布(CNCF)2021年的调查报告显示[1],在企业实际生产部署中,容器的使用率已经增加到93%,相比于2016年第1次调查时的23%,5年的时间里增加了近3倍.
传统的虚拟机技术使用Hypervisor来创建硬件抽象层,相比之下,容器作为一种操作系统级的虚拟化技术,依赖于由Linux内核提供的NameSpace和Cgroup等内核特性来实现容器之间的隔离.一个容器可以被认为是一组共享专用Linux内核资源的进程,因此在容器中运行的应用程序可以实现接近其宿主机的性能.然而,容器的隔离强度却远远比不上虚拟机,容器与宿主机之间、以及同一主机上运行的容器之间的隔离完全由底层操作系统内核在软件中强制执行,一个恶意容器可以利用宿主机上的内核漏洞来进行特权升级类的攻击,进而达到损害主机以及在该主机上运行的所有其他容器的目的.
容器镜像是容器的基础,镜像包含应用程序以及其相关依赖的一个基础文件系统.容器镜像通常在其他基础镜像上以层的方式构建,在镜像制作的过程中,通常对一些不会在容器运行中使用的资源进行了过度积累(如文件、程序等),进而导致容器镜像的体积过度膨胀.此外,Linux内核的代码库也一直在扩展,多年来公开的系统调用数量的增加表明了Linux内核代码的膨胀.Linux内核中系统调用数从1991年第1个版本中的126个增加到5.11.0版本中的334个( x86_64(64-bit))[2].容器体量的增大和Linux系统调用的膨胀导致了容器攻击面的增大.
作为一种缓解策略,容器攻击面减少技术最近开始获得关注,依据安全方面的最佳实践:最小特权原则和特权分离原则,安全研究人员主要从两个方面来减少容器的攻击面:减少容器的资源或限制容器的功能.以前的许多工作都在不同层次上应用了这个概念,如根据应用程序的需求定制内核代码[3,4]、从共享库中删除使用不到的函数[5-9]以及限制容器或应用程序的系统调用[10-15]等.
NIST容器安全指南[16]中的建议是最好通过限制容器可用的系统调用来减少攻击面.尽管各种方法的形式的多样的,但是所有这些方法的共同挑战是如何准确的识别系统调用集.大部分工作依赖于动态分析和训练[10-12],使用现实的工作负载来对容器进行训练,之后删除或者限制所有训练中没有覆盖到的代码或系统调用.这种方法最大限度地提高了可以限制的系统调用数量,但是动态训练往往无法获取全部工作负载所需的系统调用集,尤其是一些很少被执行的部分,如错误处理、缓冲处理等,因为在训练期间没有覆盖到的功能无法保证永远不会使用.一些工作依赖于静态分析[13,17,18],通过静态分析容器中的可执行文件和库来获取系统调用集合,这种方法虽然可以弥补动态训练结果的不完备,但是静态分析的范围往往受限,而且会得到一个比实际情况更大的系统调用集,达不到理想的减少容器攻击面的效果.
在本文中,容器可用性的概念主要指以下两个方面:1)功能的完备性:受限容器需要能够运行正常容器实现的所有功能;2)性能的稳定性:受限容器的工作负载与正常容器的差异处于可接受的范围之内.简单来说,用户在使用时,感觉不到受限容器与未受限容器之间的差异,受限容器不会因为运行正常的功能需求而报错.
上面两种方法都在寻求满足容器运行的最小系统调用集合,假设前提是在分析阶段没有用到的系统调用在以后的实际运行中也不会用到,这在保护容器安全性的同时,却忽略了容器的可用性,受限的容器仅仅运行极少的一部分功能,一旦在实际生产环境中部署,应用程序的一个合规操作可能会由于触发违规策略而被终止,导致严重影响.
鉴于以前学者们的工作,本文提出了AutoSec,一种容器限制策略自动生成和灵活配置的方法.AutoSec综合考虑了容器的安全性与可用性的均衡,采用动静态结合的方式来提取系统调用集合,并根据容器生命周期的不同阶段动态的生成Seccomp BPF策略,在容器部署后可以实现策略的灵活配置.AutoSec主要分为两个阶段:策略提取阶段和策略实施阶段.策略提取阶段由动态训练引擎和静态分析引擎组成,给定一个应用程序容器,动态训练引擎追踪容器初始化阶段的系统调用以及容器运行阶段的应用程序集合,静态分析引擎再对应用程序集合进行静态分析来获取应用程序中系统调用的集合进而生成应用程序与系统调用之间的映射.在策略实施阶段,容器首先使用初始化时获取的系统调用集合启动;在运行时,为了实现灵活性的配置,根据容器的需求动态的生成Seccomp BPF的规则文件以限制容器的系统调用.实验选取Docker hub上最为流行的4款功能型容器,验证了AutoSec在减少容器攻击面的有效性.
本文的主要工作包括以下几个方面:
1)本文提出了一种动静态结合的容器系统调用提取方法,可以有效的提取容器初始化阶段所需的系统调用集合和生成应用程序与系统调用之间的映射.
2)本文提出了一种需求驱动的系统调用调度方法,根据容器生命周期的不同阶段,按需调度容器中可用的系统调用.
3)根据以上工作,本文设计了一种通用的系统AutoSec,并选取Docker hub上最为流行的4款容器,验证了AutoSec在较少的性能损耗内可以有效的减少70%以上的系统调用数量.
Linux容器是一种操作系统级的虚拟化方法,可在同一宿主机的内核上运行多个用户群组.Linux内核使用Namespace[19]、Capabilities[20]、和Cgroup[21]来提供不同容器之间的隔离.
Namespace,即命名空间,Linux内核通过实现命名空间机制来实现资源的隔离.命名空间为容器提供了最原始也是最简单的隔离方式,保证一个容器中运行的进程看不到或者影响不到运行在另一个容器中的进程或者容器主机的进程.目前,Linux内核提供了8种类型的命名空间,即IPC、Network、Mount、PID、Time、User、Cgroup和UTS,分被负责信号量、挂载点、进程号等资源的隔离.
Cgroup(Control group)是Linux内核的另一个重要特性,主要用来实现对于资源的限制和审计.在容器技术的实现中,Cgroup提供了多种度量标准来确保每个容器获得公平的CPU、内存和I/O等资源,同时,Cgroup限制某个容器的资源使用量,防止其资源耗尽致使系统性能降低.Cgroup目前支持13种资源,包括cpu,cpuacct,cpuset,freezer,perf event,memory,hugetlb,rdma,blkio,pid,device,net_cls和net_prio,每个资源类型都由相应的Cgroup控制器管理.
Linux 还支持将部分root 的特权操作权限细分成 Capabilities,如果将某个权限赋给某进程,即使不是 root用户也可以执行该权限对应的特权操作.该机制可加强容器内部的权限管控,使容器内外的 root 权限隔离.
系统调用是指运行在用户空间的程序向操作系统内核请求需要更高权限运行的服务.系统调用提供了用户程序与操作系统之间的接口,系统调用接口几乎允许程序与网络、文件系统和其他敏感系统资源的所有交互.
在容器环境中,尽管使用操作系统所提供的Capabilities、Namespace等严格的软件隔离机制,但恶意容器仍然可以通过利用内核漏洞来绕过这些隔离机制.与此同时,为了支持新的特性、协议和硬件,Linux内核的代码一直在扩展.尽管不同的应用程序使用不同的系统调用,但几乎都用不到内核提供的全部系统调用,剩余的系统调用处于程序可用但是用不到的状态,但是可供攻击者所利用,进而突破隔离机制达到访问敏感资源甚至特权升级的效果.而且每个新的系统调用函数都是访问整个内核代码的大部分的入口点,从而导致内核的攻击面增大[22].限制可用的系统调用数量是减少程序攻击面的一种有效的方法,已经存在大量的相关工作,一些系统调用限制工具,如Janus[23]、Systrace[24]、ETrace[25]以及Seccomp[26]等能有效的执行系统调用限制策略.
Seccomp(Secure Computer)是Linux内核的一个特性,用于限制阻塞进程的敏感系统调用和危险参数.Seccomp支持两种模式:严格模式和过滤模式.当进程应用严格模式时的Seccomp策略时,只能执行4种系统调用,即read()、write()、exit()、sigreturn();过滤模式即Seccomp BPF,Seccomp BPF在Linux 3.5内核版本中引入,支持通过使用Berkeley的数据包过滤器做过滤规则匹配,与严格模式不同,Seccomp BPF允许用户指定需要限制的系统调用和参数.
Docker默认支持使用Seccomp BPF来过滤容器可用的系统调用.目前,Docker提供的默认配置禁止了诸如bpf、mount、ptrace 等40多个系统调用[27],可有效防御针对CVE-2014-3519、CVE-2015-8660、CVE-2016-0728 等漏洞的攻击.但是,最小特权原则[28]规定,程序应该被限制只访问完成其操作所必需的资源,对于某一个特定容器来说,其实用不到Docker默认允许的大多数系统调用.除了默认的策略外,Docker还允许用户使用“--security-optseccomp”选项为Docker配置用户自定义的Seccomp规则文件.
图1显示了AutoSec的基本架构,它由两个主要的阶段组成,策略提取阶段和策略实施阶段.
图1 AutoSec架构Fig.1 AutoSec architecture
策略提取阶段负责提取满足容器运行所需的系统调用集合,包括两个主要的组件,动态训练引擎和静态分析引擎.首先通过动态训练引擎获取容器初始化时所需的系统调用集合以及在运行时使用的应用程序集合;接着静态分析引擎对应用程序集合进行分析,得到每个程序在运行时所对应的系统调用集合.最终得到容器在初始化阶段和程序运行阶段两种不同阶段的系统调用集合.策略实施阶段负责为容器施加Seccomp BPF策略,包括3个主要的组件,监控器、生成器和调度器.首先使用动态训练中获取的容器初始化系统调用集合生成的Json格式的Seccomp配置文件来启动容器;并在容器运行期间维护一个系统调用池,池中的初始系统调用集合为启动容器所需的集合,并使用监控器对容器的运行状态进行监控,根据容器运行不同应用程序的系统调用需求,调度器会动态的改变的系统调用池中的内容,并使用生成器生成相应的Seccomp BPF策略来动态调度容器可用的系统调用.
策略提取阶段目的是提取目标容器的系统调用列表.鉴于前人的工作[10,11],动态训练使用系统可见性工具追踪程序的执行以发现程序在运行时所使用的系统调用,但是动态分析通常不能发现所有可能的执行路径;静态分析[13,17]能够更全面的发现程序中所使用的系统调用,但是往往得到的结果是一个比实际程序所使用系统调用大的多的集合.为了得到一个相对精确的系统调用集合,本文采用动静态结合的方式,分阶段和方式为容器提取系统调用集合.
2.2.1 动态训练
动态训练主要包括两个目标:在容器启动时获得容器初始化所必须的系统调用,以及在容器运行时获得必要的可执行文件.
动态分析引擎使用Sysdig[29]对容器进行监控.Sysdig是一款原生支持容器的通用性系统可见性工具,它支持多种输出过滤模式来获取系统内容.当使用dockerrun命令启动一个容器时,容器会首先进行初始化,Docker引擎会调用多组系统调用,此时,动态训练引擎监控捕获其系统调用集合,该集合用作生成容器启动时的安全策略基础.容器初始化完成后,基本服务启动,开始进入正常运行阶段,根据容器最初的设定的脚本开始执行相应的功能,动态分析引擎开始捕获容器运行了哪些可执行文件,获取其在容器中使用的可执行文件的集合.
1)获取初始化系统调用
在Docker的体系架构中,创建和启动容器由docker daemon负责,当docker daemon收到来自与docker client的启动容器的请求时,containerd会根据用户的请求来执行不同的操作,如挂载文件系统、初始化容器网络等,但是containerd并不会直接去操作容器,而是创建一个containerd-shim的进程来对容器进行操作.在启动一个新容器时,需要做一些 Namespace 和 Cgroup 的配置,以及挂载 root 文件系统、使Seccomp配置文件生效等,这些操作都有了标准的规范,即OCI(开放容器标准),runc 就是OCI的一个标准实现,containerd-shim会调用runc来执行这一系列操作来启动容器,此时runc已经被施加Seccomp策略,所以动态分析引擎首先要记录runc运行的系统调用.
此外,在大部分容器中,通常会有名称类似于docker-entrypoint.sh的 shell脚本,它主要用于在启动应用程序之前做一些准备工作,如设置环境变量、设置配置文件等.如果存在这样的脚本,那么在动态分析阶段也需要记录由该脚本执行的系统调用.
2)获取运行时应用程序
容器初始化完毕后,容器对应功能启动,容器开始进入运行阶段.容器中的应用程序大体可以分为两类,一类为容器功能相关的关键性应用,如Mysql容器中的mysqld、mysql_conf、mysqlsh等相关程序,另一类为容器功能无关的辅助性应用,如find、bash等.在容器运行的过程中,除了功能必须所需的应用外,还存在一些辅助性工具来满足容器的可操作性,因此动态训练需要引擎找出容器在运行时使用的多个应用程序.
不同功能的容器类型、不同的执行环境都会使得容器所使用的系统调用集合以及应用程序存在差异.为了增加获取应用程序的覆盖率,本文从两个方面对容器进行训练,即容器命令和应用程序命令.容器命令如dockerrun有很多的参数,如--net指定容器的网络连接类型、--volume指定挂载操作、--env-file指定文件读入环境变量等功能,在容器运行中,根据用户的需求进行设置会可能对容器的环境造成影响.本文从Docker hub中爬取对应镜像的文档说明中容器命令执行的部分,作为训练容器的命令之一.对于应用程序命令,本文为不同的容器制定了功能覆盖脚本以提高覆盖到的应用程序,确保能够覆盖绝大多数的容器功能,并在训练期间,通过手动输入命令来覆盖脚本无法触及的应用程序.
经过动态训练,AutoSec得到了两个集合:容器启动时的系统调用集合和容器运行时的应用程序集合.
2.2.2 静态分析
Linux中的应用程序大多为ELF可执行文件,在经过动态训练获得容器运行时需要的应用程序后,AutoSec通过静态分析的方式来获得应用程序的在运行时所需的系统调用.直接通过动态训练监控程序运行来获取系统调用的结果是不健全的,因为无法保证在训练阶段执行路径的覆盖率,如Nginx中缓存管理机制,当缓存满时,Nginx会生成一个单独的缓存管理进程,该进程使用unlink()系统调用来处理缓存,清除旧的缓存文件.但是,动态训练往往只是监控到Nginx缓存管理程序初始化,可能无法捕获缓存文件被删除的过程,因此无法捕获到unlink()系统调用的使用.而且,程序在正常运行期间,unlink()系统调用并不会在其他的地方使用,而且单纯的延长训练时间也并不能很好的解决问题.所以,相比于动态捕获程序在运行时使用的系统调用集合,直接对可执行文件进行静态逆向分析,往往能覆盖程序较为全面的系统调用集合.
Linux为用户程序提供了两种系统调用的方法,即直接调用和库函数调用.用户程序可以调用函数syscall()来直接调用系统调用,而且程序可以通过将嵌入汇编指令将系统调用号传递到EAX/RAX寄存器,触发软件中断以切换到内核空间的方式来直接调用系统调用.然而直接调用的方法有两个主要的缺点:首先,当直接调用系统调用时,程序可能会在用户空间和内核空间之间引入频繁的上下文切换.例如,当使用write()系统调用将字符串输出到文件时,程序必须先切换到内核空间,并为每个字符返回到用户空间,这种切换可能会给程序带来巨大的开销;其次,由于系统调用在不同的操作系统中有所不同,因此直接调用的方式不容易在不同的操作系统中移植.
大多数应用程序更多是通过调用标准库提供的库函数来调用系统调用,如GNUC库(gLibc),它是大多数容器中使用的最流行的Libc实现,gLibc提供了一种通用、高效、安全的方式来调用不同操作系统上的系统调用.例如,当使用printf()输出一个字符串时,首先程序会缓冲所有字符,然后使用write()系统调用刷新到一个文件中.在这种情况下,只需要一次用户空间到内核空间的切换,这大大减少了用户态与内核态来回切换的开销,由于库函数的效率和方便性,几乎所有的程序都使用库函数来调用系统调用.
1)直接系统调用提取
Linux中进行系统调用最直接的方式就是使用syscall()函数或原生的syscall汇编指令,在调用Libc库中没有包装函数的系统调用时,往往采用这种方式.分析这类调用需要获得被传递给系统调用的参数的值.
静态分析引擎使用二进制代码分解来提取syscall指令分配给RAX/EAX寄存器和syscall()函数分配给RDI/EDI寄存器的值来识别系统调用号.系统调用号没有在指令中编码,而是在一个寄存器中提供,如x86_64上的RAX寄存器,静态分析引擎通过推断寄存器的内容来获得系统调用号.首先,引擎使用Objdump 将二进制文件反汇编,从找到syscall指令开始,利用反向符号执行技术寻找RAX/EAX寄存器的值,最终返回包含系统调用号的符号值.之后将系统调用号映射到对应的系统调用,得到直接系统调用的集合.
2)库函数调用提取
使用库函数进行系统调用是最为常用的一种方式,为了提取程序使用库函数进行的系统调用,静态分析引擎首先生成库函数与系统调用之间的映射,之后通过提取应用程序的函数列表,来生成应用程序与系统调用的映射,具体步骤如下:
·生成库函数与系统调用之间的映射
对于程序调用的每个库,静态分析引擎首先构建一个函数调用树(FCT)来表示库中的函数调用关系.然后结合所有文件中的FCT,构建一个有向无环函数调用图(FCG),FCG表示库函数之间的所有函数调用关系.此外,由于系统调用不会调用其他库函数,因此系统调用总是出现在FCG的结束节点上.因此,可以通过遍历FCG中的所有路径来构建从库函数到系统调用的映射表.
·提取二进制文件的函数列表
给定一个ELF可执行文件,函数提取器提取每个程序中所有被调用的库函数.利用包含所有全局变量和函数信息的ELF符号表来派生出被调用的函数.具体来说,分析器首先使用工具Readelf[30]获取ELF文件的可读的ELF符号表,然后根据库到系统调用的映射表提取库函数.
·生成二进制文件与系统调用的映射
在获得程序的所有调用库函数之后,将标识的函数列表与库到系统调用的映射表进行交叉检查,最终生成应用程序与系统调用之间的映射.
策略实施阶段负责启动容器并实时监控容器的执行情况,在不同的执行阶段动态地更改一个容器内的所有进程的可用系统调用列表,限制容器在不同的阶段中只使用该阶段允许的系统调用,并按照容器内运行的程序动态的改变可用系统调用的数量.
2.3.1 Seccomp BPF策略的实现形式
BPF在1992年的Tcpdump[31]程序中首次提出,Tcpdump是一个网络数据包的监控工具,但是由于数据包的数量很大,而且将内核空间捕获到的数据包传输到用户空间会带来很多不必要的性能损耗,所以要对数据包进行过滤,只保留感兴趣的那一部分,而在内核中过滤感兴趣的数据包比在用户空间中进行过滤更有效.BPF就是提供了一种进行内核过滤的方法,因此用户空间只需要处理经过内核过滤的后感兴趣的数据包.
Seccomp 过滤模式允许开发人员编写 BPF 程序来确定是否允许给定的系统调用,基于系统调用号和参数(寄存器)值进行过滤.在Linux内核中,一个进程可以被附加到多个Seccomp过滤器中,并且所有的Seccomp过滤器都被组织在一个单向链表中,每个Seccomp过滤器都被实现为一个由Seccomp指令组成的程序代码,即一个bpf_prog结构,bpf_prog解析sock_filter的指令,如图2所示.
图2 Seccomp BPF的内核实现Fig.2 Kernel implementation of Seccomp BPF
在得到策略提取阶段对应的系统调用列表之后,生成器根据不同的使用阶段生成不同的形式的Seccomp BPF策略,包括两种形式,一种是作为限制容器启动时容器引擎所用的安全策略,一种作为容器运行时程序所需的安全策略.
2.3.2 使用Seccomp BPF保护容器安全
通过提取容器运行时的系统调用来生成Seccomp BPF策略来对容器进行限制,虽然在一定程度上可以有效的减少来自于内核系统调用的威胁.但是动态分析得到的系统调用集合往往会使得受限容器的可用性不足;静态分析得到的系统调用集合过大,达不到减少容器攻击面的理想效果.综合考虑前人的工作,AutoSec根据容器运行的不同阶段,以容器内应用程序为粒度,动态的来调度容器可用的系统调用.
Docker在启动时,允许用户使用自定义的Seccomp策略启动,如表1所示,该策略表示默认禁止所有系统调用,以白名单的方式允许chdir()系统调用的使用.但是容器一旦启动,就无法通过Docker 客户端对策略进行更改.虽然Linux内核提供了两个系统调用prctl()和seccomp()用来更改某个进程的Seccomp过滤规则,但是在本文中却不可取.因为它们只能在容器内部的进程上配置Seccomp过滤器,但是本文需要从容器外部更改容器内部的进程的Seccomp过滤器.而且,在添加了一个Seccomp过滤器后,在进程运行时不能删除或更改,即这两个系统调用来添加新的Seccomp过滤规则,但不能删除已添加的Seccomp规则.因此调度器选择定位Seccomp过滤器指针的内存地址,然后将指针指向每一个对应的应用程序生成的bpf_prog结构体以实现动态的策略部署.具体来说,分为以下4个步骤,如图3所示.
表1 Docker Seccomp 规则文件实例Table 1 Example of a Docker Seccomp ruleset file
图3 动态调度容器的系统调用的工作流Fig.3 Workflow of dynamically schedule the container′s system call
1)使用Seccomp策略启动容器
首先根据动态分析得到的系统调用列表生成表1格式的Seccomp限制策略,策略默认禁止所有系统调用,并使用白名单的形式赋予可用的系统调用.在容器启动时,使用-security-opt指定该文件.接着容器引擎开始进行一系列的初始化工作,containerd-shim在调用runc时,Seccomp开始生效,并作用于所有容器的所有的进程,此时,容器内所有的进程都被施加了Seccomp策略.
此外,AutoSec开始维护一个系统调用池,此时池中的元素为动态分析所得到的系统调用集合.
2)监控容器运行并动态更新调用池
容器开始运行后,此时系统调用池中可用的系统调用可能不能满足容器内程序所运行的需求.AutoSec基于Sysdig实现了一个监控器来捕获容器想要执行的程序,接着,对于捕获到特定程序,监控器根据静态分析中得到的二进制文件与系统调用的映射,导出执行该程序所需的系统调用集合,并将其更新到系统调用池中,如算法1所示.
算法的输入为状态变动任务Task和系统调用池Syscall_pool;输出为更新过的Syscall_pool.当监控器检测到一个任务状态的变化,会根据应用程序与系统调用的映射图将程序映射到对应的系统调用Syscalls(1~2行),接着调度器检查任务的行为,若为启动状态,则遍历Syscalls并将其加入系统调用池中,并将加入池中的系统调用的使用数(Syscall.use_num)加1,代表当前有多少任务在使用该系统调用(3~12行);若任务的状态为结束,则对于Syscalls中的每一个系统调用,将其系统调用的使用数(Syscall.use_num)减1,当使用数等于0时,则代表该系统调用没有程序正在使用,调度器将其从池中删除(13~20行).
3)Seccomp BPF规则构造与更新
系统调用池中更新后,生成器需要根据系统调用池中的集合从新生成结构体.具体来说分为两个步骤.首先,生成器使用libseccomp[32]库将一个系统调用转换为BPF过滤器指令,使用libseccomp库中的seccomp_rule_add()方法来添加所有可用的系统调用,并使用seccomp_export_bpf()方法来导出生成的BPF过滤程序.接着使用内核函数bpf_prog_create_from_user()将在用户空间生成的BPF过滤程序传递的内核空间,并在内核空间中生成bpf_prog.函数bpf_prog_create_from_user()有两个参数,一个指向用户空间的bpf程序指针和一个指向内部内核函数seccomp_check_filter()的函数指针,首先将用户空间过滤器缓冲区复制到内核缓冲区中,然后调用传入seccomp_check_filter()函数,将经典的BPF过滤器程序转换为Seccomp BPF过滤器程序,最后,生成包含带有新指令集的bpf_prog结构.
算法1.动态调度算法
输入:Task:任务,Sycall_pool:系统调用池
输出:Syscall_pool:系统调用池
1.Syscalls←[] //初始化映射
3.ifTask.action==Startthen
4.forSyscallinSyscallsdo
5.ifSyscallinSyscall_poolthen
6.Syscall.ued_num++
7.continue;
8.Syscall_pool.add(syscall)
9.Syscall.ued_num++
10.endif
11.endfor
12.endif
13.ifTask.action==Stopthen
14.forSyscallinSyscallsdo
15.Syscall.ued_num--
16.ifSyscall.use_num==0
17.Syscall_pool.delete(syscall)
18.endif
19.endfor
20.endif
21.returnSycall_pool
4)动态改变Seccomp 过滤器
使用新生成的bpf_prog结构体,调度器可以动态的更改所有容器内进程的Seccomp 过滤器.如图3所示,当容器使用“dockerrun-itd-security-optseccomp” 命令启动容器时,容器中的第1个进程(PID=1)被施加了Seccomp过滤器,它是容器内所有进程的父进程,它的子进程都会继承该过滤器,并包含指向bpf_prog结构的相同指针,当更改容器中一个进程的bpf_prog结构时,所有进程的Seccomp过滤器都会被更改.调度器通过(PID=1)的进程的task_struct来定位bpf_prog在内存中位置,然后修改内存的内容,将seccomp_filter中的filter指针指向新生成的bpf_prog结构.
实验使用一台16g内存的x86_64计算机,英特尔酷睿i7-10700 CPU和Ubuntu 20.04主机操作系统来对AutoSec进行验证.不同操作系统的系统调用数量不同,在本文的实验环境中,主机操作系统总共有334个系统调用.Docker 在默认情况下禁止44个系统调用,所以在本文的实验环境中,Docker默认允许的系统调用数量是290个.本文使用的Docker版本是20.10.18,在动态监控阶段,使用Sysdig 0.29.3来跟踪系统调用.实验选取了Docker hub上下载量超过10亿的Linux官方容器进行了系统的统计分析,并选取4款最流行的容器对AutoSec进行测试.
根据容器的用途,可以将Docker hub上的官方镜像分为基础环境性容器和特定服务型容器.其中基础环境性容器可分为操作系统型(如Ubuntu)和编程语言型容器(如Python),特定服务型容器主要包括web服务型和数据库服务型,还有一些其他特定功能的容器,如表2所示.
表2 Docker hub 热门镜像Table 2 Docker hub popular images
操作系统型容器主要为作为构建其他容器的镜像基础,其镜像大小受其功能的影响有较大差距,Busybox仅有1.24MB大小,而Centos的镜像超过了231MB.编程语言型容器同样经常作为用户构建自身容器的基础容器,Docker hub中使用最多的3个编程语言容器为Python、Golang和Openjdk(Java),其平均镜像大小是所有类别中最大的,将近800MB.以Nginx为代表的web服务型容器以及Mysql为代表的数据库服务型容器是Docker在实际生产部署中使用最多的容器类型.
基础环境型容器由于其目的是作为用户在构建镜像或实验的基础,在实际运行中会尽量的保持其功能的完整性.在本文的实验中,选取了具有代表性的4款功能型容器进行测试,分别为Nginx、Httpd、Mysql和Postgres.其中Nginx和Httpd(Apache)是在实际生产部署中使用最多的Web服务器,数据库服务器选择Mysql和Postgres.
3.2.1 动态训练
动态分析引擎监控容器的初始化和运行共30秒,得到容器在初始化阶段的系统调用以及运行阶段时用到的可执行文件集合.动态分析的结果如表3所示,可以发现在初始化阶段,Web服务型容器使用的系统调用数目较多余数据库服务型,Nginx最多,使用了98个系统调用;但在容器运行的过程中,数据库服务型容器中用到的可执行文件数目往往多于Web服务型,其中Mysql最多,达到了57个,这也验证了数据库型容器镜像的大小往往大于web服务型容器.
表3 动态分析结果Table 3 Result of dynamic analysis
在容器初始化运行的过程中,系统调用执行的数量会达到成百数千个,图4展示了每个容器中调用最频繁的前20个系统调用,可以发现频率最高的几个系统调用为rt_sigaction()、read()、opennat()、mmap()等,rt_sigaction()用于更改进程在接收到特定信号时采取的行为,read()用于读取文件,opennat()用于打开文件,mmap()能够将文件映射到内存空间,然后可以通过读写内存来读写文件.可以看出,在容器初始化阶段,大多数系统调用用于对文件系统的访问以及信号的处理上.
图4 每个容器中使用频率最多的系统调用使用次数Fig.4 Most frequently used syscall usage per container
3.2.2 静态分析
静态分析引擎分析每个容器中提取的可执行文件的系统调用数,部分结果如表4 显示,在容器运行过程中,除了容器功能相关的程序之外,一些环境相关的程序也会经常用到,如find、env等.其中,对于每一个可执行文件,在运行时用到的系统调用数基本都在70以下,最多的为nginx,用到了62个系统调用,最少是env,仅仅用了15个系统调用.
表4 静态分析结果Table 4 Static analysis results
3.3.1 系统调用数减少
以前在攻击面减少的领域的工作主要集中在减少代码的数量作为改进的主要措施.相比之下,AutoSec不删除任何代码,而只是限制恶意容器可以调用的系统调用,它通过减少(潜在恶意)应用程序暴露的系统调用数量来减少来自于主机内核的攻击面.
实验通过监控容器运行时系统调用池的数量验证减少系统调用方面的有效性,如图5所示,在监控的60s内,容器启动时的前几秒,系统调用池的数量会上升,之后逐渐下降到一个稳定的范围内,大多数容器系统调用池里面的数量维持在60~100之间,与容器默认允许的将近300个系统调用相比,AutoSec可以有效的减少容器中70%以上的系统调用数量.
图5 系统调用池中的数量波动Fig.5 Number in the syscall pool fluctuates
3.3.2 有效阻止CVE
为了进一步验证AutoSec在减少容器攻击面方面的有效性,本文收集了近5年内可以通过系统调用进行漏洞利用的CVE,一共有75个.如CVE-2022-0185使用了fsconfig()系统调用,CVE-2022-0847使用了splice()系统调用,其中大约75%的系统调用只对应于1个CVE,19%的系统调用存在过两个CVE,7%的系统调用出现在3个CVE中.
Docker默认的Seccomp策略可以减少25个CVE利用威胁,应用AutoSec可以有效的缓解51个CVE对容器的安全威胁.
3.4.1 功能的完备性
为了进一步验证受限容器功能的完备性,模拟真实场景下一个受限容器与外界的交互的情况,在容器启动后,AutoSec使用基准测试工具对容器进行测试.CloudSuite[33]的网络服务基准测试是基于开源的社交网站Elgg[34].Elgg是一个基于PHP的应用程序,它使用MySQL作为数据库服务器,提供了一个运行在Web服务器上的流媒体服务器,本文使用CloudSuite来对Nginx和Httpd进行功能测试;对于MySQL和Postgres,实验分别选取了Sysbench[35]和Pgbench[36]对其进行基准测试.
除了基准测试外,本文还同时对容器进行手动的命令调试,以测试容器是否因为合法的命令执行而崩溃.在对容器一周的运行测试中,容器没有因为意外的命令执行而崩溃.使用AutoSec对容器进行安全加固基本不会影响容器功能的完备性.
3.4.2 性能的稳定性
对容器施加Seccomp策略,会对容器的性能产生一定的负面影响.由于容器默认启用了Seccomp过滤器,因此过滤规则的更改几乎不会影响应用程序的性能.此外,一个容器中的所有进程共享同一组Seccomp过滤器,因此在更新系统调用池时,只需要生成一个新的BPF程序结构来记录BPF指令,并删除旧的BPF程序结构即可.
本文使用上面提到的基准测试工具来测量容器的每秒服务数量TPS(Transaction per Second),图6展示了与不施加Seccomp策略相比,施加Docker默认的Seccomp策略与Autosec的策略在TPS上的减少百分比.可以发现,与不施加任何Seccomp策略相比,使用Seccomp策略导致容器的TPS都略有减少,Autosec的策略会略高于Docker默认的策略,原因是Docker默认的Seccomp策略虽然包含更多的Seccomp规则,但是AutoSec在运行中对系统调用的调度以及BPF策略的生成会增加系统的开销.但是与默认的策略相比,开销增加都在2%之内,基本不会对容器的正常运行产生负面影响,可以认为是能够接受的系统开销.
图6 施加Seccomp策略导致TPS减少的百分比Fig.6 Percentage reduction in TPS due to application of Seccomp policy
容器的安全性近年来备受关注,学者们做了不少的相关工作.在减少容器攻击面方面,Cimplifier[37]依赖于动态识别资源,然后根据分析的结果,在满足用户自定义约束的前提下将一个复杂容器分离为单一用途的容器,然后通过远程过程调用(RPC)实现分离容器之间的通信.Lic-Sec[39]结合了LicShield[40]和Docker-Sec[38]的工作,并在其基础上进行了改进,首先通过SystemTap动态跟踪内核操作,之后将所有的追踪结果转换为AppArmor规则,基于强制访问控制来增强Docker容器的安全性.
正如前文所描述的,所有必需的系统调用都不能单独通过动态分析来提取,特别是对于处理异常和错误的情况,它们通常不是公共执行路径的一部分.因此动态分析不能保证完全覆盖每个应用程序所需的所有系统调用.后来,Confine[13]、Chestnut[15]、RSDS[17]等工作将静态分析融入到系统调用的提取中,以补充动态分析覆盖率不足的问题,这在一定程度上缓解了容器运行时的意外崩溃情况.但是静态分析的结果受限于分析范围的把控,分析范围过大,得到的系统调用集合起不到很好的安全保护作用,分析范围过小,则无法弥补动态分析的短板.此外,无论是动态还是静态分析,受限容器的可用性都无法得到很好的满足,调试工具和一些应用程序无法使用,除了满足基本功能之外,容器内的程序几乎无法被调试.
AutoSec强调容器的安全性与可用性的均衡,主要灵感来自于Face-change[41]、AutoArmor[42]等按需调度所需功能进而减少攻击面的方式,相比于Cimplifier、Confine等工作,AutoSec不盲目的寻求最小的系统调用集合,而是使用动静态结合的方式尽可能的获取多的容器运行状态,之后使用按需调度的方式再赋予给容器系统调用,旨在保护安全性的同时,使用户感觉不到受限容器与普通容器的区别,仍然能够满足容器的大部分功能需求.
为了保护容器的安全,减少容器的攻击面,本文基于Seccomp BPF提出了一种容器系统调用限制策略自动生成和按需调度方法,并实现了该方法实现了系统原型AutoSec,AutoSec通过动态训练和静态分析相结合的方式来提取容器的系统调用,进而生成不同的Seccomp BPF规则,在容器生命周期的不同阶段,根据容器的功能需求动态的调度可用的系统调用.实验选取了Docker Hub中最为流行的4款容器,通过安全性和可用性两个方面对AutoSec进行验证,结果表明在能够接受的性能损耗内,并不损伤容器原有的功能下,可以有效的减少暴露在容器中70%以上的系统调用,有效的保护容器安全.