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地址基础上加上一个值(偏移量)。
从论文《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
低地址
我们另外维护一个由所有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内存区域的保护,性能分析等。由于本人以目前的水平能理解的部分有限,这里暂不介绍了。