Challenge: Password Vault · CTF: DalCTF 2026 · Category: Pwning · Difficulty: Medium
A clean little heap challenge that boils down to one classic mistake: free() without nulling the pointer. We turn that dangling pointer into an arbitrary function-pointer call and redirect execution to a function the developer left in the binary but “forgot” to wire into the menu — the one that prints the flag.
1. Recon
We’re given two files: the source manager.c and the compiled binary vault. Having source makes this a white-box challenge, so let’s read it carefully.
First, the protections:
| |
The important takeaways:
- No PIE — the binary loads at a fixed base
0x400000, so every function has a hardcoded, known address. No leak needed. - NX enabled — the stack/heap is non-executable, so no injecting shellcode. We’ll need to reuse existing code.
- IBT / SHSTK (Intel CET) — indirect branches must land on an
endbr64instruction, and there’s a shadow stack. This sounds scary, but it only matters if we try to jump into the middle of a function or do ROP. Calling a legitimate function at its real entry point is completely fine — every function starts withendbr64.
So the strategy writes itself: find a way to call an existing function of our choosing, at its entry point.
2. Reading the source
The program is a password manager with a menu:
| |
Each “login” is this struct:
| |
Notice the very first field is a function pointer. That’s a giant red flag in a heap challenge. Logins are stored in a global array:
| |
The interesting functions
There’s a function that reads the flag:
| |
This is exactly what we want — it opens flag.txt and prints it. But search the menu handler: read_master_key is never called anywhere. It’s dead code, sitting in the binary at a fixed address, waiting for us.
When you create a login, the function pointer is initialised to the harmless handler instead:
| |
And access_denied just prints a rejection:
| |
Option 4 calls whatever that pointer holds:
| |
So if we can overwrite can_check with the address of read_master_key, option 4 prints the flag. The whole challenge is: how do we control those 8 bytes?
3. The bug — Use-After-Free
Look closely at delete_login:
| |
It frees the chunk but leaves logins[i] pointing at the now-freed memory. That’s a textbook use-after-free (UAF) / dangling pointer.
The validation function doesn’t save us either — it only checks for NULL, which the dangling pointer is not:
| |
So after deleting slot 0, we can still call option 4 on slot 0 and it will dereference freed memory. If we can get our own data placed into that freed chunk, we control can_check.
The reallocation primitive
set_password is the perfect tool to reclaim the freed chunk:
| |
It lets us malloc a chunk of a size we pick and then fread arbitrary bytes into it. This is the key to the exploit.
4. Heap mechanics — why the chunk comes back
Modern glibc uses the tcache (thread-local caching) for small allocations. The rule we care about:
When you
free()a small chunk, it goes onto a per-size tcache bin. The very nextmalloc()of that same size pops it straight back off — returning the exact same memory.
Our Login struct is 32 bytes (8 + 12 + 12). malloc(32) returns a chunk whose real size, including the 16-byte header rounding, is 0x30. That 0x30 bin is what we need to hit.
So when we later request a password buffer that also lands in the 0x30 bin, malloc hands us back the same address the freed Login lived at. Our fread then writes directly over the struct — and the first 8 bytes we write are can_check.
The request size for set_password just needs to round to a 0x30 chunk (anything from 25–40 bytes works); I used 32 to match exactly.
5. Putting it together
The attack flow:
new loginin slot 0 → allocates aLoginchunk (size0x30), withcan_check = access_denied.delete loginslot 0 → frees that chunk.logins[0]is now a dangling pointer; the chunk sits at the head of the0x30tcache bin.set password, size 32 →malloc(32)reclaims the same chunk. Wefreadp64(read_master_key) + paddinginto it, overwritingcan_check.check master keyslot 0 → executeslogins[0]->can_check(), which is nowread_master_key()→ flag printed.
A quick ASCII view of the chunk before and after the overwrite:
| |
We didn’t even need the username/password fields — only the first 8 bytes matter.
The target address
Because there’s no PIE, we just read the address straight out of the binary:
| |
read_master_key lives at 0x4012d6, and it begins with endbr64, so the CET/IBT indirect-branch check is satisfied — the call is perfectly legal.
6. Exploit
| |
Run it:
| |
🚩 Flag:
dalctf{fr33d_fr0m_d4s1r3_n4n4n4}A fitting flag — “freed from desire” — given the bug is a freed chunk we never let go of.
7. Lessons & remediation
The whole challenge hinges on a one-line omission. The fix is the classic UAF defence:
| |
Two compounding design smells made it exploitable:
- A function pointer as the first struct member. Putting code pointers next to attacker-controlled data is asking for trouble; if it must exist, keep it far from user input or validate it before every call.
- Validation that only checks
NULL. “Not null” is not the same as “valid.” A freed-but-not-nulled pointer sails right through.
Takeaways for players
- Read
delete/freepaths first in any heap challenge — a missing= NULLis the single most common heap bug in CTFs. - No PIE + a leftover “win” function is a huge hint that you only need to control one code pointer, not build a full ROP chain.
- tcache makes reclaiming a freed chunk trivial: free a chunk, then
mallocthe same size, and you get the same memory back to write into. - CET/IBT doesn’t block calling whole functions. It only stops jumps into the middle of code or gadget chains. Redirecting a function pointer to a real function entry is unaffected.