← Back to Writeups
CUSTOMMediumHardware

Cadence in the Cord

XESXOR9/2/20265 min read
#hardware#custom#medium#sr_container_parsing#uart_decoding_from_raw_logic#gap_duration_steganography#msb_first_bit_packing

Cadence in the Cord

Platform: CACTF2026 | Category: Hardware | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-27 | Status: Solved Techniques: sr_container_parsing, uart_decoding_from_raw_logic, gap_duration_steganography, msb_first_bit_packing

Summary

Task: a sigrok logic-analyzer capture (.sr) with a UART line whose byte values are a decoy cover story. Solution: decode UART @ 9600 8N1 on D1, then read the covert bitstream hidden in inter-frame gap durations (long gap=1, short gap=0), pack 8 bits MSB-first per character.

Cadence in the Cord — CACTF2026

Description

With the Brine Signet shattered, every house hunts whatever might make its story law. Lady Seralyne, the Velvet Spider of Suncourt, sells what she claims is the dragon's true note: not the lost thing itself, only a counterfeit cadence arranged to be believed, and a wavering house is ready to buy it as proof its claim rings true. We cut one of her sendings from the wire first. Read the pleasant words; then attend to the silences between them, and expose the forgery she is truly selling. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A logic-analyzer capture is provided. A UART line carries a "pleasant" cover message, but the real flag is hidden in the timing of the silences between UART bytes. The semantic clues ("cadence", "silences between them", "rests between them", "the first mark rings true beneath the words") all point to timing/gap steganography under the visible message.

Analysis

The download unwraps to a sigrok logic-analyzer session:

  • download.bin → ZIP → capture.sr
  • capture.sr → ZIP (sigrok container). Its metadata reports: sigrok 0.5.2, total probes=8, samplerate=2 MHz, unitsize=1, and a single named probe probe2=D1. Logic data is split across 782 logic-1-* chunks. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Concatenating all logic-1-* chunks in numeric order gives logic.raw = 16,000,000 samples, 1 byte/sample where each bit is one of the 8 channels.

Per-channel activity:

  • D2–D7: constantly high (unused).
  • D0: almost no activity.
  • D1 (the only named probe): idle-high with occasional low pulses → this is the data line.

Low-pulse widths on D1 are quantized in multiples of ~208 samples. At 2 MHz, 208 samples ≈ 104 µs → 2,000,000 / 208 ≈ 9600 baud. This is UART @ 9600, 8N1, idle-high, LSB-first. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Decoding the UART byte stream yields a cover message that literally describes the covert channel:

"The dragon's true note does not live in the words; it lives in the rests between them. A long rest raises the mark to one, a short rest lets it fall to nothing; count eight rests to every letter before the note will speak. Read the silence, not the song, and pay."

So the flag is not in the byte values — it is encoded in the inter-frame gap durations (the idle-high silences between consecutive UART frames): ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • Measure each gap = (start of next start-bit) − (end of previous stop-bit), in bit-times (÷ samples-per-bit ≈ 208.33).
  • Gaps form two clean clusters: ~19 bit-times → 0, ~96 bit-times → 1.
  • 592 gaps total → 592 bits → 74 bytes, packed 8 bits/char, MSB-first.

Solution

  1. Unzip the container chain and reassemble the raw logic.
  2. Bit-bang UART on D1 to recover frame byte boundaries (the payload byte values are just the decoy cover story).
  3. Instead of using the byte values, collect the idle gap between each pair of consecutive frames, in bit-times.
  4. Threshold the gaps into two clusters (short=0, long=1), pack MSB-first into bytes, ASCII-decode. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍
python
#!/usr/bin/env python3
import zipfile, io, glob, os, re

WORK = "."  # task dir

# --- 1. unwrap ZIP -> capture.sr -> sigrok chunks ---
with zipfile.ZipFile(os.path.join(WORK, "download.bin")) as z:
    sr_bytes = z.read("capture.sr")
sr = zipfile.ZipFile(io.BytesIO(sr_bytes))

meta = sr.read("metadata").decode()
samplerate = 2_000_000            # from metadata (2 MHz)
# concatenate logic-1-* chunks in numeric order
names = sorted(
    [n for n in sr.namelist() if n.startswith("logic-1-")],
    key=lambda n: int(re.findall(r"(\d+)$", n)[0]),
)
raw = b"".join(sr.read(n) for n in names)   # 16,000,000 bytes, 1 sample/byte
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# --- 2. extract D1 bit per sample ---
D1 = 1  # probe2=D1 -> bit index 1
line = [(b >> D1) & 1 for b in raw]

BAUD = 9600
SPB = samplerate / BAUD           # samples per bit ~= 208.33

# --- 3. UART frame boundary detection (idle-high, 8N1, LSB-first) ---
# Find falling edges = start bits; each frame spans 10 bit-times (start+8+stop).
frames = []                       # list of (start_sample, end_sample)
i = 1
N = len(line)
while i < N:
    if line[i - 1] == 1 and line[i] == 0:      # falling edge = start bit
        start = i
        # sample the 8 data bits at bit-centres, LSB-first (cover byte, decoy)
        byte = 0
        for b in range(8):
            centre = int(round(start + SPB * (1.5 + b)))
            if centre < N and line[centre]:
                byte |= (1 << b)
        stop_end = int(round(start + SPB * 10))  # end of stop bit
        frames.append((start, stop_end, byte))
        i = stop_end
    else:
        i += 1
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# --- 4. covert channel: inter-frame gap durations ---
bits = []
for (s0, e0, _), (s1, e1, _) in zip(frames, frames[1:]):
    gap_bits = (s1 - e0) / SPB    # idle silence between frames, in bit-times
    # two clusters: ~19 (short=0) vs ~96 (long=1); threshold ~ midpoint
    bits.append(1 if gap_bits > 50 else 0)

# --- 5. pack 8 bits/char, MSB-first ---
out = bytearray()
for k in range(0, len(bits) - 7, 8):
    val = 0
    for b in bits[k:k + 8]:
        val = (val << 1) | b       # MSB-first
    out.append(val)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

print(out.decode(errors="replace"))
# -> "you read the silence well HTB{REDACTED}"

The decoded covert stream reads you read the silence well followed by the flag. Packing LSB-first instead produces garbage, confirming MSB-first is correct. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍