I recently got a Cosmo Communicator from Planet Computers, the device that's basically the unholy offspring of a Psion Series 5mx and a no-name Android smartphone. The hardware is pretty nice (although it's held back in some aspects by their obvious lack of budget, as a niche manufacturer), but the software lets it down, which is why I've been trying to hack on it.

The Cosmo uses a MediaTek Helio P70 as its SoC, so its Android 9.0 ROM is based off MediaTek's Android variant. You get the usual AOSP trappings, Google services/apps (although the usual Google setup wizard is curiously missing!), a variety of MediaTek additions, and then some Planet-specific additions on top.

There's two kinds of OTA updates on the Cosmo. First, there's the main Android updater, SystemFota.apk, which is accessed via the Settings app > Advanced > Wireless update. Then, there's Cover Display Assistant, a standalone app which manages the firmware on the Cover Display (or CoDi for short - a 2" OLED panel on the front of the Cosmo, powered by a STM32 microcontroller).

I won't go into the CoDi app too much, as it's quite straightforward. It's a bespoke app that polls http://fota.planetcom.co.uk/stm32flash/cosmo_stm32_firmware_versions.txt (over HTTP, ugh) to check for updates. It's very simple, and gives you the URLs and versions for every version along with the ID of the Android ROMs they're compatible with.

The other updater is what I'm here to talk about. SystemFota is not Planet-specific; it's an obfuscated disaster provided by Shenzhen Digitime Technology. I can't find much in the way of prior research about it online other than the dante198406/OTA GitHub repo which looks like an old dump of tools/docs provided by Digitime to a device manufacturer. There's versions of the SystemFota APK, Chinese instructions on integrating it and building OTAs, and even old login credentials for their management portal.

The PowerPoint presentations in it explain some of the portal's capabilities for ODMs. They can see the IMEI numbers registered with the update system (with activation time and region), view statistics about update deployment, push and configure updates, lock updates to certain IMEIs and even blacklist certain IMEIs from updating.

This gives us some helpful background on the system, but we want to know more, especially about the app that's actually running on our devices.

(Note: I originally wrote most of this post assuming good faith on DigitimeTech's part. Further research led me to discover that much of their business appears to deal with distributing Android adware and malware, like the example in this XDA Developers thread - which explains much of the odd architectural decisions in this system and all of the obfuscation. I'll talk about that more on a later date.)

Inside SystemFota

I pulled the latest version using ADB from /system/priv-app/SystemFota/SystemFota.apk; it's not odexed which makes it a bit easier to reverse. I threw it into jadx 1.0.0, my usual first port of call for Android reversing.

In the Resources section, we find the following:

  • layouts and drawables for the interface
  • a network-security-config begging Android to permit plaintext network traffic (just what you want in your OTA updaters, really)
  • multiple ARM builds of libkey.so (Lua 5.1.5 with LuaJava)
  • server.digitimetech.crt and server.digitimetech.key (a RSA certificate and private key)
  • mystery license_01 and license_03 assets (we'll come back to these...)

The Java is fairly minimal, and partially obfuscated but there's still enough names left behind to gain a good picture of what's going on. First though, let's look at the resources

RSA Stuff

It's quite easy to read the RSA files in the assets directory with OpenSSL:

$ openssl rsa -text -noout < server.digitimetech.key
Private-Key: (2048 bit)
modulus:
    00:b4:1c:84:2d:47:87:9c:05:d4:1b:e1:ea:77:c2:
    f7:9d:39:1f:a0:52:33:82:29:27:9f:9b:00:a9:7c:
# ... elided for brevity
$ openssl x509 -text -noout < server.digitimetech.crt
Certificate:
    Data:
        Version: 1 (0x0)
        Serial Number: 17583969345499203561 (0xf406cef4b3d687e9)
    Signature Algorithm: sha1WithRSAEncryption
        Issuer: C=CN, ST=Guangdong, L=Shenzhen, O=Shenzhen digitimetech  Science and Technology Co., Ltd., OU=service department, CN=digitimetech.com
        Validity
            Not Before: Mar 16 03:35:29 2016 GMT
            Not After : Mar 14 03:35:29 2026 GMT
        Subject: C=CN, ST=Guangdong, L=Shenzhen, O=Shenzhen digitimetech  Science and Technology Co., Ltd., OU=service department, CN=digitimetech.com

The com.dtinfo.tools.activity.b Java class adds this certificate to a TrustManager when making a HTTPS request to the OTA server, so it can't be MITMed in the usual fashion. The OTA server must provide a cert trusted by it, so this gives you protection against MITM attacks...

Or it would, had they not left the private key for the certificate right in the APK. Congratulations, Digitime, on the most pointless implementation of TLS certificate pinning I've ever seen. 🔥 (And that's disregarding the fact that failed connections fall back to plaintext HTTP on a different server, but I'll talk about that later)

Extracting scripts from license_01 and license_03

This is where the Lua interpreter comes in. Each of these is a ZIP file containing Lua scripts compiled to bytecode; 01 is for the armeabi architecture and 03 is for arm64-v8a, as revealed by com.dtinfo.tools.info.FoUtil2. The underlying code appears to be the same in both, they're just for different Lua builds.

I compiled Luadec within Termux and ran it:

$ unzip /system/priv-app/SystemFota/SystemFota.apk assets/license_03
$ unzip -d bc assets/license_03
$ mkdir out outdis outnl
$ for i in bc/*; do ../luadec/luadec/luadec $i > out/$(basename $i).lua; done
$ for i in bc/*; do ../luadec/luadec/luadec -a $i > outnl/$(basename $i).lua; done
$ for i in bc/*; do ../luadec/luadec/luadec -dis $i > outdis/$(basename $i).dis; done

Luadec's heuristics have issues with some of the constructs in this bytecode, so having the -a version (turns all registers into local variables, at the cost of making the output more verbose) and the disassembled Lua bytecode can help with figuring out what's going on.

I also tried Unluac which does a slightly better job on some of the functions, but also makes things harder to follow by not showing upvalues (Lua's term for captured variables within closures) correctly.

Bootstrapping Lua

The FoHandler Java class coordinates loading of the Lua code. The following operations are permitted:

  • Java FoBoot.start_boot: calls Lua BootEntry from boot, no parameters
  • Java FoBoot.start_boot_ex: calls Lua BootEntryEx from boot, passing a string parameter
  • Java BaseIntentService.readFotaConfig: calls Lua BootReadFotaConfig from boot, no parameters, expecting and returning a string map
  • Java BaseIntent.setTestCondition: calls Lua BootSetTestCondition from boot, no parameters
  • Java InfoReceiver.onReceive: calls a Lua function/script specified at construction time, passing the intent as a parameter (this bit doesn't seem to be used)

In order to understand the Lua code you need a bit of background knowledge provided by FoStateGroup.CallFunc. The following variables are set within the Lua state:

  • env_thread_handler: the FoHandler instance (first param to CallFunc)
  • env_luastate_idx: unsure, possibly unused? (second param to CallFunc)
  • box_package_name: the Android package name
  • debug_switch: equal to BaseIntentService.debug_switch
  • trace_enable: equal to BaseIntentService.trace_enable
  • service_ver_int: always 19, in the version I examined

With this you can start looking at the four entry points themselves. I've translated some of the relevant code into pseudocode, minus debug messages and error checking:

def InitVars():
    EnvGet('box_service_class'):storeData('boot_version', EnvGet('boot_version')
    EnvGet('box_service_class'):storeData('worker_dir', EnvGet('worker_dir')

def BootSetTestCondition():
    InitVars()
    ConfigSet('activate_account', ConfigGet('activate_condition') + 1)
    ConfigSet('check_time', System.currentTimeMillis() - (ConfigGet('interval_long') * 1000) - 1000))
    handler:storeData('result', {result: 'ok'})

def BootReadFotaConfig():
    InitVars()
    hmap = {}
    for key in ('fota_url_1', 'fota_url_2', 'fota_url_3', 'fota_url_4'):
        hmap:put(key, tostring(ConfigGet(key)))
    if ConfigGet('fota_ad_switch'):
        hmap:put('fota_ad_switch', ConfigGet('fota_ad_switch'):upper() == 'TRUE')
    handler:storeData('result', hmap)

def BootEntry():
    InitVars()
    EnvGet('box_service_class'):storeData('boot_handler', start_thread('boot_thread'))
    call_boot_func('BootStart')

def BootEntryEx():
    InitVars()
    EnvGet('box_service_class'):storeData('boot_handler', start_thread('boot_thread')
    if check_worker(): #loads the worker
        call_worker_func('boot_handler', 'WorkerEntry', 0, stringParam)
    call_boot_func('BootEnd')

def BootStart():
    ver = check_worker() #loads the worker
    EnvSet('worker_version', ver)
    ScheduleCheck()
    if not ver:
        ver = check_worker()
        EnvSet('worker_version', ver)
    if ver:
        call_worker_func('boot_handler', 'WorkerEntry')
    call_boot_func('BootEnd')

This mostly calls into the Env and Config Lua scripts, so those are a good port of call for further investigation.

Env script

EnvSet forwards set requests to EnvGet('boot_handler'):storeData(key, value) - not particularly exciting. EnvGet, however, provides a veritable bounty of fields that tell us more about the information that the system uses:

  • service_context, boot_handler, worker_handler, aidl_handler: redirected to BaseIntentService.fetchData(key)
  • luastate_idx: returns the state passed to FoBoot.callFunc
  • channel_id: returns the ChannelID from the AndroidManifest (for Cosmo, this is EASTAEON)
  • project_id: returns the ProjectID from the AndroidManifest (for Cosmo, this is EASTAEON_FW_20190712)
  • model_name, rom_version_major, rom_version_minor, sdk_version: device metadata
  • service_version: returns BaseIntentService.getServiceVer() (currently v1.9)
  • boot_version: returns boot_version from the ver script (currently v1.6.64)
  • box_service_class: returns BaseIntentService
  • box_utility_class: returns FoUtil2
  • boot_dir: returns FoBoot.boot_path() (the path to the lib directory in the updater package, I think)
  • boot_script_file: returns boot_dir concatenated with boot
  • boot_config_file: returns bootcfg
  • boot_temp_dir: returns EnvGet('service_context'):getFilesDir():getAbsolutePath() plus btmp/
  • worker_version, boot_receiver: redirected to EnvGet('boot_handler').fetchData(key)
  • boot_sharedpreferences_file: returns EnvGet('service_context'):getFilesDir():getAbsolutePath() plus ../shared_prefs/bootcfg.xml
  • boot_sharedpreferences_backup_file: returns external storage path (I think) plus ../gvc/bootcfg
  • worker_path: returns EnvGet('service_context'):getFilesDir():getAbsolutePath() plus ConfigGet('worker_dir')
  • worker_script: returns EnvGet('service_context'):getFilesDir():getAbsolutePath() plus ConfigGet('worker_dir') and ConfigGet('worker_script')
  • all others: redirected to EnvGet('boot_handler').fetchData(key)

Config script

ConfigSet stores encrypted keys and values (for string values) into the prefs file identified by Env boot_config_file using the standard SharedPreferences mechanism.

ConfigGet pulls values from that file, with the following defaults used:

  • boot_url_1: http://statistics.flurrydata.com:10000/boot
  • boot_url_2: http://52.200.115.202:10000/boot
  • boot_url_3: http://{gen_domain_name()}:10000/boot (more details later)
  • worker_dir: worker
  • worker_script: worker
  • interval_short: 28800
  • interval_long: 28800
  • activate_condition: License field from AndroidManifest (for Cosmo, this is 0)
  • fota_url_1: https://app.fota.digitimetech.com
  • fota_url_2: https://app.fota.digitimetech.com
  • fota_url_3: http://47.254.145.86
  • fota_url_4: https://app.fota.digitimetech.com

Putting this together

We now have some things to look at. There's a variety of paths to explore and also some domains/URLs.

Small detour to look at get_domain_name, which is used to generate boot_url_3. I absolutely adore how dodgy this is. It feels like I'm looking at some sort of malware, except this is actually an official updater that shipped on a device I paid over £500 for... Pseudocode once again:

def get_domain_name():
    cal = java.util.Calendar.getInstance()
    hash = md5('boxdomain%04u%02u' % (cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1))
    return 'boot.b' .. (hash:sub(1, 8):lower()) .. '.net'

So this hashes strings like boxdomain201911 and selects the first 8 digits of the hash. I tried this with a few dates and couldn't get any domains that had ever been registered. I don't know if I've done something wrong, or if Digitime actually never registered those.

Anyway, back to paths. First, there's the files dir for the service context, which stores prefs and the worker script stuff. This is at /data/user/0/com.dtinfo.tools. Running find . within that directory gives me an overview of what's in there.

# ls
cache code_cache files shared_prefs
# ls cache/
# ls code_cache/
# ls -a files/
. .. .log.pro.did .log.pro.uuid btmp worker
# ls files/btmp
workerpkg
# ls files/worker/
action_check action_register action_upgrade common env  misc    phone    simplejson worker
action_extra action_test     app_package    config file network schedule uuid
# ls shared_prefs/
Dgtd.xml bootcfg.xml checkedState.xml did_pro.xml uuid_pro.xml workercfg.xml

The preferences don't hold anything too interesting right now, just some state and some device IDs. However, workerpkg is another zip file containing Lua bytecode (like the license_03 file from the APK), and it's different from what we've already looked at! This must be coming from somewhere, but where?

The action_upgrade Lua script defines ActionUpgrade, which accepts a table containing url and md5 keys and uses these to fetch and extract a new workerpkg. This is called from two places: ActionInfo and ActionCheck, which are in similarly named files.

We now have enough detail to get a rough read on what's going on! First, a look into ActionInfo. Here's some pseudocode:

def ActionInfo():
    blob = {
        action: 'info',
        UID: ConfigGet('UID'), #update system device ID
        env: EnvInfo(), #Env fields: channel_id, project_id, model_name, sdk_version, service_version, boot_version, worker_version
        config: ConfigGetInfo(), #returns all known Config fields
        phone: PhoneInfo(), #returns metadata from android.os.Build class
        telephony: PhoneReadTelephonyInfo() #returns IMEI, SIM phone number and serial number, info on connected network
    }
    json = simplejson.encode(blob)
    for key in ('boot_url_1','boot_url_1','boot_url_2','boot_url_2','boot_url_3','boot_url_3'):
        resp = NetworkPost(ConfigGet(key), json)
        if resp:
            break
    resp = simplejson.decode(resp)
    if resp.errcode > 0: return
    if resp.config: ConfigSetInfo(resp.config) #overwrites config fields
    if resp.cmd == 'register': ActionRegister(resp.params)
    if resp.cmd == 'upgrade': ActionUpgrade(resp.params)

So this sends a ton of info to the Boot endpoint about the device and checks the response. The server can supply new config values. If the command is register, the client calls ActionRegister, and if the command is upgrade, the client calls ActionUpgrade (which we already know fetches a new Worker version).

That leaves two to look at: ActionCheck and ActionRegister.

Register simply sends {action: 'register', UID: ConfigGet('UID'), env: EnvInfo()}. The server returns a new UID which is stored using ConfigSet and config which is stored using ConfigSetInfo (like with Info), and if a UID has been successfully assigned, it goes on to call ActionCheck.

Check sends {action: 'check', UID: ConfigGet('UID'), env: EnvInfo()}. The server can supply new config values as with Info and Register, and a command which can be either register, upgrade or info (which in turn triggers one of those actions).

The only reference to any of the actions outwith their own code is ActionCheck which is called from ScheduleCheck, a simple function in the schedule script that checks how long it's been since the last check and runs ActionCheck if it's been long enough. This in turn is called from BootStart, which was covered above.

Now we can make a reasonable guess at the process here...

  • Updater starts up the Lua engine, calling BootEntry, which triggers ScheduleCheck, which in turn triggers ActionCheck
  • Client sends check action
  • Server asks client to register (if it has never seen this UID before), upgrade (if the client needs a new Worker) or info (if it wants details from the client)
  • More in-depth operations are performed by the Worker, which can be dynamically updated by the server through upgrade

Let's see if we can MITM this operation in practice!

# am start-service com.dtinfo.tools.info.BaseIntentService --ei testmode 1

Doing this causes it to fire off a series of requests which can be decrypted using some simple Python code (this works for the keys/values in the shared_prefs as well):

import base64
def dec(s):
    blob = bytearray(base64.b64decode(s.encode('ascii')))
    key = b'HiBox_5i5j_XiMik'
    for i in range(len(blob)):
        blob[i] ^= key[i % len(key)]
    return blob.decode('ascii')

Manually wrapped for readability:

CLIENT: {"action":"check","env":{"sdk_version":28,"model_name":"Cosmo_Communicator",
    "service_version":"v1.9","boot_version":"v1.6.64","project_id":"EASTAEON_FW_20190712",
    "channel_id":"EASTAEON","boot_version_ext":"c"}}
SERVER: {"cmd":"register","errcode":0}
CLIENT: {"action":"register","env":{"sdk_version":28,"model_name":"Cosmo_Communicator",
    "service_version":"v1.9","boot_version":"v1.6.64","project_id":"EASTAEON_FW_20190712",
    "channel_id":"EASTAEON","boot_version_ext":"c"}}
SERVER: {"UID":"A1002[redacted]","errcode":0}
CLIENT: {"env":{"sdk_version":28,"model_name":"Cosmo_Communicator","service_version":"v1.9",
    "boot_version":"v1.6.64","project_id":"EASTAEON_FW_20190712","channel_id":"EASTAEON",
    "boot_version_ext":"c"},"action":"check","UID":"A1002678943"}
SERVER: {"params":{"url":"http:\/\/cdn.facebook-3rd.com\/cdn2\/worker_v00_64_b.rdf",
    "zip":true},"cmd":"upgrade","config":{"interval_short":43200,"interval_long":43200},
    "errcode":0}

The client asks for a check; the server tells it to register. The client registers; the server tells it its UID. The client asks for a check again, and now that it has a UID, the server tells it to download a zip. There again we have another unverified blob of Lua code being downloaded over a cleartext connection from an incredibly shady domain name. Nice.

The Worker

This worker follows a similar architecture to the main Lua blob, complete with the same crude XOR encryption algorithm. Here's the URLs from its config module:

  • worker_url_1: http://analyze.flurrydata.com:10000/v15_worker
  • worker_url_2: http://45.56.85.209:10000/v15_worker

What's more, right after downloading this worker, the client executed it and sent out a report to this URL:

CLIENT: {"phone_id":"A0121[redacted]","action":"check","env":{"worker_version":"v0.0.64",
    "call_count":0,"fota_phone_id":"nil","android_id":"34[redacted]","service_version":"v1.9",
    "package_permission":"IstPkg[-]@DelPkg[+]",
    "did_status":{"loc":"P","read":"X0P2C2","write":"X3P0C0"},"sdk_version":28,
    "display":"2160x1080","network_info":"WIFI","project_id":"EASTAEON_FW_20190712",
    "channel_id":"EASTAEON","rom_version_minor":"1572328887000","package_version_name":"9.1.0",
    "uuid":"[redacted]","boot_version":"v1.6.64","boot_uid":"A1002[redacted uid from before]",
    "uuid_status":{"loc":"P","read":"X0P2C2","write":"X3P0C0"},"if_system":true,
    "package_version_code":910,"rom_version_major":"Cosmo-9.0-Planet-10292019-V15",
    "net_subtype":"","check_signature":0,"model_name":"Cosmo_Communicator",
    "package_signature":"1.2.840.113549.1.9.1=#16146561737461656f6e4065617374656f6e2e636f6d,CN=EASTAEON,OU=SW,O=EASTAEON,L=SZ,ST=GD,C=CN@1.0736740417112e+19@3",
    "package_name":"com.dtinfo.tools","worker_version_ext":"b","serial":"unknown",
    "package_resource_path":"\/system\/priv-app\/SystemFota\/SystemFota.apk"}}
SERVER: {"errcode":0,"params":{"CID":154,"activate_time":1574769400,"PID":219221},
    "config":{"interval_short":86400,"interval_long":86400}}

I'm going to assume that CID and PID in the response refer to the channel ID and project ID. The did and uuid values (and their associated status properties) come from the uuid module, which tries to store them in a bunch of different places in an attempt to stop the user from getting rid of their tracking:

  • /data/data/com.dtinfo.tools/shared_prefs/uuid_pro.xml: this is 'XML', the X
  • /data/data/com.dtinfo.tools/files/.log.pro.uuid: this is 'private data', the P
  • /cache/.log.pro.uuid: this is 'cache', the C

So, that's the Worker. In its current form, I genuinely can't tell what the point of it is supposed to be. It doesn't collect extra data like Boot does, all it does is update itself. You may rightly ask, "what's the point of all of this?" That's a good question, because I don't know either. Let's look at the Java code and see what happens when you tap the "check for updates" button.

The Worker provided to me does almost nothing of interest, other than check in occasionally and process updates (if offered). This seemed really strange to me at first, but I'm suspecting that at some arbitrary point Digitime's server may replace it with a version that does more interesting things - they certainly have the ability to do so, and I know of at least one Worker package they provide which includes data gathering and remote APK installation/uninstallation capabilities.

com.dtinfo.tools.activity.MainActivity

jadx somewhat-helpfully prefixes 1- or 2-character names with serial numbers (e.g. a -> C0075a), so I'll be using this convention here to document what goes on.

On check for update (inside MainActivity.onClick(View)), C0112l.m236b(this) is called, which returns a string. If the string is non-empty and the device is online, it goes on to call mo1618d() and then m48h().

I'll save the discussion of the first function for a later date; it simply checks to make sure that Digitime's backdoor code is present in the running version of Android, and returns the backdoor version.

mo1618d() fetches a few things from sharedPrefs and then calls C0088c.m142u(this). If the pref ota_update is not null, it decodes it as JSON and then compares its softversion field to C0075a.f108c (one of the Android version numbers). Then, C0088c.m119a(this, 0, i) is called, with i being either 20 or 21 depending on the softversion comparison result.

C0088c.m142u(this) simply removes the rptArray sharedPref if it exists. C0088c.m119a(...) creates it under certain conditions.

Finally, there's the call to m48h(). This shows a progress dialog, constructs a C0085b object, calls .mo1654a(context, handler) on it (passing a Handler that detects when progress or a change occurs) and then calls .mo1653a() on it. It looks like this is probably the update class we care about!

The first method stores the context and handler, and increments the count_sent pref in the checkedState prefs XML.

The second one is where a HTTP request is finally generated. jadx fails to decompile it, but Ghidra does a reasonable job and saves me from reading more smali. This is encrypted (using a different key this time) and sent off to the main FOTA server. Rather than explaining everything in detail I'll just quote Ghidra's decompilation here...

void a(b this)
{
  boolean bVar1;
  int gdprFlag;
  PackageManager ref;
  String tmp;
  ApplicationInfo pAVar2;
  String pSVar3;
  Locale ref_00;
  long lVar4;
  SharedPreferences local_0;
  Object json_mainContainer;
  Object json_gen;
  StringBuilder LogStringSB;
  b$1 ref_01;
  Object json_sys;
  Object json_diy;
  JSONArray json_rpts;
  Context ref_02;
  Bundle ref_03;

  local_0 = this.g;
  bVar1 = local_0.getBoolean("acceptData",false);
  this.a = bVar1;
  local_0 = this.g;
  gdprFlag = local_0.getInt("is_gdpr",1);
  a.y = gdprFlag;
  json_mainContainer = new Object();
  json_gen = new Object();
  json_sys = new Object();
  json_diy = new Object();
  json_rpts = new JSONArray();
  ref_02 = b.f;
  ref = ref_02.getPackageManager();
  ref_02 = b.f;
  tmp = ref_02.getPackageName();
  pAVar2 = ref.getApplicationInfo(tmp,0x80);
  ref_03 = pAVar2.metaData;
  tmp = ref_03.getString("ChannelID");
  a.b = tmp;
  ref_03 = pAVar2.metaData;
  tmp = ref_03.getString("ProjectID");
  a.e = tmp;
  lVar4 = SystemClock.elapsedRealtime();
  a.k = (int)((lVar4 % 86400000) / 3600000);
  json_gen.put("fota_ver",a.a);
  json_gen.put("state_device",a.q);
  json_gen.put("customer_id",a.b);
  json_gen.put("project_id",a.t);
  json_gen.put("phone_id",a.u);
  json_gen.put("softver",a.c);
  json_gen.put("sid",a.q);
  json_gen.put("rid",a.e);
  json_gen.put("ver_apk",a.f);
  json_gen.put("ver_name",a.g);
  json_gen.put("ver_code",a.h);
  json_gen.put("app_id",a.z);
  json_gen.put("count_start",a.i);
  json_gen.put("count_sent",a.j);
  json_gen.put("count_succ",a.r);
  json_gen.put("btime",a.k);
  json_gen.put("pkg_type",a.l);
  json_gen.put("atype",a.m);
  json_gen.put("btype",a.n);
  json_gen.put("priv_recv",a.x);
  if ((this.a == false) && (a.y != 0)) {
    json_gen.put("auth_priv",0);
  }
  else {
    json_gen.put("auth_priv",1);
  }
  tmp = SystemProperties.get("ro.build.display.id","");
  tmp = tmp.replaceAll(" ","");
  pSVar3 = SystemProperties.get("ro.build.id","");
  json_sys.put("build_id",pSVar3);
  json_sys.put("display_id",tmp);
  tmp = SystemProperties.get("ro.build.version.codename","");
  json_sys.put("version_codename",tmp);
  tmp = SystemProperties.get("ro.build.version.release","");
  json_sys.put("version_release",tmp);
  tmp = SystemProperties.get("ro.build.version.sdk","");
  json_sys.put("version_sdk",tmp);
  tmp = SystemProperties.get("ro.product.manufacturer","");
  json_sys.put("manufacturer",tmp);
  tmp = SystemProperties.get("ro.product.brand","");
  json_sys.put("brand",tmp);
  tmp = SystemProperties.get("ro.product.device","");
  json_sys.put("device",tmp);
  tmp = SystemProperties.get("ro.product.model","");
  json_sys.put("model",tmp);
  tmp = c.q(b.f);
  json_sys.put("uuid",tmp);
  tmp = SystemProperties.get("ro.product.locale.region","");
  json_sys.put("region",tmp);
  ref_00 = Locale.getDefault();
  tmp = ref_00.toString();
  json_sys.put("lang",tmp);
  if (Build$VERSION.SDK_INT < 0x1a) {
    json_sys.put("serial",Build.SERIAL);
  }
  else {
    tmp = Build.getSerial();
    json_sys.put("serial",tmp);
  }
  tmp = c.j(b.f);
  json_sys.put("android_id",tmp);
  tmp = SystemProperties.get("ro.board.platform","");
  json_sys.put("platform",tmp);
  if (((a.y == 0) || (this.a != false)) || (a.B != false)) {
    tmp = c.p(b.f);
    json_sys.put("mac",tmp);
    tmp = c.k(b.f);
    json_sys.put("imei1",tmp);
    tmp = c.l(b.f);
    json_sys.put("imei2",tmp);
    tmp = c.m(b.f);
    json_sys.put("imsi1",tmp);
    json_sys.put("imsi2","");
    tmp = c.o(b.f);
    json_sys.put("isdn",tmp);
    tmp = c.n(b.f);
    json_sys.put("iccid",tmp);
    tmp = c.g(b.f);
    json_sys.put("sim_country",tmp);
    tmp = c.h(b.f);
    json_sys.put("sim_operator",tmp);
    tmp = c.i(b.f);
    json_sys.put("sim_operator_name",tmp);
    tmp = c.c(b.f);
    json_sys.put("network_country",tmp);
    tmp = c.d(b.f);
    json_sys.put("network_operator",tmp);
    tmp = c.e(b.f);
    json_sys.put("network_operator_name",tmp);
    bVar1 = c.f(b.f);
    json_sys.put("is_network_roaming",bVar1);
    tmp = c.b(b.f);
    json_sys.put("type_name",tmp);
    tmp = c.a(b.f);
    json_sys.put("subtype_name",tmp);
  }
  gdprFlag = c.a();
  json_diy.put("ram",gdprFlag);
  lVar4 = c.c();
  json_diy.put("rom_size",lVar4);
  lVar4 = c.b();
  json_diy.put("rom_avail",lVar4);
  tmp = Build.getRadioVersion();
  json_diy.put("firmware",tmp);
  json_mainContainer.put("gen",json_gen);
  json_mainContainer.put("sys",json_sys);
  json_mainContainer.put("diy",json_diy);
  json_rpts = c.t(b.f);
  json_mainContainer.put("rpts",json_rpts);
  tmp = String.valueOf(json_mainContainer);
  LogStringSB = new StringBuilder();
  LogStringSB.append("-FNetConnect-content=");
  LogStringSB.append(tmp);
  pSVar3 = LogStringSB.toString();
  b.a(pSVar3);
  ref_01 = new b$1(this,tmp);
  ref_01.start();
  return;
}

And now, for a real exchange captured from my Cosmo. Here's some quick and dirty Python code to pack and unpack the payloads:

import binascii, struct, zlib

key = b'Ti92T_77Zij_MiTik'

def decrypt_pkt(buf):
    a = buf[:2]
    b = buf[10:12]

    if buf[8] == 1:
        raise 'gzipped!!'
    if buf[9] != 1:
        raise 'not encrypted!!'

    expected_crc = struct.unpack_from('<I', buf, 4)[0]
    assert(expected_crc == zlib.crc32(buf[8:]))

    expected_len = struct.unpack_from('<H', buf, 2)[0]
    assert(expected_len == (len(buf) - 12))

    payload = bytearray(buf[12:])
    for idx in range(len(payload)):
        key_idx = idx % len(key)
        payload[idx] ^= key[key_idx]
    return (a, b, bytes(payload))

def encrypt_pkt(a, b, buf):
    result = bytearray(len(buf) + 12)
    result[0] = a[0]
    result[1] = a[1]
    struct.pack_into('<H', result, 2, len(buf))

    result[8] = 0 #not gzipped
    result[9] = 1 #encrypted
    result[10] = b[0]
    result[11] = b[1]
    for i in range(len(buf)):
        result[12 + i] = buf[i] ^ key[i % len(key)]

    struct.pack_into('<I', result, 4, zlib.crc32(result[8:]))
    return bytes(result)
CLIENT: {"gen":{"fota_ver":"3.1.2","state_device":1,"customer_id":"EASTAEON",
    "project_id":"FTPRO16945","phone_id":"19112................",
    "softver":"Cosmo-9.0-Planet-10292019-V15.<20191029_1401>","sid":1,
    "rid":"EASTAEON_FW_20190712","ver_apk":"910","ver_name":"9.1.0",
    "ver_code":"9.1.0","app_id":0,"count_start":2,"count_sent":1,"count_succ":6,
    "btime":0,"pkg_type":1,"atype":2,"btype":6,"priv_recv":0,"auth_priv":1},
    "sys":{"build_id":"PPR1.180610.011","display_id":"Cosmo-9.0-Planet-10292019-V15",
    "version_codename":"REL","version_release":"9","version_sdk":"28",
    "manufacturer":"Planet","brand":"Planet","device":"Cosmo_Communicator",
    "model":"Cosmo_Communicator","uuid":"[redacted]","region":"","lang":"en_GB",
    "serial":"[redacted]","android_id":"[redacted]","platform":"mt6771",
    "mac":"[redacted]","imei1":"[redacted]","imei2":"[redacted]","imsi1":"[redacted]",
    "imsi2":"","isdn":"","iccid":"[redacted]","sim_country":"pl","sim_operator":"26006",
    "sim_operator_name":"Telna","network_country":"gb","network_operator":"23420",
    "network_operator_name":"3 UK","is_network_roaming":true,"type_name":"WIFI",
    "subtype_name":""},"diy":{"ram":5900756,"rom_size":117393874944,"rom_avail":111664107520,
    "firmware":"MOLY.LR12A.R3.MP.V66.11,MOLY.LR12A.R3.MP.V66.11"}}
SERVER: {"state":0,"gen":{"is_gdpr":0,"state_device":1,"phone_id":"19112.............",
    "auth_priv":0,"auth_level":0,"interval_hour":0,"path2":"","project_id":"FTPRO16945",
    "count_succ":7,"priv_recv":0,"path1":""}}

The result is processed in C0085b.m103a (HTTP) and C0085b.m104a (HTTPS). It can contain three keys: state, gen, and patch.

The state field simply gets copied into sharedPreferences directly. The following fields from gen get copied into sharedPreferences: auth_priv, auth_level, priv_recv, state_device, is_gdpr, count_succ, switch_dead, interval_hour, path1, path2. The fields project_id and phone_id from gen are copied into the Dgtd sharedPrefs XML and also into the hidden /sdcard/.fot/.Dgts.xml file.

Finally there's the patch object. If it exists, the following fields get copied into sharedPreferences: inst_type, wifi, patch_id, patch_url, patch_size, patch_name, patch_version, patch_intro, patch_date, content_md5, mem, archive_type, app_id, app_style, patch_type, pkg_id, rstyle, inst_time, dl_type, min_mem.

Once this has been passed, a message gets sent back to the Handler as follows:

  • If C0075a.f120o == 1 (state): message 273 (update available)
  • If this.f170a (acceptData) or C0075a.f130y != 1 (is_gdpr): message 272 (no update available)
  • If C0075a.f127v == 1 (auth_priv): message 288 (requests acceptDataDialog)
  • If C0075a.f129x != 0 (priv_recv) or C0075a.f128w != 1 (auth_level): message 272 (no update available)
  • If !C0075a.f102B (flag that gets set after 336 is sent): message 336
  • Otherwise, message 272 (no update available)

GDPR Compliance

You may have noticed that there's an is_gdpr flag which causes certain things to be left out of the FOTA blob. As per the code which dispatches a request (com.dtinfo.tools.activity.b$1.run()), if is_gdpr is 1, the domain-based FOTA URLs are replaced with https://eu-app-fota.digitimetech.com and https://eu.fota.digitimetech.com (the IP remains the same). I don't know exactly what causes this to be set; I am clearly in the UK, connecting from a UK IP address, telling them I'm on a UK network and Digitime's servers have decided that I am not covered by GDPR.

Pretending I'm a Cosmo

Following on from the script above that can pack/unpack the payloads, here's a script that uses those functions to send one to the server...

import json, requests
blob = dict(
    gen=dict(
        fota_ver="3.1.2", #a.a
        state_device=1, #a.q
        customer_id="EASTAEON", #a.b ChannelID
        project_id="FTPRO16945", #a.t
        phone_id="", #a.u
        softver="Cosmo-9.0-Planet-10292019-V15.<20191029_1401>", #a.c
        sid=1, #a.q
        rid="EASTAEON_FW_20190712", #a.e ProjectID
        ver_apk="910", #a.f
        ver_name="9.1.0", #a.g
        ver_code="9.1.0", #a.h
        app_id=0, #a.z
        count_start=26, #a.i
        count_sent=2, #a.j
        count_succ=30, #a.r
        btime=9, #a.k = (system uptime % 86400) / 3600?? what,
        pkg_type=1, #a.l
        atype=1, #a.m what's this? suspect 1=user initiated check, 2=scheduled check
        btype=0, #a.n last seen event type? 0=user initiated, 1=on boot, 2=connectivity change, 3=timezone change, 4=connected, 5=disconnected, 6=call idle?, 8=time_set, 9=time_tick
        priv_recv=0, #a.x
        auth_priv=1 #1 if acceptData on, or is_gdpr is off
    ),
    sys=dict(
        build_id="PPR1.180610.011", #ro.build.id
        display_id="Cosmo-9.0-Planet-10292019-V15", #ro.build.display.id no spaces
        version_codename="REL", #ro.build.version.codename
        version_release="9", #ro.build.version.release
        version_sdk="28", #ro.build.version.sdk
        manufacturer="Planet", #ro.product.manufacturer
        brand="Planet", #ro.product.brand
        device="Cosmo_Communicator", #ro.product.device
        model="Cosmo_Communicator", #ro.product.model
        uuid="", #c.q(b.f) gets uuid from Data or SD
        region="", #ro.product.locale.region
        lang="en_GB", #Locale.getDefault().toString()
        serial="", #Build.getSerial()
        android_id="", #c.j(b.f) does context.getString("android_id")
        platform="mt6771", #ro.board.platform
        # gdpr stuff skipped
    ),
    diy=dict(
        ram=5900756, #c.a() checks /proc/meminfo MemTotal
        rom_size=117393874944, #c.c()
        rom_avail=112042942464, #c.b()
        firmware="MOLY.LR12A.R3.MP.V66.11,MOLY.LR12A.R3.MP.V66.11" #Build.getRadioVersion()
    )
    # also: rpts = c.t(b.f) gets checkedState.xml rptArray
)

raw_pkt = (b'3T', b'\x00\x01', json.dumps(blob, separators=(',',':')).encode('utf-8'))
hdrs = {
    'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 9; Cosmo_Communicator Build/PPR1.180610.011)',
    'Content-Type': 'application/x-www-form-urlencoded'}
result = requests.post('http://47.254.145.86/v3/mob/chk', data=encrypt_pkt(*raw_pkt))
print(decrypt_pkt(result.content))

Note that if no phone_id is specified, the server will generate one for you and pass it back - presumably this creates some kind of record on their back-end.

What comes after this? I think I may have to wait until more updates are released... I don't know the build_id or softver for previous builds of the Cosmo firmware, so I can't try and get the tool to serve me an OTA. I'll have to revisit it once that occurs and see what how it works!

Conclusion

I am extremely disappointed with the state of this update system. It's an absolute joke when it comes to security. Nothing is cryptographically signed, communications are all either in cleartext or via TLS to a server with a known private key(!) and the boot/worker system allows for arbitrary Lua code to be downloaded and executed in the background without user knowledge.

As mentioned at the start of this post, I wrote most of it while assuming good faith on Digitime's part. I held off on publishing it because I wanted to find out more about the Digitime backdoor the Cosmo ROM includes, and after doing further research I've concluded that having Digitime's software running on my device in any form is a major security risk.

There are examples online of Digitime's updater having been previously used to distribute malware on Gretel phones (with the blame placed on the OEM for some reason), and plenty of generic non-system-privileged adware that uses Digitime's servers and protocols. Their system revolves around malware-like C&C servers with domain names that attempt to impersonate legitimate services. Its nature makes it hard to inspect every single capability they have, as the code bundles and APKs they send are selected for each device, and the APIs will only respond to requests containing valid configuration IDs (difficult to guess due to their lack of a consistent format), but crafty Google searching for things like their CDN and their encryption keys and their old CDN brings up a few interesting examples.

Disabling the SystemFota package should stop the device from communicating with Digitime and running the worker, but the backdoor service baked into Android is still there, which allows any app to execute a variety of privileged actions. I assembled a proof-of-concept with it which was able to dump the Android accounts database (including auth tokens) without using Android permissions or requiring user input, from a standard app at API level 28.

There's a lot to investigate with regards to Digitime's C&C servers and services. I'm not ready to write another post on this just yet, and I plan to contact Planet with details in the hopes that they'll strip the Digitime software out of the Cosmo - although I am not particularly optimistic. (Their Gemini PDA also uses Digitime's updater, albeit an earlier version. It does not have or require the backdoor service, and it uses a different protocol, but it still executes arbitrary Lua code from Digitime's worker subsystem.)

... I'm going back to my iPhone now.