← Back to Writeups
CUSTOMMediumHardware

What the Shard Displayed

XESXOR9/2/20265 min read
#hardware#custom#medium#i2c_decoding#scl_sda_identification#ssd1306_init_recognition#gddram_framebuffer_reconstruct#page_addressing_render#linux

What the Shard Displayed

Platform: CACTF2026 | Category: Hardware | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-27 | Status: Solved Techniques: i2c_decoding, scl_sda_identification, ssd1306_init_recognition, gddram_framebuffer_reconstruction, page_addressing_render

Summary

Task: a sigrok/PulseView logic-analyzer capture (capture.sr) of an embedded device. Solution: identify the two active channels as I2C SCL/SDA, decode with sigrok-cli, recognize the SSD1306 OLED init at address 0x3C, split control bytes (0x00 command / 0x40 data), collect 3 GDDRAM frames and render the 128x64 page-addressed framebuffers to recover the flag drawn on the OLED.

What the Shard Displayed — CACTF2026

Description

The Signet shattered, the great houses fell to arguing with steel, and Alyss, Queen of Quiet Marches, began sending her dead to watch the living. One of her crows fell over our winter line, a maker's device bound beneath its wing, an eye she threaded through dead flesh to count our banners. Fed a current, it still wakes: it checks its roost, marks the hour it last saw us, and paints what it saw onto its pane. Reconstruct it, and learn what her eye found of us before the Hollow Host moves. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: We are given a logic-analyzer capture of a small embedded device. The device drives an OLED display over a serial bus. The goal is to reconstruct the bus traffic and recover the images the device painted onto its screen — one of which contains the flag.

Semantic clue mapping

The flavor text maps directly to the technique:

  • "maker's device / eye threaded through dead flesh" → a hobbyist hardware device driving an OLED "eye" (a logic capture of the display bus)
  • "checks its roost" → the I2C device address / chip select (0x3C)
  • "marks the hour it last saw us" → a clock/time frame ("05:17")
  • "paints what it saw onto its pane" → the OLED framebuffer (GDDRAM) where the flag is drawn ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

The capture container

The challenge download is a ZIP; inside is capture.sr, a sigrok / PulseView logic-analyzer capture. A .sr file is itself a ZIP container. Its metadata file describes the session:

  • sigrok 0.5.2
  • total probes=8
  • samplerate=2 MHz
  • unitsize=1 — each stored sample is one byte, packing all 8 channels as bits (channel Dn = bit n)

The logic samples are stored in concatenated logic-1-* chunks (4,000,000 samples total).

Identifying the protocol

Rather than guessing, the active channels are found by measuring per-channel duty cycle and transition counts over the sample stream (numpy): ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

ChannelHigh %TransitionsRole
D0~90%64642fast, idle-high → SCL (clock)
D1~84%4256slower, idle-high → SDA (data)
D2–D7—idleunused

Two wires, both idle-high, one fast clock plus one slower data line — the signature of I2C.

Solution

1. Decode I2C with sigrok-cli

bash
sigrok-cli -i extracted/capture.sr -P i2c:scl=D0:sda=D1 -A i2c

Key observations from the decode:

  • Write address 0x3C — the standard SSD1306 OLED I2C address.
  • The command stream begins with the classic SSD1306 initialization sequence: AE D5 80 A8 3F D3 00 40 8D 14 20 00 A1 C8 DA 12 81 CF D9 F1 DB 40 A4 A6 AF (display off, clock div, multiplex 0x3F=64, charge pump, memory mode, segment remap, COM scan, contrast, display on…).
  • Column addressing 21 00 7F (columns 0–127) and page addressing 22 00 07 (pages 0–7) → a 128×64 display. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

2. Split the SSD1306 stream (control bytes)

After the 0x3C write, each I2C payload is prefixed by an SSD1306 control byte:

  • 0x00 → the following bytes are commands
  • 0x40 → the following bytes are GDDRAM (display RAM) pixel data

Parsing the transactions (parse_i2c.py) and collecting only the 0x40 data yields 3072 GDDRAM bytes = 3 frames of 1024 bytes each (128×64 = 8 pages × 128 columns; in page addressing each byte is 8 vertical pixels).

python
#!/usr/bin/env python3
"""Split SSD1306 I2C stream into command vs GDDRAM data (0x00 vs 0x40)."""
import re
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Each I2C write to 0x3C carries: <control byte> <payload...>
# 0x00 -> command bytes, 0x40 -> display RAM (GDDRAM) bytes.
gddram = bytearray()
for control, payload in iter_i2c_writes("i2c_decode.txt"):  # (control, [bytes])
    if control == 0x40:
        gddram.extend(payload)

assert len(gddram) == 3072, len(gddram)  # 3 frames * 1024 bytes
open("gddram.bin", "wb").write(gddram)

3. Render the framebuffers

SSD1306 uses vertical byte packing: within a page, bit 0 is the top pixel and bit 7 the bottom pixel of that 8-pixel-tall band; pages stack top to bottom, columns run left to right (render.py). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
#!/usr/bin/env python3
"""Render SSD1306 128x64 page-addressed GDDRAM frames to PNG."""
from PIL import Image

W, H = 128, 64
PAGES = H // 8            # 8 pages
FRAME = W * PAGES         # 1024 bytes per frame

data = open("gddram.bin", "rb").read()
for f in range(len(data) // FRAME):
    frame = data[f * FRAME:(f + 1) * FRAME]
    img = Image.new("1", (W, H), 0)
    px = img.load()
    for page in range(PAGES):
        for col in range(W):
            byte = frame[page * W + col]
            for bit in range(8):          # bit 0 = top pixel of the page
                if byte & (1 << bit):
                    px[col, page * 8 + bit] = 1
    img.save(f"frame_{f}.png")

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Result:

  • frame_0.png — an eye graphic ("her eye")
  • frame_1.png — the text "05:17" ("the hour it last saw us")
  • frame_2.png — the flag text painted onto the display

Reading frame 2 gives the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍