BM2 - An analysis of location tracking in the AMap mobile SDK

Part 2 of the battery monitor series - A deep dive into the location tracking functionality of a popular location SDK package.

BM2 - An analysis of location tracking in the AMap mobile SDK

In this post we will dig into a mobile location services SDK by a AMap, a “leading provider of digital map in China”, which was acquired by Alibaba. Alibaba state:

“in 2018, Amap became the first Chinese maps service to navigate a path to 100 million daily users”.

If your new to this series of posts, head over to part 1 of the series for some background on how how we came across the AMap library after purchasing a connected “car battery monitor” that turned out to be collecting unnecessary location information.

The AMap SDK is freely available to download from here. The functionality explored here is contained within the “lightweight map package” (轻量版地图合包) version.

AMap have performed some light obsfucation by removing all variables and method names from the Java library. Fortunately class names are recoverable, JADX’s ‘deobsfucation’ feature does the trick. Recovering the original class names gives us huge leverage to understand the primary functionality of each class and significantly reduces the time and effort to reconstruct parts of the decompilation by manually renaming variables and method names.

All variable and method names in this post are not original, I have renamed them to a close approximation of my interpretation of their purpose.

Everything analysed in this section comes under the class path com.loc which was renamed from com.amap.api.

Observing the network traffic with MITM proxy one API endpoint stands out, /collectData:

https://dualstack-cgicol.amap.com/collection/collectData

dualstack-cgicol.amap.com resolves to the IPv4 address 106.11.130.194 and IPv6 2408:4003:1f40::26:

❯ host dualstack-cgicol.amap.com
dualstack-cgicol.amap.com is an alias for dualstack-cgicol.amap.com.gds.alibabadns.com.
dualstack-cgicol.amap.com.gds.alibabadns.com is an alias for wagbridge.amap.com.
wagbridge.amap.com is an alias for wagbridge.amap.com.gds.alibabadns.com.
wagbridge.amap.com.gds.alibabadns.com is an alias for ea119-su121-na610.wagbridge.gaode.com.
ea119-su121-na610.wagbridge.gaode.com is an alias for ea119-su121-na610.wagbridge.gaode.com.gds.alibabadns.com.
ea119-su121-na610.wagbridge.gaode.com.gds.alibabadns.com is an alias for default.cn.sz.wagbridge.amap.alibabacorp.com.
default.cn.sz.wagbridge.amap.alibabacorp.com is an alias for default.cn.sz.wagbridge.amap.alibabacorp.com.gds.alibabadns.com.
default.cn.sz.wagbridge.amap.alibabacorp.com.gds.alibabadns.com has address 106.11.130.194
default.cn.sz.wagbridge.amap.alibabacorp.com.gds.alibabadns.com has IPv6 address 2408:4003:1f40::26

Which is hosted in Alababa clould, in Beijing and Shenzhen:

❯ curl https://ipinfo.io/106.11.130.194
{
  "ip": "106.11.130.194",
  "city": "Beijing",
  "region": "Beijing",
  "country": "CN",
  "loc": "39.9075,116.3972",
  "org": "AS37963 Hangzhou Alibaba Advertising Co.,Ltd.",
  "timezone": "Asia/Shanghai",
}
❯ curl https://ipinfo.io/2408:4003:1f40::26
{
  "ip": "2408:4003:1f40::26",
  "city": "Shenzhen",
  "region": "Guangdong",
  "country": "CN",
  "loc": "22.5455,114.0683",
  "org": "AS37963 Hangzhou Alibaba Advertising Co.,Ltd.",
  "timezone": "Asia/Shanghai",
}

Even though we can decrypt the HTTPS traffic (as certificate pinning is not used), the actual payload is also encrypted.

The process of collecting and encrypting the location information is somewhat convoluted and this took quite a few days to piece together.

In summary:

  • A thread runs periodically which obtains location information, encrypts the data with AES and stores it in a “LRU cache database”
  • A second thread runs that periodically enumerates the journal information for the cache database, decrypts the data, checks if the location has moved more then 10 meters and if it has, serialising it into a binary format.
  • Encrypting the data with an AES key generated on the fly. The AES key is then encrypted with a hard-coded public RSA key stored in the application’s code. This ensures that it is impossible to decrypt the traffic over the wire unless one is in possession of the corresponding private RSA key.

An instance of the class CollectionManager is the central component responsible for logging and sending location data. Location data is defined as type DataCollection which as a byte array storing location data in a serialized binary format.

FpsCollector is responsible for collecting the initial data in arrays of instances of classes of:

  • AmaplcationGnss - GPS data (Latitude, Longitude, Bearing, Speed)
  • AmapWifi - Results of wifi scanning (ssid, rssi, mac, frequency, if connected)
  • AmapCell Cell Data (Lac, Mnc, Mcc, Signal strength, Operator)

This data is only serialised to a byte array member of a CollectionData instance when the GPS location has changed by a distance of at least 10 meters.

Three primary ‘actions’ can be invoked within the CollectionManager:

  • action 1 - Get location data from the handset
  • action 2- Send data collection over the Internet to amap servers
  • action 3 - Write serialised data to LRU database

The following diagram is a gross simplification, grey boxes are original class names, while orange reflect simplified which abstract multiple methods for the sake of brevity.

Thread - Obtain location data

GPS Data

A subclass of CollectionManager implements the method onLocationChanged of the interface android.location.LocationListener and AmapLocationGnss with parameters from instances of android.location.Location

Wi-Fi data

Wifi scanning is managed in WifiWrapper which contains an instance of android.net.wifi.WifiManager used for scanning Wifi networks.

Here the developers have attempted to conceal various strings related to the android permissions that are required to do wifi scanning. Likely this has been done to prevent reporting tools / static code analyzers from flagging that the application checks if this permission has been granted by calling android.content.Context.checkCallingOrSelfPermission

For example, before attempting to call android.net.wifi.WifiManager.startScan 

The obsfucation routine appears quite complex. Choices are to either re-implement or simply call it at runtime. The second option is easier by using Frida. Recall we are using the class and method name that appear in the original APK, not Utilities.stringDeobsfucate():

Java.perform(function() {
   let Util = Java.use("com.loc.x");
   console.log(Util.c("EYW5kcm9pZC5wZXJtaXNzaW9uLkFDQ0VTU19XSUZJX1NUQVRF"));
   console.log(Util.c("WYW5kcm9pZC5wZXJtaXNzaW9uLkNIQU5HRV9XSUZJX1NUQVRF"));
});

Hooking the application, our Frida script emmits the deobsfucated strings:

Spawned `com.dc.battery.monitor2`. Resuming main thread!
[SM-A908B::com.dc.battery.monitor2 ]-> 

android.permission.ACCESS_WIFI_STATE
android.permission.CHANGE_WIFI_STATE

So we can see they were trying to hide ACCESS_WIFI_STATE and ACCESS_WIFI_STATE. A quick way to extract other obsfucated strings from the decompiled source:

cat * |  grep -oE "m8704c\(\"(.+)\"\)" | cut -d'"' -f2 | sort -u | sed -z "s/\n/','/g"

And place in strs in the following Frida script:

Java.perform(function() {
   let strs = ['AaXNSZW1vdmFibGUK', ... ,'ZZ2V0UGF0aA'];

let i = 0;
let Util = Java.use("com.loc.x");
for (i=0;i<strs.length;i++) {
   console.log(strs[i] + " -> " + Util.c(strs[i]));
}
});

The Wifi Android documentation provides a good summary of the Wifi scanning and associated permissions.

For Wifi scan results, android.net.wifi.ScanResult are stored sequentially and the BSSIDSSID, frequency and timer values stored in instances of AMapWifi which will later be serialised.

Cell tower data

The cell ID (CID and LAC) is achieved by extending android.telephony.PhoneStateListener.onCellInfoChanged to obtain the current CellLocation as well as obtaining “all available cell information from all radios on the device including the camped/registered, serving, and neighboring cells” by calling android.telephony.TelephonyManager.getAllCellInfo()

CellLocation has the subclasses GsmCellLocation and CdmaCellLocation CellInfo has the subclasses CellInfoCdmaCellInfoGsmCellInfoLteCellInfoNrCellInfoTdscdmaCellInfoWcdma Each returns an instance of a corresponding CellIndentity subclass which contains the MCC, MNC, TAC (5G), band information, and signal strength.

The following caught my eye:

 It seems that CellInfoCdma does have accessors for the cells latitude and longitude. How and if this is populated for CDMA networks is something to follow up.

Notably, the permission ACCESS_FINE_LOCATION must be granted for all this to work, and recall the user of the application is forced to accept this permission to use the connected battery bluetooth device, discussed in part 1.

Thread - Data collection

Physical movement

Location data is only written to the application “database” under a set of conditions. Just sitting at the desk while testing will not result in database writes - you have to literally be moving. This is obviously done to not log or send redundant data.

The first check is how far you have moved since the last location logged:

 I had a suspicion what this function may be doing, but wasn’t sure how - so why not ask ChatGPT?

Thanks to GPT, we understand that the check>= 10.0d below can be interpreted as 10 meters:

There is also an additional check: The cell tower cell id and lac has to have changed from the previous location check or there has been an elapsed time from the wifi scanner results or the person is travelling at a speed greater then 10.

 The simplest is then to hook into the (renamed) distanceOrSpeedCheck() method with Frida and return true:

   let hook2 = Java.use("com.loc.cz");
   console.log('\n\n[+] Hooking "cellcollector.distanceOrSpeedCheck()"\n');
   hook2.a.overload('com.loc.ef').implementation = function(ef) {
      console.log("\nOverload cellcollector.distanceOrSpeedCheck() -> com.loc.ef(), returning true\n");
      return true;
   }

Almost there! CollectionManager must have five or more binary packed CollectionData instances created from (renamed) constructCollectionData. In other words, five previous collected locations in running memory.

 Now the data will be written to persistent storage. A unique AES key is generated and prefixed to the record. Each collection entry is encrypted with this key with the length value prefixed. The integer values are stored as two bytes.

 Splitting the integer values into two bytes uses references an odd constant, ACTION_POINTER_INDEX_MASK (0xFF) which belongs to an Android montion event libary (mouse, gamepad, trackball). Little perplexing. Moving on ..

 Assuming there are 2 collections, the structure would be:

The records are actually stored in a LRZ database, specifically using DiskLruCache see here for a decent explanation.

Binary format

All data is serialized / packed into binary data structures using FlatBuffers. It is possible to generate the approximate Java code that does the serialisation.

For example, after we have figured out what the parameters passed into the constructor for a class prefixed with ‘T’, such as ‘TGps’:

A flat buffers definition file can be written:

namespace AMap.Test;

table TGps {
   location_time:long;
   timestamp:long;
   speed:int;
   accuracy:int;
   altitude:int;
   lat:int;
   lng:int;
   bearing:short;
   satellites:byte;
}

The corresponding Java code is then generated:

./flatbuffers-23.3.3/flatc --java test/TGps.fb

The emitted Java can then be used as a reference to work back and reconstruct the related AMap classes more easily. 

This serialization of the location objects is done after distance checks are performed, invoked from FpsCollector / FpsBuilderBuffer class.

Thread - Outbound traffic

The HTTP request was sent particularly infrequently, making it somewhat frustrating. Before invoking the HTTP request, a timer which is an instance of TimeUpdateStrategy (com.loc.cn) is checked. Using Frida we can see that the threshold value is set to two hours:

To maintain a persistent timer even if the application is closed and reopened, Android shared preferences is used to store the last updated system elapsed time.

 This is can be verified by checking the respective shared preferences XML file on the filesystem (here using adb shell):

 The quickest way then get the HTTP request is to overload the UpdateStrategy function and simply return true every time it is invoked:

Java.perform(function() {
   let UpdateStrategy = Java.use("com.loc.cn");
   console.log('\n\n[+] Hooking "UpdateStrategy"\n');
   UpdateStrategy.a.overload().implementation = function() {
      console.log("\nOverload com.loc.cn.a(), returning true\n");
      return true;
   }
});

Now when we hook into the application, we get the /collectData request after 60 seconds rather then waiting a whole two hours:

Using an Objection plugin called wallbreaker we can inspect instances of this class in the heap to see what is populated:

com.br.a is responsible for issuing HTTPS requests. This is then good starting point to dump a backtrace for the call tree:

android hooking watch class_method com.loc.br.a --dump-args --dump-backtrace --dump-return

Encryption

After the (2 hour) elapsed time expires, the serialised collection data is pulled from the LRU cache. Each record from the cache is the encoded flatbuffer object. A unique AES key is generated per record. The AES key is then encrypted with the public RSA key. It is the AES encrypted flatbuffer collection/record and the RSA encrypted AES key that is send over the wire (read that twice)

The cipher mode string is obsfucated using the same routine to conceal the android permissions as discussed earlier. It’s a bit of an odd one

WUlNBL0VDQi9PQUVQV0lUSFNIQS0xQU5ETUdGMVBBRERJTkc -> RSA/ECB/OAEPWITHSHA-1ANDMGF1PADDING

The public key is hard coded as a byte array:

We need to reverse byte array string to get the base64 encoded public key (in python):

>>> a = [61, 61, 81, 65, 65, 69, 119, 65, 67, 48, 74, 80, 115, 116, 54, 75, 104, 76, 122, 97, 88, 99, 53, 71, 49, 122, 68, 70, 79, 104, 113, 113, 65, 97, 76, 54, 65, 66, 87, 53, 103, 85, 84, 113, 71, 68, 69, 76, 80, 82, 106, 51, 66, 75, 75, 69, 98, 55, 84, 108, 115, 122, 51, 106, 76, 55, 88, 122, 70, 121, 73, 75, 52, 50, 43, 101, 70, 121, 56, 105, 115, 105, 89, 120, 117, 112, 53, 48, 76, 81, 70, 86, 108, 110, 73, 65, 66, 74, 65, 83, 119, 65, 119, 83, 68, 65, 81, 66, 66, 69, 81, 65, 78, 99, 118, 104, 73, 90, 111, 75, 74, 89, 81, 68, 119, 119, 70, 77]
>>> ''.join([chr(b) for b in a])[::-1]
'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAInlVFQL05puxYisi8yFe+24KIyFzX7Lj3zslT7bEKKB3jRPLEDGqTUg5WBA6LaAqqhOFDz1G5cXazLhK6tsPJ0CAwEAAQ=='

To verify, the easiest option is to jump back to Java and call the toString() method for RSAPublicKey:

import java.security.spec.X509EncodedKeySpec;
import java.security.KeyFactory;
import java.util.Base64;
import java.security.interfaces.RSAPublicKey;

class Test {
   public static void main(String[] args) {
      String publicKey = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAInlVFQL05puxYisi8yFe+24KIyFzX7Lj3zslT7bEKKB3jRPLEDGqTUg5WBA6LaAqqhOFDz1G5cXazLhK6tsPJ0CAwEAAQ==";
      byte[] decoded = Base64.getDecoder().decode(publicKey);
      try {
         RSAPublicKey pubKey = (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
         System.out.println(pubKey);
      } catch (Exception e) {
      }
   }
}

The size and exponent looks correct:

$ java Test
Sun RSA public key, 512 bits
  params: null
  modulus: 7222190008035777895304201356655929768007619278612672000890684025813653682596708481192169923548649147427017586479720635261097937916780167864676017379228829
  public exponent: 65537

The IV for the AES encryption is heavily obsfucated. Here it makes sense to invoke the method directly at runtime with Frida.

Sending the data

Flat Table ‘type’ RootTUploadData is constructed: 

The final binary blob makes it way to CollectionUploader which does a HTTP PUT to the endpoint /colectData : 

Next up, in part 3 we will look into the Bluetooth implementation and a method to obtain the device firmware.