首次Pwn2Own Automotive引入了一个有趣的目标类别:电动汽车充电器。本文将详细介绍我们对Phoenix Contact CHARX SEC-3100的研究以及我们发现的漏洞,第二篇单独的文章将涵盖实际的漏洞利用。
我们已经将基本的漏洞模式改编成了一个在我们基于浏览器的WarGames平台上托管的挑战,如果你想亲自动手尝试利用我们发现的这个相当有趣的C++问题,可以点击这里。
尽管电动汽车充电器乍一看似乎是一个“异域”目标,带有非标准协议和物理接口,但一旦这些都弄清楚,一切最终都归结为一些处理不受信任输入的二进制文件(例如来自网络),所有经典的内存损坏原则都适用。
为什么选择CHARX?
我们选择CHARX作为目标主要有两个原因。第一个原因是它作为产品的独特性。虽然其他目标更像是面向零售/消费者的,CHARX则更“工业化”,是一种DIN导轨安装单元,看起来更适用于基础设施而不是实际充电。它作为一个异类立刻引起了我们的兴趣。
另一个更实际的原因是固件可以很容易地从制造商的网站下载,而且没有加密。提供的.raucb
包是为rauc设计的,但也可以作为squashfs文件系统镜像进行挂载或直接提取。
侦察 – 映射攻击面
一旦我们决定对CHARX进行积极的研究,我们首先通过枚举和评估潜在的攻击面来开始。
CHARX运行一个定制的32位ARM嵌入式Linux版本。默认情况下启用SSH,非特权用户user-app
的默认密码是user
。
在物理端口方面,我们感兴趣的是两个以太网端口,标记为ETH0和ETH1。ETH0用于提供与“外部世界”的连接,很可能是更大的网络和/或互联网,而ETH1则用于连接到另一个CHARX的ETH0端口。通过这种方式,CHARX单元可以链式连接,以便它们全部通信。
/etc/firewall/rules
中的防火墙规则定义了这些两个接口上可访问的端口(因此是服务)。通过这些规则、通过ssh在系统中探索一段时间和简要的反向工程,我们得到了以下粗略的服务“地图”,这是一个指示可能攻击面的指南:
CHARX远程攻击面
有些服务可以直接通过它们的TCP服务器接口,而有些只能通过MQTT消息间接访问。MQTT采用发布-订阅模型,客户端可以订阅任意数量的主题,当任何客户端向某个主题发布消息时,消息会被转发给所有订阅者。
这些服务的大多数二进制文件位于/usr/sbin/Charx*
。大多数服务基于Cython,其中python代码(带有一些用于本地功能的额外语法)被编译成本地二进制文件/共享对象,而不是被解释执行。
反向工程Cython证明是乏味的,所以我们主要集中在控制代理服务,一个本地C++二进制文件上。
控制代理概述
控制代理位于攻击面图的左上角,通过eth1端口/接口可达。这个端口旨在连接到另一个CHARX,但在我们的攻击场景中,我们将直接连接一台机器。
为了提供一些背景信息,我们发现控制代理有三个主要功能:
-
管理其他链式连接的CHARX单元之间的通信 -
管理AC控制器(板上的一个独立MCU) -
V2G(车对电网)协议消息传递(与车辆向电网出售电力相关)
在实际交互方面,可以通过UDP、TCP和HomePlug Green PHY协议与代理进行通信。我们将简要概述每个通信渠道,并在相关时讨论具体细节。
TCP JSON消息传递
TCP服务器是概念上最简单的通信方法。代理监听端口4444,接受JSON格式的消息,并提供JSON响应。
每条消息是一个具有以下格式的JSON对象:
{
"operationName": "deviceInfo", // 请求的操作
"deviceUid": "root", // 操作的目标设备
"operationId": 0, // 响应中回显的参考ID
"operationParameters": {} // 可选的操作特定参数
}
deviceUid
字段指定了代理维护的某种“设备树”中的目标设备。对于我们的目的,这主要是指示控制代理本身的root
,但也有一个表示AC控制器MCU的设备节点,如果存在并且进行了适当的“握手”,则还有代表链式连接单元的节点。
支持的一些操作包括:
-
deviceInfo
:获取指定设备的信息 -
childDeviceList
:列出设备树中的子设备 -
dataAccess
:通用硬件数据,例如读取AC控制器的温度(不支持代理的root) -
configAccess
:读/写配置变量 -
heartbeat
-
v2gMessage
:代理/处理V2G消息/响应
如果目标设备是代理本身,则消息直接处理。否则,它将被转发到适当的设备(例如,代理到链式连接的CHARX)。
UDP 广播发现
UDP 主要用于链式连接单元的自动发现,之后的通信将通过 TCP 进行。这是通过端口 4444 上的 UDP 广播数据包完成的。
基本思路是:
-
根代理广播一个 deviceInfo
JSON 请求消息 -
链式连接的子代理响应 -
根代理从响应中获取 IP,使用它通过 TCP 端口 4444 连接到子代理
这并不复杂,因为它只是用于初始发现。
HomePlug
HomePlug 是一组用于电力线通信 (PLC) 的协议。即,通过电线传输数据。在这里,具体相关的是 HomePlug Green PHY 协议。
协议以标准以太网数据包的形式定义。在实践中,一个专用的 SoC(例如某些高通芯片)将执行以太网数据包与原始电力线信号之间的转换,反之亦然。这些芯片似乎存在于某些 CHARX 型号上(尽管我们用于竞赛的 3100 型号没有这些芯片),旨在将其暴露给 Linux 用户空间作为 eth2
接口(相比之下,物理以太网端口是 eth0
和 eth1
)。
PLC 的使用很有趣并提供了一些背景,但最终是无关紧要的,因为协议只是以太网,我们只需要关心发送/接收原始数据包。以太网/第 2 层数据包有一个 10 字节的头部,后跟数据负载。
![Pwn2Own Automotive 电车充电器漏洞挖掘 Pwn2Own Automotive 电车充电器漏洞挖掘](https://ctfiot.oss-cn-beijing.aliyuncs.com/uploads/2024/07/8-1721278245.png)
值得注意的是,头部中的 16 位 EtherType
字段决定了协议,在 HomePlug Green PHY 的情况下是 0x88e1
。
控制代理通过打开一个原始套接字来发送和接收这些数据包:
socket(AF_PACKET, SOCK_RAW, htons(0x88e1))
读写原始套接字发送或接收整个原始数据包,包括头部。指定的协议 0x88e1
意味着当从套接字读取时,内核只会传递具有指定 EtherType
的数据包。
原始套接字绑定到一个接口,数据包直接从该接口进出。通常,这将是用于 PLC 的特殊 eth2
接口,但可以通过在启动 HomePlug “服务器”之前通过 configAccess
消息(通过 TCP)配置此接口。我们可以方便地将其设置为 eth1
(用于物理 ETH1 端口),我们已经连接到该端口。
HomePlug 功能与 V2G 密切相关,HomePlug “服务器”通过发送 v2gMessage
请求(带有“订阅”方法类型)来启动。
漏洞 #1:HomePlug 解析不匹配
我们使用的第一个漏洞最终导致简单的空指针解引用,使我们可以随意崩溃服务。这看起来可能没什么用,但稍后会证明其用处。
由控制代理运行的 HomePlug “服务器”从其原始套接字读取数据包,并处理每个数据包。HomePlug 数据包称为 MME(管理消息条目),有一个 5 字节的头部,后跟消息负载:
-
1 字节版本 -
2 字节 MMTYPE 表示消息类型,即一个“操作码” -
2 字节碎片信息(代理未使用)
请注意,代理不是完整实现,而是实现了 Green PHY 协议的一部分功能/MMTYPE(例如,忽略碎片信息)。你可以在这里找到完整规范的存档版本。
为了提供上下文,消息操作码通常以发送/响应对出现。从规范来看,命名方案如下:
请求消息总是以 .REQ 结尾。请求消息的响应(如果有)总是一个确认消息,以 .CNF 结尾。
指示消息总是以 .IND 结尾。指示消息的响应(如果有)总是一个响应消息,以 .RSP 结尾。
这里感兴趣的 MMTYPE 是 CM_AMP_MAP.REQ (0x601c)
,用于发送“振幅图”。消息负载的形式为:
-
2 字节 AMLEN 表示以下 4 位数字数组的大小 -
n 字节 AMDATA 长度为 (AMLEN+1)/2
代理将 MME 表示为 MMEFrame
类的子类,对于这个 MMTYPE 是 MME_CM_Amp_Map_Req
。
为了解析具有不同结构的各种消息负载,MMEFrame
对象使用了“blob”的概念,即将消息体的块复制到单独的向量中,并标记为指示它们表示哪个字段的“类型”。解析填充 blob,MME 处理查询/使用这些 blob。
以下是 MME_CM_Amp_Map_Req
构造函数的伪代码,它传递了一个指向 MME 开始(包括 5 字节头部)的指针:
MME_CM_Amp_Map_Req(MME_CM_Amp_Map_Req* this, unsigned char *raw, unsigned rawsz, unsigned amlen)
{
if ( rawsz <= 5 )
return;
if ( !amlen ) { // 解析数据包作为输入时为零
amlen = raw[5];
amlen |= raw[4] << 8;
}
this->amlen = amlen;
unsigned short ambytes = (amlen + 1) >> 1;
if ( MMEFrame::hdr_size(a1) + 2 + ambytes > rawsz ) // hdr_size 是 5
return;
MMEFrame::add_blob(this, raw, 0, 2, Amp_Map_AMLEN); // 复制头部后的字节 [0, 2)
MMEFrame::add_blob(this, raw, 2, ambytes, Amp_Map_AMDATA); // 复制头部后的字节 [2, ambytes)
this->valid = 1;
}
记住头部是 5 字节,所以消息负载应从偏移量 5 开始。由于 AMLEN 是该负载中的第一个字段,AMLEN 应该是字节 5 和 6。然而,这个构造函数错误地使用了字节 4 和 5。这个错误的值决定了以后存储的 AMDATA blob 的长度。“正确”的 AMLEN 也存储为一个 blob。
结果是 AMLEN blob 中存储了“正确”的长度,但 AMDATA blob 的大小完全不同。
为了看看这种“奇怪状态”会导致什么,让我们看看解析后的情况。以下是这个 MMTYPE 的处理程序的伪代码。它基本上在循环中将 AMLEN 条目从 AMDATA blob 复制到“会话本地”向量中:
EVSEMMEHandler::VSLACSession* session = ...;
std::vector<unsigned char> blob;
MMEFrame::get_blob(&blob, mme, Amp_Map_AMLEN);
unsigned amlen = blob[0] | (blob[1] << 8); // "correct" length
// individually copy entries from the AMDATA blob
// MMEFrame::get_amdata is essentially AMDATA[i] but for 4-bit entries
for (unsigned i = 0; i < amlen; i++)
session->amp_map.append(MMEFrame::get_amdata(mme, i));
循环迭代次数使用“正确”的 AMLEN,但是迭代的 AMDATA blob 实际上并不是那个大小!如果它更小,AMDATA[i]
可能会越界。
现在,你可能在想……
等等,我以为是个小小的空指针解引用……这看起来更像是越界读取啊!
这确实在技术上是一个越界读取,最初看起来很有希望成为信息泄露。然而,尽管似乎存在将“会话本地”向量回显回线路的代码,但我们不幸地找不到任何能够实际触发它的引用或代码路径。相反,作为安慰奖,我们可以利用这样一个事实,即大小为 0 的 std::vector
其支持存储将是一个空指针,在循环中尝试从该向量读取会导致空指针解引用。
然而,空指针解引用的 SIGSEGV 并不一定意味着进程的结束,这将引出我们下一个漏洞……
漏洞 #2:进程终止时的 Use-After-Free
我们利用的第二个漏洞是在进程退出前清理过程中发生的 UAF,我们大部分是意外发现的。有时在漏洞研究中,你花了几周时间盯着代码却一无所获(我们最初发现 HomePlug 漏洞时就是这样)。而有时,你只需附加 gdb,继续几秒钟,就神奇地出现了段错误……
发生这种情况的原因是某种系统监视器检测到服务挂起(由于 gdb 暂停)。然后监视器向进程发送 SIGTERM,意图干净地关闭它,并在之后重新启动服务。然而,在退出处理程序期间有某个错误被“有机地”触发了。
退出处理程序
在 CharxControllerAgent 二进制文件中,注册了相当数量的退出处理程序,这些处理程序由 __aeabi_atexit
注册,似乎是由 C++ 编译器隐式发出的,以销毁声明为静态的全局变量。由于静态变量构造一次但无限期存活,C++ 运行时注册退出处理程序以确保它们的销毁。
最相关的静态全局对象是一个 ControllerAgent
对象,它是一个巨大的根对象,封装了几乎所有代理的状态。这在 main
中最初构造,析构函数也作为退出处理程序注册。
另外,代理还安装了几个信号处理程序。对于 SIGTERM
和 SIGABRT
,处理程序设置一个全局布尔值,指示主运行循环应该停止,干净地从 main
返回。对于 SIGSEGV
,处理程序手动调用 exit(1)
。因此,任何这些信号的传递最终都会触发退出处理程序。
换句话说,我们之前没用的空指针解引用可以用来调用 SIGSEGV
信号处理程序,后者调用 exit
,最终会触发退出处理错误!让我们看看实际问题是什么……
析构函数有害
在深入探讨 CHARX 特定的细节之前,我们将使用一个简单的示例来演示相同的错误模式,这将更容易理解。
看看你是否能在以下代码中发现错误……
#include <vector>
#include <stdio.h>
class Outer;
// inner class with back-reference to outer class
class Inner {
public:
Outer* outer;
int idx;
Inner(Outer* o) : outer(o), idx(-1) {}
~Inner();
void init(long val);
};
// outer class holds inner class and some shared state
// (in this case a vector the inner class can add/remove from)
class Outer {
public:
Inner inner;
std::vector<long> values;
Outer() : inner(this) {}
int add(long val) {
values.push_back(val);
return values.size()-1;
}
void remove(int i) {
printf("log values: 0x%lx 0x%lxn", values[0], values[1]);
values[i] = 0;
}
};
// reserve a slot in the shared vector
void Inner::init(long val) {
idx = outer->add(val);
}
// on destruction, invalidate the slot
Inner::~Inner() {
if (idx != -1)
outer->remove(idx);
}
int main() {
static Outer o;
o.values.push_back(0x41414141);
o.inner.init(0x42424242);
return 0;
}
考虑从 main
函数返回时发生了什么。这将最终调用 Outer
的析构函数,该析构函数在构造后被注册为退出处理程序。但是,这个析构函数中会发生什么呢?它并没有显式定义,因此将使用 C++ 编译器创建的默认析构函数。
根据C++参考文档:
… 编译器以声明顺序的逆序调用类的所有非静态、非变体数据成员的析构函数 …
换句话说,对于 Outer
类,vector 将在内部类之前被析构。这导致在析构 Outer
时出现以下事件链:
这是一个非常微妙的 bug,主要由于 C++ 的隐式特性以及在析构期间内部类回调外部类的模式所导致的。一个有趣的现象是,简单地交换声明成员 inner
和 values
的两行可以“修复”这个 bug,因为析构函数将按相反的顺序调用。
ControllerAgent 的析构函数
实际的 bug 遵循相同的模式。几乎所有控制代理的全局结构/状态都根据一个 ControllerAgent
类实例。这个对象的析构函数执行程序的大部分清理。正如前面提到的,这个析构函数被注册为退出处理程序。
ControllerAgent
的一个字段是 std::list<ClientSession>
,一个包含连接客户端的“会话”列表。这类似于我们的示例中的 std::vector
。
另一个字段是一个“管理器” ClientConnectionManagerTcp
,它内部包含表示 TCP 客户端的 ClientConnectionTcp
对象的列表。这类似于我们的示例中的 inner
。
这两个列表在概念上是一对一的,其中每个更低级的 ClientConnectionTcp
都有一个对应的更高级的 ClientSession
。一个关键的“连接 ID”将每个对象与其他对象关联起来。
当更低级别的 TCP 连接关闭时,“管理器”(ClientConnectionManagerTcp
)会清理这两个对象。它拥有更低级别的对象并可以自行进行清理,但是为了清理更高级别的对象,它会调用 ControllerAgent
的函数来通知匹配的 ClientSession
应该无效。这涉及迭代 std::list<ClientSession>
来查找匹配的 ID。
然而,在析构期间,这会出现问题,因为 std::list<ClientSession>
在 ClientConnectionManagerTcp
之前被析构:
-
~ControllerAgent
启动清理 -
~std::list<ClientSession>
释放所有链表节点 -
这很可能是默认的标准库定义的析构函数 -
~ClientConnectionManagerTcp
开始清理更低级别的 TCP 连接 -
回调 ControllerAgent
来使匹配的连接 ID 无效 -
ControllerAgent
尝试在其半析构状态下搜索std::list<ClientSession>
中的匹配 ID -
在这种半析构状态下, std::list
已经不存在了… 导致 UAF!
下一步:利用
到目前为止,我们有一个 UAF 的原始漏洞,但要注意的是它只能在进程退出时触发一次(我们可以通过空指针解引用来随意启动)。
我们发现这种非常微妙的析构函数顺序问题非常有趣,这是 C++ 隐式特性如何导致意外和容易被忽视的漏洞的一个例子。进程退出时出现的 bug 也很常见被忽视。
我们将在不久的将来通过详细的后续帖子来详细介绍利用过程。同时,完整的漏洞利用代码可以在 GitHub 这里 找到。
原文始发于微信公众号(3072):Pwn2Own Automotive 电车充电器漏洞挖掘