0 引 言
为解决软件部署中的环境差异问题,Linux容器技术被广泛应用于实现软件的带环境安装。相较于传统的虚拟机技术,容器技术只虚拟了部分操作系统层和应用程序层,而没有对计算机硬件层和操作系统内核层虚拟化,因此它不仅能够更快地启动,而且占用的系统资源更少。Docker是一款优秀的开放源码的容器管理引擎,它提供了更好的隔离和移植功能,能够高效地创建、传送和运行容器。根据容器部署调查报告
[1],仅2017年至2018年,世界范围内约75%的公司已经在Docker上进行了多种基础设施部署。
虽然容器技术在多个领域得到广泛应用,但也出现了许多安全问题
[2],如容器逃逸和提权攻击等。为了增强容器的安全性,已有几种安全策略已经部署到容器环境中,在保证容器正常行为的前提下,极大限制了恶意攻击者的行为。例如,Iptables
[3]管理对容器网络的访问控制;capability机制
[4]切割root权限,保证容器即使被攻击者控制,也将攻击行为限制在某一权限内;seccomp
[5]限制用户进程可用的系统调用列表,以防止攻击者滥用不必要的系统调用。
然而,对于任意给定的Docker容器,默认的安全配置仍然有一个较大的攻击平面。恶意攻击者对系统调用的滥用会对系统造成巨大威胁:CVE-2019-11503
[6]调用chdir产生提权攻击;CVE-2019-3901
[7]使用perf_event_open泄露隐私信息;CVE-2019-13648
[8]利用sigreturn系统调用触发内核漏洞。这是由于在x86-64体系结构中,最新的Linux内核提供了349个系统调用,而seccomp默认会对所有容器禁用44个
[9]系统调用。但通过测试发现,大多数容器的运行只需要100个以上的系统调用,而那些从来不会被使用、也不会被禁用的系统调用更有可能成为攻击的跳板。
针对上述问题,本文提出了基于系统调用限制的容器安全防护方案。该方案首先依赖于静态分析,以Docker镜像为输入,通过解析层级镜像结构,获取镜像中目标执行路径和目标二进制;然后以二进制为标准库建立从库函数到对应系统调用的映射表;最后将映射表和目标二进制中调用的库函数进行对比,即可输出目标镜像所需要的系统调用白名单。其中,针对容器镜像解析困难问题,通过解析Docker镜像的层级文件系统,建立dockerfile命令和镜像层的对应关系,并获取容器镜像中的目标执行路径和目标二进制程序;针对二进制标准库分析时库函数到系统调用的映射困难问题,建立面向二进制的映射表,解决了系统调用的识别、路径爆炸和调用节点回环等问题。
1 相关工作
对于容器进程和普通Linux程序,配置适当的安全策略会增强其运行时的安全性。但是,由于技术的限制,无法为每一个进程定制所需的规则,过量的配置不能起到真正的保护作用。同样,对于内核,如果配置了过多的安全策略会导致其运行时负载过高,影响工作效率。因此本部分相关工作的讨论主要针对容器安全及其安全策略去冗余、应用程序去冗余和内核去冗余3个部分。
1.1 容器安全及其安全策略去冗余
现有通过限制系统调用增强容器安全的解决方案主要依赖于动态分析。Wan等
[10]跟踪容器内运行的应用程序,并生成对应的seccomp规则。DockerSlim
[11]生成seccomp过滤器并从Docker镜像中删除不必要的文件。Lei等
[12]将容器运行分为两个阶段,然后使用基准测试工具HammerDB
[13]分别动态跟踪两个阶段所需的系统调用。Cimplifier
[14]使用动态分析将运行多个应用程序的容器拆分为多个单一用途的容器。Rastogi等
[15]通过符号执行提出了对Cimplifier的改进。
尽管动态分析可以找到大多数系统调用,但是其仍具有一定局限性。首先,由于动态跟踪的不完整性,跟踪时不能保证识别所有必需的系统调用。在执行过程中,缺少的系统调用就会挂起被seccomp保护的进程。其次,动态跟踪进程的不同路径需要构造条件严格地输入,特别地,用户进程通常调用标准库中的各种包装函数来调用系统调用,这会极大地增加跟踪的复杂性。
Ghavamnia等
[16]在此基础上提出了动态和静态结合的方法。动态启动一个容器,然后监控指定时间内容器中运行的所有二进制程序,最后使用静态分析方法获取二进制所需的系统调用。但是,由于不是所有容器中的二进制程序都在容器的启动阶段运行,那些只在容器运行期间调用的二进制程序所需的系统调用就可能不在最后的白名单之中。例如,对于Ubuntu容器镜像,其启动过程只会运行bash和少量的常用命令,其他常用命令,如ping,就不会在启动阶段运行。本文基于静态分析,通过解析层级容器镜像,提取目标程序,然后为标准库构建函数调用图,最后对比目标程序中调用的库函数和映射表就可以得到完整的容器所需的系统调用白名单。
1.2 应用程序去冗余
已有的应用程序去冗余方案主要基于在单个过程中删除不必要的代码。Mulliner等
[17]在程序加载时删除所有未导入的函数。Quach等
[18]改进了编译器,在程序编译时识别函数边界并移除不必要的函数。Agadakos等
[19]在二进制级执行依赖库的定制化,减少程序冗余。Porter等
[20]仅在请求时加载库函数,并将程序当前运行所需的库函数部分保留在进程地址空间。Qian等
[21]使用训练和启发式方法来识别可以从二进制文件中删除的基本块。Ghaffarinia等
[22]限制了二进制文件的控制流。Ghavamnia等
[23]基于低级虚拟机(low level virtual machine,LLVM)为二进制构建函数调用图,根据函数调用图的阶段分割点,获取各阶段所需的系统调用列表。但是,由于两个阶段的分割点是手工选取的,所以该方法的实用性较低。De Marinis等
[24]通过静态分析为二进制构建函数调用图,并从调用图叶子节点获取所需的系统调用。以上两种方法在面向容器分析时不能解析层级镜像结构,而且很难应用于多语言分析的情景,并且对于一些在代码中无法获取的环境相关的系统调用,这些方案同样不能获取。
本文在针对二进制程序定制所需系统调用时,采用匹配标准库中系统调用号的传值模式,并使用回溯法确定相关寄存器的值,设计了基于邻接矩阵的函数映射关系提取算法,解决了构建映射表时因调用关系复杂引起的路径爆炸和调用节点回环问题。
1.3 内核去冗余
现有针对内核去冗余的方案主要是根据用户要求对其进行自定义操作。KASR
[25]和FACE-CHANGE
[26]使用动态分析来识别内核中未使用的部分,并使用虚拟化机制将每个应用程序限制在其配置文件范围内。Kurmus等
[27]提出了一种通过自动生成内核配置文件来针对特殊工作负载定制Linux内核的方法。但是由于内核去冗余在解决容器安全问题时,会引入较大开销,所以本文方案主要对前两种去冗余安全进行研究。
2 系统架构
本文旨在为给定Docker容器自动化生成最小且完整的系统调用白名单,然后通过限制不需要的系统调用来有效减小给定容器的攻击面。
图1为本文系统框架。
如
图1所示,本文系统主要包括两个模块:基于层级镜像解析的目标程序提取和基于二进制标准库分析的映射关系获取。本文方案首先通过分析镜像的配置文件确定层间的父子逻辑关系,然后建立与dockerfile命令的一一对应关系,通过分析dockerfile即可准确定位镜像每一层的主要功能,最后通过导出镜像的文件系统,可以获得镜像中的目标二进制程序。
获取目标二进制程序后,需要分析该程序所需的系统调用,但是由于程序调用系统调用的规则复杂,且当通过库函数调用系统调用时,对应的库函数所需要的系统调用也无法获取,从而导致分析过程无法执行。为此,本文提出建立基于二进制分析的标准库映射表,核心思想是解决为二进制标准库建立从库函数到对应系统调用映射表时存在的系统调用号识别困难、路径爆炸和调用节点回环等问题。
3 基于层级镜像解析的目标程序提取
基于层级镜像解析的目标程序提取主要定位镜像中的目标执行路径和目标二进制文件。但是,存在如下问题:
1) Docker镜像的组织结构复杂,使用了具有层级结构的联合文件系统(UnionFS),且每一层的命名都是该层内容的SHA-256值,导致无法从每层的命名中推测该层的主要功能;
2) Docker镜像各层间存储时相互独立,可以被不同Docker镜像共享,所以镜像分析时需要确定每个镜像拥有哪些层,以及各层间的逻辑关系。
为了解决这些问题,本方案中基于层级镜像解析的目标程序提取主要解决两个核心问题:层级镜像中目标层的定位和目标层中目标程序的获取。
3.1 层级镜像中目标层的定位
为了定位目标层,我们首先介绍dockerfile、Docker镜像和Docker容器之间的区别。dockerfile是一个文本文档,包含用户可用于组装Docker映像的所有命令
[28]。在使用docker build命令之后,Docker逐行执行dockerfile中的命令并在镜像中创建文件系统。Docker镜像中的所有层都是只读的,而Docker容器是镜像的运行实例。在容器运行时,将在所有只读层之上创建一个可写层用于记录操作期间所有的更改,并且在容器关闭后,在可写层执行的所有操作都会消失。为了记录这些操作,需要重新将容器打包为镜像,从而使得容器再次运行时能恢复所有操作。
dockerfile命令和镜像层的对应关系如
图2所示。由
图2可知,当使用docker save命令导出镜像的静态文件系统时,每一层内都会有一个名为json的配置文件,该文件记录了本层ID(LayerID)和父层ID(ParentID),通过分析所有层的配置文件,即可建立镜像所有层的链式父子关系。又因为dockerfile中的每条指令都构成一个镜像层,所以dockerfile中的指令数就是目标镜像的层数。通过分析dockerfile的结构,可以确定目标二进制在镜像中的层号。为了分析dockerfile的结构,首先区分基础镜像(base image)和父镜像(parent image)。基础镜像提供了操作平台和相关环境,在其dockerfile中具有FROM scratch命令。基础镜像的dockerfile中ENTRYPOINT命令指示了容器开始的执行位置,并且平台中的功能由/bin目录下的可执行程序提供。对于父镜像,它们是基于基础镜像构建的具有特定功能的上层镜像,例如redis容器。父镜像安装软件的RUN命令位于dockerfile的目标层,通过定位该命令即可定位到镜像中的目标层。通常,对于使用ENTRYPOINT命令的父镜像,该命令中的可执行文件将是目标二进制文件,这可能会节省搜索时间,提高定位性能。
3.2 目标层中目标程序的获取
获取目标层号后,将其映射到镜像中的对应层即可定位目标二进制程序。通过比较镜像中每层和dockerfile中的对应命令,可以在镜像中定位目标层。如果该镜像是基础镜像,则可以在/bin目录和其子目录中找到目标可执行文件,即目标程序。对于父镜像,目标二进制文件存在于目标层的ENTRYPOINT或/bin目录和其子目录中。
4 基于二进制标准库分析的映射关系获取
基于二进制标准库分析的映射关系获取方法,需要重点解决从二进制标准库的库函数到对应系统调用映射表建立时,所存在的系统调用号识别困难、路径爆炸与调用节点回环等问题。与其他方案不同,本文不依赖于库的源代码,通过使用反汇编工具objdump将标准库反汇编为汇编代码,并且分析代码中的调用关系。本节主要解决两个核心问题:确定系统调用号;解决路径爆炸和调用节点回环问题。
4.1 确定系统调用号
在汇编程序中,系统调用通过将系统调用号传值给寄存器eax或rax,然后触发软中断切换到内核模式请求相关内核服务。传统的系统调用中断指令是int 0x80,可是在当前系统中,为了提高调用效率,该指令已被syscall和sysenter所代替。并且,通过观察发现,在将系统调用号传给eax或rax时,存在直接和间接等不同的传值模式,包括:
模式1: 直接将常量传值给eax寄存器;
模式2: 直接将常量传值给rax寄存器;
模式3: 将常量传值给另一个寄存器,然后再把该寄存器的值复制到eax寄存器;
模式4: 进行一些数值运算,然后将结果传值给eax(通常使用xor eax、 eax将eax值置0)。
为了确定代码中直接调用的系统调用,首先,根据系统调用号的传值模式,定义了4种对应的匹配模式,如
图3所示,并根据系统调用指令,如syscall,将程序代码切分为多个部分。然后,使用回溯法确定eax或rax寄存器的位置。定位到eax或rax所在行后,如果存在直接的常量传值模式,则将保留该常量并继续回溯另一个系统调用。如果eax或rax所在行存在另一个系统调用传值,则将以同样方式回溯该寄存器的值,直至获取精准的系统调用号。如果eax或rax所在行存在其他操作,如xor,则模拟计算结果,并将该值赋予eax或rax寄存器。
在获取eax或rax中存储的系统调用号后,将其与记录系统调用号和系统调用之间关系的系统文件unistd.h进行对比,即可获取对应的系统调用。然后使用一些特殊标记将这些系统调用插入到原始汇编代码中,例如,将syscall read标记为代码中read的系统调用。
4.2 解决路径爆炸和调用节点回环
在objdump反汇编的汇编代码中,每个函数名都包含在“<”和“>”之间。对于调用者函数,在函数名后会跟随“:”。因此,为了获取函数调用关系,形成函数调用图,首先,预处理汇编代码并保留特殊标记的相关行,如“<”、“>”、“:”和syscall标记。简而言之,对原始汇编代码进行了简单的优化并删除了对分析过程无关的部分,如行号和机器码等。然后,可以很容易获取一级函数调用关系,如A→B和B→C。为了获取完整的函数调用关系,如A→B、C,需要处理大量库函数和中间函数以及这些函数间复杂的调用关系,这样极易引起路径爆炸和调用节点回环。
1) 路径爆炸。在处理函数调用图的节点连接时,传统的图形连接算法需要打开、检查和匹配要分析的文件。即使在处理单个文件时,也必须考虑连接节点的所有调用函数,大量的函数和复杂的调用关系在构造函数调用图时就会造成路径爆炸,占用全部处理器和内存资源。
2) 调用节点回环。在节点遍历时,如果函数出现调回,则函数调用图中就会出现闭环,当进行节点遍历时,很容易陷入无限循环,空耗计算机资源。
为了解决这些问题,本文设计了基于邻接矩阵的函数映射关系提取算法,如
算法1所示。该算法为每个函数和系统调用分配一个唯一且连续的值(总计
N),并使用邻接矩阵表示调用关系,初始化为false,如第2和3行所示。第6行到第8行为第一次扫描,确定一级函数调用关系,如果两个函数之间存在调用关系,或者该函数与系统调用之间存在调用关系,则邻接矩阵中的值将设置为true。第二次扫描是最重要的部分。如果在函数
i和
j之间存在调用关系,则函数
j调用的所有函数都有可能被函数
i调用。因此,执行OR操作将函数
j中的所有调用关系分配给函数
i。为了完整起见,重复此过程,直至调用关系不再增加(这里我们选择重复100次,即
N=100),如第10行到第17行所示。最后的扫描将输出调用关系结果。如果函数
i与函数
j具有调用关系,则输出两个函数名称。该算法针对拥有上万个库函数和中间函数的libc库,可以在几分钟内生成完整的函数映射表。
至此,我们已经获得从库函数到对应系统调用的映射表,最后将映射表和目标二进制程序中调用的库函数进行对比,即可输出目标镜像所需的系统调用白名单。
5 实验结果与分析
5.1 测试环境和方案
实验平台为intel i5-8265U 1.6 GHz 4核处理器和8 GB RAM,其中操作系统为内核版本5.4.0的Ubuntu 20.04,GNU C库(glibc)版本为2.29。
为了验证本文方案的有效性,首先选取50个下载量最高的官方Docker镜像,根据功能将它们分为5个类别,镜像名和其分类如
表1所示。然后,通过对测试容器进行动态跟踪,并与本文结果进行对比,分析动态跟踪方案和本文方案的差异。在执行动态跟踪时,我们将尽可能遍历容器中的所有功能。最后,收集近6年和系统调用相关的70个CVE(common vulnerabilities and exposure,通用漏洞披露)数据库
[29]中的漏洞,通过设置白名单,测试相关CVE防御率。
5.2 结果分析与讨论
图
4~
8中,动态跟踪表示对所有测试容器进行动态执行,并使用ptrace和日志监控获取其生命周期所需的系统调用列表;本文方案表示在静态分析下获取二进制所需的系统调用列表。通过将动态跟踪结果与本文方案进行对比,可以发现为每个容器定制的系统调用白名单可以使该容器正常执行所有功能,且动态跟踪获取的系统调用列表是本文方案获取白名单的真子集。通过统计测试集中每个容器白名单中系统调用的数量发现,采用本文方案时,测试容器所需的平均系统调用数为127个(总数为333个),即对于每个测试容器,采用本文方案平均可以拦截206个系统调用。
对于不同类别的容器,采用动态跟踪方案和本文方案时所需的系统调用数量也不相同(如图
4~
8所示)。
图4展示了数据库容器所需的系统调用,采用本文方案获取的平均系统调用为121个,调用数量最少的容器仅使用93个系统调用,最多不超过160个系统调用;采用动态跟踪方案时,测试集中容器所需系统调用最多为136个,最少仅需70个系统调用。对于
图5中所示操作系统容器,这些容器都共享着相似的基础镜像,且这些基础镜像执行通用命令并几乎拥有所有的基础库,如果排除此类别,则采用本文方案时测试集的系统调用可以减少70%。
图6中显示了编程语言容器所需的系统调用,在本方案下,其执行过程最多需要156个系统调用。对于
图7中基础架构容器,采用本文方案时,其所需的系统调用数量相对比较稳定,均值和中位数比较接近。对于
图8中所示的开发运维容器,采用本文方案时,其所需的系统调用数量少于150个。
总而言之,通过为Docker容器设置所需的白名单,可以极大地减小其攻击平面,有效防御和系统调用相关的软件漏洞。另外,由于Docker容器在运行时默认加载了seccomp过滤规则,所以为容器定制白名单不会产生额外的性能开销。
为了进一步确定动态跟踪结果和本文方案结果差距的原因,本文手动调试了一些镜像,包括编译和运行模式、条件分支和未调用的函数,并分析了引起差异的原因。
1) 编译和运行模式。对于某些应用程序,其编译和运行存在不同的模式,其编译选项只在运行时确定,并且为了保证程序的正常运行,不同编译模式所需的系统调用都存在于代码中。但是,在进行动态跟踪时,只能获取一个或其中一部分分支所需要的系统调用,这就会错过对其他分支的分析,从而造成系统调用的遗漏。例如,redis在动态跟踪时,可以在使能tls模式下进行编译和运行,以增强传输安全性。
2) 条件分支。在应用程序中,各种分支实现不同的功能,所有这些分支构成函数调用图。但是在动态跟踪中,即使借助fuzzing技术(通过更改输入并多次执行应用程序)也很难覆盖所有分支。那些未被覆盖的分支所调用的系统调用就不会出现在动态跟踪的结果里,造成动态跟踪和本文方案结果的差异。
3) 未调用的函数。在程序的汇编代码中,某些函数与其他函数没有直接调用关系,这些函数的执行是通过运行时参数跳转到该函数的入口地址实现的。如果在动态执行期间没有其他函数调用此函数,则将无法获得该函数调用的系统调用。但是,在采用本文方案分析静态库函数时,将包括该函数调用的系统调用,这会导致动态跟踪结果与本文方案所得最终系统调用白名单之间的差异。
为了证明定制的系统调用将减少容器的攻击面,本文收集了最近6年和系统调用相关的70个CVE数据库中的漏洞,然后测试不同数据集对于设置系统调用白名单后的软件漏洞防御率,结果如
图9所示。由
图9可知,不同类别的容器贡献了不同的漏洞防御率。其中,操作系统容器的漏洞防御率最低,最少可以防御58%的软件漏洞,对于半数以上的测试容器,都可以防御超过64%的软件漏洞。编程语言类容器的漏洞防御最高,其中一半可以防御74%的漏洞。其他3个类别容器的漏洞防御率在60%至88%之间。因此,如果根据其系统调用白名单并利用seccomp来限制对容器的系统调用,则至少58%的漏洞无法被所有测试容器利用,而超过70%的漏洞不能被一半测试容器所利用。因此,通过提取和限制对应用程序的必要系统调用,可以防止应用程序可能的漏洞利用。
6 结 语
seccomp可用来限制进程可用的系统调用,但是,对于给定容器进程仍然缺少自动化定制所需系统调用白名单的技术。本文设计了一种基于系统调用限制的容器安全自动化防护方案,为给定容器自动化生成所需系统调用列表。首先,解析dockerfile并构建与镜像层的对应关系,在目标镜像层中定位目标二进制文件。然后,使用静态分析为依赖库建立从库函数到对应系统调用的映射表,并将其与二进制文件中的被调用函数进行比较,以获取被调用的系统调用。为验证本文方案的有效性,选择了下载量前50的Docker镜像作为测试集,分别测试其相较于默认系统调用列表的系统调用减少量和对于系统调用相关软件漏洞的防御率。实验结果表明,采用本文方案时,这些镜像所调用的平均系统调用数为127个。在限制可用的系统调用之后,可以阻止大多数和系统调用相关的软件漏洞。在今后的研究中,我们会进一步细粒度地分析容器不同阶段所需的系统调用,根据不同阶段定制白名单。
国家自然科学基金(61272452)
国家重点基础发展计划(2014CB340601)
湖北省重点研发计划(2020BAA003)
苏州市前瞻性应用研究项目(SYG201845)