Pwn2Own Automotive:破解 CHARX SEC-3100

IoT 2个月前 admin
20 0 0

我们的上一篇文章探讨了我们在Pwn2Own Automotive比赛中发现的一些CHARX SEC-3100 ControllerAgent服务中的漏洞。现在,我们将详细介绍如何利用这些漏洞实现完全远程的攻击。

Pwn2Own Automotive:破解 CHARX SEC-3100


我们上次讨论的是一个释放后使用(UAF)的原语。值得注意的是,这个UAF在进程关闭时发生(类似“一次性”风格的漏洞),我们没有任何信息泄露来轻松应对ASLR(地址空间布局随机化)。

如果你想尝试自己利用类似的漏洞,我们在我们的浏览器WarGames平台上托管了一个挑战,该挑战包含了相同漏洞模式的改编版本,点击这里进行挑战。

Pwn2Own Automotive:破解 CHARX SEC-3100

遍历释放的链表

回顾一下,上一篇文章中的C++析构函数顺序错误导致在对象销毁过程中发生UAF,这发生在进程关闭的退出处理程序中。我们通过空指针解引用错误(触发信号处理程序调用exit)启动退出处理程序。一个半销毁的对象持有一个已释放的std::list,然后遍历该列表以找到具有特定ID的列表节点条目。

更具体地说,C++列表包含ClientSession对象,列表迭代的目的是找到要关闭和清理的会话。换句话说,我们正在遍历一个已释放的列表。

C++标准库实现std::list为一个链表,每个节点有下一个/前一个指针,随后是内联的数据类型:

std::list<T> {
    std::list_node<T>* head;
    std::list_node<T>* tail;
}

std::list_node<T> {
    std::list_node<T>* next;
    std::list_node<T>* prev;
    T val;
}

ClientSession的情况下,节点看起来像这样,大小为0x60

Pwn2Own Automotive:破解 CHARX SEC-3100

列表析构函数从头开始迭代每个节点,并对每个节点调用delete。然而,对于列表的“根”本身,它不会清除头/尾指针(即指向已释放的内存)。清除它们是不必要的,因为销毁的列表无效。

在列表销毁后,ClientConnectionManagerTcp析构函数最终通知ControllerAgent无效连接ID。无效化函数遍历(现在已经销毁的)列表,寻找具有匹配连接ID的会话。

列表遍历的伪代码如下所示:

void ControllerAgent::on_client_removed() {
    // client_sessions列表已销毁!cur指向现在已释放的第一个列表节点
    std::list_node<ClientSession>* cur = this->client_sessions.head;

    while (cur != &this->client_sessions) {
        if (cur->m_isConnectionAssigned
                && m_clientConnection->vtable->get_connection_id() == <expected ID>) {
            // 取消分配连接...
            break;
        }
        cur = cur->next;
    }
}

假设我们可以控制遍历中的某个节点,我们可以通过虚拟调用get_connection_id轻松劫持控制流。

控制列表节点

为了理解如何控制一个节点,请考虑列表头节点在列表销毁时已释放。当该节点在列表析构函数中被释放时,块的大小类别为0x68,并将被放入该大小的tcache缓存桶中。这里不需要完全理解glibc tcache的内部机制;只需要知道tcache缓存桶是相同大小的空闲块的单链表,其中下一个指针位于空闲块的偏移量0处。

所以当头节点被释放时,分配器基本上会执行以下操作:

p->next = tcache_bin->head;
tcache_bin->head = p;

方便的是,从tcache的角度来看,std::list_nodenext指针位于相同位置。该指针将被覆盖为当前tcache缓存桶的头,而其余内容保持不变(技术上tcache“键”也会写入偏移量8,与prev指针重叠,我们不关心)。

总结一下,当触发上述UAF列表遍历时,第一个节点将是几乎未触及的已释放列表头节点,而剩余的迭代将是***遍历tcache缓存桶***。

现在很清楚,控制列表节点归结为两件事:

  • 确保头列表节点未“分配”,以便列表遍历继续到第二个“节点”(实际上是tcache块)
  • std::list<ClientSession>析构函数之前将某些内容放入tcache,以便我们控制第二个“节点”

第一点只是后勤问题。我们可以连接两个客户端,然后断开第一个以取消分配会话,最后使用空指针解引用触发退出处理程序UAF。

填充 Tcache

我们之前提到过,JSON TCP 消息传递支持一个 configAccess 操作。此操作提供对一小部分配置变量的读/写访问。

在内部,这些变量由 ConfigurationManager(单一结构 ControllerAgent 的子结构)管理,并以 std::string 对的形式存储。设置这些字符串变量提供了一种非常方便的分配原语,并且这些字符串将在 ConfigurationManager 析构函数期间释放,该析构函数发生在列表析构函数之前。

唯一需要克服的障碍是通常配置字符串不能包含空字节…一个处理此问题的有用技巧是,认识到为 std::string 分配新值不一定会导致重新分配。

标准库实现仅在当前存储不足够大时才会分配新的存储。否则,新字符串将简单地复制到现有分配中,并在末尾附加一个空字节。

例如,对于现有的字符串 AAAA,分配一个新字符串 BB 将导致重新使用相同的分配,现在包含 BBA

到目前为止,攻击的一般计划是:

  • 设置一个配置值为大小为 0x60 的字符串
  • 反复分配较小的字符串以嵌入空字节并构造一个假的 std::list_node<ClientSession>
  • 触发空引用 => 退出处理程序/析构函数
  • 配置字符串与假节点一起被释放,放入 tcache
  • 会话列表被析构,第一个节点被释放到 tcache
    • 下一个指针变为 tcache 中的内容,即假节点
  • UAF 列表遍历到第二个假节点(释放的配置字符串)
  • 从假节点的 m_clientConnection 劫持虚拟调用

Pwn2Own Automotive:破解 CHARX SEC-3100

这留下了一个关键问题未解答:在启用 ASLR 的情况下,我们不知道将假 m_clientConnection 指向哪里,不知道小工具在哪里等等。BSS 中有许多大缓冲区(例如 TCP 输入)可以放置我们的假对象,如果我们知道它们在内存中的位置的话。

在没有信息泄露的情况下,我们需要变得有创意…

ASLR 熵

正如我们在发现 UAF 时看到的那样,如果控制器代理退出,系统监控/看门狗会重新启动它,因此猜测 ASLR 偏移、崩溃和重新启动的暴力破解方法似乎是可行的。CHARX SEC-3100 运行 32 位 ARM Linux,默认情况下仅有 8 位的 ASLR 随机化默认情况下。

这只有 256 种可能的偏移,比较容易暴力破解。

一个问题是每次迭代可能需要超过 6 秒,将成功利用的平均/期望运行时间推高至 25 分钟以上……这有点太长了。相对于天真地暴力破解 ASLR,我们可以做得更好。

ASLR “绕过”:BSS 喷射

控制器代理二进制文件的一个显著特征是它的 BSS 段非常大,大约 0x1b3000 字节,或 435 页。这引发了这样一个想法:我们可以在各个页中以相同页偏移放置多个假对象,这样如果任何一个有效载荷在正确的地址,则利用就会成功。

为了说明这一点,假设我们在未偏移地址 0x10400x2040 有假对象,然后猜测地址 0x2040 作为我们假列表节点中的指针。这种猜测在 ASLR 偏移为 0 或 0x1000 时都有效。这单独就会使成功概率加倍。随着 n 个假对象,概率变为 n / 256

Pwn2Own Automotive:破解 CHARX SEC-3100

下一步是找到 BSS 中可以容纳这些假对象的大结构。

更仔细的检查显示,超过 80% 的 BSS 属于一个名为 V2GMessageReqMsg 的结构,大小超过 0x167000 字节。此结构包含最近解析的带有操作 v2gMessage 的 TCP JSON 消息。那么这个结构中我们能控制多少呢?

填充 V2G 结构

V2G(vehicle-to-grid)是一个与电动汽车将电力卖回电网相关的协议,JSON 消息传递期望像 salesTariffconsumptionCost 这样的键。此结构包含几个子结构中的数组,而这些子结构又包含其他数组。这些嵌套数组解释了该结构的庞大尺寸。

由于此结构旨在从用户输入(通过 TCP)填充,因此许多字段可以被控制。然而,每个假对象必须使用在相同页偏移处的可控字段,这引入了显著的约束。子结构的大小不对齐,因此在一页中容易控制的字段在下一页中可能无法控制。同样,在一页中可能控制 8 个字节,而在其他页中只能控制 4 个字节,等等。

为了放松这些约束,我们将仅有 4 个字节 的假对象。换句话说,每个假对象仅是一个 vtable。这些 vtables 每一个都可以指向非约束的 “原始” 缓冲区,用于 TCP/UDP/HomePlug 数据包输入。

适应这些约束意味着只能使用某些字段作为我们的 V2G JSON 输入消息。我们最终构造的消息看起来像下面的 JSON blob,其中 {"": 0} 对象是用于推进到下一页的填充:

Pwn2Own Automotive:破解 CHARX SEC-3100

通过这种方式,我们能够在 83 页中填充假对象,占 256 个可能的 ASLR 偏移中的 83 个。这使得我们绕过 ASLR 的概率接近 1/3,远远超过了天真暴力破解的 1/256。

V2G 消息传递在程序启动后需要进行一些其他初始化,因此每次暴力破解迭代需要更长时间等待设置完成,导致每次迭代大约需要 15 秒。尽管如此,增强的成功概率非常值得每次迭代的减速。

COP Chain

提高假对象位于猜测地址的概率是很棒的,但我们也限制了自己只能使用带有虚表且没有额外负载的假对象。我们的原语从一个简单的UAF(Use-After-Free)演变成一个任意的虚调用,但在“this”参数处只有4个可控字节。

为了将其转变为完整的代码执行,我们将使用一系列COP(Call-Oriented Programming)gadget,最终实现一个带有任意参数的任意调用。

我们从控制r0和其内的4个字节(假对象的虚表)开始。我们跳转到以下gadget(我们只关心突出显示的指令):

0x498148:    mov     r4, r0
0x49814a:    ldr     r7, [r0, #0]
0x49814c:    mov     r5, r1
0x49814e:    mov     r6, r2
0x498150:    mov     r1, r3
0x498152:    ldr     r2, [sp, #24]
0x498154:    ldr     r3, [r7, #20]
0x498156:    blx     r3

这个gadget本质上是mov r4, r0,然后将控制权转移到一个不同的虚表函数(下一个gadget)。下一个gadget将是:

0x4a32ea:    ldr     r0, [r4, #0]
0x4a32ec:    ldr     r3, [r0, #0]
0x4a32ee:    ldr     r3, [r3, #8]
0x4a32f0:    blx     r3

这个gadget反引用r4,并将其值视为一个C++对象,调度一个虚调用。结合前一个gadget,我们实际上完成了一个简单的反引用r0 = *r0

关键的是,这会将之前的假虚表视为一个完整的第二个假对象。由于这个第二个假对象将在一个不受约束的缓冲区中,我们现在有一个任意虚调用,带有完全可控的“this”参数(包含任意字段)。

从这里,我们将再次触发mov r4, r0gadget,然后跳转到我们的最终gadget,这是一个循环遍历函数指针/参数对的数组的函数的一部分。这个gadget有点长,但我们已经突出了从r4控制到函数指针调用的路径:

0x458d12:    ldrh    r1, [r4, #8]
0x458d14:    ldr     r3, [r4, #4]
0x458d16:    ldr     r4, [sp, #0]
0x458d18:    cbnz    r1, 0x458d24
0x458d1a:    b.n     0x458d06
0x458d1c:    subs    r1, #1
0x458d1e:    add.w   r3, r3, #16
0x458d22:    beq.n   0x458d06
0x458d24:    ldrd    r2, r0, [r3]
0x458d28:    eors    r2, r4
0x458d2a:    tst     r2, r0
0x458d2c:    bne.n   0x458d1c
0x458d2e:    ldr     r2, [r3, #12]
0x458d30:    cmp     r2, #0
0x458d32:    beq.n   0x458d06
0x458d34:    ldr     r0, [r3, #8]
0x458d36:    mov     r1, r6
0x458d38:    blx     r2

我们最终得到一个带有任意第一个参数的任意调用,我们可以简单地将其指向system(已经存在于PLT中)以产生一个回连shell。

总结COP链:

  • 使用r0 = *r0gadget对从假虚表启动到完全控制的第二个假对象
    • mov r4, r0gadget之后
    • ldr r0, [r4]gadget
  • 使用接受函数指针/参数结构的gadget调用system
    • mov r4, r0赋予r4控制
    • 下一个gadget从r4获取函数指针/参数

构建假对象

我们有一条从假4字节对象到第二个假对象再到system的路径,但值得注意的是,每个ASLR滑动都必须单独存在这些第二个假对象。每个第二个假对象必须具有不同的虚表指针、gadget地址、用于system的命令行字符串参数地址等……

第二个假对象将分布在3个“原始”缓冲区中:

  • TCP输入
  • UDP广播输入
  • HomePlug数据包输入

接收到的UDP输入和HomePlug数据包是完全原始的,但TCP缓冲区会经历一些字符串处理,即消息是以换行符分隔的。这不是根本上的限制,但需要额外的迭代来嵌入空字节(类似于std::string的行为),并且负载不能包含换行符。

当所有负载都到位时,我们最终得到如下概念性内容:

Pwn2Own Automotive:破解 CHARX SEC-3100

根据ASLR滑动,只有一个(或没有)假对象对会真正位于正确的地址,所以一次只有一组箭头显示有效。

汇总一切

制定策略后,实施利用就变成了将所有内容包裹在必要的暴力破解逻辑中。

回顾一下利用流程,我们在一个循环中执行以下步骤:

  1. 等待代理执行V2G前置初始化
  2. 使用配置值std::string来制作一个假的列表节点
  3. 在BSS中填充V2GMessageReqMsg,包含许多假对象
    • 每个对象在同一页面偏移处,vtable只有4个字节
    • 每个vtable指向原始缓冲区中的第二个假对象
  4. 用第二个假对象(TCP/UDP/HomePlug数据包)填充原始BSS缓冲区
  5. 触发HomePlug空指针解引用,启动退出处理程序(ControllerAgent析构函数)
  6. 带有假列表节点的配置值被释放到tcache中
  7. 列表析构函数释放列表节点
    • 头节点的下一个指针被覆盖为下一个tcache指针,即假列表节点
  8. ClientConnectionManagerTcp析构函数最终调用UAF列表遍历
    • UAF列表遍历使用伪造的假列表节点中的假对象指针
  9. 在伪造的假对象地址上劫持虚函数调用
    • 如果ASLR滑动已被考虑,地址将有效,否则将崩溃
    • 进入调用system的COP链
  10. 检查是否有反向连接的shell,否则等待服务重启并重复

使用BSS喷射技术有83/256的成功概率,每次迭代尝试大约需要15秒,利用程序的平均预期运行时间不到一分钟。

参考文献,你可以在 https://github.com/ret2/Pwn2Own-Auto-2024-CHARX 找到完整的利用代码。


原文始发于微信公众号(3072):Pwn2Own Automotive:破解 CHARX SEC-3100

版权声明:admin 发表于 2024年7月30日 上午10:31。
转载请注明:Pwn2Own Automotive:破解 CHARX SEC-3100 | CTF导航

相关文章