Modern Electric Vehicle Infrastructure Security

The charger port on modern electric vehicles is effectively a network interface. This has particular implications for the security of electric vehicles and the chargers that charge them. My current understanding of physical charger port security is that most charger ports can be physically pressed or even pryed open without setting off vehicle alarms. Digital communication between the charger and the electric car happens via powerline communication. If you've ever used the wall plugs that turn your house's copper wiring into ethernet, it's the same thing.

There are also new protocols being standardized that allow charging infrastructure to be generically managed by charging station management software (CSMS). The Open Charge Point Protocol (OCPP) is an initiative to bring common management APIs to charging stations to enable quicker adoption.

In this paper, we will discuss the protocols used from the car to the charger, as well as from the charger to the charging station management systems. Both legs of the charging infrastructure offer unique attack surfaces.

The Gear

In order to build a device to perform the digital communication over the control pilot pin, there are several pieces of gear you can buy. Not all are required.

To get started evaluating electric vehicle charger ports as quickly as possible, either the full Pionix Belay Box or the simpler Yak + Yeti kits will get you going. The BelayBox contains both the Yak and Yeti kits pre-assembled and configured. Either solutions will give you a 32-amp Linux-based charger for testing and development. The EVerest open-source project will implement all the software you need to communicate using the hardware above.

The Car <-> Charger Protocol - ISO-15118 and V2x

In order to begin networked communication between the charger and the car, a 5% duty cycle pulse width modulated signal is sent over the control pilot pin from the charger to the car. Once the car detects the 5% signal, it will begin protocol negotiation, starting with an Neighbour Discovery Protocol broadcast over IPv6. The address of both the charger and the car for IPv6 communication is determined via SLAAC (not be confused with SLAC, the method to find the nearest charge port).

Currently, most Level 1 and 2 chargers perform no such communication. In general, this communication is only supported during DC charging as per vehicle charging specifications. In the future, AC charging will support this kind of digital communication, and adoption via Level 2 and 1 chargers will increase.

For ISO-15118 (Plug & Charge, Vehicle2Grid, V2House, V2x), EXI-encoded XML is used to transmit standardized data back and forth between the car and charger, such as EVCCID, EVSEID, State of Charge, and other information about the car/charger. You can find many PCAPs of this communication in this Github repository. This communication can be encrypted with TLS, but it's not required. Often, certificates used for car to charger communication are self-signed and not rooted in any common certificate authority.

The EVerest project implements a C++ library for ISO-15118 communication. We can use this library to generate many EXI messages that can be used against opaque targets.

#include <cbv2g/exi_v2gtp.h>
#include <iso15118/message/schedule_exchange.hpp>
#include <iso15118/message/variant.hpp>
#include <iostream>
#include <vector>
#include <cstdlib>
#include <iso15118/io/stream_view.hpp>

uint8_t* readAllStdin(size_t& length) {

    std::vector<uint8_t> buffer;
    char temp;

    // Read all input data from stdin
    while (std::cin.get(temp)) {
        buffer.push_back(static_cast<uint8_t>(temp));
    }

    // Allocate memory for the buffer and copy the data
    length = buffer.size();
    uint8_t* data = new uint8_t[length];
    std::copy(buffer.begin(), buffer.end(), data);

    return data;
}

int main()
{
    size_t len;
    uint8_t* data = readAllStdin(len);

    const iso15118::io::StreamInputView stream_view{data, len};
    iso15118::message_20::Variant variant(iso15118::io::v2gtp::PayloadType::Part20Main, stream_view);
}
There are several types of PayloadTypes you can use for different harnesses.
  enum class PayloadType : uint16_t {
      SAP = 0x8001,
      Part20Main = 0x8002,
      Part20AC = 0x8003,
      Part20DC = 0x8004,
  };
    
You can compile the above fuzzing harness and generate weird EXI messages for further testing.
afl-g++ iso15118.cpp -I ./libiso15118/build/_deps/libcbv2g-src/include/ \
  -I libiso15118/include/ \
  libiso15118/build/src/iso15118/libiso15118.a \
  ./libiso15118//build/_deps/libcbv2g-build/lib/cbv2g/libcbv2g_tp.a \
  libiso15118/build/_deps/libcbv2g-build/lib/cbv2g/libcbv2g_iso20.a \
  libiso15118/build/_deps/libcbv2g-build/lib/cbv2g/libcbv2g_exi_codec.a \
  -o test
Most of the crashes I ran into were benign logic issues and not directly related to memory safety.
bperry@bperry-Precision-T5610:~/tmp$ echo "QIRAAAlpAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAnACcnDk5MDk5OTk5OTk5OTk5OTA5OTkCADAAnQEAFQo=" | base64 --decode | valgrind ./test
==2570672== Memcheck, a memory error detector
==2570672== Copyright (C) 2002-2022, and GNU GPL'd, by Julian Seward et al.
==2570672== Using Valgrind-3.22.0 and LibVEX; rerun with -h for copyright info
==2570672== Command: ./test
==2570672== 
terminate called after throwing an instance of 'std::bad_optional_access'
  what():  bad optional access
==2570672== 
==2570672== Process terminating with default action of signal 6 (SIGABRT)
==2570672==    at 0x4CA3B1C: __pthread_kill_implementation (pthread_kill.c:44)
==2570672==    by 0x4CA3B1C: __pthread_kill_internal (pthread_kill.c:78)
==2570672==    by 0x4CA3B1C: pthread_kill@@GLIBC_2.34 (pthread_kill.c:89)
==2570672==    by 0x4C4A26D: raise (raise.c:26)
==2570672==    by 0x4C2D8FE: abort (abort.c:79)
==2570672==    by 0x4915FF4: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.33)
==2570672==    by 0x492B0D9: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.33)
==2570672==    by 0x4915A54: std::terminate() (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.33)
==2570672==    by 0x492B390: __cxa_throw (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.33)
==2570672==    by 0x117BD1: __throw_bad_optional_access (optional:111)
==2570672==    by 0x117BD1: value (optional:1005)
==2570672==    by 0x117BD1: void iso15118::message_20::convert(iso20_ServiceSelectionReqType const&, iso15118::message_20::ServiceSelectionRequest&) [clone .cold] (service_selection.cpp:20)
==2570672==    by 0x145548: insert_type (variant_access.hpp:30)
==2570672==    by 0x145548: void iso15118::message_20::insert_type(iso15118::message_20::VariantAccess&, iso20_ServiceSelectionReqType const&) (service_selection.cpp:42)
==2570672==    by 0x123562: iso15118::message_20::handle_main(iso15118::message_20::VariantAccess&) (variant.cpp:58)
==2570672==    by 0x1249AF: iso15118::message_20::Variant::Variant(iso15118::io::v2gtp::PayloadType, iso15118::io::StreamInputView const&) (variant.cpp:123)
==2570672==    by 0x11C8D0: main (main.cpp:34)
==2570672== 
==2570672== HEAP SUMMARY:
==2570672==     in use at exit: 78,084 bytes in 5 blocks
==2570672==   total heap usage: 14 allocs, 9 frees, 78,371 bytes allocated
==2570672== 
==2570672== LEAK SUMMARY:
==2570672==    definitely lost: 0 bytes in 0 blocks
==2570672==    indirectly lost: 0 bytes in 0 blocks
==2570672==      possibly lost: 136 bytes in 1 blocks
==2570672==    still reachable: 77,948 bytes in 4 blocks
==2570672==         suppressed: 0 bytes in 0 blocks
==2570672== Rerun with --leak-check=full to see details of leaked memory
==2570672== 
==2570672== For lists of detected and suppressed errors, rerun with: -s
==2570672== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
Aborted
bperry@bperry-Precision-T5610:~/tmp$ 
  
You may find similar crashes, and these are worth reporting upstream. But the real useful output are the EXI messages generated that exercise different codepaths. You can re-use these against any charger or car in the future to test their EXI decoding.

The EVCCID

The EVCCID is the value that is often used to identify the vehicle to the charger for automatic billing. This value is the MAC address of the interface being used for communication by the vehicle. You'll notice this in the PCAPs in repository linked above. If you were able to spoof your MAC address on the vehicle, you'd be able to abuse Plug & Charge. Some people propose a device that performs a man-in-the-middle, but this seems too complex and it should be doable from the vehicle itself.

You can, but not always, identify a vehicle's maker by its MAC address prefix.

Potential Vulnerabilities

Consider you are a developer who is told the SSH port or some web management application should listen on both IPv6 and IPv4, so you set the default configuration for the service to run on 0.0.0.0:1337 and [::]:1337. It would be incredibly easy to accidentally configure any sensitive applications to listen over the charger port, on both the electric vehicle and the charger itself.

Imagine bruteforcing the charger's SSH credentials over the charger cable because it was told to listen on all interfaces, not realizing the charger port is an interface sometimes. We can prove such an issue with the default BelayBox configuration.

Getting Nmap on the BelayBox

While Nmap is a little overkill for scanning a local interface, having Nmap on the BelayBox will also let you scan any V2X-enabled vehicles you connect to or charge with the BelayBox. These commands should be run on a regular x86/64 host and will cross-compile nmap for the BelayBox architecture.

wget https://pionix-update.de/belaybox-basecamp-demo/stable/poky-glibc-x86_64-belaybox-image-cortexa7t2hf-neon-vfpv4-raspberrypi4-toolchain-4.0.16.sh

Set up the toolchain. Then download the nmap source. Decompress the source code archive, and configure the project disabling features you won't need. Once configured, you can make the cross-compiled nmap binary.

wget https://nmap.org/dist/nmap-7.94.tgz

tar xzf nmap-7.94.tgz

cd nmap-7.94

source /opt/poky/4.0.16/environment-setup-cortexa7t2hf-neon-vfpv4-poky-linux-gnueabi

./configure --host=arm-linux-gnueabihf --without-subversion --without-liblua --without-zenmap --with-pcre=/usr --with-libpcap=included --with-pcap=linux --with-libdnet=included --without-ndiff --without-nmap-update --without-ncat --without-liblua --without-nping --without-openssl

make

Now you have a version of nmap that can run on the BelayBox directly. You can transfer your built nmap binary and scan the local address. For the BelayBox charger development kit, eth1 is the powerline interface.

root@belaybox-105c:/var/nmap/nmap-7.94# ifconfig
[snip]
	  eth1      Link encap:Ethernet  HWaddr CA:22:4B:95:E4:62
          inet6 addr: fe80::c822:4bff:fe95:e462/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:2 errors:0 dropped:0 overruns:0 frame:0
          TX packets:34 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:100
          RX bytes:1113 (1.0 KiB)  TX bytes:6137 (5.9 KiB)
[snip]

root@belaybox-105c:/var/nmap/nmap-7.94# ./nmap -6 -Pn -p- fe80::c822:4bff:fe95:e462
Starting Nmap 7.94 ( https://nmap.org ) at 2025-03-30 00:32 UTC
Nmap scan report for fe80::c822:4bff:fe95:e462
Host is up (0.000065s latency).
Not shown: 65530 closed tcp ports (reset)
PORT      STATE SERVICE
22/tcp    open  ssh
111/tcp   open  rpcbind
5355/tcp  open  llmnr
61341/tcp open  unknown
64109/tcp open  unknown

Nmap done: 1 IP address (1 host up) scanned in 5.12 seconds
root@belaybox-105c:/var/nmap/nmap-7.94#
		

Once you identify the device being used for powerline communication, you can scan it. The BelayBox has SSH listening on the charger port IPv6 interface. A "vehicle" could connect, initiate the network, and attempt to authenticate to SSH over the charger cable. You may notice the IP address is a local link address, not something you would usually see outside of the host. However, this is the SLAAC auto-created IPv6 address, based on the MAC address of the interface.

06:52:34.040620 dc:44:27:1d:cd:45 (oui Unknown) > 86:75:a6:8b:a3:c8 (oui Unknown), ethertype Unknown (0x88e1), length 85:
        0x0000:  017c 6000 0000 003e 0000 0000 0000 0000  .|`....>........
        0x0010:  0000 0000 0000 0000 0000 dc44 271d cd45  ...........D'..E
        0x0020:  0000 0000 0000 0000 0000 0000 0000 0000  ................
        0x0030:  0086 75a6 8ba3 c854 4553 4c41 2045 5600  ..u....TESLA.EV.
        0x0040:  0000 0000 0000 00                        .......
06:52:34.040907 86:75:a6:8b:a3:c8 (oui Unknown) > dc:44:27:1d:cd:45 (oui Unknown), ethertype Unknown (0x88e1), length 109:
        0x0000:  017d 6000 0000 0056 0000 0000 0000 0000  .}`....V........
        0x0010:  0000 0000 0000 0000 0000 dc44 271d cd45  ...........D'..E
        0x0020:  0000 0000 0000 0000 0000 0000 0000 0000  ................
        0x0030:  0086 75a6 8ba3 c854 4553 4c41 2045 5600  ..u....TESLA.EV.
        0x0040:  0000 0000 0000 0034 2d5a e2e5 fa01 0045  .......4-Z.....E
        0x0050:  3133 5058 5035 3337 4253 4e44 4733 53    13PXP537BSNDG3S
06:52:34.427329 IP6 fe80::de44:27ff:fe1d:cd45.49153 > ip6-allnodes.15118: UDP, length 10
06:52:34.428268 IP6 fe80::a9e4:a250:1925:5326 > ff02::1:ff1d:cd45: ICMP6, neighbor solicitation, who has fe80::de44:27ff:fe1d:cd45, length 32
06:52:34.438078 IP6 fe80::de44:27ff:fe1d:cd45 > fe80::a9e4:a250:1925:5326: ICMP6, neighbor advertisement, tgt is fe80::de44:27ff:fe1d:cd45, length 32
06:52:34.438131 IP6 fe80::a9e4:a250:1925:5326.15118 > fe80::de44:27ff:fe1d:cd45.49153: UDP, length 28
06:52:34.451904 IP6 fe80::de44:27ff:fe1d:cd45.49153 > fe80::a9e4:a250:1925:5326.61341: Flags [S], seq 6509, win 2920, options [mss 1440], length 0

This is a snippet of a PCAP between a Tesla and charger. Note both of the addresses are a local link address (fe80::a9e4:a250:1925:5326.15118 and fe80::de44:27ff:fe1d:cd45.49153). Note the charger communication is happening over port 15118 over the IPv6 link local interface. You can imagine the interesting implications here.

As a network admin looking at logs for failed authentication attempts, you would see a link local address attempting to bruteforce or authenticating to SSH. Very confusing. In the below output, you can see the two SSH authentications; one over the LAN port and one over the charger port.

root@belaybox-105c:~# last
root     pts/1        fe80::10e4:7eff: Sun Jun  1 17:25   still logged in
root     pts/0        192.168.1.135    Sun Jun  1 17:22   still logged in

The Open Charge Point Protocol (OCPP)

The Open Charge Point Protocol (OCPP) is used by charging station management systems (CSMS) to manage fleets of deployed chargers, even if it is a fleet of one in your garage. Chargers and the CSMS rely on web sockets to maintain communication. Most OCPP implementations meet the 1.6 standard. The 2.1 version will be the most recent version, with "lite" implementations also on their way for resource-constrained environments.

Today, we focus on expensive vehicles as the use-case for charging, but many people around the world are looking at how vehicles like tuk-tuks, motorbikes, and scooters can benefit from modern charging.

The Charging Station Management System (CSMS)

The CSMS allows owners of chargers to remotely maintain fleets of chargers. The most obvious use-case for this is a fleet of chargers maintained within a city, connecting to the CSMS via a SIM card or other wireless connection. The CSMS allows administrators to manage vehicle authentication, power usage, transactions, firmware updates, and much more.

Charger -> CSMS Authentication

There are 3 profiles of authentication supported by OCPP. Chargers can authenticate to the CSMS via the following security profiles.

Sometimes you may see no basic authentication requirement called Security Profile 0. Most chargers today implement and rely on Security Profile 2.

Potentional Vulnerabilities

During my initial research, I was able to focus on two open source CSMS projects; StEVe and CitrineOS. I reported one issue each to these projects, detailed below.

StEVe CSMS

The open source CSMS StEVe relies on Security Profile 0. The following message can be sent by any charger connected, with any idTag.

[2, "dddb2599-d678-4ff8-bf38-a230390a1200", "StartTransaction", {"connectorId": 42, "meterStart": 42, "idTag": "some id", "timestamp": "222222017-10-27T19:10:11Z"}]

Note the invalid timestamp. Once parsed by the StEVe CSMS, a database record is created which causes the Transactions page to crash, preventing any listing of current and past transaction in the system.

CitrineOS

CitrineOS implements several security profiles. The following vulnerability was found and fixed in CitrineOS. An invalid BootNotification message would cause the CSMS to crash completely.

[2, "5e58c16f-32ee-4589-ae5d-2272e5beeb71", "BootNotification", {}]

Note the empty JSON with no keys or values for the invalid BootNotification message. Below is the stacktrace printed once the message is recieved and processed.

2024-11-22 18:57:26 /usr/local/apps/citrineos/03_Modules/Configuration/dist/module/DeviceModelService.js:88
2024-11-22 18:57:26                     value: chargingStation.model,
2024-11-22 18:57:26                                            ^
2024-11-22 18:57:26 
2024-11-22 18:57:26 TypeError: Cannot read properties of undefined (reading 'model')
2024-11-22 18:57:26     at DeviceModelService. (/usr/local/apps/citrineos/03_Modules/Configuration/dist/module/DeviceModelService.js:88:44)
2024-11-22 18:57:26     at Generator.next ()
2024-11-22 18:57:26     at /usr/local/apps/citrineos/03_Modules/Configuration/dist/module/DeviceModelService.js:11:71
2024-11-22 18:57:26     at new Promise ()
2024-11-22 18:57:26     at __awaiter (/usr/local/apps/citrineos/03_Modules/Configuration/dist/module/DeviceModelService.js:7:12)
2024-11-22 18:57:26     at DeviceModelService.updateDeviceModel (/usr/local/apps/citrineos/03_Modules/Configuration/dist/module/DeviceModelService.js:82:16)
2024-11-22 18:57:26     at ConfigurationModule. (/usr/local/apps/citrineos/03_Modules/Configuration/dist/module/module.js:136:38)
2024-11-22 18:57:26     at Generator.next ()
2024-11-22 18:57:26     at fulfilled (/usr/local/apps/citrineos/03_Modules/Configuration/dist/module/module.js:18:58)
2024-11-22 18:57:26     at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
      
This bug caused the CitrineOS service to crash completely, resulting in a full denial of service.

VolatileOCPP Project

A project I started when beginning this research was implementing some of the Open Charge Alliance OCPP compliance tests, mainly focused around security features. During this development, I also found an old OCPP fuzzer developed at a university in Germany several years ago. The code was technically broken and only targetted OCPP 1.6. Within the VolatileOCPP project is an updated fuzzer based on this code. You can find the full code project on GitHub at https://github.com/brandonprry/VolatileOCPP. Both of the issues noted above in StEVe and CitrineOS were found with this updated fuzzer.

In addition to the OCPP fuzzer, there are several Open Charge Alliance Compliancy tests written in a C# framework. These tests are written based directly on the official OCA Compliancy Test documentation. Not every OCA test is implemented, but most of the security related tests are. For instance, the framework implements the OCA checks for verifying the CSMS supports expiring or blocking EVCCIDs.

A nice by-product of the framework is easily spinning up a simulated charger to interface with a CSMS.

string url = "ws://localhost:8180/steve/websocket/CentralSystemService/1";
string protocol = "ocpp1.6";

List<Task> tasks = new List<Task>();
for (int i = 1; i< 11; i++)
{
    Charger c = new Charger(url, protocol);
    c.ConnectorID = i.ToString();
    c.IDTag = "volatileocpp";
    tasks.Add(Task.Run(c.Simulate));
}

foreach (Task t in tasks)
    t.Wait();
      
This is the simplest example of a simulated charger interfacing with the StEVe CSMS which implements Security Profile 0. However, every security profile should be supported and most profiles (0, 1, and 2) have been tested on real-world CSMS software.