通过API与硬件黑客技术攻陷数百万智能体重秤
Mar 24, 2025 · 2580 words · 13 minute read
悄悄说:我正在与No Starch Press合作出版一本书!我写了《从零日到零日:漏洞研究实战指南》,面向希望进入漏洞研究领域的新手。从代码审查到逆向工程再到模糊测试,我不仅讲解了“做什么”,还深入探讨了“如何做”来寻找零日漏洞——这些都是我希望从一开始就能学到的东西。现已可在包括亚马逊在内的所有常规渠道进行早期访问,2025年6月正式发布!
为什么只攻击一个设备,当你可以攻击所有设备?通过逆向工程并发现智能体重秤用户设备关联流程中的漏洞,我成功控制了数百万台联网健康设备。硬件安全和Web安全是现代智能设备安全的两个重要方面,学会攻击两者可以带来令人印象深刻且可怕的结果。这篇博客文章从头到尾介绍了攻击联网智能设备的基础知识,重点关注用户设备关联的关键工作流程。
联网的……体重秤??? 🔗
假期期间,我注意到酒店健身房体重秤屏幕上有一个奇怪的图标。是一个WiFi图标。我惊恐地意识到,人们现在认为将体重秤连接到互联网是个好主意(安息吧,垃圾物联网)。当我在亚马逊上查看时,注意到大量可用的选项都支持WiFi或蓝牙连接,许多还配有可疑相似的移动应用。
事实上,许多都是由同一家原始设备制造商(OEM)生产的。即使它们是由代码库略有不同的不同OEM制造的,快速查看相关的Android应用也会发现,许多应用使用了相同的通用库,例如com.qingniu.heightscale,大概是因为从头编写兼容库的工作量要小得多。
虽然与BLE协议相关的代码很有趣,让我能够找出通过蓝牙与这些设备通信的正确操作码,但其中大部分已经被openScale项目逆向工程并记录了下来。无论如何,尝试找出需要近距离物理接触的本地漏洞并不是很有趣。
我们需要更深入 🔗
如果你的目标不仅仅是攻击一个设备,而是所有设备,那么一个关键目标就是用户设备关联流程。例如,当你第一次购买智能设备并开箱时,通常需要登录移动应用并扫描二维码或通过蓝牙与设备配对。完成后,你在制造商Web服务上的用户账户现在与物理设备关联。
这是一个难以保护的过程。从工厂开始,每个设备都需要一个唯一的设备标识符/密钥,这样在扫描设备A的二维码时就不会意外地与设备B配对。最不安全的方法是使用静态字符串,如UUID、MAC地址或序列号。虽然这些作为标识符可能没问题,但作为认证密钥并不安全。即使它们可能是随机生成的,因此难以暴力破解,但如果泄露,撤销它们将非常困难。
更安全的选择是生成加密密钥,如公钥/私钥对。这仍然使其成为物理内存提取的目标,如果密钥生成过程在某些方面较弱,攻击者仍可能为任何设备生成任意密钥。传统的解决方案是依赖良好的公钥基础设施和证书架构,允许轻松撤销受损证书。
因此,典型流程如下:
- 用户安装移动应用并使用其用户账户登录。
- 通过应用,用户连接到硬件设备。
- 硬件设备的密钥发送到移动应用。
- 移动应用将用户的密钥(例如会话令牌)和设备的密钥发送到服务器。
- 服务器确认密钥的真实性,并将用户账户与硬件设备关联。
- 用户现在可以通过互联网远程控制和从硬件设备获取数据。
看起来合理。会出什么问题呢?
OEM中的SQL注入(BT-WAF绕过) 🔗
相关的OEM一开始就跌倒了。甚至不需要购买物理设备,我就枚举了移动应用上可用的API端点,包括一个有趣的api/ota/update端点。我以为这将允许我获取固件以进一步了解设备。多亏了Android移动应用的反编译Java代码,我能够轻松重建所需的JSON主体参数。然而,即使输入正确,制造商似乎也没有太多更新可以分享。
不幸的是,在探索API端点时,我发现有几个端点存在基本的SQL注入漏洞。有趣的是,服务器使用了一个名为Baota Cloud WAF(BT-WAF)的中国WAF,它比我之前面对的许多典型WAF要强大得多。特别是,一个/api/device/getDeviceInfo端点允许查找设备的序列号,这些序列号被该制造商用作标识符和认证密钥。序列号本身用于一个/api/device/bindv2端点,该端点将请求用户的账户与序列号引用的设备绑定或关联!“序列号”本身是一个随机生成的MAC地址,存储在设备上。
以下是易受攻击端点的初始负载主体:
1
2
3
|
{
"serialnumber":"'001122334455"
}
|
这里没有太多可操作的空间。如果有第二个注入点,我可能能够制定一个更微妙的跨负载。经过大量试错,我最终成功绕过了BT-WAF:
1
2
3
|
{
"serialnumber":"'or\n@@version\nlimit 1\noffset 123#"
}
|
让我们稍微分解一下。如果这注入到像SELECT * FROM devices WHERE serial = 'INJECTION'
这样的SQL语句中,最终注入的SQL将是:SELECT * FROM devices WHERE serial = 'INJECTION'or\n@@version\nlimit 1\noffset 123#'
。这里有两个关键的绕过小工具:
@@version
总是评估为true,可以用于代替更明显的1=1
。
\n
换行符可以打破语句,而不是空格。
有了这个,我现在能够泄露任何设备的设备信息,包括用作认证密钥的序列号!事实证明,通过增加偏移量,设备数量超过二十万台。
在Withings WBS06上获取串行调试Shell 🔗
当我转向研究其他设备时,我遇到了Withings Body体重秤。与其他设备类似,它具有WiFi和蓝牙连接以及自定义移动应用。这是一个更有信誉的品牌,有趣的是,它似乎也与诺基亚Body秤共同品牌。
通过应用的API拉取固件进行进一步分析相对容易。然而,与路由器等使用的更复杂的固件(通常包括完整的文件系统和Linux操作系统)不同,这是一个裸机ARM固件。我希望我能更多地阐述逆向裸机ARM的艺术,但经验更丰富的专家的视频和博客基本上告诉了你同样的事情:这非常困难。
尽管如此,我还是尝试了一下,遵循Barun的《在Ghidra中分析裸机固件二进制文件》博客文章,并得到了我需要知道的一些近似值。这涉及通过其FCC认证文档中的内部图片找出WBS06的微控制器型号,并设置正确的内存映射。
引起我注意的是几个零散的字符串,暗示了一个……shell?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
Connection Manager Shell Command
Usage:
wifi <wifi_sync_flags>
Attempts a Wifi sync with the given flags.
wifi_sync_flags is a combination of the following flags:
0x01 (allow update), 0x02 (store DbLib), 0x04 (send DbLib), 0x08 (send
rawdata),
0x10 (send wlog), 0x20 (send events), 0x40 (send extras)
wifi_no_update <wifi_sync_flags>
Attempts a Wifi sync, no update allowed (even if set in flags).
wifi_update <wifi_sync_flags>
Attempts a Wifi sync, allows update if available (even if not set in
flags).
bt Attempts a Bluetooth sync
do Attempts a Wifi/Cellular sync and fallback to Bluetooth if it fails.
|
为什么智能体重秤上会有shell?通过更多的侦查,我发现了另一位研究人员的Reddit帖子,他实际上在早期型号WBS05上找出了UART引脚。
这看起来相当简单,所以我兴奋地开始尝试在WBS06上复制这一点。最大的线索是WBS06底部也有相同的三个孔,对应Tx、Rx和GND UART引脚,与FCC文档中的内部图片对比确认了这一点。
然而,我的初步努力失败了。尽管用逻辑分析仪正确找出了正确的波特率,我的串行连接一直返回乱码。经过许多小时的痛苦,我意识到我便宜的CP2102 USB转TTL转换器是问题的根源,使用更可靠的FT232终于得到了我需要的结果。
现在我有了一個调试shell,我可以探索设备上存储的所有数据,包括证书、密钥等!当然,虽然这很令人兴奋,但这并没有多大意义——我可以“黑客”一个我已经拥有的设备,没什么大不了的。
破碎的用户设备关联逻辑 🔗
为了真正测试远程向量,我需要完全了解设备如何向API服务器认证自己并执行用户设备关联。
例如,connection_manager wifi
命令会尝试连接到API服务器,并带有详细的调试日志。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
|
shell>connection_manager wifi
[info][CM] Connection manager request, action = 3, wifi sync flags = 0xffffffff
[VAS] t:15
[info][CM] Start with cnlib action = 3
[VAS] t:15
[CNLIB] Recovered LastCnx from DbLib
[AM] Defuse id 4
[TIME] Current time (timestamp) 0 , 8h 0min 0sec
[TIME] Waking up in 16h 90min 60sec
[TIME] Add random time 0
[AM] Set id 3 at 63060
[AM] Set id 1 at 600
[CNLIB] Try to connect via wifi (1)
[DBLIB][ERASEBANK] Bank 1
[info][DBLIB][SUBSADD] 14 0
[info][CM] Initializ[VAS] t:15
e Wifi
[WIFIM] Request
[WIFIM] init
[VAS] t:15
wifi_chip_enable
bcm43438_request
== Set dcdc_sync ==
bcm43438_request: pwron module
[WIFIMFW] current_fw == FW_2 1
version 1
size 80
[WIFIMFW] wifi_crc: 0
[WIFIMFW] Take current bank
[WIFIMFW] Firmware block 1a8000 : OK
[WIFIMFW] Wifi Offset 21a370, lenght 58d1d
[WWD] HT Clock available in 31 ms
[WWD] mac: a4:7e:fa:19:2c:f6
supported channels: 13
[WIFIM] init OK
[info][CM] Wifi initialized
[WIFIM] join_configured_ap
[VAS] t:15
[WIFIM] ssid = ...
[WIFIM] key = ...
[WIFIM] WPA key already saved
[WWD] join: ssid=<...>, sec=0x00400004, key=<...>
[WDM] wwdm_join_event_handler: state=1, wifim_err=9, stopped=0
[WDM] wwdm_join_event_handler: state=2, wifim_err=9, stopped=0
[WDM] wwdm_join_event_handler: state=2, wifim_err=0, stopped=1
[WDM] wwdm_join_event_handler: stopped
[WWD] join: wiced_res=0, wifim_res=0
[info][WIFIM] join: attempt #0, rc=0
[info][WIFIM] join: SSID <...> join rc=0 after 1 attempts
[VAS] t:15
[VAS] t:15
[info][WIFIM] join: RSSI=-64
[VAS] t:15
[WIFIM] connect: use static ip
[WIFIM] Interface UP (Status : 0xf)
[WIFIM] netif_up: use DHCP
[WIFIM] Interface UP (Status : 0xf)
[WIFIM] netif_up:
[WIFIM] IP=192.168.0.9
[WIFIM] Mask=255.255.255.0
[WIFIM] Gw=192.168.0.1
[WIFIM] DNS[0]=192.168.0.1
[WIFIM] DNS[1]=0.0.0.0
[WIFIM] connect_cfg_ap: success
[info][CM] Joined configured AP successfully
[VAS] t:15
[info][CM] Store DbLib...
[VAS] t:15
[DBLIB][ERASEBANK] Bank 2
[info][CM] Store DbLib done
[HTT[VAS] t:15
S_CLIENT] Init
[HTTPS_CLIENT] Init
[info][CM] Wslib init successful, carry on
[VAS] t:15
[WS] WsLib_StartSession
[WS] __WsLib_Once
[WS] Https_client browsing <https://wbs06-ws.withings.net/once?appliver=1181&appname=WBS06&apppfm=device>
[HTTPS_CLIENT] New connection or Adress/Security Changed
[HTTPS_CLIENT] Close
[HTTPS_CLIENT] Init
[HTTPS_CLIENT] Handshake started
{"status":0,"body":{"user":[{"userid":...,"screens":[{"id":66,"deactivable_status":6,"src":1,"embid":11,"rk":1}]},...]}}
>
[DBLIB][ERASEBANK] Bank 1
[WS] WSLIB_OK
[WS] Https_client browsing <https://wbs06-ws.withings.net/v2/summary?appliver=1181&appname=WBS06&apppfm=device>
[HTTPS_CLIENT] Socket already opened
[WS] Params <action=getforscale&sessionid=...>
{"status":0,"body":[{...}]}
>
[WS] WSLIB_OK
[USLIB] FLUSH STORED MEASURE
[USLIB] 0 measure(s) flushed
[WS] Https_client browsing <https://wbs06-ws.withings.net/v2/weather?appliver=1181&appname=WBS06&apppfm=device>
[HTTPS_CLIENT] Socket already opened
[WS] Params <action=getforecast&sessionid=...short=1&enrich=t>
...
|
我还尝试替换设备上存储的mTLS证书,以使WiFi拦截更容易,但它按预期工作,因为服务器拒绝了我自己的自签名证书。
尽管如此,多亏了调试日志和从内存中读取各种状态数据,我弄清楚了大部分认证流程:
- 通过蓝牙从移动应用接收WiFi凭据后,设备现在可以独立连接到API服务器。
- 设备呈现其证书并使用相互TLS(mTLS)连接到API服务器。
- API服务器返回一个随机数。
- 设备使用本地私钥对随机数进行签名,并将其发送到服务器。
- API服务器确认签名有效,并返回设备会话令牌。
- 设备现在可以使用设备会话令牌作为认证与API服务器交互。
有趣的是,用户设备关联工作流程可以通过两种方式完成。第一种方式由用户的移动应用启动:
- 移动应用已经拥有用户的会话令牌。
- 应用通过蓝牙获取设备的会话令牌。
- 应用使用
Session-Id: USER_SESSION_TOKEN
向API服务器认证,并发送请求负载userid=USER_ID& sessionidtoken=DEVICE_SESSION_TOKEN
。userid
是一个简单的递增数字。
- API服务器确认
Session-Id
和sessionidtoken
有效,然后将userid
与DEVICE_SESSION_TOKEN
所属的设备ID关联。
第二种方式由设备启动:
- 设备已经拥有设备会话令牌。
- 设备通过蓝牙从应用获取用户的会话令牌。
- 设备使用
Session-Id: DEVICE_SESSION_TOKEN
向API服务器认证,并发送请求负载deviceid=DEVICE_ID& sessionidtoken=USER_SESSION_TOKEN
。deviceid
是一个简单的递增数字。
- API服务器确认
Session-Id
和sessionidtoken
有效,然后将deviceid
与USER_SESSION_TOKEN
所属的用户ID关联。
两种方法都经过了适当的加固和验证;尝试在第一种流程中更改userid
或在第二种流程中更改deviceid
都会失败,因为它们与Session-Id
会话令牌不匹配。
然而,在业务逻辑中存在一个致命缺陷。也许我可以用服务器端验证逻辑的近似值来说明这一点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
if (req.session.isValid) {
if (!validateSession(req.body.sessionidtoken)) {
return error
}
const targetSession = fetchSession(req.body.sessionidtoken)
// 用户应用启动的流程
if (targetSession.type === 'device') {
associate(req.body.userid, targetSession.id)
// 设备启动的流程
} else if (targetSession.type === 'user') {
associate(req.body.deviceid, targetSession.id)
}
}
|
这里的错误是什么?好吧,考虑一个请求,其中Session-Id
和sessionidtoken
都是攻击者的用户会话令牌,而deviceid
设置为他们不拥有的设备。逻辑仍然会认为这是一个设备启动的流程,并且从不要求攻击者提供与目标deviceid
对应的会话令牌!花几秒钟用这个思路解析代码。
相反,代码应该进行额外的验证:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
if (req.session.isValid) {
if (!validateSession(req.body.sessionidtoken)) {
return error
}
const targetSession = fetchSession(req.body.sessionidtoken)
// 用户应用启动的流程,验证要关联的用户与会话令牌头匹配
if (req.body.userid === req.session.id && targetSession.type === 'device') {
associate(req.body.userid, targetSession.id)
// 设备启动的流程,验证要关联的设备与会话令牌头匹配
} else if (req.body.deviceid === req.session.id && targetSession.type === 'user') {
associate(req.body.deviceid, targetSession.id)
}
}
|
由于这个错误,根据可用的设备ID,我估计超过100万台潜在设备可以重新关联到攻击者用户账户。
负责任的披露在假期期间迅速得到修复:
- 2024年12月29日:向供应商报告
- 2025年1月3日:报告确认并修复
这证明了他们对安全的重视——漏洞影响每个供应商,但我知道我宁愿