最近,LLVM 合入了一个新的特性 —— Stack Clash Protection(栈冲突保护),简单地来说,就是从编译器层面去做漏洞的防护。

前言

最近,LLVM 合入了一个新的特性 —— Stack Clash Protection(栈冲突保护),简单地来说,就是从编译器层面去做漏洞的防护。

相关知识非常有趣,感兴趣地可以直接看文末的参考链接的一些原文,本文一些翻译术语可能不算特别标准,还请谅解。

Stack Clash 栈冲突

栈冲突是内存管理中的一个漏洞,常见于 Linux、FreeBSD 等的 i386 和 amd64 架构,可被利用来破坏内存和执行任意代码。

简单描述其思路就是,当栈内存增长过多以及接近另一块内存区域时,程序可能会错乱地认为这块外来的内存区域是栈内存,攻击者可以通过重写这块内存区域的数据从而改变调用栈(栈内存)的行为。

注意:需要与 stack overflow(栈溢出) 区分开来,栈溢出是写入不受字节限制的数据来溢出替换函数的返回,从而导向到其他的执行流程。

漏洞利用

想象一下,如果堆内存向高地址增长,栈内存向低地址增长,如果它们冲突在一起了,会怎么样?

1
2
b97bb000-b97dc000 rw-p 00000000 00:00 0          [heap]
bf7c6000-bf806000 rw-p 00000000 00:00 0          [stack]

stack guard-page

在我之前的文章《堆内存优化之动态栈分配》中,有介绍过 stack guard-page (栈内存的哨兵页),这里再简单描述一下。

虚拟内存给线程 1MB 的保留内存,而初始时,线程会从这 1MB 中申请分配 4KB 来使用,当栈内存增长超出时,再去申请内存分配使用。

那如何判断栈内存什么时候要再申请分配呢?就会在当前已分配的内存后面接上 guard-page(哨兵页),一旦触碰到(touched) 哨兵页,就会引发异常中断(缺页中断),然后申请使用更多的内存,哨兵页同时也会挪到最后分配页的后面。

那么这个用来判断栈内存增长的哨兵页是如何保护内存的冲突呢?

通常哨兵页会引发两种效果:

  • 缺页中断,分配更多栈内存

  • 进程终止(SIGSEGV),因为需要增长的内存可能被其他的内存区域给映射过了

第二点就对栈冲突起到了防护的作用。

攻击步骤

相关漏洞利用已经可以突破 guard-page 的防护了,因为它的机制过于脆弱,如果栈内存的上面已经有映射过的内存区域,可以直接将 SP(stack pointer,栈指针) 移到那片内存区域中,而不会引发缺页中断。

作为对比,堆内存的增长就健壮得多了,使用系统调用 brk() 申请更多的堆内存,由内核来进行相应的操作。

说回到栈冲突,主要的攻击步骤有 4 步:

  1. Clash (冲突):申请栈内存直到另一片内存区域

  2. Run :将 SP 栈指针挪到栈顶

  3. Jump:越过 guard-page,将 SP 移到那片内存区域当中

  4. Smash (破坏):重写内存数据

细节

关于另一片的内存区域可能会是以下的一些:

  • 堆内存

  • 匿名的 mmap() 出来的内存

  • ld.so 的读写段内存

  • PIE(Position-Independent Executable) 的读写段内存

步骤 1 的内存申请有可能是:

  • 栈内存和堆内存

  • 栈内存和匿名 mmap() 内存

  • 栈内存

如何申请栈内存:

  • 通过大量字节的命令行参数(command-line)和环境变量

  • 递归函数调用

  • 在极少案例中,如 Solaris 的 ld.so ,不需要申请,因为另一块内存区域已经被映射到栈内存之上

步骤 3 的要点:

  • 要大于哨兵页的大小

  • 结束位置需要在栈内存中,在哨兵页之上

  • 开始位置需要是在哨兵页之下的内存空间

  • 不能完整写入内存,因为会触碰到哨兵页

例子

举其中一个 CVE 利用的例子来进行说明,更多例子可以查看文末的参考链接。

在 i386 Debian 8.5 中,Exim 被用来提权,Exim 是在类 Unix 操作系统上使用的邮件传输代理。

1、clash the stack with the heap

通过传入大量的 -p 的命令行参数,Exim 会分配内存接收而不会释放(CVE-2017-1000369 漏洞)。

-p 参数会被 execve() 将字符串分配到栈内存中,我们需要栈内存覆盖一半的堆栈距离(栈底到堆顶距离),还有堆内存也覆盖一半。

通常会有栈大小限制,如果设置 RLIMIT_STACK 为 136MB,那么堆栈距离是最小的,但 execve 也有接收参数大小的限制,大小限制为 1/4 的 RLIMIT_STACK,此时 -p 参数无法覆盖到一半的堆栈距离。

如果调大 RLIMIT_STACK,堆栈距离也会跟着增加,但设置为 RLIM_INFINITY (4GB on i386) ,内核会将 mmap 的布局改变,由从上往下变成从下往上。然后情况会变成:

  • 堆栈距离约为 2GB,堆开始于 0x40000000 之上,而栈结束于 0xC0000000 之下

  • execve 的大小限制为 1GB,可以达成堆栈距离的一半的目标

  • 可以在 0x80000000 左右的地址进行操作

2、Move the stack-pointer (esp) to the start of the stack

操作完栈内存后,把栈指针移到当前栈内存的起始位置。

3、Jump over the stack guard-page and into the heap

为了把栈指针移入到堆内存时不会触碰到哨兵页,需要用 -d 参数写入不完整的数据,-d 参数会写入 32KB (STRING_SPRINTF_BUFFER_SIZE) 大小的栈顶数据,Exim 会调用 string_printf() 函数,而我们给 -d 只传小量字符串,从而使 32KB 后面没有数据。

4、Smash the stack with the heap

我们的栈指针已经挪到堆内存中了,在 string_printf() 函数返回之前,它会调用 string_copy() 函数,这个函数会将 -d 参数传入的数据复制(memcpy) 到堆内存的结束位置,而此时栈指针还指向这里。

此时我们就可以通过 -d 参数传入的字符串,通过堆内存,来改变调用栈的行为。

我们可以重写 memcpy() 的返回地址为 libc 的 system() 函数(不会被 ASLR 影响,Debian 8.5 的漏洞),从而完成提权。

防护

毕竟是已经提交到 CVE 的漏洞,所以相应的防护措施都已经有了:

  • 加大哨兵页的大小(起码 1MB) —— 还是有被攻击利用的风险

  • 用 -fstack-check 重编相关的库 —— 成本较高,但无法被攻击利用了

GCC -fstack-clash-protection

先简单了解一下 GCC 的实现原理。

用到的是栈探针(stack probing),在我之前的文章《堆内存优化之动态栈分配》中也有说到,这里也简单再描述一下。

编译器引入了栈探针(stack probing)来对付超出 4KB 的栈内存申请。

由于栈内存是静态分配的,所以编译器是可以检测到这种超大的栈内存分配的,对于这种调用栈(stack frame),编译器会生成栈探针(stack probing)指令,实际是调用 _chkstk 函数。

当有相关的栈内存增长操作时,栈探针指令会强行触发哨兵页,从而引发缺页中断。

LLVM 实现

LLVM 和 GCC 的实现差异不算太大。

中心思想是:当超出一页内存大小才进行栈探针操作,还要防止信号量打断执行流而没有触发栈探针指令。

下面的这个流程图可以概括静态分配和动态分配时,内存分配和探针之间的交互模式

1
2
3
4
5
     + ----- <- ------------ <- ------------- <- ------------ +
     |                                                        |
[free probe] -> [page alloc] -> [alloc probe] -> [tail alloc] + -> [dyn probe] -> [page alloc] -> [dyn probe] -> [tail alloc] +
                                                              |                                                               |
                                                              + <- ----------- <- ------------ <- ----------- <- ------------ +

这里说的动态分配是指动态栈分配,同样在我之前的文章《堆内存优化之动态栈分配》中有说到过,就不再展开了,感兴趣的可以看下原文。

参考链接

https://www.qualys.com/2017/06/19/stack-clash/stack-clash.txt

https://access.redhat.com/security/cve/CVE-2017-1000364

https://blog.qualys.com/vulnerabilities-research/2017/06/19/the-stack-clash

https://blog.llvm.org/posts/2021-01-05-stack-clash-protection/