CrewCTF2024 misc/Sniff Writeup

Challenge

Description

I came across this mysterious device. So I hooked up my logic analyzer and recorded somebody using it. (capture.sol) This challenge has two flags in the flag{} format

Images

Device

Everything

Raspberry Pi first

Raspberry Pi second

Logic Analyzer first

Logic Analyzer second

Display first

Display second

Intro

Instead of sleeping, I made the mistake^Wwise decision of looking at my Discord notification that said there was a hardware challenge in this CTF. It just so happened that there it was using an ATmega-powered keyboard and an e-paper screen, and it almost seemed like a coincidence since I was designing my own keyboard and wanted to interface with an e-paper screen in the near future. This seemed like a great learning opportunity so I started working on the two-part challenge.

There were a few files inside the dist.zip, with the most interesting one being capture.sal which was technically a zip but actually the analyzer file for Salae. Sadly it seemed to need their proprietary program to open.

Screenshot of Saleae Logic 2, the program used for viewing the dump.

The first thing I did was figuring out what each channel was connected to and what it meant. It seemed that the keyboard and display were controlled by the Raspberry Pi which then seemed to go to the logic analyzer. So I looked at what each channel was connected to and based on its connected pin on the Pi, I found its function via pinout.xyz. I ended up with this:

Channel 0: P03  I2C SDA
Channel 1: P05  I2C SCL
Channel 2: P11  GPIO 17 (busy)
Channel 3: P13  GPIO 27 (reset)
Channel 4: P15  GPIO 22 (data/command)
Channel 5: P21  MOSI
Channel 6: P23  SPI0 SCLK
Channel 7: P24  SPI0 CE0

Raspberry Pi Pinout

Part 1

In Logic 2, I opened the I2C analyzer and outputted the dump in the terminal tab into a file

I2C analyzer screenshot in Logic 2 in the Terminal view. I2C analyzer screenshot in Logic 2 in the Data Table view. It seemed there was a lot of NUL bytes being sent, probably indicating that there wasn’t anything during that cycle, and a few seconds later there were also some other different bytes with NUL and some 0x01 bytes in between, and these seemed to be printable ASCII.

read to 0x5F ack data: 0x01
read to 0x5F ack data: 0x01
read to 0x5F ack data: 0x66
read to 0x5F ack data: 0x6C
read to 0x5F ack data: 0x61
read to 0x5F ack data: 0x67
read to 0x5F ack data: 0x7B
read to 0x5F ack data: 0x37
read to 0x5F ack data: 0x01
read to 0x5F ack data: 0x31
read to 0x5F ack data: 0x37
read to 0x5F ack data: 0x66
read to 0x5F ack data: 0x37
read to 0x5F ack data: 0x35
read to 0x5F ack data: 0x01
read to 0x5F ack data: 0x33
read to 0x5F ack data: 0x32
read to 0x5F ack data: 0x7D
read to 0x5F ack data: 0x01
read to 0x5F ack data: 0x0D

Filtering out the 0x00 and 0x01 data bytes and converting to ASCII results in flag{717f7532}.

Part 2

Logic 2 SPI analyzer output.

I first outputted the SPI dump from the analyzer into a file and kept only the MOSI and MISO columns.

Time [s],Packet ID,MOSI,MISO
4.108880200000000,0,0x12,0x00
5.109988000000000,0,0x01,0x00
5.110044320000000,0,0xF9,0xFF
5.110062800000000,0,0x00,0xFF
5.110081280000000,0,0x00,0xFF
5.110125600000000,0,0x3A,0x00
5.110167440000000,0,0x1B,0xFF
5.110210120000000,0,0x3B,0x00
5.110251760000000,0,0x0B,0xFF

To actually understand what’s going on, I couldn’t find any proper documentation initially. There wasn’t even a proper datasheet on DigiKey; the “datasheet” was just a summary of the product.

DigiKey “datasheet”

Then I found the Python library source from Pimoroni of their epaper screens, which is probably what was used to make this challenge.

def setup(self):
  """Set up Inky GPIO and reset display."""
  if not self._gpio_setup:
      if self._gpio is None:
          try:
              import RPi.GPIO as GPIO
              self._gpio = GPIO
          except ImportError:
              raise ImportError('This library requires the RPi.GPIO module\nInstall with: sudo apt install python-rpi.gpio')
      self._gpio.setmode(self._gpio.BCM)
      self._gpio.setwarnings(False)
      self._gpio.setup(self.dc_pin, self._gpio.OUT, initial=self._gpio.LOW, pull_up_down=self._gpio.PUD_OFF)
      self._gpio.setup(self.reset_pin, self._gpio.OUT, initial=self._gpio.HIGH, pull_up_down=self._gpio.PUD_OFF)
      self._gpio.setup(self.busy_pin, self._gpio.IN, pull_up_down=self._gpio.PUD_OFF)

      if self._spi_bus is None:
          import spidev
          self._spi_bus = spidev.SpiDev()

      self._spi_bus.open(0, self.cs_pin)
      self._spi_bus.max_speed_hz = 488000

      self._gpio_setup = True

  self._gpio.output(self.reset_pin, self._gpio.LOW)
  time.sleep(0.5)
  self._gpio.output(self.reset_pin, self._gpio.HIGH)
  time.sleep(0.5)

  self._send_command(0x12)  # Soft Reset
  time.sleep(1.0)
  self._busy_wait()

def _update(self, buf_a, buf_b, busy_wait=True):
  """Update display.

  Dispatches display update to correct driver.

  :param buf_a: Black/White pixels
  :param buf_b: Yellow/Red pixels

  """
  self.setup()

  self._send_command(ssd1608.DRIVER_CONTROL, [self.rows - 1, (self.rows - 1) >> 8, 0x00])
  # Set dummy line period
  self._send_command(ssd1608.WRITE_DUMMY, [0x1B])
  # Set Line Width
  self._send_command(ssd1608.WRITE_GATELINE, [0x0B])
  # Data entry squence (scan direction leftward and downward)
  self._send_command(ssd1608.DATA_MODE, [0x03])
  # Set ram X start and end position
  xposBuf = [0x00, self.cols // 8 - 1]
  self._send_command(ssd1608.SET_RAMXPOS, xposBuf)
  # Set ram Y start and end position
  yposBuf = [0x00, 0x00, (self.rows - 1) & 0xFF, (self.rows - 1) >> 8]
  self._send_command(ssd1608.SET_RAMYPOS, yposBuf)
  # VCOM Voltage
  self._send_command(ssd1608.WRITE_VCOM, [0x70])
  # Write LUT DATA
  self._send_command(ssd1608.WRITE_LUT, self._luts[self.lut])

  if self.border_colour == self.BLACK:
      self._send_command(ssd1608.WRITE_BORDER, 0b00000000)
      # GS Transition + Waveform 00 + GSA 0 + GSB 0
  elif self.border_colour == self.RED and self.colour == 'red':
      self._send_command(ssd1608.WRITE_BORDER, 0b00000110)
      # GS Transition + Waveform 01 + GSA 1 + GSB 0
  elif self.border_colour == self.YELLOW and self.colour == 'yellow':
      self._send_command(ssd1608.WRITE_BORDER, 0b00001111)
      # GS Transition + Waveform 11 + GSA 1 + GSB 1
  elif self.border_colour == self.WHITE:
      self._send_command(ssd1608.WRITE_BORDER, 0b00000001)
      # GS Transition + Waveform 00 + GSA 0 + GSB 1

  # Set RAM address to 0, 0
  self._send_command(ssd1608.SET_RAMXCOUNT, [0x00])
  self._send_command(ssd1608.SET_RAMYCOUNT, [0x00, 0x00])

  for data in ((ssd1608.WRITE_RAM, buf_a), (ssd1608.WRITE_ALTRAM, buf_b)):
      cmd, buf = data
      self._send_command(cmd, buf)

  self._busy_wait()
  self._send_command(ssd1608.MASTER_ACTIVATE)

It was also communicating over SPI which seemed to indicate that this was the proper library. Then I looked at the setup() and _update() functions in library/inky/inky_ssd1608.py, and the SPI commands that were sent in the analyzed dump log matched exactly, including each byte of the LUT table.

All the SPI commands used in the library are used with named constants that are defined library/inky/ssd1608.py:

"""Constants for SSD1608 driver IC."""
DRIVER_CONTROL = 0x01
GATE_VOLTAGE = 0x03
SOURCE_VOLTAGE = 0x04
DISPLAY_CONTROL = 0x07
NON_OVERLAP = 0x0B
BOOSTER_SOFT_START = 0x0C
GATE_SCAN_START = 0x0F
DEEP_SLEEP = 0x10
DATA_MODE = 0x11
SW_RESET = 0x12
TEMP_WRITE = 0x1A
TEMP_READ = 0x1B
TEMP_CONTROL = 0x1C
TEMP_LOAD = 0x1D
MASTER_ACTIVATE = 0x20
DISP_CTRL1 = 0x21
DISP_CTRL2 = 0x22
WRITE_RAM = 0x24
WRITE_ALTRAM = 0x26
READ_RAM = 0x25
VCOM_SENSE = 0x28
VCOM_DURATION = 0x29
WRITE_VCOM = 0x2C
READ_OTP = 0x2D
WRITE_LUT = 0x32
WRITE_DUMMY = 0x3A
WRITE_GATELINE = 0x3B
WRITE_BORDER = 0x3C
SET_RAMXPOS = 0x44
SET_RAMYPOS = 0x45
SET_RAMXCOUNT = 0x4E
SET_RAMYCOUNT = 0x4F
NOP = 0xFF

I then noticed that there was a long string of bytes being sent after a 0x24 which in the library indicated that it was the memory buffer for the black/white channel ending with a 0x00 MISO, with the yellow/red channel afterward with a 0x26 MOSI and also ended with 0x00 MISO

...
0x19,0xFF
0x01,0xFF
0x00,0xFF
0x3C,0x00
0x01,0xFF
0x4E,0x00
0x00,0xFF
0x4F,0x00
0x00,0xFF
0x00,0xFF
0x24,0x00
0xFF,0xFF
0xFF,0xFF
0xFF,0xFF
0xFF,0xFF
0xFF,0xFF
0xFF,0xFF
0xFF,0xFF
0xFF,0xFF
0xFF,0xFF
0xFF,0xFF
...

There also seemed to be two different updates at around 5 seconds and 70 seconds.

Logic 2 SPI analyzer with 0x24 searched to show when each screen update started.

However, the number of bytes written was 4250, which wasn’t the 3812.5 or 2756 bytes I was expecting. This wasn’t divisible by 250 nor 122 and so I was stuck for a long time. Looking through the library source for more than an hour with my tired self didn’t help much either. As a last ditch attempt, I tried converting the raw bytes into an image via Pillow, I used the L mode (8bpp) and just got an uninteresting garbled image.

Garbled image reflecting my sadness at being unable to get the flag.

Part 2 Part 2: Electric Boogaloo

Joey asking in the CTF’s Discord about the challenge. Me being surprised in the Discord for my stupidity that I blame on being tired.

After waking up and working on the CTF after it ended, my partner asked on the Discord and found some interesting very helpful information. It turned out the image was a packed 1bpp image. This meant that each byte in the memory framebuffer contained 8 pixels (8 bits / 1 bits per pixel = 8 pixels).

# under show()
buf_a = numpy.packbits(numpy.where(region == BLACK, 0, 1)).tolist()
buf_b = numpy.packbits(numpy.where(region == RED, 1, 0)).tolist()

In the Python source, this was shown by buf_a and buf_b being packed bits of 1bpp via NumPy. I don’t know much about NumPy, so this was a skill issue as I initially assumed it was a complicated way of saving all the black and red pixels into lists. This is a good reminder that the documentation should be checked for all unfamiliar functions instead of naively assuming what they seem to do.

Also in addition to the screen being rotated by 90 degrees, the vertical resolution is actually 136 pixels and not 120 according to the driver.

Knowing all this solved all my problems as 4250 * 8 was indeed divisible by 250 and the actual vertical resolution 136.

All I had to do was change the Pillow mode when converting the bytes to an image from L (8bpp) to 1 (1bpp) and the (rotated) resolution from (250, 16) to (136, 250) and got an actual image.

Extracted image of the display showing the initial message shown in the challenge description.

I used the first updated bytes which was the screen shown in the challenge’s screenshots. Using the second update’s bytes gave half of the flag in the black/white channel and the other half in the yellow/red channel.

The flag in the black/white channel. The flag in the yellow/red channel.

Each character index in both channels seemed to alternate, so the actual flag was flag{ec9cf2b7}. After I finished writing this writeup and seeing the two images side-by-side, they probably could’ve been overlayed after one’s colours are inverted, and is probably what was meant by “stitching” the channels together.

Conclusion

This was my most favourite CTF challenge by far and I learned a lot, especially about stuff I wanted to learn like how SPI e-paper screens work and not be lost with I2C. I am personally now curious whether the SPI screens can be interfaced directly with the MCU instead of going through an intermediate daughterboard/HAT and how different the protocol for parallel screens are since they’re much faster and use more pins.