这篇文章有些老了,2016年6月的;照例贴出文章翻译自lwn.net。内核栈可以说是在linux设计的一个薄弱点:它小到内核开发者必须不断考虑把啥放到栈里才能防止溢出。可是这些溢出经常发生,甚至在没有试图强制制造错误的攻击者时也会发生,并且,在Jann Horn最近(2016年)说明的,攻击者想要制造栈溢出是有原因的。当溢出发生时,内核甚至都不能有效检查到,能做的也很少。在内核的生命周期中,栈很少有改变,不过一些最近的工作有着使内核栈变得更加健壮的潜力。
当前的内核栈的样子
每一个进程陷入到内核代码中运行(对于进程的用户态内核态我在这里有一些论述)都会有自己的一个小栈;在当前内核,32系统的内核栈是8KB,64位系统的内核栈是16KB,位于内核的线性映射区,因此也就是物理连续的;这个物理连续的要求,在内存逐渐变得碎片化之后本身就成文问题,因为找到两个或四个物理连续的页面已经变得很困难了。直接映射的内存同样使溢出检查页不再可能(防溢出页就是在栈后的无访问权限页,我有写用户态栈如何被侦测到溢出以及内核栈怎么才能侦测溢出),因为增加溢出检查页将会浪费一个真实的内存页。
然后就导致,没有什么立刻就能指示内核栈溢出的信号。栈增加过大的就会覆盖下面(说下面是因为在大多数架构中栈向下增长)的区域,无论其中存有什么信息。有内核开发选项允许在内核栈放一个canaries,可以通过追踪栈的使用来侦测溢出。但是如果在工作环境中侦测到栈溢出,一般都是溢出实际发生了,并且未知量的破坏都已经造成。
更为有趣的是,这里还有一个很有争议的数据结构-thread_info,放在栈底区域。因此如果内核栈溢出了,thread_info这个提供了几乎所有内核知道的关于这个进程的信息的入口,将会最先被覆盖。不必说,这就使栈溢出对于攻击者来说更加有趣了;很难知道说栈底后的内存李会放着什么,但是thread_info结构却是一个广为所知的。
毫不不令人惊讶的是,内核开发者们肯定会很努力的避免栈溢出。在栈上alloc(一般都是自动变量的形式),都是被仔细检查的,并且作为一般规矩性的,递归是不允许的。但是惊喜总是以一系列的形式袭来,从一个没注意的变量申请到一个未提前算好深度的调用链。存储子系统,一个文件系统,存储技术和网络代码可以堆砌到任意深度的地方,更是这些问题的重灾区。这一系列的惊喜直接导致3.15release内核把64位内核栈扩到了16KB,限制依旧在;并且由于不是一个栈供所有进程使用,因此任何增加其大小的举动都会被放大很多倍。
防止栈溢出在一段时间内依旧是内核开发者需要面对的挑战,但是内核更好的应对栈溢出的发生也是应该考虑的。做这件事的关键,就像在Andy Lutomirski的虚拟映射内核栈补丁组看到的,已经转移到内核栈是如何被映射的了。
虚拟映射栈
几乎所有内核直接访问的内存都是通过直接映射范围内的地址访问的。这个范围是一大段直接线性映射到物理内存的地址,这就会看起来好像内核是在物理内存地址上工作。在一个64位系统,所有内存都通过这种方式映射到内核;32位系统没有能力映射全部,于是会更复杂一点。
Linux是一个虚拟内存系统,内核也是通过虚拟内存访问内存,即便是在直接映射范围内。刚巧内核预留了另一个地址范围的内存来映射虚拟内存,这个区域vmalloc()调用时所申请的,被称作vmalloc范围。这一区域的内存分配是不连续的。过去这个区域被用来获取大块虚拟连续但实际不必连续的内存。
内核栈的物理连续是几乎没有什么必要的,因此原则上讲他们可以作为独立的页来申请,并映射到vmalloc区域。这样做可以消除内核中最大的连续物理内存的应用场合,恩,算是最大的之一;使系统在内存变碎片化后更加健壮。这样还可以使栈防溢出机制生效而不浪费内存(还是用户态栈如何被侦测到溢出的问题),仅仅是增加一条页表,就可以使内核在栈溢出即立刻感知。Andy的补丁就是这样做的:他从vmalloc区申请内核栈,当内核栈溢出,他优雅的增加了溢出处理,打出一条提示,不会oops,然后溢出的进程就被杀掉了。
这个补丁组相当简单,大部分工作在于处理肮脏的不同架构的细节。看起来是对内核一次非常显著的提升,在代码审查中得到大量正面评价,也有一些尚待解决的小问题。
不太好的影响
这其中一个非常重要的问题就是性能表现,从vmalloc区分配内存,Andy说,使clone()创建进程花了1.5us。一些工作对进程创建的开销很敏感,将会受到此影响,于是linus的回复就不怎么令人惊讶了:这个问题需要在合代码前解决。Andy认为大部分开销可以通过使vmalloc()更快来解决(vmalloc从来没有被认真的优化过),linus建议保持一个小的per-cpu的缓存作为与分配的栈。他已经说的很明白了:想要在合并代码之前把性能的退化解决掉。
另外有一个潜在的开销,没有被测过的开销:TLB miss问题。直接映射的区域使用巨页映射,因此整个内核(包含代码、数据、栈)可以装到一条TLB里。整个vmalloc区却不是,使用单页映射创建窗口到内存中。由于用内核栈是普遍的,如果这些栈都是用vmalloc,那么可能TLB miss的增加就会成真了。