智能汽车的新攻击面:GNU Glibc内存损坏漏洞分析(CVE-2020-6096)

 

0x00 摘要

现代汽车是一个复杂的机器,往往是将机械和计算机系统融为了一体。随着汽车科技的不断进步,一些附加的传感器和设备开始被添加到车辆上,以帮助驾驶员掌握内部或外部环境。这些传感器可以为驾驶员提供实时信息,将车辆连接到全球车联网,并且在某些情况下,还可能会主动使用这些遥测数据来驾驶车辆。

在这些车辆中,通常还会集成移动组件和云组件,以提升最终用户体验。驾驶员将拥有车辆监控、远程启动/停止、实时更新和道路救援等功能,这些附加服务无疑改善了驾乘生活的质量。

但与此同时,所有这些电子和计算机系统在其连接的车辆中,引入了许多不同的攻击媒介,包括蓝牙、数字无线电(HD Radio/DAB)、USB、CAN总线、Wi-Fi,在某些车辆中还包括蜂窝网络。如同其他的嵌入式系统一样,联网的车辆也容易遭受到网络攻击和安全威胁。联网车辆面临的一些威胁包括软件漏洞、基于硬件的攻击,甚至是对车辆的远程控制。在最近开展的一些研究之中,Cisco的客户体验评估与渗透团队(CX APT)在GNU libc中发现了针对ARMv7的内存损坏漏洞,这将导致Linux ARMv7操作系统存在被漏洞利用的风险。这个漏洞被编号为TALOS-2020-1019/CVE-2020-6096。

CX APT集合了来自NDS、Neohapsis和Portcullis的专家,该团队为全球客户提供各种安全评估和攻击模拟服务。CX APT IoT安全事件专门研究并识别汽车组件中存在的漏洞。有关此漏洞的更多信息,您可以在这里阅读完整的公告。CX APT与Cisco Talos一起披露了该漏洞,libc库的维护者计划在8月发布修复此漏洞的更新。

 

0x01 初始溢出分析

作为工程师和开发人员,我们对库函数的行为存在很多固有的假设,特别是一些比较完善的标准库,例如libc。然而,如果这些假设是不成立的,就可以更改程序执行,并导致未定义的行为。基于这种情况下,Cisco在memcpy()的ARMv7实现中发现了一个漏洞,该漏洞能够导致程序进入未定义的状态,并允许在目标应用程序中执行远程代码。最终,在原本应该发生段错误或崩溃的情况下,memcpy()中存在的漏洞将导致程序继续执行。这种意外的行为可能会导致程序在运行时状态已经损坏的状态下继续执行,从而导致存在漏洞利用的机会。

我们在对连接的车辆进行近源渗透测试的过程中,在嵌入式Web服务器中发现了整数下溢出漏洞。该嵌入式Web服务器通过车辆的Wi-Fi网络对外暴露,因此有权访问该网络的任何人都可以访问这台Web服务器。尽管该整数下溢出漏洞最终将允许在车辆上实现远程代码执行,但嵌入式设备上memcpy()函数的行为却更加有趣。
我们发现,容易受到攻击的嵌入式Web服务器是使用C++语言进行编码的。当Web服务器收到较大的GET请求时,我们观察到它发生崩溃并产生段错误。自然,这样的崩溃非常值得关注,应该对其开展进一步的研究。

经过进一步的分析,我们将崩溃归因于如下的代码片段。这段代码是根据嵌入式Web服务器的可执行映像重构的。

重构的代码片段:

在上面的逻辑中,尝试解析HTTP请求,直至找到行尾的字符。在这个HTTP请求中,行尾字符由CR/LF字符序列定义,以十六进制表示为0x0D和0x0A。最重要的是,应该关注,这个特定实现只需要找到其中的一个字符(CR或者LF)就会结束此行。

GET请求解析后的状态保存在sLineBuffer结构中。该结构由四个元素组成,如下图所示。

反汇编中的sLineBuffer结构:

bufsz – 缓冲区的大小

nl_pos – 找到CR/LF字符的缓冲区偏移量

len – 当前行的长度

buf – 当前正在解析的缓冲区

为了将整数下溢出转换为远程代码执行,上述HTTP请求解析循环将重复进行四次,以正确设置用于PC控制的堆栈。

第一次循环

在第一次循环过程中,sLineBuffer结构完全使用默认值填充。

bufsz = 2048

nl_pos = 0

len = 0

漏洞利用开始时,sLineBuffer结构的状态:

由于sLineBuffer->lenrecv_len都设置为0,因此将跳过第10行的for循环,继续向下执行,到达第23行的recv函数。然后,recv()函数将从套接字读取2048字节,并写入sLineBuffer->buf[0]位置。

recv()返回时,recv_len变量将被设置为返回值2048,执行将继续向下到达第31行,其中sLineBuffer->len被设置为等于recv_len

第二次循环

在第二次循环的开始,sLineBuffer结构是在上次循环结束时的赋值。

bufsz = 2048

nl_pos = 0

len = 2048

第二次循环开始时,sLineBuffer结构的状态:

执行将继续到第10行的for ()循环,该循环将在接收到的请求中搜索CR/LF字符,for ()循环将在缓冲区的开始和偏移量sLineBuffer-> len + recv_len之间进行循环。但是,由于sLineBuffer-> lenrecv_len的大小均为2048,因此for ()循环最终将到达缓冲区末尾,并在堆栈中搜索CR/LF字符。
for ()循环将搜索缓冲区结束后的CRLF字符:

如果请求的前2048个字符中没有CR/LF字符,那么下一个要找到的CR/LF字符将位于缓冲区末尾的堆栈上。在我们的示例中,我们在偏移量2760处发现了另一个换行符。
偏移量为2760处找到的换行符:

在找到换行符后,sLineBuffer中的nl_poslen变量将进行相应更新,请参考第14行和第17行。变量sLineBuffer->nl_pos此时的值为0xAC9(2761),而sLineBuffer->len此时的值为0xfffffd37(-713)

第三次循环

如第7行所示,在第三次循环后不久,就会执行对memcpy()的调用。但是,在上一次循环中,我们已经看到此时sLineBuffer->len的值是一个负值。
调用memcpy()时注册的内容:

如上所示,num的函数参数,也就是要复制的字节数被设置为0xfffffd37。这个十六进制值分别等于无符号十进制形式的+4294966583,以及有符号十进制形式的-713。由于memcpy()需要的是一个无符号整数,因此对memcpy()的调用会尝试复制4294966583字节,这就会导致出现段错误。
但是,在我们的尝试过程中,并没有发生段错误,memcpy()成功返回了。

 

0x02 对memcpy的分析

在调用memcpy()之后,将memcpy()实现的地址加载到PC寄存器中,然后执行就转到第一条指令。memcpy()的ARMv7实现专门是针对该体系结构的。
ARMv7 memcpy()实现的汇编:

由于ARM调用约定,memcpy()函数参数存储在以下寄存器中。

R0 – 目的地址

R1 – 源地址

R2 – 要复制的字节数(num)

如第二行所示,CMP指令会将num(要复制的字节数)的值与64进行比较。根据定义,ARM中的CMP指令从寄存器值中减去操作数的值,生成适当的条件代码。

CMP{cond} Rn, Operand2

CMP指令将Rn的值减去Operand2的值。除了会丢弃结果之外,这条指令与SUBS指令相同。

这些条件代码存储在当前程序状态寄存器(CPSR)的前四个字节中。

条件码设置为最高有效四位的ARM CPSR寄存器结构:

将num与64进行比较之后,第三行的BGE指令将解释这些结果,并进入到相对应的分支中继续执行。

如果要复制64个或更多字节,则程序执行将进入到分支,执行会转移到地址0x405ffcb4。如果需要复制的字节数少于64个,则不会采用该分支,并且执行将继续向下。这一实现的其他行是一系列ARM NEON指令,这些指令已经针对复制64字节以下的内容进行了优化。

但是,在这种情况下,传递给这个memcpy()调用的num值等于0xfffffd37,这个值是在调用memcpy()之前在调用程序函数中进行的赋值。由于寄存器R2中包含负值,因此CMP指令将得出负的结果,因此相应地设置了条件代码。CMP指令前后的条件代码如下所示。

CMP指令之前的条件CPSR:

CMP指令之后的CPSR寄存器的内容:

如图所示,条件代码的值从0变为了1。

BGE是一个针对符号的分支,它是大于等于0的情况。因此,如果值为负,将不会走到这个分支。最终,当长度值为负时,memcpy()函数也就不会走到这个分支了,而是会继续复制少于64个自己的内容。

这意味着,并不会复制完整的4294966583字节(即num参数的无符号对应值),而是仅仅复制最低有效字节数。

为了展示该漏洞,并重点说明ARMv7的memcpy()实现与其他平台之间的区别,我编写了一个简短的测试程序。该程序尝试将0xfffffd37(分别是无符号十进制形式的+4294966583和有符号十进制形式的-713)字节复制到内存中的某个位置。

测试程序代码中展示了memcpy()中的差异:

在其他平台上运行时,这个程序将出现段错误,因为memcpy()的num参数0xfffffd37被正确解释为“size_t”值,该值是无符号整数。

在ARMv7架构上运行的测试memcpy()程序:

在x64体系结构上运行的测试memcpy()程序:

令人惊讶的是,memcpy()在ARMv7上的实现并没有将num参数视为“size_t”值,而是将其视为有符号整数。num值0xfffffd37被解释为-713,这意味着,将仅仅复制55个字节。复制之后,该程序将成功完成,并且程序将会退出。

因为在memcpy()定义中期待要复制的字节数(num)为无符号整数,因此在memcpy()实现过程的任何时刻都不会对num参数执行带符号的分支操作。取代BGE的是无符号大于或等于的对应分支。这样就可以确保将num参数视为无符号,并且memcpy()将会按期运行,不会在调用程序中导致未定义的行为。

 

0x03 完成漏洞利用

由于memcpy()复制的字节数远少于我们的4294966583字节,而是复制了55个字节,程序执行从memcpy()返回(而不是出现段错误),并继续处理GET请求。
重构的代码片段:

第三次循环(续)
memcpy()返回之后,我们仍然看到sLineBuffer->len保留了负值。并且,此时sLineBuffer结构中的变量为以下值。

调用memcpy()之后sLineBuffer结构的状态:

bufsz = 2048
nl_pos = 2761
len = -713

随着执行继续向下,再次到达第24行的recv()函数。为了确定将数据写入的位置,程序使用sLineBuffer->len作为sLineBuffer->buf的偏移量。但是,由于sLineBuffer->len为负,这将导致recv()将内容写入堆栈中,从而覆盖sLineBuffer结构。

recv()函数之前和之后的堆栈:

第四次循环

覆盖sLineBuffer结构以使其包含我们想要的值之后,HTTP解析循环的最后一次循环将使用我们覆盖的值来获得对recv()函数的完全控制。

在最后一次调用recv()之后不久,我们可以看到recv()沿着链接寄存器(LR)在堆栈中保存了寄存器R0到R3。该操作之前和之后的堆栈结构如下所示。

recv()保存寄存器之前的堆栈内容:

recv()保存寄存器之后的堆栈内容:

如图所示,返回地址(寄存器LR的内容)保存在堆栈中的0x762806f8位置。

但是,由于recv()函数使用sLineBuffer结构的变量作为其函数的参数,因此我们可以使用覆盖的值,来控制在什么位置写入什么内容。

使用recv()覆盖返回地址后的堆栈:

这样一来,我们就可以覆盖PC,并执行任意代码。

就在返回之前,recv()会尝试从堆栈中弹出一些已经保存的寄存器,特别是LR寄存器。由于保存的LR寄存器值已经被缓冲区覆盖,因此我们现在可以控制recv()返回的位置。

recv()函数的结尾在返回之前使用特定值覆盖LR寄存器:

如我们所见,堆栈指针(SP)指向的地址,其值是位于mcount()函数内ROP Gadget的地址。

来自recv()的返回地址被ROP Gadget的地址覆盖:

在从堆栈中弹出该值后,LR寄存器将被我们的受控返回地址覆盖,并且此时已经成功覆盖了recv()的返回地址。来自mcount()的指令会将我们的参数从堆栈中弹出,并将程序控制移交给system(),从而使我们可以在所连接的车辆上实现远程代码执行。

从连接的车辆上返回的反向Shell:

 

0x04 总结

作为工程师和开发人员,我们对库函数的行为存在很多固有的假设。如果这些假设是不成立的,那么其最终的行为可能会损害程序的完整性,并导致出现可以利用的漏洞。

在我们的案例中,memcpy()的ARMv7实现中的一个漏洞就可以导致程序进入未定义状态,并最终运行远程代码执行。当利用memcpy()漏洞时,在本应该发生段错误或者崩溃的地方,程序却继续执行。这种意外行为可能导致程序进入未定义状态,并最终允许远程执行代码。

(完)