CVE-2020-14386 复现

CVE-2020-14386 复现

Fri Jul 25 2025 Pin
1689 words · 10 minutes

梗概

CVE-2020–14386(Linux 内核 af_packet 子系统中的内存损坏漏洞)的发现凸显了网络安全领域持续存在的挑战: 权限提升容器化安全 (docker逃逸)内核级漏洞的交叉点。

环境搭建

安装编译依赖:

Terminal window
sudo apt update
sudo apt install -y build-essential libncurses-dev bison flex libssl-dev libelf-dev \
bc dwarves zstd git

下载 Linux Kernel 5.7.1 的源码:

Terminal window
wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.7.1.tar.xz
tar -xvf linux-5.7.1.tar.xz
cd linux-5.7.1

Linux Kernel 配置选项:

Terminal window
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:

Terminal window
# 清除旧编译
make clean
make -j$(nproc) bzImage

然后,利用 create-image.sh 脚本创建一个文件系统的镜像 (bulleye.img)。然后利用 qemu 的 net user mode 作为网络,随后启动 qemu 即可。

启动环境成功后,创建一个普通账户。我这里创建的是 gosh 账户:

Terminal window
adduser gosh
usermod -aG sudo gosh
# 验证
groups gosh
Environment

技术细节

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()。如下图所示:

call 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 进而崩溃。

page fault

利用思路

利用的思路是将原语变为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字节
  • 我们可以覆盖refcount的最高两字节
    • 编译后,key_id和deactivated都占4字节
    • 如果我们利用bug越界写9~10字节,我们可以覆盖refcnt的1~2字节

Thanks for reading!

CVE-2020-14386 复现

Fri Jul 25 2025 Pin
1689 words · 10 minutes