Hacking cars in JavaScript (Running replay attacks in the browser with the HackRF)

16/09/2024

A couple of years ago, I built a project using the RTL-SDR to get live raw data from passing airplanes, in the browser. As I wanted to explore more using Software-Defined Radios, I bought a HackRF One device that can both receive and transmit data, and I downloaded Universal Radio Hacker to start playing around with it. After tinkering a little bit, I had a hunch that it would probably be possible to recreate a similar tool in the browser, all in JavaScript, so I spent time doing just that!

My ultimate goal was to answer the question... is it possible to hack a car using JavaScript? I didn't know at all if I was going to get there because my knowledge of all things SDR related is still minimal, but I'm very excited to share that it works!! 😃 This blog post explains the process I went through to figure things out.

⚠️ Important notes ⚠️
  • I am writing this blog post for educational purposes only. Even though using SDRs to listen and transmit data is not illegal in itself, please check with your local laws regarding the need to have a radio license to transmit on specific frequencies. Additionally, recreating some of the experiments explained in this blog post without explicit consent is illegal.
  • I take no responsibility for how you decide to use the information shared in this post.

Demo

Before diving into the technical details, you can check out the website that you can use to receive, record and transmit data, so you can use it to hack doorbells, garage doors, and more. Below is a quick demo video using it to run a rolljam/replay attack to hack my friend's car (with consent).

Receiving data

To receive data from the HackRF, I first wrote the code needed to connect the device to the browser using the WebUSB API.

On the web, when connecting to a USB device, the user first needs to select it from a pop-up.

The code below handles displaying the list of available devices in that pop-up window. The value of the filters parameter is optional. If no value is specified, the pop-up will list all devices currently connected to the computer via USB, but if you pass an object with a product and vendor ID, it will only display the device you're looking to connect to.

const device = await connect();

const connect = async () => {
  const dev = await navigator.usb
    .requestDevice({
      filters: [
        // see: https://github.com/mossmann/hackrf/blob/master/host/libhackrf/53-hackrf.rules
        { vendorId: 0x1d50, productId: 0x6089 },
      ],
    })
    .catch((e) => null);
  return dev;
};

There are a few different ways to find the vendor and product ID for a device but in the context of the WebUSB API, the easiest one is to visit chrome://usb-internals in Chrome while having the HackRF device plugged into your computer and select the Devices tab. You should see a table with a vendor ID and product ID column like the one below:

Then you can copy these values in your code.

Once that device is selected in the UI, the following code handles the connection:

await device.open();
await device.selectConfiguration(1);
await device.claimInterface(0);

The value passed as configuration and interface can be found in two ways, either by reading the code in the official HackRF SDK or by inspecting the device in the browser.

For, the first option, you can read through the source code of the SDK and find where the configuration and interface is set and look at the values passed.

Otherwise, the second option is a bit faster and relies on the chrome://usb-internals page mentioned just before. If you click on the Inspect button in the table, you get additional details about the device, including the configuration (that should be 1), and the interface (that should be 0).

At this point, the browser should be connected to the device but nothing is really happening. Settings still need to be set such as the gain, sample rate and frequency.

Setting the gain

To set the LNA (Low-Noise Amplifier) gain, I used the controlTransferIn method of the WebUSB API. To figure out exactly the values to pass to this method, I referred to the official HackRF SDK. In the SDK, the function that sets the LNA gain calls another function libusb_control_transfer which led me to the official libusb documentation that describes the arguments that need to be passed in.

The libsub_control_transfer function takes 8 parameters. Looking at the official HackRF SDK, the function is used the following way:

result = libusb_control_transfer(
  device->usb_device,
  LIBUSB_ENDPOINT_IN | LIBUSB_REQUEST_TYPE_VENDOR | LIBUSB_RECIPIENT_DEVICE,
  HACKRF_VENDOR_REQUEST_SET_LNA_GAIN,
  0,
  value,
  &retval,
  1,
  0)

I'm not going to explain in depth the different arguments here as they are explained in the documentation but the ones that are particularly important for this project are the 2nd, 3rd, 4th and 5th.

The 2nd argument will help understand if we need to use controlTransferIn or controlTransferOut from the WebUSB API. LIBUSB_ENDPOINT_IN indicates we need to use controlTransferIn.

The 3rd argument HACKRF_VENDOR_REQUEST_SET_LNA_GAIN will be the one to use as the request parameter in the WebUSB API.

The 4th one (0) will be used as the value parameter and the 5th one (value, which is the gain value used) will be the index parameter.

const result = await device.controlTransferIn(
  {
    requestType: "vendor",
    recipient: "device",
    request: HackRF.HACKRF_VENDOR_REQUEST_SET_LNA_GAIN,
    value: 0,
    index: 40, // The gain can be between 0 and 40
  },
  1
);

You can also refer to the MDN web docs to understand more about this function.

At this point, the LNA gain should be set but more settings are needed.

Setting the sample rate

Following the same logic as the previous section, the sample rate can also be set, with a slight difference that you might notice if you look at the code sample below:

const params = new DataView(new ArrayBuffer(8));
params.setUint32(0, freqHz, true);
params.setUint32(4, divider, true);

const result = await device.controlTransferOut(
  {
    requestType: "vendor",
    recipient: "device",
    request: HackRF.HACKRF_VENDOR_REQUEST_SAMPLE_RATE_SET,
    value: 0,
    index: 0,
  },
  params.buffer
);

First, the sample rate is set using controlTransferOut. I referred to this section of the HackRF SDK to figure that out, notice it is using LIBUSB_ENDPOINT_OUT.

Then, you'll notice the index is set to 0 and the data representing the sample rate is actually used as second parameter in controlTransferOut, which is described in the MDN docs.

Setting the frequency

Now that I've explained how to set the LNA gain and the sample rate, setting the frequency follows the same logic and is done with the following code:

const data = new DataView(new ArrayBuffer(8));
const freqMhz = Math.floor(freqHz / 1e6);
const freqHz0 = freqHz - freqMhz * 1e6;
data.setUint32(0, freqMhz, true);
data.setUint32(4, freqHz0, true);

const result = await device.controlTransferOut(
  {
    requestType: "vendor",
    recipient: "device",
    request: HackRF.HACKRF_VENDOR_REQUEST_SET_FREQ,
    value: 0,
    index: 0,
  },
  data.buffer
);

At this point, the necessary settings are set but the device is not yet listening.

Start receiving data

The last two steps to start receiving data are to set the device to receiving mode and use the transferIn method from the WebUSB API to indicate to the device to start transferring the data to the UI.

const setDeviceReceivingMode = async () => {
  return await device.controlTransferOut({
    requestType: "vendor",
    recipient: "device",
    request: HackRF.HACKRF_VENDOR_REQUEST_SET_TRANSCEIVER_MODE,
    value: HackRF.HACKRF_TRANSCEIVER_MODE_RECEIVE,
    index: 0,
  });
};

Once the device is in receiving mode, the following code triggers the transfer of the data.

const startRx = async (callback) => {
  await setDeviceReceivingMode(); // function defined above

  const transfer = async () => {
    const result = await device.transferIn(1, HackRF.TRANSFER_BUFFER_SIZE);

    if (result) {
      callback(new Uint8Array(result.data.buffer));
    }
    await transfer();
  };

  await transfer();
};

await startRx((data) => {
  console.log(data); // live radio frequency data
});

You use can then use the Canvas API to build a visualization of this data. In my tool, I tuned the antenna to the frequency 433.92MHz, pressed the button on a cheap doorbell I bought and was able to confirm everything worked by seeing the data in the spectrum visualizer.

Recording data

Once I confirmed that I could receive live data, I used the File System Web API to implement the recording functionality.

First, the user needs to select a file that the data will be recorded to:

let fH;

const selectFile = async () => {
  [fH] = await window.showOpenFilePicker();
  let writableFile = await fH.createWritable();
  return writableFile;
};

Then, when receiving data, we can write it to the file:

await startRx((data) => { // Function defined in the previous section
  if(recording){ // variable set on click in the UI when starting the recording
    await writableFile.write(data);
  } else {
    await writableFile.close();
  }
})

And that's it! The live data will be saved to the selected file.

Transmitting data

Once receiving and recording data works, the next step is to transfer it back. To do this, I am using the File System Web API again to load the data recorded in the local file and transmitting it using transferOut from the Web USB API.

let file, fH;

const selectFile = async () => {
  [fH] = await window.showOpenFilePicker();
  file = await fH.getFile();
  return file;
};

After the file is selected, the device needs to be set to transmit mode.

await device.controlTransferOut({
  requestType: "vendor",
  recipient: "device",
  request: HackRF.HACKRF_VENDOR_REQUEST_SET_TRANSCEIVER_MODE,
  value: HackRF.HACKRF_TRANSCEIVER_MODE_TRANSMIT,
  index: 0,
});

Then, a transmitting gain needs to be set, the same way as the other settings defined earlier in this post.

await device.controlTransferIn(
  {
    requestType: "vendor",
    recipient: "device",
    request: HackRF.HACKRF_VENDOR_REQUEST_SET_TXVGA_GAIN,
    value: 0,
    index: 40, // Value must be <= 40.
  },
  1
);

Finally, we can get the data from the file, store it into a variable and pass it as the second argument to the transferOut method.

const data = await file.arrayBuffer();

await device.transferOut(2, data);

To check that it works properly, I also decided to use the Canvas API to visualize the imported data so I could have an idea of what the signal recorded looks like.

At this point, you can receive, record and transmit data back from the HackRF device, all in the browser, only using vanilla JavaScript and browser APIs!

Rolljam & replay attack

I'm not actually interested in hacking cars in a malicious way but one of the things I wanted to experiment with is running a rolljam/replay attack from the browser and this can be done with the three functionalities I explained in the rest of this post.

So how does a rolljam attack work?

First let's briefly talk about how a car usually opens and closes. To keep it very simple, to lock/unlock a car, in general, pressing the button on your fob sends a code to your car's receiver. If the car recognises the code, it locks or unlocks the car.

Before 1995, it worked with the same code sent to the car everytime, one to lock and one to unlock. However, this system poses a security risk because if the code is intercepted, the car can be locked and unlocked by anyone who recorded the signal. You can see how easy it would be to use the code explained above to hack a car that way.

To mitigate this, most cars manufactured after 1995 implement what is called "rolling codes". Instead of a unique code for locking and unlocking, a list of codes rotates so when one of them is used, it becomes temporarily obsolete until the list rotates and gets back to that code.

So a rolljam attack consists in jamming the car's receiver so it cannot receive the code sent by the fob, while simultaneously recording that code so the attacker can replay it.

In the context of the code shown in this post, the only piece missing is the one to jam. However, jamming is basically transmitting a bunch of random data so the code sample to transmit a recorded file can be updated to transmit random values instead, for example:

const randomData = Array.from({ length: 3670000 }, () =>
  Math.floor(Math.random() * 100)
);

for (let i = 0; i < 20; i++) {
  await startTx(new Uint8Array(randomData));
}

Also, as this attack needs the setup to both transmit and record at the same time, it requires two HackRF devices to work. One will be used to listen to the frequency used by the fob and record the signal to a file, while the other one will be jamming the receiver. Once the signal is recorded successfully, it can be replayed by one of the devices.

Conclusion

Overall, it was a super fun project to work on and it was really exciting to be able to validate that hacking a car in JavaScript is possible! 🎉

Even though this post is about cars specifically, this can be used to hack garage doors, remote lights, doorbells, some drones, theoretically anything that is remotely controlled by an RF receiver/transmitter.

Parts of this project are still a work in progress, I really wanted to implement a functionality to analyze the binary data recorded to be able to do some reverse engineering but I haven't gottent to it yet.

If you end up getting a HackRF or other device and build something with it, let me know!