GCC SSP Canary功能简介

  处理一个开机coredump问题时,发现是一个必现canary问题,即内存越界访问。一般这类问题发生在数组越界访问,不过这次出现的rootcause有所不同,为代码未对齐导致结构体没对齐,继而在数据传输过程中出现访问越界。bug简单,但鉴于canary是一个有趣的设计,犹如人体免疫系统的表层屏障,能有效规避一些bug,因此我便萌生兴趣系统地了解这个机制,相关学习记录成此文。

  这篇文章首先会介绍canary的基本原理、使能方式、运行机制、canary值产生原理,最后通过gdb一个实例完整解析整个运行流程,阐释debug过程,后续我也争取更加详细阐述canary一些设计的深入原理和目的。Hope you can enjoy it.

1. 背景

  我遇到的实际问题如下,可以看到__stack_chk_fail的字眼,也就是canary问题的标志,这类问题就是栈内存越界访问导致,一般出现在数组越界上,不过这次我们遇到的是代码对齐问题。debug时我看了下code,猜原因,然后对代码就找到rootcause。实际对canary熟悉后,基本可以很快定位问题。此次分析不会提供原始问题代码,毕竟直接晒公司代码是要收律师函的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(gdb) bt
#0 0xea36bd5c in raise () from /lib/libc.so.6
#1 0xea36f838 in abort () from /lib/libc.so.6
#2 0xea3a32cc in ?? () from /lib/libc.so.6
#3 0xea4212e0 in __fortify_fail () from /lib/libc.so.6
#4 0xea42129c in __stack_chk_fail () from /lib/libc.so.6
#5 0xecd173dc in XXX_HAL_AUD_SetGeqEnable (ePort=XXX_MW_SND_OUTPORT_I2S1,
bOnOff=XXX_TRUE) at src/xxx_hal_audio.c:976
#6 0xee9d8428 in XxxAudioCtrl::GEQAef::setGEQEnable (this=0xe6400590,
bOnoff=bOnoff@entry=XXX_TRUE)
at ../middleware/sdkctrl/xxx_mw_audioctrl.cpp:2200
#7 0xee9d9168 in setSndEffect (pctSndModeParam=<optimized out>)
at ../middleware/sdkctrl/xxx_mw_audioctrl.cpp:423
#8 0xeeaa5efc in setSndModeSetting (
ptSndModeSetting=ptSndModeSetting@entry=0xe587ecd4)
at ../middleware/setting/xxx_mw_audio.cpp:4181

2. GCC SSP的canary基本原理

  Stack Canary是GCC Smash Stack Protector(SSP)机制的一个组成部分。通过在loader加载程序时给进程预留一个随机数,称为Canary,当进程内各个函数做栈初始化时,GCC SSP在局部变量和EBP之间插入该值,并在函数返回时,取出该值检查是否被改写,以此判定是否发生内存越界访问等相关问题。下面我们将通过一个实例来解析其实现,文章采用的系统环境如下。

Linux version 4.4.0-119-generic (buildd@lcy01-amd64-013)
gcc version 5.4.0 20160609 (Ubuntu 5.4.0-6ubuntu1~16.04.9)

  关于测试采用的代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* filename: Canary_demo_naive.c */
#include <stdio.h>
int canaryTest(void)
{
char str[20];
str[0] = 0xAB;
str[18] = 0xBC;
str[19] = 0xEF;
return 0;
}

int main(int argc,char *argv[])
{
canaryTest();
return 0;
}

2.1 功能使能方式

  canary功能分两个阶段实现,

  1. 编译阶段通过相关编译开关使能SSP,在满足条件的函数中嵌入代码
  2. 在运行阶段检测实现,运行到相关代码检测是否发生栈越界访问,是则终止程序执行(abort)

  编译阶段,通过如下编译开关控制需要在哪些函数上加保护,目前常用的开关有如下几种,相关详细介绍可以参考“Strong” stack protection for GCC, 实际还有另外两种功能,不过比较少用,不做介绍。

  1. -fstack-protector (GCC 4.1): 当函数定义大小大于等于8字节的数组时使能Canary,当然也可以通过–param=ssp-buffer-size=N 来控制对应的起效阈值
  2. -fstack-protector-all: 对所有非内联函数使能Canary
  3. -fstack-protector-strong (GCC 4.9): 满足以下三个条件都会插入保护代码,相对前两种具有更好的表现,其相关历史可参考Kees Cook blog
    1) 局部变量的地址作为赋值语句的右值或函数参数;
    2) 局部变量为数组或含数组的数据类型,忽略数组的长度和类型;
    3) 带register声明的局部变量
  4. -fno-stack-protector: 禁用canary功能

  对上文的Canary_demo_naive.c采用gcc -S Canary_demo_naive.s Canary_demo_naive.c和gcc -S Canary_demo_naive-no.s Canary_demo_naive.c -fno-stack-protector,比较编译出来的差异如下。

  依据上图左侧汇编代码,我们可以画出如下内存分布图。初始化阶段,从FS[40]抓取一个64bit的random值(canary)保存在栈底,返回时取出该值与FS[40]比较,若该值发生变化,调用__stack_chk_fail报错退出程序。显而易见,当出现越界访问时,canary值会被改写,检测机制生效,这也就是SSP Canary的运行阶段的实现原理。

2.2 功能缺陷

  再看回上文汇编部分。我们申请的是一个20 bytes数组,考虑canary的8 bytes,则分配时应该是28 bytes,但编译器申请的是32字节,经过几次测试我发现,编译器分配的长度是满足申请所需的最小16n bytes。
  这个实现会出现什么问题呢?如果我们所需的字节数加上8刚好等于16n时,一越界,canary就会被才到,功能正常。但如果所需长度小于16n,就会出现gap,当越界访问不超越这个gap,就会出现检测不到越界的问题。Canary_demo_naive.c中越界访问str[23]是检测不到的,只有在写str[24]真正踩到canary时才会报错。

2.3 canary的产生

2.3.1 Canary常见类型

  Canary的类型主要有三种,Terminator canaries、Random canaries、Random XOR canaries。后面我们可以看到GCC源码中Terminator和Random XOR有相应实现,三者区别如下。
  Terminator canaries:缓冲区溢出攻击中大部分是通过字符串操作实现,基于此,Terminator Canary通过NULL、CR、LF或-1的组合形成。攻击者若想绕过Canary检查,则必须在其攻击字串中加入NULL,而strcpy类函数都是以NULL为字符串结束符,这样就成功规避了此类针对标准库函数的漏洞利用。
  Random canaries:Terminator的缺点在于值固定且可知,攻击者可通过其他方式(memcpy)绕过,因此Random Canary应运而生。一般来说,Random Canary产生于一个熵值足够大的随机数发生源,在程序初始化阶段保存在一个不可读的位置,并留存在一个全局变量中,而后者也就意味着攻击者依旧有机会获取到这个值,但实际已经能隔绝大部分攻击。
  Random XOR canaries:在随机数的基础上,将之与部分控制符通过XOR操作形成,这样攻击难度进一步提升,攻击者需要获取到随机数、控制符和对应算法才能获取到对应的Canary值来实现绕过。
  实际上,GCC中采用了Random配合Terminator的方式实现Canary值的生成和使用。

2.3.2 GCC Canary生成原理

  在了解了其工作原理和类型,接下来介绍GCC中Canary这个值的来源。我们前面提到是通过FS[40]获取到的,那FS[40]这个值又是从何而来?
  因为这个值在main函数中就会被用到,因此其产生早于main函数。我们也知道,main()并不是进程运行的第一个函数,其在linux中的启动过程为 _start -> libc_start_main -> libc_csu_init -> _init -> main -> _fini,更具体可以通过文章linux编程之main()函数启动过程了解。而__libc_start_main之后的流程实现于glibc。有这个基础后,我们可以通过glibc-2.27的源码,了解Canary值的来源。
  首先我们来了解下FS这个寄存器存放的基址是什么东西,通过flow libc_start_main >> libc_setup_tls >> TLS_INIT_TP,看到TLS_INIT_TP这个宏的实现如下,如下位置将%fs的基址设置为TLS。

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
/* Code to initially initialize the thread pointer.  This might need
special attention since 'errno' is not yet available and if the
operation can cause a failure 'errno' must not be touched.

We have to make the syscall for both uses of the macro since the
address might be (and probably is) different. */
# define TLS_INIT_TP(thrdescr) \
({ void *_thrdescr = (thrdescr); \
tcbhead_t *_head = _thrdescr; \
int _result; \
\
_head->tcb = _thrdescr; \
/* For now the thread descriptor is at the same address. */ \
_head->self = _thrdescr; \
\
/* It is a simple syscall to set the %fs value for the thread. */ \
asm volatile ("syscall" \
: "=a" (_result) \
: "0" ((unsigned long int) __NR_arch_prctl), \
"D" ((unsigned long int) ARCH_SET_FS), \
"S" (_thrdescr) \
: "memory", "cc", "r11", "cx"); \
\
_result ? "cannot set %fs base address for thread-local storage" : 0; \
})

  通过上面宏我们可以看到该基址指向的数据类型为tcbhead_t,对应数据结构如下,因为我的系统是x86_64,因此计算对应的偏移可得FS[40]对应的成员是stack_guard。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct
{
void *tcb; /* Pointer to the TCB. Not necessarily the
thread descriptor used by libpthread. */
dtv_t *dtv;
void *self; /* Pointer to the thread descriptor. */
int multiple_threads;
int gscope_flag;
uintptr_t sysinfo;
uintptr_t stack_guard;
uintptr_t pointer_guard;
........
void *__padding[8];
} tcbhead_t;

  而stack_guard是通过如下代码设置。其中,_dl_random为_dl_sysdep_start函数从内核获取到的一个随机数,经_dl_setup_stack_chk_guard计算生成canary,再通过THREAD_SET_STACK_GUARD宏则将canary赋值%fs:0x28。

1
2
3
4
5
6
7
  /* Set up the stack checker's canary.  */
uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard (_dl_random);
# ifdef THREAD_SET_STACK_GUARD
THREAD_SET_STACK_GUARD (stack_chk_guard);
# else
__stack_chk_guard = stack_chk_guard;
# endif

  _dl_setup_stack_chk_guard实现如下,可以看到有两种实现方式,当_dl_random为空时,Canary取值为0xff10,否则将_dl_random低8 bits置0算得canary,这样能保证最后一个字节是’\0’,也就是终止符。

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
static inline uintptr_t __attribute__ ((always_inline))
_dl_setup_stack_chk_guard (void *dl_random)
{
union
{
uintptr_t num;
unsigned char bytes[sizeof (uintptr_t)];
} ret = { 0 };

if (dl_random == NULL)
{
ret.bytes[sizeof (ret) - 1] = 255;
ret.bytes[sizeof (ret) - 2] = '\n';
}
else
{
memcpy (ret.bytes, dl_random, sizeof (ret));
#if BYTE_ORDER == LITTLE_ENDIAN
ret.num &= ~(uintptr_t) 0xff;
#elif BYTE_ORDER == BIG_ENDIAN
ret.num &= ~((uintptr_t) 0xff << (8 * (sizeof (ret) - 1)));
#else
# error "BYTE_ORDER unknown"
#endif
}
return ret.num;

  至此,我们完成对canary的基本介绍,接下来我们将通过一个demo模拟canary越界访问,也就是文章开头那个bug。

3. Canary Bug 一例

  点击下载
  实际工作中,除了客制化和非关键的代码会开源给客户,一些涉及关键算法的代码会以库的方式提供,这些库会在不同工程被引用,因此当底层代码更新时,特别是数据结构、数据类型发生变化是,需要及时同步头文件和库到不同的工程中,保证代码对齐。否则,会出现各种难以预料的bug,我接手这个项目处理的第一个问题就是这类问题。
  我们可以看到demo中,libDemoCanary.c是以.so方式release的,其对应的头文件libDemoCanary.h。为了模拟没有对齐的情况,我再增加了一个libDemoCanary2.h,canary_demo.c包含该头文件,下图可以看到该demo差异一个char型。

 make产生canary_demo,执行产生coredump,当没有开启canary我们抓到的会是一个segment fault,开后会出现stack smashing detected。解析coredump如下,我们可以看到异常发生在main中,而不是发生在overWriteData()中,因为后者定义合法,内存申请大小匹配,但对于main来说,数据则超过了其栈空间,出现异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
donald-zhuang@ubuntu:~/Desktop/canary_demo/final$ ./canary_demo 
*** stack smashing detected ***: ./canary_demo terminated
Aborted (core dumped)

donald-zhuang@ubuntu:~/Desktop/canary_demo/final$ gdb ./canary_demo core
Type "apropos word" to search for commands related to "word"...
Reading symbols from ./canary_demo...done.
[New LWP 9940]
Core was generated by `./canary_demo'.
Program terminated with signal SIGABRT, Aborted.
#0 0x00007f27a4aac428 in __GI_raise (sig=sig@entry=6) at ../sysdeps/unix/sysv/linux/raise.c:54
54 ../sysdeps/unix/sysv/linux/raise.c: No such file or directory.
(gdb) bt
#0 0x00007f27a4aac428 in __GI_raise (sig=sig@entry=6) at ../sysdeps/unix/sysv/linux/raise.c:54
#1 0x00007f27a4aae02a in __GI_abort () at abort.c:89
#2 0x00007f27a4aee7ea in __libc_message (do_abort=do_abort@entry=1, fmt=fmt@entry=0x7f27a4c0649f "*** %s ***: %s terminated\n") at ../sysdeps/posix/libc_fatal.c:175
#3 0x00007f27a4b9015c in __GI___fortify_fail (msg=<optimized out>, msg@entry=0x7f27a4c06481 "stack smashing detected") at fortify_fail.c:37
#4 0x00007f27a4b90100 in __stack_chk_fail () at stack_chk_fail.c:28
#5 0x000000000040079f in main (argc=1, argv=0x7ffd4b1bfc18) at canary_demo.c:19

  具体解析,gdb运行demo,在17行处打断点,反汇编,抓取寄存器和内存信息。通过*main<+4>可以看到,main的栈帧申请了0x30字节,main<+30>可知stSrc起始在sp-0x10处,可用空间为0x20 - 0x8 = 0x18 bytes。抓寄存器信息,通过RBP获得进程的canary为0xb06404f1 a278ee00。通过RSP dump栈内数据,可以到此时的canary是正常的。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
(gdb) disassemble /m
Dump of assembler code for function main:
14 {
0x0000000000400746 <+0>: push %rbp
0x0000000000400747 <+1>: mov %rsp,%rbp
0x000000000040074a <+4>: sub $0x30,%rsp
0x000000000040074e <+8>: mov %edi,-0x24(%rbp)
0x0000000000400751 <+11>: mov %rsi,-0x30(%rbp)
0x0000000000400755 <+15>: mov %fs:0x28,%rax
0x000000000040075e <+24>: mov %rax,-0x8(%rbp)
0x0000000000400762 <+28>: xor %eax,%eax

15 canary_data_t stSrc;
16 memset(&stSrc, 0, sizeof(canary_data_t) );
0x0000000000400764 <+30>: lea -0x20(%rbp),%rax
0x0000000000400768 <+34>: mov $0x18,%edx
0x000000000040076d <+39>: mov $0x0,%esi
0x0000000000400772 <+44>: mov %rax,%rdi
0x0000000000400775 <+47>: callq 0x400620 <memset@plt>

17 overWriteData(&stSrc);
=> 0x000000000040077a <+52>: lea -0x20(%rbp),%rax
0x000000000040077e <+56>: mov %rax,%rdi
0x0000000000400781 <+59>: callq 0x400600 <overWriteData@plt>

18 return 0;
0x0000000000400786 <+64>: mov $0x0,%eax

19 }
0x000000000040078b <+69>: mov -0x8(%rbp),%rcx
0x000000000040078f <+73>: xor %fs:0x28,%rcx
0x0000000000400798 <+82>: je 0x40079f <main+89>
0x000000000040079a <+84>: callq 0x400610 <__stack_chk_fail@plt>
0x000000000040079f <+89>: leaveq
0x00000000004007a0 <+90>: retq

(gdb) i registers
rbp 0x7fffffffe3a0 0x7fffffffe3a0
rsp 0x7fffffffe370 0x7fffffffe370


(gdb) x/2x 0x7fffffffe3a0-0x8
0x7fffffffe398: 0xa278ee00 0xb06404f1

(gdb) x/16x 0x7fffffffe370
0x7fffffffe370: 0xffffe488 0x00007fff 0x00000000 0x00000001
0x7fffffffe380: 0x00000000 0x00000000 0x00000000 0x00000000
0x7fffffffe390: 0x00000000 0x00000000 0xa278ee00 0xb06404f1
0x7fffffffe3a0: 0x004007b0 0x00000000 0xf782b830 0x00007fff

  单步执行进入overWriteData,对16、17行打断点,执行同main函数的步骤,我们可以看到overWriteData函数的帧大小为0x40,因此在0x7fffffffe320到0x7fffffffe35F之间,对stDes的操作不会踩到canary,在执行memcpy之前,main和overWriteData的栈内存如下。两个canary都完好。

1
2
3
4
5
6
7
8
9
10
11
12
(gdb) x/2x 0x7fffffffe360-0x8
0x7fffffffe358: 0xa278ee00 0xb06404f1

(gdb) x/32x 0x7fffffffe320
0x7fffffffe320: 0xffffe380 0x00007fff 0xffffe380 0x00007fff
0x7fffffffe330: 0xf7de7ab0 0x00007fff 0x00000000 0x00000000
0x7fffffffe340: 0xff000000 0x00000000 0xff000000 0x00000000
0x7fffffffe350: 0x00000000 0x00000000 0xa278ee00 0xb06404f1
0x7fffffffe360: 0xffffe3a0 0x00007fff 0x00400786 0x00000000
0x7fffffffe370: 0xffffe488 0x00007fff 0x00000000 0x00000001
0x7fffffffe380: 0x00000000 0x00000000 0x00000000 0x00000000
0x7fffffffe390: 0x00000000 0x00000000 0xa278ee00 0xb06404f1

  单步执行到返回语句处,在执行完cpy操作后,我们可以看到此时,main函数的栈canary已经被踩,而overWriteData则完好,因此后者返回时正常,但返回到main函数时,在从main函数返回时,canary检测到越界访问,因此coredump退出,问题出现。

1
2
3
4
5
6
7
8
9
10
11
12
(gdb) n
Breakpoint 3, overWriteData (stSrc=0x7fffffffe380) at libDemoCanary.c:17
17 return 0;
(gdb) x/32x 0x7fffffffe320
0x7fffffffe320: 0xffffe380 0x00007fff 0xffffe380 0x00007fff
0x7fffffffe330: 0x0000000a 0x00000014 0x0000001e 0x00000028
0x7fffffffe340: 0x00000000 0x00000000 0x00000000 0x00000000
0x7fffffffe350: 0x00000000 0x00000000 0xa278ee00 0xb06404f1
0x7fffffffe360: 0xffffe3a0 0x00007fff 0x00400786 0x00000000
0x7fffffffe370: 0xffffe488 0x00007fff 0x00000000 0x00000001
0x7fffffffe380: 0x0000000a 0x00000014 0x0000001e 0x00000028
0x7fffffffe390: 0x00000000 0x00000000 '0x00000000' 0xb06404f1

4. 总结

  通过上面这个bug,我过了一遍GCC的缓冲区溢出攻击保护机制,实际也学到了好些新的知识,毕竟这么底层的东西,平时也是比较少会接触到的。这时候也发现GDB在这种场景分析中十分实用。我在学习C++的虚函数原理时,也通过x/32i看到可执行代码部分的实现,收获颇多,多接触这种底层机理的了解也有助于我们更进一步写出好的程序。

参考文献

PLAYING WITH CANARIES https://www.elttam.com.au/blog/playing-with-canaries/
canary analysis https://hardenedlinux.github.io/2016/11/27/canary.html
Stack Smashing Protector https://wiki.osdev.org/Stack_Smashing_Protector
Stack Smashing On A Modern Linux System https://www.exploit-db.com/papers/24085/
函数调用过程探究 http://www.cnblogs.com/bangerlee/archive/2012/05/22/2508772.html
fs:0x28介绍 https://stackoverflow.com/questions/10325713/why-does-this-memory-address-fs0x28-fs0x28-have-a-random-value