HTB 2022 Cyber Apocalypse CTF - Hardware - Secret Codes

The fourth hardware challenge has a download with a Capture433.sal file, and the following description:

A sudden burst of RF signals started in an attempt to compromise our perimeter security. We intercepted a few of them but our ADC broke during the transformation to a serial signal leaving us with only an analog capture. We need to decode it and find if they are close to finding the correct passcode.

From experience and the above text we know the following:

So we open the file in Saleae Logic, to find the following:

So we have two signals, the top one being a digital signal, and the bottom one an analog signal. Zooming in on the first burst, it is clear that the digital signal is just the analog signal that has been passed through the ADC:

The fact that there is a little bit of the digital signal is included is very nice, as this means that we can use the Saleae Logic analyzers to find what kind of signal it is.

 Analyzing as async serial data

At first we thought it would be simple serial data, and thus used the Async Serial analyzer:

We estimated the Bit Rate so that the dots would line up with the signal:

Now as you can see, the data that the Async Serial analyzer finds (the hexadecimal in the blue bar: 0xB2AD354D4CCD534D) isn’t any logical value. At this point we assumed that this was because the flag would show up later in the signal, so we just continued on.

The goal now it to analyze the whole analog signal with the same analyzer, but Saleae currently doesn’t have support for using the analyzers on an analog signal, nor does it have an ADC built in. But it does have support for exporting an analog signal to a csv file, which we can easily parse with Python.

The export functionality also supports a downsampling ratio for analog signals, which we set to 2920. This results in the export having 856 samples per second, which means we can ignore the timestamp data from the export, as the export has the same bit rate as the signal.

Now we need to parse the data, which we do with Python. Note that we also need to account for the partiy bit, which we don’t want in our output:

#!/usr/bin/env python3

input_path = "LogicExport/analog.csv"

bits = []

with open(input_path, 'r') as f:
	f.readline()
	# previous = 0
	count = -1
	while line := f.readline():
		value = float(line.split(',')[1][:-1])
		if count == -1:
			if value > 2:
				count = 0
			continue

		if count == 64:
			count += 1
			continue
		if count > 64:
			if value > 2:
				print("Error")

			count = -1
			continue

		if value > 2:
			bits.append(0)
		else:
			bits.append(1)

		count += 1

print(bits)

qword_count = len(bits) // 64

print((qword_count, len(bits) / 64))

correct_bits = []

for i in range(qword_count):
	correct_bits += bits[i*64:i*64+64][::-1]

print(correct_bits)

byte_list = []
byte = ""
for bit in correct_bits:
	byte += str(bit)
	if len(byte) == 8:
		byte_list += [hex(int(byte, 2))]
		byte = ""

print(byte_list)

hex_string = ""

for byte in byte_list:
	hex_string += byte[2:]

print(hex_string)

print(bytes.fromhex(hex_string))

for i in range(0, qword_count):
	print(byte_list[i*8:i*8+8])

Which gives the following output:

['0xb2', '0xad', '0x35', '0x4d', '0x4c', '0xcd', '0x53', '0x4d', '0x34', '0xb5', '0x4d', '0x2d', '0x54', '0xb4', '0xb5', '0x2d', '0xd4', '0xb5', '0x2d', '0x2c', '0xaa', '0xcc', '0xcc', '0xb5', '0xd4', '0xb4', '0xac', '0xb5', '0x4c', '0xb5', '0x54', '0xb5', '0xac', '0xb4', '0xaa', '0xcc', '0xd2', '0xb5', '0x2b', '0x2d', '0xcc', '0xad', '0x54', '0xb5', '0x34', '0xb5', '0x53', '0x2d', '0xcc', '0xb4', '0xaa', '0xcd', '0x53', '0x2c', '0xd2', '0xb5', '0xb4', '0xb4', '0xb5', '0x2d', '0x4c', '0xb5', '0x54', '0xad', '0x2d', '0x35', '0x33', '0x35', '0x55', '0x4c', '0xd5', '0x35', '0xca', '0xad', '0x2c', '0xb4', '0xac', '0xb5', '0x2a', '0xcd']
b2ad354d4ccd534d34b54d2d54b4b52dd4b52d2caaccccb5d4b4acb54cb554b5acb4aaccd2b52b2dccad54b534b5532dccb4aacd532cd2b5b4b4b52d4cb554ad2d353335554cd535caad2cb4acb52acd
b'\xb2\xad5ML\xcdSM4\xb5M-T\xb4\xb5-\xd4\xb5-,\xaa\xcc\xcc\xb5\xd4\xb4\xac\xb5L\xb5T\xb5\xac\xb4\xaa\xcc\xd2\xb5+-\xcc\xadT\xb54\xb5S-\xcc\xb4\xaa\xcdS,\xd2\xb5\xb4\xb4\xb5-L\xb5T\xad-535UL\xd55\xca\xad,\xb4\xac\xb5*\xcd'
['0xb2', '0xad', '0x35', '0x4d', '0x4c', '0xcd', '0x53', '0x4d']
['0x34', '0xb5', '0x4d', '0x2d', '0x54', '0xb4', '0xb5', '0x2d']
['0xd4', '0xb5', '0x2d', '0x2c', '0xaa', '0xcc', '0xcc', '0xb5']
['0xd4', '0xb4', '0xac', '0xb5', '0x4c', '0xb5', '0x54', '0xb5']
['0xac', '0xb4', '0xaa', '0xcc', '0xd2', '0xb5', '0x2b', '0x2d']
['0xcc', '0xad', '0x54', '0xb5', '0x34', '0xb5', '0x53', '0x2d']
['0xcc', '0xb4', '0xaa', '0xcd', '0x53', '0x2c', '0xd2', '0xb5']
['0xb4', '0xb4', '0xb5', '0x2d', '0x4c', '0xb5', '0x54', '0xad']
['0x2d', '0x35', '0x33', '0x35', '0x55', '0x4c', '0xd5', '0x35']
['0xca', '0xad', '0x2c', '0xb4', '0xac', '0xb5', '0x2a', '0xcd']

At this point we noticed that there was no flag in the data. We thought we may need to perform some other modifications, or that the data would maybe have the most significant bit first. However, this didn’t work either.

And then we noticed that exactly half of the possible characters that can be present in hex, aren’t ever there. The 0, 1, 6, 7, 8, 9, e, and f characters are never in there.

Which made us think that the encoding that we were using was wrong after all. So we went back to Saleae to find a different encoding that works better.

 Manchester encoding

Manchester encoding works a little bit different from the simple serial encoding that we thought it was before. Instead of bits being either a high or a low signal, the bits are either a rising or falling edge in the signal.

One of the properties of this encoding is that if it’s read as serial data at double the bit rate, there are combinations that will never occur. This is because a valid manchester signal, at double the bit rate, can only be 01 (a falling edge) or 10 (a rising edge). This means that exactly half of the possible values (00 and 11) will never occur, which is exactly what we saw when reading it as serial data. Very promising so far.

Lucky for us, Saleae Logic also has a Manchester analyzer, which we use next:

Note that we now use half the bit rate that we were using with the Async Serial analyzer, and thus also half the amount of bits per frame. This results in the following data:

Hooray! It found the start of the flag: HTB{!

Now we want to parse the analog data using Python again, for which we need an export. But because we need to find rising and falling edges, we need double the bit rate to actually detect changes. So we need exactly the export we had already made for the simple serial encoding, which we can reuse.

Due to the specifics, we didn’t end up reusing the script as well, but rather rewrote it from scratch. This is because manchester encoding actually has some difficulties:

We solved the first by checking (in Saleae Logic) that all the signal bursts start with a zero bit. This can be done because we know that the combination low-low and high-high can never occur. So the following signal (low, high, low, high, low, low, high, high, low), has to be divided into the pairs:

This means that the first low isn’t actually part of the signal, and that we are sure the signal starts with a high-low, which is a zero bit.

This also solves the second issue, as we can now use this to align the signal as well.

Putting all of this knowledge into a script, we get the following:

#!/usr/bin/env python3

input_path = "LogicExport/analog.csv"

bits = []

with open(input_path, 'r') as f:
	f.readline() # To skip the header

	while line := f.readline():
		value = float(line.split(',')[1][:-1])
		if value > 2:
			bits.append(1)
		else:
			bits.append(0)

window = 0
real_bits = []
last_full_zero = True

while window < len(bits) - 2:
	if bits[window] != bits[window + 1]:
		if not last_full_zero:
			if bits[window] > bits[window + 1]:
				real_bits.append('0')
			else:
				real_bits.append('1')
			window += 1
		else:
			last_full_zero = False
	else:
		if bits[window] == 0:
			last_full_zero = True

	window += 1

print(real_bits)

count = len(real_bits) // 33

for i in range(count):
	word = real_bits[i*33 + 1:i*33+33]
	for j in range(4):
		byte = word[j*8:j*8+8]
		print(chr(int(''.join(byte), 2)), end='')

print()

Which outputs:

['0', '0', '1', '0', '0', '1', '0', '0', '0', '0', '1', '0', '1', '0', '1', '0', '0', '0', '1', '0', '0', '0', '0', '1', '0', '0', '1', '1', '1', '1', '0', '1', '1', '0', '0', '1', '1', '0', '0', '0', '1', '1', '0', '0', '1', '1', '0', '0', '0', '0', '0', '1', '1', '0', '0', '1', '0', '0', '0', '0', '1', '1', '0', '0', '1', '0', '0', '0', '0', '1', '1', '0', '1', '0', '1', '0', '1', '0', '1', '1', '1', '1', '1', '0', '1', '1', '0', '0', '1', '1', '0', '0', '0', '1', '1', '0', '0', '0', '1', '0', '0', '0', '1', '1', '0', '0', '0', '0', '0', '0', '1', '1', '0', '1', '0', '0', '0', '0', '1', '1', '0', '1', '1', '1', '0', '0', '1', '1', '0', '0', '0', '1', '0', '0', '1', '1', '0', '1', '1', '1', '0', '0', '0', '1', '1', '1', '0', '0', '1', '0', '1', '0', '1', '1', '1', '1', '1', '0', '0', '1', '1', '0', '1', '1', '1', '0', '0', '1', '1', '0', '1', '0', '0', '0', '0', '0', '1', '1', '0', '0', '1', '0', '0', '0', '1', '1', '0', '0', '0', '0', '0', '1', '1', '1', '0', '1', '0', '1', '0', '0', '0', '1', '1', '1', '0', '0', '1', '0', '1', '1', '0', '1', '0', '0', '0', '0', '1', '0', '1', '1', '1', '1', '1', '0', '0', '1', '1', '0', '1', '0', '1', '0', '0', '1', '1', '1', '0', '0', '0', '0', '0', '0', '1', '1', '0', '1', '0', '0', '0', '1', '1', '0', '0', '0', '1', '1', '0', '0', '1', '1', '0', '0', '1', '1', '0', '0', '0', '1', '0', '0', '0', '0', '1', '0', '1', '0', '0', '0', '0', '0', '0', '0', '0', '1', '0', '1', '0', '1', '0', '0', '0', '1', '0', '0', '1', '1', '0', '0', '0', '1', '0', '1', '1', '1', '1', '0', '0', '0', '1', '1', '0', '1', '1', '1', '0', '0', '1', '1', '0', '1', '1', '0', '0', '1', '1', '1', '1', '1', '0', '1']
HTB{c0d25_f10471n9_7h20u9h_5p4c3!@*&^76}

Looks like the flag! But it doesn’t work.

So we added a bit more debugging prints into the script:

#!/usr/bin/env python3

input_path = "LogicExport/analog.csv"

bits = []

with open(input_path, 'r') as f:
	f.readline() # To skip the header

	while line := f.readline():
		value = float(line.split(',')[1][:-1])
		if value > 2:
			bits.append(1)
		else:
			bits.append(0)

window = 0
real_bits = []
last_full_zero = True

while window < len(bits) - 2:
	if bits[window] != bits[window + 1]:
		if not last_full_zero:
			if bits[window] > bits[window + 1]:
				real_bits.append('0')
			else:
				real_bits.append('1')
			window += 1
		else:
			last_full_zero = False
	else:
		if bits[window] == 0:
			last_full_zero = True

	window += 1

print(real_bits)

count = len(real_bits) // 33

for i in range(count):
	word = real_bits[i*33 + 1:i*33+33]
	print(word)
	for j in range(4):
		byte = word[j*8:j*8+8]
		print(chr(int(''.join(byte), 2)), end='')

print()

Which gives:

$ ./solver.py
['0', '0', '1', '0', '0', '1', '0', '0', '0', '0', '1', '0', '1', '0', '1', '0', '0', '0', '1', '0', '0', '0', '0', '1', '0', '0', '1', '1', '1', '1', '0', '1', '1', '0', '0', '1', '1', '0', '0', '0', '1', '1', '0', '0', '1', '1', '0', '0', '0', '0', '0', '1', '1', '0', '0', '1', '0', '0', '0', '0', '1', '1', '0', '0', '1', '0', '0', '0', '0', '1', '1', '0', '1', '0', '1', '0', '1', '0', '1', '1', '1', '1', '1', '0', '1', '1', '0', '0', '1', '1', '0', '0', '0', '1', '1', '0', '0', '0', '1', '0', '0', '0', '1', '1', '0', '0', '0', '0', '0', '0', '1', '1', '0', '1', '0', '0', '0', '0', '1', '1', '0', '1', '1', '1', '0', '0', '1', '1', '0', '0', '0', '1', '0', '0', '1', '1', '0', '1', '1', '1', '0', '0', '0', '1', '1', '1', '0', '0', '1', '0', '1', '0', '1', '1', '1', '1', '1', '0', '0', '1', '1', '0', '1', '1', '1', '0', '0', '1', '1', '0', '1', '0', '0', '0', '0', '0', '1', '1', '0', '0', '1', '0', '0', '0', '1', '1', '0', '0', '0', '0', '0', '1', '1', '1', '0', '1', '0', '1', '0', '0', '0', '1', '1', '1', '0', '0', '1', '0', '1', '1', '0', '1', '0', '0', '0', '0', '1', '0', '1', '1', '1', '1', '1', '0', '0', '1', '1', '0', '1', '0', '1', '0', '0', '1', '1', '1', '0', '0', '0', '0', '0', '0', '1', '1', '0', '1', '0', '0', '0', '1', '1', '0', '0', '0', '1', '1', '0', '0', '1', '1', '0', '0', '1', '1', '0', '0', '0', '1', '0', '0', '0', '0', '1', '0', '1', '0', '0', '0', '0', '0', '0', '0', '0', '1', '0', '1', '0', '1', '0', '0', '0', '1', '0', '0', '1', '1', '0', '0', '0', '1', '0', '1', '1', '1', '1', '0', '0', '0', '1', '1', '0', '1', '1', '1', '0', '0', '1', '1', '0', '1', '1', '0', '0', '1', '1', '1', '1', '1', '0', '1']
['0', '1', '0', '0', '1', '0', '0', '0', '0', '1', '0', '1', '0', '1', '0', '0', '0', '1', '0', '0', '0', '0', '1', '0', '0', '1', '1', '1', '1', '0', '1', '1']
HTB{['0', '1', '1', '0', '0', '0', '1', '1', '0', '0', '1', '1', '0', '0', '0', '0', '0', '1', '1', '0', '0', '1', '0', '0', '0', '0', '1', '1', '0', '0', '1', '0']
c0d2['0', '0', '1', '1', '0', '1', '0', '1', '0', '1', '0', '1', '1', '1', '1', '1', '0', '1', '1', '0', '0', '1', '1', '0', '0', '0', '1', '1', '0', '0', '0', '1']
5_f1['0', '0', '1', '1', '0', '0', '0', '0', '0', '0', '1', '1', '0', '1', '0', '0', '0', '0', '1', '1', '0', '1', '1', '1', '0', '0', '1', '1', '0', '0', '0', '1']
0471['0', '1', '1', '0', '1', '1', '1', '0', '0', '0', '1', '1', '1', '0', '0', '1', '0', '1', '0', '1', '1', '1', '1', '1', '0', '0', '1', '1', '0', '1', '1', '1']
n9_7['0', '1', '1', '0', '1', '0', '0', '0', '0', '0', '1', '1', '0', '0', '1', '0', '0', '0', '1', '1', '0', '0', '0', '0', '0', '1', '1', '1', '0', '1', '0', '1']
h20u['0', '0', '1', '1', '1', '0', '0', '1', '0', '1', '1', '0', '1', '0', '0', '0', '0', '1', '0', '1', '1', '1', '1', '1', '0', '0', '1', '1', '0', '1', '0', '1']
9h_5['0', '1', '1', '1', '0', '0', '0', '0', '0', '0', '1', '1', '0', '1', '0', '0', '0', '1', '1', '0', '0', '0', '1', '1', '0', '0', '1', '1', '0', '0', '1', '1']
p4c3['0', '0', '1', '0', '0', '0', '0', '1', '0', '1', '0', '0', '0', '0', '0', '0', '0', '0', '1', '0', '1', '0', '1', '0', '0', '0', '1', '0', '0', '1', '1', '0']
!@*&['0', '1', '0', '1', '1', '1', '1', '0', '0', '0', '1', '1', '0', '1', '1', '1', '0', '0', '1', '1', '0', '1', '1', '0', '0', '1', '1', '1', '1', '1', '0', '1']
^76}

Looking at the analog signal in Saleae Logic, we noticed that the second signal burst didn’t match; the last bit isn’t correct:

It ends on high, low, low, high, low, high, so we know that they are a falling edge, a rising edge, and another rising edge. Which should be 011, but the output of the script is 010, which is a single bit off. Changing that bit means that the second part goes from cod2 to cod3, which makes a lot more sense as well.

This is the only bit that doesn’t get extracted correctly, giving us the right flag:

HTB{c0d35_f10471n9_7h20u9h_5p4c3!@*&^76}

The reason it doesn’t get extracted correctly is because of how we exported the data. It doesn’t have a low signal between the last two high signals of the second burst. This is probably because we downsample the data, which in this specific case misses the low part of that signal. Thinking about that there are at least two potential solutions:

  1. Export at a lower ratio and handle the bitrate using the timestamps in Python
  2. Start the export at a different starting point (which can potentially be done with a time marker in Saleae Logic)

Since this is the only occurance of this bug we just fixed it manually, so we didn’t try either of the two potential solutions.

 Thoughts on the challenge

While we have seen challenges with Saleae Logic before, this is the first time that we had to do analog to digital conversion to get the answer. This meant that while it is a Saleae Logic file, a good part of the challenge is actually done outside of it. This made it a new kind of challenge for us.

The addition of the digital data for the first burst of signals was very helpful, and also made the challenge much nicer. If that hadn’t been there it would have been a much harder challenge, because the Saleae Logic analyzers wouldn’t have been of any help. It being there made the challenge a lot of fun, whereas it would probably have been very frustrating if it hadn’t been there.

The main lessons we learned was how to work with analog data from a Saleae Logic file, and some more on how Manchester encoding works.