Linux内核权限提升漏洞详解

  • A+

摘要

最近,我一直忙着对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)。

1.png

(摘自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帖子中找到。

漏洞补丁

我提交了修复该漏洞的补丁,具体如下所示:

1.png

图11 本人为该漏洞提供的补丁代码

其思路是,如果我们将netoff的类型从unsigned short改为unsigned int,就可以检查它是否大于USHRT_MAX,如果大于的话,就直接丢弃该数据包,而无需进一步处理。

利用思路

我们的利用思路是将相关原语转换为UAF漏洞。为此,我们考虑对某个对象的引用计数进行递减。例如,如果一个对象的refcount值为0x10001,那么内存破坏过程如下所示:

上面演示了利用CVE-2020-14386漏洞来清零对象refcount中一个字节的过程。这里同时展示了内存在被破坏前的情形(以refcount值为0x10001为例),以及被破坏后的情形(refcount=0x1)。

1.png
图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/