Pwn2Own Automotive 电车充电器漏洞挖掘

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

我们已经将基本的漏洞模式改编成了一个在我们基于浏览器的WarGames平台上托管的挑战,如果你想亲自动手尝试利用我们发现的这个相当有趣的C++问题,可以点击这里。

尽管电动汽车充电器乍一看似乎是一个“异域”目标,带有非标准协议和物理接口,但一旦这些都弄清楚,一切最终都归结为一些处理不受信任输入的二进制文件(例如来自网络),所有经典的内存损坏原则都适用。

Pwn2Own Automotive 电车充电器漏洞挖掘

为什么选择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在系统中探索一段时间和简要的反向工程,我们得到了以下粗略的服务“地图”,这是一个指示可能攻击面的指南:

Pwn2Own Automotive 电车充电器漏洞挖掘

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 接口(相比之下,物理以太网端口是 eth0eth1)。

PLC 的使用很有趣并提供了一些背景,但最终是无关紧要的,因为协议只是以太网,我们只需要关心发送/接收原始数据包。以太网/第 2 层数据包有一个 10 字节的头部,后跟数据负载。

Pwn2Own Automotive 电车充电器漏洞挖掘

值得注意的是,头部中的 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* thisunsigned 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, 02, 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 中最初构造,析构函数也作为退出处理程序注册。

另外,代理还安装了几个信号处理程序。对于 SIGTERMSIGABRT,处理程序设置一个全局布尔值,指示主运行循环应该停止,干净地从 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 时出现以下事件链:

Pwn2Own Automotive 电车充电器漏洞挖掘

这是一个非常微妙的 bug,主要由于 C++ 的隐式特性以及在析构期间内部类回调外部类的模式所导致的。一个有趣的现象是,简单地交换声明成员 innervalues 的两行可以“修复”这个 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 之前被析构:

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

下一步:利用

到目前为止,我们有一个 UAF 的原始漏洞,但要注意的是它只能在进程退出时触发一次(我们可以通过空指针解引用来随意启动)。

我们发现这种非常微妙的析构函数顺序问题非常有趣,这是 C++ 隐式特性如何导致意外和容易被忽视的漏洞的一个例子。进程退出时出现的 bug 也很常见被忽视。

我们将在不久的将来通过详细的后续帖子来详细介绍利用过程。同时,完整的漏洞利用代码可以在 GitHub 这里 找到。


原文始发于微信公众号(3072):Pwn2Own Automotive 电车充电器漏洞挖掘

版权声明:admin 发表于 2024年7月18日 上午10:25。
转载请注明:Pwn2Own Automotive 电车充电器漏洞挖掘 | CTF导航

相关文章