摘要
最近,我一直忙着对Linux内核中的数据包套接字源码进行审计。在此过程中,我发现了一个Linux内核中的内存损坏漏洞:CVE-2020-14386漏洞。利用该漏洞,攻击者可以将Linux系统中普通用户的权限提升为特权用户,即root用户。在这篇文章中,我将为读者详细介绍该漏洞的原理、利用方法以及相应的防护措施。
几年前,安全研究人员在数据包套接字的实现代码中发现了几个的漏洞(CVE-2017-7308和CVE-2016-8655),同时,Project Zero博客以及Openwall网站还刊出了一些文章,专门对这些漏洞进行了相关的介绍。
具体来说,如果内核启用了AF_PACKET套接字(CONFIG_PACKET=y),并赋予了触发该漏洞的进程CAP_NET_RAW权限,同时,如果该进程启用了用户命名空间(CONFIG_USER_NS=y),并且运行非特权用户进行访问的话,则攻击者就可以利用该漏洞在非特权用户命名空间中获得root权限。令人惊讶的是,在某些发行版中,默认情况下就能满足了这一长串的约束条件,比如Ubuntu。
技术细节
(本文的所有代码片段都来自5.7内核源。)
由于AF_PACKET套接字的实现已经在Project Zero博客中深入介绍过了,所以这里就不再赘述,而是直接描述这个漏洞及其根源。
这个漏洞源自于tpacket_rcv函数中一个导致内存破坏的算术问题,该函数位于net/packet/af_packet.c文件中。
这个算术问题于2008年7月19日提交的commit 8913336中被引入 (“packet: add PACKET_RESERVE sockopt”)。然而,直到2016年2月,在提交的commit 58d19b19cd99(“packet: vnet_hdr support for tpacket_rcv”)中,它才成为可触发的内存破坏问题。当然,为了修复这个问题,人们也做过一些尝试,比如2017年5月提交的commit bcc536(“net/packet: fix overflow in check for tp_reserve”),以及2017年8月提交的commit edb58be(“packet: Don't write vnet header beyond end of buffer”)。然而,这些修复措施并不足以防止内存损坏。
我们先来看看PACKET_RESERVE选项:为了触发该漏洞,必须用TPACKET_V2环形缓冲区和PACKET_RESERVE选项的特定值创建一个原始套接字(domain为AF_PACKET,type为SOCK_RAW)。
(摘自https://man7.org/linux/man-pages/man7/packet.7.html)
其中,上面的手册中提到的headroom只是一个由用户指定大小的缓冲区,它将在环形缓冲区上接收到的数据包的实际数据之前被分配。这个值可以通过setockopt系统调用从用户空间进行设置。
```case PACKET_RESERVE:
{
unsigned int val;
if (optlen != sizeof(val))
return -EINVAL;
if (copy_from_user(&val, optval, sizeof(val)))
return -EFAULT;
if (val > INT_MAX)
return -EINVAL;
lock_sock(sk);
if (po->rx_ring.pg_vec || po->tx_ring.pg_vec) {
ret = -EBUSY;
} else {
po->tp_reserve = val;
ret = 0;
}
release_sock(sk);
return ret;
}
```
图1 setockopt的实现代码:PACKET_RESERVE
从图1中我们可以看到,代码首先会检查值是否小于INT_MAX。实际上,这个检查是在补丁中加入的,目的是为了防止在packet_set_ring中计算最小帧的长度时出现溢出。之后,还会验证是否为接收/发送环缓冲区分配了内存页。这样做是为了防止tp_reserve字段和环形缓冲区本身之间出现不一致的情况。
设置了tp_reserve的值之后,我们可以通过setsockopt系统调用触发环形缓冲区本身的分配,其中optname为PACKET_RX_RING:
```
为接收异步数据包创建一个内存映射的环形缓冲区。
```
图2 手册页中关于PACKET_RX_RING选项的描述
这是在packet_set_ring函数中实现的。最初,也就是在分配环形缓冲区之前,将对从用户空间接收到的tpacket_req结构进行几项算术检查:
```
min_frame_size = po->tp_hdrlen + po->tp_reserve;
…
…
if (unlikely(req->tp_frame_size < min_frame_size))
goto out;
```
图3 在packet_set_ring函数中进行的几项检查
从图3中我们可以看到,首先计算出最小帧的长度,然后与从用户空间接收到的值进行比对。这个检查能够确保每一帧中都有足够的空间来存放tpacket报头结构和tp_reserve的字节数。
在完成所有的检查后,将通过调用alloc_pg_vec来分配环形缓冲区:
```
order = get_order(req->tp_block_size);
pg_vec = alloc_pg_vec(req, order);
```
图4 调用packet_set_ring函数中的环形缓冲区分配函数
正如我们从上图中看到的,块的大小是从用户空间进行控制的。其中,alloc_pg_vec函数用于分配pg_vec数组,然后通过alloc_one_pg_vec_page函数分配各个块:
```
static struct pgv alloc_pg_vec(struct tpacket_req req, int order)
{
unsigned int block_nr = req->tp_block_nr;
struct pgv *pg_vec;
int i;
pg_vec = kcalloc(block_nr, sizeof(struct pgv), GFP_KERNEL | __GFP_NOWARN);
if (unlikely(!pg_vec))
goto out;
for (i = 0; i < block_nr; i++) {
pg_vec[i].buffer = alloc_one_pg_vec_page(order);
```
图5 alloc_pg_vec的实现
我们可以看到,alloc_one_pg_vec_page函数将使用__get_free_pages来分配块页面:
```
static char *alloc_one_pg_vec_page(unsigned long order)
{
char *buffer;
gfp_t gfp_flags = GFP_KERNEL | __GFP_COMP |
__GFP_ZERO | __GFP_NOWARN | __GFP_NORETRY;
buffer = (char *) __get_free_pages(gfp_flags, order);
if (buffer)
return buffer;
```
图6 alloc_one_pg_vec_page的实现代码
块的分配工作完成后,pg_vec数组将被保存到packet_ring_buffer结构体中,而后者将被嵌入到代表套接字的packet_sock结构体中。
当在接口上接收到数据包时,将调用与tpacket_rcv函数绑定的套接字,并将数据包数据以及元数据TPACKET写入环形缓冲区。在实际应用中,比如tcpdump,这个缓冲区会被映射到到用户空间,因此可以从中读取数据包数据。
漏洞详情
现在让我们深入了解一下tpacket_rcv函数的实现代码(见图7)。首先,它会调用skb_network_offset,以便将接收到的数据包中的网络报头的偏移量提取到maclen中。就本例来说,报头的长度为14个字节,这是一个以太网报头的长度。之后,根据TPACKET报头、maclen和tp_reserve的值(由用户控制),来计算netoff(代表帧中网络报头的偏移量)。
但是,这个计算可能会发生溢出,因为tp_reserve的类型是 unsigned int,而netoff的类型是unsigned short,而对tp_reserve值来说,唯一的约束(如我们前面所见)就是要小于int_max。
```
if (sk->sk_type == SOCK_DGRAM) {
…
else {
unsigned int maclen = skb_network_offset(skb);
netoff = TPACKET_ALIGN(po->tp_hdrlen +
(maclen < 16 ? 16 : maclen)) +
po->tp_reserve;
if (po->has_vnet_hdr) {
netoff += sizeof(struct virtio_net_hdr);
do_vnet = true;
}
macoff = netoff – maclen;
}
```
图7 tpacket_rcv中的算术运算
如图7所示,如果在套接字上设置了PACKET_VNET_HDR选项,则会在其中加入sizeof(struct virtio_net_hdr),以便处理virtio_net_hdr结构体,它应该刚好超过以太网报头。最后,计算以太网报头的偏移量并保存到MACOFF中。
在该函数的后面,如下图8所示,会使用virtio_net_hdr_from_skb函数将virtio_net_hdr结构体写入环形缓冲区。在图8中,h.raw指向环形缓冲区中当前空闲的帧(该帧是在alloc_pg_vec中分配的)。
```
if (do_vnet &&
virtio_net_hdr_from_skb(skb, h.raw + macoff –
sizeof(struct virtio_net_hdr),
vio_le(), true, 0))
goto drop_n_account;
```
图8 调用tpacket_rcv中的virtio_net_hdr_from_skb函数
最初,我认为可以通过溢出让netoff成为一个较小的值,这样的话,macoff就可以接收一个比块长度更大的值,以便在写入时溢出缓冲区的边界。
然而,下面的检查可以防止上面的溢出:
```
if (po->tp_version <= TPACKET_V2) {
if (macoff + snaplen > po->rx_ring.frame_size) {
…
…
snaplen = po->rx_ring.frame_size – macoff;
if ((int)snaplen < 0) {
snaplen = 0;
do_vnet = false;
}
}
```
图9 tpacket_rcv函数中的另一个算术检查
不过,这个检查仍然无法阻止内存被破坏,因为我们仍然可以通过溢出netoff使macoff成为一个小的整数值。具体来说,我们可以让macoff小于sizeof(struct virtio_net_hdr),也就是10个字节,然后用virtio_net_hdr_from_skb执行写入操作时溢出缓冲区的边界。
攻击原语
通过控制macoff的值,我们可以将virtio_net_hdr结构初始化,初始化的偏移量可控制在环形缓冲区后面最多10个字节。为此,可以通过virtio_net_hdr_from_skb函数将整个结构体清零,然后根据skb结构初始化结构内的一些字段。
```
static inline int virtio_net_hdr_from_skb(const struct sk_buff *skb,
struct virtio_net_hdr *hdr,
bool little_endian,
bool has_data_valid,
int vlan_hlen)
{
memset(hdr, 0, sizeof(hdr)); / no info leak */
if (skb_is_gso(skb)) {
…
if (skb->ip_summed == CHECKSUM_PARTIAL) {
…
```
图10 virtio_net_hdr_from_skb函数的实现代码
然而,我们可以设置skb,以便只将0写入该结构体中。这样我们就可以将__get_free_pages分配的内存空间后面的1-10个字节全部归零。如果不执行任何堆操作策略,就会立即发生内核崩溃。
POC
触发该漏洞的POC代码可以在下面的Openwall帖子中找到。
漏洞补丁
我提交了修复该漏洞的补丁,具体如下所示:
图11 本人为该漏洞提供的补丁代码
其思路是,如果我们将netoff的类型从unsigned short改为unsigned int,就可以检查它是否大于USHRT_MAX,如果大于的话,就直接丢弃该数据包,而无需进一步处理。
利用思路
我们的利用思路是将相关原语转换为UAF漏洞。为此,我们考虑对某个对象的引用计数进行递减。例如,如果一个对象的refcount值为0x10001,那么内存破坏过程如下所示:
上面演示了利用CVE-2020-14386漏洞来清零对象refcount中一个字节的过程。这里同时展示了内存在被破坏前的情形(以refcount值为0x10001为例),以及被破坏后的情形(refcount=0x1)。
图12 将对象refcount中的一个字节清零
从图13中我们可以看到,在内存被破坏之后,refcount的值将变为0x1,所以在释放一个引用之后,对象也将被释放。
然而,为了实现这一目标,必须满足以下约束条件:
- refcount必须位于对象的最后1到10个字节中;
- 我们需要能够在一个内存页末尾为对象分配内存空间;这是因为get_free_pages会返回一个页对齐的地址。
借助于一些grep表达式,并对相关代码进行手工分析之后,我们得到了以下对象:
```
struct sctp_shared_key {
struct list_head key_list;
struct sctp_auth_bytes *key;
refcount_t refcnt;
__u16 key_id;
__u8 deactivated;
};
```
图13 sctp_shared_key结构体的定义
看起来这个对象能够满足我们的所有约束条件:
- 我们可以从一个无权限的用户上下文中创建一个sctp服务器和一个客户端。具体来说,这个对象是在sctp_auth_shkey_create函数中分配的。
- 我们可以在一个内存页的末尾为对象分配内存空间。其中,该对象的长度为32字节,并通过kmalloc进行分配。这意味着对象是在kmalloc-32缓存中分配内存的。我们能够验证,在执行get_free_pages操作之后,还能分配一个kmalloc-32的slab缓存页。所以,我们将能够破坏该slab缓存页中的最后一个对象。由于4096 % 32 = 0,所以,在slab页的最后不会剩下多余的空间,最后一个对象就在我们分配的内存空间的后面。其他的slab缓存大小可能对我们不利,比如96字节,因为4096 % 96 != 0。
- 我们可以破坏refcnt字段的最高2个字节。在编译后,key_id和deactivated的大小都是4个字节。如果我们使用这个漏洞来破坏9-10个字节,就能破坏refcnt字段的1-2个最高有效字节。
小结
令我惊讶的是,Linux内核中仍然存在这样简单的算术安全问题,而且以前还没有被发现过。此外,非特权用户命名空间暴露了本地权限提升的巨大攻击面,因此各个发行版应该慎重考虑是否应该启用它们。
原文地址:https://unit42.paloaltonetworks.com/cve-2020-14386/
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论