逆向小米运动健康:从抓包到生成 FIT 文件

逆向小米运动健康:从抓包到生成 FIT 文件

· json · rss
Subscribe:

About

起因

我用小米手环记录运动已经快两年了。某天想把骑行数据导进 Strava 做路线分析,才发现问题:官方 App 的导出能力相当有限,而云端其实存着远比界面展示更丰富的原始数据——逐秒心率、速度、踏频、GPS 轨迹、甚至游泳每一趟的配速和划水次数,全都躺在服务器上,只是没有出口。

于是我决定自己做这个出口。最终成果是一个本地 Web 应用 ~/mifit-web(Flask + 离线自包含前端),能直接对接小米运动健康后端,拉取 → 解密 → 解析 → 生成标准 .fit 文件,也能在网页上看完整的数据分析和轨迹地图。

这篇文章记录整个逆向过程——包括那些让我卡了好几个小时的坑。如果你也在做类似的 App 数据自救,希望这些经验能省你一些时间。

先说结论:这套流程目前跑通了 416 条运动记录,覆盖 2024-10 到 2026-09 共 23 个月18 种运动类型,解析出的时长、心率、卡路里与官方报表零条不匹配

第一层:抓包

目标 App com.xiaomi.miwatch.pro,后端 hlth.io.mi.com。iOS 上我用 HTTP Catcher 抓包,产物是 .hcf 文件。

坑:一条请求被拆成多条记录

解析 .hcf 时第一个意外是:HTTP 头在一条记录里,加密参数的表单却在紧随其后的另一个分片里。记录头格式是:

00 00 0c 05  (请求) / 00 00 0c 08  (响应)
+ reqseq(4) + ts(8, 毫秒) + paylen(4)

坑:绑定顺序必须按「文件位置」,不能按「时间序」

要把请求行和它的参数表单配对。我一开始按时间顺序配对,签名校验率只有 47%——因为相邻请求的表单会互相串位。

改成按文件位置划窗口,再用「哪个候选的 data 能解出合法 JSON」来定绑,校验率立刻升到 79%

还有个很隐蔽的坑:提取 _nonce 的正则如果写成 _nonce=([^\x00\r\n ]+),会贪婪地吞掉后面整个查询串——实测 nonce 变成了 287 个字符。结果是所有候选都不满足「nonce 长 12/16」而被丢弃,表现为「表单 0 个、请求 0 条」,可正则明明匹配到 21 处。正确写法必须排除 &

_nonce=([^\x00\r\n &]+)

另外 token 有域的概念:抓包里会同时出现两个 serviceToken——hlth 域的约 172 字符,api.io.mi.com 的约 216 字符。取错就一直是 auth err

第二层:签名算法

小米这套接口的每个请求都带三个加密参数:data(业务数据密文)、rc4_hash__signature。要自己构造请求,必须完全复现它的算法。反编译 APK 后我一点点把逻辑拼了出来,其中最要命的一条是这个:

致命坑:RC4 必须跳过前 1024 字节 keystream

esj 类的构造函数结尾有一句 b(f17415b),而 f17415b 是一个 new byte[1024] 的全零数组——等于用 1024 个 0x00 去预热 RC4 状态机。

所以实际 keystream 是从第 1024 字节开始的。如果按标准 RC4 从 0 开始取,得到的是高熵垃圾,还会误判成「security 不对」,一路往错误方向排查。这是整件事里最大的时间黑洞。

另外三个容易搞错的点

  • t12.i() 是标准 Base64,不是 hexdigest。判据:signature 密文 28 字符(= 20 字节 SHA1),rc4_hash__ 密文 40 字符(= 28 字节)。如果你算出 40 字符的十六进制串,说明用错了 hexdigest()
  • 一条请求内多个参数共享同一条 RC4 keystream(连续累加)。y54.cnew esj(strB) 一次,然后按 TreeMap 顺序切分:dataK[0:n]rc4_hash__ 接着 K[n:]。只单独解 data 看不出问题,校验第二个参数时才会暴露。
  • rc4_hash__ 用明文 kv 算,signature 用密文 kv 算。两者都不包含 _nonce / signature / rc4_hash__;而 data 自身不在签名 kv 里,是以字面量 data=<值> 的形式拼进签名串的。

完整算法

raw32 = SHA256( b64decode(security) || b64decode(_nonce) )   # 32 字节
strB  = base64encode(raw32)

# y54.c —— GET/POST 通用(注意:不是 y54.d)
rc4_hash__ = b64(SHA1( METHOD & path & "k=v"按key排序 & strB ))   # 明文 kv
rc4 = RC4(raw32, skip=1024)                                    # 整条请求只建一次!
for k in sorted(treeMap):
    out[k] = b64( rc4.xor(utf8(treeMap[k])) )
signature = b64(SHA1( METHOD & path & "k=v"按key排序 & strB ))   # 密文 out
out["_nonce"] = nonce

_nonce 的构造是 base64( 8字节随机 + int32_be((now+timeDiff)//60000) ),共 16 字符——末 4 字节是大端分钟数,正好可以用来核对抓包时间。

还有个必须知道的事实:security 会随登录变化,必须用与目标请求同时段的那个。来源是响应头 extension-pragma 里的 ssecurity,或登录响应 JSON 的 data.ssecurity

第三层:三个把 401 变 200 的关键

算法复现对了(重新加密能和抓包原值逐字符比对一致),但请求发出去仍然是 401。又踩了三个坑才通:

A. 请求参数是「单个 data 字段」,值是整段 JSON 字符串

# ✓ 对
kv = {"data": '{"watermark":0,"limit":30}'}
# ✗ 错(会一直 401!)—— 把 JSON 字段拆成独立参数
kv = {"watermark": 0, "limit": 30}

拆开算签名,服务端一律返回 auth err。验证方法:用已知明文重新加密,和抓包原值逐字符比对,三个字段必须全对。

B. @Secret(pathPrefix="healthapp/") → 签名要用裁剪后的 subpath

规则在 CloudInterceptor.subpath:如果 pathPrefix 非空,就取 URL 中该子串出现位置之后的部分;为空则从第一个 / 起取。

URL   /healthapp/service/gen_download_url
签名  /service/gen_download_url        ← 用这个算 signature / rc4_hash__

同理 /healthapp/home/profiles/home/profiles。而 /app/v1/*/ops/v1/* 的 pathPrefix 为空,用完整路径。这一条解开后,401 立刻变 200。

C. 响应解密用「请求同一个 nonce」,且有三种形态

要按顺序试:① 明文 JSON(不加密的端点);② gzip 包着的 base64 密文(chunked 响应);③ 直接就是 base64 密文(非 chunked 响应,实时 API 常见)。

第四层:原始文件的加密

拿到接口还不够——真正的原始数据(逐秒心率、GPS 轨迹)是通过一个文件下载接口拿的,返回的是加密文件。我一度以为解不开,后来发现密钥就明晃晃地放在 gen_download_url 的响应里

{"result": {"<suffix>_<ts>": {
   "url": "https://cnbj1.fds.api.xiaomi.com/...",
   "obj_key": "[REDACTED]",   ← AES 密钥在这里
   "method": "GET"}}}

反编译确认了 AESCoder(String aesKey) { this(Base64.decode(aesKey, 8)); },于是:

key  = base64.urlsafe_b64decode(obj_key + '=' * (-len(obj_key) % 4))   # 16 字节
IV   = b'1234567887654321'          # AESCoder.getInitialVector() 硬编码
algo = AES/CBC/PKCS5Padding

ct  = base64.urlsafe_b64decode(txt + '=' * (-len(txt) % 4))
raw = AES_CBC_decrypt(key, IV, ct)  然后 PKCS5 unpad

每份文件的 obj_key 各不相同(GPS 文件和打点文件的密钥不一样),必须各自取用,不能复用。

坑:CDN 返回体有两种形态

① 裸 base64url,'=' padding 被剥掉        b'iLgArCVs8Jn...c'
② JSON 字符串(带双引号),同样无 padding  b'"4feFfXCJLHKw...q"'

.strip() 去不掉引号。而更阴的是——引号恰好让长度凑成了 4 的倍数,于是代码跳过了补 padding 的逻辑,解码时长度又少 2 → 抛 Incorrect padding

症状很有意思:一部分活动(乒乓球、腰部训练、钓鱼……)详情页直接报错,另一部分完全正常。原因是这两类活动走的分支不同。修法是先剥引号、再按真实长度补 =

第五层:二进制格式解析

解密后得到的是 schema 驱动的二进制。解析器在 APK 里是 com/xiaomi/fit/fitness/parser/schema/ 那一套。以 GPS 文件为例,头部 9 字节:

[0:4] timestamp u32LE   [4] timezone u8   [5] version u8
[6]   fileType   bitField: normalDataFlag(7) dataType(6..2) fileType(1..0)
[7]   encComp    bitField: encryptionMode(7..4) compressionMode(3..0)
[8]   validity   bitField: time(7) lon(6) lat(5) acc(4) speed(3) src(2) alt(1) hdop(0)

点字段必须按 schema 的固定偏移,不能顺次臆测

[0:4]   time                          u32LE
[4:8]   longitude                     f32LE
[8:12]  latitude                      f32LE
[12:16] locationHorizontalAccuracy    f32LE   ← 水平精度,不是海拔!
[16:18] locationSpeedAndGpsSource     u16LE   speed=(v>>4)/10 m/s, src=v&0x0F
[18:22] altitude                      f32LE
[22:26] hdop                          f32LE

我一开始把 [12:16] 当海拔,结果拿到的是水平精度——平坦路恒为 4.75m,定位野值点跳到 1448,看着像条曲线,其实毫无物理意义。这类「看着合理但完全错误」的 bug 最难发现。

核心认知:结构是变的,绝不写死字节数

同一个 App 导出的文件,因为 validityFlags 决定哪些字段被写入,布局逐个活动都不同

骑行徒步
GPS 点长18 B18 B
record 头部11 B13 B
record 点长7 B13 B
速度在哪record 里 (u16LE@4, ×0.1)record 里没有,只有 GPS 有
里程单位米 (÷1)0.1 米 (÷10)

所以解析器只能自适应探测,而关键是——用什么当校验信号?答案是官方报表。报表里的 distance / duration / avg_hrm / max_hrm / calories 就是标准答案:

  • GPS:枚举点长 P,要求 body % P == 0,且时间戳单调不减、坐标聚在一小片区域、速度物理合理。
  • record:枚举 (头部长度, 段头长度, 段头内偏移, 点长),要求段链恰好走到 EOF,再加「里程合计 ≈ 报表 distance」「点数 ≈ 报表 duration」「心率列均值/最大 ≈ 报表」三重打分。

坑:段链定位不能靠「第一个像时间戳的值」

record 文件是多个段拼接的,结构是「段头 + N 个点」循环直到 EOF。段头里 resumeTimestamp 是段的起始时间。

如果从 offset 0 开始扫「第一个像时间戳的 u32」,会命中段头里由掩码/里程高位拼出的伪值(实测 0x739e0000),于是把 814 秒的骑行算成 65011721 秒

阴险的地方在于:段链本身没坏,段数/点数/里程全对,只有时长崩了,极易漏掉。正确判据要三个同时成立:

vals = [u32le(head, off) for each seg head]
all(MIN_UNIX < v < MAX_UNIX for v in vals)      # 全部落在合理 unix 区间
all(b >= a for a, b in zip(vals, vals[1:]))      # 跨段非递减
all(vals[i+1]-vals[i] >= rcs[i]*0.95)            # 后段起点 ≥ 前段起点 + 前段点数

骑行与徒步实测都落在 offset 8

坑:duration 和 elapsed 是两回事

指标含义实测对齐
报表 duration实际运动秒数(不含暂停)= 文件点数徒步 12745 = 12745 ✓
end_time - start_time墙钟跨度(含暂停)骑行 1607 s ✓

end_time - start_time 去校验 duration 会误判成 bug(骑行 1001 vs 814,看着差 23%),其实两个都对。FIT 里分别对应 total_timer_timetotal_elapsed_time

第六层:游泳分段指标的破解

这是最近才攻下的部分,也是最花时间的一块。

小米 App 在游泳详情里会画出「分段配速」「分段划频」「分段 SWOLF」三张图。我想知道这些是本地算的还是服务端给的。反编译定位到 SportRecordConverter.java,它构造 SwimmingPassage 对象时读的是 oneSportRecord.getPace() / getSwolf()——来自服务端下发的 record 字段,不是本地计算的。wire 级字段名是 sectionPace / sectionSwolf / sectionStrokeFrequency

但把 get_sport_records_by_time 的完整响应翻遍,value 里只有汇总字段:avg_pacemax_paceavg_swolfbest_swolfstroke_countturn_count……没有任何 section 数组

关键线索来自 schema 文件 swimming_record_v3.json——它的 dataPoint 里明确声明了 sectionPace / sectionSwolf / sectionStrokeFrequency,而且字节大小是写明的

为什么按 schema 硬套永远对不上

APK 里那个 schema 声明的是 version 8.0,dataPoint 约 35 字节。而实际文件是 version 2、每小节 26 字节——字段增删过。按 schema 硬套只会一路错位,表现为 sectionDistance 读出 65280 这种荒谬值。

突破:放弃固定布局,改用「扫时间戳 + 值域筛选」重新同步

实测出来的 26 字节布局:

+0   endTime                  u32   本节结束时间
+4   dataType                 u8    0=小节 / 1、2=段落汇总
+5   sectionPace              u16   秒/百米       ★ 分段配速
+7   sectionSwolf             u16   SWOLF         ★ 分段 SWOLF
+9   sectionDistance          u16   本小节距离(米,=池长)
+11  activeCalories           u16
+13  totalArmPull             u16   本小节划水次数
+15  turnCount                u16   本小节转身次数
+17  sectionStrokeFrequency   u8    本小节划频     ★ 分段划频
+18  unknownStrokeFreq        u8
+19  breastStrokeFreq         u8
+20  freeStrokeFreq           u8
+21  backStrokeFreq           u8
+22  butterflyStrokeFreq      u8
+23  totalCalories            u16
+25  标志位                   u8

真正的障碍是:每约 4 个小节后跟一条变长的段落汇总记录(字段布局不同)。直接按固定 26 字节往前走,到那里就错位了。

解法是:扫描「4 字节时间戳(落在合理 unix 区间)+ 1 字节 dataType ≤ 8」的候选起点(并去除重叠),再用值域筛出真正的小节:1 ≤ 距离 ≤ 100 且 30 ≤ swolf ≤ 400 且 40 ≤ pace ≤ 1200

顺便破了 SWOLF 的算法

单趟用时(秒) = sectionPace × 池长 / 100
SWOLF        = round(单趟用时) + 划水次数

逐字节验证:id=14 第 0 趟,pace=216 × 25/100 = 54.0 秒,划水 14 次 → 54 + 14 = 68,正好等于文件里存的 swolf。

验证结果(与官方报表精确吻合)

id=14  44 小节  距离 1100/1100  转身 44/44  划水 602/602
       swolf 均 66.4/66   最佳 51/51
       pace  均 211/210   最快 158/158   最慢 316/316   划频 max 24/24
id=5   44 小节  距离 1100/1100  转身 44/44  划水 617/617
       swolf 均 71.1/71   最佳 54/54
       pace  均 228/228   最快 164/164   最慢 452/452

池长从 sectionDistance 的众数推出来——因为官方报表里 pool_width 是 0,表示用户没设置过。

第七层:不编造数值

逆向数据最容易犯的错,是「猜一个看起来合理的值填进去」。我给自己定了两条硬规则:

规则一:文件里没有的字段,一律返回 None

而不是填 0。因为 0 在运动数据里是有意义的真实值(静止时速度就是 0),拿它表示「缺失」会让下游无法区分。

具体到 UI:缺失的卡片直接隐藏,FIT 里不写零,页面明确标注「文件里没有此字段」。

规则二:列位置随类型变的字段,一律用官方值反查

心率列和卡路里列在不同运动类型里位置不同:

骑行     P=7 :心率 b1 | 距离 b3÷1  | 速度 b4(u16×0.1 km/h)
室内跑步 P=12:心率 b1 | 距离 b2÷10 | 步幅 b3(cm) | 步频 b9
徒步     P=13:心率 b1 | 距离 b3÷10 | 步幅 b4(cm) | 步频 b10
室内健身 P=4 :心率 b0 | 卡路里 b1 整字节
爬楼机   P=4 :心率 b0 | 卡路里 b1 整字节
椭圆机   P=3 :心率 b1 | 卡路里 b0 高 4 位

这里踩过一个很典型的坑:hr_col = detect(...) or 1列号 0 是合法值,但 0 or 1 == 1,结果室内健身和爬楼机的心率被解成 1/1。

另一个:卡路里探测写成「只有点长 ≥ 6 才读」,而爬楼机 P=4、椭圆机 P=3,结果是这些类型的卡路里永远是 0。改成用官方 calories 反查(逐点求和 ≈ 官方值 ±3%,优先取值多样的列),命中率:爬楼机 254/254、室内健身 7/7、椭圆机 242/242、室内跑步 323/323、徒步 1335/1335、骑行 95/96。

规则三:派生值不判对错,只说明

有些官方值是滤波/派生过的,和原始文件算出来的天然不同。比如「最大速度」:徒步原始 GPS 最大 17.64 km/h,官方只报 6.91;而骑行两者几乎一致(37.80/37.76、41.04/41.01、57.60/56.94,差 <1.5%)。

所以对照表里这类差异标蓝色「说明」而非红色叉号——对不上不代表解错了。

第八层:导出 FIT

一份完整的 FIT = GPS 文件 + record 文件,按时间戳合并。

  • 同一秒可能有多个 GPS 点,必须用 gps_by_t.setdefault(t, []).append(g) 全部保留,否则会丢掉约 69 个点(746 → 677)。
  • 只有打点、没有 GPS 的秒也要单出一条,否则丢心率。
  • distance 必须按时间轴单次累加:先把 newDistance 按时间累加成 dict,GPS 独有的秒沿用上一个累计值。分段累加会算成 4201m(超过报表的 4088m)。
  • FIT 的 speed 单位是 m/s;record 里是 km/h × 10,GPS 里是 m/s(÷10 后)——别混。

坑:FIT 纪元

所有 timestamp 必须减 631065600(FIT 纪元是 1989-12-31 UTC),否则解出来是 2046 年。这是最容易漏的一步。

产出校验(883 records,CRC 全部无错):

含GPS=746  含心率=866  距离末值=4088.0m  单调递增=True
心率 均126.1/最大171   速度最大37.80 km/h
session: cycling | 4088.0m | 1001.0s | 95kcal | HR 127/171

顺带解决:海拔补全

小米的 GPS 文件里不含海拔字段(实测导出都是 18 字节变体,只写到 speedAndSource),小米也没有「查任意坐标海拔」的接口。所以只能用第三方 DEM 按坐标补。

这里有个值得记的教训:数据集选错会让累计爬升虚高好几倍。

我最初用 ASTER GDEM,一条 250km 的骑行算出累计爬升 2989m。但对比别人相似路线的 800m 左右,差距太大。查下来原因是:ASTER 在珠三角冲积平原会把 8m 的城区读成 43m(+35m 伪影),250km 里被累加了 537 次。

换成 SRTM 后同一条路线是 1260m。地标真值也支持这个结论:顺德大良城区实际约 8m,ASTER 读 43m,SRTM/mapzen 读 14m;而皂幕山 805m,三套数据都给 796~798。

算法上也做了修正:累计爬升用滞回阈值 + 按距离 100m 等间隔重采样,而不是直接把所有正增益加起来(DEM 的整米阶梯噪声会被全算成坡)。并且报告一个不确定度区间(T=5/T=12 两端)而非单一数字——250km 骑行的区间是 983~1744m。

踩过的其他坑(速查)

  • 服务端单次请求最多回 50 条,且静默截断。区间 7d → 10 条,90d → 46 条,365d → 50 条,730d → 还是 50 条。必须分块拉取:块内返回 ≥ 48 就把区间对半细分重拉。我一开始只拉 30 天,以为账号里就 30 条记录,实际有 416 条、覆盖 23 个月。
  • days = int(x) or 90 是个陷阱。前端「全部」按钮传 days=0,被当成 falsy 静默变成 90 天。要显式判 None,再把 <=0 映射成上限。
  • 无 GPS 的运动类型要维护白名单。球类(台球/乒乓/羽毛球)、钓鱼、健美操、HIIT、腰部训练等,服务端不存 GPS,下载 404 是正常的。不列入白名单就会每次打开详情都报假警告。但攀岩有轨迹(实测 10982 点),不能一并排除。
  • DataPoint 数量 ≠ 时长。官方 duration 等于文件里的点数,但 end_time - start_time 包含暂停。
  • 室内跑步没有速度列,配速必须由每秒距离推(1000 / step_m);且全程配速要用全部秒数(即官方 duration 的语义),用「有位移的秒数」会偏快(6'01" vs 官方 6'29")。
  • 室内跑步的 b3 是步幅不是距离——当距离累加会得到 193075m(真实 5378m),错 36 倍。

验证方法论

回头看,整个逆向过程里最有价值的不是某个具体发现,而是这套验证习惯:

  • signature 当试金石。它对已知密文做哈希,所以只要猜中 security 就能立刻验证,不必等明文解对。比盲试解密快得多。
  • 必须跨参数校验。单独解开 data 不代表算法完整,只有同时校验 rc4_hash__ 才会暴露「共享 keystream」这类坑。
  • 终极闭环是重新加密。用算法加密已知明文,与抓包原值三方逐字符比对——全对才算真通了。
  • 官方报表永远是最好的标准答案。距离、时长、心率均值/最大、卡路里、速度——每一项都能当校验信号,比人工判断可靠得多。
  • 解析失败要当 bug 查,不能当噪声忍。Incorrect paddingsectionDistance=65280 这类异常,背后往往都是一个真实的结构性发现。

最终成果

现在这套东西是一个本地 Web 应用(Flask + 离线自包含前端,图表和地图都不依赖外网 CDN):

  • 记录列表:416 条 / 18 种运动类型,支持按类型筛选和分页
  • 详情页:心率/速度/海拔/爬升曲线 + Leaflet 轨迹地图 + 各运动类型的专有面板(跑步的分段配速/步频/步幅散点,游泳的分段配速/划频/SWOLF)
  • 数据对照表:解密值 vs 小米官方,逐项差异标注
  • FIT 导出:可直接导入 Garmin / Strava
  • 离线韧性:凭证过期时仍可用缓存的历史数据

整个解析链路的核心设计原则是自适应 + 用官方值校验,而不是写死任何字节数——因为同一个 App 导出的文件,布局本来就逐个活动不同。

如果你也在做类似的事,希望这篇能帮你绕过那几个最深的坑。尤其是那个 RC4 跳过 1024 字节——它真的浪费了我很多时间。


本文所述均为对自有账号数据的个人备份与分析,未涉及任何服务端的未授权访问。文中所有凭证类字段均已隐去。