Reverse Engineering Xiaomi’s Analytics app

I own a Xiaomi Mi4 and I discovered it comes with a pre-installed app called AnalyticsCore, package name com.miui.analytics, that’s running in the background. I’m not a big fan of apps gaining information without my permissions, so I started investigating its activities. For those who don’t know, Xiaomi is the largest smartphone manufacturer in China and actively growing worldwide.

For this I downloaded dex2jar and Java Decompiler and started AnalyticsCore.apk in it. The APK is downloadable here if you want to take a look yourself.
I first googled what its purpose is, and I found a single thread on the Xiaomi forums, but there is no response or explanation on what it does. See this thread.

Inside Java Decompiler there are mainly three interesting classes in how AnalyticsCore gets his updates, named c.class, e.class and f.class by Java Decompiler. Here is the code of a function inside f.class. (all decompiled code)

private boolean I()
  {
    boolean bool = false;
    if (b.t()) {}
    for (;;)
    {
      return bool;
      long l2 = J();
      m.d("Analytics-UpdateManager", "last update check time is " + new Date(l2).toString());
      long l1 = new Random(System.currentTimeMillis()).nextLong();
      if (System.currentTimeMillis() - l2 >= (l1 % (2L * 43200000L) + 2L * 43200000L) % (2L * 43200000L) - 43200000L + 86400000L) {
        bool = true;
      }
    }
  }

The above function checks some time within every 24 hours for a new Analytics update. It makes the following request every day within 24 hours, which is very often if you ask me:

  public void run()
  {
    int i = 0;
    long l1 = System.currentTimeMillis();
    for (;;)
    {
      int j = i + 1;
      if (i < 2) {}
      try
      {
        Object localObject2 = new java/lang/StringBuilder;
        ((StringBuilder)localObject2).();
        Object localObject1 = f.a(this.A);
        ((StringBuilder)localObject2).append("currentApiVersion0.0.0");
        Object localObject3 = new java/lang/StringBuilder;
        ((StringBuilder)localObject3).();
        ((StringBuilder)localObject2).append("currentCoreVersion" + k.t(f.b(this.A)));
        localObject3 = new java/lang/StringBuilder;
        ((StringBuilder)localObject3).();
        ((StringBuilder)localObject2).append("imei" + com.miui.analytics.internal.a.k.j(f.b(this.A)));
        localObject3 = new java/lang/StringBuilder;
        ((StringBuilder)localObject3).();
        ((StringBuilder)localObject2).append("mac" + com.miui.analytics.internal.a.k.k(f.b(this.A)));
        localObject3 = new java/lang/StringBuilder;
        ((StringBuilder)localObject3).();
        ((StringBuilder)localObject2).append("model" + com.miui.analytics.internal.a.k.getModel());
        localObject3 = new java/lang/StringBuilder;
        ((StringBuilder)localObject3).();
        ((StringBuilder)localObject2).append("nonce" + (String)localObject1);
        localObject3 = new java/lang/StringBuilder;
        ((StringBuilder)localObject3).();
        ((StringBuilder)localObject2).append("package" + f.b(this.A).getPackageName());
        localObject3 = new java/lang/StringBuilder;
        ((StringBuilder)localObject3).();
        ((StringBuilder)localObject2).append("ts" + l1);
        ((StringBuilder)localObject2).append("[email protected]#)(*[email protected]!#");
        localObject3 = n.getMd5Digest(((StringBuilder)localObject2).toString()).toLowerCase(Locale.getDefault());
        localObject2 = new java/lang/StringBuilder;
        ((StringBuilder)localObject2).("http://sdkconfig.ad.xiaomi.com/api/checkupdate/lastusefulversion?");
        ((StringBuilder)localObject2).append("currentApiVersion=0.0.0");
        Object localObject4 = new java/lang/StringBuilder;
        ((StringBuilder)localObject4).();
        ((StringBuilder)localObject2).append("¤tCoreVersion=" + k.t(f.b(this.A)));
        localObject4 = new java/lang/StringBuilder;
        ((StringBuilder)localObject4).();
        ((StringBuilder)localObject2).append("&imei=" + com.miui.analytics.internal.a.k.j(f.b(this.A)));
        localObject4 = new java/lang/StringBuilder;
        ((StringBuilder)localObject4).();
        ((StringBuilder)localObject2).append("&mac=" + com.miui.analytics.internal.a.k.k(f.b(this.A)));
        localObject4 = new java/lang/StringBuilder;
        ((StringBuilder)localObject4).();
        ((StringBuilder)localObject2).append("&model=" + URLEncoder.encode(com.miui.analytics.internal.a.k.getModel(), "utf-8"));
        localObject4 = new java/lang/StringBuilder;
        ((StringBuilder)localObject4).();
        ((StringBuilder)localObject2).append("&nonce=" + (String)localObject1);
        localObject1 = new java/lang/StringBuilder;
        ((StringBuilder)localObject1).();
        ((StringBuilder)localObject2).append("&package=" + f.b(this.A).getPackageName());
        localObject1 = new java/lang/StringBuilder;
        ((StringBuilder)localObject1).();
        ((StringBuilder)localObject2).append("&ts=" + l1);
        localObject1 = new java/lang/StringBuilder;
        ((StringBuilder)localObject1).();
        ((StringBuilder)localObject2).append("&sign=" + (String)localObject3);
        localObject1 = new java/net/URL;
        ((URL)localObject1).(((StringBuilder)localObject2).toString());
        localObject1 = (HttpURLConnection)((URL)localObject1).openConnection();
        ((HttpURLConnection)localObject1).setRequestMethod("GET");
        ((HttpURLConnection)localObject1).setConnectTimeout(5000);
        ((HttpURLConnection)localObject1).connect();
        localObject2 = new java/lang/String;
        ((String)localObject2).(d.a(((HttpURLConnection)localObject1).getInputStream()));
        localObject1 = new java/lang/StringBuilder;
        ((StringBuilder)localObject1).();
        m.d("Analytics-UpdateManager", "result " + (String)localObject2);
        localObject1 = new org/json/JSONObject;
        ((JSONObject)localObject1).((String)localObject2);
        localObject3 = ((JSONObject)localObject1).optString("url");
        i = ((JSONObject)localObject1).optInt("code", 0);
        localObject2 = ((JSONObject)localObject1).optString("v");
        f.a(this.A, ((JSONObject)localObject1).optInt("force", 0));
        f.a(this.A, ((JSONObject)localObject1).optBoolean("wifi", true));
        if ((!TextUtils.isEmpty((CharSequence)localObject3)) && (!TextUtils.isEmpty((CharSequence)localObject2)))
        {
          localObject4 = new com/miui/analytics/internal/a;
          ((a)localObject4).((String)localObject2);
          if ((b.q()) || (((a)localObject4).a == 0))
          {
            f.a(this.A, ((JSONObject)localObject1).optString("md5"));
            f.b(this.A, (String)localObject3);
            f.c(this.A).execute(this.A.aP);
          }
        }
        while (i != -8) {
          return;
        }
        long l2 = f.c(this.A, ((JSONObject)localObject1).optString("failMsg"));
        l1 = l2;
        i = j;
      }
      catch (Exception localException)
      {
        f.a(this.A, 0L);
        m.e("Analytics-UpdateManager", "exception ", localException);
        i = j;
      }
    }

As you can see, it makes a request to http://sdkconfig.ad.xiaomi.com/api/checkupdate/lastusefulversion? which is of course an official Xiaomi domain. It sends some parameters with it: including IMEI, MAC address, Model, Nonce, Package name and signature.

After the above code has been executed, it might get an (updated) apk file back. Inside e.class this APK file gets downloaded:

public void run()
  {
    try
    {
      if ((!k.m(f.b(this.A))) && (f.d(this.A))) {}
      for (;;)
      {
        return;
        Object localObject1 = new java/net/URL;
        ((URL)localObject1).(f.e(this.A));
        localObject1 = (HttpURLConnection)((URL)localObject1).openConnection();
        ((HttpURLConnection)localObject1).setRequestMethod("GET");
        ((HttpURLConnection)localObject1).setConnectTimeout(5000);
        ((HttpURLConnection)localObject1).connect();
        if (((HttpURLConnection)localObject1).getResponseCode() == 200)
        {
          Object localObject2 = d.a(((HttpURLConnection)localObject1).getInputStream());
          localObject1 = localObject2;
          Object localObject3;
          if (!TextUtils.isEmpty(f.f(this.A)))
          {
            localObject3 = a.a((byte[])localObject2);
            localObject1 = localObject2;
            if (!f.f(this.A).equalsIgnoreCase((String)localObject3)) {
              localObject1 = null;
            }
          }
          if (localObject1 != null)
          {
            Log.d("Analytics-UpdateManager", "download apk success.");
            localObject2 = new java/io/File;
            ((File)localObject2).(f.g(this.A));
            localObject3 = new java/io/FileOutputStream;
            ((FileOutputStream)localObject3).((File)localObject2);
            ((FileOutputStream)localObject3).write((byte[])localObject1);
            ((FileOutputStream)localObject3).close();
            f.h(this.A);
          }
        }
      }
...


The download location for the APK is set in f.class, where also the 24h time check was placed:

private String G()
  {
    try
    {
      Object localObject = new java/lang/StringBuilder;
      ((StringBuilder)localObject).();
      localObject = this.mContext.getExternalCacheDir().getAbsolutePath() + "/Analytics.apk";
      return (String)localObject;
    }
    catch (Exception localException)
    {
      for (;;)
      {
        String str = "";
      }
    }
  }


Now the question is, where does this APK gets installed? I couldn’t find any proof inside the Analytics app itself, so I’m guessing that a higher privileged Xiaomi app runs the installation in the background. The question is then: does it verify the correctness of the APK, and does it make sure that it is in fact an Analytics app? If it does not, that means Xiaomi can install any app on your device it wants, as long as it’s named Analytics.apk.

Update 12:31: Someone told me the package gets installed from l.class, with following code:

try
    {
      paramContext.getPackageManager().getClass().getMethod("installPackage", new Class[] { Uri.class, Class.forName("android.content.pm.IPackageInstallObserver"), Integer.TYPE, String.class }).invoke(paramContext.getPackageManager(), new Object[] { Uri.parse(paramString), null, paramContext.getPackageManager().getClass().getField("INSTALL_REPLACE_EXISTING").get(null), null });
      m.d("AppInstaller", "install apk success.");
      return;
    }
...


It seems like there indeed is no validation on what APK is getting installed. So it looks like Xiaomi can replace any (signed?) package they want silently on your device within 24 hours. And I’m not sure when this AppInstaller gets called, but I wonder if it’s possible to place your own Analytics.apk inside the correct dir, and wait for it to get installed (edit: getExternalCacheDir() is inside the app’s sandbox, so probably not). But this sounds like a vulnerability to me anyhow, since they have your IMEI and Device Model, they can install any apk for your device specifically.

If you own a Xiaomi device yourself, you might want to block all access to Xiaomi related domains, because by far this isn’t the only request to a Xiaomi site. I use AdAway for this. It does require root access, but that should be no problem if you run the International ROM. I don’t know if the official rom supports root access out of the box.
My AdAway:

Here is a link to a post with other bloatware apps you can safely remove from your device, next to Analytics: https://forum.xda-developers.com/xiaomi-mi-3/general/tip-safe-to-remove-bloatware-list-miui-t2999283

If anyone has tips or a comment, please email or contact me.

How to not implement Premium features in Android

I love to regularly watch new episodes of American series and I prefer to watch them with English or Dutch subtitles. I use a script called subliminal to download them right after downloading an episode or movie from usenet (my Cubox-i4Pro handles this automatically, I can much recommend it as upgrade for your Raspberry Pi!).

Unfortunately subliminal can’t always find the right subtitles, and especially not for downloading an episode that has just been broadcasted in America. This made me look for a way to directly download subtitles from my couch, and I came across this Android app called ‘MightySubs‘ which let you download subtitles from all popular subtitle sites to your samba or ftp share.

MightySubs has a free version and a premium version that costs 99 cents. The free version has some limitations including:

  • There are only two languages available: English and your device’s language setting (premium version has 22 languages).
  • Not more than 10 daily downloads allowed (premium version increases this limit by providing custom account details for addic7ed).

 
When trying to edit a premium setting it gives a toast message like in the picture above, stating it is not available in the free version and the premium version must be bought to enable this option. That got me thinking whether it only blocks the settings panel or actually blocks the functionality inside the app itself. I figured it’s only blocking the option and that it must be possible to change this directly in the saved settings.

Most of the time, an app saves user’s settings by using the Android SharedPreferences class. It writes saved data to an xml file stored inside the ‘shared_prefs’ folder located at /data/data/com.yourpackage where com.yourpackage is the package name of the app.

This is most likely also how MightySubs works. The only thing we need is the package name. That is easy to find by looking at the Google Play store URL: https://play.google.com/store/apps/details?id=info.toyonos.mightysubs. So the app’s prefs data is stored at /data/data/info.toyonos.mightysubs/shared_prefs.
The device needs to be rooted to get inside this dir. Mine is, so I browsed to /data/data/info.toyonos.mightysubs/shared_prefs and there was indeed a file named info.toyones.mightysubs_preferences.xml with the following contents:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="PrefCurrentProfileIp">192.168.1.10</string>
    <string name="PrefSubtitlesFetchersList">{&quot;activeFetchers&quot;:[2,1,6]}</string>
    <string name="PrefDailyDownloadCounter">20150814:0</string>
    <boolean name="PrefOnlyWithoutSubtitles" value="false" />
    <boolean name="PrefCurrentProfileDownloadPathEnable" value="false" />
    <string name="PrefSubtitlesManualSelection">1</string>
    <string name="PrefAdditionalExtensions"></string>
    <string name="PrefAddic7edPassword"></string>
    <string name="PrefAddic7edUsername"></string>
    <string name="PrefCurrentProfilePath">/path/to/series/</string>
    <boolean name="PrefFullLanguageExtension" value="false" />
    <string name="PREF_VERSION_KEY">1.5.0</string>
    <string name="PrefCurrentProfileName">Local profile</string>
    <string name="PrefHearingImpaired">2</string>
    <string name="PrefCurrentProfileType">1</string>
    <boolean name="PrefKeepSubtitleFilename" value="false" />
    <string name="PrefActiveProfile">1</string>
    <string name="PrefDefaultLanguage">EN</string>
    <string name="PrefLanguageExtension">0</string>
    <string name="PrefCurrentProfileMediaType">0</string>
    <null name="PrefCurrentProfileUsername" />
    <null name="PrefCurrentProfilePassword" />
</map>

This is the file where all the settings are stored. And it is possible to edit them thanks to our root access. Looking at the names of the strings, it seems like PrefDefaultLanguage is the one that contains the enabled languages. It is currently set to “EN”.

Editing this string should give us more languages. My first try was to change it to “EN NL” to enable Dutch subtitles, but that made the app crash. I tried “EN,NL”, but same result. My last try was “EN;NL” and that worked. It unlocked the premium feature.

 
I also entered my addic7ed account details, which also bypasses the daily download limit. There is no need to buy the premium version anymore.

Contacting the developer

I sent an email about my discovery to the MightySubs developer on 31th of July, to the email provided in the Google Play Store. Unfortunately I have not received a response and the app has not been updated. (article updated, see below)

I would still suggest buying the premium version of the app if you are going to use premium features. It’s a great working app and well worth the 99 cents.

If you make premium features and you want to make sure people aren’t directly editing your settings, do not just disable the settings dialog, but also confirm in the app itself whether the right options are chosen. Even better would be to completely remove unused functionality: this would also decrease the app’s download size (only removing is probably a bit more work).

By all means, make sure that people are not able to use a setting that you don’t want them to use. This counts for a lot of things in security though, and sometimes people tend to forget this, also often on website dropdown forms.
 
Update 16/08/2015: I received a reply from the developer. He wrote (click here for all):

“I read your blog entry 🙂 Nice job. Just one thing. The trick you explained, about the language, is not accurate. You transformed the language setting from EN to EN;NL (both English and Dutch). But it is permitted in the free version. Both languages are already here and can be selected together. A real hack would have been turning EN to DE or FR (not allowed in the free version for you).
About Addic7ed credentials, filling them doesn’t overcome the limitation (10 per day). It just allows premium users to have a better quota on this site. It could be a real pain to be unlimited on MightySubs but limited on Addic7ed.

Anyway your demonstration is accurate, the hack is real and I should fix that.”

He says that both English and Dutch are permitted in the free version, but Dutch was not available for me and it showed a message “Purchase the premium version to get more languages”. Any how, it doesn’t matter, as it is possible to change it to whatever language you want including DE;FR (I tested it).
I wasn’t aware that the account details didn’t bypass the limitation, however in the file above there is also a string called PrefDailyDownloadCounter which probably counts the number of subtitles downloaded so far. I did not test it, but resetting it to zero after ten downloads would probably work.

The developer further wrote that he will bring out a new version in September with new features. Hopefully this hack is also fixed by then.