Post

Bricking My Car Stereo

Join me on a four month journey to un-brick my car's head unit

Bricking My Car Stereo

LLMs were used in the writing of this article - this is a dramatic retelling from a compilation of consecutive Claude sessions, edited together by me (human)

The Setup

My car’s head unit is a SoC running Android 7.1.1 on an engineering build from 2019. It runs a heavily customized Chinese ROM. It also came with an app to change to logo on the boot animation to other car manufacturers for some reason (CarLogoSelect.apk). Once I saw the app’s GUI showed options by referring to their file names (NISSAN.WEBP) I got the idea to completely customize the boot animation. Should be easy on and old Android, right?

The idea sat with me. Flash forward about 3 years and I was digging a little deeper. I found that the boot animation is actually 2-phased:

  1. The bootloader’s (U-BOOT) actual animation, before the Linux kernel even loads. It’s a static 24-bit BGR1 BMP loaded from /bootloader/logo_hd.bmp.
  2. The Android boot animation, shown after the kernel loads (at least partially). Loads a file called bootanimation.zip - a zip of image frames2

Getting In

To get an initial shell on the unit I installed an app called SSHelper which opens up SSH on the machine with default credentials. The SSH user is a regular Android app user (untrusted_app) which means it can’t do much - especially since the FS in mounted as read-only. To get started, I had to bridge the network gap by connecting both my laptop and the unit to the same WiFi network.

From there I logged in and the first thing I tried was:

1
settings put global development_settings_enabled 1
1
2
Error: java.lang.SecurityException: Permission Denial...
requires android.permission.ACCESS_CONTENT_PROVIDERS_EXTERNALLY

Makes sense that our user can’t touch the settings provider. But setprop goes through a different permission path and doesn’t care:

1
2
setprop service.adb.tcp.port 5555
setprop ctl.restart adbd # android debug bridge daemon

And from my PC:

1
2
3
adb connect 10.138.143.186:5555
adb root      # engineering build, always works
adb remount   # /system read-write

adb root just works here because it’s an eng build with ro.adb.secure=0. This ADB config does not persist across reboots so every single boot needs the full “SSH in, set the props, connect, root, remount” dance.

Reversing the Logo App

Before touching anything privileged, I wanted to understand how CarLogoSelect.apk actually worked. So I ran it through jadx and apktool.

First thing I found: the password gating the whole app is hardcoded.

1
if ("888888".toLowerCase(Locale.UK).equals(password.getText()...))

The app runs as system (android:sharedUserId="android.uid.system"), which is how it writes to /bootloader/ without the device being “rooted” in the traditional sense. It scans a list of paths for user-supplied images:

  • /sdcard/LOGO/
  • /storage/usbhost1/LOGO/
  • /storage/usbhost2/LOGO/
  • /storage/extsd1/LOGO/
  • /storage/extsd2/LOGO/

And on this hardware, it writes the result to /bootloader/logo_hd.bmp. Drop an image in /sdcard/LOGO/ and the app does the privileged write for you. Good to know but since I already had adb root, I could skip the ceremony and push directly.

Replacing the Boot Logo (1/2)

To convert any image to the format U-Boot was easy enough. Once the bitmap was ready, I could push it

1
adb push logo_hd.bmp /bootloader/logo_hd.bmp

Replacing the Boot Animation (2/2)

The first attempt I made was to push to /data/local/bootanimation.zip, which is Android’s documented override path. Nothing happened. The old animation played exactly as before.

So I ran strings on the bootanimation binary to see which paths it actually checks:

1
2
3
4
5
/system/media/bootanimation-encrypted.zip
/oem/media/bootanimation.zip
/system/media/bootanimation.zip
/system/media/bootanimation_1024.zip
/system/media/bootanimation_800.zip

It checks them in order and picks the first one that it can find. Crucially, there’s a resolution-specific bootanimation_1024.zip, and on this 1024px screen that’s the one that plays.

Second problem: even after pushing to the right file it still used the stock animation. Checking

1
2
adb shell getprop debug.sf.nobootanimation
# 1

Made me realize the device ships with the custom boot animation disabled. This config isn’t in any config file I could find so some random init process sets it at runtime. The quick-and-dirty fix is to override it permanently in build.prop:

1
adb shell "echo 'debug.sf.nobootanimation=0' >> /system/build.prop"

After that, the workflow finally came together:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mkdir -p bootanim/part0
ffmpeg -y -i batman.mp4 \
  -vf "scale=1024:600:force_original_aspect_ratio=decrease,pad=1024:600:(ow-iw)/2:(oh-ih)/2:color=black,fps=30" \
  -q:v 4 bootanim/part0/%04d.jpg

printf "1024 600 30\np 0 0 part0\n" > bootanim/desc.txt

cd bootanim
rm -f ../bootanimation.zip
zip -0 -r ../bootanimation.zip desc.txt part0/
cd ..

adb push bootanimation.zip /system/media/bootanimation_1024.zip
adb shell "stop bootanim; setprop service.bootanim.exit 0; start bootanim"

And so I had a custom boot animation and custom-custom boot logo.

The Part Where I Break Everything

Hello, this is the The Part Where I Break Everything.

Then I tried a higher resolution clip (1008p, 60fps, 18 seconds long). I converted it at 30fps and pushed it.

The animation started, ran for about three seconds and froze. And stayed frozen. Every boot after that hit the same wall of the animation loading and freezing without the system ever fully booting. With no Android, the SSH server never comes up because the network stack never initializes. The USB ports on the unit are host-only so they enumerate USB drives but don’t do ADB device mode. The RST button cold-reboots the unit straight back into the same frozen file. I had soft-locked myself out of my own dashboard and a fix requires some sort of bootstrapping. Bummer. But technically not a brick!

The mistake was obvious in hindsight, which is the worst kind of obvious. The 21MB zip has compressed JPEGs but the bootanimation service decompresses each one into the full frame in RAM:

1
542 frames × 1024 × 600 × 4 bytes ≈ 1.3 GB

The device has only 2GB of RAM so it ran out of memory midway through decoding. This is where the draft of this post sat for four months, because this is where the unit sat for four months.

“And now I just sit in silence” - Tyler Joseph

Four Months in a Frozen Boot

I had a few ideas on how to recover from the stuck boot animation. I built a recovery boot drive from a sketchy Russian car-modding forum but it didn’t work. In fact, every recovery path for this unit assumes you can get something to talk to it, and a head unit frozen between the kernel and Android init talks to nobody. No ADB over USB and certainly not over the network. Over a few days, I took apart the actual device and tried looking for any physical port to plug into (or even a physical SD card I could plug in another device and fix there) but to no avail.

So I gave up and drove around with a frozen screen for a while, which is a deeply stupid way to own a car stereo - no music and a reverse camera that only works for the first 30 seconds after starting the car.

And then it fixed itself. Twice.

In August, after a gas-station stop, I got back in the car and the thing had booted normally - but I didn’t have my laptop handy to make the fix permanent.

In October it happened again. My best guess is that some service held a counter such that eventually, after enough cold power-cycles, it gave up on the animation and lets init continue but I couldn’t prove it. Another interesting piece of evidence is the almost equal spacing between the autonomous recoveries - 2 months after the initial mess up, then another 2 months (to within a day).

The important part is that on the October boot I could get do anything so I opened SSH again and got to work. This time I did the thing I should have done before touching anything the first time: I pulled copies of everything I was about to depend on, because up to this point I’d never had a single backup of the originals. Off the device came the live bootanimation_1024.zip (the 1.27GB brick, for the museum), the generic bootanimation.zip, the pre-change build.prop, and the current logo_hd.bmp. Now I had a rollback. Now I could be reckless responsibly.

The static logo was easy since I could reset it from the CarLogoSelect app on the head unit screen while the car was sitting there.

The animation was the one that bricked me, so I went after the actual bomb:

1
adb shell rm /system/media/bootanimation_1024.zip

The moment I did that, the path lookup logic fell through to the next file that exists, which was the generic /system/media/bootanimation.zip.

I previewed it live without rebooting:

1
adb shell "stop bootanim; setprop service.bootanim.exit 0; start bootanim"

And a tidy little logo spun up on the dashboard. I decided, on the spot, that I liked it more than whatever cinematic nonsense had bricked me in the first place. So I kept it after doing the math again:

1
74 frames × 1024 × 600 × 4 bytes ≈ 182 MB

This one was never going to freeze, I was sure of it3. Last step: make it persist across a reboot. Something kept flipping nobootanimation back to 1, so I pinned it in build.prop:

1
adb shell "echo 'debug.sf.nobootanimation=0' >> /system/build.prop"

And I had music and my reverse-cam working again. Worth it.

What I learned

  1. FAFO is axiomatic
  2. The sin of hubris comes for us all
  3. How to say “brick” in Russian

FOOTNOTES

  1. Why not RGB? 🤷‍♂️ ↩︎

  2. Why does it use such a degenerate format? IDK ↩︎

  3. I was NOT sure of it. Hubris. ↩︎

This post is licensed under CC BY 4.0 by the author.