CVE-2020-14386:Linux内核权限提升漏洞分析

 

0x00 前言

最近一段时间,我一直在审计Linux内核中的数据包套接字源码,最后成功发现了CVE-2020-14386,这是Linux内核中的一个内存破坏漏洞。利用该漏洞,低权限用户可以在Linux系统中将权限提升至root级别。在本文中,我将与大家分享该漏洞的技术细节以及如何利用该漏洞。

几年以前,研究人员在数据包套接字中发现了多个漏洞(CVE-2017-7308以及CVE-2016-8655),也公开了一些研究成果(如Project ZeroOpenwall),从中我们可以对相关领域有个大致了解。

为了触发该漏洞,我们需要内核启用AF_PACKET套接字(CONFIG_PACKET=y),触发漏洞的进程也需要具备CAP_NET_RAW权限。如果用户命名空间处于启用状态(CONFIG_USER_NS=y),且能被非特权用户访问,那么非特权用户就可以在低权限用户空间满足漏洞触发条件。比较意外的是,某些Linux发行版(如Ubuntu)在默认情况下同时满足了这几个约束条件。

 

0x01 技术细节

备注:本文引用的所有代码片段均来自于5.7版内核源码。

由于Project Zero之前已经详细分析过AF_PACKET的具体实现,因此这里我将忽略已经涉及过的一些细节(比如帧和块之间的关系),直接描述漏洞及漏洞根源。

该漏洞源自于一个计算错误,最终将导致内存破坏,具体位置在(net/packet/af_packet.c的)tpacket_rcv函数中。

这个计算错误从2008年7月19日起开始引入,来自于commit 8913336(“packet: add PACKET_RESERVE sockopt”)。然而,只有在2016年的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环形缓冲区(ring buffer)来创建raw socket(AF_PACKET域,SOCK_RAW类型),并且使用PACKET_RESERVE特殊选项。

图1. 摘抄自man7.org

上图中提到的headroom实际上只是由用户指定大小的一个缓冲区,该缓冲区分配位置位于环形缓冲区中收到的每个报文数据之前。用户可以通过setsockopt系统调用来设置这个值。

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;
}

图2. setsockoptPACKET_RESERVE的实现

如上图所示,代码首先会检查该值是否小于INT_MAX。这个检查逻辑添加自某次补丁,目的是避免在计算packet_set_ring中的最小帧大小时出现溢出问题。随后,代码会验证是否尚未为接受/发送环形缓冲区分配页面,这样可以避免tp_reserve字段与环形缓冲区自身之间存在的不一致问题。

设置tp_reserve的值后,我们可以通过setsockopt系统调用,使用PACKET_RX_RING选项来触发系统分配环形缓冲区。官方文档中关于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函数中调用环形缓冲区分配函数

如上图所示,block大小可以从用户空间来控制。alloc_pg_vec函数会分配pg_vec数组,然后通过alloc_one_pg_vec_page函数分配每个block:

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来分配block页面:

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代码片段

分配block后,pg_vec数组被保存到packet_ring_buffer结构中,嵌入在packet_sock结构中,该结构用来表示套接字。

当网卡接口上收到报文时,绑定到tpacket_rcv函数的套接字会被调用,报文数据与TPACKET元数据会被写入环形缓冲区。在tcpdump之类的实际应用程序中,这个缓冲区会被内存映射至用户空间,从中可以读取报文数据。

 

0x02 漏洞分析

现在我们来深入分析tpacket_rcv函数的具体实现(如图7所示)。首先,代码会调用skb_network_offset,提取接收报文的网络头偏移值,存放到maclen。在我们的示例中,这个值为14个字节,对应以太网头的大小。随后,代码会计算netoff(代表帧中网络头的偏移值),计算过程涉及到TPACKET头(每个版本都为固定值)、maclen以及tp_reserve值(由用户控制)。

然而这个计算过程可能会溢出,因为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函数中的计算方式

如上图所示,如果套接字上设置了PACKET_VNET_HDR选项,就会在其中添加sizeof(struct virtio_net_hdr),以处理virtio_net_hdr结构,该结构应该位于以太网头之后。最后,代码会计算以太网头偏移值,保存到macoff中。

随后如图8所示,代码会使用virtio_net_hdr_from_skb函数,将virtio_net_hdr结构写入环形缓冲区中,其中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收到的值将大于block的大小(下溢出),导致写操作超出缓冲区的边界。

然而,如下检查代码阻止了这种攻击方式:

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,在缓冲区边界后执行写操作。

 

0x03 利用原语

通过控制macoff的值,我们可以控制环形缓冲区后最多偏移10个字节的空间,从而初始化virtio_net_hdr结构。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个字节。此时如果不执行任何堆控制策略,内核将马上崩溃。

 

0x04 PoC

大家可以访问此处,获取能够触发漏洞的PoC。

 

0x05 补丁

为了修复该bug,我提交了如下补丁

图11. 我提交的补丁

该补丁的主要工作原理是,如果我们能将netoff的类型从unsigned short改成unsigned int,那么就可以判断该值是否超出了USHRT_MAX。如果满足该条件,则可以丢弃报文,避免进一步处理该报文。

 

0x06 漏洞利用

我们对该漏洞的主要利用思路是将前面的利用原语改成一个释放后重用(use-after-free)原语。为了实现该目标,我们考虑过减少对某些对象的引用计数。比如,如果某个对象的引用计数值为0x10001,那么内存破坏情况如下图所示:

图12. 在对象引用计数值中清空1个字节

如图3所示,破坏内存后,引用计数的值将变成0x1,因此在减少一处引用后,该对象会被释放。

然而,为了触发该场景,我们需要满足如下约束条件:

1、引用计数值必须位于该对象最后的1-10个字节中;

2、我们需要在页面的尾部分配对象。这是因为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结构体

该对象看上去能够满足我们的约束条件:

1、我们可以从非特权用户上下文中创建一个sctp服务端及客户端。更具体一些,该对象会在sctp_auth_shkey_create函数中分配。

2、我们可以在页面尾部分配该对象。

  • 该对象的大小为32字节,通过kmalloc分配。这意味着该对象会在kmalloc-32缓存中分配。
  • 我们可以验证是否可以在get_free_pages分配的空间后再分配一个kmalloc-32 slab缓存。因此我们可以在这个slab缓存页面中破坏最后一个对象(这是因为4096 % 32 = 0,slab页面末尾没有可用的空间,并且最后一个对象分配在我们分配的空间之后。其他slab缓存大小对我们而言可能并不适用,比如大小为96字节时,4096 % 96 != 0)。

3、我们可以破坏refcnt字段的2个高位字节。

  • 完成编译后,key_id以及deactivated的大小均为4字节。
  • 如果我们使用该漏洞来破坏9-10个字节,就会破坏refcnt字段的1-2个最高有效位。

 

0x07 总结

我比较惊讶的是,这么简单的一个计算安全性问题竟然还存在于Linux内核中,并且之前一直没被发现过。此外,非特权用户命名空间也暴露出了巨大的本地提权攻击面,因此Linux的各个发行版需要考虑是否启用这些功能。

(完)