Picture of the setup with the bicycle in a stand and my laptop with the SDR
Bike + SDR = šŸ”„

Downgrading Shimano Di2

Last year security researchers found vulnerabilities in popular bicycle parts that allow an attacker to shift someone else's bike or make it impossible for the rider to shift themselves (source). It just so happens that the bike I recently bought has Shimano Di2 components and is vulnerable to the attack—or at least it would be vulnerable if it ran older firmware. This blog post is about downgrading the firmware of my shift levers and rear derailleurs in order to get them into a vulnerable state. I also wrote a detailed post about reproducing the MakeShift attack.

Preparing the bike

My bike is a Cube Cross Race C:62 SLX. At the time of purchase, it offered great value for money. The relevant components for the attack are the rear derailleur (Shimano GRX Di2 RD-RX825) and the shift levers (Shimano Ultegra Di2 ST-R8170). The rear derailleur was running firmware version 4.2.3 and the shift levers were on 4.2.2. I only downgraded the firmware of these specific components, but at the time of writing it likely works for other parts too. If you try to downgrade your own components, please be cautious and double‑check everything. I am not responsible if you brick them.

With that out of the way, we can start the downgrading process. The first step is to prepare your bike as if you were updating the components. Since we need to change the version of the shift levers, they need to be connected by wire to the rear or front derailleur or to the battery. It is probably easiest to connect them to your battery. Instructions are available on bettershifting.com (archive). Consider permanently wiring the levers to your battery, as it will make wireless attacks impossible, increase the time between recharges of your battery by about 50%, and make it impossible to forget replacing the coin batteries of the shift levers.

Preparing the phone

Shimano provides the ā€œE‑Tube Projectā€ app. It can be used to update the Di2 components via Bluetooth. A physical button on the rear derailleur needs to be pressed to pair the bike with the phone. You can additionally protect access with a passkey. I read an old forum post that said it's possible to downgrade old groupsets by renaming the firmware files to a newer version and then doing an ā€œupdateā€ with the renamed files. The information I found was rather old and mentioned the Shimano Windows software. A different component had to be used to connect a computer to the bike, which I don't own. I use Linux and an Android phone, so I had to find a different way.

I was hoping to follow a process analogous to the forum post. I wanted to use Burp Suite to see where the app downloads firmware files from and then change the responses to trick the app into flashing the old and insecure firmware to my bike. It turned out that's almost everything that needs to be done. The firmware files themselves also must be patched. The tricky part is intercepting the requests because Shimano tries hard to prevent anyone from doing that. What follows are my failed attempts at using Burp Suite with the ā€œE‑Tube Projectā€ app. In the end, the only way that worked for me was to root the phone, hide the root from the app, and use cert-fixer to add the needed certificates to the system certificate store. Feel free to skip to the next chapter if you are not interested in the failed ideas.

At first, I wanted to use an Android emulator to run the app to inspect its network requests. However, this turned out to be impossible as the app is only available for ARM64 platforms and my laptop has an x86-64 architecture. I tried to set up an ARM64 Android Virtual Device, but did not manage to do so. It seems like only old versions of Android Studio were able to do that. Even if it had worked, the emulator does not support Bluetooth, so the app would not have been able to connect to the phone, rendering it useless.

My next idea was to analyze the APK statically and change the URLs to a domain I control, so I could proxy the network requests. I would then also be able to inspect the requests and responses. I spent a lot of time on this, but the app kept crashing after I re‑signed the changed APK. At the time I thought I caused the crash with my changes, but I now believe the app has tamper detection.

Since this idea was a dead end, I decided to install LineageOS 22.2 on my phone so I could add system certificates and then use Burp Suite on my computer to monitor the network traffic. The device is a Fairphone 5, so the process was well documented (guide). The only time‑consuming part was backing up my data, as I don't use a Google account with the phone. The first step was to unlock the bootloader. Afterwards, a few files get flashed via fastboot and optional add‑ons are installed. The phone was rebooted and Aurora Store was installed. I installed the Shimano ā€œE‑Tube Projectā€ app through Aurora Store. When I launched the app, everything worked as expected. To add system certificates, I installed Magisk and rooted the phone following the official documentation. The Fairphone 5 doesn’t have a RAM disk and the boot.img file is part of the LineageOS download. It does have a separate vbmeta partition, so an additional step was required. Contrary to what the documentation says, my data was not deleted when flashing the vbmeta partition. With this, the device was rooted.

Whenever I tried to launch the ā€œE‑Tube Projectā€ app, it instantly crashed. I first suspected that something went wrong when rooting the phone. I used

adb logcat

to analyze the logs. The stacktrace was confusing and I couldn't make sense of it. Here is part of the output in case anyone wants to find the relevant code in the APK:

10041 10041 D AndroidRuntime: Shutting down VM
10041 10041 E AndroidRuntime: FATAL EXCEPTION: main
10041 10041 E AndroidRuntime: Process: com.shimano.etubeprojectmobile.droid.phone, PID: 10041
10041 10041 E AndroidRuntime: java.lang.RuntimeException: Unable to instantiate application com.shimano.etubeproject.application.ETubeApplication package com.shimano.etubeprojectmobile.droid.phone: java.lang.RuntimeException
10041 10041 E AndroidRuntime:        at android.app.LoadedApk.makeApplicationInner(LoadedApk.java:1478)
10041 10041 E AndroidRuntime:        at android.app.LoadedApk.makeApplicationInner(LoadedApk.java:1403)
10041 10041 E AndroidRuntime:        at android.app.ActivityThread.handleBindApplication(ActivityThread.java:7658)
10041 10041 E AndroidRuntime:        at android.app.ActivityThread.-$$Nest$mhandleBindApplication(Unknown Source:0)
10041 10041 E AndroidRuntime:        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2500)
10041 10041 E AndroidRuntime:        at android.os.Handler.dispatchMessage(Handler.java:109)
10041 10041 E AndroidRuntime:        at android.os.Looper.loopOnce(Looper.java:232)
10041 10041 E AndroidRuntime:        at android.os.Looper.loop(Looper.java:317)
10041 10041 E AndroidRuntime:        at android.app.ActivityThread.main(ActivityThread.java:8934)
10041 10041 E AndroidRuntime:        at java.lang.reflect.Method.invoke(Native Method)
10041 10041 E AndroidRuntime:        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:591)
10041 10041 E AndroidRuntime:        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:911)
10041 10041 E AndroidRuntime: Caused by: java.lang.RuntimeException
10041 10041 E AndroidRuntime:        at util001.notification.ErrReport.c(Unknown Source:40)
10041 10041 E AndroidRuntime:        at util001.notification.ErrReport.c(Unknown Source:33)
10041 10041 E AndroidRuntime:        at util001.notification.ErrReport.init(Unknown Source:39)
10041 10041 E AndroidRuntime:        at util001.notification.ErrReport.start(Unknown Source:52)
10041 10041 E AndroidRuntime:        at com.shimano.etubeprojectmobile.droid.phone.EntryApplication.attachBaseContext(Unknown Source:20)
10041 10041 E AndroidRuntime:        at util001.notification.ErrApplication.attachBaseContext(Unknown Source:7)
10041 10041 E AndroidRuntime:        at android.app.Application.attach(Application.java:346)
10041 10041 E AndroidRuntime:        at android.app.Instrumentation.newApplication(Instrumentation.java:1358)
10041 10041 E AndroidRuntime:        at android.app.LoadedApk.makeApplicationInner(LoadedApk.java:1471)
10041 10041 E AndroidRuntime: ... 11 more
10041 10041 I Process : Sending signal. PID: 10041 SIG: 9
 1386  2666 I ActivityManager: Process com.shimano.etubeprojectmobile.droid.phone (pid 10041) has died: fg TOP
 1386  1716 I libprocessgroup: Removed cgroup /sys/fs/cgroup/uid_10215/pid_10041
 1386  2666 W ActivityTaskManager: ProcessRecord{67f52d0 10041:com.shimano.etubeprojectmobile.droid.phone/u0a215} is removed with pending start  ActivityRecord{66016356 u0 com.shimano.etubeprojectmobile.droid.phone/com.shimano.etubeproject.presentation.main.MainActivity t198}
  872   872 I Zygote : Process 10041 exited due to signal 9 (Killed)

I tried to look at the code with JADX, but many of the functions could not be decompiled to Java. The smali code was also complex, so I didn't pursue it further.

Then I thought the app might have root detection. My only explanation for why they would go to such lengths is that the app can also be used with e‑bikes. By law, their motors are only allowed to accelerate up to 25 km/h, but this might be circumvented by flashing different software. Detecting a rooted phone and crashing the app could be a protection against that.

I tried various approaches to bypass root protection. When statically analyzing the app, I noticed some Firebase code that attempts to detect a rooted phone (and emulators or apps signed with a debug key). Previously I had used the APKLab extension for VS Codium to repackage the app after my changes. It used debug keys to sign the app, so I thought that might have caused the crashes. I patched the smali of the root-detection function and generated keys matching the official ones. Even then, the app crashed on launch. There must be another root check.

I then tried a Frida hook to bypass root protection at runtime. That failed too. The app crashed before any hooks could run. After trying hooks published by other users (e.g. fridantiroot), I gave up on Frida. The app seems to detect if frida-server is running and always crashes. Renaming the executable had no effect so I terminated firda-server and tried other methods.

At the end I found out that Magisk has the capability to hide itself. After enabling ā€œEnforce DenyListā€ and adding the ā€œE‑Tube Projectā€ app to the list, the app finally stopped crashing.

Screenshot of the Magisk app with the settings to hide root highlighted. The relevant settings are ā€˜Enforce DenyList’ and ā€˜Configure DenyList’
Screenshot of the Magisk app with the settings to hide root highlighted
Screenshot of the Magisk app showing the exception list
Screenshot of the Magisk app showing the exception list

The next step was to add the Burp Suite certificates to the system store so I could inspect HTTPS traffic. This isn’t as easy on current Android versions, but the cert-fixer Magisk module makes it simple. The repo only has 137 stars on GitHub, so I was hesitant at first. It doesn’t contain much code, so I checked it for obvious issues and then installed it via Magisk and rebooted. Now all user certificates automatically get copied to the system store on boot. I connected the phone to my computer via USB-C, set the Wi‑Fi proxy to localhost:8080, and ran adb reverse tcp:8080 tcp:8080 to tunnel the traffic. With that, everything was finally set up to analyze the traffic.

Observing network requests

When the ā€œE‑Tube Projectā€ app starts, it immediately makes several requests to Firebase and Facebook. Shimano also uses Gigya for login and analytics. The request most relevant to downgrading firmware was a GET to https://api.shimano.com/etube/firmware/5.4.2. The response is a JSON listing each component’s latest firmware version, filename, size, MD5 hash, and download URL. The app version was 5.4.2, so I tried the API with an older app version (e.g. 5.3.2). It returned almost the same JSON, just missing entries for some components. The versions it did return were the same though. In conclusion, https://api.shimano.com/etube/firmware/5.4.2 is used to check and download firmware, but it doesn’t expose older firmware for downgrading.

Getting old firmware files

Shimano publishes a changelog. For firmware 4.2.2 they mention it fixes an important vulnerability, so I assumed the last insecure version for my rear derailleur and shift levers was 4.2.1. Unfortunately I couldn’t find old firmware files online.

Then I noticed the app bundles some firmware under assets/Firmware. There are websites such as APKPure that allow you to download old versions of an app. I unzipped a few and compared their firmware filenames. The version numbers within the filenames increment with app versions. The challenge was mapping filenames to my GRX and Ultegra components. These components are also named with letters and then 8000. However there were no files like that. There were 7000 (105 level) and 9000 (Dura Ace level) files though. Luckily, both my derailleur and levers had updates available. I used Burp Suite’s match-and-replace to strip out JSON entries from the JSON that was returned when calling https://api.shimano.com/etube/firmware/5.4.2. In order to remove the entry for STR9270 for example, I used the regex:

\{\s*"version" : "4\.3\.0",
\s*"filename" : "STR9270\.4\.3\.0\.dat",
\s*"filesize" : "\d+",\s*"md5" : "[a-f0-9]+",
\s*"type" : "firmware",
\s*"download_url" : "[^"]+"\s*\}

I then checked in the app which parts had to be removed until the app no longer showed an available update. I then knew which files were needed for my components

I also contacted Terry from bettershifting.com for advice. He is a great person and replied within minutes. He’s an expert on Di2 but wasn’t familiar with downgrading. He did mention Ultegra‑level often shares Dura‑Ace firmware, which matched my experiments. At the end I was fairly confident that the correct file for the shift levers was STR9270 or 2STR9270 (Dura Ace line) but for the rear derailleur it was RDR7150-D. I never found out why the entry STR9270 as well as 2STR9270 provide firmware for my shift levers. The app only contains STR9270. Only the API mentions 2STR9270. This meant that I was unable to get old versions for 2STR9270, but I hoped that it would not matter, as I thought the app contains all relevant files.

Changing the network requests

The app enforces HTTPS, so I used Cloudflare Pages to host the old firmware over HTTPS. At first I kept using Burp’s rules, but switched to mitmproxy when I needed more flexibility. With it I replaced the JSON entries for STR9270 and RDR7150‑D and removed 2STR9270. It’s important to only alter the version, filesize, and MD5. You cannot give the files an arbitrary name. Otherwise the firmware will not be displayed in the app. Each segment of the version number must be a single digit (e.g. 6.2.1 is fine, but 2.10.3 isn’t).

Part of the changed response
Part of the changed response

Patching the firmware files and downgrading

Now the app showed firmware updates were available in a popup, but on the update screen they didn’t appear. Using the real updated files worked as expected. This told me that the files themselves must also contain a version number. The popup relies on the API’s version, while the update screen reads the firmware file’s internal version.

Screenshot of the firmware file opened in a hex editor. The 33rd byte is the major version number. The 34th byte is the minor number and the 36th byte is the patch number
Screenshot of the 4.3.0 firmware file opened in a hex editor with the version number highlighted

Inspecting the files in a hex editor, I located the version bytes and changed them to 4.3.0 for the shifters and 4.3.1 for the rear derailleur. The app then listed them correctly. I double checked everything and then pressed the update button, hoping I wasn't going to brick my bike. No errors were thrown and after the process was done, the derailleur displayed version 4.3.1 correctly. The shifters’ version field was blank but they still flashed fine. This completes the downgrade. The only test left was to ensure the bike still shifted and that the shifters would work wirelessly.

Screenshot of the e-Tube Project app with the old firmware with updated version numbers available
Screenshot of the e-Tube Project app before downgrading
Screenshot of the e-Tube Project app after downgrading. The version number of the rear derailleur is correctly displayed as 4.3.1, but the shift levers’ version is blank
Screenshot of the e-Tube Project app after downgrading

Connect the shifters wirelessly

The shifters fall back to wireless as soon as the wire is unplugged. Of course they then need their own power supply, so I inserted a coin cell into each shift lever and disconnected them from the battery. It’s important to plug in the dummy plug where the wire was before. Otherwise the components won’t work. The levers showed full battery, but shifting didn’t work. At first I suspected another issue, but then I realized the bike was still paired with the app. Shifting is disabled when paired. After unpairing, the shifters worked wirelessly on the vulnerable firmware. This completes the setup for the attack.

Reinstating a Secure State

After the attack, you'll likely secure your bike again. During my investigation, I accidentally updated the rear derailleur to the official version 4.3.0. My original plan was to install the old firmware as version 4.2.4. This would allow me to stop the forwarding the requests to Burp Suite / mitmproxy and then install the official version 4.3.0 for both components. This path is blocked due to my mistake. My local bike shop was unable to flash arbitrary version to the components. The only option seems to be to wait for Shimano to publish updates with a higher version number so they can be installed through the app. I will just wait for Shimano to publish version 4.3.2 or higher and then use the official update method to switch to it. I reconnected the shifters with the battery after reproducing the attack, so there are no wireless signals sent anymore. This means my bike is protected against the MakeShift vulnerability already and there is no urgency to update to the newest firmware.