HTB: Unique Challenge
Unique - HackTheBox Challenge Writeup
Challenge Information
| Field | Value |
|---|---|
| Challenge Name | Unique |
| Category | Hardware / Forensics |
| Difficulty | Very Easy |
| Author | d3vn0mi |
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.
# Unpack the real capture (password: hackthebox)mkdir -p /tmp/w && cd /tmp/wunzip -o -P hackthebox /out/<uuid>.zip -d /tmp/wStep 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).
# .sal is a zip archive under the hoodunzip -o unique/trace_captured.sal -d salls -la sal/# meta.json analog-0.bin analog-1.bin digital-0.bin digital-1.binStep 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 samplesWalking 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_Lch1 = np.load("ch1.npy").astype(np.int32) # CAN_Hdiff = 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 = 40Frames 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 framesdef 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 framesThis 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-ASCIIprint(by_id.most_common(5))# 0x452: 476 <- VIN broadcast0x452 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
python3 -c "import sys; sys.path.insert(0, 'lib')from htb_api import HTBClientc = HTBClient()print(c.submit_challenge(215, flag))"# -> "Congratulations!"Tools Used
| Tool | Purpose |
|---|---|
Python 3 / struct | Reverse-engineering the undocumented .sal block format |
| NumPy | Bulk sample array handling and differential signal math |
Custom CAN decoder (candec.py) | Bit-timing recovery, de-stuffing, frame parsing |
unzip | Extracting the password-protected challenge archive and the .sal container |
HTB API client (htb_api.py) | Flag submission |
Key Learnings
- Saleae Logic 2’s
.salformat is an undocumented, chained-block binary layout — recognizing the[begin][end][count]header pattern and chaining blocks by matchingend-to-beginwas 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}