HTB Cyber Apocalypse 2026 - The Salt Crown: Hardware / What the Shard Displayed (Medium)

Author: Hubert Feyrer / hubertf - 31.07.2026

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

A logic capture of an I2C bus with an OLED display on it. The flag was never text - it was drawn to a 128x64 screen, and the capture holds the pixel data.

1. Download

$ ls -la
-rw-rw-r--@ 1 feyrer staff 35919 Jul 17 01:13 capture.sr

Another sigrok capture - so PulseView again.

2. Docker/nc - what we get

Neither. Offline capture only.

PulseView showing the two-channel capture with the I2C decoder attached

Two channels: d0 pulses steadily (a clock), d1 carries data. Note the gaps in the clock - they turn out to matter.

Conclusion: Clock plus data on two wires is I2C. Attaching the decoder with d0 = SCL and d1 = SDA produces valid transactions, which confirms the guess.

3. Analysis steps

3.1 Identify the device (success)

I2C addresses are the fastest way to identify hardware.

$ head export1.txt
1962491-1962639 I2C: Address/Data: Address write: 3C
1962470-1962470 I2C: Address/Data: Start
1962639-1962660 I2C: Address/Data: Write
1962660-1962681 I2C: Address/Data: ACK
1962681-1962850 I2C: Address/Data: Data write: 00
1962850-1962871 I2C: Address/Data: ACK
1962871-1963039 I2C: Address/Data: Data write: AE
...

0x3C is the default address of the SSD1306/SSD1315 family of small OLED displays. And 0xAE as the first command is display off - the standard opening move of an SSD1306 init sequence. Two other addresses appear on the bus (0x50, 0x68 - an EEPROM and an RTC), which are the "checks its roost, marks the hour" of the challenge text and irrelevant here.

Conclusion: A 128x64 OLED. The datasheet gives the protocol: every transaction starts with a control byte, 0x00 for commands and 0x40 for pixel data.

3.2 Read the init sequence (success)

The geometry and the memory layout are not guesses - the init commands state them.

A8 3F        multiplex ratio 0x3F+1 = 64 rows
20 00        memory addressing mode = horizontal
21 00 7F     column range 0..127     = 128 columns
22 00 07     page range   0..7       = 8 pages of 8 rows

In horizontal addressing one data byte is a vertical run of eight pixels, bit 0 topmost. So a full screen is 128 x 8 = 1024 bytes.

Conclusion: Everything needed to rebuild the image is in the capture. Filter for transactions to 0x3C whose control byte is 0x40, concatenate the payload, and unpack it with that bit layout.

3.3 Reassemble the transactions (success)

One detail of the sigrok export costs time if missed: the lines are not in timestamp order. In the excerpt above, the Start at 1962470 comes after the address line at 1962491.

Conclusion: Sort by the leading sample number before grouping into transactions, otherwise the byte stream is subtly scrambled. That also explains the gaps in the clock seen in section 2: they are the boundaries between three separate frames, not glitches.

4. Solution

#!/usr/bin/env python3
"""
Rebuild what the OLED showed, from a sigrok I2C text export.

The bus carries an SSD1315 (128x64, command-compatible with the SSD1306) at
address 0x3C. Each transaction starts with a control byte: 0x00 means the
rest are commands, 0x40 means pixel data.

The init sequence selects horizontal addressing (20 00) and each frame is
preceded by 21 00 7F / 22 00 07, i.e. the full 128 columns and 8 pages. In
that mode one data byte is a vertical run of eight pixels - bit 0 topmost -
so a full screen is 128 * 8 = 1024 bytes.

Usage: python3 decode.py [export.txt]
"""

import re
import sys

from PIL import Image

# --- the bus ---------------------------------------------------------------
DISPLAY_ADDR = 0x3C     # SSD1315 default slave address; 0x50 and 0x68 also
                        # appear in the capture, an EEPROM and an RTC sharing
                        # the bus, and are ignored here

# --- SSD1315 control byte, the first byte of every transaction --------------
CTRL_COMMAND = 0x00     # everything that follows is commands
CTRL_DATA = 0x40        # everything that follows is pixel data

# --- geometry, as configured by the init sequence in the capture -----------
# A8 3F sets the multiplex ratio to 0x3F+1 = 64 rows, and 20 00 selects
# horizontal addressing, under which one data byte is a vertical run of
# eight pixels with bit 0 topmost.
WIDTH = 128
HEIGHT = 64
PAGES = HEIGHT // 8     # a "page" is one such 8-pixel band
FRAME_BYTES = WIDTH * PAGES

SCALE = 6               # nearest-neighbour zoom, purely for legibility


def transactions(path):
    """Reassemble I2C transactions; the export is not in timestamp order."""
    rows = []
    for line in open(path):
        m = re.match(r'(\d+)-\d+ I.C: Address/Data: (.*)', line.strip())
        if m:
            rows.append((int(m.group(1)), m.group(2)))
    rows.sort(key=lambda r: r[0])

    out, cur = [], None
    for _, what in rows:
        if what == 'Start':
            cur = {'addr': None, 'data': []}
            out.append(cur)
        elif cur is None:
            continue
        elif what.startswith('Address write:'):
            cur['addr'] = int(what.split(':')[1].strip(), 16)
        elif what.startswith('Data write:'):
            cur['data'].append(int(what.split(':')[1].strip(), 16))
    return out


def main():
    path = sys.argv[1] if len(sys.argv) > 1 else 'export1.txt'

    pixels = bytearray()
    for t in transactions(path):
        if (t['addr'] == DISPLAY_ADDR and t['data']
                and t['data'][0] == CTRL_DATA):
            pixels += bytes(t['data'][1:])

    frames = len(pixels) // FRAME_BYTES
    print('%d bytes of pixel data = %d frames' % (len(pixels), frames))

    for n in range(frames):
        buf = pixels[n * FRAME_BYTES:(n + 1) * FRAME_BYTES]
        img = Image.new('1', (WIDTH, HEIGHT))
        for y in range(HEIGHT):
            page, bit = divmod(y, 8)
            for x in range(WIDTH):
                img.putpixel((x, y), buf[page * WIDTH + x] >> bit & 1)
        name = 'frame%d.png' % (n + 1)
        img.resize((WIDTH * SCALE, HEIGHT * SCALE), Image.NEAREST).save(name)
        print('  wrote', name)


if __name__ == '__main__':
    main()

5. Run it

$ python3 decode.py
3072 bytes of pixel data = 3 frames
 wrote frame1.png
 wrote frame2.png
 wrote frame3.png

3072 = 3 x 1024, exactly three full screens:

Decoded frame 1

Frame 1

Decoded frame 2

Frame 2

Decoded frame 3, showing the flag

Frame 3 - what the eye found.

Flag

HTB{3v3ry_crow_w3ars_h3r_3y3s}

6. Summary of how the exploit works

#StageMechanism
1Identify the bus Clock plus data on two channels is I2C. Address 0x3C and a first command of 0xAE (display off) identify an SSD1306-compatible 128x64 OLED.
2Read the geometry from the init A8 3F = 64 rows, 20 00 = horizontal addressing, 21/22 = full 128x8 window. One data byte is a vertical run of 8 pixels, bit 0 topmost; one screen is 1024 bytes.
3Sort before grouping The sigrok export is not in timestamp order. Sorting by sample number before assembling transactions is what makes the byte stream correct.
4Render Keep only 0x3C transactions with control byte 0x40, concatenate, unpack into a bitmap. 3072 bytes = three frames; the flag is on the third.

What makes this one satisfying is that no reversing is involved at all - the display's datasheet is the specification, and the device announces its own configuration in the init sequence before sending a single pixel. The one genuine trap is the export ordering, and it fails quietly: unsorted data still decodes, still produces plausible-looking frames, and simply renders noise.