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.)
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:
network-security-config
begging Android to permit plaintext network traffic (just what you want in your OTA updaters, really)libkey.so
(Lua 5.1.5 with LuaJava)server.digitimetech.crt
and server.digitimetech.key
(a RSA certificate and private key)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
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)
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.
The FoHandler Java class coordinates loading of the Lua code. The following operations are permitted:
FoBoot.start_boot
: calls Lua BootEntry
from boot, no parametersFoBoot.start_boot_ex
: calls Lua BootEntryEx
from boot, passing a string parameterBaseIntentService.readFotaConfig
: calls Lua BootReadFotaConfig
from boot, no parameters, expecting and returning a string mapBaseIntent.setTestCondition
: calls Lua BootSetTestCondition
from boot, no parametersInfoReceiver.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 namedebug_switch
: equal to BaseIntentService.debug_switch
trace_enable
: equal to BaseIntentService.trace_enable
service_ver_int
: always 19, in the version I examinedWith 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.
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 metadataservice_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')
EnvGet('boot_handler').fetchData(key)
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
: 28800interval_long
: 28800activate_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
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...
BootEntry
, which triggers ScheduleCheck
, which in turn triggers ActionCheck
check
actionregister
(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)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.
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 CSo, 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.
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:
C0075a.f120o == 1
(state): message 273 (update available)this.f170a
(acceptData) or C0075a.f130y != 1
(is_gdpr): message 272 (no update available)C0075a.f127v == 1
(auth_priv): message 288 (requests acceptDataDialog)C0075a.f129x != 0
(priv_recv) or C0075a.f128w != 1
(auth_level): message 272 (no update available)!C0075a.f102B
(flag that gets set after 336 is sent): message 336You 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.
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!
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.
Planet Computers have been in touch with me following my disclosure, and the situation is being resolved. More details at the end of the follow-up post: https://wuffs.org/blog/digitime-tech-fota-backdoors
Previous Post: Spoonalysis: Mapping UK Chain Pub Prices
Next Post: "16 Shades of Grey" - Building a Psion/EPOC32 Emulator