逆向工程CMF Watch Pro 2的BLE协议,记录了GATT布局、AES-128-CBC加密命令帧、认证握手以及健康数据同步,用于开发替代配套应用。
非官方。 本文档描述了 CMF Watch Pro 2(CMF by Nothing)的蓝牙低功耗(BLE)协议, 通过逆向工程重建,用于替代配套应用。它与 Nothing/CMF 无关联,也未获其认可。使用风险自负。
帧头和操作码中的所有多字节整数均为大端序。 命令载荷内部的整数除非另有说明,否则为小端序
(这与设备固件一致)——请注意例外情况(GOALS_SET、GPS_PUSH、批量传输的偏移量/长度为
大端序)。
以下每项非显而易见的声明都标注了其确定方式:
如果后面的章节纠正了前面的内容,前面的文本会保留并附上指引,而不是删除——了解哪些解读 已被尝试并推翻,可以为下一个人省去同样的弯路。
所有捕获的测试设备:CMF Watch Pro 2-5485,固件 1.0.0.73,序列号 CI04102520008192,
MCU Actions ATS3089C(Cortex-M4),屏幕 466×360。
手机是 GATT 客户端;手表是外设,广播名为 CMF Watch Pro 2-XXXX(4 个十六进制字符)。
| 用途 | 服务 | 特征 | 属性 |
|---|---|---|---|
| 命令 写入 | 0000fff0-0000-1000-8000-00805f9b34fb | 0000fff2-… | 写入 |
| 命令 通知 | 0000fff0-… | 0000fff1-… | 通知 |
| Shell 写入(AT) | — | 77d4ff01-2fe2-2334-0d35-9ccd078f529c | 写入 |
| Shell 通知(AT) | — | 77d4ff02-… | 通知 |
| 批量 数据写入 | — | 02f00000-0000-0000-0000-00000000ffe1 | 写入 |
| 批量 数据通知 | — | 02f00000-…ffe2 | 通知 |
通过向每个 CCCD(00002902-…)写入 01 00 来启用通知。命令通道(fff1/fff2)
承载下述帧协议。Shell 通道(77d4…)承载纯 AT 风格文本(例如 AT GETSECRET;见 §14)。
数据通道(02f0…)承载大型二进制数据块(表盘、固件、AGPS),由命令通道上的控制操作码协调。
服务 UUID — 手表广播约 10 个主要服务。在真实设备上枚举得到:
0xfff0(命令)、0x180f(电池)、0x180a(设备信息)、0xefe7、0xffd0、
02f00000-…ffe0 和 02f00000-…fe00(数据)、77d4e67c-2fe2-2334-0d35-9ccd078f529c
(Shell / 配对)、e49a3001-f69a-11e8-8eb2-f2801f1b9fd1、f48a23c0-f69a-11e8-8eb2-f2801f1b9fd1。
⚠️ Shell 服务 UUID 是
77d4e67c-…,而非77d4ff00-…。本文档的早期修订版 假定该服务与其特征(77d4ff01/77d4ff02,§14)共享ff00前缀——事实并非如此, 至少在本次检查的设备上是这样(发现来自 freethinkel/fmc, 见 §来源)。特征 UUID 未变。尚未验证77d4e67c在不同设备间是否稳定——请枚举而非硬编码。
🌐 Web Bluetooth 提示。 Chromium 只会发现页面在
optionalServices中列出的服务, 即使是对未过滤的getPrimaryServices()调用也是如此——列出 3 个服务的页面只能看到 3 个, 而chrome://bluetooth-internals(Chrome 自身的 C++ 层,无作用域限制)会显示全部 10 个。 如果你编写浏览器客户端,请提前列出上述每一个 UUID,否则配对会因明明存在的服务而失败。 Firefox/Safari 不支持 Web Bluetooth;需要用户手势 + HTTPS/localhost。
✅ 整个真实会话仅在单一命令通道上运行——在一次 160 秒的高强度使用捕获中,除显式的 OTA/表盘传输外,数据/固件或 Shell 通道上没有流量。
0xF5)每条命令通道消息都被封装在一个或多个 11 字节头的帧中:``` +------+-----------+--------+-------------+-------------+--------+-------------------+ | 0xF5 | chunkLen | cmd1 | chunkCount | chunkIndex | cmd2 | chunk bytes … | | 1 B | 2 B (BE) | 2 B BE | 2 B BE | 2 B BE | 2 B BE | chunkLen bytes | +------+-----------+--------+-------------+-------------+--------+-------------------+ __________________________ 11-byte header ____________________________/
- `cmd1`/`cmd2` 共同构成**操作码**(见 §6)。🔎 已对照官方应用的数据帧构建器(`C6117b.m30831g`)确认。
- `chunkCount` = 该命令的总分块数;`chunkIndex` 从 **1** 开始计数。
- `chunkLen` = 本帧中 `chunk` 的字节数。
- 单次 BLE 写入可能因链路 MTU 而被分片;接收端会缓冲原始字节并重新提取完整帧。大载荷会被拆分为多个分块(`cmd1/cmd2` 相同,`chunkIndex` 递增)并按顺序重组。
### 操作码约定(✅ 已在线上确认)
- `cmd1 = 0xFFFF`:`cmd2` 为 `0x80xx`/`0x90xx` 时 = 手机→手表(请求/设置);为 `0x00xx`/`0xa0xx` 时 = 手表→手机(应答)。配对通过低字节匹配(`0x9055`↔`0xa055`,`0x8051`↔`0x0051`)。
- 特定功能的 `cmd1`:`cmd2` 后缀 = `0x0001` **SET**(设置)、`0x0002` **GET**(获取)、`0x0003` **ACK**(确认)。
### 分块主体
每个分块的主体为 `payloadPiece ‖ CRC32_LE(payloadPiece)`(4 字节 CRC,小端序,zlib/IEEE)。如果命令是**加密**的(见 §3),则整个 `payloadPiece ‖ CRC` 会再经过 AES-128-CBC/PKCS7 加密,该密文即成为帧的 `chunk`。
**明文怪癖:** 对于明文操作码,手表在 `chunkLen` 中*计入* 4 字节 CRC,但**不会**传输它。因此解码明文帧时,实际数据长度为 `chunkLen − 4`。(加密帧照常将 CRC 包含在密文内部。)
分块大小(使加密分块落在 AES 块边界上),其中 `maxWrite = mtu − 3`:
- 加密:`floor((maxWrite − 11) / 16) * 16 − 4 − 1`
- 明文:`maxWrite − 11 − 4 − 1`
✅ 所有观察到的加密帧 `chunkLen` 值均为 16 的倍数(块对齐成立)。
---
## 3. 加密原语
- **AES-128-CBC**,采用 **PKCS7** 填充和**固定 IV**(来自固件 `CmfCharacteristic.AES_IV`):
`50 51 52 53 54 55 56 57 60 61 62 63 64 65 66 5A`。
- **CRC32**(zlib/IEEE),以 4 个字节的小端序输出。
- **SHA-256**,作用于各部分的拼接结果。
密钥派生:```
authkey = SHA256( rnd1 ‖ rnd2 ‖ secret )[0..16] // persisted across sessions
sessionKey = SHA256( nonce ‖ authkey )[0..16] // per connection
secret = 16 字节设备密钥(可通过 shell 命令从手表获取
AT GETSECRET → GETSECRET:<32-hex>,OK)。rnd1 = 手机选择的 16 个随机字节;rnd2 = 来自手表的 16 个随机字节。nonce = 来自手表 nonce 回复的字节。密钥设置后,除 §5 中列出的明文操作码外,所有命令通道帧均经过 AES 加密。
✅ 两种派生方式均已验证:从已 root 手机的 ntwatch.db 中恢复的 authkey 与从捕获的 rnd1/rnd2/secret 派生的值匹配;从捕获的 nonce 复现的 sessionKey 可解密实时帧。
两条入口路径共享相同的 nonce/confirm 尾部。
phone → (shell) AT GETSECRET watch → (shell) GETSECRET:<32hex>,OK phone: rnd1 = random16 ; signed1 = SHA256(rnd1 ‖ secret) phone → AUTH_PAIR_REQUEST (plaintext) payload = rnd1(16) ‖ signed1(32) // 48 B watch → AUTH_PAIR_REPLY (plaintext) payload = rnd2(16) ‖ signed2(32) // 48 B phone verifies signed2 == SHA256(rnd2 ‖ secret) phone: authkey = SHA256(rnd1 ‖ rnd2 ‖ secret)[0..16] → set crypto key = authkey phone → AUTH_PHONE_NAME (encrypted) payload = 0xA5 ‖ model(UTF-8) // e.g. "CMF Watch Pro 2" watch → AUTH_WATCH_MAC (encrypted) phone → AUTH_NONCE_REQUEST (encrypted) payload = 0xA5 watch → AUTH_NONCE_REPLY (encrypted) payload = nonce phone: sessionKey = SHA256(nonce ‖ authkey)[0..16] → set crypto key = sessionKey phone → AUTHENTICATED_CONFIRM_REQUEST (encrypted) payload = 0xA5 watch → AUTHENTICATED_CONFIRM_REPLY (encrypted) → state = Initialized
在 `AUTH_FAILED (0xFFFF,0xA061)` 或签名不匹配时,身份验证失败。
### 4.2 重新连接(authkey 已知)```
set crypto key = authkey (persisted)
phone → AUTH_PHONE_NAME (encrypted) payload = 0xA5 ‖ model
watch → AUTH_WATCH_MAC (encrypted)
phone → AUTH_NONCE_REQUEST (encrypted) payload = 0xA5
watch → AUTH_NONCE_REPLY (encrypted) payload = nonce
sessionKey = SHA256(nonce ‖ authkey)[0..16] → set crypto key = sessionKey
phone → AUTHENTICATED_CONFIRM_REQUEST (encrypted) payload = 0xA5
watch → AUTHENTICATED_CONFIRM_REPLY (encrypted) → Initialized
✅ 重连顺序(无 shell 流量)在真实抓包中观察完整。
⚠️→✅ 数据查询前必须先发送
TIME。 在Initialized之后,手表在会话中收到TIME (FFFF 8004)之前不会响应BATTERY、SERIAL_NUMBER_GET或ACTIVITY_FETCH_*握手——没有它, 只会收到一条主动上报的FIRMWARE_VERSION_RET,其余全部超时。✅ 已在 Pixel 8a 上实测确认: 不发送TIME直接发送三个 GET → 仅固件回复;先发送TIME→ 电量 和序列号开始回复。
推荐的阶段 2 顺序:TIME → FIRMWARE_VERSION_GET → SERIAL_NUMBER_GET →
BATTERY (0xA5) → 配置推送 → 健康同步(§8)。
大多数设置没有独立的“读取”操作码。发送 *_GET(cmd2 = 0x0002,载荷
0xA5)会使手表以 SET 操作码回复(cmd2 = 0x0001)并携带当前值。
SET 命令以 cmd2 = 0x0003 和空主体确认。
一旦设置密钥,帧即进行 AES 加密,但以下操作码除外,它们始终为明文:
AUTH_PAIR_REQUEST (FFFF 8047)、AUTH_PAIR_REPLY (FFFF 0048)DATA_CHUNK_WRITE_WATCHFACE (FFFF 9064)、DATA_CHUNK_WRITE_FIRMWARE (FFFF 9042)、
DATA_CHUNK_WRITE_AGPS (FFFF 905F)帧头(cmd1/cmd2)始终明文传输,因此即使没有密钥,任何抓包中命令序列也可见——只有加密的载荷需要 sessionKey。
(cmd1, cmd2)GET/SET/REQUEST = 手机→手表;RET/REPLY/ACK/RESPONSE/DATA = 手表→手机。
| 名称 | cmd1,cmd2 |
|---|---|
| TIME | FFFF 8004 |
| FIRMWARE_VERSION_GET / _RET | FFFF 8006 / FFFF 0006 |
| SERIAL_NUMBER_GET / _RET | 00DE 0002 / 00DE 0001 |
| BATTERY | 005C 0001 |
| TRIGGER_SYNC | 005C 0002 |
| USER_INFO_SET / _RET 🔎✅ | 0095 0001 / 0095 0003 |
| FACTORY_RESET | 009A 0001 |
| DEVICE_REBOOT 🔎 | FFFF 9080 |
| RESOLUTION_GET 🔎 (→ 466×360) | FFFF 907F |
| GPS_PUSH / _RET | FFFF 906A / FFFF A06A |
| UNBIND_SET / _RET | FFFF 907A / FFFF A07A |
| 名称 | cmd1,cmd2 |
|---|---|
| AUTH_PHONE_NAME | FFFF 8049 |
| AUTH_WATCH_MAC | FFFF 0049 |
| AUTH_PAIR_REQUEST / _REPLY | FFFF 8047 / FFFF 0048 |
| AUTH_NONCE_REQUEST / _REPLY | FFFF 804B / FFFF 004C |
| AUTHENTICATED_CONFIRM_REQUEST / _REPLY | FFFF 804D / FFFF 0004 |
| AUTH_FAILED | FFFF A061 |