houjingyi的博客

bupt coder

CVE-2014-3153浅析-android内核提权漏洞

这个漏洞是2014年5月份爆出来的,影响范围非常广。受影响的Linux系统可能被直接DOS,精心设计可以获取root权限。漏洞产生于内核的Futex(fast userspace mutexes,快速用户空间互斥体)系统调用。对于这个漏洞说得比较清楚的是天融信的文章[3],只不过我发现天融信的文章也是整理的老外的几篇文章还没有标明出处,这个就有点过分了。本文主要是在天融信整理的基础上结合《漏洞战争》一书中的资料,试图一篇文章能把这个问题彻底讲清楚。

Futex简介

在传统的Unix系统中进程间同步机制都是对一个内核对象操作来完成的,这个内核对象对要同步的进程都是可见的,其提供了共享的状态信息和原子操作。当进程间要同步的时候必须要通过系统调用在内核中完成。可是很多同步是无竞争的,即某个进程进入互斥区到再从某个互斥区出来这段时间常常没有进程也要进这个互斥区或者请求同一同步变量的。但是在这种情况下,这个进程也要陷入内核去看看有没有人和它竞争,退出的时侯还要陷入内核去看看有没有进程等待在同一同步变量上。这些不必要的系统调用(或者说内核陷入)造成了大量的性能开销。为了解决这个问题,Futex应运而生。Futex是一种用户态和内核态混合的同步机制。首先,同步的进程间通过mmap共享一段内存,Futex变量就位于这段共享内存中。当进程尝试进入互斥区或者退出互斥区的时候,先去查看共享内存中的Futex变量,如果没有竞争发生则只修改Futex而不用再执行系统调用了。当通过访问Futex变量告诉进程有竞争发生,则还是得执行系统调用去完成相应的处理(wait或者wake up)。
Futex具体实现形式是一个用户态地址空间定义的4字节无符号整形变量。Futex有两种,一种是非优先级继承(non-pi)futex,一种是优先级继承(pi)futex。当变量的值不为0时,内核中线程试图在该Futex上获取锁时就会被阻塞。每个阻塞在Futex上的线程由futex_q结构体来描述。

Futex相关结构体

struct futex_q {
    struct plist_node list;
    struct task_struct *task; //被阻塞线程指针
    spinlock_t *lock_ptr;
    union futex_key key;
    struct futex_pi_state *pi_state;
    struct rt_mutex_waiter *rt_waiter;
    union futex_key *requeue_pi_key;
    u32 bitset;
};

当线程被阻塞时,futex_q.rt_waiter指向一个rt_mutex_waiter结构体。

struct rt_mutex_waiter {
    struct plist_node list_entry;
    struct plist_node pi_list_entry;
    struct task_struct *task;
    struct rt_mutex *lock;
#ifdef CONFIG_DEBUG_RT_MUTEXES
    unsigned long ip;
    struct pid *deadlock_task_pid;
    struct rt_mutex *deadlock_lock;
#endif
    int prio;
};

当有多个线程在同一个优先级继承的Futex变量上被阻塞时,这些线程的futex_q.rt_waiter就通过pi_list_entry字段链接成一个链表,这个链表定义在rt_mutex.wait_list

struct rt_mutex {
    raw_spinlock_t wait_lock;
    struct plist_head wait_list;
    struct task_struct *owner;
#ifdef CONFIG_DEBUG_RT_MUTEXES
    int save_state;
    const char *name, *file;
    int line;
    void *magic;
#endif
};

利用futex_cmp_requeue_pifutex_wait_requeue_pifutex_lock_pi三个函数存在的两个漏洞,攻击者可以造成Futex变量有等待者却没有拥有者,破坏Futex架构,进一步通过精心设计的函数栈填充修改栈上的等待者rt_mutex中的数据,从而对内核中的数据进行修改,达到提权目的。下面将逐步分析漏洞利用过程中的几部分原理。

Futex相关API

futex_lock_pi(uaddr)
futex_lock_pi中会调用futex_lock_pi_atomic,如果返回1,那么表明获得锁,futex_lock_pi直接返回。如果返回0,那么调用futex_lock_pi的线程会进入等待状态。
这里写图片描述
如果ret的值为0,那么会执行到rt_mutex_timed_lock->rt_mutex_timed_fastlock->rt_mutex_slowlock-> task_blocks_on_rt_mutex,在 task_blocks_on_rt_mutex中会在当前函数栈上创建一个rt_mutex_waiter对象,并调用plist_add将其插入到rt_mutex.wait_list链表。然后线程就会处于等待状态,直到得到锁或者接收到线程结束信号。
这里写图片描述
futex_wait_requeue_pi(uaddr1,uaddr2)
futex_wait_requeue_pi函数会在栈上初始化一个rt_mutex_waiter变量,并将其保存在同样在栈上初始化的一个futex_q变量中。然后在uaddr1上等待,直到其被requeue到一个uaddr2上。
这里写图片描述
futex_cmp_requeue_pi(uaddr1,uaddr2)
do_futex中:
这里写图片描述
do_futex主要是针对不同的命令调用相应的处理函数,调用futex_cmp_requeue_pi时实际调用的是futex_requeuefutex_requeue中唤醒futex_wait_requeue_pi线程有两种方式:
1.futex_proxy_trylock_atomic尝试获取uaddr2锁,如果成功,则唤醒uaddr1上等待的线程,函数返回,否则继续执行。注意,这一步没有进入内核互斥量中,如果成功,将不进入内核互斥量,而是直接返回到用户空间,从而减小内核互斥量的开销;
2.rt_mutex_start_proxy_lock尝试获取uaddr2锁,如果成功,则唤醒等待线程,如果失败,则将线程阻塞到uaddr2的内核互斥量上,将rt_waiter加入rt_mutex的waiter list。如果进入内核互斥量等待,返回到futex_wait_requeue_pi时,需要执行rt_waiter的清理。
这里写图片描述
所以总结一下执行的流程如下。
这里写图片描述

环境搭建

下载编译Android源代码之后下载Android内核源代码,回退到有问题的版本。

git clone https://android.googlesource.com/kernel/goldfish.git -b goldfish3.4
cd goldfish
git checkout e8c92d268b8b8feb550ca8d24a92c1c98ed65ace kernel/futex.c

编译时开启Compile the kernel with debug info选项,以下面的命令启动虚拟机。
这里写图片描述
在另一个终端开启gdb远程调试。
这里写图片描述
这里写图片描述
利用Metasploit生成的代码进行测试,看到内核崩溃的信息如下。
这里写图片描述

漏洞触发

这部分的内容主要来自[1],这里仅仅做了个整理和翻译。

relock漏洞

前面我们说过futex_lock_pi会调用futex_lock_pi_atomic
这里写图片描述
其中cmpxchg_futex_value_locked(&curval, uaddr, 0, newval)尝试去锁住uaddr,它的实现的含义是如果uaddr中存储的值为0,那么就说明没有线程占用锁,成功获取锁之后将当前线程的id写进去。这里就存在一个问题,那就是uaddr是用户空间的变量,我们可以在程序中手动设置为0,从而达到释放锁而不必通过futex_unlock_pi。这必然是存在一些问题的,因为futex_unlock_pi中有一些收尾工作没有做。如果线程A先锁住了uaddr,用户将uaddr的值设为0,然后在线程B中再次去锁住uaddr,结果会成功而不会阻塞,这个时候线程A和B都拥有锁uaddr,从而造成了relock漏洞。下面编写一个小程序证明一下。

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <linux/futex.h>
#include <sys/syscall.h>
#include <sys/types.h>

#define futex_lock_pi(mutex) syscall(__NR_futex, mutex, FUTEX_LOCK_PI, 1, 0, NULL, 0)

int mutex = 0;

void *thread(void *arg)
{
    int ret = 0;
    pid_t tid = syscall(__NR_gettid);
    printf("Entering thread[%d]\n", tid);
    ret = futex_lock_pi(&mutex);
    if (ret) 
    {
        perror("Thread could not aqcuire lock\n");
        goto Wait_forever;
    }
    printf("Mutex acquired (mutex = %d)\n", mutex);
Wait_forever:
    printf("Exiting thread[%d]\n", tid);
    while (1) 
    {
        sleep(1);
    }
}

int main(int argc, char *argv[])
{
    pthread_t t;
    printf("pid = %d\n", getpid());
    printf("mutex = %d\n", mutex);
    printf("Acquiring lock\n");
    if (futex_lock_pi(&mutex))
    {
        perror("Lock error");
        return -1;
    }
    printf("Mutex acquired (mutex = %d)\n", mutex);
    if (argc > 1) 
    {
        printf("Releasing lock\n");
        mutex = 0;
        printf("mutex = %d\n", mutex);
    }
    if (pthread_create(&t, NULL, thread, NULL)) 
    {
        perror("Could not create thread");
        return -1;
    }
    while (1) 
    {
        sleep(1);
    }
    return 0;
}

这里写图片描述

requeue漏洞

正常情况下对futex_wait_requeue_pifutex_cmp_requeue_pi的调用如下。

syscall(__NR_futex, &uaddr1, FUTEX_WAIT_REQUEUE_PI, 1, 0, &uaddr2, uaddr1); //在uaddr1上等待
syscall(__NR_futex, &uaddr1, FUTEX_CMP_REQUEUE_PI, 1, 0, &uaddr2, uaddr1); //尝试获取uaddr2上的锁,然后唤醒uaddr1上的线程

这个时候如果我们再次调用下面的语句将失败而直接返回,并不会进入系统调用。

syscall(__NR_futex, &uaddr1, FUTEX_CMP_REQUEUE_PI, 1, 0, &uaddr2, uaddr1)

而requeue漏洞允许我们在以上两条语句执行之后,继续执行下面这样的语句。

syscall(__NR_futex, &uaddr2, FUTEX_CMP_REQUEUE_PI, 1, 0, &uaddr2, uaddr2);

这个语句中所有地址都变成了uaddr2,也就是说将等待在uaddr2上的线程重排到uaddr2上,这是不合逻辑的,但是Futex没有检查这样的调用,也就是说没有检查uaddr1==uaddr2的情况,从而造成了我们可以二次进入futex_requeue中进行唤醒操作。

漏洞组合

表面上看上去这两个漏洞似乎影响都不是很大,对内核没有构成直接威胁,不过聪明的攻击者对这两个漏洞进行了组合,获取了一种不错的效果。
这里写图片描述
1.线程1调用futex_lock_pi锁住uaddr2,此时没有其他竞争,所以成功锁住uaddr2;
2.创建线程2,并等待线程2进入系统调用状态。同时线程2调用futex_wait_requeue_pi(uaddr1,uaddr2)等待被唤醒,在futex_wait_requeue_pi中会在栈上分配一个rt_waiter,这个rt_waiter会通过futex_q传给 futex_requeue进行进一步操作;
3.线程2进入内核等待后,线程1继续执行,调用futex_requeue(uaddr1,uaddr2)唤醒线程2。但是由于uaddr2已经被线程1锁住,futex_requeue尝试获取uaddr2锁将失败,从而不能够唤醒线程2,所以此时futex_requeue需要负责将线程2阻塞到uaddr2的rt_mutex上,同时将futex_wait_requeue_pi中的rt_waiter加入到rt_waiter的waiter list上;
4.利用relock漏洞,将uaddr2赋值为0,释放uaddr2上的锁,这个时候线程2不会被唤醒,因为线程2是等待在rt_mutex上,已经进入内核互斥量中;
5.利用requeue漏洞,调用futex_requeue(uaddr2,uaddr2)futex_lock_pi_atomic判断uaddr2上的值为0, 从而成功获得锁,然后requeue_pi_wake_futex被执行来唤醒线程2。q->rt_waiter被赋值为NULL,表示不再需要rt_waiter,因为已经获得了锁,而futex_wait_requeue_pi会认为没有进入内核互斥量等待,也就是说rt_waiter没有被加入到rt_mutex的waiter list上,因此futex_wait_requeue_pi将执行不清理rt_waiter的分支代码,从而造成了线程2被唤醒,但是它的rt_waiter没有从rt_mutex上摘除,而这个rt_waiter还正好在栈上。
还记得前面总结的执行流程么?漏洞利用的执行流程如下。
调用futex_requeue(uaddr1,uaddr2)
这里写图片描述
调用futex_requeue(uaddr2,uaddr2)
这里写图片描述

内核提权

为了理解利用的过程,我们还需要掌握一些相关技巧,这部分的内容主要来自[2],这里仅仅做了个整理和翻译。

栈内存控制

我们来看一个简单的例子(编译时不使用任何优化选项)。

#include <stdio.h>

int foo(int initialize, int val)
{
    int local;
    if (initialize) 
    {
        local = val;
    } 
    else 
    {
        printf("local foo is %d\n", local);
    }
}

int bar(int initialize, int val)
{
    int local;

    if (initialize)
     {
        local = val;
    } 
    else 
    {
        printf("local bar is %d\n", local);
    }
}

int main()
{
    foo(1, 10); //set to 10
    foo(0, 0); //print it
    bar(1, 12); //set to 12
    foo(0, 0); //print foo again
    return 0;
}

这段代码很简单,函数foo和bar具有完全相同的函数体,调用bar(1,12),然后再调用foo(0,0),结果打印的是bar设置的12。为什么foo会打印出bar里面的值,而不是之前foo里面的值?因为它们具有完全相同的函数体,从而函数栈也相同,在main函数中每次调用都会使用相同的栈,而栈内的内容不会在函数返回时清空,这样在foo(0,0)的时候,没有去赋值而是直接使用栈上的值,这个栈刚刚被bar使用过,所以就造成了foo获取到了bar内的值。这就是一个简单的栈上内容的控制。在链表中也是一样,如下面的例子所示(以gcc -m32 list1.c命令编译)。

/**
 * An example of bug that can be exploited through stack manipulation
 * Use -m32 to compile as 32 bit app, so that the int size is the same as pointer size
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct node 
{
       const char *value;
       struct node *next;
       struct node *prev;
};

struct list 
{
       struct node *head;
       struct node *tail;
};

void list_add(struct list *lst, struct node *newnode)
{
    if (lst->head==NULL) 
    {
        lst->head = newnode;
        lst->tail = newnode;
    } 
    else 
    {
        newnode->prev = lst->tail;
        lst->tail->next = newnode;
        lst->tail = newnode;
    }
}

struct node * list_remove_last(struct list *lst) 
{
    struct node *result;
    result = lst->tail;
    if (lst->head==lst->tail)
    { 
        lst->head = lst->tail = NULL;
    } 
    else
    {
        lst->tail = lst->tail->prev;
        lst->tail->next = NULL;
    }
    return result;
}

void list_print(struct list *lst)
{
    struct node *tmp;
    tmp = lst->head;
    while (tmp) 
    {
        printf("Value = %s\n", tmp->value);
        tmp = tmp->next;
    }
}

void list_add_new(struct list *lst, const char *val)
{
    struct node *newnode  = (struct node *)malloc(sizeof(struct node));
    newnode->next = NULL;
    newnode->value = strdup(val);
    list_add(lst, newnode);
}   


void print_with_end_of_list(struct list *lst)
{
    struct node instack;
    instack.next = 0;
    instack.value = "--END OF LIST--";
    printf("Not a buggy function\n");
    list_add(lst, &instack);
    list_print(lst);
    /*we ignore the returned node*/
    list_remove_last(lst);
}

void buggy_print_with_end_of_list(struct list *lst)
{
    int dummy_var1; 
    int dummy_var2;
    int dummy_var3;
    struct node instack;
    printf("a buggy function, here is the location of value on stack %p\n", &instack.value);
    instack.next = 0;
    instack.value = "--END OF LIST--";
    list_add(lst, &instack);
    list_print(lst);
    /*we 'forgot' to remove the list element*/
}

void a_function_to_exploit(int element_number, void * value)
{
    int i;
    int buf[10];
    if (element_number==-1) 
    { 
        for (i=0; i < 10; i++) 
        {
            printf("location of buf[%d] is %p\n", i, &buf[i]);
        }
        return;
    }
    buf[element_number] = (int)value;   
}

int main(int argc, char * argv[])
 {
    struct list mylist;
    mylist.head = NULL;
    mylist.tail = NULL;
    int pos;
    char *val;
    /*we have one parameter*/
    pos = -1;
    if (argc==3) 
    {
        pos = atoi(argv[1]);
        val = argv[2];
    }       
    printf("we will use pos: %d\n", pos);
    list_add_new(&mylist, "Alpha");
    list_add_new(&mylist, "Beta");
    print_with_end_of_list(&mylist);
    buggy_print_with_end_of_list(&mylist);
    a_function_to_exploit(pos, val);
    list_print(&mylist);
    /*this is just a demo, i am skipping the cleanup code*/
    return 0;
}

这段代码依然利用了前面例子中说明的栈重用的情况,后面的函数使用了前面函数的栈,只不过用链表的形式展示了出来。代码的bug在于那个buggy_print_with_end_of_list函数和a_function_to_exploit函数,前者造成漏洞,后者利用漏洞。正常情况下,链表里的节点都是在list_add_new中生成的,生成的节点存储在堆中,而buggy_print_with_end_of_list的节点 是在栈上临时存放的,本应该在函数返回前,将这个临时节点从链表中摘除,但是它没有,结果就造成了一个存在与栈上的节点,而当调用a_function_to_exploit时,这个栈又会被重用,例子中对这个节点进行了赋值,从而导致我们不需要知道这个链表的头指针,就可以修改这个节点的内容。
这里写图片描述

内核任意地址写指定值

考虑下面这个函数。

void node_remove(struct node *n)
{
    struct node *prevnode = n->prev;
    struct node *nextnode = n->next;
    nextnode->prev = prevnode;
    prevnode->next = nextnode;

}

假设prev位于结构体偏移4,next位于结构体偏移8。如果我们想在X处写Y值,那么需要在Y处伪造一个fakenode,使得n->next = fakenoden->prev=X-8,这样当调用node_remove(n->next)->prev = n->prev(n->prev)->next = n->next,也就是fakenode->prev = n->prev(n->prev)->next = fakenode,最终在X处写入了Y。这样的堆利用技巧非常常见,不再细说了。关键问题是说了这么多,和这个漏洞有什么关系呢?通过栈重用可以修改栈上的rt_waiter,只不过rt_waiter在内核栈中,修改它要麻烦一些,不能直接写个函数去修改,因为我们写的函数都是用户空间的,进不去内核,所以需要一个内核函数去修改它。这个内核函数要满足下面两个条件:
1.它的函数内核栈足够的大以至于可以覆盖到rt_waiter的地址;
2.我们能够向这个栈上写东西,函数会将我们传递的参数复制到栈上。
TowelRoot中使用的sendmmsg函数就满足这两个要求。函数最后在内核中调用的是___sys_sendmsg。该函数栈上数据与rt_waiter的重叠部分如下图。
这里写图片描述
很明显,通过写入特定的msgvec.msg_namemsgvec.msg_iov,就可以改写rt_waiter节点的内容,使之按照我们的路径去执行。在plist链表中有两个链,一个是prio链,一个是节点链。那么一个节点, 为什么要两个链?因为他们具有不同的视图,用途不一样。链表中的每个节点都不同,但是他们的prio值是可以相同的(具有相同的优先级),所以node_list链接了所有节点,而prio_list仅链接了prio不同的节点。内核在利用优先级选择节点的时候,会选择链接在prio_list上的节点,例如prio为20的节点,如果节点被成功唤醒,该节点将被清除,然后内核会修改该链表,将另外一个prio=20的节点链接到prio_list上。如果我们可以插入节点的话就可以利用prio的值来控制插入节点的位置。如果修改rt_waiter节点的prio.next指针指向共享内存中事先被填充了作为一个plist节点的必要数据的一个特定位置(fake_node),再调用futex_lock_pi向链表中添加rt_waiter节点,假设fake_node节点优先级为35,新的节点优先级为34,过程如下。

fake_node.prev.next = new_node;
new_node.prev = fake_node.prev;
fake_node.prev = new_node;
new_node.next = fake_node;

插入之前fake_node.prev的值设置为要修改的内核地址-offset。
这里写图片描述
这里写图片描述
根据前面的讲解不难理解插入之后可以看到红色的node.next指向后继节点,也就是说地址A中的值被修改成了新节点的地址。虽然只能修改为新节点的地址,但是确实是实现了内核任意地址写指定值。

内核任意地址任意写

thread_info.addr_limit变量规定了特定线程的用户空间地址最大值,超过这个值的地址,用户空间代码不能访问。所以把addr_limit改成0xffffffff就可以对内核为所欲为了。rt_waiter这个结构是在内核栈上分配的,而又由于thread_info与内核栈共用8K的内存空间,因此任意内核栈的地址与上0xffffe000,就会得到thread_info的地址。现在我们可以对它进行写入一个新rt_waiter的地址,但是还没有能力写入0xffffffff。不过既然可以修改一次addr_limit,自然还可以多次修改addr_limit,关键是每个rt_waiter的地址又是不同的,但是请注意,这个rt_waiter的地址却不一定的递减的,因为不同线程具有独立的内核栈,所以不同线程的rt_waiter不在同一个栈上,地址是随机分布的。这样就有了一个方法,可以将0xffffffff写进去,如下所示。
这里写图片描述
首先主线程创建线程A,用于提权操作。线程A创建后,调用futex_lock_pi产生rt_waiter然后进入等待,这个时候,主线程向其发送信号,唤醒它继续执行信号处理程序。在信号处理程序中,线程A开始获取addr_limit的地址,然后尝试读取其内容直到成功;这个时候主线程开始创建新的线程B,用以产生新的rt_waiter,并利用上面的技术将这个新的rt_waiter的地址写进线程A的addr_limit。循环执行这个 过程,直到线程A成功读取addr_limit的内容。如果线程A的内核栈的地址低于线程B的内核栈的地址的话,我们将新的rt_waiter的地址写进线程A的addr_limit的话,这个时候线程A的用户空间的最大值就会变成线程B的内核栈上的地址,而由于B的内核栈高于A的内核栈,所以这个时候, 线程A便可以访问addr_limit了,addr_limit的地址已经属于用户空间了。既然是用户空间,那么就可以将0xffffffff写进addr_limit中,从而将线程A的所有地址都对用户空间开放,来进一步执行提权操作了。所以最终我们在线程A中获得了内核空间的任意访问能力,请注意,只限于线程A,因为每个线程在内核看来是独立的,是具有不同内核栈和thread_info(addr_limit)的。

内核数据修改与提权

既然可以对内核数据随意修改了,那么现在的问题就是获取要修改数据的地址。thread_info包含了线程的主要信息,当然也就包括了线程的task_struct。而task_struct结构体包含了该线程的所有信息。这其中就包括权限方面的重要信息cred,该结构体是线程权限的管理者,标识了当前线程的权限。修改uid,gid,suid的值为0从而实现提权。
这里写图片描述

代码细节

我们来看看Metasploit代码中的一些细节。
创建了search_goodnumsend_magicmsg两个线程。
这里写图片描述
search_goodnum执行触发漏洞的第一步syscall(__NR_futex, &uaddr2, FUTEX_LOCK_PI, 1, 0, NULL, 0);
这里写图片描述
send_magicmsg执行触发漏洞的第二步syscall(__NR_futex, &uaddr1, FUTEX_WAIT_REQUEUE_PI, 0, 0, &uaddr2, 0);
这里写图片描述
search_goodnum执行触发漏洞的第三,四,五步,syscall(__NR_futex, &uaddr1, FUTEX_CMP_REQUEUE_PI, 1, 0, &uaddr2, uaddr1);uaddr2=0syscall(__NR_futex, &uaddr2, FUTEX_CMP_REQUEUE_PI, 1, 0, &uaddr2, uaddr2);
这里写图片描述
之后将did_socket_tid_read设为1,使得send_magicmsg执行对sendmmsg的系统调用。当系统调用syscall时,/proc/PID/task/TID/status中voluntary_ctxt_switches会增加,以此判断CPU是否进入内核。在setup_exploit中伪造节点。
这里写图片描述
得到thread_info的地址。
这里写图片描述
通过创建伪终端读数据,使线程阻塞,在修改addr_limit的过程中就是通过这种方法进行等待修改。
这里写图片描述
cred是随时可能被内核调用的重要的结构体,它采用了RCU同步机制。需要先copy出来一个副本buf,在副本上进行修改,然后再将buf写回去。
这里写图片描述

补丁分析

补丁代码很简单,只是在futex_requeue函数里添加个判断,当uaddr1和uaddr2相等时,则返回失败。
这里写图片描述

参考资料

1.The Futex Vulnerability
2.Exploiting the Futex Bug and uncovering Towelroot
3.cve2014-3153 漏洞之详细分析与利用
4.《漏洞战争》及其配套资料
5.CVE-2014-3153内核漏洞分析

阅读更多
个人分类: 移动安全
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

加入CSDN,享受更精准的内容推荐,与500万程序员共同成长!
关闭
关闭