我们的上一篇文章探讨了我们在Pwn2Own Automotive比赛中发现的一些CHARX SEC-3100 ControllerAgent服务中的漏洞。现在,我们将详细介绍如何利用这些漏洞实现完全远程的攻击。
我们上次讨论的是一个释放后使用(UAF)的原语。值得注意的是,这个UAF在进程关闭时发生(类似“一次性”风格的漏洞),我们没有任何信息泄露来轻松应对ASLR(地址空间布局随机化)。
如果你想尝试自己利用类似的漏洞,我们在我们的浏览器WarGames平台上托管了一个挑战,该挑战包含了相同漏洞模式的改编版本,点击这里进行挑战。
遍历释放的链表
回顾一下,上一篇文章中的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
:
列表析构函数从头开始迭代每个节点,并对每个节点调用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_node
的next
指针位于相同位置。该指针将被覆盖为当前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
将导致重新使用相同的分配,现在包含 BB�A
。
到目前为止,攻击的一般计划是:
-
设置一个配置值为大小为 0x60
的字符串 -
反复分配较小的字符串以嵌入空字节并构造一个假的 std::list_node<ClientSession>
-
触发空引用 => 退出处理程序/析构函数 -
配置字符串与假节点一起被释放,放入 tcache -
会话列表被析构,第一个节点被释放到 tcache -
下一个指针变为 tcache 中的内容,即假节点
-
-
UAF 列表遍历到第二个假节点(释放的配置字符串) -
从假节点的 m_clientConnection
劫持虚拟调用
这留下了一个关键问题未解答:在启用 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 页。这引发了这样一个想法:我们可以在各个页中以相同页偏移放置多个假对象,这样如果任何一个有效载荷在正确的地址,则利用就会成功。
为了说明这一点,假设我们在未偏移地址 0x1040
和 0x2040
有假对象,然后猜测地址 0x2040
作为我们假列表节点中的指针。这种猜测在 ASLR 偏移为 0 或 0x1000
时都有效。这单独就会使成功概率加倍。随着 n
个假对象,概率变为 n / 256
。
下一步是找到 BSS 中可以容纳这些假对象的大结构。
更仔细的检查显示,超过 80% 的 BSS 属于一个名为 V2GMessageReqMsg
的结构,大小超过 0x167000
字节。此结构包含最近解析的带有操作 v2gMessage
的 TCP JSON 消息。那么这个结构中我们能控制多少呢?
填充 V2G 结构
V2G(vehicle-to-grid)是一个与电动汽车将电力卖回电网相关的协议,JSON 消息传递期望像 salesTariff
和 consumptionCost
这样的键。此结构包含几个子结构中的数组,而这些子结构又包含其他数组。这些嵌套数组解释了该结构的庞大尺寸。
由于此结构旨在从用户输入(通过 TCP)填充,因此许多字段可以被控制。然而,每个假对象必须使用在相同页偏移处的可控字段,这引入了显著的约束。子结构的大小不对齐,因此在一页中容易控制的字段在下一页中可能无法控制。同样,在一页中可能控制 8 个字节,而在其他页中只能控制 4 个字节,等等。
为了放松这些约束,我们将仅有 4 个字节 的假对象。换句话说,每个假对象仅是一个 vtable。这些 vtables 每一个都可以指向非约束的 “原始” 缓冲区,用于 TCP/UDP/HomePlug 数据包输入。
适应这些约束意味着只能使用某些字段作为我们的 V2G JSON 输入消息。我们最终构造的消息看起来像下面的 JSON blob,其中 {"": 0}
对象是用于推进到下一页的填充:
通过这种方式,我们能够在 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, r0
gadget,然后跳转到我们的最终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 = *r0
gadget对从假虚表启动到完全控制的第二个假对象-
mov r4, r0
gadget之后 -
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
的行为),并且负载不能包含换行符。
当所有负载都到位时,我们最终得到如下概念性内容:
根据ASLR滑动,只有一个(或没有)假对象对会真正位于正确的地址,所以一次只有一组箭头显示有效。
汇总一切
制定策略后,实施利用就变成了将所有内容包裹在必要的暴力破解逻辑中。
回顾一下利用流程,我们在一个循环中执行以下步骤:
-
等待代理执行V2G前置初始化 -
使用配置值 std::string
来制作一个假的列表节点 -
在BSS中填充 V2GMessageReqMsg
,包含许多假对象-
每个对象在同一页面偏移处,vtable只有4个字节 -
每个vtable指向原始缓冲区中的第二个假对象
-
-
用第二个假对象(TCP/UDP/HomePlug数据包)填充原始BSS缓冲区 -
触发HomePlug空指针解引用,启动退出处理程序( ControllerAgent
析构函数) -
带有假列表节点的配置值被释放到tcache中 -
列表析构函数释放列表节点 -
头节点的下一个指针被覆盖为下一个tcache指针,即假列表节点
-
-
ClientConnectionManagerTcp
析构函数最终调用UAF列表遍历-
UAF列表遍历使用伪造的假列表节点中的假对象指针
-
-
在伪造的假对象地址上劫持虚函数调用 -
如果ASLR滑动已被考虑,地址将有效,否则将崩溃 -
进入调用 system
的COP链
-
-
检查是否有反向连接的shell,否则等待服务重启并重复
使用BSS喷射技术有83/256的成功概率,每次迭代尝试大约需要15秒,利用程序的平均预期运行时间不到一分钟。
参考文献,你可以在 https://github.com/ret2/Pwn2Own-Auto-2024-CHARX 找到完整的利用代码。
原文始发于微信公众号(3072):Pwn2Own Automotive:破解 CHARX SEC-3100
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论