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.
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.
The lock, with its premium look (and a matching price), offers many features (that, in our case, translate into many attack surfaces):
Additionally, the TTLock app offers the following (non-exhaustive) list of features:
Limiting the use of virtual keys:
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.
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.).
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:
esp_base_mac_addr_set(uint8_t *mac)
.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.
For the ESP-IDF version, we based our code on the BLE examples Espressif provide with their SDK. Specifically, we used:
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.
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.
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.
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:
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.
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 );
}
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:
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:
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.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;
[...]
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.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:
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/initialization0x71
: 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
).
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.
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:
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:
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.
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.
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 |
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:
Connects to the TTLock server and performs the login sequence:
SCIENER
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!