Dollar Bin Reverse Engineering

December 24, 2021 ❖ Tags: writeup, hardware, reverse-engineering, tc32, radare2, java

The background for this project is a lesson in avoiding dishonest vendors. Two years ago, I was looking to purchase a smart watch with sleep tracking capabilities1; I've always had difficulty sleeping and wanted a way of finally quantifying that difficulty. One of my requirements was the ability to pull data off of the watch without the use of proprietary software, so the only options I was seriously considering were those on Gadgetbridge's "supported devices" list. At the time, I was still in high school, and still awed by the affordability of consumer electronics on websites such as AliExpress (woefully unaware of the ethical implications of supporting a totalitarian state's economy). Moreover, I was somewhat capable of reading and writing 汉语, so the Xiaomi Mi Band 2 fit the bill. I took to Ebay to purchase one, finding a listing for 10.99 USD with free shipping. I ordered it, and things were okay. That is, until the package arrived.

Figure 1: Clearly not the Mi Band 2.

What appeared outside my garage was not what I ordered. I gave the vendor the benefit of the doubt, thinking that it may have been a mistake, and explained that they had sent me the wrong product.

Hi [my Ebay username],

Thank you for your message. Sincerely sorry for your inconvenience.

Please kindly konw [sic] that they are the same kind product and all the functions are the same [sic]. In order to protect your interest, we suggest that we issue $5 USD refund without returning the item and you can keep this item and try to use it. If it is suit for you and please feel free to give us a positive feedback. If it is still not your favor, please kindly do NOT leave any feedback.

Please kindly let us know if you agree.

If you need further assistance or inquiry, please feel free to contact us.

I'm antipathetic toward anyone trying to slight me, so I threatened to file a complaint with Ebay.

Hi [my Ebay username],

We feel sorry to know that you have received your parcel but the watch you received is not the same as the listing in our store.

In order to protect your interest, we suggest that we issue a full refund without returning the item and you can keep this item and try to use it. If it suit for you and please feel free to give us a positive feedback. If it is still not your favor, please kindly do NOT leave any feedback.

Please kindly let us know if you agree.

They did give me a refund, so I got the watch for free. But it was unusable to me. I put it aside, noting its liberation as a project for another time.

Years later, I decided that finally reverse engineering the smart watch would be a nice quarantine activity to share with my friends through the magic of live streaming. The project is over now, but the recordings are available on PeerTube.

As usual, the project began with reconnaissance. There was software to interface with the watch, I just refused to install it on my cellphone2. To figure out how to talk to the watch, the path of least resistance was to reverse engineer that software. It was an Android app.

Reverse Engineering Android Apps

Most software using the Android SDK is written in Java, a language which runs on a process virtual machine. This means that Java code doesn't run "on the processor"3, but instead in an interpreter-like program known as a "virtual machine" (VM). Source code is still compiled, but the target is a fairly high-level "bytecode" rather than the machine code that would be output by a C compiler.

Virtual machines can be quite fast, but the performance characteristics of the Java VM were deemed unsuitable for the sorts of phones on the market in Android's early days4. Hence, the Dalvik virtual machine was developed: a comparable process virtual machine with a register-based architecture (the Java VM is stack-based) and fewer virtual machine instructions4. Java bytecode and Dalvik bytecode are nearly isomorphic; the latter can be thought of as an optimistic post-processing of the former. The compilation process for an Android app is, conceptually, using the Java compiler to obtain JVM bytecode for the app's sources, and then feeding that bytecode into dx to obtain Dalvik bytecode. Nowadays, the Dalvik VM is no more, but the techniques for reverse engineering Dalvik bytecode are still relevant as modern Android runtimes still use the Dalvik executable format5.

A quick rundown of the process for reverse engineering android apps: an Android APK, like you'd get from F-Droid or the Play Store, is just a ZIP archive with a specific structure and some signatures.

$ file com.uthink.ring.426.apk 
com.uthink.ring.426.apk: Zip archive data, at least v0.0 to extract, compression method=deflate
$ unzip -l com.uthink.ring.426.apk | grep classes.dex
  8685980  00-00-1980 00:00   classes.dex

All of the code is in one or more classes.dex files. You can, if you're a caveman (or faced with some seriously obfuscated code), unzip the APK and dump the .dex file into radare2 or smali. But I know how to use technology, so I used JADX to recover something closer to Java source code.

There are more tools out there. But in this case, I didn't need to reach for anything besides JADX because the source code was unobfuscated. R.java, the table of references to application resources, was as rich as the symtab of a non-stripped ELF.

package com.uthink.ring;

public final class R {
    public static final class anim {
        public static final int abc_fade_in = 2130771968;
        public static final int abc_fade_out = 2130771969;
        public static final int abc_grow_fade_in_from_bottom = 2130771970;
        public static final int abc_popup_enter = 2130771971;
        public static final int abc_popup_exit = 2130771972;
        public static final int abc_shrink_fade_out_from_bottom = 2130771973;
        public static final int abc_slide_in_bottom = 2130771974;
        public static final int abc_slide_in_top = 2130771975;

I'd been saved 90% of the reverse engineering work.

An Introduction to Bluetooth Low Energy


The manual that came with the watch left much to be desired, but it did at least tell me that Bluetooth Low Energy (BLE) was being used to communicate with the wearer's smartphone. Not well that, despite the name, BLE is a different protocol from what's normally called "Bluetooth".

The premise of BLE is that one device acts as a "server" to which "clients" can connect and request characteristics: essentially, some packet of data from the server, such as the number of steps counted by the watch. There are a few other concepts (services, descriptors) in BLE, but they largely wrap around the concept of characteristics.

A BLE server is identified by a MAC address, and any particular characteristic, service, etc. that a BLE server exposes is an attribute, which is identified by a UUID. There are tools to enumerate the available attributes, BLExplorer being the one I used initially.

In our case, the MAC address of the watch is conveniently available from the user interface… for some reason.


Though, if this weren't the case, it would be easy enough to run hcitool lescan.

Furthermore, none of the characteristics require authentication to read from, so I was half-way towards my goal of being able to pull data off of the watch. What was left to do was make sense of the data I was reading.

I headed to the Android Developer Docs to find the interfaces involved with reading BLE characteristics. It was sufficient to grep for references to BluetoothGattCharacteristic. The files of interest are BluetoothLeService.java (~6k LoC) and UpdateImage.java (~500 LoC). The former gives us names for all of the characteristics we can read from the watch.

public static final UUID BLE_AUDIO_CMD                     = UUID.fromString("0000af01-0000-1000-8000-00805f9b34fb");
public static final UUID BLE_AUDIO_DATA                    = UUID.fromString("0000af02-0000-1000-8000-00805f9b34fb");
public static final UUID BLE_AUDIO_DESCRIPTION             = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
public static final UUID BLE_AUDIO_SERVICE                 = UUID.fromString("0000af00-0000-1000-8000-00805f9b34fb");
public static final UUID MAXSCEND_OTA_CMD                  = UUID.fromString("0000FD02-0000-1000-8000-00805F9B34FB");
public static final UUID MAXSCEND_OTA_DATA                 = UUID.fromString("0000FD01-0000-1000-8000-00805F9B34FB");
public static final UUID MAXSCEND_OTA_DESCRIPTION          = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
public static final UUID MAXSCEND_OTA_SERVICE              = UUID.fromString("0000FD00-0000-1000-8000-00805F9B34FB");
public static final UUID TELINK_SPP_DATA_OTA               = UUID.fromString("00010203-0405-0607-0809-0a0b0c0d2b12");
public static final UUID TELINK_SPP_DATA_OTA_SERVICE       = UUID.fromString("00010203-0405-0607-0809-0a0b0c0d1912");
public static final UUID WERUN_SERVICE                     = UUID.fromString("0000fee7-0000-1000-8000-00805f9b34fb");
public static final UUID YOHO_BATTERY_INFO                 = UUID.fromString("0000cc03-0000-1000-8000-00805f9b34fb");
public static final UUID YOHO_CHARACTERISTIC_CONFIGURATION = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
public static final UUID YOHO_CONTROL                      = UUID.fromString("0000cc06-0000-1000-8000-00805f9b34fb");
public static final UUID YOHO_DEVICE_INFO                  = UUID.fromString("0000cc02-0000-1000-8000-00805f9b34fb");
public static final UUID YOHO_REALTIME_DATA                = UUID.fromString("0000cc04-0000-1000-8000-00805f9b34fb");
public static final UUID YOHO_SERVICE                      = UUID.fromString("0000cc00-0000-1000-8000-00805f9b34fb");
public static final UUID YOHO_SYNC_DATA                    = UUID.fromString("0000cc05-0000-1000-8000-00805f9b34fb");
public static final UUID YOHO_USER_INFO                    = UUID.fromString("0000cc01-0000-1000-8000-00805f9b34fb");

While this isn't enough to use them (for example, YOHO_CONTROL is clearly the entry point to a multitude of functionality), the JADX output is readable enough that behavior is easily determined. Take this excerpt from BluetoothLeService.java as an example:

public static void setVibrate(BluetoothGatt bluetoothGatt, boolean z) {
    BluetoothGattService service;
    BluetoothGattCharacteristic characteristic;
    Log.i(TAG, "setVibrate()");
    if (bluetoothGatt != null && \\
        (service = bluetoothGatt.getService(YOHO_SERVICE)) != null && \\
        (characteristic = service.getCharacteristic(YOHO_CONTROL)) != null) {
        if (z || ((Boolean) SPUtils.get(sContext, Constant.HAS_BT, false)).booleanValue()) {
            characteristic.setValue(new byte[]{1, 1});
        } else {
            characteristic.setValue(new byte[]{1, 0});

If you're averse to Java, the bottom-line is that the vibrate feature6 is configured by sending a packet to YOHO_CONTROL where the first byte is 1 and the second byte is whether or not to enable vibration.

Perhaps that's a bit mundane. If you're more interested by the acronym "OTA" appearing in this context, you're not alone.

Striking Oil

It wasn't long until I came across a bunch of plaintext API secrets for Aliyun, which is apparently China's answer to Amazon Web Services. The keys were for their S3-equivalent (OSS: Object Storage Service), which I needed my friend Luis to explain to me as I was an AWS virgin until a few months ago. S3 (and OSS) are key-value databases. The database is divided into buckets. It's a fairly simple way of storing chunks of data "in the cloud", and the format for keys makes apparent the comparison to a file system.

Luis also found ossutil, which is like awscli for Aliyun, so we were able to list off the buckets with a couple shell commands.

~ $ ./ossutil64 ls
CreationTime                                 Region    StorageClass    BucketName
2018-05-15 09:43:06 +0000 UTC        oss-cn-beijing        Standard    oss://android-update
2019-11-15 08:25:54 +0000 UTC        oss-cn-beijing        Standard    oss://mcube-osm
2018-04-28 06:31:06 +0000 UTC        oss-cn-beijing        Standard    oss://mcube-ota
Bucket Number is: 3

1.727812(s) elapsed
~ $ ./ossutil64 ls oss://android-update
LastModifiedTime                   Size(B)  StorageClass   ETAG                                  ObjectName
2018-05-24 06:03:42 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://android-update/Bingo Sport/
2018-05-18 09:31:37 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://android-update/DJObewegt/
2018-05-15 09:49:26 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://android-update/l8star/
2018-05-15 09:43:33 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://android-update/yoho/
Object Number is: 4

1.716424(s) elapsed
~ $ ./ossutil64 ls oss://mcube-ota
LastModifiedTime                   Size(B)  StorageClass   ETAG                                  ObjectName
2020-11-04 03:06:57 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Dialog/
2020-11-04 03:07:07 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Dialog/MP1612/
2021-03-23 03:06:34 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Dialog/MP1613/
2021-07-02 02:43:31 +0000 UTC       281396      Standard   91F5B5CA01AE0953C225E71C1B145153      oss://mcube-ota/Dialog/MP1613/mc_band.8F.64.09.00.img
2021-03-18 07:22:23 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Dialog/MP1615/
2021-07-01 02:53:01 +0000 UTC       286676      Standard   6893E93A305B0DA4FEC5E30E0B67E598      oss://mcube-ota/Dialog/MP1615/mc_band.8F.64.0F.01.img
2021-07-26 03:06:29 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/GuangJi/
2021-07-26 03:10:52 +0000 UTC        92392      Standard   A69DD1D52C5A714507CA4E18705E02B5      oss://mcube-ota/GuangJi/GM121Q1UI_V7B_6B_04_30.bin
2020-04-29 08:27:35 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Habit+/
2020-01-10 09:50:03 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/JT/
2020-01-10 09:50:15 +0000 UTC       119876      Standard   EC902EEA38076E9132978E77CE1D72F9      oss://mcube-ota/JT/FACTORY_JT_R7_0.96_HRS3300_V6E_73_00_00.bin
2020-01-10 09:50:15 +0000 UTC       117568      Standard   A072ED4E40E2E255670EEDC16D4FB595      oss://mcube-ota/JT/FACTORY_JT_R9_1.0_HRS3300S_V6B_72_00_00.bin
2020-01-10 09:50:15 +0000 UTC       168023      Standard   CE29DA4EE5E2829A43B9B8C03D016284      oss://mcube-ota/JT/JT_R3_0.66_HRS3300S_V4A_72_00_00.bin
2020-01-10 09:50:15 +0000 UTC       107564      Standard   1827A73A6778C25C231DE716B5479E60      oss://mcube-ota/JT/JT_R5_0.96_96X96_HRS3300_V4F_6E_00_00.bin
2020-06-17 10:10:47 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/M2/
2020-07-23 15:01:22 +0000 UTC       111616      Standard   DB7815961B514B0637A398FB1CC679DD      oss://mcube-ota/M2/M2_E_IPE167_V41_7E_00_32.bin
2020-07-23 15:01:22 +0000 UTC       111616      Standard   E784EB22FDB99F943C9790D935D341ED      oss://mcube-ota/M2/M2_GS_IPG67_V41_7E_00_33.bin
2018-11-19 09:54:26 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Maxsend/
2019-11-12 10:29:47 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Maxsend/GM115/
2019-11-12 10:33:05 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Maxsend/GM115/0.96S/
2019-11-12 10:38:06 +0000 UTC        96940      Standard   E34860D829252CB9F7FB3EA94F5C32C4      oss://mcube-ota/Maxsend/GM115/0.96S/GM115_0.96S_V82_62_00_20.bin
2019-12-26 10:18:31 +0000 UTC        96100      Standard   1D979392E3E040319EFDF59BE90CC82D      oss://mcube-ota/Maxsend/GM115/0.96S/GM115_V82_63_00_24.bin
2019-11-12 10:30:33 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Maxsend/GM115/7735BOE/
2019-11-12 10:30:51 +0000 UTC        96940      Standard   BB4945F2B255C2D97725F30E840C91B9      oss://mcube-ota/Maxsend/GM115/7735BOE/GM115_7735BOE_V82_62_00_21.bin
2019-12-26 10:19:10 +0000 UTC        96100      Standard   111EE153248CA0A09CBCE84ED58C2A6C      oss://mcube-ota/Maxsend/GM115/7735BOE/GM115_V82_63_00_25.bin
2020-05-14 11:33:04 +0000 UTC       100564      Standard   BA066102FA168C7FCFD434DEB8BCBE9D      oss://mcube-ota/Maxsend/GM115/GM115_CEUI_0.96S_MC34XX_HRS3300_JJ_V82_66_00_36.bin
2020-05-14 11:33:20 +0000 UTC       100584      Standard   D88180FA87B24E934D2CBDDE9E051006      oss://mcube-ota/Maxsend/GM115/GM115_CEUI_BOE_0.96S_MC34XX_HRS3300_JJ_V82_66_00_37.bin
2020-08-19 08:50:57 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Maxsend/GM120/
2020-08-19 08:52:20 +0000 UTC       116852      Standard   63B9FD299CCFA9D47C22DF79ACD9B712      oss://mcube-ota/Maxsend/GM120/GM120M_V8D_63_00_21.bin
2020-07-27 11:44:29 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Maxsend/GM127B0/
2020-07-27 11:45:29 +0000 UTC       115772      Standard   34C740D1B7C6E1C732B96306FC2F3917      oss://mcube-ota/Maxsend/GM127B0/GM127_V8C_6D_00_21.bin
2019-12-10 14:44:46 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Maxsend/M3/
2020-05-15 11:13:43 +0000 UTC       102428      Standard   8032B2E58825204E9264B434BB57F684      oss://mcube-ota/Maxsend/M3/M3_7735BOE_0.96S_MC34XX_HRS3300_SC7R30_JJ_V80_6C_00_25.bin
2020-08-06 03:39:21 +0000 UTC       101856      Standard   D4633331413CA3992E836257023271EC      oss://mcube-ota/Maxsend/M3/M3_HSD_0.96S_V80_6E_00_24.bin
2019-11-09 08:39:37 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Maxsend/M4/
2020-05-15 11:14:19 +0000 UTC       102656      Standard   3B6DEE0CA2399446B60499F7C0681B3D      oss://mcube-ota/Maxsend/M4/M4_UI3_7735BOE_0.96S_MC34XX_HRS3300_SC7R30_JJ_V81_69_00_25.bin
2020-05-15 13:31:52 +0000 UTC       102636      Standard   2F8CD37A50EB9F3D68DB250EC2A80C98      oss://mcube-ota/Maxsend/M4/M4_UI3_7735BOE_0.96S_MC34XX_HRS3300_SC7R30_JJ_V81_69_00_27.bin
2020-05-15 11:14:19 +0000 UTC       102636      Standard   FEE888D1CCA800FD2275B748410477E2      oss://mcube-ota/Maxsend/M4/M4_UI3_HSD_0.96S_MC34XX_HRS3300_SC7R30_JJ_V81_69_00_24.bin
2020-05-15 13:31:52 +0000 UTC       102636      Standard   02B36A5DD5E7154EC163FA20B0BF0FD0      oss://mcube-ota/Maxsend/M4/M4_UI3_HSD_0.96S_MC34XX_HRS3300_SC7R30_JJ_V81_69_00_26.bin
2020-06-01 07:52:49 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/Maxsend/gm116m/
2020-06-01 07:59:42 +0000 UTC       100420      Standard   F2F8A68725BA5B78039011B87F8D1C1B      oss://mcube-ota/Maxsend/gm116m/GM116M_EARTHUI_1.3S_MC34XX_HRS3300_JJ_V8A_60_00_24.bin
2020-08-12 05:46:13 +0000 UTC        99920      Standard   F36E3E2BC232D48497C717A6CFF0F315      oss://mcube-ota/Maxsend/gm116m/GM116M_SF1049A_V3D_64_00_23.bin
2019-04-08 01:50:48 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-ota/force-upgrade/
2020-06-01 08:02:51 +0000 UTC         1528      Standard   9D0DB298389CF3296ADFD459C18CB2C1      oss://mcube-ota/force-upgrade/force upgrade.txt
2020-08-04 08:17:16 +0000 UTC       206740      Standard   BBF5D1BCC6845E63AE084DF75CDD4312      oss://mcube-ota/mc_band.8F.61.0A.20.img
Object Number is: 45

6.872844(s) elapsed
~ $ ./ossutil64 ls oss://mcube-osm
LastModifiedTime                   Size(B)  StorageClass   ETAG                                  ObjectName
2019-11-15 08:27:09 +0000 UTC            0      Standard   D41D8CD98F00B204E9800998ECF8427E      oss://mcube-osm/andromeda/
2019-11-15 08:27:20 +0000 UTC       153824      Standard   DC701B0A89718AFE223C62DE10139A33      oss://mcube-osm/andromeda/andromeda2_ble_1.0.0.bin
Object Number is: 2

1.736465(s) elapsed

I swear these buckets were populated with more entries when I was streaming. Anyway, this is when we realized that this is an elaborate scheme. Whatever organization is behind the watch I have actually makes several kinds of smart watches, and several distinct apps for interfacing with them.

The name on the box is a hint that we care about either M2_E_IPE167_V41_7E_00_32.bin or M2_GS_IPG67_V41_7E_00_33.bin, which are deceptively similar.

$ radiff2 M2*
File size differs 111617 vs 111638
Buffer truncated to 111617 byte(s) (21 not compared)
0x000065f4 32 => 33 0x000065f4
0x0000c1b0 32 => 33 0x0000c1b0
0x0000d9ac 32 => 33 0x0000d9ac
0x0001a876 452d49504531 => 47532d495047 0x0001a876
0x0001b27f 452d49504531 => 47532d495047 0x0001b27f
0x0001b2a0 452d49504531 => 47532d495047 0x0001b2a0

But turns out that we're not interested in those. The four hexadecimal bytes in each filename corresponds to a device identifier that's spit out by OTA characteristic.

$ sudo gatttool -I
[                 ][LE]> connect A4:C1:7A:56:82:90
Attempting to connect to A4:C1:7A:56:82:90
Connection successful
[A4:C1:7A:56:82:90][LE]> characteristics
handle: 0x0002, char properties: 0x12, char value handle: 0x0003, uuid: 2b120008-0600-072a-0100-050200042a00
handle: 0x0004, char properties: 0x02, char value handle: 0x0005, uuid: 0708090a-0b0c-0d2b-1200-080600072a01
handle: 0x0007, char properties: 0x06, char value handle: 0x0008, uuid: 00010203-0405-0607-0809-0a0b0c0d2b12
handle: 0x000b, char properties: 0x08, char value handle: 0x000c, uuid: 0000fec7-0000-1000-8000-00805f9b34fb
handle: 0x000d, char properties: 0x20, char value handle: 0x000e, uuid: 0000fec8-0000-1000-8000-00805f9b34fb
handle: 0x0010, char properties: 0x02, char value handle: 0x0011, uuid: 0000fec9-0000-1000-8000-00805f9b34fb
handle: 0x0012, char properties: 0x32, char value handle: 0x0013, uuid: 0000fea1-0000-1000-8000-00805f9b34fb
handle: 0x0015, char properties: 0x2a, char value handle: 0x0016, uuid: 0000fea2-0000-1000-8000-00805f9b34fb
handle: 0x0019, char properties: 0x0a, char value handle: 0x001a, uuid: 0000cc02-0000-1000-8000-00805f9b34fb
handle: 0x001b, char properties: 0x12, char value handle: 0x001c, uuid: 0000cc03-0000-1000-8000-00805f9b34fb
handle: 0x001e, char properties: 0x12, char value handle: 0x001f, uuid: 0000cc04-0000-1000-8000-00805f9b34fb
handle: 0x0021, char properties: 0x1a, char value handle: 0x0022, uuid: 0000cc05-0000-1000-8000-00805f9b34fb
handle: 0x0024, char properties: 0x08, char value handle: 0x0025, uuid: 0000cc06-0000-1000-8000-00805f9b34fb

[A4:C1:7A:56:82:90][LE]> char-read-uuid 0000cc02-0000-1000-8000-00805f9b34fb
handle: 0x001a   value: 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f 10 11 12 13

[A4:C1:7A:56:82:90][LE]> char-read-hnd 0x001a
Characteristic value/descriptor: 00 00 00 00 00 00 00 00 00 00 00 21 41 6b 00 00 00 00 00 00

[A4:C1:7A:56:82:90][LE]> char-read-uuid 0000cc02-0000-1000-8000-00805f9b34fb
handle: 0x001a   value: 00 00 00 00 00 00 00 00 00 00 00 21 41 6b 00 00 00 00 00

In our case, we want something with the filename ..._V41_xx_00_21.bin (note indices 11-14 in the byte array above). I have such a file saved from when I was initially doing the reverse engineering for this project, LD702A_DY_0.42_MC34XX_EM70XX_HRS3300_JJ_V41_72_00_21.bin, but this doesn't appear in the listing from ossutil from today. I suspect the organization has since nuked several firmware images.

Flashing Firmware

I was determined to flash custom firmware to this device. There are several characteristics whose name contains "OTA", but only the TELINK_* ones are advertised by my watch. The first clue, in BluetoothLeService.java:

public void onServicesDiscovered(BluetoothGatt bluetoothGatt, int i) {
    int i3 = 0;
    while (true) {
        if (i3 >= services.size()) {
        UUID uuid2 = services.get(i3).getUuid();
        if (uuid2.toString().equals(BluetoothLeService.TELINK_SPP_DATA_OTA_SERVICE.toString())) {
            BluetoothLeService.sOtaType = 1;
        } else if (uuid2.toString().equals(BluetoothLeService.MAXSCEND_OTA_SERVICE.toString())) {
            BluetoothLeService.sOtaType = 2;
        } else {
            BluetoothLeService.sOtaType = 0;

So we want to find a branch dependent on BluetoothLeService.sOtaType being 1. There's one hidden deep in MainActivity.java7:

public void onReceive(Context context, Intent intent) {
    else if (action.equals(Constant.ACTION_OTA_CONFIRMED)) {
            int intValue = ((Integer) SPUtils.get(MainActivity.this, Constant.DEV_BATT_PERCENTAGE, 0)).intValue();
            int intValue2 = ((Integer) SPUtils.get(MainActivity.this, Constant.DEV_BATT_STATUS, 3)).intValue();
            if (intValue >= 50 || intValue2 != 3) {
                SPUtils.put(MainActivity.this, "has_weather", false);
                if (BluetoothLeService.sOtaType == 1) {
                    if (TelinkOta.getBytesCount() != 0) {
                        new TelinkOtaTask().execute(new Void[0]);
                } else if (BluetoothLeService.sOtaType == 2) {
            } else {
                new AlertDialog.Builder(MainActivity.this)
                    .setNegativeButton(R.string.ok, (DialogInterface.OnClickListener) null)
                Log.i(MainActivity.TAG, "can not upgrade firmware while battery is less than 50%");

This brings us to the conveniently named TelinkOtaTask.

public class TelinkOtaTask extends AsyncTask<Void, Void, Void> {
    long delay = 100;

    public TelinkOtaTask() {

    /* access modifiers changed from: protected */
    public void onPreExecute() {

    /* access modifiers changed from: protected */
    public Void doInBackground(Void... voidArr) {
        for (int i = 0; i < TelinkOta.getBlockCount() + 3; i++) {
            if (i != 0) {
                if (i == 1) {
                    BluetoothLeService.writeOtaCharacteristic(new byte[]{1, -1});
                } else if (i == TelinkOta.getBlockCount() + 2) {
                } else {
                    if (TelinkOta.getCurrentCount() % 100 == 0) {
                    publishProgress(new Void[0]);
        return null;

    /* access modifiers changed from: protected */
    public void onProgressUpdate(Void... voidArr) {

    /* access modifiers changed from: protected */
    public void onCancelled() {

    /* access modifiers changed from: protected */
    public void onCancelled(Void voidR) {

The code is using this com.uthink.ring.update.TelinkOta class to chunk the firmware image, and write it to the OTA characteristic one block at a time. We begin the transfer by writing 0x01ff, and end by writing TelinkOta.getEndCmd(). The TelinkOta class is short enough that I can drop the listing here.

package com.uthink.ring.update;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;

public class TelinkOta {
    public static final int BLOCK_LENGTH = 16;
    public static final int CODE_SIZE_LENGTH = 4;
    public static final int CODE_SIZE_START = 24;
    private static final boolean DEBUG = false;
    private static final String TAG = TelinkOta.class.getSimpleName();
    public static final boolean USE_CB = false;
    public static int blockCount;
    public static byte[] bytes;
    public static int bytesCount;
    public static int currentCount;

    public static void setFile(InputStream inputStream) {
        try {
            bytesCount = inputStream.available();
            blockCount = (int) Math.ceil((double) (((float) bytesCount) / 16.0f));
            bytes = new byte[bytesCount];
        } catch (FileNotFoundException e) {
        } catch (NullPointerException e2) {
        } catch (IOException e3) {

    public static void setFile(byte[] bArr) {
        bytesCount = bArr.length;
        blockCount = (int) Math.ceil((double) (((float) bytesCount) / 16.0f));
        bytes = bArr;

    public static int getBytesCount() {
        return bytesCount;

    public static int getBlockCount() {
        return blockCount;

    public static int getCurrentCount() {
        return currentCount;

    public static void currentCountPlus() {

    public static int getCodeSize() {
        return ByteBuffer.wrap(Arrays.copyOfRange(bytes, 24, 28)).order(ByteOrder.LITTLE_ENDIAN).getInt();

    public static byte[] getEndCmd() {
        byte[] bArr = new byte[6];
        bArr[0] = 2;
        bArr[1] = -1;
        int i = blockCount;
        bArr[2] = (byte) ((i - 1) & 255);
        bArr[3] = (byte) (((i - 1) >> 8) & 255);
        bArr[4] = (byte) (bArr[2] ^ 255);
        bArr[5] = (byte) (bArr[3] ^ 255);
        return bArr;

    public static byte[] getBlock(int i) {
        byte[] bArr;
        byte[] bArr2 = new byte[0];
        byte[] bArr3 = {(byte) (i & 255), (byte) ((i >> 8) & 255)};
        try {
            if (i == blockCount - 1) {
                int i2 = bytesCount % 16;
                if (i2 == 0) {
                    int i3 = i * 16;
                    bArr = Arrays.copyOfRange(bytes, i3, i3 + 16);
                } else {
                    int i4 = i * 16;
                    byte[] copyOfRange = Arrays.copyOfRange(bytes, i4, i4 + i2);
                    byte[] bArr4 = new byte[(16 - i2)];
                    for (int i5 = 0; i5 < bArr4.length; i5++) {
                        bArr4[i5] = -1;
                    bArr = concatByteArrays(copyOfRange, bArr4);
            } else {
                int i6 = i * 16;
                bArr = Arrays.copyOfRange(bytes, i6, i6 + 16);
            byte[] concatByteArrays = concatByteArrays(concatByteArrays(bArr2, bArr3), bArr);
            int CRC_16 = CRC_16(byteToUnsignedChar(concatByteArrays));
            byte[] concatByteArrays2 = concatByteArrays(concatByteArrays, new byte[]{(byte) (CRC_16 & 255), (byte) ((CRC_16 >> 8) & 255)});
            return concatByteArrays2;
        } catch (NullPointerException unused) {
            return bArr2;

    public static int CRC_16(char[] cArr) {
        char[] cArr2 = {0, 40961};
        int i = 0;
        char c = 65535;
        while (i < cArr.length) {
            char c2 = cArr[i];
            char c3 = c;
            for (int i2 = 0; i2 < 8; i2++) {
                c3 = cArr2[(c3 ^ c2) & 1] ^ (c3 >> 1);
                c2 = (char) (c2 >> 1);
            c = c3;
        return c;

    public static void close() {
        bytesCount = 0;
        blockCount = 0;
        currentCount = 0;
        bytes = null;

    public static byte[] concatByteArrays(byte[] bArr, byte[] bArr2) {
        byte[] bArr3 = new byte[(bArr.length + bArr2.length)];
        System.arraycopy(bArr, 0, bArr3, 0, bArr.length);
        System.arraycopy(bArr2, 0, bArr3, bArr.length, bArr2.length);
        return bArr3;

    public static char[] byteToUnsignedChar(byte[] bArr) {
        char[] cArr = new char[bArr.length];
        for (int i = 0; i < cArr.length; i++) {
            cArr[i] = (char) (bArr[i] & 255);
        return cArr;

    public static void printByteToHex(byte[] bArr) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bArr) {
            if (sb.length() > 0) {
            sb.append(String.format("%02x", new Object[]{Byte.valueOf(b)}));

The bottom-line is that we're breaking the firmware image into 16-byte blocks and attaching a CRC-16 to each one. The code's already there, so I hacked together a little main function to perform the chunking for a file of my choosing.

public static void main(String[] args) {
    if (args.length != 1) {
        System.err.printf("usage: TelinkOta [IMAGE]\n");

    try {
        FileInputStream f = new FileInputStream(args[0]);
    } catch (FileNotFoundException e) {

    System.out.println(printByteToHex(new byte[]{1, -1}));
    for (int i = 0; i < TelinkOta.getBlockCount(); i++) {
        byte[] block = TelinkOta.getBlock(i);

This spits out each "packet" as a line of hexadecimal digits. I can then use some Emacs magic to turn said lines into shell commands, producing an extremely cursed shell script to flash a hard-coded firmware image to the device.8

gatttool -b A4:C1:7a:56:82:90 --char-write-req --handle=0x0008 --value=01ff
gatttool -b A4:C1:7a:56:82:90 --char-write-req --handle=0x0008 --value=00000e800103000000004b4e4c54000288006365
gatttool -b A4:C1:7a:56:82:90 --char-write-req --handle=0x0008 --value=01007680000000000000cc9e0100000000007423
gatttool -b A4:C1:7a:56:82:90 --char-write-req --handle=0x0008 --value=020031083209320a910202ca085004b1fa878c26
gatttool -b A4:C1:7a:56:82:90 --char-write-req --handle=0x0008 --value=03002008c06b210885061f08c06b200885063504
gatttool -b A4:C1:7a:56:82:90 --char-write-req --handle=0x0008 --value=040000a02009200a910202ca085004b1fa873b7f
gatttool -b A4:C1:7a:56:82:90 --char-write-req --handle=0x0008 --value=05001f09200a910202ca085004b1fa871b090552
gatttool -b A4:C1:7a:56:82:90 --char-write-req --handle=0x0008 --value=06001d08084001b048403fa31bf31b58a5abd4be
gatttool -b A4:C1:7a:56:82:90 --char-write-req --handle=0x0008 --value=070012c11fa2050b060812f302da02d3830271fc
gatttool -b A4:C1:7a:56:82:90 --char-write-req --handle=0x0008 --value=0800fbc1040b88a21a40fe87c0460080800095a0
gatttool -b A4:C1:7a:56:82:90 --char-write-req --handle=0x0008 --value=090000868000020680001009110a110b9a02592c

At this point, I hadn't reverse-engineered the firmware image, so I attempted changing one of the ASCII strings that radare2 could find, with little consideration to what might happen.

Figure 2: Before and after flashing firmware.

Where I Would Have Gone Next

I found being able to flash unauthenticated firmware to be a humorous attack vector. How about a worm for that $5 watch you got at the gas station?

I came across a Gitter conversation revealing I'm not the first to try to reverse engineer this watch. Ah well. They figured out that the SoC is likely based on TLSR8232, and the MCU is likely to be the TC329. That saved me from having to hammer my watch into pieces.

I couldn't find much information on either, so if I were going to reverse engineer the firmware, I would have to reverse engineer the SDK to figure out things like the image load address. The project's on the shelf for now because of that. It might be a fun project to reverse engineer the SoC/MCU, and I'd certainly learn a lot, but that would be more work than I want to put into this – I don't have an immediate interest in firmware reverse engineering at the moment.



This was before I learned how the sleep tracking features on these sorts of products work: not well. In retrospect, I should've saved up for an EEG device like the now-discontinued Zeo.


My primary concern was the software being non-free, but I also voiced concerns about privacy in the live stream. There are three separate analytics platforms tracking users of the application.


Of course, Java is pervasive, so we have cursed things such as Jazelle which do execute bytecode on hardware.


When I was first working on this, the damn vibrate feature woke me up at five in the morning, trying to tell me that the watch was low on battery.


If you're unfamiliar with the Android SDK, "activities" compose the UI. As in, this snippet is in the UI code.


In this case, I'm referring to the characteristic by its handle rather than by its UUID. My uninformed understanding is that the handle is a sort of "short" identifier used for the same purpose as the UUID. I'm doing it this way because gatttool only let me write to this particular characteristic if I used a handle.


There have apparently been some reverse engineering efforts involving the TC32.

Webmentions for this Page

Alternatively, you can send an anonymous comment.