CVE-2018-15664符号链接替换漏洞分析

漏洞简介

Aleksa Sarai在2019年公布了一个docker的符号链接替换漏洞(cve-2018-15664)。该漏洞的成因是docker cp过程中会遭受到TOCTOU攻击。简单来讲就是docker在cp过程中的路径在被进行安全转换后并没有被立即使用,攻击者可以先使用一个合法文件进行安全转换,在转换后的路径被使用之前将其替换为一个带有恶意软链接的文件进行攻击。

漏洞代码分析

(git commit id : 20262688df290f1196c5620112488f6445b7eb26)
docker cp分为两类:从容器中向宿主机复制文件和从宿主机向容器中复制文件。本文以从容器中向宿主机复制文件为例简单从代码层面分析漏洞的成因。
当用户执行docker cp命令从容器中向宿主机复制文件时,docker client 会接受到用户输入的命令并进入下面的处理逻辑(/cli/command/container/cp.go)


func copyFromContainer(ctx context.Context, dockerCli *command.DockerCli, srcContainer, srcPath, dstPath string, cpParam *cpConfig) (err error) {
    ...
    if cpParam.followLink {
        srcStat, err := statContainerPath(ctx, dockerCli, srcContainer, srcPath)
        if err == nil && srcStat.Mode&os.ModeSymlink != 0 {
            linkTarget := srcStat.LinkTarget
            if !system.IsAbs(linkTarget) {
                srcParent, _ := archive.SplitPathDirEntry(srcPath)
                linkTarget = filepath.Join(srcParent, linkTarget)
            }

            linkTarget, rebaseName = archive.GetRebaseName(srcPath, linkTarget)
            srcPath = linkTarget
        }

    }

    content, stat, err := dockerCli.Client().CopyFromContainer(ctx, srcContainer, srcPath)
    ...
}

其中 statContainerPath函数会将文件路径信息和容器信息发送到daemon的“head /container/(containerID)/archive”接口。CopyFromContainer函数会将文件路径信息和容器信息发送到daemon的“get /container/(containerID)/archive”接口

Head /container/(containerID)/archive :文件路径转换

该接口daemon主要负责将path信息进行安全转换,daemon的处理逻辑如下(/api/server/router/container/copy.go)

func (s *containerRouter) headContainersArchive(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
    v, err := httputils.ArchiveFormValues(r, vars)
    if err != nil {
        return err
    }

    stat, err := s.backend.ContainerStatPath(v.Name, v.Path)
    if err != nil {
        return err
    }

    return setContainerPathStatHeader(stat, w.Header())
}

ContainerStatPath函数经过一系列函数调用后会调用到FollowSymlinkInScope函数(/pkg/symlink/fs.go)

func FollowSymlinkInScope(path, root string) (string, error) {
    path, err := filepath.Abs(filepath.FromSlash(path))
    if err != nil {
        return "", err
    }
    root, err = filepath.Abs(filepath.FromSlash(root))
    if err != nil {
        return "", err
    }
    return evalSymlinksInScope(path, root)
}

该函数将路径进行一个安全的转换,将在作用域 root 中评估 path 中的符号链接,并返回在调用时保证包含在 root 范围内的结果。
例如: /foo/bar -> /outside
FollowSymlinkInScope("/foo/bar", "/foo") == "/foo/outside",而不是"/outside"
经过FollowSymLinkInScope函数转换后的路径将会被返回给docker client。

Get /container/(containerID)/archive:文件打包

docker client接收到daemon安全转换后的路径后并没有立即使用该路径,而是经过一些处理后使用CopyFromContainer函数发送给daemon的“get /container/(containerID)/archive”接口。这个间隙就是该漏洞产生的原因。
daemon接受到该接口传来的信息后会根据路径信息和容器信息进行文件的打包工作,并将打包好的文件通过response返回给docker client。daemon的处理逻辑如下(/api/server/router/container/copy.go)

func (s *containerRouter) getContainersArchive(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
    v, err := httputils.ArchiveFormValues(r, vars)
    if err != nil {
        return err
    }

    tarArchive, stat, err := s.backend.ContainerArchivePath(v.Name, v.Path)
    if err != nil {
        return err
    }
    defer tarArchive.Close()

    if err := setContainerPathStatHeader(stat, w.Header()); err != nil {
        return err
    }

    w.Header().Set("Content-Type", "application/x-tar")
    return writeCompressedResponse(w, r, tarArchive)
}

s.backend.ContainerArchivePath函数会经过一系列函数调用后调用到TarWithOptions函数(/pkg/archive/archive.go)

func TarWithOptions(srcPath string, options *TarOptions) (io.ReadCloser, error) {
    tb, err := NewTarballer(srcPath, options)
    if err != nil {
        return nil, err
    }
    go tb.Do()
    return tb.Reader(), nil
}

该函数首先创建了一个新的Tarballer对象并调用该对象的Do函数进行文件打包。Do函数又会调用到addTarFile函数(/pkg/archive/archive.go)

func (ta *tarAppender) addTarFile(path, name string) error {
    ...
    if fi.Mode()&os.ModeSymlink != 0 {
        var err error
        link, err = os.Readlink(path)
        if err != nil {
            return err
        }
    }
    ...
}

在addTarFile函数中会首先判断路径下文件的软链接信息,如果有软链接就将软链接指向的文件打包进最终的文件中。但是此步骤是在宿主机路径下进行解析的,所以如果攻击者在FollowSymlinkInScope转换后,addTarFile使用前对目标文件添加一个恶意的软链接,如“/etc/shadow”,那addTarFile进行软链接解析的时候会将宿主机的/etc/shadow打包进最终的文件中返回给docker client。

修复代码分析

暂时性修复措施

官方建议用户在使用docker cp命令前执行docker pause命令,并在结束cp命令后执行docker unpause。pause和unpause命令被自动加入到cp命令的运行过程中,通过冻结容器的方式阻断copy过程中对容器文件数据的篡改。

根本性修复措施

(git commit id : 364f9bce16e8c95c79fc68d23867e871f20cb452)
官方代码修复链接:https://github.com/moby/moby/pull/39292
漏洞的提出者指出该漏洞的根本修复方法在于修改chrootarchive中的归档逻辑,因此官方修复后的代码主要是对Do函数进行了限制,让其在容器的文件系统下进行打包而不是宿主机的文件系统下。(/pkg/chrootarchive/archive_unix.go)

func invokePack(srcPath string, options *archive.TarOptions, root string) (io.ReadCloser, error) {
    ...
    err = goInChroot(root, tb.Do)
    ...
}

goInChroot函数将tb.Do的执行范围限制在了root范围下,即容器的目录下。(/pkg/chrootarchive/chroot_linux.go)

func goInChroot(path string, fn func()) error {
    return unshare.Go(
        unix.CLONE_FS|unix.CLONE_NEWNS,
        func() error {
            if err := mount.MakeRSlave("/"); err != nil {
                return err
            }
            return mounttree.SwitchRoot(path)
        },
        fn,
    )
}

goInChroot函数调用了unshare.Go函数

func Go(flags int, setupfn func() error, fn func()) error {
    ...
    go func() {
        ...
        if err := unix.Unshare(flags); err != nil {
            started <- os.NewSyscallError("unshare", err)
            return
        }

        if setupfn != nil {
            if err := setupfn(); err != nil {
                started <- err
                return
            }
        }
        close(started)

        if fn != nil {
            fn()
        }
    }()

    return <-started
}

将goInChroot和unshare.Go函数结合起来看,我们可以发现修复后做限制的逻辑是:

  • 开启一个goroutine进行文件打包操作
  • 对开启的这个goroutine使用unshare系统调用赋予其新的Mount命名空间,并且与父进程共享一套文件系统(在后续的版本中使用reexec代替了此部操作)
  • 使用mounttree.SwitchRoot(path)进行chroot

BigCircle
0 声望0 粉丝