Vibe Hacking 实录 - 逆向智能落地灯协议
前言
最近买了一盏国产的落地灯, 标称最大功率 70W, 可调色温亮度, 还支持 App 控制. 如果能把灯接入自动化控制, 用单片机发送协议就更好了。
定位通信类型
灯送了一个遥控器, 上面有配对按键, 没有给红外预留的开孔。说明是无线连接。我手上没有 SDR, 于是先拆开遥控器看看电路板。
这个遥控器确实是国产厂商极致控制成本的典范。遥控器整机没有任何五金紧固件,两片塑料壳和电池仓盖都是卡扣设计。PCB 是单面板,按键是典型的金属膜案件,触点和飞线全部用碳膜实现。板子连丝印都没有,批号直接做在线路层。板子上有一个典型的 2.4GHz PCB 天线,旁边有一颗定制 IC,上网查不到型号。估计是这个遥控器的出货量非常大,厂方定制的 IC。
其实劫持控制还有一条路子,就是直接让单片机假装按键飞线到遥控器上。然而这个遥控器为了节约矩阵 IO,用了电阻分压扩展按键矩阵的行数。单片机没有办法直接输出 1.5V 这样的电平,要飞线还得买模拟开关。贼难焊接。我打算先研究下无线协议。
寻找入口
确定是 2.4GHz 之后,我尝试用手机抓包,发现就是 BLE 通信。按键会让遥控器发出一个厂商自定义的 BLE Advertisement, 灯收到之后根据负载内容执行相应操作。但是抓包发现,重复相同动作都有完全不同的二进制,而且分布很均匀。说明有加密来对抗分析。
但是呢,结合厂商极致压缩成本的调性, 我怀疑 App 发送的命令也是用的相同的协议。Google 了一下灯灯型号和 App 的名字,我在 Home Assistant 论坛上看到有人提到过这个灯的 App,以及社区已经有了 ESPHome 组件来将其接入智能家居系统。我下载了组件、让大模型把协议通信搬出来。然后上板子测试,同时用手机抓包。我发现包的形式压根就不一样。可能这个 App 本身就支持几种不同的协议,社区开发的组件不是我手上这个版本的协议。
我们中山电子厂也是远销海外了,lieterally 全世界都在用这个叫 Smart lamp pro 的玩意。
突破口

官网提供了两个 App, 一个是 Smart lamp pro, 另一个看起来很简陋,叫做智美灯控,但是也能正常控制。我觉得,可以从这个垃圾 App 入手,逆向包来获得 BLE 协议。

打开包一看,代码确实充满了国产外包 Java 风味。代码里是各种复制粘贴的 example, 乱写的注释和随便写的常量。让大模型看了看,定位协议拼装的部分。我打开一看,这个叫 liblight 的包里面是混淆过的 Java 库。混淆的力度不大,程序的控制流基本没有变化,只是标识符改成了abcdef而已。
我尝试导出源码让大模型古法分析,但是效果不好。后面我发现 Jadx 还有 MCP 插件,这波直接超级提速了。用 Jadx 反编译并且做了基础的重命名之后,我让 Gemini 3 Flash 接手 Jadx 来继续分析并逐个符号重命名,大概跑了一个小时就把代码逻辑整理出来了。

这套通信协议和控制器大概率是供应链的方案,然后一堆下游杂牌厂商直接就拿来用了。虽然 App 写的很垃圾,但是加密的流程并不简单。
App 里面有三套不同的协议,加密的强度不太一样。
结合抓包的模式来看,这个灯用的是一种有 HFJK 标记的协议,加密强度中等。通过一些预定义的业务逻辑拼出二进制明文之后,有下面的流程。
明文数据 (16字节)
|
v
┌──────────────────────────┐
│ 构建原始数据结构 │
│ (地址+命令+载荷) │
└──────────────────────────┘
|
v
┌──────────────────────────┐
│ 第一次混淆: 载荷区域 │
│ raw[9:13] 使用SBOX加密 │
│ Key: seqId + seqNum │
└──────────────────────────┘
|
v
┌──────────────────────────┐
│ CRC16校验 │
│ 计算raw[0:12]的校验和 │
└──────────────────────────┘
|
v
┌──────────────────────────┐
│ 第二次混淆: 全局加密 │
│ raw[0:15] 全部使用SBOX │
│ Key: 0x56 + rnd │
└──────────────────────────┘
|
v
┌──────────────────────────┐
│ 协议封装 │
│ 添加头部"HFKJ"和尾部0xFF │
└──────────────────────────┘
|
v
最终加密数据包 (26字节)
HFJK....FFFF
代码里有写死的密钥,密码学这块就没啥好说的。在配对的时候也没有抓到灯回传的数据,说明所谓的配对,大概率就是遥控器和灯之间绑定地址和滚动码而已。
重新实现
从 App 的源码迁移出来就有点曲折了。我原本以为从 Java 到 Cpp 基本就是字到字到逐字翻译,我就放手让 LLM 弄了。弄出来的东西看起来像那么回事,但是跑起来输出的二进制形状对,但是跑不通。
于是我古法跟踪了一下代码流动,发现了问题所在。模型在反混淆的时候,给两个不同的字段取了类似的名字,导致后面 Agent 在编码的时候把两个概念弄混了。我提示了模型,让它重新理清字段的关系,然后重新生成代码。还是没有跑通。
于是我抓包去重,收集了一些单元测试让模型自己修正,最后比较出几个问题:
- 编码的时候, 模型在 reasoning 过程中加入了一些奇怪的特殊逻辑,可能是为了迎合少量的参考用例。
比如说,强制向某些字节里写写死的字节。
- 确实有一些特殊逻辑是 App 里有但是遥控器逻辑没有实现的。
最后手工修正了几行代码 (实际上排查了两天), 终于跑通了。
后面简单聊一下命令拼装的逻辑。命令包括指令和参数两部份。指令是一个字节,参数是三个字节。
常见的指令有
- 开灯 0xB3
- 关灯 0xB2
- 设置亮度 0xB5
- 设置色温 0xB7
参数编码设计也比较简单。开关灯没有参数,位置直接写 00 00 00。
亮度和色温就有点意思。第一个字节是操作类型:
- 0x00 代表设置绝对值
- 0x01 代表增加
- 0x02 代表减少
后面两个字节是具体的数值。亮度范围是 0-1000, 色温范围是 0-140. 这个逻辑其实挺抽象的, 因为灯没有状态回读的能力,遥控器搞个增加减少的命令完全没有意义。而且,增减和灯的状态不一致的时候,灯也不会有任何反馈。
然后就按照这样拼成一个二进制串
RND | SEQ | 设备地址 [4字节] | 指令 | 0xFF (固定的位置内容) | SEQ | 参数 [3字节] | CRC16 [2字节]
然后丢进加密函数,发给灯就行了。另一个有意思的是,我发现 SEQ 滚码计数器压根没有作用,灯完全不在乎 SEQ 是多少。
那配对是怎么一回事呢? 毕竟灯不能反向发出信息。经过分析,其实就是灯在配对模式的时候,把配对时的遥控器地址设置成自己的地址而已。我测试了一波,只要地址一样,你可以用 114514 个不同的遥控器控制这盏灯。