VnutZ Domain
Copyright © 1996 - 2026 [Matthew Vea] - All Rights Reserved

2026-07-15
Featured Article

AVENGERCON X Badge CTF

[index] [25 page views]
Tagged As: Army, Cyber, Hacking, How To, and Programming

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

BinaryHexadecimalASCII
0110011066f
011011006Cl
0110000161a
0110011167g
011110117B{
00110010322
0110010165e
011110107Az
011111017D}

... 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.
  • Request: 0xC0 [Digit 1] [Digit 2] [Digit 3] [Digit 4] [Digit 5]
    • Note: Digits must be provided as raw hex values (e.g., 0x09, not ASCII '9').
  • Response (Success): 0x30 0x01
  • Response (Failure): 0x30 0x00

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.
  • Request: 0xC1 [State] (Where 0x00 is OFF and 0x01 is ON).
  • Response: 0x31 [State] (Command Acknowledged)

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.
  • Request: 0xC2 [State] (Where 0x00 is OFF and 0x01 is ON).
  • Response: 0x32 [State] (Command Acknowledged)

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).
  • Request: 0xC6
  • Response (Success): 0x36 0x01 0x00 [Current Token Count]
  • Response (Error): 0x36 0x00 [Cooldown Seconds Remaining] [Current Token Count or Error Code 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.
  • Request: 0xC7
  • Response: 0x37 [8 Bytes of Profile Data] [Corruption Flag]
      Profile Data Fields (Bytes 0-7):
    • Byte 0: ID Byte 1
    • Byte 1: ID Byte 2
    • Byte 2: AvengerCon Token Count
    • Byte 3: Cooldown Timer (High byte)
    • Byte 4: Cooldown Timer (Low byte)
    • Byte 5: Privilege Level (0 for Basic, 1 for Admin)
    • Byte 6: Checksum (High byte)
    • Byte 7: Checksum (Low byte)
  • Corruption Flag: 0x00 = Valid Checksum, 0x01 = Checksum Corrupted.

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.
  • Request: 0xC5 [Address] [Data Byte]
  • Response (Success): 0x35 0x01 0x00
  • Response (Error): 0x35 0x00 [Global Error Code]

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()


More site content that might interest you:

Whatever helps you feel superior to me, eh?


Try your hand at fate and use the site's continuously updating statistical analysis of the MegaMillions and PowerBall lotteries to choose "smarter" number. Remember, you don't have to win the jackpot to win money from the lottery!


Tired of social media sites mining all your data? Try a private, auto-deleting message bulletin board.


paypal coinbase marcus