LWN:用户空间的影子堆栈!
共 5859字,需浏览 12分钟
·
2022-03-09 19:46
关注了就能看到更多这么棒的文章哦~
Shadow stacks for user space
By Jonathan Corbet
February 21, 2022
DeepL assisted translation
https://lwn.net/Articles/885220/
对系统进行攻击的时候,为了攻破运行中的某个进程,攻击者最喜欢的目标就是 call stack 了。只要他们能找到一种方法来将 stack 上的返回地址(return address)改写掉,那么就可以将系统的控制权重定向到他们精心选择的代码上,从而可以让 "游戏结束"。因此,人们在保护堆栈这个方面做了大量的工作。其中一种很有希望的技术就是影子堆栈(shadow stock),于是在各种处理器里面都开始添加影子堆栈的支持。要想利用影子堆栈来保护用户空间的应用程序,则还需要更多时间。目前内核社区内正在进行这个话题的讨论,但看起来比人们预想的要棘手。此外,由于这些补丁已经存在挺长时间了,所以它们自己也出现了一些向后兼容问题。
Shadow-stack basics
每当一个函数调用另一个函数时,被调用函数里的信息,包括所有参数以及函数完成工作后应该跳转回去的地址都会被放到调用栈(call stack)里。随着函数调用逐层深入,堆栈中的返回地址的数量也在迅速增加。通常一切都能按部就班地进行,但只要堆栈内容有任何一点损坏,都可能会导致一个或多个返回地址被改写掉,从而导致 CPU 执行代码跳转到一个意料之外的地方。运气好的情况下也会导致应用程序崩溃,运气不好的话,也许这个错误的数据是故意准备好的,那么系统的执行过程继续下去就会导致更加棘手的问题。
shadow stack 试图给堆栈创建一个副本来解决这个问题,这个副本里面(通常)只包含返回地址这类数据。每当一个函数被调用时,返回地址会被同时写入常规堆栈以及影子堆栈中。当该函数返回时,返回地址需要从两个堆栈中都提取出来进行比较,如果不匹配的话系统就会给出红色警报,并(可能)kill 掉相关进程。影子堆栈可以完全用软件方式来实现,尽管影子堆栈也是可被改写的,但也也提高了攻击者的攻击门槛,他们现在必须要能破坏两个内存区域了,其中一个不容易确定是在什么位置。不过,硬件支持使影子堆栈的话可以使其更加强大。
英特尔处理器(还有其他一些厂商)就可以提供这种支持。如果一个影子堆栈正确建立起来(这是一个特权操作),那么后续将返回地址入栈的操作以及在函数返回时进行比较的操作全都是由 CPU 硬件自己完成的。同时,影子堆栈通常不能被应用程序写入(只有通过函数调用以及 RET 指令方式才能写入),因此不会被攻击者破坏。硬件还要求影子堆栈本身要有一个特别的 "restore token",用来确保两个进程不会共享同一个影子堆栈,因为这种情况也被利用来作为攻击突破口。
Supporting user-space shadow stacks
当前版本的影子堆栈支持 patch 是由 Rick Edgecombe 发布的,其中大部分 patch 本身是由 Yu-cheng Yu 编写的,这项工作的许多早期版本都是他发布的。要启用这个功能需要 35 个规模很大的 patch,而且这个问题还没有完全解决。人们可能会想知道这里有什么难点,毕竟影子堆栈似乎是一个大多数代码中几乎可以完全忽略的功能,但生活从来没有那么简单。
可以想到,内核里必须要准备代码来管理用户空间的影子堆栈。这其中包括在处理器上启用该功能、为每个特定的进程来进行处理。每个进程都需要有自己的影子堆栈,并设置好自己的 restore token,然后修改影子堆栈指针寄存器(这是个特权操作)来指向它。还需要处理那些 fault,包括正常的 page fault,也包括比如违背了完整性校验的那些 integrity-violation trap 等。还需要管理许多关于上下文切换的信息。对于这种新功能来说,这些都是很正常的工作。
为影子堆栈本身所分配的内存空间必须要特别对待。它是属于用户空间的,但通常不允许用户空间的代码对其进行写入。处理器也必须要能识别出这些专门用于影子堆栈的内存,所以在页表中要有特别的标记,这就导致事情变得有点复杂了。在每个页表项(PTE, page-table entry)中预留了许多 bit 用来描述相应的保护措施以及其他各种状态,但 X86 架构定义中未包括影子堆栈相关的 bit。这里有一些 PTE bit 是留给操作系统使用的,Linux 并未用完所有的 PTE bit,因此可以为这个目的而留出一个 bit,但显然有一些其他的操作系统中没有多余的 PTE bit,所以如果为这个目的来占用一个 bit 的计划并不受欢迎。
硬件工程师得出的解决方案看起来可能有点 hack。如果某个 page 的 write-enable bit 是 0(表明它不能被写入),但 dirty bit 又是 1(表明它已经被写入过了),那么 CPU 就判定这些 page 是影子堆栈的一部分。因为这个组合在正常使用中应该是没有意义的,所以这种做法看起来很合理。
不幸的是,Linux 内核开发者们在许多年前就得出了类似的结论,所以 Linux 对 PTE bit 出现这种组合的情况已经有了自己特有的解释。内核用这种方法来标记写时复制的页面(copy-on-write page)。如果某个进程试图写入该页,因为缺乏写入权限所以会触发一个 trap,而 dirty 位是 1 就让内核知道需要对该页进行一次复制,并把写入权限赋予这个进程。这个机制一直运行得很好,但是现在 CPU 开始对这种组合给出了它自己特有的解释。所以大部分的 patch 都是用来为一个新增的 _PAGE_COW flag 来寻找一个尚未使用的 PTE bit,并修改内存管理代码从而适配起来。
当然,影子堆栈还带来了其他一些复杂的问题。如果一个进程调用了 clone(),那么就必须为子进程分配一个新的影子堆栈,内核会自动处理好这个任务。但是 signal 又一次来 kernel 开发者们添加了麻烦,因为 signal 也涉及到各种对堆栈的操作。如果某个进程用 sigaltstack() 来给 signal handler 处理程序设置了一个替代堆栈,情况就更糟糕了。当时的 patch set 根本就无法处理这种情况。因此基于这些细节(有很多)出发,引出了这个很长的 patch 系列。
ABI issues
影子堆栈的使用,对于大多数应用程序来说应该是完全透明的。毕竟,开发人员正常情况下很少考虑 call stack。但是总有一些应用程序会对它们的 stack 做一些特别的操作。首先是多线程程序,它们会明确管理每个线程的堆栈区域。还有一些程序可能会把他们自己特别制作的 thunk 代码(参见 https://en.wikipedia.org/wiki/Thunk ) 放到堆栈上,甚至可能还会放其他一些更隐蔽的内容。如果没有特别处理的话,在这些程序用在影子堆栈环境下就会出问题。这种大规模的 regression 问题也正是安全功能(security features)不受欢迎的原因,所以开发者采取了各种措施来避免这种情况。
他们深思熟虑之后,提出的计划是对准备用影子堆栈运行的应用程序进行标记(在.note.gnu.property ELF section 有一个特殊的 property)。没有对堆栈进行特殊使用的应用程序可以直接重新编译一下,后续就可以在运行时支持影子堆栈了。对于那些更复杂的情况,我们定义了一组 arch_prctl() 操作来实现对影子堆栈的显式操作。GNU C 库也添加了功能,从而可以在应用程序启动时使用这些调用来正确配置好环境,而内核将在运行这些标记好的程序的时候启用影子堆栈。包括 Fedora 和 Ubuntu 在内的一些发行版,已经针对影子堆栈构建好了他们平台上的二进制文件,他们所需要的只是一个合适的内核来运行额外的保护。
不过,根据尚未被接受和合并的内核功能来发布代码,总是很容易出问题的做法。这次影子堆栈又变成了典型的例子。根据当前 patch 系列的封面所说,arch_prctl() API "因为太奇怪而被放弃了"。但是,那些部署在世界各地的系统上的针对影子堆栈准备好的二进制文件在构建时就认为是一定会有那个 API 的,才不管它是不是奇怪。如果内核会审查 ELF 文件中的标记从而为这些程序来启用影子堆栈,那么其中一些程序就会出问题。这就会导致全世界的系统管理员至少在 2040 年之前都会禁用影子堆栈,从而使整个工作的目的完全无法落实了。
解决这个问题的一个明显办法是,永远不要认可当前 ELF 中的影子堆栈特有标记,而是新创建一个标记来利用内核实际会支持的接口。然而,这里的最终决定是让内核完全不去处理识别二进制文件是否支持影子堆栈,而是让 C 库来处理这个功能。所以,如果这个版本的 ABI 被采用的话,内核就永远不会启用影子堆栈了,除非用户空间显式要求。
The proposed interface
对影子堆栈功能的整体控制是通过一个(人们假设这个接口并不奇怪)arch_prctl() 的调用来实现的:
status = arch_prctl(ARCH_X86_FEATURE_ENABLE, ARCH_X86_FEATURE_SHSTK);
还有一个 ARCH_X86_FEATURE_DISABLE 操作用来关闭影子堆栈,以及 ARCH_X86_FEATURE_LOCK 用来防止未来的改动。
虽然大多数应用程序不需要担心影子堆栈,但其中有一些需要有能力创建新的影子堆栈。使用 makecontext() 系列接口的应用程序就是一个很显著的例子。创建影子堆栈需要有内核的支持,而相关的内存又必须有上述的特殊 page bit 组合设置,而且还必须包括 restore token。所以针对这些操作要有一个新的系统调用:
void *map_shadow_stack(unsigned long size, unsigned int flags);
所需堆栈的大小就是 size 指定的,而 flags 只可以是 SHADOW_STACK_SET_TOKEN,用来请求在堆栈中存储一个 restore token。成功后的返回值就是这个堆栈的基地址。
在使用这个新的堆栈的时候,需要执行 RSTORSSP 指令来进行切换,这很可能是作为线程之间的用户空间上下文切换的一部分来完成。该指令会在进行切换前先对 page 权限以及 restore token 进行必要验证。它还会将新的影子堆栈上的 token 标记为此时在使用中,从而防止该堆栈被其他进程使用。
那些做了特别棘手的操作的应用程序可能就需要能对影子堆栈进行写入了。由于众所周知的原因,通常不允许这种操作,但正如 Edgecombe 所指出的,这 "限制了那些潜在的有用的应用程序,他们可能想牺牲一点安全,以此为代价来实现一些奇怪的工作"。对于这些特殊情况,可以通过 arch_prctl() 来打开另一个特性(LINUX_X86_FEATURE_WRSS),它可以启用 WRSS 指令,用它就能写入影子堆栈的内存区域了。在这种情况下,仍然不可以直接根据指针来对该内存进行写入。
What next?
这项工作并不是个新的工作,它的早期版本在 2018 年的 LWN 文章中就有所涉及。影子堆栈 patch set 之前经过了 30 个版本。关于 control-flow integrity 的工作(indirect branch tracking)也达到了第 29 版本,不过这个当前已经被搁置了(尽管 Peter Zijlstra 刚刚提出了另一种实现)。随着有一个新的开发者领导这项工作,以及缩小了的目标,还有一些人们要求要做的改动,我们希望这项工作能够最终合入 mainline。
有很多因素让这个希望看起来很可能会落实。虽然有人对这套 patch 的各个部分都提出了一些意见,似乎目前没有多少人反对它的工作方式。不过,开发者们确实对缺乏支持另一个 signal stack 而感到担忧。这个功能在某种程度上来说肯定是必要的,所以在这个功能被合并之前,还是需要先看看如何解决这个问题。
还有一个另一个讨论,是关于用户空间中的检查点/恢复(CRIU, Checkpoint/restore in user space)的,这个功能为了实现目标而采用了一些不正当的手段。checkpoint 生成过程中牵涉到将 "parasite" 代码注入到需要抓取快照的目标进程中,来抓取所需的信息,然后完成一个特殊的 return 操作来恢复正常执行。这正是影子堆栈所要防止的那种控制流篡改问题(control-flow tampering)。我们讨论了各种可能的解决方案,但目前还没有落实到代码上。正如 Thomas Gleixner 所说,在影子堆栈可以被合并之前,也需要先解决这个问题:"我们不能因为内核升级而破坏 CRIU 机制"。
最后,这个功能所支持的硬件范围肯定需要再扩大一些。有一些 AMD 的 CPU 也实现了影子堆栈,看起来实现方式可以兼容,但是在这个 patch set 中只支持了英特尔的 CPU。人们认为原因可能是无法进行测试。这一点至少是需要改变的,才能让工作继续推进下去。影子堆栈在 32 位系统上也无法支持。要解决这个问题可能更加困难,不清楚人们是否有动力去做这项工作。不过,无论是否支持 32 位系统,在这段代码进入 mainline 之前都仍有工作要做。不要期望它能在不久的将来出现在 Linux 的某个版本中。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~