一次校园智能水控系统安全测试
摘要

针对高校宿舍热水系统的安全测试显示,该系统由小程序、云端和蓝牙水表构成。水表固件具备可靠的防护能力,依靠随机挑战、指令消息认证码与设备令牌的三重校验,能有效抵御指令重放、参数篡改和假云端伪造攻击;开阀依赖云端密钥运算,脱离官方程序仅能实现合规中继,无法绕过计费。

相反,云端接口防线薄弱,服务端完全未执行签名校验,且长达七天的认证凭证在移动端明文存储,存在任意重放与调用风险。此外,计费机制仅按通话时长估算而非采集物理水流,导致异常未出水时仍会扣费。系统整体暴露出水表固件严密、云端防线虚设且计费与物理事实脱钩的“哑铃错位”问题。

一、起因

宿舍热水由一套「微信小程序 + 云端 + 蓝牙水表」的系统控制。作为每天都在用的东西,我对它的信任模型产生了好奇:如果我不用小程序,能不能控制自己的水表?如果有人伪造指令,水表认不认?

这些问题层层递进,最后变成了四个具体的测试目标:

  1. 能否脱离官方小程序,自己实现完整的开关水流程?
  2. 抓到的历史指令,重放还有效吗?
  3. 如果攻击者伪造一个”假云端”签发指令,水表能否识破?
  4. 云端 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 层,而讽刺的是,那里挂着一套看起来完整、实际完全不校验的签名代码——安全的假象比没有安全更危险,因为它会让维护者高估防线位置。

暂无评论

发送评论 编辑评论


				
上一篇