Decrypting a Car Infotainment OTA Package

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

Decrypting a Car Infotainment OTA Package

Well, where to start — my car’s infotainment system finally got an update. It added the features I wanted, and I’d been waiting a long time. Great news.

My plan: just pull the APKs straight out of the OTA package and install them, no need to update the whole infotainment system. That plan failed.

I got my hands on the long-awaited OTA package and opened it up:

The OTA package

It’s encrypted. Ouch.

Then I thought: if it’s encrypted here, the infotainment system must also decrypt it before installing. So, curious, I went digging.

0x1 Getting the updater APK

Through the car-owner group I got the APK responsible for OTA upgrades. Dropped it into jadx and searched for the keyword HuOs:

Sure enough, there it was — my upgrade path and the package format, .zip.

Following the call stack statically led to the decrypt method:

The decrypt method

The package is encrypted with AES/CBC/PKCS5Padding. The infotainment system decrypts the file after receiving it, then installs it.

0x2 Getting the decryption key

From the previous step, the key comes from a ckey value. That value is decrypted, and the first 16 bytes become the system package’s AES key:

ckey decrypt

Decrypting this needs two things:

  1. Get the ckey file. (The ever-reliable car-owner group provided it.)
  2. Get the decrypt implementation. (Same group, again.)

Step 2 took a few detours. I first assumed the decrypt logic lived in boot-framework.vdex, but after searching it turned out to be in boot-ext.vdex.

That requires two tools:

  1. Convert VDEX to CDEX.
  2. Convert CDEX to DEX.

Finally open it up:

The decompiled dex

The decrypt method passes the file’s value and a key into native code, which computes the result:

Native decrypt

So I also need the token:

0x3 Getting the token that decrypts the key

Static analysis shows the token comes from checking some file values in the updater APK inside native code, which returns the result:

Token via native

I first assumed I’d need to reverse the native computation — then I decompiled it and saw…

It just compares the fetched data and directly returns the token. I overcomplicated it.

int __fastcall Java_com_***_***_***_WhiteBoxNativeImpl_connectionToNativeVerifyDigest(JNIEnv *a1, int a2, int a3, size_t a4, int a5, int a6, size_t a7, int a8, int a9, int a10, int a11)
{
  _DWORD *v13; // r9
  const char *v14; // r5
  const char *v15; // r10
  int v16; // r0
  const char *v17; // r5
  jbyte *v18; // r4
  jbyte *v19; // r6
  jstring (*v20)(JNIEnv *, const char *); // r2
  const char *v21; // r1
  const char *v23; // r4
  void *v24; // r9
  const char *v25; // r10
  jbyte *v26; // r4
  jbyte *v27; // r5
  const char *v28; // [sp+1Ch] [bp-2Ch]
  const char *v29; // [sp+24h] [bp-24h]

  v13 = malloc(0x20u);
  v14 = (*a1)->GetStringUTFChars(a1, a9, 0);
  v15 = (*a1)->GetStringUTFChars(a1, a5, 0);
  if ( !j_readFileFromApk(v14, v15, v13) )
  {
    _android_log_print(4, "LeosinAcsctl: digest", "can't find file, LINE = %d", 275);
LABEL_8:
    v20 = (*a1)->NewStringUTF;
    v21 = "can't find file";
    return (int)v20(a1, v21);
  }
  v29 = v14;
  v16 = j_calculateDigest(a1, v13, a10);
  if ( !v16 || (v17 = (const char *)v16, !v13[4]) )
  {
    _android_log_print(4, "LeosinAcsctl: digest", "unsupproted signAlg, LINE = %d", 281);
    v20 = (*a1)->NewStringUTF;
    v21 = "unsupproted signAlg";
    return (int)v20(a1, v21);
  }
  v18 = (*a1)->GetByteArrayElements(a1, a3, 0);
  v19 = (jbyte *)malloc(a4);
  qmemcpy(v19, v18, a4);
  if ( strncmp(v17, v19, a4) )
  {
    _android_log_print(4, "LeosinAcsctl: digest", "mainfiestFileDigest is wrong, LINE = %d", 290);
    v20 = (*a1)->NewStringUTF;
    v21 = "mainfiestFileDigest is wrong";
    return (int)v20(a1, v21);
  }
  (*a1)->ReleaseByteArrayElements(a1, (jbyteArray)a3, v18, 2);
  (*a1)->ReleaseStringUTFChars(a1, (jstring)a5, v15);
  free(v19);
  free(v13);
  v23 = (*a1)->GetStringUTFChars(a1, a8, 0);
  v24 = malloc(0x20u);
  if ( !j_readFileFromApk(v29, v23, v24) )
  {
    _android_log_print(4, "LeosinAcsctl: digest", "can't find file, LINE = %d", 301);
    goto LABEL_8;
  }
  v28 = v23;
  v25 = (const char *)j_calculateDigest(a1, v24, a11);
  v26 = (*a1)->GetByteArrayElements(a1, a6, 0);
  v27 = (jbyte *)malloc(a4);
  qmemcpy(v27, v26, a7);
  __android_log_print(4, "LeosinAcsctl: digest", "dexClassDigestLen is %d", a7);
  __android_log_print(4, "LeosinAcsctl: digest", "dexClassDigestArr is %s", v27);
  __android_log_print(4, "LeosinAcsctl: digest", "dexClassDigest is %s", v25);
  if ( !strncmp(v27, v25, a7) )
  {
    (*a1)->ReleaseByteArrayElements(a1, (jbyteArray)a6, v26, 2);
    (*a1)->ReleaseStringUTFChars(a1, (jstring)a8, v28);
    free(v27);
    free(v24);
    (*a1)->ReleaseStringUTFChars(a1, (jstring)a9, v29);
    v20 = (*a1)->NewStringUTF;
    v21 = "****";
  }
  else
  {
    _android_log_print(4, "LeosinAcsctl: digest", "can't find file, LINE = %d", 313);
    v20 = (*a1)->NewStringUTF;
    v21 = "dexclass digest is wrong";
  }
  return (int)v20(a1, v21);
}

0x4 Decrypting the key

Now that I have the token, I pass the token and ckey into Unidbg to compute the result:

    public byte[] key() {
        String y2 = "decryptUsingNative([BLjava/lang/String;)[B";
        // arg1: ckey value, arg2: token value
        DvmObject<?> dvmObject = UmeJni.callStaticJniMethodObject(emulator, y2, Base64.getDecoder().decode("j3AR4u/Jxxxxxxxxxxxxj7ibVPSX695NCfhxxxxQWc="), "****");
        return (byte[]) dvmObject.getValue();
    }

Unidbg key

0x5 Decrypting

With the key in hand, back to step one: decrypt with AES/CBC/PKCS5Padding to get the OTA package:

AES decrypt

Then use the open-source payload-dumper-go to extract system.img from payload.bin:

And there’s the APK I wanted:

APK extracted

0x6 Wrap-up

The whole thing took an afternoon. I overestimated the difficulty at first — once I actually got going, it helped enormously that the car group provided the files to analyze. The hardest part is always getting started; once you do, the rest flows.

Overall the analysis wasn’t hard — just basic calls and a few simple concepts bolted together.

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch