TraceTogether: A Technical Look

Lucien Loiseau
7 min readMar 24, 2020

--

Since the beginning of the COVID-19 pandemic, Singapore has been exemplary in its effort to contain the spread of the virus. In this battle, when someone is found to be infected, contact tracing investigators immediately race against the clock to identify infection cluster as well as people who may have crossed the path of that infected person and issue them a quarantine order. Obviously this method is very labour intensive as it requires a lot of interviews and phone calls to rebuild one’s travel history.

Last Saturday (Mar 21, 2020), the Government Digital Service (GDS) of Singapore released a mobile app called TraceTogether aimed at supporting the effort of contact tracing investigators and within 3 days the app was already installed by 620 000 users. I was able to install it and dig around a little bit to understand how it works.

Looking at their documentation, the app supposedly works by advertising a Temporary ID over Bluetooth Low Energy (BLE). When two devices are collocated within BLE range, they can detect each other and record this contact event in the local storage. This contact log should never leave your phone unless you are found to be infected or if your ID was discovered in the log of an infected user in which case you will be contacted by the Ministry of Health (MOH) and requested to export the log.

This app got me very excited because it is a nice use case for mesh networks and is it not everyday that we see a government app aimed at “security” that is respectful about our privacy in its very design and it seems to have been done right. So let’s have a look!

Diving In

After Installing the app on Android and being done with the on-boarding (it checks your phone number by sending you an SMS), I immediately notice the little icon in the notification bar indicating that it runs a foreground service. I suppose that the service is used to perform continuous scanning of the bluetooth neighborhood as well as advertising the ID. UI-wise, the app is very simple and doesn’t provide any information about the contacts that you may discover, it does however shows an upload button.

#1 — A quick look at the APK

After playing around with the app, I pulled the apk with adb, first I need to get the installation path of the apk:

$ adb shell pm path sg.gov.tech.bluetrace

I then simply pulled the apk on my laptop using the path I got from the previous command, still with adb:

$ adb pull /data/app/sg.gov.tech.bluetrace-hcileOpEyR7kXRGI9ZXcbQ==/

I then unzip the apk, uses dex2jar on the file classes.dex and finally run jd-gui on the newly created file classes-dex2jar.jar to dig into the source code.

I notice the use of a certain number of well-use analytics libraries like firebase crashlytics, google-analytics and snowplowanalytics. However at this point I couldn’t find much information, the interesting part of the app is heavily obfuscated in a package named “o” where all the bluetooth and upload logic happens and since I cannot easily make much sense of it, I decide to stop the investigation here and try from another angle.

#2 — Runtime data

Backing up the app

Since I had the app installed for the last 3 days, I decided to have a look at the app folder to peak directly into the preferences and the database. For that I use adb to backup the app and then android backup extractor to decrypt and unzip the archive.

$ adb backup -f data.ab -noapk  sg.gov.tech.bluetrace$ java -jar ../scripts/android-backup-tookit/android-backup-extractor/android-backup-extractor-20180521-bin/abe.jar unpack data.ab extracted.tar ""$ tar xvf extracted.tar
$ cd apps/sg.gov.tech.bluetrace
$ find . -maxdepth 3 -type d
.
./f
./f/zendesk
./f/zendesk/request
./f/.Fabric
./f/.Fabric/io.fabric.sdk.android:fabric
./f/.Fabric/com.crashlytics.sdk.android.crashlytics-core
./f/.Fabric/com.crashlytics.sdk.android:answers
./sp
./r
./r/app_textures
./r/app_webview
./r/app_webview/Default
./db

Here there is much more meat !

The Record Database

First of all I dump the SQlite DB to see what’s truly inside. I see two tables, record_table and status_table

The record_table contains a list of record with each entry having the following:

  • A Timestamp
  • the temporary ID of the peer (I replaced all the base64 text with random data just in case)
  • An organization which is always set to SG_MOH
  • Phone model
  • RSSI
  • TxPower which is always NULL

In 3 days I gathered about 4000 such records though a lot of them are actually duplicate and some may also be different temporary ID of the same phone (I work from home so I definitely did not met that many people in 3 days :-)).

The status_table contains the list of all the start/stop scanning event. From this we can see that the apps runs an 8 seconds scan every 40 seconds. I believe keeping those log may be useful if one wants to check how often was the app used by the user (especially if the app becomes mandatory).

The Tracer configuration

I then peaked at the XML configuration in the folder:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<long name="NEXT_FETCH_TIME" value="1585019700000" />
<long name="EXPIRY_TIME" value="1585023300000" />
<int name="CHECK_POINT" value="3" />
<string name="PHONE_NUMBER">********</string>
<boolean name="IS_ONBOARDED" value="true" />
<string name="HANDSHAKE_PIN">******</string>
</map>

The most interesting information is the NEXT_FETCH_TIME which at that moment indicated about 37 minutes in the future while the EXPIRY_TIME indicated about 2 hours in the future. After testing I discovered that the ID is fetched every hour (if an internet connection is available). I suppose this means that the temporary ID is not computed locally but fetched from a server and so it must be kept somewhere. Looking around I discovered a binary file in apps/sg.gov.tech.bluetrace/f/packet that contains a base64 text encoding a 60 bytes binary blob. That is exactly the same size than the discovered temporary ID in the contact log so it is certainly it

The BLE advertising beacon

to check the payload being advertised by the app, I use another Android phone with the great nrfconnect app on it. I placed the phone very close to the one running TraceTogether so as to quickly identify the right beacon simply by looking at the received power lever.

TraceTogether does not directly advertise the temporary ID in the advertising payload. Instead it advertises a few interesting GAP values:

  • 0xFF (Manufacturer Specific Data): 0xff03363661
  • 0x07 (128 bit service UUID): b82ab3fc-1595–4f6a-80f0-fe094cc218f9

The manufacturer identifier is a 16-bits ID in Little-Endian which means that it is 0x03ff and according to the Bluetooth SIG this ID belongs to Withings (a company that builds smart things using BLE such as connected scale, smart watches, etc..) The remaining part of manufacturer data 0x363661 is manufacturer specific and I am not sure what it means but it is too small to be the temporary ID advertised by TraceTogether.

The service UUID however uniquely identifies the TraceTogether service and can be used as a fingerprint when looking for other TraceTogether app in the bluetooth neighborhood. In order to retrieve the Temporary ID, we have to “query” the service UUID using GATT, fortunately this is just one click away with nrfconnect, we first need to connect to the device and then we can READ the GATT Characteristics to pull the ID:

When we query this service, we get a big payload of 160 bytes. Certainly this contains more than just a Temporary ID. I could not identify any structure within this payload, I was not able either to identify the id that I found in the storage earlier (f/packet). Trying to print this payload as an ascii string doesn’t bring any information. Lastly I checked to see if only some parts of the payload changed when the ID got refreshed but the entire payload was different so I couldn’t identify any headers or static field. So my best guess is that this payload is somewhat encrypted, might require some static analysis of the code to reverse it.

UPDATE: on twitter, zero typic confirmed through static analysis that the payload was encrypted. I was able to decrypt the payload with openssl like so

$ openssl enc -d -nopad -aes-256-cbc -K **key** -iv 0 -in payload

And lo and behold, the decrypted data is a JSON-object containing the phone model, the temporary ID (in base64 form) the version and the organization, pretty much what’s in a record_table entry !

That is all for now!

Note: as I am about to publish this post, I realized there is two other article about TraceTogether but more focused on the code static analysis: https://medium.com/@frankvolkel/tracetogether-under-the-hood-7d5e509aeb5d
https://medium.com/@zerotypic/reversing-tracetogether-initial-analysis-edc940e86aa8

--

--