Shadow Stack技术概述

Shadow Stack技术概述

原文和术语

主要对下面这两篇论文的笔记和总结。

  • SoK: Shining Light on Shadow Stacks
  • The Performance Cost of Shadow Stacks and Stack Canaries

instrument:插桩

prologue:指函数头部(比如插桩的时候,把一些指令插入到函数最开始的地方)

epilogue:指函数尾部

Shadow Stack设计

要求

高性能

与老代码兼容

安全性强

评判角度

运行时

内存

代码大小

栈结构

核心思想:将Shadow Stack和原来的栈放在不同的地址空间。防止缓冲区溢出等方式覆盖掉。从而保证Shadow Stack的完整性。通过给每个函数插入prologue和epilogue来做mapping。

Shadow Stack有两种栈结构:

  • 平行(Parallel)
  • 压缩型(Compact)

Parallel Stack

Shadow Stack和原来的Stack的大小完全相同,是一种直接映射(Direct Mapping)方法。注意,只是大小一样即可。因为我们只需要检查里面的RA,只有RA要是真正的RA,需要完全地映射到Shadow stack中(也就是刚入栈时rsp寄存器指向的内容)。其他的数据不需要复制,以减少额外的存储器读写开销。而且Shadow Stack的整体位置是在原来的Stack地址基础上加上一个值(偏移量)。

Shadow Stack技术概述

从论文《SoK: Shining Light on Shadow Stacks》中的Fig2可以看出,这种方法从大小上看只是相当于把Stack平移了。这个加的值是多少,也是一个问题。一种方法是加一个常数,但这样一但这个常数泄漏,那相当于Shadow Stack的地址也泄露了。这样是比较危险的。

论文《SoK: Shining Light on Shadow Stacks》中提出一种新的偏移量设置方法。偏移量被设置为一个寄存器的值。这个寄存器将在运行时确定。这样的好处在于,一是比起设置常数降低风险,二是因为寄存器是Thread-local的(这一点将会由操作系统保证),对于多个线程,每个线程都可以设定独立的一个偏移。

prologue(假设偏移存储在寄存器r15当中):

mov rax, [rsp]
mov [rsp + r15], rax

epilogue(恢复返回值位置的值,此时已经到函数尾部,rsp已经回到原来指向RA的位置,所以直接将[rsp + 15]重新复制给[rsp]恢复RA):

mov rax, [rsp + r15]
mov [rsp], rax

Compact Stack

实际上我们很容易知道,刚才的方法很浪费空间。我们注意到我们只关注RA。因此,如果只考虑栈中的所有RA:

(原来的栈)

高地址
RA1
...
RA2
...
RA3
...
低地址

那么所有的RA单独来看实际上也构成一个栈

(所有的RA构成的栈)

高地址
RA1
RA2
RA3
低地址

Shadow Stack技术概述

我们另外维护一个由所有RA构成的栈作为Shadow Stack即可。该栈的栈顶指针被称为Shadow Stack Pointer(SSP)。

专用寄存器SSP

prologue(假设用r15保存SSP):

sub r15, 8 # 把SSP减去8,留下存储新RA的余地
mov rax, [rsp] # 将RA存储到RAX中
mov [r15], rax # 将RAX的值=RA存入SS中

等价于在SSP中push进了一个RA。

epilogue:

mov rax, [r15]
mov [rsp], rax
add r15, 8

等价于把SSP中的RA给pop到[rsp]了。

这种方法被称为把SSP存在专用寄存器(Dedicated register)的方法。这个寄存器必须是callee-save register。使用寄存器保存的一大缺陷在于,会造成compiler在寄存器分配(Register Allocation)的时候,少一个寄存器作为分配的选择,可能会导致有一些变量被spill到栈上,影响程序的性能。

段SSP

还有一种很常见、经典的方法。将SSP保存在某个内存地址上。这个内存地址使用GS作为段寄存器。GS:[0]指向Shadow Stack的底部。

prologue:

sub gs:[0], 8 # 把SSP减去8,留下存储新RA的余地
mov rax, [rsp] # 把RA存储到RAX中
mov rcx, gs:[0] # 把SSP存储到RCX中
mov [rcx], rax # 把RA存入SS中

epilogue:

mov rcx, gs:[0]
mov rax, [rcx]
mov [rsp], rax
add gs:[0], 8

可见,段SSP多了一些存储器访问指令。但是没有影响编译器寄存器分配的寄存器选择范围。

校验返回地址的方式

  • 比较(Compare)式:通过比较,不相等就crash程序
  • 复写(Overwrite)式:直接将SS中的RA覆盖掉原始栈中的RA(就像上面的epilogue的意思那样)

实际上他们能够达到一样的效果,但是后者性能开销更小。

实现细节

这一部分主要讨论插桩位置。

插桩位置

有两种选择。一种是在所有call和每个函数的epilogue插桩,还有一种直接对每个函数的prologue和epilogue插桩。比较精巧的方式显然是后者。但是后者相对于前者有个缺陷。前者的话,我们可以控制call的之前,先把下一条指令的地址写入到SS里面,这样确保SS里面存储的地址是正确的。而后者的话,需要在call之后,跳转到函数中之后,再将处于函数原始栈上的返回地址存入SS。这样就有个问题,call到开始执行函数之间存在几个时钟周期的指令延迟。这可能会给攻击者造就TOCTTOU攻击的机会。(这种攻击表示,设定的时间和使用的时间之间有空隙,这可能导致设定的值在中间被其他的进程修改。对于TOCTTOU攻击网上有不少资料,可以去了解下)。但是其实这种攻击能成功的概率很小。因为对时间精度的要求实在太高了。

上面已经涵盖了Shadow Stack的基本技术和基本实现方式。后面论文还介绍了如何使用硬件增强SS内存区域的保护,性能分析等。由于本人以目前的水平能理解的部分有限,这里暂不介绍了。

上一篇:CSS学习第四天(1)-圆角边框、盒子阴影、文字阴影


下一篇:/deep/在chrome89+中出现样式混乱的问题