4.2 Stack Canaries
Stack Canaries(取名自地下煤矿的金丝雀,因为它能比矿工更早地发现煤气泄漏,有预警的作用)是一种用于对抗栈溢出攻击的技术,即SSP安全机制,有时也叫作Stack cookies。Canary的值是栈上的一个随机数,在程序启动时随机生成并保存在比函数返回地址更低的位置。由于栈溢出是从低地址向高地址进行覆盖,因此攻击者要想控制函数的返回指针,就一定要先覆盖到Canary。程序只需要在函数返回前检查Canary是否被篡改,就可以达到保护栈的目的。
4.2.1 简介
Canaries通常可分为3类:terminator、random和random XOR,具体的实现有StackGuard、StackShield、ProPoliced等。其中,StackGuard出现于1997年,是Linux最初的实现方式,感兴趣的读者可以阅读论文StackGuard: Automatic Adaptive Detection and Prevention of Buffer-Overflow Attacks。
? Terminator canaries:由于许多栈溢出都是由于字符串操作(如strcpy)不当所产生的,而这些字符串以NULL“\x00”结尾,换个角度看也就是会被“\x00”所截断。基于这一点,terminator canaries将低位设置为“\x00”,既可以防止被泄露,也可以防止被伪造。截断字符还包括CR(0x0d)、LF(0x0a)和EOF(0xff)。
? Random canaries:为防止canaries被攻击者猜到,random canaries通常在程序初始化时随机生成,并保存在一个相对安全的地方。当然如果攻击者知道它的位置,还是有可能被读出来。随机数通常由/dev/urandom生成,有时也使用当前时间的哈希。
? Random XOR canaries:与random canaries类似,但多了一个XOR操作,这样无论是canaries被篡改还是与之XOR的控制数据被篡改,都会发生错误,这就增加了攻击难度。
下面我们来看一个简单的示例程序。
使用的GCC版本为5.4.0,包含多个与Canaries有关的参数,这里先使用最常见的-fstack-protector进行编译,64位程序的执行情况如下所示。
可以看到开启Canaries后,程序终止并抛出错误“stack smashing detected”,表示检测到了栈溢出。其反汇编代码如下所示。
注意开头和结尾的两处加粗部分。在Linux中,fs寄存器被用于存放线程局部存储(Thread Local Storage, TLS),TLS主要是为了避免多个线程同时访存同一全局变量或者静态变量时所导致的冲突,尤其是多个线程同时需要修改这一变量时。TLS为每一个使用该全局变量的线程都提供一个变量值的副本,每一个线程均可以独立地改变自己的副本,而不会和其他线程的副本冲突。从线程的角度看,就好像每一个线程都完全拥有该变量。而从全局变量的角度看,就好像一个全局变量被克隆成了多份副本,每一份副本都可以被一个线程独立地改变。在glibc的实现里,TLS结构体tcbhead_t的定义如下所示,偏移0x28的地方正是stack_guard。
从TLS取出Canary后,程序就将其插入rbp-0x8的位置暂时保存。在函数返回前,又从栈上将其取出,并与TLS中的Canary进行异或比较,从而确定两个值是否相等。如果不相等就说明发生了栈溢出,然后转到__stack_chk_fail()函数中,程序终止并抛出错误;否则程序正常退出。具体情况可以查看12.5 SSP Leak一节。
而如果是32位程序,那么Canary就变成了gs寄存器偏移0x14的地方,如下所示。
脚本checksec.sh对Canary的检测也是根据是否存在__stack_chk_fail(或者__intel_security_cookie)来进行判断的。
4.2.2 实现
以64位程序为例,在程序加载时glibc中的ld.so首先初始化TLS,包括为其分配空间以及设置fs寄存器指向TLS,这一部分是通过arch_prctl系统调用完成的。然后程序调用security_init()函数,生成Canary的值stack_chk_guard,并放入fs:0x28。完整的调用栈如下所示。
除security_init()函数外,在__libc_start_main()函数中也可以生成Canary。其中__dl_random指向一个由内核提供的随机数,当然也可以选择由glibc自己产生。这些随机数是根据计算机周围环境生成熵池,然后利用多种哈希算法计算而成的。
接下来进入_dl_setup_stack_chk_guard()函数,并根据位数(32或64)以及字节序生成相应的Canary值。需要注意的是,为了使Canary具有字符截断的效果,其最低位被设置为0x00。当然如果dl_random指针为NULL,那么Canary为定值。
然后程序将生成的Canary交给THREAD_SET_STACK_GUARD宏进行处理,其中THREAD_SETMEM可以直接修改线程描述符的成员,而THREAD_SELF就是指当前线程的线程描述符。
执行完毕后,Canary值就被放到fs:0x28的位置,程序运行时即可取出使用。但是如果程序没有定义THREAD_SET_STACK_GUARD宏,通常是一些TLS不用于储存Canary值的体系结构,那么就会把这个值直接赋值给__stack_chk_guard,这是一个全局变量,放在.bss段中。
攻击Canaries的主要目的是避免程序崩溃,那么就有两种思路:第一种将Canaries的值泄露出来,然后在栈溢出时覆盖上去,使其保持不变;第二种则是同时篡改TLS和栈上的Canaries,这样在检查的时候就能够通过。本章的剩下部分我们将通过两个例题分别展示这两种方法。更多的例子可以查看9.2节和12.5节的相关内容,还可以阅读这篇2002年的论文Four different tricks to bypass StackShield and StackGuard protection。
4.2.3 NJCTF 2017:messager
第一道例题来自2017年的NJCTF,该程序本身就能通过socket进行通信,不需要用socat进行绑定,所以直接运行即可,端口为5555。
程序分析
程序一开始就将flag从文件里取出,存放到unk_602160,相应也有一个通过socket发送flag的函数sub_400BC6(),不难想到,我们最终就是要控制程序调用这个函数。
在一个while循环中,每次发生连接程序就复刻(fork)一个子进程,然后跳出循环,调用sub_400BE9()函数与用户进行交互,如果函数正常返回,会打印出字符串“Message received!\n”。
在sub_400BE9()函数中我们发现了一个明显的栈溢出漏洞,程序试图读入最多0x400字节到0x64字节大小的缓冲区。
漏洞利用
一个进程包括代码、数据和分配给进程的资源。当调用fork()的时候,系统先给新进程分配资源,例如存储数据和代码的空间,然后把原进程的所有值都复制到新进程中,相当于克隆了一个自己。
通常情况下,对Canaries进行爆破是不太可能的。在32位下,除去低位固定的“\x00”,还有0x100^3=16 777 216种情况,64位则更多。另外,爆破意味着大量的崩溃,而程序重启后Canaries的值也会重新生成。但是同一个进程内包括复刻的子进程,它们的Canaries是不会变的,且子进程崩溃不会影响到主进程,这就给了我们爆破的机会。
爆破是逐字节进行的,根据进程崩溃与否来判断填充上去的字节是否正确。获得Canaries的值后,我们就可以在溢出时保持其不变,并覆盖返回地址,获得flag。
解题代码
4.2.4 sixstars CTF 2018:babystack
第二道例题来自2018年的sixstars CTF,在此还要感谢sixstars的朋友公开所有题目源码。将题目编译成64位的可执行文件,开启Full RELRO、Canary和NX,需要注意的是这里指定参数“-pthread”启用了POSIX线程库。
程序分析
我们跳过逆向工程,直接看源码。在main()函数中,程序通过pthread_create()创建线程,运行函数是start()。
在start()函数中我们找到一个栈溢出漏洞,最多可以读入0x10000字节到0x1000字节大小的缓冲区。
漏洞利用
New bypass and protection techniques for ASLR on Linux中的研究指出,对于通过pthread_create()创建的线程,glibc在TLS的实现上是有问题的。由于栈是由高地址向低地址增长,glibc就在内存的高地址处对TLS进行了初始化,从TLS减去一个固定值,可以得到新线程用于栈寄存器的值。而从TLS到传递给pthread_create()的运行函数的栈帧,距离小于一页。因此,攻击者无须纠结原Canaries的值是什么,可以直接溢出足够多的数据篡改tcbhead_t.stack_guard。下面是论文作者给出的示例。
可以看到,当前栈帧和TCB结构体之间的距离是0x7b8,小于一页,当溢出的字节足够多,同时覆盖栈上的canaries以及TLS上的stack_guard,使它们的值相等时,就可以绕过检查。
搞清楚了如何绕过Canaries的保护,接下来的步骤就很常规了:通过栈溢出覆盖返回地址,从而执行ROP,利用puts()泄露libc的地址,然后用read()将one-gadget读到.bss段,利用stack pivot将栈转移过去,最后“leave;ret”的组合将RIP赋值为one-gadget,从而获得shell。
解题代码