HTB Cyber Apocalypse 2026 - The Salt Crown: Hardware / Cadence in the Cord (Easy)

Author: Hubert Feyrer / hubertf - 31.07.2026

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

A logic analyser capture of a UART line. The transmitted text is a decoy - the actual message is encoded in the gaps between the characters.

1. Download

$ ls -la
-rw-rw-r--@ 1 feyrer staff 112808 Jul  6 01:53 capture.sr

$ file capture.sr
capture.sr: Zip archive data, at least v2.0 to extract, compression method=store

A .sr file is a sigrok capture - a zip with raw logic samples and a metadata file naming the channels and sample rate.

2. Docker/nc - what we get

Neither. A pure offline capture, opened in PulseView.

PulseView showing the UART capture with the decoded byte stream

The capture in PulseView, with the UART decoder attached.

3. Analysis steps

3.1 Determine the baud rate (success)

A UART decoder needs the baud rate. It is measurable straight off the trace: find the narrowest pulse, which is one bit time - or here, a two-bit run whose edges are easy to place.

>>> 3788685 - 3788480
205                                    # microseconds for 2 bits

>>> 1 / ((3788685 - 3788480) * 1e-6) * 2
9756.09756097561                       # bits per second

Conclusion: 9756 is 9600 within measurement error - the standard rate closest to it. With 8N1 the decoder produces clean bytes immediately.

3.2 Read the message (failed)

Export the decoded bytes and reassemble the text.

$ head export-rx.txt
4001195-4001404 UART: RX: Start bit
4001403-4003071 UART: RX: 54
4003070-4003279 UART: RX: Stop bit
4007289-4007498 UART: RX: Start bit
4007497-4009165 UART: RX: 6F
...

$ cat export-rx.txt | grep -v bit | sed 's,.*: ,,' | xxd -r -p | fold -s -w70
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.

Conclusion: No flag in the text - and the text says so itself. It also gives the complete specification of the real encoding: long gap = 1, short gap = 0, eight gaps per character. The payload is in the timing, not the data.

3.3 Measure the gaps (success)

Zooming out in PulseView makes the structure visible: the characters do not arrive at a steady rate.

PulseView zoomed out, showing uneven gaps between the UART characters

Zoomed out, the idle stretches between characters differ visibly in length.

The export carries absolute sample positions, so the gaps can be measured exactly: from the end of one stop bit to the start of the next start bit.

...
4003070-4003279 UART: RX: Stop bit     <- gap starts at 4003279
4007289-4007498 UART: RX: Start bit    <- gap ends at 4007289
                                          gap = 4010 us

    short gaps ~  4010 us  -> bit 0
    long  gaps ~ 20013 us  -> bit 1

Conclusion: The two populations are a factor of five apart, so a threshold anywhere around 10000 us separates them cleanly - no calibration needed. Eight gaps per character, MSB first.

4. Solution

2hf.pl walks the export in triples (stop bit, start bit, data), measures each gap, thresholds it and reassembles bytes.

#!perl

open(IN, "export-rx.txt") or die;

$skip = <IN>; # skip first
$skip = <IN>; # skip first

$bit_str = "";

while (!eof(IN)) {
	$stop = <IN>; chomp($stop);
	$start = <IN>; chomp($start);
	$data = <IN>; chomp($data);
	#
	print("stop:  $stop\n");
	print("start: $start\n");

	$pause_start = (split(/[ -]/, $stop))[1];
	$pause_end   = (split(/[ -]/, $start))[0];
	#
	print("pause_start:  $pause_start\n");
	print("pause_end:    $pause_end\n");

	$diff = $pause_end - $pause_start;
	print("diff:    $diff\n");

	if ($diff >0) {
		if ($diff < 10000) { # values are ~4010 / ~20013
			$bit = 0;
		} else {
			$bit = 1;
		}
		print("bit: $bit - bit_str=$bit_str\n");

		$bit_str .= $bit;
		if (length($bit_str) == 8) {
			$bit_num = oct("0b". $bit_str);
			printf("bit_num: 0x%x\n", $bit_num);
			
			$bit_str = "";
		}
	}

	print("\n");
}

5. Run it

$ perl 2hf.pl | grep bit_num | sed 's,.*0x,,' | xxd -r -p
you read the silence well HTB{th3_f1rst_m4rk_r1ngs_tru3_b3n34th_th3_w0rds}

The hidden channel even acknowledges being read before handing over the flag.

Flag

HTB{th3_f1rst_m4rk_r1ngs_tru3_b3n34th_th3_w0rds}

6. Summary of how the exploit works

#StageMechanism
1Decode the obvious layer Measure the shortest pulse to get 9600 baud, attach the UART decoder, export the byte stream. Result: a letter with no flag in it.
2The text is the manual "A long rest raises the mark to one, a short rest lets it fall to nothing; count eight rests to every letter." The decoy explains the real encoding.
3Measure the silence The gap between one stop bit and the next start bit is either ~4010 us or ~20013 us. Threshold at 10000, eight gaps per byte.

This is a covert timing channel, and it survives everything a normal analysis does to it: the UART decode is correct, the bytes are correct, the text is readable - and none of that touches the payload. The information is in when the characters arrive, a dimension the decoder discards by design. Worth remembering that a sigrok export keeps the absolute sample positions, which is exactly what makes the timing recoverable after the fact.