HTB - Cadence in the Cord

EasyHackTheBox6 min read

Scenario

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.


Enumeration

The challenge provides a Sigrok session file, capture.sr, of course the first step is to inspect the Sigrok session and determine which logical channel contains useful data. Running sigrok-cli -i capture.sr --show reveals a samplerate of 2 MHz, 8 logic channels, and one named channel, D1, which stands out as the most likely candidate for protocol decoding.

bash
sigrok-cli -i capture.sr --show

Example output:

text
Samplerate: 2000000
Channels: 8
- 0: logic
- D1: logic
- 2: logic
- 3: logic
- 4: logic
- 5: logic
- 6: logic
- 7: logic
Logic unitsize: 1
Logic sample count: 16000000

With no guarantee that the signal is UART or which baud rate is in use, the fastest path is a small sweep across the available channels and common UART rates. This approach is consistent with Sigrok’s UART workflow, where the decoder must be attached to the correct logic probe and configured with the correct serial parameters before meaningful output appears.

bash
for ch in 0 D1 2 3 4 5 6 7; do
  for rate in 9600 19200 38400 57600 115200; do
    echo "=== Channel $ch @ $rate ==="
    sigrok-cli -i capture.sr -P uart:rx=$ch:baudrate=$rate -A uart=rx-data 2>/dev/null | head -5
  done
done

The useful hit is D1 @ 9600, which begins with the bytes 54 6F 20 74 68, or To th in ASCII. That confirms the capture is carrying readable UART data on channel D1 at 9600 baud.


Protocol Recovery

At this point the visible payload can be decoded, but the challenge text warns that the real secret is not in the message body. The UART plaintext itself contains the decoding instructions: the hidden data lives in the rests between frames, a long rest means 1, a short rest means 0, and eight rests form one letter.

Recovered UART text:

text
To the buyer who paid in secrets: what follows is the pleasant tone, the goods I sell in daylight and never miss. Lord Varo's debt, due at the second thaw, yours for a marriage you already own. The Harlow inheritance, contested by a cousin whose witnesses I arranged. Take them and thank me. But what is written is worth nothing. 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.

This is the moment where the challenge pivots from ordinary serial decoding into timing analysis. UART is asynchronous, meaning its correctness depends on symbol timing rather than a shared clock, so using gaps between completed frames as a covert channel is a natural trick for this kind of challenge.


Analysis

A custom parser is the cleanest way to solve the rest of the challenge. A Sigrok .sr session is a container format holding metadata and logic chunks, and the metadata in this capture shows a 2 MHz samplerate with probe2=D1, which maps the named line to bit position 1 in each sample byte.

The script concatenates all logic-1-* chunks, extracts bit position 1, and reconstructs UART frames by searching for a valid start bit, sampling the 8 data bits at the midpoint of each symbol, and checking for a valid stop bit. With a samplerate of 2,000,000 and baudrate 9600, each bit lasts about 208.33 samples, so a standard 10-bit UART frame spans roughly 2083 samples.

python
BIT_SAMPLES = 2_000_000 / 9600
FRAME_SAMPLES = int(BIT_SAMPLES * 10)

Once valid frames are recovered, the gap between one frame and the next is measured as:

text
gap = next_frame_start - (previous_frame_start + frame_duration)

That produces 592 gaps across 593 recovered UART frames. The distribution is highly structured and immediately separates into two clusters, which is exactly what the challenge text predicts.

text
4011 samples  -> short gap
4012 samples  -> short gap
20014 samples -> long gap
20015 samples -> long gap

The ±1-sample variation is just rounding noise from sampling and frame alignment, so the two logical classes are still unambiguous. Mapping short gaps to 0 and long gaps to 1, then grouping the resulting bitstream into chunks of 8 bits, follows the embedded instructions exactly.


Exploit

The only remaining ambiguity is byte interpretation. Hidden bitstreams like this often fail on the first try because of bit ordering or alignment, so the solver should test both short/long mappings, MSB-first versus LSB-first, and all offsets from 0 to 7. In this case, the correct decoding is:

  • short gap = 0
  • long gap = 1
  • MSB-first
  • offset = 0

That combination yields the hidden sentence:

text
you read the silence well HTB{[REDACTED]}

The flag can then be extracted directly from the decoded ASCII stream.


Solve Script (solver.py)

python
import zipfile
import numpy as np
from collections import Counter
import re

SAMPLERATE = 2_000_000
BAUDRATE = 9600
BIT_SAMPLES = SAMPLERATE / BAUDRATE
FRAME_BITS = 10
FRAME_SAMPLES = int(BIT_SAMPLES * FRAME_BITS)

def read_sigrok_capture(path):
    with zipfile.ZipFile(path, 'r') as z:
        logic_files = sorted(
            [n for n in z.namelist() if n.startswith('logic-1-')],
            key=lambda x: int(x.split('-')[-1])
        )
        raw = b''.join(z.read(f) for f in logic_files)
        samples = np.frombuffer(raw, dtype=np.uint8)
        return samples

def extract_channel(samples, bitpos=1):
    return (samples >> bitpos) & 1

def decode_uart(signal):
    frames = []
    i = 0
    limit = len(signal) - int(FRAME_SAMPLES * 1.5)

    while i < limit:
        if signal[i] == 1 and signal[i + 1] == 0:
            start_idx = i + 1
            mid_start = int(start_idx + BIT_SAMPLES / 2)

            if mid_start >= len(signal) or signal[mid_start] != 0:
                i += 1
                continue

            bits = []
            ok = True
            for b in range(8):
                idx = int(round(start_idx + BIT_SAMPLES * (1.5 + b)))
                if idx >= len(signal):
                    ok = False
                    break
                bits.append(int(signal[idx]))

            if not ok:
                break

            stop_idx = int(round(start_idx + BIT_SAMPLES * 9.5))
            if stop_idx < len(signal) and signal[stop_idx] == 1:
                byte_val = sum(bit << b for b, bit in enumerate(bits))
                frames.append((start_idx, byte_val))
                i = stop_idx + 1
                continue

        i += 1

    return frames

def gap_stats(frames):
    gaps = []
    for i in range(1, len(frames)):
        end_prev = frames[i - 1][0] + FRAME_SAMPLES
        gap = frames[i][0] - end_prev
        gaps.append(gap)
    return gaps

def bits_to_bytes_msb(bits, offset=0):
    bits = bits[offset:]
    out = []
    for i in range(0, len(bits) - 7, 8):
        out.append(int(''.join(bits[i:i+8]), 2))
    return bytes(out)

def main():
    samples = read_sigrok_capture('capture.sr')
    d1 = extract_channel(samples, bitpos=1)
    frames = decode_uart(d1)

    print(f'Found {len(frames)} valid UART frames')

    visible = bytes([b for _, b in frames]).decode('ascii', errors='replace')
    print('\n=== Visible UART text ===')
    print(visible)

    gaps = gap_stats(frames)
    counts = Counter(gaps)

    print('\nGap distribution:')
    for gap, count in counts.most_common(4):
        print(f'{gap:5d} samples: {count}')

    short_gap = 4011
    long_gap = 20015

    bits = []
    for g in gaps:
        if abs(g - short_gap) < abs(g - long_gap):
            bits.append('0')
        else:
            bits.append('1')

    hidden = bits_to_bytes_msb(bits, 0).decode('ascii', errors='replace')
    print('\n=== Hidden message ===')
    print(hidden)

    m = re.search(r'HTB\{[^}]+\}', hidden)
    if m:
        print('\nFlag:')
        print(m.group(0))

if __name__ == '__main__':
    main()

Takeaways

This challenge is a good reminder that protocol decoding is sometimes only the first layer. The visible UART message existed mainly to deliver instructions for a second covert channel encoded in inter-frame timing, and that trick works especially well in asynchronous serial protocols where spacing can be manipulated without breaking the visible payload.

A practical lesson from the solve is to treat metadata, payload, and timing as separate sources of evidence. When a challenge says to read the silence, it usually means the transport-layer rhythm matters just as much as the bytes themselves.