梗概
CVE-2020–14386(Linux 内核 af_packet
子系统中的内存损坏漏洞)的发现凸显了网络安全领域持续存在的挑战: 权限提升 、 容器化安全 (docker逃逸) 和内核级漏洞的交叉点。
环境搭建
安装编译依赖:
sudo apt updatesudo apt install -y build-essential libncurses-dev bison flex libssl-dev libelf-dev \ bc dwarves zstd git
下载 Linux Kernel 5.7.1 的源码:
wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.7.1.tar.xztar -xvf linux-5.7.1.tar.xzcd linux-5.7.1
Linux Kernel 配置选项:
make defconfig
scripts/config \ --set-str CONFIG_LOCALVERSION "-cve-2020-14386" \ --enable CONFIG_DEBUG_INFO \ --enable CONFIG_DEBUG_INFO_BTF \ --enable CONFIG_KALLSYMS \ --enable CONFIG_TMPFS \ --enable CONFIG_TMPFS_XATTR \ --enable CONFIG_DEVTMPFS \ --enable CONFIG_DEVTMPFS_MOUNT \ --enable CONFIG_E1000 \ --disable CONFIG_SYSTEM_TRUSTED_KEYS \ --disable CONFIG_SYSTEM_REVOCATION_KEYS
编译 Linux Kernel:
# 清除旧编译make cleanmake -j$(nproc) bzImage
然后,利用 create-image.sh
脚本创建一个文件系统的镜像 (bulleye.img)。然后利用 qemu 的 net user mode 作为网络,随后启动 qemu 即可。
启动环境成功后,创建一个普通账户。我这里创建的是 gosh 账户:
adduser goshusermod -aG sudo gosh# 验证groups gosh

技术细节
PACKET_RESERVE 是一个在 2008 年被引入的网络操作选项。在 man7.org 中对其的描述是:
PACKET_RESERVE (with PACKET_RX_RING) By default, a packet receive ring writes packets immediately following the metadata structure and alignment padding. This integer option reserves additional headroom.
简单来说就是为附加的 headroom 预留一些空间。相关的操作在代码在 packet_setsockopt()
中,可以看到 optlen 一定要为 4 否则不做处理。随后立即调用 copy_from_user()
从用户空间拷贝数据到 val
变量中,因此 val
变量是我们用户可控的。随后使用 lock_sock()
锁住 socket,然后复制版本号。
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;}
通过进一步调试,我们发现后续的调用流程会来到 packet_set_ring()
。如下图所示:

gef> register$rcx : 0x0000000000000000$rdx : 0x0000000000000000$rsp : 0xffffc90000213e40 -> 0x0000000000000000$rbp : 0xffffc90000213ee8 -> 0x0000000000000010$rsi : 0xffffc90000213e70 -> 0x0000000100800000$rdi : 0xffff8881394ed000 -> 0x0000000000000000$rip : 0xffffffff819fb092 <packet_set_ring+0x2> -> 0x544155415641c889
调试发现 (int)req->tp_block_size
大小为 0x00800000,因此下述语句不会成功:
if (unlikely((int)req->tp_block_size <= 0)) goto out;if (unlikely(!PAGE_ALIGNED(req->tp_block_size))) goto out;
接着程序来到如下代码片段:
min_frame_size = po->tp_hdrlen + po->tp_reserve;
这里主要是在计算最小帧,调试发现 po->tp_hdrlen
大小为 0x34。然后来到如下的判断,其中 req->tp_frame_size
大小为 0x00011000。
if (unlikely(req->tp_frame_size < min_frame_size)) goto out;if (unlikely(req->tp_frame_size & (TPACKET_ALIGNMENT - 1))) goto out;
然后,经过一些块的计算过程来到了分配环节:
order = get_order(req->tp_block_size);pg_vec = alloc_pg_vec(req, order);
其中 alloc_pg_vec()
实现如下:
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;
/* __get_free_pages failed, fall back to vmalloc */ buffer = vzalloc(array_size((1 << order), PAGE_SIZE)); if (buffer) return buffer;
/* vmalloc failed, lets dig into swap here */ gfp_flags &= ~__GFP_NORETRY; buffer = (char *) __get_free_pages(gfp_flags, order); // order = 0xb if (buffer) return buffer;
/* complete and utter failure */ return NULL;}
简单来说,alloc_one_pg_vec_page
函数会使用__get_free_pages
来分配block页面。分配block后,pg_vec
数组被保存到packet_ring_buffer
结构中,嵌入在packet_sock
结构中,该结构用来表示套接字。
漏洞分析
在 “技术细节” 中,我们了解到了 set 流程是如何分配内存的。那么在缺陷功能 tpacket_rcv()
我们才能有的放矢。通过调试,走了一堆不知道干嘛的流程。最终发现 maclen
大小为 0xe。如果套接字上设置了PACKET_VNET_HDR
选项,就会在其中添加sizeof(struct virtio_net_hdr)
(0xa),以处理virtio_net_hdr
结构,该结构应该位于以太网头之后。最后,代码会计算以太网头偏移值,保存到macoff
中。
if (sk->sk_type == SOCK_DGRAM) { macoff = netoff = TPACKET_ALIGN(po->tp_hdrlen) + 16 + po->tp_reserve;} 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; // 可能存在溢出}
随后如图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;
本来,我们可以控制 po->tp_reserve
来溢出 netoff
使得 macoff 很大。但实际在调用上述函数之前,回先执行如下的检验。这就导致 macoff 很大会被检测出来。
if (macoff + snaplen > po->rx_ring.frame_size) { if (po->copy_thresh && atomic_read(&sk->sk_rmem_alloc) < sk->sk_rcvbuf) { if (skb_shared(skb)) { copy_skb = skb_clone(skb, GFP_ATOMIC); } else { copy_skb = skb_get(skb); skb_head = skb->data; } if (copy_skb) skb_set_owner_r(copy_skb, sk); } snaplen = po->rx_ring.frame_size - macoff; if ((int)snaplen < 0) { snaplen = 0; do_vnet = false; }}
但是 h.raw + macoff – sizeof(struct virtio_net_hdr)
这一过程仍然可能是存在问题的。我们只需要让 macoff < sizeof(struct virtio_net_hdr) 就可以发生上抬 h.raw。这种情况是存在,我们同样可以操纵 netoff
来实现这一点。此前,我们调试的时候发现 struct virtio_net_hdr
的大小为 0xA(10) 字节。因此我们最多溢出 10 字节,并且是 10 个 0 字节:
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) { // …
此时如果其他 buffer 什么都没有申请过。那么我们很可能会触发 pages fault,导致 kernel 引发 panic 进而崩溃。

利用思路
利用的思路是将原语变为UAF,为了达到这个目的,考虑将一些对象的引用计数减少,例如,一个对象的引用计数是0x10001。如果发生了前向溢出,引用计数将会变为 1,再经历一次释放后,对象将会被free。但为了使其发生,需要满足下列条件:
- refcount需要在对象的最后1~10字节
- 需要对象被分配在页的末尾:
- 因为get_free_pages返回页对齐的地址
经过分析,下面这个对象满足条件:
struct sctp_shared_key { struct list_head key_list; struct sctp_auth_bytes *key; refcount_t refcnt; __u16 key_id; __u8 deactivated;};
看起来这个对象满足我们的条件限制:
- 我们可以在非特权用户上下文中创建一个sctp server和client
- 对象在sctp_auth_shkey_create函数中创建
- 我们可以在页的末尾分配对象:
- 对象大小为32字节,通过kmalloc分配,意味着它使用kmalloc-32
- 因为4096 % 32 = 0,所以在slab页的末尾没有空闲的空间,最后一个对象会正好在我们分配的空间前面。其他slab cache不一定合适,如96字节
- 对象大小为32字节,通过kmalloc分配,意味着它使用kmalloc-32
- 我们可以覆盖refcount的最高两字节
- 编译后,key_id和deactivated都占4字节
- 如果我们利用bug越界写9~10字节,我们可以覆盖refcnt的1~2字节