HTB: Unique Challenge

Unique - HackTheBox Challenge Writeup

Challenge Information

FieldValue
Challenge NameUnique
CategoryHardware / Forensics
DifficultyVery Easy
Authord3vn0mi

Description

We found a car but we are unable to identify if it’s the exact one that we have been searching for. The serial network of the car seems intact so we tapped into it and collected some packets. Can you help us find the VIN of the car that is transmitted repeatedly over the network?

Solution

The challenge ships a .sal file — a Saleae Logic 2 capture — containing raw analog and digital samples tapped off a car’s serial (CAN) bus. The .sal container turned out to be an undocumented binary block format with no public spec, so recovering the actual sample stream required reverse-engineering the block layout by hand before any CAN decoding could start. Once the analog channel pair was reconstructed, the differential CAN_H/CAN_L signal decoded cleanly into standard CAN frames, and one arbitration ID stood out — broadcasting the same 8-byte ASCII payload over and over. Reassembling those repeated payloads yielded the VIN, which was the flag.

Key Steps

Step 1: Recover the capture artifact

The staged capture file was a 0-byte placeholder; the real ~75 MB archive was sitting in the output directory as a password-protected zip.

Terminal window
# Unpack the real capture (password: hackthebox)
mkdir -p /tmp/w && cd /tmp/w
unzip -o -P hackthebox /out/<uuid>.zip -d /tmp/w

Step 2: Identify the container format

The .sal file is itself a zip containing Saleae Logic 2 capture data — a meta.json describing the recording, plus raw sample files for two analog channels (5 MS/s) and two digital channels (50 MS/s).

Terminal window
# .sal is a zip archive under the hood
unzip -o unique/trace_captured.sal -d sal
ls -la sal/
# meta.json analog-0.bin analog-1.bin digital-0.bin digital-1.bin

Step 3: Reverse-engineer the .sal block layout

The .bin files aren’t a flat sample array — Saleae Logic 2 stores them as a chain of length-prefixed blocks with an embedded render preview, and the format isn’t publicly documented. Each block starts with a [begin u64][end u64][count u64] header followed by count raw int16 samples.

import struct
# Block header format discovered by inspection: 3x uint64 (begin, end, count)
# followed by `count` int16 samples, then a downsampled preview blob.
# Blocks chain together — the next block's `begin` matches the previous block's `end`.
HEADER = struct.Struct("<QQQ")
def parse_blocks(path):
data = open(path, "rb").read()
offset = 0x33 # first block header starts here
samples = []
while offset < len(data):
begin, end, count = HEADER.unpack_from(data, offset)
offset += HEADER.size
chunk = struct.unpack_from(f"<{count}h", data, offset)
samples.extend(chunk)
offset += count * 2
# skip the trailing render-preview blob to reach the next block header
offset = find_next_block(data, offset, end)
return samples

Walking all 859 chained blocks reconstructed 75,068,077 samples per channel — a full 15.01-second capture.

Step 4: Decode CAN from the differential analog pair

CAN uses a differential signal (CAN_H / CAN_L); subtracting the two analog channels isolates the dominant/recessive bit levels cleanly.

import numpy as np
ch0 = np.load("ch0.npy").astype(np.int32) # CAN_L
ch1 = np.load("ch1.npy").astype(np.int32) # CAN_H
diff = ch1 - ch0 # clean ~2650-count dominant level
# Run-length analysis showed dominant/recessive runs quantize to
# 40 samples per bit -> 125 kbit/s (standard CAN low-speed rate).
BIT_SAMPLES = 40

Frames were split on an idle gap of ≥11 recessive bits, bit-destuffed, then parsed field by field (SOF, 11-bit ID, RTR, IDE, DLC, data bytes, CRC):

# candec.py (excerpt) — bitstream -> CAN frames
def decode_frames(bits):
frames = []
for raw_frame in split_on_idle(bits, min_recessive=11):
stuffed = destuff(raw_frame)
can_id = bits_to_int(stuffed[1:12])
dlc = bits_to_int(stuffed[15:19])
payload = extract_data_bytes(stuffed, dlc)
frames.append((can_id, dlc, payload))
return frames

This produced 2,192 candidate frames, 1,239 of which parsed cleanly.

Step 5: Find the repeating VIN broadcast

Grouping decoded frames by arbitration ID and scoring payloads by ASCII-printable ratio surfaced one ID transmitting the same content on a tight repeat cycle:

import collections
by_id = collections.Counter()
for can_id, dlc, payload in frames:
by_id[can_id] += 1
# ID 0x452 dominates the traffic — 476 frames, all high-ASCII
print(by_id.most_common(5))
# 0x452: 476 <- VIN broadcast

0x452 sent 476 frames = 4 distinct 8-byte ASCII chunks, each repeated 119 times — the VIN split across sequential CAN frames:

seq = [payload for can_id, dlc, payload, ok in frames if can_id == 0x452 and ok]
chunks = dict(collections.Counter(seq))
for chunk, count in chunks.items():
print(count, chunk)
# 119 b'HTB{REDACTED}
# 119 b'...'
# 119 b'...'
# 119 b'...}'

Concatenating the four chunks in transmission order reassembled the full flag.

Step 6: Submit

Terminal window
python3 -c "
import sys; sys.path.insert(0, 'lib')
from htb_api import HTBClient
c = HTBClient()
print(c.submit_challenge(215, flag))
"
# -> "Congratulations!"

Tools Used

ToolPurpose
Python 3 / structReverse-engineering the undocumented .sal block format
NumPyBulk sample array handling and differential signal math
Custom CAN decoder (candec.py)Bit-timing recovery, de-stuffing, frame parsing
unzipExtracting the password-protected challenge archive and the .sal container
HTB API client (htb_api.py)Flag submission

Key Learnings

  • Saleae Logic 2’s .sal format is an undocumented, chained-block binary layout — recognizing the [begin][end][count] header pattern and chaining blocks by matching end-to-begin was the key to reconstructing a usable sample stream from what looked like opaque binary data.
  • CAN bus signals are differential (CAN_H − CAN_L); subtracting the two analog channels turns a noisy pair into a clean two-level signal that’s trivial to threshold.
  • Bit timing can be recovered empirically from run-length statistics — measuring dominant/recessive pulse widths revealed the 40-samples-per-bit (125 kbit/s) rate without needing it documented anywhere.
  • Grouping decoded CAN frames by arbitration ID and scoring payloads by ASCII-printable ratio is a fast way to find “interesting” traffic (like a VIN broadcast) in a bus full of mostly-numeric sensor noise.

Flag

HTB{REDACTED}