一、起因
宿舍热水由一套「微信小程序 + 云端 + 蓝牙水表」的系统控制。作为每天都在用的东西,我对它的信任模型产生了好奇:如果我不用小程序,能不能控制自己的水表?如果有人伪造指令,水表认不认?
这些问题层层递进,最后变成了四个具体的测试目标:
- 能否脱离官方小程序,自己实现完整的开关水流程?
- 抓到的历史指令,重放还有效吗?
- 如果攻击者伪造一个”假云端”签发指令,水表能否识破?
- 云端 API 自身的认证防线是否可靠?
二、系统架构
逆向小程序包后,整个系统的通信结构如下:
微信小程序 ──HTTPS──> 云端 API (mh5.mhito.net, ASP.NET)
│ │
│ 蓝牙中继 │ 签发指令
▼ ▼
水表 <──── 指令由手机蓝牙透明转发到表 ────
关键设计:水表不直接联网。所有开阀/关阀指令由云端生成,经用户手机蓝牙”人肉中继”写入水表。这个架构决定了它的信任模型——水表必须有能力验证”这条指令真的出自云端”,否则蓝牙范围内任何人都能伪造指令。
实测发现该校存在两代协议并存:
| 旧协议 | v4 新协议(本次实测对象) | |
|---|---|---|
| 蓝牙服务 | FEE0 |
FEE7(特征 FEC7写/FEC8通知/FEC9读) |
| 广播名 | 15 位 IMEI | 6 位表号(即水表上贴的编号) |
| 开阀流程 | GetRandom + ForwarBluetoothMessage |
Device4/ToPreRecharge 三步闭环 |
| 指令防护 | 未知 | 表侧随机挑战 + 指令尾 MAC |
水表是 v4 新协议——这决定了后面所有测试的路径。
三、API 签名
3.1 签名算法的来历
小程序内所有 API 请求经 mySignObject 函数签名。从还原的 app-service.js 中提取的实现:
function mySignObject(params) {
params.timeStamp = Date.now(); // 客户端自生成
params.random = rand(100000, 999999); // 客户端自生成
const sorted = Object.keys(params).sort();
let plain = '';
sorted.forEach(k => plain += params[k]);
params.signature = md5(plain + 'comcms'); // 硬编码盐值
return params;
}
盐值 comcms 硬编码在小程序包里——任何解包的人都能离线伪造合法签名。这是第一个发现(静态层面)。
3.2 服务器到底验不验?
但硬编码盐值只是”弱”,真正的问题在实测中浮现。我用本人账号对 user/CheckIdentity 接口做了一组对照实验:
| 请求形态 | 服务器响应 |
|---|---|
| 合法签名(对照) | 通过 |
| 篡改签名 1 个字符 | 通过 |
| 完全删除 signature 参数 | 通过 |
| 1 小时前的时间戳(签名对该时间戳合法) | 通过 |
| 同一请求原样连发两次 | 两次都通过 |
| 无 JWT | 401 拒绝 |
| 篡改 UID 的伪造 JWT | 401 拒绝 |
| 已过期 JWT | 401 拒绝 |
结论出乎意料:服务端根本没有实现签名校验逻辑。不是弱校验,是不校验。篡改也好、删除也罢,服务器一概放行——整段签名代码纯粹是写给逆向者看的装饰品。
有效的防线只剩 JWT 本身:签名验证、过期验证都真实生效。但 JWT 有两个伴生问题——有效期长达 7 天,且在小程序本地存储中明文可提取(PC 端 Radium 框架的 MMKV 存储做了加密,移动端为明文)。
综合判定:持有任意有效 JWT 者,可对该系统全部 API 任意调用、无限期重放截获的请求。 签名机制形同虚设,时间戳无时效窗,nonce 无去重。
四、蓝牙协议
实现脱离小程序的控制工具,需要完整复刻协议。通过”实现→观察表应答→修正”的循环,v4 协议的指令层被完全还原:
帧格式:[长度 1B][类型+会话序号 2B][载荷],首字节恒等于后续字节数。类型码:C0 预存/取回、C1 开阀、C2 关阀、C5/C4 激活确认、表应答镜像指令的类型与序号。
开阀完整时序(实测还原,与小程序 run4 页面逐行对照验证):
手机 云端 水表
│ │ │
├─ ToPreRecharge ───────────>│ │
│<─ 07 C0[seq] 01 [令牌] ────┤ │
├─ 写入预存指令 ─────────────────────────────────────>│
│ │ 表吐随机值 R │
│<─────────────────────── 10 C0[seq] [R] ──────────────┤
├─ ForwarBluetoothMessage ──>│ │
│<─ 13 C1[seq] [令牌][参数][VAR][MAC4B] ──┤ │
├─ 写入开阀指令 ──────────────────────────────────────>│
│<─────────────────────── 0F C1[seq] [状态包] ─────────┤
├─ 转发状态包 ──────────────>│ │
│<─ 03 C5[seq] 00 激活指令 ──┤ │
├─ 写入激活指令 ──────────────────────────────────────>│
│<─────────────────────── 0B C7[seq] [执行帧] ─────────┤ ← 出水
├─ 转发执行帧 ──────────────>│ │
│<─ "开阀成功" ──────────────┤ │
几个实测踩出来的工程细节,文档里不会写、但真实固件很挑剔:
- 协议帧必须单次 GATT 写入完整发送。拆成两包(18+2 字节)水表直接静默丢弃——它把”一次写入”视为一条完整帧;
- Chrome Web Bluetooth 默认 MTU 23(有效载荷恰 20 字节),而微信小程序框架会自动协商到 512。所有 v4 指令 ≤20 字节,恰好压线;
- 开阀后立刻关阀,表会拒绝响应取回指令(状态机切换中),需要 ≥1.5 秒间隔或换新会话重试。
基于以上,可以使用手机浏览器直连水表,本机服务负责签名与云端中继。至此,第一目标达成——完全脱离官方小程序实现开关水,正常计费,全部功能可用。
五、重放
工具就绪后,重放测试只是一次点击。捕获一组完整开阀会话(预存→开阀→激活三条指令),待会话结束后原样重发:
| 步骤 | 水表反应 |
|---|---|
| 预存指令 | 接受,吐出新随机值(与原会话不同) |
| 开阀指令(含旧挑战) | 回状态包,错误码置位(0x01),回显设备令牌 |
| 激活指令 | 无应答,蜂鸣告警 |
未出水,未扣款。结论:v4 协议的表侧挑战-应答机制真实有效——旧指令里的挑战值对不上新会话的随机值,表拒收。
一个值得玩味的细节:预存指令是静态的(不含挑战,任何会话通用),表来者不拒。它无害,因为它只能”发起会话”——真正的钥匙在后面那步。但这说明设计者做了取舍:把成本花在必须保护的地方。
六、假云端
重放被拒只证明”旧的不行”,还没回答”伪造的新指令行不行”。于是尝试使用本机 127.0.0.1 上的伪造 API,完整复刻 v4 协议时序,让手机中继把假服务器签发的指令透明转发给水表。这个场景模拟的了完全控制云端信道(如 DNS 欺骗后的假 API)时,水表能否正常反应。
四个篡改场景,对应攻击者可能找到的四类捷径:
| 场景 | 篡改内容 | 结果 |
|---|---|---|
| 随机 MAC | 指令尾 4B 用随机值 | 拒绝 |
| 全零 MAC | 尾 4B = 00000000 |
拒绝 |
| 无尾截断 | 去掉尾 4B(19 字节帧) | 拒绝 |
| 令牌篡改 | 设备令牌改 1 字节 | 拒绝 |
四个场景下全部都进行了拒绝。水表固件不止验证”MAC 字段存在”,而是完整校验其内容与设备令牌的绑定。
那么 MAC 尾巴能否被推算出来?全部实测样本做了系统检验:
- 尾 4B 与表随机值 R 的直接异或、反转、字节和、变量混合——五类无密钥弱算法全部排除;
- 同一个表随机值 R 在两次会话中产生了不同的尾 4B——说明云端侧还有隐藏 nonce 参与运算。就算算法透明,输入也凑不齐;
- 密钥只存在于厂商签发侧。不在小程序(指令是算好下发的),不在表广播,不在蓝牙流量。
理论上的在线暴力枚举通道(每次蓝牙交互试一个 MAC)也存在工程死结:每轮约 1.5 秒,2³² 次约需两百年,且表对连续无效指令会蜂鸣告警——物理上自我暴露。
七、计量与物理的脱钩
一次测试中,开阀指令链路层 ACK 但水表未执行(当时工具的写入方式有 bug,表收到了残缺指令)。云端已建立订单并预扣 3 元,三小时后手动触发关水结算——订单按会话时长估算出 53 升用水量,扣费 1.325 元。而水表物理上一滴水都没出。
也就是说:云端计量不依赖水表回传的实际脉冲,而是用”时长 × 估算流量”推算。会话建立但实际未出水的任何场景——通信中断、阀故障、指令没执行成功——都会产生与物理事实不符的账单。
八、哑铃错位:这套系统的安全格局
四天测试下来,整套系统的安全画像非常清晰,也非常反常识:
┌─────────────────────────────────────────┐
│ 云端 API:签名不校验、无时间窗、无去重、 │
│ JWT 明文可提取、7 天有效期 │
├─────────────────────────────────────────┤
│ 传输/蓝牙层:帧格式清晰(无加密无混淆, │
│ 但这在架构上不是问题,见下) │
├─────────────────────────────────────────┤
│ 水表固件:挑战-应答 + 指令 MAC + 设备令牌 │
│ 三重验证,重放/伪造/篡改全场景实测有效 │
└─────────────────────────────────────────┘
哑铃两头,一重一轻。最有意思的是:蓝牙链路不加密恰恰不是漏洞——因为安全性不依赖链路保密,而依赖指令本身的密码学验证。攻击者能完整看到每一条指令,却造不出一条能被接受的。
真正的风险集中在 API 层,而讽刺的是,那里挂着一套看起来完整、实际完全不校验的签名代码——安全的假象比没有安全更危险,因为它会让维护者高估防线位置。
