还在为挖不完的漏洞、打不完的补丁苦恼吗?不妨通过分隔化为潜在漏洞构筑安全“水密舱”,保障系统软件乘风破浪。
一起来回顾下郭迎港在SDC2024 上发表的议题演讲:《BULKHEAD:通过分隔化打造内核安全的水密舱》
郭迎港:南京大学计算机学院博士生
师从曾庆凯教授,曾赴美国明尼苏达大学Prof. Kangjie Lu研究组做访问学者。主要研究方向为操作系统内核安全,包括内核特权分离和内核模块分隔化。研究项目基于最小特权原则增强内核安全,通过形式化建模分析各种特权分离方案的安全效果,并利用新的硬件特性结合程序分析技术实现内核模块的安全高效分隔化,限制内核漏洞的影响。相关成果发表于NDSS、ACSAC、ESORICS、软件学报等会议和期刊。
*以下为速记全文
各位老师好,我是来自南京大学计算机学院的25届博士生郭迎港,今天很荣幸有机会和大家分享一下我们在操作系统内核安全方向的一些探索,议题题目叫BULKHEAD,也就是船舱与船舱之间隔板的意思。
接下来我们就看看如何通过在内核里边增加隔板来构建水密舱,从而有效地防止漏洞威胁的蔓延,避免整个系统的倾覆。
今天的报告主要分为这几个部分,首先我们介绍一下内核分隔化的背景,然后通过漏洞威胁来分析为什么需要分隔化,接着分享我们的解决方案以及它的实验效果。最后和大家开放性地探讨一下,除了操作系统内核,分隔化思想还能应用到哪些场景当中。
内核是整个操作系统的基石,很多厂商现在也都有自己定制化的内核,那么从架构上来讲,可以分为两个大的阵营,宏内核以及微内核架构。
以Linux为代表的宏内核,它的各个内核模块是处于同一地址空间当中,共享权限,模块之间可以高效地交互,但是缺乏故障隔离机制。任意一个薄弱的模块,特别像驱动,假如说出现一个漏洞,攻击者都有可能从这个漏洞出发,逐步地攻陷整个内核,颠覆整个系统。
相反,微内核把很多的内核模块分隔到不同的用户态进程当中,但这样一种安全隔离是以性能为代价的,因为模块之间的交互现在变成了进程间通信IPC,它需要陷入内核空间再返回,这样一来整个系统的开销就会有显著的增长。
如何来实现既安全又高效的内核架构呢?鸿蒙选择了一个微内核的技术路线,进行了全栈的自主研发,很大程度上去提升微内核的性能表现,具体的一些技术细节,推荐大家可以去读一下陈海波老师在今年OSDI上发表的一篇介绍鸿蒙微内核设计的论文。我们今天主要关注沿用宏内核架构的那些系统,特别是基于Linux Kernel的系统,对于这样一些系统而言,有没有机会以比较小的工程代价来获取安全保障上的提升呢?内核分隔化就是这样一种通用的、基础的防御解决方案。
我们类比造船的思想,通过增加一些隔板,把内核模块分隔到互不可信的安全域中。它不像微内核那样隔离到用户态进程当中,而是在内核内部,在内核的同一特权层,把这些模块分隔到单独的一个我们叫水密舱当中。这样一来,假如说薄弱模块出现一个漏洞,它的威胁仅仅会局限在单一的水密舱中,不会去影响其他的子系统,整个系统就不会被攻击者轻而易举地颠覆掉。
大家可能会觉得再怎么限制漏洞的影响范围,它不照样还能够被触发利用吗?值得注意的是,一次完整的攻击往往是由多步链接组成的。攻击者从最薄弱的模块如驱动入手,但是真正有价值的目标常常是在其他的内核模块,比如说文件系统、进程调度。如果我们做了细粒度的分隔化,就可以有效地阻断攻击链。即使单一的漏洞被触发,攻击者也无法实现攻击目的。
接下来我们就通过一些漏洞威胁来进一步地分析分隔化的必要性。
随着像fuzzing这样的漏洞挖掘技术的不断发展,可以看到近年来内核漏洞的数量不断增长。并且内核仍然在不断的迭代,代码量还在不断的膨胀。可以说想要消除掉所有的内核漏洞显然是一个不现实的问题。那么除了被动地去做漏洞响应之外,我们亟需考虑如何主动地来应对潜在的漏洞威胁。
从类型上来看,数量最多的漏洞类型仍然是内存损坏,接着是溢出以及输入验证相关的一些逻辑漏洞。这些类型的漏洞其实都可以通过对内存做分隔来进行有效的防控。
除了数量上层出不穷,我们可以看到 Linux Kernel的漏洞在各个子系统之间的分布也是非常广泛。各个模块多多少少都会有漏洞分布,虽然说网络驱动是最薄弱的,但是主内核的问题也不容忽视。因为我们没有办法预测下一个漏洞会出现在哪里,内核分隔化就应该考虑互不可信的威胁模型下的双向隔离。
刚才提到的主要是一些潜在的未知漏洞,其实对于已知的漏洞,修补也是非常困难的。我们做了一个统计,Linux kernel的漏洞从发现到补丁真正并入主线,平均花费66天的时间。如果我们再考虑供应链上下游的传播,一个补丁从主线到各个发行版,再到下游厂商自己定制的内核,传播时间会更长。这样一个漫长的漏洞修复时间窗口就给了攻击者发动N-day攻击的机会。分隔化可以及时地把出现问题的模块先放进一个沙箱环境里边去,限制它的影响,从而为我们进一步的根因分析、补丁开发来争取时间,避免N-day攻击的破坏。
总结一下,面对层出不穷,分布广泛,修补困难的漏洞困境,内核分隔化可以对已知漏洞起到一个防微杜渐的作用,对未知漏洞则可以防患于未然。
隔离其实是一个非常传统的安全话题,我们先来比较一下各种隔离机制。
基于软件插桩检查的隔离也就是SFI为每一条访存指令插入检查来确保访存地址在合法范围内,大量的检查会显著降低系统性能。基于地址空间的隔离会用多套页表为不同地址空间构造不同的内存映射,但地址空间切换相较于函数调用也很耗时,并且跨地址空间传输数据需要进行复杂的拷贝。基于虚拟化的隔离通过二级页表也就是Intel的EPT构建不同的内存视图,vmfunc指令也支持高效的EPT切换,但这种方案依赖于更高特权层的hypervisor,引入了新的TCB也就是可信基,并且在云环境下还会面临嵌套虚拟化带来的挑战。相比而言,基于硬件机制的隔离可以通过软硬结合更好地平衡安全与性能,既不引入新的特权软件,还能实现高效访问控制和隔离域切换。
接下来就介绍一下我们基于Intel PKS特性的内核分隔化方案。这是整体的系统架构图。Intel PKS即Memory Protection Keys for Supervisors,是针对内核态页面的MPK变种,从12代酷睿和4代至强处理器开始支持。
从原理上讲,PKS使用页表项中的4位作为pkey也就是一种tag,从而可以将内存划分为2的4次方——16个不同的域。以一个新的per-thread寄存器PKRS对这些内存域进行访问控制,每个域都对应设置WD位即write-disabled不可写和AD位,access-disabled,不可读也不可写。
而我们就基于这一机制对Linux Kernel进行分隔。每个内核模块都用不同的pkey标记,LKM代表loadable kernel module。我们在配置内核时,选择为Y的功能就会编译进主内核,而选择为M的就会生成一个.ko文件,作为LKM加载至单独的分隔域中。当处于主内核域时,PKRS寄存器对应第一行的值,它设置主内核对其他内核模块的访问权限为10即不可写,所有的内核代码则设为01,既不可写也不可读,也就是我们稍后要介绍的应对控制流劫持的仅执行内存,自身内存资源则可以随意访问。通过更新PKRS寄存器可以高效实现分隔域切换,比如切换至其他行代表的内核模块就又会限制对主内核的访问权限,从而实现互不可信的模块间的双向隔离。
但记得我们开头强调过,宏内核原本是共享权限的,要是任意模块都能修改页表中的pkey或者更新PKRS寄存器的话,这种分隔化不就会被轻易打破吗?
为此,我们在内核内部通过特权分离构造一个in-kernel monitor,这是一个特殊的内核模块,只有它可以安全地更新页表和权限控制寄存器,并通过SGT即switch gate table中记录的元数据监管分隔域之间的切换和交互。
那么这个monitor是怎么构建出来呢?具体而言,特权分离的核心有两点,即管理内存资源的特权和管理寄存器资源的特权。
对于内存资源,我们确保页表页标有monitor域对应的pkey,这些页面对于其他域而言不可写,并为此hook所有页表分配和更新函数,切换PKRS至monitor,由monitor唯一负责页表管理。
而寄存器资源则涉及一些特权指令的执行,比如更新PKRS的wrmsr、加载页表基址的mov-to-cr3。我们必须消除monitor之外其他内核模块中的这些特权指令。由于x86是一个非定长的指令集,指令消除就必须考虑符合正常指令边界的intended指令,以及意图之外形成的unintended指令。
我们将正常指令流中的intended指令替换为跳转至monitor的switch gate,由monitor代为安全执行。而unintended指令则通过二进制改写消除掉。
以更新PKRS的指令0F30为例,假如其跨指令边界形成,则可以通过插入nop指令或者调整指令顺序来消除掉。
假如其由一条更长指令的寄存器域构成,则可以通过寄存器重分配,把rcx改用rdx来消除掉。
假如unintended指令由立即数组成,就可以进行数据调整。综合这些等效指令替换策略就可以保障只有monitor能够执行更新特权寄存器的指令,而其他内核模块不能借此破坏分隔化。
接下来我们看看内核分隔化设计如何全面应对各种安全威胁,包括数据篡改、控制流劫持和特殊的混淆代理攻击做好特权分离。
第一种典型威胁就是数据篡改,比如通过修改cred、file、inode等安全敏感数据对象实现提权攻击。
为此,分隔化保障了数据完整性。我们为每个分隔域实现了基于内存池的私有堆和私有栈。这些内存池都标记好了各自域的pkey,代表了内存对象的唯一所属权。通过hook内存分配函数确保每个域都使用属于自己的私有内存。
这样一来,堆漏洞和栈漏洞就都无法篡改其他域的数据。比如由于pkey不同,域1的堆溢出在试图影响域2时就会触发访存错误。
那么当域1真得需要向域2传递数据时怎么办呢?
对于共享数据,在首次跨域访存尝试触发错误后,我们在page fault handler中检查数据的合法性,检查通过后由monitor更新pkey即数据所属域,从而实现零拷贝的数据传输。
第二大类安全威胁则是控制流劫持,包括最传统的恶意代码注入和代码复用攻击,比如ROP以ret指令串联起代码中的若干gadget片段从而实现攻击。
作为应对,我们选择了比控制流完整性CFI更加轻量高效的仅执行内存。代码区域不可写使得攻击者无法注入恶意代码,不可读再加上地址空间随机化使得攻击者难以找到有效gadget的位置来发起代码复用攻击。
最后介绍一种特别值得关注的混淆代理攻击,即攻击者模块利用接口混淆其他模块代其执行恶意行为。
这里看一个抽象的简单示例。假设LKM1不能访问LKM3内存而LKM2可以。但LKM1可以调用接口函数lkm12_cpy来将size大小的mem1拷贝至mem2。
那么假如攻击者控制了LKM1,他就可以通过传入恶意参数,使得mem2实际指向LKM3的内存或者size溢出至LKM3的内存,从而混淆LKM2代理其破坏LKM3。
乍一听,大家可能会觉得这个例子有些奇怪,内核中对于这种类似内存拷贝函数的参数怎么会没有安全检查呢?
这其实是由于分隔化造成了信任边界的演化,进而引入了新的攻击面。在宏内核架构下,LKM1、LKM2、LKM3共享权限,互相信任,lkm12_cpy作为一个内核内部的函数并不暴露给攻击者,他的参数也不是攻击者可控的数据,自然不需要像copy_from_user那样执行复杂的安全检查,一些检查也可以在接口调用前由LKM1进行。
但在分隔化之后,模块之间互不可信,lkm12_cpy成为了直接暴露给攻击者的接口,LKM1就可以不遵守调用前的约束传入任意恶意数据,最终造成混淆代理攻击。
为此,我们提出了一种全新的安全属性即隔间接口完整性。它一方面保障隔间切换只能发生指定的出入口点,另一方面使得数据交互必须严格遵循安全策略。这样一来,我们对跨域接口进行基于安全策略元数据的检查,从而防止恶意参数造成混淆代理攻击。
具体而言,在内核模块运行前,我们基于LLVM对其进行静态分析以生成安全策略。分析流程又分为三个阶段,首先提取模块边界,即以exported symbol、函数指针等形式暴露的接口,接着分析通过接口函数会被边界两侧都访问到的共享数据,第三阶段则沿着这些共享数据的定义-使用链进行数据流分析,生成用于接口检查的安全约束。生成的安全策略元数据将被注册进由monitor保护的switch gate table(SGT)中。最后通过LLVM插桩在接口处插入switch gate。
而在运行时,switch gate会根据gate id查找SGT,根据SGT中的metadata验证交互双方信息,检查通过后才原子性地切换至目标隔间。
这里展示了switch gate伪代码以及SGT中元数据的格式。每条元数据以gate id作为索引,记录了source和target的地址、地址空间、权限控制寄存器PKRS值以及私有栈指针。
write_pkrs更新寄存器意味着隔间切换,但在此之前我们先读取元数据验证源头信息的合法性,并通过对比当前值和目标值避免冗余更新。在更新后我们再次对比目标值进行检查,切换私有栈,最后直接跳转至元数据记录的目标地址。
在更新PKRS后为什么要进行二次检查呢?这其实是为了确保这段switch gate的原子性,防止攻击者不执行前面的元数据读取和验证,直接从中间执行write_pkrs更新恶意目标值。
而确定性则意味着switch gate的行为由gate id唯一确定,而不受其他攻击者恶意参数影响。接口的target信息都是从受保护的元数据中读取的,而不是攻击者传入的。
最后排他性则受益于我们之前介绍的二进制改写和仅执行内存。整个内核只有switch gate包含了PKRS更新指令,其他地方都被二进制改写消除掉了,攻击者也没办法引入新的指令不经由switch gate进行隔间切换。
Switch gate的这三种属性共同保障了隔间接口的完整性。
大家可能注意到这段代码中还有一段负责地址空间的切换,这又是怎么回事呢?这就引出了我们的设计在全面安全保护之外的另一点可扩展性上的创新,即两级分隔化。
PKS特性4位的pkey最多支持16个不同的分隔域,但Linux Kernel单论驱动就成百上千,只有支持更多的分隔域才能以更细的粒度限制漏洞的影响。
但一个好消息是不同的地址空间可以复用pkey,每新增一个地址空间就可以新支持16个分隔域。
但问题又来了,地址空间切换要比PKRS寄存器更新的开销大很多。这里展示一些具体的数据,一条PKRS寄存器更新指令在我们的实验机器上需要185个cycle,比基于虚拟化做隔离的vmfunc指令更快,而考虑隔间接口完整性的整个switch gate花费224个cycle,一旦再涉及地址空间切换,开销将翻倍变为523个cycle。
那么有没有办法既增加分隔域数量又尽可能避免地址空间切换呢?
我们观察到内核模块间的交互是呈现局部性,通过执行depmod指令可以发现绝大多数的内核模块仅仅依赖于少于10个其他模块,这就给了我们做性能优化的机会。
为此我们进行了一个两级分隔化的设计。第一级就是同一个地址空间内基于PKS的隔离,第二级则是局部性感知的地址空间切换。所谓局部性感知体现在地址空间划分上,我们考虑内核模块之间的依赖关系去做整个内存空间的划分,给它划分成共享区域和不同的私有区域。像这幅图展示的,因为主内核和monitor是所有的内核模块都要用到的,我们给它放到一个共享的区域里边去。而其他的模块,比如说这里LKM1/2是一组依赖关系,而LKM3/4是另外一组毫不相干的依赖关系,这两组依赖关系就可以分处两个不同的私有地址空间,通过不同的内存映射来确保他们可以复用同一个pkey。
首先看安全效果,我们这里选取了一些有代表性的内核CVE,一方面类型多样,包括UAF、堆溢出、栈溢出、整数溢出以及和输入验证、missing check相关的一些逻辑漏洞。另一方面这些CVE也是分布于不同的模块。可以看到通过分隔化,可以有效的把这些CVE的影响限制在我们的水密舱当中。
那么在性能上,这个Phoronix测试套件反映了内核整体的性能开销。即使是分隔160个模块,平均开销也仅在2.44%。这对于内核隔离所取得的安全收益来讲,其实是一个非常可观的性能表现了。
聚焦到某一个子系统而言的话,我们就看ipv6子系统去跑ApacheBench。不管隔离多少个模块,不管传递多大的文件,开销也都始终维持在2%左右。这里也是和之前曾经取得过顶会杰出论文的一个分隔化工作做了一个直接的比较,可以看到我们的性能表现要比之前工作好很多。
内存开销的话微基准测试在1.66%,宏基准测试是0.63%,对于现在的机器而言也都是可以接受的。
最后也是想开放性地和大家探讨一下,除了操作系统内核,这样一种分隔化思想还能应用到哪些场景当中去?
那么第一个场景是TEE内部的分隔,包括上午讲到的端侧的TrustZone,以及现在云侧机密虚拟机的各种架构,像Intel的TDX、 AMD的SEV、ARM的CCA等。随着大家对数据隐私越来越关注,大量的计算负载都想塞到TEE里边去,造成了TEE在不断的膨胀,里边放了不同厂商的各种应用,那么这些应用之间其实是需要一个分隔化的设计去防止TEE内部一个应用出现问题以后去影响整个TEE,通过分隔化也是可以做到最小化可信基。
然后第二个场景是多语言系统的分隔化。现在都追求用Rust重写一切,但是重写一切也不是一蹴而就的。在很长一段时间内,我们可能用的都是Rust和传统的内存不安全语言的模块并存的一个系统。那么在这样一个系统当中,Rust模块和传统的遗留模块之间也是要做好分隔的,包括Rust的内部unsafe部分和safe部分之间也是要做好隔离的。
接下来讨论的一个场景是AI OS的分隔化。现在的大模型和ChatGPT刚引入时的聊天机器人已经不同了,现在讲各种的智能体,它要和多种多样的应用程序以及OS进行深度的融合,去完成各种各样复杂的任务。在完成这个任务的过程中,也需要把我们的大模型和host OS以及其他应用程序之间做好访问权限的控制。
这里简单举一个小例子,在ChatGPT刚引入代码解释器时就发生过一个问题,通过在提示词里边传一个shell指令来造成代码解释器的RCE。一个ls指令直接就把host机器上面的文件列出来了。很显然我们的大模型、代码解释器和host机器之间是要有一个隔离设计的,比如把它放在沙箱环境里边去执行。这样另一个方向上也防止了一些恶意的应用程序去窃取大模型。
最后假如说大家有兴趣想在一个新的场景下,想在自己的系统上,通过分隔化去做一些安全增强,怎么入手?我建议大家分步去考虑这样三个问题。
第一个问题我们应该用什么机制作为隔板?推荐大家去关注一些基于tag的硬件机制,比如说Intel PKS、ARM MTE。然后第二个问题,想要把隔板放置在哪里?需要做一些模块边界的分析。最后一个问题如何放置隔板?最主要的就是要考虑模块间交互接口的一个安全设计,谢谢大家。
峰会议题PPT及回放视频已上传至【看雪课程】:https://www.kanxue.com/book-leaflet-195.htm
【已购票的参会人员可免费获取】:我方已通过短信将“兑换码”发至手机,按提示兑换即可~