解密Flume水监测器流量
摘要
作者详细描述了如何通过从闪存中提取静态32字节密钥,并使用libhydrogen secretbox参数,解密Flume水监测器的MQTT流量,揭示了尽管固件中存在noise密钥交换代码,但并未涉及会话密钥。
<p>此前已提交:<a href="https://lobste.rs/s/vnigrw/diving_into_flume_water_monitor" rel="ugc">https://lobste.rs/s/vnigrw/diving_into_flume_water_monitor</a></p>
<p><a href="https://lobste.rs/s/g94aro/decrypting_flume_water_monitor_traffic">评论</a></p>
查看缓存全文
缓存时间: 2026/08/12 18:24
# 解密 Flume 水监测器流量
来源:https://lithostech.com/2026/08/decrypting-flume-water-monitor-traffic/
在我之前的文章(https://lithostech.com/2024/12/diving-into-flume-water-monitor/)中,我拆解了一个 Flume 水监测器,并追踪了数据从传感器、经过网桥、一直到 Flume 云端 MQTT 服务器的完整通信路径。我识别出了加密库(LibHydrogen)、提取了固件 ELF,甚至通过破坏闪存中的公钥,成功胁迫网桥发送了明文数据。但那种方法是破坏性的;网桥无法与 Flume 服务器完成握手,所以真实数据停止了流动。当时我以为自己走进了死胡同,但后来发现,找到另一条路对我来说其实是常有的事。我不断冒出更多想法,思考当时还能做哪些不同的事,以及还能进行哪些更深入的研究。于是,我决定继续追逐这个不太健康的执念——弄清楚究竟怎样才能在网络中读取这些数据。
## 为什么我原以为这会很难
固件包含了完整的 LibHydrogen Noise N 密钥交换实现。在反汇编中,`hydro_kx_n_1` 位于 `0x4023382c`(客户端侧),`flume_kx_init_client` 位于 `0x40234550`,它以 `PSK=NULL` 调用该函数,并从闪存加载服务器公钥。这些函数有真实的交叉引用指向会话密钥存储位置。种种迹象都表明网桥在与 Flume 服务器协商临时会话密钥,这会使得被动解密成为不可能,除非向闪存写入自定义密钥对,并在中继中实现完整的密钥交换。我确实这么做了,还期望看到它成功运作,结果却再一次失望。根据之前的调查,当我使密钥存储区域的 magic 前缀失效时,Flume 服务器返回了错误 174:“Phydro crypto key exchange function failed”。这个错误信息,加上固件中的密钥交换代码,让我几乎确信会话密钥确实在起作用。但当我实际捕获实时流量,尝试只用静态设备密钥解密时,一切数据都干净地解密出来了。根本没有会话密钥。我确定上次我也试过这个,但当时肯定没有把 secretbox 的所有参数弄对。Noise N 代码存在于固件中,但并未用于正常的 MQTT 流量。它可能用于 OTA 更新,或者某个只运行一次的配置步骤,又或者只是旧协议版本的遗留物。我不知道。重要的是,在实践中,网桥用存储在闪存中的同一个静态 32 字节密钥来加密和解密所有消息。至少我的设备是这样的。
## 加密参数
网桥使用 LibHydrogen 的 `secretbox` 结构加密所有 MQTT 负载,双向通信使用同一个对称密钥。该密钥位于固定的闪存地址(`0x3f9010`),无需修改设备即可读取。加密参数很简单:
- 算法:`hydro_secretbox`(LibHydrogen)
- 密钥:位于闪存地址 `0x3f9010` 的 32 字节
- 上下文:ASCII 字符串 `12345678`
- 消息 ID:`0`(所有消息均为常量)
有些人看到这些 SecretBox 上下文和消息 ID 参数可能会会心一笑,但我认为 Flume 选择这些参数并没有做错什么。libhydrogen 的文档在谈到上下文时指出,其目的是通过隔离域来减轻意外错误(https://github.com/jedisct1/libhydrogen/wiki/Contexts)。在这种方案中可能只有一个域,所以 `12345678` 和其他任何上下文一样合适。至于消息 ID,文档中还说:
> 如果应用程序不需要这种机制,使用常量 msg_id(如 0)也完全没问题。消息标识符是可选的,不必唯一。
这些参数在固件中可以找到,或者可以猜到,尽管显然我上次没有猜对它们。无论如何,只要知道这些参数,任何消息都可以被解密。
## 读取密钥
网桥的 ESP8266 在板上有标注好的 UART 引脚。连接一个 3.3V USB 转串口适配器,并在上电时将 GPIO0 拉低,芯片就会进入 UART 下载模式。然后,用 `esptool` 就可以读取任意闪存区域:
```
esptool read_flash 0x3f9010 0x20 device_sk.bin
xxd -p -c 32 device_sk.bin
```
这会打印出一个 64 字符的十六进制字符串,即设备密钥。网桥不需要重新刷写,也不需要以任何方式修改。移除 GPIO0 跳线,重新组装,网桥即可正常启动。
## 中继架构
有了密钥和加密参数后,我构建了 flumewatch(https://github.com/stevecrozz/flumewatch),这是一个透明的中间人中继,位于网桥和 Flume 云端服务器之间。它原封不动地转发所有流量,同时解密一份消息副本以供本地使用。
网桥连接到 `mqtt.prod.flumetech.com` 的 1883 端口(纯 TCP,无 TLS)。通过在网络层重定向这条流量,无论是通过 DNS 覆盖还是路由器上的目标 NAT,网桥都会连接到中继。中继是一个运行 Aedes(https://github.com/moscajs/aedes)MQTT 代理的 Node.js 进程。当网桥连接时,中继会:
1. 接受网桥的 MQTT CONNECT(捕获其客户端 ID 和凭据)
2. 使用相同凭据,建立到 `mqtt.prod.flumetech.com` 的自身连接
3. 将网桥的所有订阅镜像到上游服务器
4. 双向转发每条消息,并解密每个负载以供记录
由于中继逐字节地转发加密负载,网桥和 Flume 服务器都无法察觉任何变化。网桥继续正常工作,Flume 移动应用也照常运行。我构建了 libhydrogen-wasm(https://github.com/stevecrozz/libhydrogen-wasm),这是将 LibHydrogen 编译为 WebAssembly 的 Emscripten 版本,专门用于在 JavaScript 中处理解密。
## 流量揭示了什么
消息通过遵循格式 `2//12//` 的 MQTT 主题进行传输。网桥订阅了 `responses//#`(每个设备一个,一个用于网桥自身,一个用于传感器),以接收来自 Flume 的下行消息。
### 连接序列
当网桥通电时,握手过程清晰地说明了整个流程:
**第 1 步:标识(类型 7)。** 网桥的第一条消息以一个固件分支和提交哈希进行自我标识:
```
{"bridge_id": null, "branch": "squidward", "sha_full": "2f1c870a72eca7d7eb38cb145718a202fe1c4a86"}
```
**第 2 步:启动日志(类型 3)。** 紧接着,网桥发送带有复位原因和固件版本的诊断日志消息:
```
[
{"timestamp": 0, "type": 1, "level": 4, "message": "RST REASON: DIRTY "},
{"timestamp": 0, "type": 1, "level": 4, "message": "SDK reset #6: external"},
{"timestamp": 0, "type": 1, "level": 4, "message": "SHA: 2f1c870a72eca7d7eb38cb145718a202fe1c4a86 | BRANCH: squidward"}
]
```
**第 3 步:服务器响应。** Flume 服务器确认连接、列出已配对的传感器,并返回一个设置对象:
```
{
"code": 602,
"message": "Request OK",
"timestamp": 1785096108,
"sensors": ["62AC1323E5A55F6F"],
"devices": [{"uuid": "62AC1323E5A55F6F", "hardware_id": "ASY-00007"}],
"settings": {
"1": 1,
"2": "/provisioning",
"3": "/frames",
"4": "/responses",
"5": "mqtt.flumewater.com:1883",
"6": 30000,
"7": 1200000,
"8": 4093,
"9": 65281,
"10": 14,
"11": 261135,
"12": 12,
"13": 60,
"14": 0
}
}
```
设置中包含 MQTT 端点(`"5": "mqtt.flumewater.com:1883"`,确认是纯 TCP 无 TLS),一些看似时间间隔的值(`"6": 30000`、`"7": 1200000`——可能是以毫秒为单位的报告频率和心跳间隔),以及各种用途未知的配置标志。
**第 4 步:握手后事件(类型 4)。** 握手完成后,网桥会触发一批事件代码:
```
[
{"timestamp": 1785096110, "type": 1},
{"timestamp": 1785096110, "type": 1024},
{"timestamp": 1785096110, "type": 16384}
]
```
这些事件代码是 2 的幂。其确切含义未知,但它们在成功握手后总是出现,表明它们标志着启动状态转换。
### 水流数据
最有趣的消息是类型 1,发布在传感器的设备主题上。这些消息包含单调递增的带时间戳值:
```
[
{"timestamp": 1785104646, "value": 3828906},
{"timestamp": 1785104676, "value": 3828907},
{"timestamp": 1785104691, "value": 3828908},
{"timestamp": 1785104716, "value": 3828909},
{"timestamp": 1785104731, "value": 3828910},
{"timestamp": 1785104767, "value": 3828911}
]
```
`value` 字段看起来是一个累计计数器,很可能是水表旋转元件的磁阻脉冲计数,不过这仅仅是基于硬件的一种推断。在用水量较低的时段,计数器每 15 到 30 秒增加 1。在用水量较高的时段,它增加得更快:
```
[
{"timestamp": 1785105201, "value": 3828929},
{"timestamp": 1785105206, "value": 3828932},
{"timestamp": 1785105211, "value": 3828936},
{"timestamp": 1785105216, "value": 3828941},
{"timestamp": 1785105221, "value": 3828943}
]
```
这里计数器每 5 秒增加 3 到 5。计数器增量与实际水量之间的关系未知;用一个已知体积的容器进行校准,就能确定换算系数。
### 传感器状态
传感器主题上的类型 2 消息携带一组以位掩码为键的字段:
```
[
{
"1": 2,
"2": 17,
"4": 18,
"8": 7,
"16": -79,
"32": 3252,
"128": 0,
"1024": 24,
"2048": 7,
"4096": 0,
"8192": 1,
"16384": 3214,
"131072": 3530,
"262144": 50,
"timestamp": 1785105287
}
]
```
一些字段可以通过其取值范围来识别:
| 键 | 可能含义 | 理由 |
|---|---|---|
| 16 | RSSI(dBm) | -79 是典型的无线信号强度 |
| 32 | 电池电压(mV) | 3252 → 3.252V,对锂电池来说合理 |
| 16384 | 参考电压? | 3214 mV,与电池电压接近 |
### 网桥状态
网桥不那么频繁地报告自身的类型 2 状态:
```
[{"4": 28496, "32": -87, "128": 13, "256": 10, "512": -60, "timestamp": 1785105496}]
```
| 键 | 可能含义 | 理由 |
|---|---|---|
| 32 | 传感器 RSSI(dBm) | -87,915 MHz 无线链路 |
| 512 | WiFi RSSI(dBm) | -60,典型的室内 WiFi 信号 |
| 4 | 运行时间或空闲堆 | 28496,可能是秒或字节 |
### 心跳
Flume 服务器定期发送心跳(类型 4,下行),网桥会原样回显为类型 6:
```
{
"timestamp": 1785105391038968,
"heartbeat": {"id": "c4fc411648ca757e2f5210aa9af441f7", "seq": 1}
}
```
时间戳似乎具有微秒级精度。序列号随着每次心跳递增;我观察到它们大约每 10 分钟到达一次。
## 收尾
由于这次我真的解密了网络中的数据,我决定做一个负责任的人,真的让 Flume 的人知道我的所作所为。他们专业地回应了我,并确认每个设备都有一个随机的密钥,因此我发布这篇博客文章不会危及任何人的用水信息。Flume 的首席技术官 James Fazio 想指出:
> 如果[网桥]设备被配置为连接本地 MQTT 服务器而不是 Flume 的服务,它将不再收到 Flume 的固件更新或技术支持。
我可能很快就会重新使用 Flume 云 MQTT 服务,因为我确实希望我的设备能得到 Flume 的支持。我认为 Flume 无法分辨我是否运行了自己的代理。但因为我在重定向流量时用了不少粗暴的网络手段,这确实有可能阻止固件更新。现在至少我们知道,即使将来确有需要,我们也能在没有 Flume 的情况下让这些设备继续运行。
在我看来,这里使用的安全性实际上已经足够了。我之所以能获得这些信息,只是因为我对硬件进行了长时间的实际接触、愿意用 esptool 探测我的设备,并且能完全访问它所在的本地网络。这已经是相当高的门槛了,尤其是如果任何人只要打开我家路边人行道上的水表井盖看一眼,就能了解到同样的信息。我知道,一旦发布这篇文章,这一切可能会在某个安全更新中被“修复”。但我真心希望不会。我想给 Flume 提一个建议。能不能提供一个“开发者模式”,让我可以指定我自己的,或者至少是额外的 MQTT 服务器?它可以尽力交付。这甚至可能对你们自己的开发人员也有用。并且注明该功能仅供开发者使用,这样它就不必成为附带全部支持负担的一流产品功能。如果这个功能当时存在,我肯定就不会花这么多时间研究这个最终对我来说相当有趣的谜题了。
## 还能做些什么
中继已能工作,数据也在流动。有一些仍然开放的研究方向:
- **脉冲到水量的校准。** 在观察计数器的同时放出已知体积的水,就能确定换算系数。
- **本地仪表盘。** 有了解码后的流量数据,将其导入 InfluxDB 或 Home Assistant,就能提供独立于 Flume 云端的纯本地用水监测。
- **位掩码字段识别。** 在不同条件下(低电量、弱信号、温度变化)进行更长时间的观察,应该能揭示其余状态字段所编码的内容。
- **报告频率。** 流量数据大约每 30 秒成批到达。Flume 的握手响应可能控制着这一频率;精心构造的响应可能会提高分辨率。
完整的中继实现托管在 github.com/stevecrozz/flumewatch(https://github.com/stevecrozz/flumewatch)。
相似文章
Flume Water Monitor 915 MHz 安全性相当不错
一位安全研究人员分析了Flume Water Monitor的915 MHz射频链路,花费中等程度的努力成功破解了其加密,并认为该设备的安全性对于消费者产品而言是合理的。
@TheAhmadOsman:疯狂 DSv4 Flash 发现并起草邮件披露泄露的私钥 - 在局域网上逆向工程 - 48 个 UDP 数据 …
Ahmad Osman 使用 DeepSeek v4 Flash 和 Qwen 3.8 27B 逆向工程专有猫喂食器,识别泄露的私钥,并将其移出云端,保持完整功能对等。
@leaf_sanren: https://x.com/leaf_sanren/status/2073069608437764266
作者详细介绍了如何通过逆向工程破解微信Mac版的加密系统,使用Frida动态插桩捕获系统函数CCKeyDerivationPBKDF生成的密钥,从而解密29个加密数据库,实现了自动获取群聊记录的目标,并计划开源该项目。
我破解了AppLovin的广告中介加密协议
一名研究人员逆向工程了AppLovin的广告中介加密协议,发现其使用弱的非加密伪随机数生成器和静态盐值来加密设备信息,即使在用户拒绝追踪权限的情况下,也允许在应用间确定性重新识别iPhone。
我骗Claude泄露了你最深藏的秘密
一位安全研究人员演示了一种方法:通过将数据编码到Web请求URL中,利用记忆检索与网页浏览功能的组合,诱骗Claude AI从其记忆系统中窃取用户个人数据。