linux 内核安全增强(一)— stack canary

linux 内核安全增强(一)— stack canary

一、背景知识 —— aarch64的函数栈

1.栈生长方向与push/pop操作

栈是一种运算受限的线性表, 入栈的一端为栈顶,另一端则为栈底, 其生长方向和操作顺序理论上没有限定.

在aarch64平台上,栈是向低地址方向增长的(STACK_GROWS_DOWNWARD)
栈的PUSH/POP通常要先移动SP:

  • PUSH操作为PRE_DEC,即 PUSH操作为 sp = sp -4; store;
  • POP操作为 POST_INC,即POP操作为 read; sp=sp+4;

2.返回地址的存储

  • x86平台是call指令时自动push函数返回地址到栈;

  • ret指令自动pop函数返回地址出栈;

这两步操作都是在callee执行前硬件自动完成的.

而在arm/aarch64平台发生函数调用时(blx),硬件负责将函数的返回地址设置到通用寄存器LR(/X30)中, callee中的代码负责将LR保存到栈中(需保存的寄存器参考AAPCS标准)

3.函数栈分配

在不考虑动态分配的情况下, 函数中使用的栈大小在编译阶段就已经确定了(见备注1), 一个aarch64中的典型的程序栈如下所示:

img

x86/arm平台的不同在于:x86和arm平台的函数返回地址通常都存于callee栈的栈底:

  • x86平台是硬件完成的push/pop操作,故返回地址先入栈
  • arm平台callee函数的首指令通常是先push通用寄存器, 函数返回前最后语句pop通用寄存器如:
1
2
3
4
5
6
7
8
9
000005fc <test>:                                                                                                                                                                       
5fc: b590 push {r4, r7, lr} /* 先push通用寄存器和函数返回地址 */
5fe: b089 sub sp, #36 ; 0x24 /* 再为局部变量预留存储空间 */
600: af00 add r7, sp, #0
602: 6078 str r0, [r7, #4]
......
634: 3724 adds r7, #36 ; 0x24
636: 46bd mov sp, r7
638: bd90 pop {r4, r7, pc}

​ 在此两个平台中若发生了栈溢出则直接可以覆盖到当前函数的返回地址.

  • 而aarch64通常是先预留栈再保存函数返回地址,如:
1
2
3
4
5
6
7
8
9
0000000000400654 <test>:
/* 预留栈 || 在栈顶保存函数返回地址 */
400654: a9bc7bfd stp x29, x30, [sp, #-64]! /* sp = sp - 64; sp[0] = x29; sp[1] = x30; */
400658: 910003fd mov x29, sp
40065c: b9001fe0 str w0, [sp, #28]
400660: b9801fe1 ldrsw x1, [sp, #28]
......
400680: a8c47bfd ldp x29, x30, [sp], #64 /* x29 = sp[0]; x30 = sp[1]; sp = sp +64; */
400684: d65f03c0 ret

最终的函数栈如上图所示, 由于变量是向高地址方向生长的,故:

  • 在x86/arm平台的栈上溢(向高地址溢出)通常可直接修改当前函数(这里的callee)的返回地址
  • 在aarch64平台的栈上溢(向高地址溢出)则通常只能修改到父函数(caller)的返回地址

二、stack canary简介

stack canary是一个比较久远的安全特性,linux内核在2.6版本便已经引入, 在5.0又引入了增强的per-task stack canary

其原理比较简单,即:

  • 每个函数执行前先向栈底插入一个canary值(如下图)以确保顺序的栈上溢在破坏到父函数栈帧前必须要先破坏canary
  • 每个函数返回时检测当前栈帧中的canary是否被修改,若被修改则代表发生了溢出(报错)

stack canary并不能检测到所有的栈溢出问题, 只有在满足:

  • 攻击者不知当前插入当函数栈中canary的值(无infoleak)

  • 攻击者只能顺序的覆盖栈中数据,无法跳过canary覆盖数据

两个前提条件时才能检测到栈溢出,故其并非一种理论上安全的防御方式,也只能针对顺序覆盖的栈溢出提供一定的缓解。

三、stack canary基本思路与业界实现

虽然原理简单,但实现上还是要解决两个主要问题:

1.作为对比基准的canary来自哪里?

函数入口需要向函数栈push一个原始的canary,函数出口需要将函数栈中的canary(后续称为stack_canary)和原始值做对比,在此过程中原始值需要保持不变并且可以被代码获取到:

1.1 原始值来自全局变量

默认stack canary使用全局符号(变量) __stack_chk_guard 作为原始的canary(后续称为全局canary), 在gcc/clang中均使用相同的名字.

  • 全局canary的优点在于:实现简单,开启stack_canary保护的代码中只需要定义一个全局变量__stack_chk_guard 并在初始化时为其赋值一个随机数即可__
  • 全局canary的缺点在于:
    • 所有进程间共享同一个全局canary,只要某进程/线程中发生了infoleak,那么整个canary机制就可以被绕过了.
    • 全局canary(__stack_chk_guard)的值在运行期间难以改变,否则会导致已有的函数返回时直接crash
1.2 原始值来自per-cpu变量

per-cpu变量的引入是为了实现per-task的stack canary,每个cpu上同时只能运行一个进程/线程, per-cpu变量可以随进程的切换而切换,故通过一个per-cpu变量完全可以为每个进程/线程解引用到不同的canary地址(后续称为per-cpu canary),以实现per-task的canary。

per-cpu canary的优点在于:

每个进程/线程拥有自己的canary, 可减少infoleak的影响

per-cpu canary的缺点在于:

需要依赖于硬件平台的一个per-cpu变量(如aarch64 用户态tpidr_el0,内核态sp_el0)

需要编译器增加对应支持

2.每个函数中push/pop/check canary的代码谁来写?

通常stack canary的桩代码都是由编译器来插入的,但对具体硬件平台, 不同编译器的支持也有所不同

2.1 gcc/llvm 均支持全局canary:

gcc/llvm中编译选项-fstack-protector/-fstack-protector-strong均已支持, 开启后函数出入口会从全局变量__stack_chk_guard中获取全局canary

2.2 gcc/llvm 均支持aarch64的per-cpu canary:
  • gcc通过-mstack-protector-guard系列选项可以指定某系统寄存器作为stack canary per cpu的资源(后面称为sysreg)
  • clang 主线目前也已支持-mstack-protector-guard 系列选项,但目前尚无可用发行版[1]
  • clang --target=–target=aarch64-linux-android 中支持per cpu的stack canary,但其只能使用默认的 tpidr_el0系统寄存器作为索引, 偏移值也是默认的0x40
1
2
3
4
-mstack-protector-guard*系列包含三个选项:
* -mstack-protector-guard=sysreg: 使用系统寄存器作为per cpu canary的索引
* -mstack-protector-guard-reg=sp_el0: 作为索引的系统寄存器名(必须是系统寄存器,代码中最终会生成msr/mrs作为访问指令)
* -mstack-protector-guard-offset=16: 偏移地址,最终canary来自 *(sp_el0 + offset)
2.3 arm linux内核可通过gcc plugin支持 per-cpu canary:

​ arm linux kernel 通过一个gcc plugin(arm_ssp_per_task_plugin)基于per-cpu 寄存器sp实现了 per-task canary功能

四、编译器中全局canary的实现

这里以aarch64平台,gcc + -fstack-protector-strong为例,其实现逻辑如下(源码分析见备注2):

  • 函数入口将全局canary => stack_canary(stack_canary地址为编译期间预留在当前函数栈底的)
  • 函数出口对比全局canary和stack_canary是否还一致,一致则跳转到4)
  • 检测到栈溢出, 调用__stack_chk_fail函数
  • 函数返回

在aarch64的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//aarch64-linux-gnu-gcc -g -fstack-protector-strong test.c -S -o ./gcc/test.s
1 test:
2 stp x29, x30, [sp, -64]! /* 分配函数栈帧 */
3 mov x29, sp
4 str w0, [sp, 28]
5 adrp x0, __stack_chk_guard /* 获取全局canary *__stack_chk_guard */
6 add x0, x0, :lo12:__stack_chk_guard
7 ldr x1, [x0]
8 str x1, [sp, 56] /* 全局canary => stack_canary(位于栈底) */

9 ....... /* 函数体 */

10 adrp x0, __stack_chk_guard /* 函数返回前再次获取全局canary */
11 add x0, x0, :lo12:__stack_chk_guard
12 ldr x0, [x0]
13 ldr x2, [sp, 56] /* 读取stack_canary */
14 eor x0, x2, x0 /* 对比stack_canary是否被破坏 */
15 cmp x0, 0
16 beq .L3 /* 未破坏跳转到函数返回 */
17 bl __stack_chk_fail /* 被破坏则跳转到 __stack_chk_fail */
18
19 .L3:
20 ldp x29, x30, [sp], 64
21 ret

五、编译器中per-cpu canary的实现

​ per-cpu canary时编译器会通过 *(reg + offset)的方式获取当前cpu上的canary(如下面例子中的 * (sp_el0 + 16), 而程序自身需要确保线程切换时per-cpu的canary也要随之切换, 在aarch64下的汇编代码如下(源码分析见备注2):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/*
-mstack-protector-guard=sysreg: 使用系统寄存器作为per cpu canary的索引
-mstack-protector-guard-reg=sp_el0: 作为索引的系统寄存器名(必须是系统寄存器,代码中最终会生成msr/mrs作为访问指令)
-mstack-protector-guard-offset=16: 偏移地址,最终canary来自 *(sp_el0 + offset)
*/
//aarch64-linux-gnu-gcc -g -fstack-protector-strong -mstack-protector-guard=sysreg -mstack-protector-guard-reg=sp_el0 -mstack-protector-guard-offset=16 test.c -S -o ./gcc/test.s
1 test:
2 stp x29, x30, [sp, -64]! /* 分配函数栈帧 */
3 mov x29, sp
4 str w0, [sp, 28]
5 mrs x0, sp_el0 /* x1 = *(sp_el0 + 16); 为per cpu canary值 */
6 add x0, x0, 16
7 ldr x1, [x0]
8 str x1, [sp, 56] /* per cpu canary => stack_canary */
9 ......
10 mrs x0, sp_el0 /* 再次获取per cpu的canary */
11 add x0, x0, 16
12 ldr x0, [x0]
13 ldr x1, [sp, 56] /* 再次获取stack_canary */
14 eor x0, x1, x0 /* 对比匹配则正常结束,不匹配跳转到__stack_chk_fail */
15 cmp x0, 0
16 beq .L2
17 bl __stack_chk_fail
18 .L2:
19 ldp x29, x30, [sp], 64
20 ret

六、linux内核对stack canary的支持

linux内核中与stack canary相关的配置项主要有三个,分别是:

  1. CONFIG_STACKPROTECTOR:

    平台无关的编译选项,其决定是否开启 stack canary保护, 开启则默认指定编译选项 -fstack-protector,使用__stack_chk_guard 作为全局canary对比

  2. CONFIG_STACKPROTECTOR_STRONG

    平台无关的编译选项,其决定是否开启strong保护,开启则额外指定编译选项 -fstack-protector-strong.

  3. CONFIG_STACKPROTECTOR_PER_TASK

    平台相关的编译选项, 其决定是否开启内核per-task的stack canary保护(此时需编译器的per-cpu canary和对应硬件平台支持)

七、aarch64平台内核stack canary的实现

1.全局canary的实现

1
2
3
CONFIG_STACKPROTECTOR =y
CONFIG_STACKPROTECTOR_STRONG =y
CONFIG_STACKPROTECTOR_PER_TASK=n

全局canary对于内核来说并没有太多的工作,只需要在系统启动时设置好__stack_chk_guard并定义检测失败的回调__stack_chk_fail 即可,插桩代码均由编译器实现(见四), 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
./arch/arm64/kernel/process.c
#if defined(CONFIG_STACKPROTECTOR) && !defined(CONFIG_STACKPROTECTOR_PER_TASK)
#include <linux/stackprotector.h>
/* 这里__stack_chk_guard被定义为一个变量, 实际上定义为__ro_after_init可能更好, 此变量可写通常也不会有太大问题,因为对此变量的修改通常会直接导致内核检测到栈溢出而crash */
unsigned long __stack_chk_guard __read_mostly;
EXPORT_SYMBOL(__stack_chk_guard);
#endif

./arch/arm64/include/asm/stackprotector.h
static __always_inline void boot_init_stack_canary(void)
{
#if defined(CONFIG_STACKPROTECTOR)
unsigned long canary;

get_random_bytes(&canary, sizeof(canary)); /* 获取一个半随机数 */
canary ^= LINUX_VERSION_CODE;
canary &= CANARY_MASK;
current->stack_canary = canary;

if (!IS_ENABLED(CONFIG_STACKPROTECTOR_PER_TASK))
__stack_chk_guard = current->stack_canary; /* 如果没指定 per thread,则初始化全局canary */

#endif
.......
}

./kernel/panic.c
__visible noinstr void __stack_chk_fail(void)
{
instrumentation_begin();
panic("stack-protector: Kernel stack is corrupted in: %pB",
__builtin_return_address(0));
instrumentation_end();
}
EXPORT_SYMBOL(__stack_chk_fail);

2.per-task canary的实现

CONFIG_STACKPROTECTOR =y
CONFIG_STACKPROTECTOR_STRONG =y
CONFIG_STACKPROTECTOR_PER_TASK=y
per-task canary时内核除了初始化外还需要负责为每个进程生成随机的canary,并负责在进程切换时同步per-cpu的寄存器与进程的关系,此时内核新增的配置项和数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
./arch/arm64/kernel/asm-offsets.c
#ifdef CONFIG_STACKPROTECTOR
DEFINE(TSK_STACK_CANARY, offsetof(struct task_struct, stack_canary)); /* task_struct中增加per thread的canary */
#endif
./arch/arm64/Kconfig
config STACKPROTECTOR_PER_TASK
def_bool y
depends on STACKPROTECTOR && CC_HAVE_STACKPROTECTOR_SYSREG

./arch/arm64/Makefile
ifeq ($(CONFIG_STACKPROTECTOR_PER_TASK),y)
prepare: stack_protector_prepare
/* 增加编译选项 -mstack-protector-guard=sysreg -mstack-protector-guard-reg=sp_el0 -mstack-protector-guard-offset=TSK_STACK_CANARY*/
stack_protector_prepare: prepare0
$(eval KBUILD_CFLAGS += -mstack-protector-guard=sysreg \ ## per-task编译选项支持
-mstack-protector-guard-reg=sp_el0 \
-mstack-protector-guard-offset=$(shell \
awk '{if ($$2 == "TSK_STACK_CANARY") print $$3;}' \
include/generated/asm-offsets.h))
endif

根据编译选项可知,per-task模式下内核指定编译器通过 *(sp_el0 + TSK_STACK_CANARY) 来解引用per-cpu canary, sp_el0在内核中用来存储当前进程task_struct的指针,即对于内核来说对 *(sp_el0 + TSK_STACK_CANARY) 的解引用即相当于访问 current->stack_canary.

由于sp_el0在内核中是随着进程切换而切换的(见__switch_to),故stack canary特性并不需要做额外的操作,其只需要在每个线程创建时为其生成新的canary即可:

1
2
3
4
5
6
7
8
static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
......
#ifdef CONFIG_STACKPROTECTOR /* 这里不是CONFIG_STACKPROTECTOR_PER_TASK是因为x86平台此特性兼容的历史原因,这里欠缺一点优雅 */
tsk->stack_canary = get_random_canary(); /* fork线程时总是新生成一个随机数作为新线程的canary */
#endif
......
}

故在aarch64内核中 per-task canary的思路可整理如下:

  • 内核自身sp_el0记录task_struct(即current)地址,并随进程切换而切换
  • 在task_struct中增加一个成员stack_canary, 则此成员总是可以通过 (sp_el0 + TSK_STACK_CANARY)找到
    进程创建时总是为其生成一个新的canary记录到 current->stack_canary
  • 编译器开启per-cpu canary支持,基准的canary值总是来自sp_el0 + TSK_STACK_CANARY,也就是 current->stack_canary

(69条消息) linux 内核安全增强(一)— stack canary_ashimida@的博客-CSDN博客___stack_chk_guard