今天为大家推荐的论文来自中科院计算所内构安全实验室投稿并发表在 USENIX Security 2024 上的最新工作HIVE: A Hardware-assisted Isolated Execution Environment for eBPF on AArch64。该工作提出了一种全新的软硬件协同的eBPF安全执行环境,来从根源上解决eBPF面临的安全性问题,同时也避免了现有基于验证的方法带来的状态爆炸问题,提升了eBPF程序的复杂性。
该工作将eBPF程序视为一种新型的内核态应用,认为内核需要一种基于隔离而不是基于验证的方法,并提出了一种用于AArch64上的eBPF程序隔离执行环境。为了提供等效的安全保证,该工作系统总结了eBPF验证器的安全目标,并将eBPF中的指针分为两类:指向BPF对象的兼容类型指针和指向内核对象的排他类型指针。对于前者,该工作将所有eBPF内存与内核解耦开来,并通过利用非特权访存指令来隔离eBPF程序中的内存访问;对于后者,该工作利用指针认证来强制内核对象的访问控制。实验评估结果表明,该工作提出的隔离执行环境不仅高效,而且支持复杂的eBPF程序。目前,研究组正在研究跨平台的隔离执行环境,助力相关的架构和系统研究。
该论文贡献如下:
对eBPF验证器的全面研究,该工作首次对验证器进行了全面的研究,总结了它的安全属性,并讨论了它在复杂性和安全性方面的问题。
一种用于eBPF程序的新型隔离执行环境,该工作在AArch64上提出了一种新的隔离执行环境,通过组合一组硬件功能来隔离eBPF程序,这些功能可以提供与静态验证等价的安全性。
实现和评估的新见解,该工作实现并评估了隔离执行环境的原型系统。结果表明,eBPF程序的动态隔离是可行的,可以代替基于验证的方法。
背景
Linux BPF(伯克利数据包过滤器,Berkeley Packet Filter)于1993年推出,主要用于在内核中创建网络数据包过滤程序。近年来,其扩展版本eBPF(扩展伯克利数据包过滤器,extended BPF)的提出为用户提供了一种革命性的技术。它使得用户拥有了将程序动态加载到内核中的能力,允许用户在不修改内核代码的情况下来自定义内核的行为。目前Linux eBPF已成为扩展内核功能的强大且流行的功能,除了原有的网络包过滤功能外,还被扩展到包括流量控制和跟踪、任意内核函数挂钩、跟踪和性能事件分析等。用户可以通过编写C代码并使用LLVM提供的BPF后端将其编译为BPF字节码,最后通过系统调用将其加载到内核中。
eBPF的框架如上图所示,当用户使用BPF系统调用将eBPF程序(字节码)加载到内核中时,内核中的验证器会首先验证该程序是否存在安全问题,如存在恶意的越界读写破坏内核,或者是存在潜在的越界漏洞。如果验证失败,内核将拒绝该程序的加载,从而保证内核安全。验证成功后,eBPF程序将被内核中的JIT编译器发射为本机指令(如ARM64指令)。
eBPF子系统为eBPF程序提供了寄存器、Maps存储、栈帧、上下文等数据结构和一组内核中的helper(辅助)函数。其中,BPF指令集是一种精简指令集,其寄存器被映射到特定架构中的通用寄存器中。Maps是内核为eBPF程序提供的一种数据存储特性,用户可以通过它来静态申请自定义大小的数据区域。Map的大小必须在eBPF程序加载时确定,Map创建成功后,用户态应用和eBPF程序都可以访问,从而实现它们之间的数据共享。eBPF程序的栈帧在内核栈上。当内核的函数调用eBPF程序时,eBPF函数的栈帧在当前内核栈的顶部。每次执行eBPF程序时,内核都会根据其程序类型准备一个上下文对象,并将该对象作为第一个参数传递给eBPF程序的入口函数。一般来说,不同eBPF程序类型的上下文对应于内核中不同的数据结构。eBPF程序可以调用内核提供的某些特殊函数——helper函数,这些函数通常用于获取内核信息、操作内核数据结构、与其他子系统交互以及操作网络包等。eBPF程序可调用的helper函数由其程序类型决定。
eBPF验证器(Verifier)是用来确保eBPF程序不会损害内核主要安全机制。上图给出了验证器的工作流程。它由三个连续的阶段组成,随着阶段的递进,其验证逻辑也变得更加复杂。其中,预处理阶段通过线性扫描的方法重定位eBPF程序中的所有重定位项,在此过程中还会检查程序中的指令格式是否合法,例如是否存在非法的操作码(如间接调用)。控制流图检查阶段通过深度优先搜索的方法,遍历eBPF程序的控制流图,并禁止所有的越界跳转和不可达的代码。验证器的绝大部份逻辑集中在最后阶段,该阶段创建并维护了一个状态机,用于探索eBPF程序中所有可能的指令路径,并在路径探索期间记录每条路径上所有寄存器和栈上变量的类型和范围。由于它遍历了所有可能的路径,因此在该工作中将其称为全路径分析。
前两个阶段的主要目的是确保控制流的安全,而最后一个阶段旨在验证eBPF程序是否符合一组安全属性。具体来说,在路径遍历过程中,每当分析到一条指令时,全路径分析都会根据当前的程序状态和指令的行为进行相应的状态检查与状态分析。该过程与KLEE等符号执行技术类似,通过将所有的输入和内存单元符号化来分析程序的可能的状态。
动机
许多研究工作已经利用eBPF来增强内核的功能,包括用户自定义内核各个子系统,加速用户态应用,提高网络性能和安全性,跟踪内核事件等等。然而,eBPF在其应用的过程中面临着两个问题,一是安全性问题,即恶意或存在漏洞的eBPF程序由于验证器的漏洞而逃脱了验证。二是复杂性问题,即合法的eBPF程序由于静态验证的能力有限而无法通过验证。
安全性问题
eBPF验证器是保证eBPF程序不会威胁内核的主要安全机制。如上图中左侧部分所示,自2014年推出验证器以来,其代码规模在迅速增长,以支持对eBPF新的功能的检查。例如,随着bpf_spin_lock()这一helper函数的引入,验证器需要保证eBPF程序在同一时刻仅包含一个锁,并在程序退出前释放锁。再如,为了支持eBPF程序内部的函数调用,验证器中添加了约500行C代码。最后,内核开发人员仍在积极开发新的验证功能(例如panic和断言等)。
然而,随着其功能的不断增加与变化,以及设计和实现一个健全和完整的静态分析工具的挑战,验证器暴露了大量的漏洞。如图右侧部分所示,自2014年以来,eBPF子系统中总共发现了60个CVE,其中在验证器中发现了39个CVE,这使得验证器成为了近年来一个重要的风险来源。相比之下,内核中不同平台下的eBPF JIT编译器共发现了3个CVE。
在eBPF推出时,默认非特权用户能够加载eBPF程序,但由于近年来静态验证带来的安全风险,Linux内核不得不在2019年关闭了该特性,除非系统管理员手动打开该配置,否则只允许特权用户加载eBPF程序。这一限制逃避了静态分析的安全问题,并且限制了eBPF的应用场景。
复杂性问题
eBPF已经应用在众多的内核场景中,该工作总结了近年来使用eBPF的相关工作,如表所示,包括使用eBPF扩展文件系统,定制化调度系统和内核中的锁,实现内核的热补丁等。然而,正如这些工作中提到的,他们在实践过程中都遇到了eBPF带来的复杂性问题。研究人员往往通过面向验证器的编程来使用一些技巧规避复杂性问题,例如在访存指令前加入掩码指令来帮助验证器降低验证的复杂性,手动循环展开来消除程序中的回边,将复杂的函数分解为几个简单的子函数等。即便如此,复杂性问题仍然是一个长期困扰eBPF用户的问题,并且仍然是许多相关工作强调的持续存在的问题。
复杂性问题的根源在于全路径分析阶段面临着状态爆炸问题,即在静态分析的过程中,每当遇到分支时验证器就需要复制程序的状态,而一旦程序中包含循环或者大量的分支,静态分析就无法工作,因此为了确保静态分析能够在一定时间内完成,验证器不得不对程序复杂性施加了一些限制,包括不允许程序编写循环,并限制程序中分支的数量和指令的数目等。
近年来,内核开发人员已经意识到复杂性问题并试图解决该问题。例如,eBPF在2017年引入了函数调用功能,从而允许eBPF程序的开发人员在编程时将复杂的函数分解为多个简单的函数,这些简单函数将分别进行函数内的分析。此外,经过大量的设计和尝试,内核开发人员在2019年支持了常量循环,在此之前,eBPF程序无法包含任何循环语句而只能强制循环展开。在2021年,eBPF引入了bpf_loop()等helper函数,允许用户将循环体封装到单独的函数中,并通过这些helper函数回调该函数。
虽然上述方案看似解决了复杂性问题,但由于它们打破了程序内部的状态传递,往往会加剧复杂性问题。以回调循环为例,由于这种回调风格的设计无法在循环体和循环条件之间传递程序状态,会导致静态分析过估计。此外,这种回调风格的函数还限制了函数调用之间的指针传递,因此循环体不能使用任何自定义指针。到目前为止,复杂的eBPF程序仍然无法通过验证。如内核开发者所说,eBPF的目标是替换内核模块成为扩展内核的实际方法,然而,目前为止,还没有任何一个内核模块被成功替代。
更糟糕的是,复杂性问题解决方案有时会带来额外的安全问题,因为它会对状态进行剪枝以减少待分析的状态数量。例如,在2023年,内核开发人员发现,由于bpf_loop()只检查第一次循环时的循环体状态,使得恶意的eBPF程序会利用后续循环进行越界访问。此外,由于该函数允许嵌套调用,因此还会导致拒绝服务攻击。通过结合eBPF尾调用机制,一个eBPF程序最长可以独占CPU运行数百万年之久。
总的来说,全路径分析已经成为了eBPF发展和应用的瓶颈。因此该工作针对eBPF程序的加固目标是解决eBPF中的上述挑战,通过构建一个隔离执行环境来解决eBPF的安全性和复杂性问题,以使eBPF更广泛的应用在实际当中。
理解静态验证
由于缺乏关于验证器的全面文档,该工作总结了验证器中的所有检查,并仔细检查了触发条件、检查内容、依赖关系和限制等。上图给出了验证器在全路径分析中执行安全检查的全景视图。bpf_check()是验证器的入口,它调用的do_check()包含全路径分析中的大部分安全检查。每当遇到指令时,它会根据指令的语义和所有操作数的状态(即类型和值范围)执行安全检查。图的两侧分别给出了安全检查对应的安全属性。
安全属性是验证器在实现层面确保的安全性。在此基础上,该工作还总结了设计层面的安全目标,如上表所示。所有的安全目标都与eBPF开发人员并得到了确认。对于SG-1,BPF程序只能访问自己的内存。指针之间的权限和类型不匹配可能导致非法内存访问,因此它们也与内存安全有关;对于SG-2,应防止信息泄漏,包括内核布局、未初始化信息和侧信道攻击访问的内核内存。此外,标量和指针之间的类型不匹配也可能泄漏内核布局;对于SG-3,由于BPF程序运行时禁用了抢占,并且eBPF不支持异常处理,因此应防止DoS攻击。
设计
该工作认为,从用户自定义代码的角度来看,eBPF程序不能简单地视为内核扩展。与用户态应用相对应,eBPF程序应该看作是内核态应用。用户态应用通过系统调用来请求内核服务,而eBPF程序通过helper函数调用来请求内核服务。与eBPF程序一样,用户态应用程序也需要隔离以保证内核的内存安全,并且它们采用的不是静态验证方法,而是基于硬件的动态检查方法。
在ARM架构中,基于EL的内存隔离机制禁止运行在EL0上的用户态应用程序访问内核的内存,这是因为内核内存页面被设置为特权页面。因此,如果可以使用类似的硬件辅助方法为eBPF程序创建隔离的执行环境,则静态验证中的一些繁重的检查任务可以由硬件辅助的动态检查代替,这也是该工作的核心思想。
具体来说,如上图所示,该工作首先将eBPF程序可访问的数据页都设置为非特权页面。随后,在JIT编译时将eBPF程序中所有的内存访问指令发射为非特权访问指令。由于eBPF程序在内核态(EL1)运行,根据AArch64种非特权访存的硬件特性,这些指令被视为在用户态(EL0)执行。因此,eBPF程序可以访问非特权页面上的eBPF数据,但不能访问其他的内核内存,这是因为内核原本的内存依旧是特权页面。值得一提的是,因为eBPF程序的代码对其本身来说是不可读写的,因此其代码所在的页面仍然是特权页面。eBPF程序中的非访存指令(如函数调用等)不受影响,仍然以EL1特权级运行,
这种指令粒度的执行区别称为基于指令级的内存隔离技术。该技术使得eBPF程序在与内核交互时无需切换特权态,因此避免了域切换带来的开销。
除了直接访问eBPF数据外,eBPF程序还会调用内核提供的helper函数并传入指向eBPF数据的指针作为参数。然而,AArch64下的PAN机制的存在使得这些helper函数无法访问eBPF数据,这是因为helper函数是内核的代码,其中的访存指令仍然是普通的访存指令。为了解决这个问题,该工作为eBPF数据额外创建了映射,称之为eBPF影子数据。这些页面都被设置为特权页面,因此helper函数可以直接访问它们。在eBPF程序调用helper函数前,相应的参数指针被重定位到指向影子数据区域。
通过对eBPF数据解耦并隔离,上述方法能够解决对eBPF数据对象的安全访问。然而,内核还允许eBPF程序直接通过内存访问指令来直接访问内核对象的某些特定字段。例如上下文对象作为一个内核数据对象,其中的部分子域是eBPF程序可以直接访问的,全路径分析会通过分析解引用的指针类型以及访存指令的偏移来判断被访问的子域,从而判定是否合法。然而,这种细粒度的内核对象访问控制对于非特权访问隔离来说是非常困难的。一方面,eBPF程序中所有内存访问指令都是非特权访存指令,因此无法访问内核空间中的任何对象。另一方面,由于无法区分哪条访存指令在访问哪个内核对象,因此也就无法判断安全与否。
在eBPF子系统中,安全检查与对象类型是强绑定的。即使在相同的安全属性下,指向不同数据类型的指针也存在不同的安全检查。例如,对于指向map中值的指针来说,eBPF通过指针的基址和偏移量来检查是否有越界访问,而对于指向eBPF上下文对象的指针,则是通过检查一个可访问偏移量的白名单。为了覆盖每一个安全检查,该工作总结了eBPF中所有的指针类型,从指针的产生点、指针传播和运算情况、以及指针解引用点来梳理了指针的生命周期,同时还根据这些指针的安全检查以及对应的检查点进行了分析。根据指针使用和安全检查的相似性,所有指针可以分为两类:兼容类型和排他类型。
两种指针的一个不同之处在于,兼容类型的指针是可计算的,例如对指针增加一定的步长,并且对解引用的访存指令没有限制,而排他类型指针是不可计算的,只能使用以指针附加访存指令中的常量偏移来访存。eBPF子系统中维护了一个离散的白名单,其中记录了每种eBPF程序类型能够访问的内核对象类型以及能访问其中的哪些子域,该常量偏移用于检查是否正在访问白名单中的子域。此外,由于不同类型的内核对象其允许访问的子域对应的偏移量不完全相同,因此一条访存指令如果被用于访问排他类型的对象,那么该指令只能用于访问这个特定类型的内核对象,而不允许访问其他类型的对象,这也是“排他”这一名称的由来。兼容类型指针则没有这个独占的限制。
基于这一观察,该工作利用指针认证硬件特性来确保排他类型指针的完整性,并使用常规的访存指令来提供访问内核对象的能力。其核心思想为,利用非特权访存带来的指令级隔离技术来通过异常的方式探测排他类型指针的访存使用点,即一旦触发崩溃,则说明eBPF程序有可能在解引用排他类型指针,探测到使用点后再进一步验证访存的安全性,验证通过后即可将触发异常的非特权访存指令重写为普通的访存指令,并利用指针认证硬件施加动态检查,从而保证后续访问不会触发异常且是安全的。该方法与兼容类型指针解决方案兼容,这是因为eBPF中不允许这两种类型指针之间的转换。
为了提供安全等价的隔离执行环境,该工作进一步设计了针对信息泄露和拒绝服务攻击的方案。与用户态应用类似,该工作为每个eBPF程序提供了独立的内存和地址空间,称为BPF空间。BPF空间中的内存被设置为非特权页面,存放着eBPF兼容类型相关数据。BPF影子空间和BPF空间分别按顺序放置在虚拟地址的最高位置,从而与内核地址空间布局随机化解耦。而对内核自身对象的直接引用(排他类型指针),该工作通过对其增加一层数据抽象,消除了指针信息。由于eBPF程序中的访存指令为非特权指令,因此其对内核的侧信道攻击与用户态应用相同,变成了Meltdown攻击,该攻击已由AArch64中的CSV2硬件补丁阻止。该工作还为eBPF程序增加了对应的执行时间检查机制与异常处理机制,从而阻止eBPF程序的非法状态危害到内核。
实验
安全性实验
该工作首先对安全等价性进行了分析,并利用已有的CVE,针对验证器中实现层面的安全属性进行了点对点的攻击。具体攻击属性如下表所示。除此之外,该工作还针对隔离执行环境可能存在的新的攻击面进行了攻击,包括滥用指针探测、指针认证攻击以及硬件配置。
性能实验
该工作首先评估了访存指令的性能,如下表所示。可以观察到非特权访问指令与普通的访存指令几乎一样快,这也意味着有较高的隔离效率。实验还测量了指针认证中与签名和验证操作相关的指令的性能,可以发现相关的指令开销较低。实验还测量了调用helper函数所产生的额外开销。结果表明,helper函数参数的延迟相对较小,并且与参数的数量弱相关。实验还测量了切换E0PD、切换页表的开销。此外,由于eBPF程序的起始处还插入了初始化相关令,实验对其一并进行了统计。从实验数据中可以观察到,这三个操作都是比较高效的。
为了评估实际应用程序的开销,实验通过提前加载BPF程序,然后运行应用程序以触发BPF程序的执行。实验共使用了Tracee和BCC提供的168个eBPF程序,先后运行在原生eBPF和隔离执行环境中,并测量了包括Nginx在内的4个真实应用,实验证明隔离执行环境的开销较低。
实验还测量了eBPF程序执行的cycle数,可以看到随着程序指令数目的增加,隔离执行环境带来的开销越来越低。
复杂性实验
为了验证隔离执行环境对程序复杂性的提升效果,实验选择了10个内核中现有的内核模块,将其编译为eBPF程序加载至内核中,由于静态验证的限制(循环、分支等),这些模块内核均无法通过验证器的验证。而隔离执行环境则可以正确运行它们。
实验还测试了加速文件I/O请求的实验。原始程序通过pread()系统调用迭代地将文件读取到用户空间缓冲区。而通过将系统调用密集型代码编译成BPF程序,并将其加载到内核中。系统调用被转换为helper调用。验证器拒绝了该程序,因为循环的迭代次数无法静态确定,而隔离执行环境可以成功运行。实验逐渐增加了每次读取的缓冲区大小,并评估了加速比。如下图所示,当I/O请求的大小较小时,基于隔离的eBPF能够将基于系统调用的I/O加速到181%。
slides:https://www.usenix.org/system/files/usenixsecurity24_slides-zhang-peihua.pdf
王喆,中国科学院计算技术研究所处理器芯片全国重点实验室副研究员。长期从事系统安全、操作系统、编译和体系结构的研究。研究成果发表在IEEE Security & Privacy、CCS、USENIX Security、USENIX OSDI等安全和系统领域的国际顶级会议/期刊上,并获得CCS杰出论文奖和最佳论文提名奖。带领团队研制的多款实际系统,落地应用到开源社区、企业等单位中,在现实任务中发挥重要作用,受到广泛好评。