我如何对基于 ESP32 的智能家居设备进行逆向工程以获得远程控制访问权限并将其与 Home Assistant 集成。
介绍
最近,我有点痴迷于把家里的所有东西都连接到家庭助理。在一个应用程序中将所有东西连接起来并实现自动化,真是令人满足;我终于可以忘记每个不同品牌的智能产品的随机移动应用程序了。
但我的一款产品却顽固地无法连接任何设备,除了它自带的手机应用程序。这是一款造型优美的空气净化器,可惜的是,它的应用程序却让人失望。
许多现代产品的基本功能都依赖于互联网连接和云账户,谁知道它们会收集哪些不必要的数据或给家庭网络带来哪些技术漏洞?
我想像控制其他智能设备一样控制这台昂贵的空气净化器。这标志着一段充满挑战却又充满乐趣的旅程的开始。
是时候破解空气净化器了!😆
顺便说一句,如果你喜欢我的内容,你可以给我买杯咖啡支持我的内容创作!
本篇文章的内容旨在用于对物联网智能设备和网络协议进行逆向工程的过程进行教育。
黑客攻击这个词听起来很吓人,所以我想澄清一下,我的目的仅仅是升级我购买的智能设备,使其与我的智能家居系统集成。升级过程不会影响该产品的任何其他实例或其云服务。因此,所有与产品相关的敏感数据,例如私钥、域名或 API 端点,都已从本文中删除或删除。
对您的设备进行修补可能会使任何保修失效,并有永久损坏设备的风险;请自行承担风险。
计划
如果我们要破解这个设备并让它通过定制软件进行控制,我们就需要了解它当前的功能并规划一个攻击点,以最少的工作来实现我们的目标。
该设备本身就支持通过手机应用进行远程控制,但需要云账户才能使用,这有点烦人。通过切换手机的蓝牙、WiFi 和 5G 网络,我确认该应用需要网络连接才能控制设备。无法通过蓝牙或 WiFi 进行本地远程控制。
这意味着移动应用程序和设备必须连接到云服务器才能实现远程控制。因此,在该网络的某个地方,设备与其云服务器之间的数据必须是风扇转速以及应用程序控制的其他所有数据。
所以,这就是我们的攻击点:
-
如果我们可以拦截设备的网络流量并更改这些值,我们就可以控制该设备。
-
如果我们可以模拟所有服务器响应,我们就可以控制设备,而无需依赖互联网连接及其云服务器。
移动应用分析
我首先研究的是远程控制手机应用程序。这可以快速收集一些信息,因为安卓应用程序拆解起来相对简单。
Android 上的应用程序以文件形式存储.apk
。只需在线快速搜索,即可找到下载特定应用程序最新版本的网站.apk
。如果您不知道,应用程序的格式.apk
从技术上来说就是一个.zip
文件!您可以直接提取它们来浏览应用程序的内容。
Android 应用包含已编译的 Java 可执行文件,通常名为classes.dex
。您可以.jar
使用dex2jar并使用jd-gui以重建的源代码形式浏览内容。
找到该应用程序后MainActivity.class
发现它是使用 React Native 构建的!
package com.smartdeviceapp;import com.facebook.react.ReactActivity;publicclassMainActivityextendsReactActivity{protected String getMainComponentName(){return"SmartDeviceApp";}}
对于使用 React Native 构建的 Android 应用,您可以在 中找到 JavaScript 包assets/index.android.bundle
。
对应用程序包的快速扫描显示,它使用安全的 WebSocket 连接:
self.ws =newWebSocket("wss://smartdeviceapi.---.com");
这款 Android 应用并没有太多亮点;正如预期的那样,它连接到云服务器,以便远程控制智能设备。由于能够轻松获取一些可读的源代码,因此值得快速浏览一下。我们随时可以参考这个 bundle,看看是否能在其中找到任何共享的值或逻辑。
网络检查
接下来,是时候看看设备和云服务器之间的网络流量了;这就是我们试图拦截并理想情况下模拟的。
我在本地使用 Pi-hole,它是一个 DNS 服务器,可以屏蔽跟踪和一些广告,但它也有一个实用的功能,可以按设备浏览 DNS 查询。通过导航到该Tools > Network
页面并选择设备的本地网络地址,我们可以看到它正在向 DNS 服务器查询云服务器的域名地址:
现在我们知道了它所连接的云服务器的域,我们可以使用该Local DNS
功能将网络流量发送到我的本地工作站(192.168.0.10
)而不是他们的云服务器:
然后我们可以使用Wireshark查看来自智能设备的流量。我们可以通过使用ip.addr == 192.168.0.61
(智能设备地址)过滤器来监控工作站网络接口来实现。
通过这样做,我就能看到从智能设备发送到端口上的工作站的 UDP 数据包41014
!
数据包分析
所以,我们知道智能设备使用 UDP 与其云服务器通信。但现在,它正尝试与我的工作站通信,并希望它能像云服务器那样做出响应。
我们可以使用一个简单的 UDP 代理作为我们的工作站作为智能设备与其云服务器之间的中继。
我用过Cloudflare 的 DNS 解析器(1.1.1.1
)来查找他们云服务器的真实 IP 地址(因为我的 Pi-hole DNS 会直接解析到我工作站的本地 IP 地址)。然后我使用节点UDP转发器作为将流量中继到云服务器的简单方法:
udpforwarder \
--destinationPort 41014--destinationAddress X.X.X.X \
--protocol udp4 --port 41014
X.X.X.X
是他们的云服务器的真实IP地址。
再查看Wireshark,我们可以看到智能设备与其云服务器之间的所有网络流量!
启动设备时,它会向服务器发送一个包含如下数据的数据包:
Hex View 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000 55 00 31 02 01 23 45 67 89 AB CD EF FF 00 01 EF U.1..#Eg........
00000010 1E 9C 2C C2 BE FD 0C 33 20 A5 8E D6 EF 4E D9 E3 ..,....3 ....N..
00000020 6B 95 00 8D 1D 11 92 E2 81 CA 4C BD 46 C9 CD 09 k.........L.F...
00000030 0E .
然后服务器将做出如下响应:
Hex View 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000 55 00 2F 82 01 23 45 67 89 AB CD EF FF 37 34 9A U./..#Eg.....74.
00000010 7E E6 59 7C 5D 0D AF 71 A0 5F FA 88 13 B0 BE 8D ~.Y|]..q._......
00000020 ED A0 AB FA 47 ED 99 9A 06 B9 80 96 95 C0 96 ....G..........
此后的所有数据包似乎都具有类似的结构。它们不包含任何可读的字符串,但充满了看似随机的数据字节;这可能是雪崩效应指向加密。
我搜索了一下,看看这个数据包结构是否是一个现有的协议。我发现一些智能设备使用了 DTLS,而且它基于 UDP。
然而,Wireshark 确实支持检测 DTLS 数据包,但将此数据包标记为 UDP,这意味着它无法从数据中确定基于 UDP 的协议。我仔细检查了 DTLS 规范,但其中描述的报头格式与我们在数据包中看到的有所不同,因此我们知道这里没有使用 DTLS。
此时,我们遇到了阻碍;我们不了解这些数据包中的数据是如何格式化的,这意味着我们还不能操作或模拟任何东西。
如果使用有据可查的协议,这会容易得多,但那还有什么乐趣呢?
物理拆卸
我们知道有两个应用程序知道如何读取这些数据包:智能设备和它的云服务器。不过,我手边没有他们的云服务器,所以现在是时候看看智能设备内部了!
只需拧开几个螺丝,就能轻松拆卸。里面是主 PCB,包含微控制器、连接风扇的端口,以及连接正面控制面板的排线。
主控制器标记为ESP32-WROOM-32D
。该微控制器通常用于智能设备,具有 WiFi 和蓝牙功能。
我偶然发现了ESP32-逆向GitHub repo,其中包含与 ESP32 相关的逆向工程资源列表。
串行连接
ESP32 包含一个闪存芯片,其中很可能存储了包含应用程序逻辑的固件。
ESP32 的制造商提供了一个名为esptool与 ESP32 中的 ROM 引导加载程序进行通信。使用此工具可以从闪存中读取数据,但首先,我们必须建立串行连接!
引用ESP32 产品规格书,我们可以找到引脚布局图:
这里我们可以看到TXD0
(35) 和RXD0
(34) 引脚。我们需要将一根线连接到这两个引脚,并连接一个接地引脚,以实现串行连接。
设备 PCB 上有几个针孔,通常用于连接调试和烧写芯片的引脚;我能够直观地追踪从这两个串行引脚到针孔的走线!这样我就可以轻松地焊接分线接头,以便临时插入跳线。否则,我可能会小心翼翼地直接将其焊接到芯片引脚上。
将万用表设置为连续性模式后,我能够通过参考GND
ESP32 上的 (38) 针脚来找到哪个孔是接地的。
现在,我们需要一个端口来处理这个 UART 串行通信。我用了我的鳍状肢零USB-UART Bridge
,其类别下有一个方便的应用程序GPIO
。
我使用 3 根跳线将它们连接在一起:
-
Flipper Zero
TX
<-->RX
ESP32 -
Flipper Zero
RX
<-->TX
ESP32 -
Flipper Zero
GND
<-->GND
ESP32
TX
这里故意将 和线RX
交叉;我们想要将数据传输到另一台设备的接收线!
在 Windows 设备管理器的类别下Ports (COM & LPT)
,我发现我的 Flipper Zero UART 设备是COM7
。使用油灰COM7
配置为高速串行连接后115200
,我能够成功连接到 Flipper Zero。在搜索过程中,我发现这个速度经常用于 ESP32,所以我决定在这里使用它。
启动智能设备时,我注意到串行输出中有一堆日志数据:
rst:0x1(POWERON_RESET),boot:0x13(SPI_FAST_FLASH_BOOT)configsip:0, SPIWP:0xeeclk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00mode:DIO, clock div:2load:0x3fff0030,len:4476ho 0 tail 12 room 4load:0x40078000,len:13512ho 0 tail 12 room 4load:0x40080400,len:3148entry 0x400805f0********************************** Starting SmartDevice **********************************This is esp32 chip with2 CPU core(s), WiFi/BT/BLE, silicon revision 1, 4MB external flash
Minimum free heap size:280696bytesnvs_flash_init ret:0Running app from: factory
Mounting FAT filesystem
csize:1122 KiB total drive space.0 KiB available.FAT filesystem mounted
SERIAL GOOD
CapSense Init
Opening[rb]:/spiflash/serial
Serial Number: 0123456789abcdefff
Opening[rb]:/spiflash/dev_key.key
Device key ready
Base64 Public Key:**REDACTED**Opening[rb]:/spiflash/SmartDevice-root-ca.crt
Opening[rb]:/spiflash/SmartDevice-signer-ca.crt
Addtimeout:10000,id:0RELOAD FALSE
Opening[rb]:/spiflash/server_config
MP PARSE DONE
Server: smartdeviceep.---.com:41014
我们可以从这个输出中挑选出一些有用的信息:
-
该设备有一个4MB的闪存芯片。
-
该应用程序从 运行
factory
,这是工厂闪存的默认应用程序的通用分区名称。 -
已安装 FAT 文件系统。
-
该应用程序读取以下文件:
-
序列号
-
设备密钥
-
两个 CA 证书(根证书和签名者证书)
-
服务器配置
-
倾销Flash
太棒了,现在我们有一个可以正常工作的串行连接,我们可以专注于转储闪存,希望它包含有关如何读取这些数据包的信息!
要读取闪存,我们需要以不同的模式启动 ESP32,具体来说就是它所谓的Download Boot
模式。数据手册中对此进行了技术解释Strapping Pins
。简而言之,我在 ESP32 启动时,用一根跳线将GND
Flipper Zero 的一个端口连接到了IO0
ESP32 的 (25) 引脚。
使用 Putty 检查串行输出,我们可以看到这已成功将智能设备启动到该Download Boot
模式:
rst:0x1(POWERON_RESET),boot:0x3(DOWNLOAD_BOOT(UART0/UART1/SDIO_REI_REO_V2))waiting for download
现在我们可以关闭 Putty 并切换到终端来使用 esptool。
我们可以使用以下命令从 ESP32 转储整个 4MB 的闪存数据:
esptool -p COM7-b 115200 read_flash 00x400000 flash.bin
我转储了几次闪存以确保我能够正确读取并备份它们,以防我们意外地破坏某些东西,因为这样我们就可以闪回转储。
为了使用 Flipper Zero 成功读取闪存,我必须更改其配置以指定波特率115200
而不是Host
。
闪存分析
我们已经将 ESP32 Flash 的数据转储到一个二进制文件中,现在我们需要对其进行解读。我发现esp32刀成为实现这一目标的最佳工具。
它读取了闪存文件并提取了大量有用信息。它也是唯一一个成功将此转储重新格式化为 ELF 格式并正确映射虚拟内存的实用程序,稍后会详细介绍!让我们看看能找到什么:
python esp32knife.py --chip=esp32 load_from_file ./flash.bin
这会记录大量信息并将输出数据保存到./parsed
文件夹中。
这里第一个感兴趣的文件是partitions.csv
,该表映射了闪存中的数据区域:
# ESP-IDF Partition Table
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs,0x9000, 16K,otadata, data, ota,0xd000, 8K,phy_init, data, phy,0xf000, 4K,factory, app, factory,0x10000, 768K,ota_0, app, ota_0,0xd0000, 768K,ota_1, app, ota_1,0x190000, 768K,storage, data, fat,0x250000, 1M,
在这里,我们可以看到一些有趣的条目:
-
有三个应用程序分区。其中两个标记为
ota
,用于写入无线固件更新。另一个标记为factory
,我们从启动期间的串行输出中得知,这是当前正在使用的应用程序分区。 -
该
storage
分区具有 FAT 类型,这很像我们在串行输出中看到的安装的 FAT 文件系统。 -
nvs
是一个键值存储分区,这里可能存在一些有用的数据。
其他读者提到,如果设备启用了闪存加密(在这种情况下没有启用),则此闪存转储可以受到保护。
设备存储
我最初很好奇想看看nvs
键值存储分区中有什么数据。
这些数据的最新状态被提取到了part.0.nvs.cvs
,我唯一能看到的有趣数据是我的 WiFi SSID 和密码。但我也在和中找到了完整的历史更新日志,part.0.nvs.txt
其中揭示了一些之前使用过的 WiFi 凭证;什么!?难道有人在我之前就用过这玩意儿?😆
接下来,该查看 FATstorage
分区的内容了。我发现OSFMount成为一个伟大的 Windows 应用程序;它将文件系统映像安装为虚拟磁盘并允许写入!
这揭示了我们之前从串行输出中看到的一些有趣的文件:
dev_info
dev_key.key
serial
server_config
SmartDevice-root-ca.crt
SmartDevice-signer-ca.crt
wifi_config
我检查了这些文件的内容并发现:
-
dev_info
- 标有 UUIDfirmware
,可能是安装的版本 -
dev_key.key
- 256 位私钥(prime256v1),此公钥已打印到标有Device key
!的串行输出。 -
serial
- 序列号 -
server_config
- 我们之前找到的地址和端口号 -
SmartDevice-root-ca.crt
- 具有 256 位公钥的 CA 证书(prime256v1) -
SmartDevice-signer-ca.crt
- 具有 256 位公钥(prime256v1)的 CA 证书,并以根证书作为其 CA(证书颁发机构) -
wifi_config
- 我的 WiFi SSID 和密码
该dev_key.key
文件以 -----BEGIN EC PRIVATE KEY-----
椭圆曲线私钥开头;我使用了openssl验证方法如下:
openssl ec -in dev_key.key -text -noout
并且我还使用 openssl 验证了这两个.crt
文件:-----BEGIN CERTIFICATE-----
openssl x509 -in./SmartDevice-root-ca.crt -text -noout
openssl x509 -in./SmartDevice-signer-ca.crt -text -noout
证书和设备密钥存储在设备上强烈表明它们用于加密 UDP 网络数据包数据。
初始静态分析
现在我们已经了解了存储,是时候了解设备上运行的应用程序了。
我们知道它正在运行factory
分区,所以我part.3.factory
在吉德拉CodeBrowser。Ghidra 是 NSA 提供的免费开源逆向工程工具套件;它是付费软件的替代品。IDA专业版。
我们打开的这个文件是直接从闪存中读取的分区映像;它由多个数据段组成,每个数据段都映射到 ESP32 上不同的虚拟内存区域。例如,0x17CC4
分区映像中偏移量处的数据实际上映射到0x40080ce0
设备的虚拟内存中,因此尽管这个文件包含所有应用程序逻辑和数据,但 Ghidra 无法理解如何解析任何绝对内存引用,至少目前是这样。稍后会详细介绍!
ESP32 微处理器使用 Xtensa 指令集,Ghidra 最近也添加了对此指令集的支持!加载图像时,您可以选择语言Tensilica Xtensa 32-bit little-endian
。我们可以运行自动分析;虽然它目前还无法给出很好的结果,但我们仍然可以查看它能够找到的任何已定义的字符串。
弦理论
已编译应用程序中的文本字符串是逆向工程时定位和理解逻辑的快速方法;它们可以揭示很多有关应用程序的信息。
由于此编译文件仅包含处理器的字节码指令,因此没有函数名称、数据类型或参数。它乍一看可能像一堆乱码,但只要你找到一个像 这样的字符串引用Failed to read wifi config file
,就能开始拼凑出其中的逻辑。对编译后的应用程序进行逆向工程可能很困难,但这无疑是一项值得挑战的挑战。
因此,我查看了Defined Strings
Ghidra 中的窗口,看看能找到什么,并注意到我们在串行输出中看到的所有字符串,例如:
000031c4"Serial Number: %s\r\n"000031fc"Device key ready\r"00003228"Base64 Public Key: %s\r\n"
正如预期的那样,该地址是字符串在分区映像中的位置。理想情况下,这应该是在 ESP32 上运行时虚拟内存中的地址;这样,我们就能看到任何引用该字符串的字节码。我们很快就会解决这个问题!
与这些琴弦紧邻的还有一些其他有趣的东西:
000030d0"Message CRC error\r"00003150"Seed Error: %d\r\n"000031c4"Serial Number: %s\r\n"000031fc"Device key ready\r"00003228"Base64 Public Key: %s\r\n"00003240"Error reading root cert!!!!\r"00003260"Error reading signer cert!!!!\r"00003280"PRNG fail\r"0000328c"ECDH setup failed\r"000032a0"mbedtls_ecdh_gen_public failed\r"000032c0"mbedtls_mpi_read_binary failed\r"000032e0"Error copying server key to ECDH\r"00003304"mbedtls_ecdh_compute_shared failed: 0x%4.4X\r\n"00003334"Error accessing shared secret\r"00003354"####### MBED HKDF failed: -0x%4.4X ########\r\n"00003384"Sign failed\n ! mbedtls_ecp_group_copy returned 0x%4.4X\n"000033c0"Sign failed\n ! mbedtls_ecp_copy returned 0x%4.4X\n"000033f4"Sign failed: 0x%4.4X\r\n"3f403d30"Write ECC conn packet\r\n"
我们可以从这些字符串中提取出很多有用的信息。即使不读汇编代码,我们也可以推测它对这些数据做了什么。
以下是我注意到的:
-
CRC 错误代码:这是一种校验和算法,可能是数据包的一部分。
-
姆贝德斯是一个实现加密原语、X509 证书操作以及 SSL/TLS 和 DTLS 协议的开源库。
-
ECDH 和 HKDF 原语函数直接来自 mbedtls。我们已经知道它没有使用 DTLS 协议,因此我们可以假设它使用它们来实现自定义协议。
-
我们还可以假设附近提到的文件也是相关的:
-
序列号
-
设备密钥
-
根证书
-
签名者证书
-
-
客户端发送“ECC 连接包”;这是 ECDH 密钥交换过程的一部分;我们稍后也会讨论这一点!
Ghidra 设置
好的,现在是时候我们配置 Ghidra 来更好地分析这个 ESP32 应用程序了。
首先,esp32knife 支持将应用程序的二进制分区映像重新格式化为 ELF 格式,这样 Ghidra 更容易理解。为了支持该RTC_DATA
段,我对其进行了一些小调整,并将其推送到我在 GitHub 上的 fork 中:feat: 添加对 RTC_DATA 图像段的支持。
然后我们可以导入更有用的part.3.factory.elf
二进制part.3.factory
分区映像。
但是这次导入时,我们想在运行自动分析之前做几件事,所以我们暂时选择不这样做。
接下来,我们可以使用SVD-加载器-Ghidra脚本从官方导入外围结构和内存映射esp32.svd文件。
我们还可以使用内置SymbolImportScript
脚本加载所有 ROM 功能的标签。我在这里发布了一个文件,其中包含适用于 Ghidra 的 ESP32 所有 ROM 功能标签:ESP32_ROM_标签.txt. 这将帮助我们识别常见的 ROM 功能,例如printf
。
最后,我们从菜单栏运行自动分析Analysis > Auto Analyze
。
让我们看看这对我们之前找到的字符串有什么影响:
3f4031c4"Serial Number: %s\r\n"3f4031fc"Device key ready\r"3f403228"Base64 Public Key: %s\r\n"
我们现在可以看到相同的字符串被正确映射到它们的虚拟内存地址,这意味着分析将检测到引用它们的任何指针或指令!
ESP32 有多个版本,例如ESP32c2
、 和ESP32s2
。我链接的 ROM 标签和.svd
文件是默认的,ESP32.
如果您使用的是其他版本,则需要导入特定的版本.svd
,并按照我的 gist 中的 README 创建特定的 ROM 标签。
固件修改
到目前为止,我的 PCB 位置不太好,无法连接风扇和控制面板。所以,我想看看拔掉它们之后它是否还能正常工作。不幸的是,它无法正常工作;串口记录了以下内容:
I2C read reg fail1
No Cap device found!
REGuru Meditation Error: Core 0 panic'ed (IllegalInstruction). Exception was unhandled.
Memory dump at 0x400da020
现在我们已经很好地配置了 Ghidra,我查看了日志中提到的地址;它就在字符串引用旁边No Cap device found!
,并且在函数开始时,它记录了"CapSense Init\r"
。这肯定是用于使用电容式感应输入的控制面板的!
我在 Ghidra 中将此函数命名为InitCapSense
:
voidInitCapSense(){FUN_401483e0("CapSense Init\r");// ... CapSense logic}
然后,我按照对该函数的引用找到了另一个似乎作为任务/服务启动的函数;我将其重命名为StartCapSenseService:
voidStartCapSenseService(){ _DAT_3ffb2e2c =FUN_40088410(1,0,3);FUN_4008905c(InitCapSense,&DAT_3f40243c,0x800,0,10,0,0x7fffffff);return;}
再次,我跟踪函数引用,找到了调用的函数StartCapSenseService
。使用 Ghidra 的 Patch 指令功能,我将这call
条指令替换为一条nop
(无操作)指令,从而删除了函数调用:
// Original400d9a28 25 63 af call8 FUN_4008905c
400d9a2b 65 31 00 call8 StartCapSenseService
400d9a2e e5 37 00 call8 FUN_400d9dac
// Patched400d9a28 25 63 af call8 FUN_4008905c
400d9a2b f0 20 00 nop
400d9a2e e5 37 00 call8 FUN_400d9dac
我们希望将此更改刷入 ESP32,因此我替换了修改的字节,不是在这个 ELF 文件中,而是在part.3.factory
二进制分区映像中,因为该映像是直接从闪存中读取的原始格式,因此很容易写回。我使用十六进制编辑器查找并替换了以下字节:
2564af 653100 e53700
->2563af f02000 e53700
然后,我将修改后的映像写入 ESP32 闪存的偏移量 处0x10000
,即工厂分区的分区表的偏移量:
esptool -p COM7-b 115200 write_flash 0x10000./patched.part.3.factory
但是当尝试启动它时,我们从串行输出收到错误:
E (983) esp_image: Checksum failed. Calculated 0xc7 read 0x43E (987) boot: Factory app partition isnot bootable
好的,现在有一个校验和。幸运的是,esptool 内部的代码知道如何计算它,所以我写了一个小脚本来修复应用程序分区映像的校验和:功能:添加图像校验和修复脚本。
现在,我们可以使用它来修复校验和并刷入修复后的图像:
python esp32fix.py --chip=esp32 app_image ./patched.part.3.factory
esptool -p COM7-b 115200 write_flash 0x10000./patched.part.3.factory.fixed
我再次尝试在不使用控制面板的情况下启动设备;现在一切正常!我们成功修改了智能设备的固件!
数据包头
让我们重新关注数据包。我们知道数据包不遵循众所周知的协议,这意味着我们必须自己弄清楚它的结构。
我多次捕获设备启动过程中的数据包,并进行比较。我发现前十三个字节与其他数据包相似,而其余部分似乎已被加密。
这是两次启动之间从服务器接收到的第一个数据包;您可以看到数据匹配直到偏移量0x0D
:
Hex View 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000 55 00 2F 82 01 23 45 67 89 AB CD EF FF 37 34 9A U./..#Eg.....74.
00000010 7E E6 59 7C 5D 0D AF 71 A0 5F FA 88 13 B0 BE 8D ~.Y|]..q._......
00000020 ED A0 AB FA 47 ED 99 9A 06 B9 80 96 95 C0 96 ....G..........
Hex View 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000 55 00 2F 82 01 23 45 67 89 AB CD EF FF 81 85 3F U./..#Eg.......?
00000010 8A 10 F5 02 A5 F0 BD 28 73 C2 8C 05 71 6E E4 A3 .......(s...qn..
00000020 A6 36 FD 5C E0 D5 AC 3E 1A D5 C5 88 99 86 28 .6.\...>......(
弄清楚前几个值并不太难,然后我注意到剩下的九个字节与设备串行输出的序列号相匹配,这样我们就得到了数据包头格式:
55 // magic byte to identity the protocol00 31 // length of the packet in bytes02 // message identifier01 23 45 67 89 AB CD EF FF // device serial
-
魔术字节通常用于唯一地标识特定格式的数据。
-
在这样的数据包中,与大小相关的字节和消息 ID 是很常见的。
最先发送和接收的数据包与随后发送和接收的数据包的格式略有不同;00 01
客户端数据包的标头后面总是有字节,并且它是唯一一个消息 ID 为 的数据包0x02
。
将其与其他数据包进行比较,我注意到消息 ID 有一个模式:
-
0x02
- 从智能设备发送的第一个数据包 -
0x82
- 从云服务器收到的第一个数据包 -
0x01
- 从智能设备发送的所有其他数据包 -
0x81
- 从云服务器接收的所有其他数据包
您可以看到,此值的高位表示它是客户端请求(0x00
)还是服务器响应(0x80
)。而低位在第一次交换(0x02
)和所有其他数据包(0x01
)之间是不同的。
数据包校验和
我们之前注意到应用程序中有一个字符串,暗示"Message CRC error\r"
数据包中有一个 CRC 校验和。了解数据中是否存在校验和会很有帮助,这样就不会干扰任何解密尝试。
我跟踪了对这个字符串的引用,发现有一个函数引用了它。
让我们看一下该函数的反编译代码:
// ...iVar1 =FUN_4014b384(0,(char *)(uint)_DAT_3ffb2e40 +0x3ffb2e42);iVar2 =FUN_400ddfc0(&DAT_3ffb2e44, _DAT_3ffb2e40 -2);if(iVar1 == iVar2){if(DAT_3ffb2e47 =='\x01'){FUN_400db5c4(0x3ffb2e48, _DAT_3ffb2e40 -6);}elseif(DAT_3ffb2e47 =='\x02'){FUN_401483e0(s_Connection_message_3f4030e4);} pcVar3 =(char *)0x0; _DAT_3ffb3644 =(char *)0x0;}else{FUN_401483e0(s_Message_CRC_error_3f4030d0); pcVar3 =(char *)0x0; _DAT_3ffb3644 =(char *)0x0;}// ...
我们可以看到块s_Message_CRC_error
中正在使用的标签else
,因此该if
语句必须验证消息的 CRC 数据。
FUN_4014b384
此逻辑比较两个函数和的结果FUN_400ddfc0
。如果这是验证数据包的校验和,则一个函数必须为数据包数据生成校验和,另一个函数必须从数据包中读取校验和值。
我们可以使用这些参数来帮助我们判断哪个是哪个,但让我们看一下两者:
uint FUN_4014b384(int param_1, byte *param_2){ uint uVar1;if(param_1 ==0){ uVar1 =(uint)*param_2 *0x100+(uint)param_2[1];}else{ uVar1 =(uint)*param_2 +(uint)param_2[1]*0x100;}return uVar1 &0xffff;}
这看起来不像是一个 CRC 函数。它实际上看起来像一个读取可配置字节序的 16 位 uint 的函数;原因如下:
-
将一个值乘以
0x100
(256) 相当于左移 8 位(16 位值的一半),因此0x37
变为0x3700
。第一个代码块中的逻辑将其添加到索引 [1] 处的字节;这是内存中紧随其后的下一个字节,因此这基本上是从指针if
读取一个大端 uint16param_2
-
代码块的逻辑
else
类似,但移位的是第二个字节而不是第一个字节,因此读取的是小端 uint16 数据。因此,该param_1
参数配置了结果的字节序。 -
返回语句
&
对返回值执行按位与 () 运算符0xFFFF
,通过将任何高位清零将值限制为 16 位数据。
uint FUN_400ddfc0(byte *param_1, uint param_2){ uint uVar1; uint uVar2; byte *pbVar3; pbVar3 = param_1 +(param_2 &0xffff); uVar1 =0xffff;for(; pbVar3 != param_1; param_1 = param_1 +1){ uVar1 =(uint)*param_1 <<8^ uVar1; uVar2 = uVar1 <<1;if((short)uVar1 <0){ uVar2 = uVar2 ^0x1021;} uVar1 = uVar2 &0xffff;}return uVar1;}
现在,这看起来更像是一个校验和函数;for
里面有一个循环,其中包含一堆按位运算符。
我打开一个捕获的数据包ImHex,一款面向逆向工程师的十六进制编辑器。它有一个方便的功能,可以显示当前选定数据的校验和。
因为另一个函数读取 16 位 uint,所以我选择 CRC-16 并开始选择可能被散列的字节区域,留下 2 个字节未选择,我认为 16 位散列可能在那里。
到目前为止还没有找到,但后来我注意到可以在 ImHex 中配置 CRC-16 参数。因此,我尝试了一种简便的快捷方式,设置 ImHex 使用反编译函数中找到的值,通过一系列不同的参数组合来计算 CRC-16 校验和。
成功了!数据包的最后两个字节是数据包中所有其他数据的 CRC 校验和,具体来说是带有0x1021
多项式和0xFFFF
初始值的 CRC-16 校验和。我用其他数据包检查了这一点,它们都通过了校验和。
现在我们知道每个数据包的最后 2 个字节是 CRC-16 校验和,可以将其排除在任何解密尝试之外!
密钥交换
之前,我们注意到mbedtls
标记为 ECDH 和 HKDF 的原语。那么,它们到底是什么呢?
ECDH(椭圆曲线迪菲-赫尔曼密钥交换)是一种密钥协商协议,允许两方(例如智能设备及其云服务器)通过不安全通道(UDP)建立共享密钥,双方各持有一个椭圆曲线公钥和私钥对。我在《面向开发者的实用密码学》一书中找到了对此的更详细的解释:ECDH密钥交换。
本质上,如果智能设备和服务器生成一个 EC 密钥对并交换各自的公钥,他们就可以使用对方的公钥和自己的私钥来计算共享密钥。这个共享密钥可以用来加密和解密数据包!即使他们在不安全的网络上交换公钥,你仍然需要其中一个私钥来计算共享密钥。
这对于保护此类数据包非常理想,并且客户端发送的第一个数据包实际上ECC conn packet
在日志中被命名为:
UDP Connect: smartdeviceep.---.com
smartdeviceep.---.com = 192.168.0.10UDP Socket created
UDP RX Thread Start
Write ECC conn packet
这是一个巨大的进步;我们知道第一个数据包交换很可能是交换 EC 公钥以建立 ECDH 密钥协议来加密所有其他数据包。
如果我们忽略数据包头(从起始处算起 13 个字节)和校验和(末尾 2 个字节),我们可以看到,用于此潜在密钥交换的数据包内容均为 32 字节(256 位),这对于公钥来说是合法的大小。即使客户端的请求00 01
在起始处包含这些内容,我们也可以假设这只是一些不重要的数据描述符,因为它的值在启动期间不会改变:
// Client request packet contents:Hex View 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000 00 01 D1 C2 B3 41 70 17 75 12 F7 69 25 17 50 4A .....Ap.u..i%.PJ
00000010 C5 DD D4 98 06 FE 24 6B 96 FD 56 14 4A 70 7E 51 ......$k..V.Jp~Q
00000020 55 57 UW
// Server response packet contents:Hex View 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000 07 A8 02 73 52 42 1F 1F C1 41 B4 E4 5B D9 A9 9A ...sRB...A..[...
00000010 5A DD 0F 94 F1 AB 9E E8 86 C7 99 7E 08 68 52 C5 Z..........~.hR.
好的,那么 HKDF 是什么呢?它是基于 HMAC 的密钥派生。它可以将通过 Diffie-Hellman 计算出的共享秘密转换为适合加密的密钥材料。哇,这很有道理;它很可能就是用来派生密钥来加密和解密其他数据包的。
密码学分析
为了解密这些数据包,我们需要准确了解加密密钥的生成方式。这包括所有可能的输入数据以及可配置选项。
可以安全地假设 ECDH 和 HKDF 函数用于数据包数据,因此重点关注密钥生成过程,我总结了我们需要了解的变量:
在我们假设的密钥交换过程中,智能设备及其云服务器都会交换 256 位数据。但请记住,智能设备固件还会从存储中加载以下密钥:
-
256 位设备密钥对(私钥和公钥)
-
256位云服务器
"root"
公钥 -
256位云服务器
"signer"
公钥
这里有很多可能性,所以我再次查看了 Ghidra 中的应用程序。通过跟踪错误字符串,我找到了生成此密钥的函数!我通过将汇编代码与 mbedtls 源代码进行比较,逐步标记了函数和变量。我将其注释并简化为以下伪代码:
intGenerateNetworkKey(uchar *outputKey, uchar *outputRandomBytes){// Generate an ECDH key pair char privateKey1 [12]; char publicKey1 [36];mbedtls_ecdh_gen_public( ecpGroup, privateKey1, publicKey1,(char *)mbedtls_ctr_drbg_random, drbgContext
);// Overwrite generated private key?mbedtls_mpi_read_binary(privateKey1,(uchar *)(_DAT_3ffb3948 +0x7c),1);// Overwrite generated public key?mbedtls_ecp_copy(publicKey1,(char *)(_DAT_3ffb3948 +0x88));// Load another public key? char publicKey2 [36];mbedtls_ecp_copy(publicKey2,(char *)(_DAT_3ffb38cc +0x88));// Compute shared secret key using privateKey1 and publicKey 2 char computedSharedSecret [100]; uchar binarySharedSecret [35];mbedtls_ecdh_compute_shared( ecpGroup, computedSharedSecret, publicKey2, privateKey1,(char *)mbedtls_ctr_drbg_random, drbgContext
);mbedtls_mpi_write_binary(computedSharedSecret, binarySharedSecret,0x20);// Generate random bytesmbedtls_ctr_drbg_random(globalDrbgContext, outputRandomBytes,0x20);// Derive key mbedtls_md_info_t *md =mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); uchar* deviceSerialNumber =(uchar *)GetDeviceSerialNumber();mbedtls_hkdf( md, binarySharedSecret,// salt0x20, outputRandomBytes,// input0x20, deviceSerialNumber,// info9, outputKey,0x10);}
能够解释 Ghidra 中的汇编代码甚至反编译代码当然是一项后天习得的技能;我想强调的是,这需要一段时间才能弄清楚,中间有很多中断!
这个函数做了一些不寻常的事情;我们可以从中了解到以下内容:
-
生成的 ECDH 密钥对被丢弃,并被从内存中其他地方加载的密钥替换,这很奇怪。由于 ECDH 密钥对生成函数在应用程序中没有其他地方使用,因此这些密钥很可能是我们之前看到的固件存储中的文件。
-
HKDF 使用的算法是
SHA-256
。 -
计算出的共享秘密用作HKDF
salt
。 -
随机字节被生成为 HKDF
input
。 -
设备序列号用作 HKDF
info
。 -
HKDF 输出密钥大小为
0x10
(16 字节/128 位)。
我们现在对智能设备如何生成潜在加密密钥有了更好的了解。
请记住,他们的云服务器也必须生成此密钥,这意味着它需要具有与 HKDF 相同的所有输入变量。
了解了这一点,我们可以回顾一下 HKDF 函数的三个动态输入,并了解服务器如何拥有它们:
-
salt
- 共享秘密:服务器必须能够访问用于 ECDH 共享秘密计算的相同私钥和公钥,或者将公钥用于我们的私钥,将私钥用于我们的公钥。 -
input
- 随机字节:服务器必须能够访问智能设备上这些随机生成的字节;要么我们把这些字节发送到服务器,要么从技术上讲,服务器可以重新创建所使用的伪 RNG 方法。然而,生成的字节大小为0x20
(32 字节 / 256 位),与密钥交换数据包中发送的数据大小完全匹配,因此我们很有可能将其发送到那里! -
info
-设备序列号:我们已经知道设备序列号是数据包头的一部分,因此服务器可以轻松访问该值。
我很好奇应用程序如何处理这些随机生成的字节,于是我检查了调用函数对它们做了什么:
stack[0]=0x00;stack[1]=0x01;GenerateNetworkKey(&KeyOutput, stack[2]);log(2,2,"Write ECC conn packet\r\n");SendPacket((int)param_1,2, stack[0],0x22);
我们可以看到,随机字节GenerateNetworkKey
被写入了堆栈,更棒的是,这些00 01
字节恰好在它之前写入堆栈,然后所有0x22
字节都被放入数据包中发送。这与我们在密钥交换数据包中看到的格式完全一致!
记录关键数据
通过静态分析已经取得了很大进展,我们计算解密密钥所需的最终值是共享秘密。
在逆向工程的这个阶段,我还没有像这篇博文中展示的那样干净地逆向函数,而是想尝试直接从设备动态获取密钥。
在这里,通过 JTAG 进行调试是明智的选择。但是,我没有注意到 PCB 上这些引脚的分线点,而且我想避免直接焊接到 ESP32 引脚上,所以我想挑战一下自己,修改一下固件,让它通过串口打印!
CapSense 服务仍然被禁用,所以我想我会在该逻辑上编写一个函数来打印出共享密钥并在计算后立即调用它!
因此,在伪代码中,我想将我的函数调用添加到该GenerateNetworkKey
函数中。在它生成密钥之后:
intGenerateNetworkKey(uchar *outputKey, uchar *outputRandomBytes){// ... // Add my function call:print_key(binarySharedSecret);}// Custom function saved over unused logic:voidprint_key(char *key){for(int i =0; i <32; i++){log("%2.2x", key[i]);}}
虽然提到Xtensa指令集架构手册,我把一些程序集拼凑在一起,像这样:
// Original400dbf2d 25 4b 6c call8 GetDeviceSerialNumber// Patched400dbf2d e5 ff fd call8 print_key// print_key:400d9f2c 36 41 00 entrya1, 0x20400d9f3b 42 c2 20 addia4,a2, 0x20400d9f3e 52 a0 02 movia5, 0x2400d9f41 61 ea db l32ra6, PTR_s_%2.2x // "%2.2x"400d9f44 d2 02 00 l8uia13,a2, 0x0400d9f47 60 c6 20 mova12,a6400d9f4a 50 b5 20 mova11,a5400d9f4d 50 a5 20 mova10,a5400d9f50 22 c2 01 addia2,a2, 0x1400d9f53 25 ed 05 call8 log400d9f56 27 94 ea bnea4,a2, LAB_400d9f44400d9f59 22 a0 00 movia2, 0x0400d9f5c 90 00 00 retw
我们修补GetDeviceSerialNumber
函数调用,因为这是在共享密钥生成之后直接进行的,并且指向密钥的指针仍然在寄存器中a2
。
我刷入了修改后的固件,启动了设备,并检查了串行输出:
Write ECC conn packet
e883eaed93c63d2c09cddebce6bb15a7f4cb5cedf00c1d882b8b292796254c9c
成功!我们打印出了共享密钥!
我重启了设备好几次,想看看密钥是否发生了变化,结果发现它一直没变。这很可能是使用固件存储中的密钥计算出来的,但现在我们有了计算出来的静态值,就不需要再逆向计算了。
数据包解密
好的,我们现在了解了导出解密密钥的方法并获得了所有输入值;它看起来像这样:
const hkdfOutputKey =hkdf({method:'SHA-256',salt: Buffer.from('e883eaed93c63d2c09cddebce6bb15a7f4cb5cedf00c1d882b8b292796254c9c','hex'),input: randomBytesFromDeviceKeyExchangePacket,info: deviceSerialNumber,outputKeySize:0x10,});
为了安全起见,我编写了另一个固件补丁,打印 HKDF 调用的密钥输出,并尝试从捕获的数据包中重新创建密钥。成功了!这证实我们已经正确地逆向了密钥创建函数,并且能够在我们自己的应用程序中复制密钥创建逻辑。
但现在我们需要找到所使用的加密算法。我回顾了格式化数据包的函数,找到了对加密函数的调用:
char randomBytes [16];// Write device serialmemcpy(0x3ffb3ce0, deviceSerialNumber,9);// Generate and write random bytesmbedtls_ctr_drbg_random(globalDrbgContext, randomBytes,0x10)memcpy(0x3ffb3ce9, randomBytes,0x10);// Write packet datamemcpy(0x3ffb3cf9, data, dataSize);// Pad with random bytesmbedtls_ctr_drbg_random(globalDrbgContext dataSize +0x3ffb3cf9, paddingSize);// Run encryption on the data + paddingFUN_400e2368(0x3ffb3cf9, dataSize + paddingSize,&HKDFOutputKey, randomBytes);
我注意到,在将设备序列号复制到数据包后,会生成16个随机字节,并直接复制到其后。这些字节也会提供给加密函数。因此,我们知道它们是加密算法的输入变量。
我们知道密钥是 128 位,另外还有 128 位附加随机数据。
我研究了加密函数,由于循环执行了一堆按位操作,该函数显然与加密相关,并且注意到对静态数据块的引用。
这些数据始于63 7C 77 7B F2 6B 6F C5
,在 mbedtls 源代码中进行搜索后发现它是AES 正向 S-Box!
我决定直接尝试对捕获的数据包进行 AES 解密,并成功解密了一个数据包!!🎉
Hex View 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000 00 00 65 00 53 00 82 A4 74 79 70 65 AF 6D 69 72 ..e.S...type.mir
00000010 72 6F 72 5F 64 61 74 61 5F 67 65 74 A4 64 61 74 ror_data_get.dat
00000020 61 85 A9 74 69 6D 65 73 74 61 6D 70 CF 00 00 01 a..timestamp....
00000030 8D 18 05 31 FB A9 46 41 4E 5F 53 50 45 45 44 00 ...1..FAN_SPEED.
00000040 A5 42 4F 4F 53 54 C2 A7 46 49 4C 54 45 52 31 00 .BOOST..FILTER1.
00000050 A7 46 49 4C 54 45 52 32 00 07 07 07 07 07 07 07 .FILTER2........
该算法是AES-128-CBC
,并使用附加随机数据作为IV
(初始化向量)。
中间人攻击
现在,我们可以创建 MITM(中间人)攻击,而无需任何固件修补。这是因为设备的私钥已经已知,密钥派生逻辑已被逆向工程,并且任何所需的动态数据都会在不安全的网络上暴露。
如果它正确实现了 ECDH,智能设备将拥有一个不会暴露的唯一私钥,而我们最简单的攻击途径就是生成我们自己的服务器密钥对并进行任何固件修改,以便设备接受我们的自定义公钥。
但由于其自定义协议的设计,我们可以编写一个中间人攻击 (MITM) 脚本,该脚本无需对智能设备进行任何修改,即可拦截、解密甚至修改网络通信。所以,这就是我们要做的!
现在的主要目标是解密和记录尽可能多的数据;然后,我们可以参考它来编写一个完全替代其云服务器的本地服务器端点。
我编写了一个快速的 Node.js 脚本来执行此操作:
const dns =require("dns");const udp =require("dgram");const crypto =require("crypto");const hkdf =require("futoin-hkdf");const fs =require("fs");// Key Genconst sharedSecretKey = Buffer.from("e883eaed93c63d2c09cddebce6bb15a7f4cb5cedf00c1d882b8b292796254c9c","hex");functioncalculateAesKey(deviceSerialNumber, inputData){returnhkdf(inputData,16,{salt: sharedSecretKey,info: deviceSerialNumber,hash:"SHA-256",});}// Packet Parsinglet latestAesKey =null;let packetCounter =0;const proxyLogDir = path.join(__dirname,"decrypted-packets");functiondecryptPacket(data, deviceSerial){constIV= data.subarray(0xd,0x1d);const encryptedBuffer = data.subarray(0x1d, data.length -2);const decipher = crypto.createDecipheriv("aes-128-cbc", latestAesKey, parsed.IV); decipher.setAutoPadding(false);return Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]);}functionlogPacket(data){const messageId = data.readUInt8(3);const deviceSerial = data.subarray(4,4+9);if(messageId ===2){// Key Exchangeconst randomlyGeneratedBytes = data.subarray(0xf, data.length -2); latestAesKey =calculateAesKey(deviceSerial, randomlyGeneratedBytes);}else{// Encrypted Packets fs.writeFileSync( path.join(proxyLogDir,`packet-${id}.bin`),decryptPacket(data));}}// Networkingdns.setServers(["1.1.1.1","[2606:4700:4700::1111]"]);constPORT=41014;const cloudIp = dns.resolve4("smartdeviceep.---.com")[0];const cloud = udp.createSocket("udp4");let latestClientIp =null;let latestClientPort =null;cloud.on("message",function(data, info){logPacket(data); local.send(data, latestClientIp, latestClientPort);});const local = udp.createSocket("udp4");local.bind(PORT);local.on("message",function(data, info){logPacket(data); latestClientIp = info.address; latestClientPort = info.port; cloud.send(data,PORT, cloudIp);});
在这里,我们结合所有研究来实施 MITM 攻击。
就像我们第一次捕获数据包时一样,我们配置 Node.js 以使用 Cloudflare 的 DNS 解析器来绕过我们的本地 DNS 服务器。
我们在本地创建一个 UDP 套接字来接受来自智能设备的数据包,并创建一个套接字与云服务器通信。
-
我们从智能设备接收到的任何信息,都会被记录并发送到云服务器
-
我们从云服务器收到的任何信息,我们都会记录并发送到智能设备
我们将 2 的数据包messageId
视为密钥交换数据包,其中智能设备将随机字节发送到服务器,然后我们计算用于解密未来数据包的 AES 密钥。
在拍摄时,我使用他们的移动应用程序远程控制智能设备,以便我们可以参考日志并自己复制逻辑。
数据交换格式
我们现在有了解密的数据包数据,但数据仍然是序列化的二进制格式:
Hex View 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000 01 00 64 00 29 00 82 A4 74 79 70 65 A7 63 6F 6E ..d.)...type.con
00000010 6E 65 63 74 A8 66 69 72 6D 77 61 72 65 C4 10 00 nect.firmware...
00000020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 83 ................
我的思想深入逆向工程的世界,我设法逆转所有数据包的结构,并整合一些 JavaScript 来实现数据与 JSON 之间的相互转换。
标头非常简单,同样只是一些 ID 和长度,但采用小端顺序:
-
01 00
- 数据包ID -
64 00
- 交易ID -
29 00
- 序列化数据长度
经过一些修改,我找到了序列化的格式:
-
82
- 地图 -
A4
- 长度为 4 的字符串 -
A7
- 长度为 7 的字符串
逆向起来很有趣,因为输入更多地是用位来描述的,但对于这些简单的情况,从字节上可以清楚地读取。
回想起来,我不明白为什么我没有寻找一个符合这种序列化二进制数据格式的现有解决方案;我当时以为一切都是自定义解决方案。但现在搜索了一下,发现这只是消息包,所以我想我只是进行了逆向工程并编写了部分 msgpack 实现😆
切换到流行的实现,我们可以看到数据很容易解压成 JSON:
const{ unpack, pack }=require('msgpackr');const packedData = Buffer.from('82A474797065A7636F6E6E656374A86669726D77617265C41000000000000000000000000000000000','hex');const unpackedData =unpack(packedData);// unpackedData:{type:'connect',firmware:<Buffer 00000000000000000000000000000000>}
网络日志分析
在为智能设备编写自定义本地服务器的准备中,让我们看一下捕获的解压后的网络日志:
🔑 密钥交换包:
智能设备向服务器发送随机字节以供 HKDF 使用。
// Smart Device RequestD1C2B34170177512F7692517504AC5DDD49806FE246B96FD56144A707E515557// Server Response00000000000000000000000000000000
↙️获取设备状态:
智能设备启动时从服务器获取其初始状态。
// Smart Device Request{type:'mirror_data_get'}// Server Response{type:'mirror_data_get',data:{timestamp:1705505010171n,FAN_SPEED:0,BOOST:false,FILTER1:0,FILTER2:0}}
🔗连接时:
当智能设备连接到服务器时,它会发送其当前固件的 UUID。服务器会响应可能下载的固件或配置更新的 UUID。
// Smart Device Request{type:'connect',firmware:<Buffer 00000000000000000000000000000000>}// Server Response{type:'connect',server_time:1706098993961n,firmware:<Buffer ab cd ef ab cd ef ab cd ef ab cd ef ab cd ef ab>,config:<Buffer 00000000000000000000000000000000>,calibration:<Buffer 00000000000000000000000000000000>,conditioning:<Buffer 00000000000000000000000000000000>,server_address:'smartdeviceep.---.com',server_port:41014,rtc_sync:{ss:13,mm:23,hh:12,DD:24,MM:1,YYYY:2024,D:3}}
⤵️服务器更新智能设备状态:
当服务器想要更新智能设备的状态时,它会发送这样的数据包。
// Server Request{type:'mirror_data',data:{FAN_SPEED:1,BOOST:false}}
⤴️智能设备更新服务器状态:
智能设备每当状态发生变化时,就将其最新状态发送到服务器。
// Smart Device Request{type:'mirror_data',data:{timestamp:1706105072142n,FAN_SPEED:1,BOOST:false,FILTER1:0,FILTER2:0}}// Server Response{type:'mirror_data'}
🛜保持活力:
智能设备频繁向服务器发送保持活动数据包,以便服务器可以使用开放连接来发送状态更新。
// Smart Device Request{type:'keep_alive',stats:{rssi:-127n,rtt:684,pkt_drop:1,con_count:1,boot_str:'',uptime:100080}}// Server Response{type:'keep_alive'}
MQTT 桥接器
我们需要一种方法将 Home Assistant 连接到我们的自定义服务器,该服务器负责处理智能设备网络。MQTT非常适合这种情况;它是一种专为物联网消息传递而设计的协议,可以在 Home Assistant 中轻松配置。为此,我设置了蚊子Home Assistant 的插件,它是一个将一切连接在一起的开源 MQTT 代理。
连接链将如下所示:
Home Assistant
<--> MQTT Broker
<--> Custom Server
<--> Smart Device
。
伪代码中的自定义服务器逻辑看起来像这样:
functionHandleSmartDeviceRequest(req){switch(req.type){case'mirror_data_get':{// Device wants state, send latest MQTT state or default fallback device.send({fan_speed: mqtt.get('fan_speed')||0});return;}case'mirror_data':{// Device state has changed, publish and retain in MQTT broker mqtt.publish('fan_speed', req.fan_speed,{retain:true});return;}}}functionHandleMQTTMessage(topic, msg){switch(topic){case'set_fan_speed':{// MQTT wants to change state, forward to device device.send({fan_speed: msg.fan_speed });return;}}}
这种逻辑看似简单,但经过精心设计。最新状态保留在 MQTT 代理中。然而,状态更新的真实来源始终是设备,这意味着除非设备通过自定义服务器进行更新,否则状态永远不会在 MQTT 代理中更新。这涵盖了以下几种极端情况:
-
如果状态更新不成功,我们不应该显示状态已更新。
-
如果智能设备通过其物理控制面板进行更新,则状态更新应通过 MQTT 代理反映。
我们在此支持的三个主要案例是:
-
当智能设备启动并首次连接到自定义服务器时,它会请求最新状态;我们可以尝试从 MQTT 代理的保留值中获取该状态,或者恢复到默认状态。
-
当 Home Assistant 想要更新状态时,它会向 MQTT 代理发送命令。我们可以从自定义服务器订阅这个命令主题,并将请求转发给智能设备。
-
当智能设备的状态由于任何原因发生变化时,它会发送数据
mirror_data
包来更新服务器状态;我们将此值发送给 MQTT 代理以更新状态,并告诉它将数据保留为最新值。
我在我的小型家庭自动化服务器上运行了这个自定义服务器以及 Mosquitto 和 Home Assistant。然后配置了我的 Pi-hole 本地 DNS,将云服务器的域名解析到我的自定义服务器上。
家庭助理集成
此过程的最后一步是配置 Home Assistant,将 MQTT 主题映射到设备类型。对于我的空气净化器来说,最接近的集成是MQTT 风扇;在我的configuration.yaml
我添加了这样的内容:
mqtt:fan:-name:"Air Purifier"unique_id:"air_purifier.main"state_topic:"air_purifier/on/state"command_topic:"air_purifier/on/set"payload_on:"true"payload_off:"false"percentage_state_topic:"air_purifier/speed/state"percentage_command_topic:"air_purifier/speed/set"speed_range_min:1speed_range_max:4
我添加了控制风扇速度以及打开和关闭设备的主题。
一切正常!我已经用了几个星期了,一直运行良好,没有任何问题!我甚至设置了一点自动化功能,所以如果我的独立空气监测器的PM2.5或VOC水平过高,它就会在一段时间内增强空气净化器的运行!
技术回顾
不管怎样,该服务背后的工程师决定不实现像 DTLS 这样的标准协议。他们创建了一个自定义解决方案,但这给系统带来了一些弊端:
-
我们不确定每个设备是否都有自己独特的私钥,但无论是否有,都有缺点:
-
如果所有设备共享相同的固件私钥,攻击者只需对一个设备进行逆向工程即可对任何其他设备进行 MITM 攻击。
-
然而,如果每个设备都有自己独特的私钥,服务器就必须维护一个数据存储,将设备序列号映射到每个设备的密钥。因此,一旦发生任何数据丢失,服务器将完全失去响应任何设备通信的能力;这对企业来说是一个可怕的想法。除非有一个不安全的网络回退机制,但这同样令人担忧,而且开发起来非常耗时。
-
-
由于固件包含静态私钥,攻击者只需转储一次固件即可获取密钥并发起中间人攻击。然而,如果在运行时生成 EC 私钥,则需要写入权限才能修补服务器公钥或应用程序固件,而这些固件可以通过其他方式保护。
此外,这款移动应用在应用商店的评价只有一星。这让我不禁怀疑,出乎意料的定制技术实现和异常糟糕的终端用户应用体验之间是否存在关联。构建定制系统远不止最初的开发;系统需要支持,错误也需要修复。
总的来说,从安全角度来看,这并不是一个糟糕的实现;你仍然需要物理访问才能攻击设备;任何事物都有其利弊,也存在从我们的角度来看不可见的变量。
自定义实现增加了网络通信的隐蔽性。然而,通过隐匿性实现安全这只是短期的胜利。虽然它或许能阻止针对标准技术实现的通用攻击。但从更宏观的角度来看,它只不过是一个恼人却又可绕过的陷阱,攻击者可以轻易越过。
最近我和大家聊过一些关于工程师为什么要从零开始构建,而不是使用成熟的标准。这是一个非常有趣的话题;我会留到下次再写!
结论
那是一次多么疯狂的旅程啊!
我想强调的是,逆向工程的过程并不像这篇文章看起来那么顺利;我已尽力将所有内容排版得更清晰,以便您阅读。但实际上,我经常处于迷茫之中,不确定下一步是否可行,不得不同时处理许多任务和理论,反复在多个地方取得进展,以尽快验证我的假设。
我尝试了一些方法,但都无功而返,不值得在这篇文章中专门讨论:
-
我尝试运行固件Espressif 的 QEMU 分支修补了 CapSense 服务,并加载了虚拟电子保险丝以匹配固件中的 MAC 地址,结果发现它不支持 WiFi 模拟。不过,看到它虚拟启动还是挺有意思的!
-
在完全逆向应用程序逻辑之前,我还尝试刷入不同的序列号、设备密钥和证书,看看是否会产生影响。结果并没有太多进展。事实证明,这很可能只会影响用于 HKDF 盐值的计算共享密钥,而我们无论如何都会将其转储。
这个项目确实让我提升了不少技能。我也很自豪,终于实现了把这个设备添加到 Home Assistant 的目标!成功解密第一个数据包的那一刻真是太棒了;一切都水到渠成了。
我仍然对创建一个开源项目来去云化和调试智能家居产品充满好奇;我已经学到了很多关于实现这一目标的技术方面的知识。
感谢阅读!希望这篇文章对你有所帮助。我为创作它付出了巨大的努力,甚至可能比我实际完成这个项目的投入还要多。如果能收到关于格式的反馈,那就太好了!
如果您能帮助分享该帖子,我将不胜感激。
您可以关注十了解我正在做的事情。
如果您觉得它有用并愿意支持我的内容创作,您可以给我买杯咖啡!您的支持帮助我继续创作内容并分享我对逆向工程的热情!
放轻松👋
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论