<--

Say Friend and Enter: Digitally lockpicking an advanced smart lock (Part 1: functional analysis)

By Lev Aronsky (@levaronsky) & Idan Strovinsky & Tomer Telem
February 20, 2024
*
*
Disclaimer: code excerpts provided here are
based on Ghidra's decompiler, and might have
syntax issues, such as non-existent types,
missing return statements, etc.

Preface

A modern smart home contains many different types of devices. And among them - we thought that smart locks would be a very intriguing target. Not only breaking into them is as interesting as breaking into any other IoT device - but the implication of vulnerabilities in a smart lock is that an attacker gains not only online, but also physical access to the home. And so, for this research, we chose a lock marketed by the Israeli company Kontrol, named Kontrol Lux Lock.

While the lock is marketed in Israel by Kontrol, it is, in fact, developed in China, by a company named Sciener. That company develops a multitude of smart lock designs, and sells them to companies around the world. Those companies add their own logo to the devices (and maybe update the designs a bit, to make them unique), and market them under their own names. However, the firmware running on the device, and the app used to communicate with the lock (named TTLock) are developed solely by Sciener. And so, while this research focused on the Kontrol Lux Lock (as that’s the device we acquired), we believe that most of the findings will be relevant to many locks utilizing similar firmware, sold around the world under different brand names.

Kontrol Lux Lock

The lock, with its premium look (and a matching price), offers many features (that, in our case, translate into many attack surfaces):

  1. Control via a smartphone app (TTLock, available for iOS and Android). The lock communicates with the app over Bluetooth LE (BLE).
  2. Locking/unlocking via an integrated keypad.
  3. Unlocking via a fingerprint reader.
  4. Unlocking using an RFID tag.
  5. Unlocking over the Internet, via a special gateway device.
  6. Locking/unlocking via designated buttons (on the inner side of the door).
  7. Sound alarm after multiple unlocking failures.

Additionally, the TTLock app offers the following (non-exhaustive) list of features:

  1. Granting access (virtual keys) to the lock to additional users.
  2. Limiting the use of virtual keys:

    • Recurring timeframes (e.g., to let a cleaning crew into the apartment)
    • Limited timeframe (e.g., to let AirBNB guests in for the duration of their stay)
    • Single use (e.g., to let a maintenance worker into the apartment)
  3. Logging the use of the lock - every locking and unlocking through the app is logged.
  4. Configuration of the lock: adding fingerprints, RFID tags, etc.

TTLock App Main Screen

This blog post is split into two parts. The first part will explain the analysis we performed on the application and the firmware, that allowed us to understand the inner workings of the lock and its ecosystem. The second part will focus on the actual vulnerabilities that we discovered during this research.

Analyzing the TTLock app

We decided to begin our research with the TTLock app - specifically, the Android version (for easier analysis). The latest version at the time (6.5.0) was based on Flutter, and that made analyzing it rather challenging. As the saying goes, though, work smarter - not harder. We decided to take a look at an older version, and luckily, version 6.4.5 was not Flutter-based - so we decided to focus on that.

Decompilation of the app with jadx was rather straightforward: simply opening the APK with jadx-gui works well. There are several functions throughout the codebase, that fail to decompile correctly - they are marked by jadx with appropriate comments, and the function code contains raw Java opcodes. To assist with code readability, it’s worth configuring jadx to ignore such errors. Based on our experience, the resulting decompiled code represents the function logic very well, and haven’t had any issues with false/inaccurate code.

While the APK is full of different libraries, we read the manifest to figure out its entry point. It was located in a package named, unsurprisingly, com.ttlock - and that’s what we focused on in our analysis.

The application’s communication mechanism with the lock is performed through an object called Command:

public Command(byte b) {
  this.header = r0;
  byte[] bArr = {ByteCompanionObject.MAX_VALUE, 90};
  this.encrypt = DigitUtil.generateRandomByte();
  this.protocol_type = b;
  this.data = new byte[0];
  this.length = (byte) 0;
  generateLockType();
}

public Command(byte[] bArr) {
  this.header = r1;
  byte[] bArr2 = {bArr[0], bArr[1]};
  byte b = bArr[2];
  this.protocol_type = b;
  try {
    if (b >= 5) {
      this.organization = r1;
      this.sub_organization = r0;
      this.sub_version = bArr[3];
      this.scene = bArr[4];
      byte[] bArr3 = {bArr[5], bArr[6]};
      byte[] bArr4 = {bArr[7], bArr[8]};
      this.command = bArr[9];
      this.encrypt = bArr[10];
      int i = bArr[11];
      this.length = i;
      byte[] bArr5 = new byte[i];
      ...
      generateLockType();
  } catch (Exception e) {
    e.printStackTrace();
  }
}

The structure of the BLE message is outlined in the following table:

Offset Description Length Value
0 Header 2 0x7f, 0x5a
2 Protocol version 1 0x05 (for Kontrol Lux)
3 Protocol subversion 1 0x03 (for Kontrol Lux)
4 Scene 1 0x02
5 Organization 2 0x00, 0x10
7 Sub organization 2 0x00, 0x22
9 Command 1 the requested command
10 Encrypt 1 0xaa/0xa0/0x55
11 Payload length 1 The length of the payload
12 Payload 0-255 payload
-4 (from end) CRC 2 The CRC-16 of this message
-2 (from end) Footer 2 0x0d, 0x0a

When creating a new command, the content of the command is transferred to a method found in the CommandUtil class, that internally propagates the call to another implementation class, that is responsible for encrypting the data based on the protocol version (such as CommandUtil_V2, CommandUtil_V2S, etc.). For our lock, the class that implements its protocol is CommandUtil_V3, and the use of AES encryption can be seen in this function:

public void setData(byte[] bArr, byte[] bArr2) {
  LogUtil.d("data=" + DigitUtil.byteArrayToHexString(bArr), DBG);
  LogUtil.d("aesKeyArray=" + DigitUtil.byteArrayToHexString(bArr2), DBG);
  byte[] aesEncrypt = AESUtil.aesEncrypt(bArr, bArr2);
  this.data = aesEncrypt;
  this.length = (byte) aesEncrypt.length;
}

When an action is performed in the app, the command is sent to the lock, and the response from the lock is parsed by the app. The response is received and parsed in a handler function called processCommandResponse:

public void processCommandResponse(byte[] bArr) {
...
  Command command = new Command(bArr);
  if (!command.isChecksumValid()) {
    Error error = Error.LOCK_CRC_CHECK_ERROR;
    this.error = error;
    error.setCommand(command.getCommand());
    errorCallback(this.error);
    return;
  }
  ...
  switch (command.getLockType()) {
    case 2:
    case 3:
    case 6:
    case 7:
      bArr2 = command.getData();
      break;
    case 4:
      bArr2 = command.getData(aesKeyArray);
      break;
    case 5:
    case 8:
      this.mHandler.removeCallbacks(this.disConRunable);
      bArr2 = command.getData(aesKeyArray);
      break;
  ...

Once we understood the basic mechanism of the application’s communication with the lock, it was possible to dive into the process of opening the lock.

First, the application checks whether the user who attempts to open the lock is the owner of the lock (called admin throughout the app), or a normal user (that is the case when the lock is shared with another user), and calls the appropriate function.

public static void A_checkAdmin(TransferData transferData) {
  Command command = new Command(transferData.getLockVersion());
  command.setCommand((byte) 65);
  String adminPs = transferData.getAdminPs();
  String unlockKey = transferData.getUnlockKey();
  if (adminPs.length() > 10) {
    adminPs = new String(DigitUtil.decodeDefaultPassword(DigitUtil.stringDividerByDotToByteArray(adminPs)));
  }
  if (adminPs.length() < 10) {
    adminPs = String.format(Locale.ENGLISH, "%10s", adminPs).replace(" ", Constant.nosetting);
  }
  String str = adminPs;
  String str2 = (unlockKey == null || unlockKey.length() <= 10) ? unlockKey : new String(DigitUtil.decodeDefaultPassword(DigitUtil.stringDividerByDotToByteArray(unlockKey)));
  transferData.setAdminPs(str);
  transferData.setUnlockKey(str2);
  int lockType = command.getLockType();
  if (lockType == 3) {
    CommandUtil_V2S.checkAdmin(command, str, transferData.getLockFlagPos());
  } else if (lockType == 4) {
    CommandUtil_V2S_PLUS.checkAdmin_V2S_PLUS(command, str, str2, transferData.getLockFlagPos(), TransferData.getAesKeyArray(), transferData.getAPICommand());
  } else {
    if (lockType != 5) {
      if (lockType == 6) {
        CommandUtil_Va.checkAdmin(command, str);
      }
    }
    CommandUtil_V3.checkAdmin(command, transferData.getmUid(), str, str2, transferData.getLockFlagPos(), TransferData.getAesKeyArray(), transferData.getAPICommand());
  }
  transferData.setTransferData(command.buildCommand());
  BluetoothLeService.getBluetoothLeService().sendCommand(transferData);
}

...

public static void U_checkUserTime(TransferData transferData) {
  Command command = new Command(transferData.getLockVersion());
  command.setCommand(Command.COMM_CHECK_USER_TIME);
  String unlockKey = transferData.getUnlockKey();
  long startDate = transferData.getStartDate();
  long endDate = transferData.getEndDate();
  LogUtil.d("startDate:" + startDate, false);
  LogUtil.d("endDate:" + endDate, false);
  String str = unlockKey.length() > 10 ? new String(DigitUtil.decodeDefaultPassword(DigitUtil.stringDividerByDotToByteArray(unlockKey))) : unlockKey;
  transferData.setUnlockKey(str);
  if (startDate == 0 || endDate == 0) {
    startDate = 949338000000L;
    endDate = permanentEndDate;
  }
  transferData.setStartDate(startDate);
  transferData.setEndDate(endDate);
  String formateDateFromLong = DigitUtil.formateDateFromLong((startDate + transferData.getTimezoneOffSet()) - TimeZone.getDefault().getOffset(System.currentTimeMillis()), "yyMMddHHmm");
  String formateDateFromLong2 = DigitUtil.formateDateFromLong((endDate + transferData.getTimezoneOffSet()) - TimeZone.getDefault().getOffset(System.currentTimeMillis()), "yyMMddHHmm");
  int lockType = command.getLockType();
  if (lockType == 3) {
    CommandUtil_V2S.checkUserTime(command, formateDateFromLong, formateDateFromLong2, transferData.getLockFlagPos());
  } else if (lockType == 4) {
    CommandUtil_V2S_PLUS.checkUserTime_V2S_PLUS(command, formateDateFromLong, formateDateFromLong2, str, transferData.getLockFlagPos(), TransferData.getAesKeyArray(), transferData.getAPICommand());
  } else {
    if (lockType != 5) {
      if (lockType == 6) {
        CommandUtil_Va.checkUserTime(command, formateDateFromLong, formateDateFromLong2);
      }
    }
    CommandUtil_V3.checkUserTime(command, transferData.getmUid(), formateDateFromLong, formateDateFromLong2, str, transferData.getLockFlagPos(), TransferData.getAesKeyArray(), transferData.getAPICommand());
  }
  transferData.setTransferData(command.buildCommand());
  BluetoothLeService.getBluetoothLeService().sendCommand(transferData);
}

It’s evident from the code that the application creates a new Command instance that contains the details of opening the lock. In addition, we saw that the application performs seemingly different actions depending on the type of lock. However, upon diving deeper into the implementation, the only actual difference between the versions is the implementation of the setData function.

Version 3 (the version of our lock) uses AES encryption:

public void setData(byte[] bArr, byte[] bArr2) {
  LogUtil.d("data=" + DigitUtil.byteArrayToHexString(bArr), DBG);
  LogUtil.d("aesKeyArray=" + DigitUtil.byteArrayToHexString(bArr2), DBG);
  byte[] aesEncrypt = AESUtil.aesEncrypt(bArr, bArr2);
  this.data = aesEncrypt;
  this.length = (byte) aesEncrypt.length;
}

Version 2S uses encryption implemented in native code:

public void setData(byte[] bArr) {
  byte[] encodeWithEncrypt = CodecUtils.encodeWithEncrypt(bArr, this.encrypt);
  this.data = encodeWithEncrypt;
  this.length = (byte) encodeWithEncrypt.length;
}
Java_com_scaf_android_client_CodecUtils_encodeWithEncrypt
          (long *param_1,undefined8 param_2,undefined8 param_3,byte param_4) 
{
  byte bVar1;
...  
  lVar2 = tpidr_el0;
  lVar8 = *(long *)(lVar2 + 0x28);
  uVar4 = (**(code **)(*param_1 + 0x5c0))(param_1,param_3,0);
  uVar3 = (**(code **)(*param_1 + 0x558))(param_1,param_3);
  uVar5 = (**(code **)(*param_1 + 0x580))(param_1,uVar3);
  (**(code **)(*param_1 + 0x680))(param_1,uVar5,0,uVar3,uVar4);
  lVar6 = (**(code **)(*param_1 + 0x5c0))(param_1,uVar5,0);
  uVar7 = (**(code **)(*param_1 + 0x558))(param_1,uVar5);
  uVar14 = uVar7 & 0xffffffff;
  if (0 < (int)(uint)uVar7) {
    bVar1 = (&DAT_00100738)[uVar7 & 0xff];
    if ((uint)uVar7 < 0x20) {
      uVar7 = 0;
    }
    else {
      uVar7 = uVar7 & 0xffffffe0;
      bVar10 = bVar1 ^ param_4;
      puVar11 = (undefined8 *)(lVar6 + 0x10);
      uVar13 = uVar7;
      do {
        uVar5 = puVar11[-1];
        uVar4 = puVar11[-2];
        uVar16 = puVar11[1];
        ...
        puVar11 = puVar11 + 4;
      } while (uVar13 != 0);
      if (uVar7 == uVar14) goto LAB_00100ef8;
    }
    lVar9 = uVar14 - uVar7;
    pbVar12 = (byte *)(lVar6 + uVar7);
    do {
      lVar9 = lVar9 + -1;
      *pbVar12 = bVar1 ^ *pbVar12 ^ param_4;
      pbVar12 = pbVar12 + 1;
    } while (lVar9 != 0);
  }
LAB_00100ef8:
  uVar4 = (**(code **)(*param_1 + 0x580))(param_1,uVar14);
  (**(code **)(*param_1 + 0x680))(param_1,uVar4,0,uVar14,lVar6);
  if (*(long *)(lVar2 + 0x28) == lVar8) {
    return uVar4;
  }
}

At first glance the function looks complicated, but upon further inspection it becomes clear that this is not an encryption algorithm, but rather an encoding.

After sending an authorization command, the lock will return a response that contains a challenge (a random number). The goal of the challenge is to prevent replay attacks, since the first message is always fixed. To succeed in the challenge verification, the app adds the challenge value to the value of the unlockKey variable (that is known both to the app and the lock), and returns the sum back to the lock as part of the next command.

public void unlockByAdministrator(ExtendedBluetoothDevice extendedBluetoothDevice, int i, String str, String str2, String str3, int i2, long j, String str4, long j2) {
  byte[] convertAesKeyStrToBytes = (str4 == null || "".equals(str4)) ? null : DigitUtil.convertAesKeyStrToBytes(str4);
  LogUtil.d("uid=" + i + " lockVersion=" + str + " adminPs=" + str2 + " unlockKey" + str3 + " lockFlagPos=" + i2 + " unlockDate=" + j + " aesKeyArray=" + DigitUtil.byteArrayToHexString(convertAesKeyStrToBytes), DBG);
  TransferData transferData = new TransferData();
  transferData.setAPICommand(3);
  transferData.setCommand((byte) 65);
  transferData.setmUid(i);
  transferData.setLockVersion(str);
  transferData.setAdminPs(str2);
  transferData.setUnlockKey(str3);
  transferData.setLockFlagPos(i2);
  transferData.setUnlockDate(j);
  TransferData.setAesKeyArray(convertAesKeyStrToBytes);
  transferData.setTimezoneOffSet(j2);
  CommandUtil.A_checkAdmin(transferData);
}

...

public void unlockByUser(ExtendedBluetoothDevice extendedBluetoothDevice, int i, String str, long j, long j2, String str2, int i2, String str3, long j3) {
  LogUtil.d("this:" + toString(), DBG);
  byte[] convertAesKeyStrToBytes = (str3 == null || "".equals(str3)) ? null : DigitUtil.convertAesKeyStrToBytes(str3);
  LogUtil.d("uid=" + i + " lockVersion=" + str + " unlockKey" + str2 + " lockFlagPos=" + i2 + " aesKeyArray=" + DigitUtil.byteArrayToHexString(convertAesKeyStrToBytes), DBG);
  TransferData transferData = new TransferData();
  transferData.setAPICommand(4);
  transferData.setCommand(Command.COMM_CHECK_USER_TIME);
  transferData.setmUid(i);
  transferData.setLockVersion(str);
  transferData.setStartDate(j);
  transferData.setEndDate(j2);
  transferData.setUnlockKey(str2);
  transferData.setLockFlagPos(i2);
  transferData.setTimezoneOffSet(j3);
  TransferData.setAesKeyArray(convertAesKeyStrToBytes);
  CommandUtil.U_checkUserTime(transferData);
}

If the challenge response matches the lock’s expectation, the lock will open.

Once we found the functionality of opening the lock, as well as the handler that listens to BLE messages, we used Frida to install hooks on those functions to see the actual values of the various variables and fields in the code above. Armed with that knowledge, we developed a proof-of-concept code in Python that imitated the functionality and could be used to open and close the lock, given the prerequisite information (such as the AES encryption key, etc.).

BLE MITM

As we saw in the previous section, the process of connecting to the lock and communicating with it is rather simple (as far as BLE is concerned), and the responsibility to secure the communications is delegated to the application layer. In other words, the lock is ready to accept connections from any device at the BLE layer, and all the authentication/authorization is happening at a higher level, afterwards.

As a matter of fact, this lack of authentication/authorization at the BLE level goes both ways. Therefore, it should be possible to create a device that exposes the same BLE address and characteristics as the actual lock, and the app would happily connect to it and attempt communicating with it. And vice-versa - we could impersonate the app and connect to the lock. That’s exactly what we decided to do.

We chose to use an ESP32 MCU for this development. It’s cheap, readily available, supports Bluetooth LE, and has plenty of development resources, including Arduino IDE support. In fact, we developed two versions of lock impersonation devices: one based on Arduino for ESP32, and another based on the standard ESP32 SDK (esp-idf).

The steps for impersonating a lock are rather straightforward. Regardless of the development framework chosen, they boil down to:

  1. Set the MAC address of the ESP32 to that of the lock using a call to esp_base_mac_addr_set(uint8_t *mac).
  2. Initialize the BLE stack as a peripheral (i.e., an edge device that a BLE central connects to, just like our lock).
  3. Configure the BLE services and characteristics. Expose the same services and characteristics as the lock (these can be easily enumerated using, e.g., nRF Connect on Android). Not all of them are required, and it’s sufficient to instantiate the 0x1910 service with a command characteristic 0xFFF2 and a notification characteristic 0xFFF4.

This rudimentary setup is already sufficient to act as a mock lock - the TTLock app will connect to it on attempts to communicate with the lock. At that point, the only thing left is to handle the communication - specifically, processing data sent to the 0xFFF2 characteristic, and responding with sensible data on the 0xFFF4 characteristic.

nRF Connect displays the services exposed by the lock

For the ESP-IDF version, we based our code on the BLE examples Espressif provide with their SDK. Specifically, we used:

  • gatts_table_creat_demo to impersonate the lock (this is a demo for a BLE server advertising several services)
  • gattc_demo to impersonate the app (this is a demo for a simple BLE client)

All we had to do for both, as mentioned above, was change the MAC address for the server (using esp_iface_mac_addr_set) and adjust the services and characteristics to match the lock and app.

Lock firmware analysis

Getting the hands on the firmware

There are usually two approaches to getting a firmware of a device: either extract it from the device itself, or download it from an update server. While extracting the firmware from the actual device has its advantages (you can be sure that what you’re analyzing is exactly what’s running on the device, e.g.), downloading a firmware from an update server, if possible, lets you get your hands dirty quickly. In our case, downloading the firmware from the server was as easy as setting up a proxy on our Android phone, and checking for firmware updates in the TTLock app. The downloaded firmware was not encrypted (this can be easily deduced via entropy analysis, or by searching for strings), and we could proceed to analyze it in Ghidra.

Analyzing in Ghidra

The first step in firmware analysis is figuring out its architecture. The binary file we downloaded did not look like anything familiar (ARM/MIPS/etc.), so we opened up the lock to see what exactly we’re dealing with. Looking for the MCU (that’s usually the largest chip with the highest number of legs), we found a TLSR8251. As a matter of fact, we found two - more on that later (the chips are marked as U1 on both of the boards). In any case, a quick search revealed that it’s a chip developed by a Chinese company named TELink, with a custom architecture named TC32. Luckily, Ghidra plugins for this architecture were available, so we could proceed with the firmware analysis without additional preparations.

Mainboard of the apartment unit Mainboard of the outside unit

Of course, loading the firmware into Ghidra, even with the correct plugin, is not very helpful at first. To begin analyzing the firmware’s logic, you first have to know its entry point, the different registers the MCU works with (and their purpose), etc. So, we set to work. Our approach was as follows:

  1. Read the datasheet: getting the datasheet allowed us to understand basic, but important, properties of the MCU, such as its memory layout, its entry point, and its interrupts. It also contains information about the MCU’s special registers. Looking for functions that access certain special registers can shed some light on those functions’ purpose. TELink provides the datasheet here.

  2. Get the SDK: TELink provides multiple SDKs that are used to develop firmware for TLSR8-based devices. This was very useful in understanding the inner workings of our firmware. The SDK came with multiple samples. We could build those samples, with symbols, dump the disassembly of each sample - and then search for signatures of different functions from the samples in our firmware. This trick, while a bit tedious, transformed our firmware from a mess of unknown function calls to a reasonably readable state. Following is a screenshot of the main function, that illustrates how readable the code became. Such readability was very advantageous to our research. E.g., we could now follow the code from the entry point all the way to registering callbacks for BLE characteristic writes - which meant we knew the addresses of these callbacks, and could focus on analyzing them. And these callback handlers themselves were readable, too - thanks to many library functions that were also recognized thanks to the SDK samples.

void main(void)
{
  undefined *puVar1;
  unsigned int *reg_tmr_ctrl;
  unsigned int is_return_from_deep_sleep;
  char bVar2;
  unsigned int *reg_tmr2_tick;
  
  *(unsigned int *)PTR_DAT_00000d90 = 0x2BAD;
  *(unsigned int *)PTR_DAT_00000d98 = 0x301D;
  puVar1 = PTR_DAT_00000d9c;
  PTR_DAT_00000d9c[2] = 1;
  bVar2 = 0;
  FUN_00026030(0xb4,0x40000);
  cpu_wakeup_init();
  is_return_from_deep_sleep = (unsigned int)(char)*PTR_DAT_00000da0;
  rf_drv_init(2);
  gpio_init(~is_return_from_deep_sleep + is_return_from_deep_sleep + (unsigned int)bVar2);
  clock_init(0x43);
  reg_tmr2_tick = PTR_REG_TMR2_TICK_00000da4;
  *(unsigned int *)PTR_REG_TMR2_TICK_00000da4 = 0;
  reg_tmr_ctrl = (unsigned int *)(reg_tmr2_tick + -0x18);
  *reg_tmr_ctrl = 0xFF8001FF & *reg_tmr_ctrl;
  *reg_tmr_ctrl = *reg_tmr_ctrl | 0x26200;
  *reg_tmr_ctrl = *reg_tmr_ctrl | 0x40;
  *reg_tmr_ctrl = *reg_tmr_ctrl | 0x800000;
  if ((*puVar1 == 0x0) && (DAT_00077000 != -1)) {
    analog_write(0x8a);
  }
  if (is_return_from_deep_sleep == 0) {
    user_init_normal();
  }
  else {
    user_init_deepRetn();
  }
  *PTR_REG_IRQ_MASK+3_00000db0 = 1;
  do {
    main_loop();
  } while( true );
}

Analyzing the encryption

One of the first things we wanted to focus on when analyzing the firmware was the encryption mechanism, as beating it would bring us a lot closer to controlling the lock wirelessly.

TLSR8251 supports hardware-accelerated AES cryptography. The functionality, controlled through multiple special registers, is exposed in a convenient API as part of the SDK. Our original assumption was that this functionality was used to encrypt and decrypt the BLE traffic - and we could, indeed, find references to it from several places in code. But that proved to be a dead end.

Meanwhile, as part of analyzing the firmware with binwalk, we knew that it contained an AES SBOX (and its inverse). These two 256-byte long buffers are used to perform AES encryption and decryption in software, and by analyzing the references to the buffers, we found the software-based encryption and decryption functions in the firmware. These proved to be very useful, as by backtracking from these two locations in code, we were able to find the code responsible for encrypting and decrypting BLE messages.

In fact, we found that multiple keys are used throughout the firmware:

  1. Application AES key. This key is used when communicating with the TTLock app. When the lock is uninitialized, a hard-coded key is used at first. Once the app connects to the lock and initializes it, the lock generates a new key using the built-in PRNG functionality, stores it, and sends it back to the app. From that point on, that generated key is used to encrypt the communication with the app until the lock is reset.
  2. Gateway AES key. This key is used when communicating with the gateway. It is hard-coded, and seems to be used for decrypting certain messages from the gateway - we have not explored this functionality too much.
  3. Wireless keypad AES key. This key is used when communicating with the keypad. It is also hard-coded, and a new, random key is generated upon each connection from the keypad.

Following are the encryption and decryption functions, in all their decompiled glory:

int aes_sw_decrypt_with_key(byte *plaintext,byte *ciphertext,size_t len,char *aesKey)
{
  int total_decrypted;
  char cVar1;
  uint blocks_count;
  int iVar2;
  byte *pbVar3;
  byte *pbVar4;
  bool bVar5;
  undefined *AESInverseSBox;
  undefined *iv;
  
  if (plaintext != ciphertext) {
    memcpy(plaintext,ciphertext,len);
  }
  AESInverseSBox = PTR_AES_INVERSE_SBOX_0000d18c;
  iv = PTR_initialization_vector_0000d188;
  blocks_count = len >> 4;
  if (blocks_count != 0) {
    pbVar4 = plaintext + (len - 0x10);
    do {
      total_decrypted = 0;
      do {
        pbVar4[total_decrypted] = pbVar4[total_decrypted] ^ iv[total_decrypted + 0xa0];
        total_decrypted += 1;
      } while (total_decrypted != 0x10);
      total_decrypted = 0x90;
      do {
        FUN_0000cce0(pbVar4,1);
        iVar2 = 0;
        do {
          pbVar4[iVar2] = AESInverseSBox[pbVar4[iVar2]];
          iVar2 += 1;
        } while (iVar2 != 0x10);
        iVar2 = 0;
        do {
          pbVar4[iVar2] = pbVar4[iVar2] ^ iv[iVar2 + total_decrypted];
          iVar2 += 1;
        } while (iVar2 != 0x10);
        if (total_decrypted == 0) break;
        FUN_0000cd40(pbVar4,1);
        bVar5 = total_decrypted != 0;
        total_decrypted = total_decrypted + -0x10;
      } while (bVar5);
      if (blocks_count == 1) goto LAB_0000d150;
      pbVar4 = pbVar4 + -0x10;
      cVar1 = '\0';
      pbVar3 = pbVar4;
      do {
        pbVar3[0x10] = pbVar3[0x10] ^ *pbVar3;
        cVar1 += '\x01';
        pbVar3 = pbVar3 + 1;
      } while (cVar1 != '\x10');
      blocks_count -= 2;
    } while( true );
  }
LAB_0000d162:
  blocks_count = (uint)plaintext[len - 1];
  if ((blocks_count == 0) || (total_decrypted = len - blocks_count, len <= blocks_count)) {
    total_decrypted = 0;
  }
  return total_decrypted;
LAB_0000d150:
  total_decrypted = 0;
  do {
    pbVar4[total_decrypted] = pbVar4[total_decrypted] ^ aesKey[total_decrypted];
    total_decrypted += 1;
  } while (total_decrypted != 0x10);
  goto LAB_0000d162;
}

uint aes_sw_encrypt_with_key(byte *plaintext,byte *ciphertext,size_t len,byte *aesKey)
{
  undefined *puVar1;
  uint blocks;
  uint padded_length;
  int iVar2;
  int iVar3;
  byte *pbVar4;
  char cVar5;
  undefined padding_buffer [16];
  undefined *pAESSBox;
  
  blocks = (len >> 4) + 1;
  padded_length = blocks * 0x10;
  if (plaintext != ciphertext) {
    memcpy(ciphertext,plaintext,len);
  }
  memset(padding_buffer,0x10 - (len & 0xf) & 0xff,0x10);
  if (len < padded_length) {
    memcpy(ciphertext + len,padding_buffer,padded_length - len);
  }
  if (len == padded_length) {
    memcpy(ciphertext + padded_length,padding_buffer,0x10);
  }
  pAESSBox = PTR_AES_SBOX_0000d0a0;
  puVar1 = PTR_initialization_vector_0000d09c;
  blocks &= 0xfffffff;
  if (blocks != 0) {
    while( true ) {
      pbVar4 = ciphertext;
      iVar2 = 0;
      do {
        pbVar4[iVar2] = pbVar4[iVar2] ^ aesKey[iVar2];
        iVar2 += 1;
      } while (iVar2 != 0x10);
      iVar2 = 0;
      do {
        pbVar4[iVar2] = pbVar4[iVar2] ^ puVar1[iVar2];
        iVar2 += 1;
      } while (iVar2 != 0x10);
      iVar2 = 0x10;
      cVar5 = '\x01';
      do {
        iVar3 = 0;
        do {
          pbVar4[iVar3] = pAESSBox[pbVar4[iVar3]];
          iVar3 += 1;
        } while (iVar3 != 0x10);
        FUN_0000cce0(pbVar4,0);
        if (cVar5 != '\n') {
          FUN_0000cd40(pbVar4,0);
        }
        iVar3 = 0;
        do {
          pbVar4[iVar3] = pbVar4[iVar3] ^ puVar1[iVar3 + iVar2];
          iVar3 += 1;
        } while (iVar3 != 0x10);
        cVar5 += '\x01';
        iVar2 += 0x10;
      } while (cVar5 != '\v');
      blocks -= 2;
      if (blocks == 0) break;
      ciphertext = pbVar4 + 0x10;
      aesKey = pbVar4;
    }
  }
  return padded_length;
}

A few things to note about these functions, that will be of importance later on:

  • They return, respectively, the length of the resulting buffer (decrypted and unpadded/encrypted and padded).
  • While they support receiving different buffers for plaintext and ciphertext, effectively they work in-place (notice the memcpy in the beginning of each function). As a matter of fact, throughout the firmware, they are always called with the same argument passed as the source and destination of the encryption/decryption call.

Analyzing the BLE functionality

By looking for callers of the decryption function, we were able to locate the handler of incoming BLE messages. It’s a rather large function, that first decrypts the message buffer, and then goes into a huge jumptable to perform the required functionality, based on the requested command (the command is indicated in the message header, and is not encrypted). This being the handler was also verified by finding BLE-related routines from the SDK, and analyzing the function used as the callback for BLE incoming messages.

Using information obtained in the TTLock app (where the different command bytes are named), as well as the unofficial TTLock Javascript SDK, we had a rather good understanding of the jumptable, and could focus on analyzing the code that handled interesting commands.

Most of the commands are protected by preconditions, such as the lock being uninitialized, or that a successful authentication/authorization was performed during this BLE session.

Let’s look, e.g., at the code that handles the unlock command (0x47):

[...]  
data = *(byte **)(unaff_r5 + 4);
if ((in_r3 == 0x60) && (*(ushort *)(PTR_DAT_0001b090 + 0x43) != DAT_0001b094)) {
  build_error_response(unaff_r6,0x1b);
  complete_handle_packet();
  return;
}
bVar1 = is_in_setting();
if (((uint)context->is_CCA1_after_key_generated + aes_key_generated_negative != 0 & bVar1) != 0) {
                  /* ERROR_IN_SETTING */
  build_error_response(param_10,5);
  complete_handle_packet();
  return;
}
iVar2 = get_permission_level();
if ((((iVar2 != 1) && (iVar2 = get_permission_level(), iVar2 != 2)) &&
    (iVar2 = get_permission_level(), iVar2 != 3)) &&
   (iVar2 = get_permission_level(), iVar2 != 4)) {
  bulid_error_response_no_permission();
  return;
}
if (((uint)*data << 0x18 | (uint)data[1] << 0x10 | (uint)data[3] | (uint)data[2] << 8) == 
    PTR_admin_data_0001b08c->unlockKey + context->psFromLock) {
  unlock_implementation();
}
                  /* ERROR_Dyna_Password_Out_Time */
build_error_response(param_10,8);
complete_handle_packet();
return;
[...]
  1. The first condition is unclear, but we couldn’t reproduce it.
  2. The second condition checks whether the lock hasn’t been initialized, and is in pairing mode. If the condition is met, an error is returned and the operation is aborted.
  3. The third condition checks whether the result from get_permission_level is not 1, 2, 3, or 4. The value would be 1 or 2 if this command was preceded by a successful call to check_amdin, and 3 or 4 if it was preceded by a call to check_user_time. If it was preceded by neither, an error is returned and the operation is aborted.
  4. The fourth condition compares the value of the 1st 4 bytes of the message buffer to the challenge value (the unlock key plus the random 16-bit value generated by the lock earlier). If the values match, the unlock operation is performed (the actual code for that is in unlock_implementation).

There are two commands that perform authentication and authorization (required for step 3): check_user_time (0x55), and check_admin (0x41). The first command, check_user_time, simply validates that the current time set in the lock is between the 2 provided values (and if so, sets the permission level to 3). The second command, check_admin, compares the provided 4-byte key, referred to as adminPs, matches the key stored in the lock (and if so, sets the permission level to 1). Both of these messages produce a response that contains a randomly generated 16-bit integer we call the challenge. Any command that requires authorization, will expect a challenge response to be contained in the message. The challenge response is the sum of the challenge value and a predetermined shared secret, we call the unlockKey (it’s generated by the lock during the initialization procedure). An example of the challenge response verification can be seen in step 4 above.

There are just several commands that don’t require prior authorization - most do. And among those commands that require authorization, very few accept the user permission level as sufficient - namely, the locking and unlocking commands. The rest require authorization via the check_admin command. Examples of commands that require this privilege level are:

  • Fingerprint management commands
  • RFID management commands
  • PIN management commands
  • DFU initiation command
  • etc.

Wireless keypad functionality

In addition to the multitude of commands supported by the lock inside the huge switch case described above, there is another check that is performed at the start of the handler function. The Encrypt field (offset 10) in the BLE message is checked, and if its value is 0xa0 (as opposed to 0xaa used for messages coming from the TTLock app), the message is parsed by a completely different function, responsible for communication with a wireless keypad.

That function supports two different values for the command field of the message (the 10th byte):

  • 0x70: wireless keypad pairing/initialization
  • 0x71: wireless keypad message (encrypted, contains the keys pressed)

The contents of the pairing command (0x70) are always decrypted with a hardcoded AES key, and if the decrypted message reads SCIENER, a new AES key is generated and used for further communications (i.e., for decrypting 0x71 message):

if (data[9] == 0x70) {
  if (data_size < 0xb) {
    return;
  }
  i = memcmp(&data[0xc], "SCIENER", 7);
  if (i != 0) {
    return;
  }
  i = memcmp(NULL, data + 0x13, 4);
  if (i == 0) {
    return;
  }
  [...]
                  /* Generate wireless keypad AES key */
  generateRandomNumReverseParams(&pContext[1].field_0x13,0x10);
  memcpy(response_buffer + 3,&pContext[1].field_0x13,0x10);
  response_buffer[19] = (int)((uint)(byte)PTR_DAT_0001d110[0x13] << 0x1b) < 0;
  build_response_packet_for_wireless_keypad(data[10],1,response_buffer,0x14);
  return;
}

The regular wireless keypad message (0x71) can contain one or more keypresses (in fact, they are simply represented as an array of bytes in the message, where each byte represents a key press). It is encrypted using the AES key generated during the last pairing request (command 0x70).

Gateway firmware analysis

The lock system contains a gateway device, that is intended for controlling the lock over the Internet. The device connects to the Internet over WiFi (G2) or Ethernet (G3), and communicates with the lock over BLE. When a gateway is in use, the lock owner can lock and unlock the door remotely, without direct access to the lock (the TTLock app communicates with the gateway over the Internet, and the gateway then transmits the relevant commands over BLE).

In our case, we had a Gateway G2 (WiFi version) device on hand, and as we opened it up, we found that it’s based on the same chip as the lock, TLSR8251 (marked as U1 on the board). It also uses an ESP-12S for the WiFi connection, but that chip is used mainly for its WiFi stack, and the rest of the logic resides on the TLSR8251 chip.

Gateway G2 PCB

Getting the hands on the firmware (revisited)

While getting the firmware of the lock was easy enough via the update server, we had no such luck with the gateway firmware. No firmware updates were available, no matter what firmware we reported to the update server. Therefore, we had to revert to extracting the firmware from the device itself.

To assist with firmware extraction, we used the official Telink Debugger. It’s a small device, used for debugging various Telink chips, available for purchase at AliExpress for about $36:

Telink Debugger

Despite the architecture’s similarity to ARM, The debugger uses a proprietary protocol to connect and debug various Telink chips, and not SWD. Ironically, it is also called Single Wire. And, in fact, it is closer to being single-wire than the original ARM debugging protocol. Whereas SWD uses a single wire for I/O (SWDIO), and another wire for timing (SWDCLK), Telink’s version uses a single wire for everything. As a matter of fact, if the device is powered, you don’t even have to connect VCC and ground, and a single wire between the debugger’s SWM (Single Wire Master) pin to the chip’s SWS (Single Wire Slave) pin is all that it takes to debug the chip.

The debugger device is used in conjunction with a specialized software suite by Telink, called BDT. It comes either as a Windows application, or a web app that communicates with the debugger over USB. The software has support for core debugging functionality, including:

  • Full memory access
  • Full special register access
  • Full flash access (i.e., reading existing firmware and writing new firmware)
  • Ability to pause and resume the MCU’s execution at any time

There were a couple of omissions in the functionality, that made debugging the lock and gateway devices quite a bit more challenging. Namely, there is no support for breakpoints, and no way to access the general-purpose registers on the MCU (except the program counter, PC). So, what would we do when we wanted to debug the firmware and analyze some values at a certain address? We simply replaced the opcode at the address with a jump to a free section in memory, where we put in code that pushes the general-purpose registers of interest onto the stack and goes into an endless loop. Then, we waited until the device stopped responding (meaning it went into the endless loop). We could verify this by making sure the value of the PC register matched our expectations, and then we’d read the general-purpose registers from the stack memory.

Analyzing in Ghidra (revisited)

Armed with the experience of analyzing the lock’s firmware, understanding the inner workings of the gateway firmware proved a breeze. Since the gateway uses the same chip, and the firmware is based on the same SDK, we could easily identify many functions that were shared between the lock and the gateway. Thus, we quickly arrived at the encryption mechanism, and found that the gateway would generate a new encryption key upon connecting to the Internet. In fact, the whole process was very similar to that used by the lock to generate the encryption key for BLE communications: a hard-coded AES key is used in the beginning of communicating with TTLock’s server, and a special command is sent to the server to switch to a different, randomly generated key.

Analyzing live traffic

To support the static analysis we performed on the gateway communications, we chose to record the actual traffic between the gateway and TTLock’s server. We accomplished this by using socat to create a tunnel that we could monitor. In theory, a wireless router with proper sniffing functionality could be used, as well.

< 2023/07/06 16:55:35.000032887  length=29 from=0 to=28
    SYN
 70 5a 00 00 00 00 00 00 00 00 0f 7f 5a 00 00 00 00 00 00 00 45 01 00 ad 0d 0a b8 0b 0c
> 2023/07/06 16:55:35.000072462  length=106 from=0 to=105
    ACK, ???
 71 5a 00 01 f6 8e 20 ab 22 bf 1f 7f 5a 05 03 02 00 01 00 01 45 55 10 7a 3c f9 52 ab bd cd a8 76 9e 66 cd b4 76 4d 77 64 0d 0a db 0b 0c 71 5a 00 01 f6 8e 20 ab 22 bf 2f 7f 5a 05 03 02 00 01 00 01 24 55 20 e6 56 46 27 be 81 dd 56 6b 49 e2 5a ec 29 5e 01 ad f4 ff 6f 1f 4e f2 5f d5 0e ce 1b 0f 16 d7 40 1d 0d 0a e7 0b 0c
> 2023/07/06 16:55:35.000262959  length=125 from=106 to=230
 70 5a 00 01 f6 8e 20 ab 22 bf 6f 7f 5a 05 03 02 00 01 00 01 0d 55 60 b0 55 f7 0d 53 c8 07 4c 3a 6d c7 5f 43 46 ca bb bf 75 69 86 27 cd 05 4f 10 cb 81 cc c3 9d f3 aa 9b 11 d2 08 86 5c c4 8b e4 b9 91 9a 9a 5e 1d af 02 0c 3d 69 e0 4c 38 c9 26 f9 c4 10 c9 a3 61 50 05 ff 8b ee 43 ea ac 7b ce fd 36 73 e9 9b ac eb da 92 c4 29 ee 1b dc 05 dc 0f 24 e4 1d fc a1 3d c5 0d 0a ec 0b 0c
< 2023/07/06 16:55:35.000380573  length=45 from=29 to=73
 71 5a 00 01 f6 8e 20 ab 22 bf 1f 7f 5a 05 03 02 00 01 00 01 45 55 10 7a 3c f9 52 ab bd cd a8 76 9e 66 cd b4 76 4d 77 64 0d 0a db 0b 0c
< 2023/07/06 16:55:35.000381003  length=61 from=74 to=134
 71 5a 00 00 f6 8e 20 ab 22 bf 2f 7f 5a 00 00 00 00 00 00 00 24 01 20 f6 b1 ac a7 43 3f 93 84 88 6d 50 f2 d0 19 64 85 98 c8 9f 39 32 d4 75 96 e5 f1 f4 75 e4 0a 75 a5 06 0d 0a 4f 0b 0c
< 2023/07/06 16:55:35.000918828  length=45 from=135 to=179
 70 5a 00 00 f6 8e 20 ab 22 bf 1f 7f 5a 00 00 00 00 00 00 00 19 01 10 81 02 8f c4 78 b5 a5 b5 2a 32 6f 8f dc 2c df ba bd 0d 0a 2d 0b 0c
> 2023/07/06 16:55:35.000955182  length=61 from=231 to=291
    Message from gateway, setting the new key to 5b863403ee8eb102058646357266c1fb:
 70 5a 00 01 f6 8e 20 ab 22 bf 2f 7f 5a 05 03 02 00 01 00 01 19 55 20 be 67 d1 2f 2c a4 db b8 37 4f 03 58 c2 97 f4 be 8d 15 7e 17 33 34 78 07 ac d3 4a 5a 67 63 3d 2b 70 0d 0a 34 0b 0c
< 2023/07/06 16:55:37.000145467  length=45 from=180 to=224
    ACK
 70 5a 00 00 f6 8e 20 ab 22 bf 1f 7f 5a 00 00 00 00 00 00 00 0d 01 10 69 3e 2d c7 ed 54 4f d2 41 91 e9 f3 d6 d3 e1 22 6f 0d 0a a2 0b 0c
< 2023/07/06 16:55:40.000253482  length=29 from=0 to=28
    SYN
 70 5a 00 00 00 00 00 00 00 00 0f 7f 5a 00 00 00 00 00 00 00 45 01 00 ad 0d 0a b8 0b 0c
> 2023/07/06 16:55:40.000292935  length=106 from=0 to=105
    SYN ACK; Version (SCIENER\x0c6.0.0.22....)
 71 5a 00 01 f6 8e 20 ab 22 bf 1f 7f 5a 05 03 02 00 01 00 01 45 55 10 f3 4c 33 1e 3e 0d cf ca 99 01 be 03 19 52 cd cf 7a 0d 0a db 0b 0c 71 5a 00 01 f6 8e 20 ab 22 bf 2f 7f 5a 05 03 02 00 01 00 01 24 55 20 17 ba e8 ab d1 27 eb 0f 56 12 ce 21 d3 67 e2 0e 26 35 dd b8 8c 7a c3 54 e4 b0 7f 94 b4 bb ad 6f 2a 0d 0a e7 0b 0c
< 2023/07/06 16:55:40.000915884  length=45 from=29 to=73
    ACK
 71 5a 00 01 f6 8e 20 ab 22 bf 1f 7f 5a 05 03 02 00 01 00 01 45 55 10 f3 4c 33 1e 3e 0d cf ca 99 01 be 03 19 52 cd cf 7a 0d 0a db 0b 0c
< 2023/07/06 16:55:40.000918002  length=61 from=74 to=134
 71 5a 00 00 f6 8e 20 ab 22 bf 2f 7f 5a 00 00 00 00 00 00 00 24 01 20 a4 7d 70 d3 7f 15 cb 2b 77 a2 9d 5a 0c 99 31 5d f4 77 c0 a2 28 1d 5d 73 82 d3 f0 98 ac 63 e1 f0 7f 0d 0a 72 0b 0c
> 2023/07/06 16:55:42.000721979  length=45 from=106 to=150
    Lock info (MAC, Protocol, some extra adv. data)
 71 5a 00 01 f6 8e 20 ab 22 bf 1f 7f 5a 05 03 02 00 01 00 01 0b 55 10 eb 21 62 78 f0 6e 6d e6 1f bb ae 8b 48 7c 78 65 e3 0d 0a db 0b 0c
> 2023/07/06 16:55:46.000321805  length=45 from=151 to=195
    Lock info (MAC, Protocol, some different adv. data)
 71 5a 00 01 f6 8e 20 ab 22 bf 1f 7f 5a 05 03 02 00 01 00 01 0b 55 10 08 6e 7a 71 14 53 46 32 59 b4 f8 b0 f8 e7 cf 5f f6 0d 0a db 0b 0c

As we could understand the structure of the messages based on the staic analysis, it was very clear that the communication protocol was rather similar to the BLE communications between the lock and the phone:

Offset Description Length Value
0 Key type 1 0x70: hardcoded, 0x71: generated
1 Header 2 0x5a, 0x00
3 Direction 1 0x01: to server, 0x00: to gateway
4 Gateway MAC 6 Gateway MAC address
10 Length 1 length of the payload
11 Payload 0-255 payload
-4 (from end) CRC 2 The CRC-16 of this message
-2 (from end) Footer 2 0x0b, 0x0c

Emulating a gateway

Now that we understood the gateway protocol, we could emulate it in code. To do this, we used Python. In fact, we wrote code to emulate both the gatway itself (that allowed us to converse with the TTLock server and be used in attacks), and the TTLock server (in order to test whether malicious responses could cause the gateway to malfunction). Here is an excerpt of the gatway emulation code, that simulates a gateway that was reset and paired to a new user:

class GatewayEmulator:
    [...]

    def _login_sequence(self):
        # Expected: ACK/echo (empty data)
        self._read()

        # Ping (any data)
        self._write(0x45, b"")
        # Expected: Pong (any data)
        self._read()

        # Version data
        self._write(0x24, b"SCIENER\x0c6.0.0.0")
        # Expected: SCIENER
        self._read()

    def _generate_key(self):
        # Generated key
        self._write(0x19, b'\x01' + GENERATED_AES_KEY)
        # Expected: CMD 0xd :: OK (key set)
        self._read()

    def _report_nearby_locks(self):
        # Nearby lock info (MAC, protocol version, extra adv. data): b"7d13ba99fa15030101b15430"
        while True:
            self._write(0x0b, LOCK_MAC + binascii.unhexlify("030101b15430"))
            self._write(0x0b, LOCK_MAC + binascii.unhexlify("010101") + random.randbytes(3))
            # # Expected: CMD 0x25 :: 0xffff (lock registered???)
            if select.select([self._socket], [], [], 0.1)[0]:
                envelope = self._read()
                if envelope.message.command == 0x25:
                    break

    def _respond_loop(self):
        while True:
            if select.select([self._socket], [], [], 0.1)[0]:
                envelope = self._read()
                if envelope.message.command == 0x41:
                    self._write(0x54, bytes([0x41, 0x01, 0x00, 0x00, 0x00, 0x00]), 0x71, LOCK_MAC)
                elif envelope.message.command == 0x60:
                    self._write(0x54, bytes([0x60, 0x00, 0x03]), 0x71)
                    break

    def emulate(self):
        self._login_sequence()
        self._generate_key()
        self._report_nearby_locks()
        self._respond_loop()
        self._socket.close()

    def replacekey(self):
        self._login_sequence()
        self._generate_key()
        self._socket.close()

The main function that emulates the gateway is emulate, that performs the following actions:

  1. Connects to the TTLock server and performs the login sequence:

    1. Read a message with an empty payload
    2. Send a message with an empty payload
    3. Read a message (discard the payload)
    4. Send a message with version info
    5. Read a message with the payload SCIENER
  2. Generates a new AES key
  3. Reports any nearby locks (their MAC addresses)
  4. Keep responding to commands from the server (typically, these commands will be intended for the lock)

Sharp-eyed readers will notice the nefariously called replacekey method - it will be discussed in the next post about the vulnerabilities we discovered. Stay tuned! The next part is ready, enjoy!