AVENGERCON X Badge CTF
Introduction
Richard Shmel has been making badges for Army's AVENGERCON for years. The first electronic badges featured a scavenger hunt for codes to activate lights. The electronic badge became more complicated (and fun) for the second and third generations by including a CTF hidden in and around the badge.
| AVENGERCON X Badge Front |
If you couldn't figure out the AVENGERCON X challenges, or didn't have time to try, here is a walk through for defeating Shmel's badge. Tasks range from decoding binary, decrypting ciphertext, reading morse code, implementing a privilege escalation, and coding a buffer overflow. While the flag submission site is now off-line, hopefully documenting the CTF here will allow continued fun figuring out all of the puzzles.
| AVENGERCON X Badge Back |
Easy Challenge
On the front of the badge are a bunch of 8bit numbers. You could do solve it the hard way by converting each of them into their hexadecimal values and looking them up on an ASCII table ...
| Binary | Hexadecimal | ASCII |
|---|---|---|
| 01100110 | 66 | f |
| 01101100 | 6C | l |
| 01100001 | 61 | a |
| 01100111 | 67 | g |
| 01111011 | 7B | { |
| 00110010 | 32 | 2 |
| 01100101 | 65 | e |
| 01111010 | 7A | z |
| 01111101 | 7D | } |
... or just paste them into a converter and reveal the flag -- flag{2ez}.
Serial Port Challenge
On the side of the badge is a micro-USB port. Given that AVENGERCON IX's CTF was accessed through the serial port, it seemed logical a flag would be hidden there. The easiest way to get the flag was simply plugging the badge into a Linux box, observing the USB device in /dev, cat'ing the output and pushing the badge's reset button.
cat /dev/ttyUSB0
AC10 badge online
Status locked
flag{teKdy7uXcdaAaCQ3}
Officially the challenge was to figure out the proper way to interact with the serial port because the remainder of the challenges require actually interfacing with it. This was really just a matter of picking the correct baud rate - 1200, 2400, 4800, 9600, 18200, 38400, 57800, 115600. Just for the nerds, why are baud rates those numbers anyway? It really goes back to the original telco days for symbols per second and is governed by time divisions based on timing crystals for older electronics.
Anyway, just using minicom and trying to read from the port easily identifies the correct baud rate. Run the program with a set rate and push the reset button. When it finally comes back intelligibly, you've found the correct baud rate.
minicom -D /dev/ttyUSB0 -b 115600
minicom -D /dev/ttyUSB0 -b 57600
minicom -D /dev/ttyUSB0 -b 38400
Python Program
Everything from this point on requires interacting with the badge using it's API. Fortunately, the CTF provided that information:
For the rest of the CTF solutions, we'll slowly expand a Python program by adding functions for each challenge. But to get started, this skeleton code will become the basis for everything else.
import serial
import string
import time
import sys
SERIAL_PORT ='/dev/ttyUSB0'
BAUD_RATE = 38400
################################################################################
# function opens the serial port and returns an object
################################################################################
def open_serial():
print(f"[INFO] Connecting to {SERIAL_PORT}")
try:
ser = serial.Serial(
port = SERIAL_PORT,
baudrate = BAUD_RATE,
bytesize = serial.EIGHTBITS,
parity = serial.PARITY_NONE,
stopbits = serial.STOPBITS_ONE,
timeout = 1
)
return ser
except serial.SerialException as e:
print(f"[ERROR] error - {e}")
sys.exit(1)
################################################################################
# function sends commands and payloads to the serial port with a reponse retry
################################################################################
def send_serial(ser, command, payload, delay=0.015):
retry = True
try:
while retry:
retry = False if delay >= 1 else True
if len(payload) > 0:
ser.write(command + payload)
else:
ser.write(command)
time.sleep(delay)
if ser.in_waiting > 0:
response = ser.read(ser.in_waiting)
return response
elif retry:
print(f"[ERROR] no response ... extending delay\n")
delay = 1
else:
print(f"[ERROR] no response ... fail\n")
return b"DEADBEEF"
except Exception as e:
print(f"[ERROR] {e}")
ser.close()
sys.exit(1)
################################################################################
# main ... obviously
################################################################################
def main():
print(f"Beat Navy")
if __name__ == "__main__":
main()
This program provides a skeleton from which to solve the rest of the challenges - open_serial() and send_serial() will be used by everything else. Intuitively from the name, open_serial() will open the serial port with the appropriate baud rate, stop bit, and parity bit.
The send_serial() function takes three parameters and an optional fourth. The first is the serial port object returned by open_serial(). The second is the appropriate badge op code from the reference document. The third is whatever byte string is intended for a payload. The optional fourth represents a wait interval for making sure the chip has enough time to respond without reading too quickly (defaults to 15 milliseconds). If there is no data after the specified wait interval, the function will pause for a second and then try again before bailing out.
In testing, I found trying to read the port faster than 15 milliseconds usually resulted in no data. It would be possible to just loop against reading the ser.in_waiting() function until data was available but I wanted the function to kill itself if nothing came back.
Unlock the Badge Challenge
You probably noticed the badge indicated a "locked" status. The next flag comes from unlocking the badge. It's a five digit PIN which means you've potentially got 99,999 numbers to brute force through. Add a simple function to perform five nested for loops to iterate through all 99,999 PIN combinations. Exception handling provides an easy way to terminate the loop when the proper PIN is discovered. There's an API call for unlocking the badge.
| 0xC0 - Authenticate (Unlock) |
| The badge boots into an AUTH_LOCKED state and will drop all hardware commands until a valid 5-digit PIN is provided. |
|
Sending the 0xC0 opcode followed by a payload of the digits (in binary - not as ASCII numbers) to the badge attempts to unlock it. A failed PIN will return 0x30 and 0x00 in the response. Success comes from detecting bytes 0x30 and 0x01 in the response followed by the flag.
################################################################################
# function brute forces the badge PIN
################################################################################
def brute_unlock():
ser = open_serial()
try:
for byte1 in range(10):
for byte2 in range(10):
for byte3 in range(10):
for byte4 in range(10):
for byte5 in range(10):
payload = bytes([byte1,byte2,byte3,byte4,byte5])
command = bytes([0xc0])
response = send_serial(ser, command, payload)
if response[0] == 0x30 and response[1] == 0x01:
print(f"[INFO] Found PIN -- {byte1} {byte2} {byte3} {byte4} {byte5}")
raise ValueError(f"[INFO] terminating brute force")
except Exception as e:
print(f"{str(e)}")
ser.close()
################################################################################
# main ... obviously
################################################################################
def main():
brute_unlock()
When approaching the brute force, I had the classic question of whether to loop in reverse thinking Richard would hide the PIN at the high end. But then I knew he'd think we'd think that and put it back at the low end. But then I ... yeah, you get the idea. Stop thinking about it and just let the computer do the work to reveal the flag -- flag{96Aka5ShrDq7av35}.
Blinking Lights Challenge
The next challenge was to activate the badge's LED. The API provided an opcode, 0xC1, for controlling the states of the LED.
| 0xC1 - LED Control |
| Overrides the default LED state to trigger internal hardware tasks. |
|
Two quick functions to the program take care of this. The first is unlock() whose purpose should be intuitively obvious to the most casual observer. Rather than brute forcing the badge everytime, unlock() simply sends the 80683 PIN previously discovered to enable the rest of the badge's functions.
################################################################################
# function unlocks the badge
################################################################################
def unlock():
ser = open_serial()
payload = bytes([8,0,6,8,3])
command = bytes([0xc0])
response = send_serial(ser, command, payload)
print(f"[INFO] {response}")
ser.close()
################################################################################
# function activates the badge LEDs
################################################################################
def leds():
ser = open_serial()
payload = bytes([0x01])
command = bytes([0xc1])
response = send_serial(ser, command, payload)
if response == b'\x31\x01':
print(f"[INFO] {response}")
ser.close()
################################################################################
# main ... obviously
################################################################################
def main():
unlock()
leds()
The addition of the leds() function simply sends the aforementioned opcode and LED status value to the badge. Finally, modify the main() to call the new functions. Running the program will reveal the LED is now on ... but blinking. Richard is a die hard RF junkie so it should be no surprise the LED is blinking in morse code. Watch the blinks for awhile and you'll eventually derive the next flag:
- ⋅ - ⋅ ⋅ - - - - - - - - ⋅ ⋅ ⋅
CWMODE
Encryption Challenge
All CTF's tend to incorporate an encryption challenge of some sort. The CTF prompt itself was about two factor authentication. The API document provides opcode 0xC2 for activating a key polling feature.
| 0xC2 - Key Polling Enable |
| Enables a background polling task for the deluxe 2FA hardware
addition! Only 29.99 a month! Once the 2FA key is pulled, the task ends and the state machine falls back to idle. |
|
Seemingly nothing happens. Now look closely at the front of the AVENGERCON X badge. There is a picture of a key beside two jumper pins. While the badge is in polling mode, use something metallic and short the pins. This causes the badge to reveal a series of bytes. Coincidentally, the number of bytes returned matches the number of hexadecimal bytes printed on the back of the AVENGERCON X badge. Now simply XOR the provided decryption key onto the badge's cipher text and reveal the flag -- flag{7d86clv72vzksdwgv4fka9padb}.
The following crypto() function will put the badge into polling mode, wait for the jumpers to be shorted, print the decryption key, perform the XOR, print the plaintext, and return the badge to normal operation. Remember to call unlock() and crypto() from the main() function.
################################################################################
# function decodes the badge's encrypted 32 byte code
################################################################################
def crypto():
ser = open_serial()
payload = bytes([0x01])
command = bytes([0xc2])
response = send_serial(ser, command, payload)
polling = True
print(f"Put the jumper on the badge pins")
while polling:
time.sleep(10)
print(f"Waiting ...")
if ser.in_waiting > 0:
response = ser.read(ser.in_waiting)
print(f"[INFO] decryption key : {response}")
print(f"[INFO] decryption key length : {len(response)} bytes")
polling = False
break
# decrypt the cipher text (XOR)
ctext = bytes([0xE9, 0xD3, 0xA8, 0x9A, 0x38, 0xD9, 0xB8, 0x8F, 0x17, 0x55, 0xA8, 0x6E, 0x41, 0xB5, 0xDC, 0xFE, 0xBC, 0xEC, 0x39, 0xEB, 0xC9, 0xC1, 0x78, 0x74, 0x40, 0x7F, 0xFE, 0x76, 0x75, 0x2C, 0xC8, 0x46])
ptext = bytes(c ^ k for c, k in zip(ctext, response))
print(f"[INFO] plaintext : {ptext.hex()}")
print(f"[INFO] plaintext : {ptext.decode('ascii')}")
# turn off 2FA polling
payload = bytes([0x00])
command = bytes([0xc2])
response = send_serial(ser, command, payload)
ser.close()
Tokens Challenge
This challenge prompts with "HA HA HA I HAVE MORE SUPER COOL AVENGER TOKENS THAN YOU DO. ALL CAPS MEANS I AM YELLING!" First, you need to determine how many tokens you have. Second, you need to get more tokens. Let's look at the two badge API opcodes that help.
| 0xC6 - Increment Avenger Token |
| Increments the internal token counter located in the EEPROM profile. Triggering this command enforces a strict 255-second cooldown before it can be called again. Subject to memory integrity checksum (0x04). |
|
| 0xC7 - Diagnostic Profile Dump |
| Helper function that dumps the entirety of the 8-byte EEPROM profile block, regardless of read cooldowns, and evaluates the checksum integrity. |
|
Sending opcode 0xC7 to the unlocked badge and interpreting the resultant byte at offset 2 shows the badge only has one token in its default state. The solution to this challenge requires increasing that token count above some unknown value.
There are two ways to approach this challenge. The first would make repeated use of the 0xC6 opcode to just keep incrementing the token count until it crosses a threshold. According to the documentation, each call of this function imposes a 255 second cooldown period before it can be called again. If the threshold were to max out the byte - 0xFF - that would require 254 API calls costing 64,770 seconds of cooldown, or roughly 18 hours.
What if that cooldown time could be overridden? With the 0xC5 opcode, its possible to override the high and low bytes of the cooldown timer at offsets 3 and 4. Changing the cooldown to zero would allow rapidly calling the 0xC6 opcode to increment the token without waiting.
| 0xC5 - Write |
| Writes a single byte to the EEPROM. |
|
But if you're already going to modify the table, why not just modify the token counter directly and skip all the repetitive writes? That's option 2. This challenge will require some additional helper functions -- calc_checksum() and fix_checksum() -- to make things easier (and which will be re-used for later challenges). There is a two byte checksum on the EEPROM profile block and the AVENGERCON badge likes to "not work" if the contents of the block don't compute against the stored checksum. The fix_checksum() function will simply read the EEPROM block, call calc_checksum() to generate the new hexadecimal values using the modulo 65535 checksum algorithm, and then use the 0xC5 opcode to write the corrected values.
NOTE: There was some trial and error figuring out which checksum algorithm to use. It really just involved sending a "good" buffer into online checksum calculators and then looking at which one produced the same values that were currently in the checksum fields.
################################################################################
# function computes new modulo 65535 checksum
################################################################################
def calc_checksum(buffer):
sum = 0
for i in range(len(buffer)):
sum += buffer[i]
print(f"[DEBUG] sum = {sum:02X}")
chk = sum % 65535
hi = (chk >> 8) & 0xFF
lo = chk & 0xFF
print(f"[DEBUG] checksum is {chk:02X}")
return hi, lo
################################################################################
# function writes corrected checksum
################################################################################
def fix_checksum():
print(f"[DEBUG] fix_checksum()")
ser = open_serial()
command = bytes([0xc7])
response = send_serial(ser, command, '')
# fix checksum
hi, lo = calc_checksum(response[1:7])
payload = bytes([0x07, lo])
command = bytes([0xc5])
response = send_serial(ser, command, payload)
print(f"[DEBUG] {response}")
payload = bytes([0x06, hi])
command = bytes([0xc5])
response = send_serial(ser, command, payload)
print(f"[DEBUG] {response}")
ser.close()


