深入解析Pwn2Own汽车大赛:CHARX漏洞挖掘中的C++析构器滥用与UAF漏洞

本文详细介绍了针对Phoenix Contact CHARX SEC-3100电动汽车充电器的漏洞研究过程,重点分析了两个关键漏洞:HomePlug协议解析错误导致的空指针解引用,以及C++析构器顺序引发的进程退出时释放后重用(UAF)漏洞,并探讨了其在Pwn2Own Automotive大赛中的应用。

Pwn2Own Automotive: CHARX漏洞发现 | RET2 Systems博客

工程博客

Pwn2Own Automotive: CHARX漏洞发现

滥用微妙的C++析构器行为实现UAF

2024年7月17日

作者:Jack Dates

首届Pwn2Own Automotive大赛引入了一个有趣的目标类别:电动汽车充电器。本文将详细介绍我们对Phoenix Contact CHARX SEC-3100进行的一些研究以及我们发现的漏洞,第二篇独立的文章将涵盖实际的漏洞利用过程。

如果你希望亲自动手尝试利用我们发现的那个相当有趣的C++问题,我们已经将这个基本漏洞模式改编成一个挑战,并将其托管在我们的浏览器内WarGames平台上。

尽管电动汽车充电器最初看起来像是一个具有非标准协议和物理接口的“特殊”目标,但一旦理解了这些,最终一切都归结为某个二进制程序在消费不受信任的输入(例如来自网络),所有经典的内存损坏原理都适用。

为什么选择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在系统上花费一些时间以及进行简要的逆向工程,我们最终得到了以下粗略的服务“地图”,它指明了可能的攻击面:

一些服务可以通过其TCP服务器直接交互,而另一些只能通过MQTT消息间接寻址。MQTT采用发布-订阅模型,客户端可以订阅任意数量的主题,当任何客户端向某个主题发布消息时,该消息将被转发给所有订阅者。

这些服务的大多数二进制文件位于/usr/sbin/Charx*。大多数服务是基于Cython的,其中Python代码(包含一些用于原生功能的额外语法)被编译成本地二进制文件/共享对象,而不是被解释执行。

逆向工程Cython证明是乏味的,因此我们选择主要关注控制器代理服务,这是一个原生的C++二进制程序。

控制器代理概述

控制器代理在攻击面图的左上方表示,可通过eth1端口/接口访问。

此端口旨在连接到另一个CHARX,但在我们的攻击场景中,我们将直接连接一台机器。

为了提供一些背景信息,我们遇到了控制器代理的三个主要功能:

  1. 管理其他菊花链连接的CHARX单元之间的通信
  2. 管理AC控制器(板上的一个独立MCU)
  3. V2G(车辆到电网)协议消息传递(与车辆向电网出售电力相关)

在实际交互方面,可以通过UDP、TCP和HomePlug Green PHY协议与代理通信。

我们将简要概述每个通信通道,并在相关时讨论具体细节。

TCP JSON消息传递

TCP服务器在概念上是最简单的通信方法。代理在端口4444上监听,接受JSON格式的消息,并提供JSON响应。

每条消息都是具有以下格式的JSON对象:

1
2
3
4
5
6
{
    "operationName": "deviceInfo",   // 请求的操作
    "deviceUid": "root",             // 操作的目标设备
    "operationId": 0,                // 在响应中回显的引用ID
    "operationParameters": {}        // 可选的特定于操作的参数
}

deviceUid字段指定了代理维护的某种“设备树”中的目标设备。就我们的目的而言,这主要是root,表示控制器代理本身,但也有一个代表AC控制器MCU的设备节点,如果存在菊花链单元并执行了适当的“握手”,还会有其他节点。

一些支持的操作包括:

  • deviceInfo:获取指定设备的信息
  • childDeviceList:列出设备树中的子设备
  • dataAccess:通用硬件数据,例如读取AC控制器的温度(根代理不支持)
  • configAccess:读/写配置变量
  • heartbeat
  • v2gMessage:代理/处理V2G消息/响应

如果目标设备是代理本身,则直接处理消息。否则,它将被转发到适当的设备(例如,代理到菊花链的CHARX)。

UDP广播发现

UDP主要用于菊花链单元的自动发现,之后将通过TCP进行通信。这是通过端口4444上的UDP广播数据包完成的。

基本思路是:

  1. 根代理广播一条deviceInfo JSON请求消息
  2. 菊花链子代理响应
  3. 根代理从响应中获取IP,并使用它通过TCP端口4444连接到子代理

这里没有太多复杂性,因为它仅用于初始发现。

HomePlug

HomePlug是用于电力线通信(PLC)的一系列协议。也就是说,通过电力线传输数据。

具体来说,这里相关的是HomePlug Green PHY协议。

该协议是以标准以太网数据包来定义的。实际上,一个专用的SoC(例如某些Qualcomm芯片)将以太网数据包转换为原始电力线信号,反之亦然。

看起来这些芯片存在于某些CHARX型号上(尽管不是我们用于比赛的3100型号),旨在作为接口eth2暴露给Linux用户空间(与eth0eth1的物理以太网端口相比)。

PLC的使用很有趣,并提供了一些背景信息,但最终是无关紧要的,因为协议就是以太网,我们只需要关心发送/接收原始数据包。

以太网/第2层数据包有一个10字节的头部,后面跟着数据载荷。

值得注意的是,头部中的16位EtherType字段决定了协议,对于HomePlug Green PHY,该值将是0x88e1

控制器代理通过打开一个原始套接字来发送和接收这些数据包:

1
socket(AF_PACKET, SOCK_RAW, htons(0x88e1))

读取或写入原始套接字将发送或接收整个原始数据包,包括头部。指定的协议0x88e1意味着当从套接字读取时,内核将只传递具有指定EtherType的数据包。

原始套接字绑定到一个接口,数据包直接路由到该接口并从该接口路由。通常,这将是用于PLC的特殊eth2接口,但可以通过configAccess消息(通过TCP)在启动HomePlug“服务器”之前配置该接口。我们可以方便地将其设置为eth1(用于物理ETH1端口),我们将已经连接到该端口。

HomePlug功能与V2G密切相关,HomePlug“服务器”是通过发送一个带有“subscribe”方法类型的v2gMessage请求(通过TCP)来启动的。

漏洞 #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是从消息体中复制出来、放入单独向量的块,并标记有表示它们代表哪个字段的“类型”。解析过程填充blob,MME处理过程查询/使用这些blob。

以下是MME_CM_Amp_Map_Req构造函数的伪代码,它被传递一个指向MME(包括5字节头部)起始位置的指针:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
MME_CM_Amp_Map_Req(MME_CM_Amp_Map_Req* this, unsigned char *raw, unsigned rawsz, unsigned amlen){
    if ( rawsz <= 5 )
        return;
    if ( !amlen ) { // 当将数据包作为输入解析时,将为0
        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处理程序的粗略伪代码。

它基本上是从AMDATA blob中将AMLEN个条目循环复制到一个“会话本地”向量中:

1
2
3
4
5
6
7
8
EVSEMMEHandler::VSLACSession* session = ...;
std::vector<unsigned char> blob;
MMEFrame::get_blob(&blob, mme, Amp_Map_AMLEN);
unsigned amlen = blob[0] | (blob[1] << 8); // "正确的"长度
// 从AMDATA blob中逐个复制条目
// MMEFrame::get_amdata 本质上是 AMDATA[i],但是针对4位条目
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:进程退出时的释放后重用(UAF)

我们利用的第二个漏洞是进程退出前清理过程中发生的UAF,这主要是我们偶然发现的。有时在漏洞研究中,你花几周时间盯着代码却一无所获(我们最初就是这样,发现了HomePlug漏洞)。

其他时候,你只需附加gdb,继续几秒钟,然后神奇地得到一个段错误……

发生这种情况的原因是,某种系统监控器检测到服务已挂起(由于在gdb中暂停)。监控器然后向进程发送SIGTERM,意图是干净地关闭它,并在之后重新启动服务。然而,在退出处理程序中,某些漏洞被“自然地”触发了。

退出处理程序

CharxControllerAgent二进制程序中,大量退出处理程序由__aeabi_atexit注册,这似乎是C++编译器隐式发出的,用于销毁声明为static的全局变量。

由于静态变量只构造一次但无限期存活,C++运行时注册退出处理程序以确保它们被销毁。

最相关的静态全局变量是一个ControllerAgent对象,一个封装了几乎所有代理状态的大型根对象。这最初在main函数中构造,其中析构函数也被注册为退出处理程序。

相关说明,代理还安装了几个信号处理程序。对于SIGTERM和SIGABRT,处理程序设置一个全局布尔值,指示主运行循环应停止,干净地从main返回。对于SIGSEGV,处理程序手动调用exit(1)。因此,这些信号中的任何一个被传递都会最终触发退出处理程序。

换句话说,我们之前无用的空指针解引用可以用来调用SIGSEGV信号处理程序,该处理程序调用exit,最终将触发退出处理漏洞!让我们看看实际的问题是什么……

析构器的危害

在深入CHARX的具体细节之前,我们将用一个简单的玩具示例来演示相同的漏洞模式,这将更容易推理。

看看你能否发现以下代码中的漏洞……

 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
#include <vector>
#include <stdio.h>

class Outer;

// 具有反向引用外部类的内部类
class Inner {
    public:
        Outer* outer;
        int idx;
        Inner(Outer* o) : outer(o), idx(-1) {}
        ~Inner();
        void init(long val);
};

// 外部类持有内部类和一些共享状态
// (在本例中是一个向量,内部类可以向其中添加/删除)
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%lx\n", values[0], values[1]);
            values[i] = 0;
        }
};

// 在共享向量中预留一个槽位
void Inner::init(long val) {
    idx = outer->add(val);
}

// 销毁时,使槽位无效
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,向量在内部类之前被销毁。这导致了在销毁Outer时发生以下事件链:

  1. ~Outer 开始清理。
  2. ~std::vector<long> 释放向量的后备存储。
  3. ~Inner 开始清理。
  4. ~Inner 回调到 Outer::remove 来修改 Outer::values
  5. 此时,Outer::values 已经被销毁!UAF!

这是一个非常微妙的漏洞,主要是由C++的隐式特性与内部类在销毁期间回调外部类的模式共同引起的。

这种隐式性的一个有趣结果是,仅仅交换声明成员innervalues的两行就可以“修复”这个漏洞,因为析构函数随后将以相反的顺序被调用。

ControllerAgent 析构器

实际的漏洞遵循相同的模式。控制器代理几乎所有全局结构/状态都植根于一个ControllerAgent类实例。反过来,该对象的析构器执行程序的大部分清理工作。如前所述,此析构器被注册为退出处理程序。

ControllerAgent的一个字段是std::list<ClientSession>,一个“会话”列表,每个会话代表一个连接的客户端。

这是我们的玩具示例中std::vector的类比。

另一个字段是一个“管理器”ClientConnectionManagerTcp,它在内部持有一个代表TCP客户端的ClientConnectionTcp对象列表。

这是我们的玩具示例中内部类的类比。

这两个列表在概念上是一对一的,每个较低级别的ClientConnectionTcp都有一个相应的高级ClientSession。一个整型“连接ID”将一个对象与另一个对象关联起来。

当较低级别的TCP连接关闭时,“管理器”(ClientConnectionManagerTcp)会清理这两个对象。它拥有较低级别的对象,可以自己执行清理,但为了清理更高级别的对象,它回调到ControllerAgent函数,通知它匹配的ClientSession应该被设为无效。这涉及遍历std::list<ClientSession>查找匹配的ID。

然而,这在销毁过程中会出问题,因为std::list<ClientSession>ClientConnectionManagerTcp之前被销毁:

  1. ~ControllerAgent 启动清理。
  2. ~std::list<ClientSession> 释放所有链表节点。
    • 这很可能是标准库定义的默认析构函数。
  3. ~ClientConnectionManagerTcp 开始清理较低级别的TCP连接。
    • 回调到ControllerAgent以使一个连接ID无效。
    • ControllerAgent 尝试在其 std::list<ClientSession> 中搜索匹配的ID。
    • 在这种半销毁状态下,这个 std::list 已经不见了……UAF!

下一步:漏洞利用

至此,我们有了一个UAF原语,但有一个注意事项:它只能在进程退出时触发一次(我们可以通过空指针解引用随意启动)。

我们发现这个非常微妙的析构器顺序问题相当有趣,它是C++的隐式特性如何导致意外且容易被忽视的漏洞的一个例子。

同样常见的是忽略仅在进程退出时发生的漏洞。

漏洞利用过程在一篇后续文章中介绍。

另外,如果你想尝试利用相同的核心漏洞模式(但禁用了ASLR!),请查看我们在浏览器内WarGames平台上的这个挑战。

供参考,ZDI公告/CVE分配如下:

  • HomePlug空指针解引用:CVE-2024-26003, ZDI-24-860
  • 析构器UAF:CVE-2024-26005, ZDI-24-861

GITHUB | TWITTER | BLOG | CONTACT

(C) 2025 RET2 SYSTEMS, INC.

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计