一个隐藏在Go语言标准库中的目录穿越漏洞 CVE-2022-29804
2023-3-24 12:57:0 Author: xz.aliyun.com(查看原文) 阅读量:50 收藏

一天,我看完了番剧后,闲着无聊审计了一下我用来做内网共享的小工具——"Go HTTP File Server"。
这是一个文件服务器,可以快速搭建http服务器共享文件.
启动的默认路径为当前路径(./)

非常安全的代码

这个工具默认是没有鉴权的 所有我们直接看文件浏览的部分

func (s *HTTPStaticServer) getRealPath(r *http.Request) string {
    path := mux.Vars(r)["path"]
    if !strings.HasPrefix(path, "/") {
        path = "/" + path
    }
    path = filepath.Clean(path) // prevent .. for safe issues
    relativePath, err := filepath.Rel(s.Prefix, path)
    if err != nil {
        relativePath = path
    }
    realPath := filepath.Join(s.Root, relativePath)
    return filepath.ToSlash(realPath)
}


func (s *HTTPStaticServer) hIndex(w http.ResponseWriter, r *http.Request) {
    path := mux.Vars(r)["path"]
    realPath := s.getRealPath(r)
    if r.FormValue("json") == "true" {
        s.hJSONList(w, r)
        return
    }

    if r.FormValue("op") == "info" {
        s.hInfo(w, r)
        return
    }

    if r.FormValue("op") == "archive" {
        s.hZip(w, r)
        return
    }

    log.Println("GET", path, realPath)
    if r.FormValue("raw") == "false" || isDir(realPath) {
        if r.Method == "HEAD" {
            return
        }
        renderHTML(w, "assets/index.html", s)
    } else {
        if filepath.Base(path) == YAMLCONF {
            auth := s.readAccessConf(realPath)
            if !auth.Delete {
                http.Error(w, "Security warning, not allowed to read", http.StatusForbidden)
                return
            }
        }
        if r.FormValue("download") == "true" {
            w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(filepath.Base(path)))
        }
        http.ServeFile(w, r, realPath)
    }
}

乍一看上去,这段代码好像没有什么问题。它使用了 Go 标准库中的 filepath.Clean (去除 ..) 和 filepath.Join(合并路径) 函数,来防止目录穿越。

标准库中的漏洞

我刚好还有些空余时间,所以我又开始检查 Go 标准库中的函数实现。

filepath.Clean

func Clean(path string) string {
    originalPath := path
    volLen := volumeNameLen(path)
    path = path[volLen:]
    if path == "" {
        if volLen > 1 && originalPath[1] != ':' {
            // should be UNC
            return FromSlash(originalPath)
        }
        return originalPath + "."
    }
    rooted := os.IsPathSeparator(path[0])

    // Invariants:
    //  reading from path; r is index of next byte to process.
    //  writing to buf; w is index of next byte to write.
    //  dotdot is index in buf where .. must stop, either because
    //      it is the leading slash or it is a leading ../../.. prefix.
    n := len(path)
    out := lazybuf{path: path, volAndPath: originalPath, volLen: volLen}
    r, dotdot := 0, 0
    if rooted {
        out.append(Separator)
        r, dotdot = 1, 1
    }

    for r < n {
        switch {
        case os.IsPathSeparator(path[r]):
            // empty path element
            r++
        case path[r] == '.' && r+1 == n:
            // . element
            r++
        case path[r] == '.' && os.IsPathSeparator(path[r+1]):
            // ./ element
            r++

            for r < len(path) && os.IsPathSeparator(path[r]) {
                r++
            }
            if out.w == 0 && volumeNameLen(path[r:]) > 0 {
                // When joining prefix "." and an absolute path on Windows,
                // the prefix should not be removed.
                out.append('.')
            }
        case path[r] == '.' && path[r+1] == '.' && (r+2 == n || os.IsPathSeparator(path[r+2])):
            // .. element: remove to last separator
            r += 2
            switch {
            case out.w > dotdot:
                // can backtrack
                out.w--
                for out.w > dotdot && !os.IsPathSeparator(out.index(out.w)) {
                    out.w--
                }
            case !rooted:
                // cannot backtrack, but not rooted, so append .. element.
                if out.w > 0 {
                    out.append(Separator)
                }
                out.append('.')
                out.append('.')
                dotdot = out.w
            }
        default:
            // real path element.
            // add slash if needed
            if rooted && out.w != 1 || !rooted && out.w != 0 {
                out.append(Separator)
            }
            // copy element
            for ; r < n && !os.IsPathSeparator(path[r]); r++ {
                out.append(path[r])
            }
        }
    }

    // Turn empty string into "."
    if out.w == 0 {
        out.append('.')
    }

    return FromSlash(out.string())
}

调试了一遍后,我发现 filepath.Clean 对路径处理非常完美。这个函数可以将路径中的冗余部分去除,同时可以处理不同操作系统下的路径分隔符问题.

filepath.Join

但是 filepath.Join 函数就不太一样了,这个函数在 Plan9、Unix 和 Windows 三个操作系统类型下有着不同的实现。

func join(elem []string) string {
    // If there's a bug here, fix the logic in ./path_plan9.go too.
    for i, e := range elem {
        if e != "" {
            return Clean(strings.Join(elem[i:], string(Separator)))
        }
    }
    return ""
}

在 Unix 系统下,filepath.Join 非常简单,它会在Clean之后直接拼接路径,没有任何问题。

func volumeNameLen(path string) int {
    if len(path) < 2 {
        return 0
    }
    // with drive letter
    c := path[0]
    if path[1] == ':' && ('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z') {
        return 2
    }
    // is it UNC? https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx
    if l := len(path); l >= 5 && isSlash(path[0]) && isSlash(path[1]) &&
        !isSlash(path[2]) && path[2] != '.' {
        // first, leading `\\` and next shouldn't be `\`. its server name.
        for n := 3; n < l-1; n++ {
            // second, next '\' shouldn't be repeated.
            if isSlash(path[n]) {
                n++
                // third, following something characters. its share name.
                if !isSlash(path[n]) {
                    if path[n] == '.' {
                        break
                    }
                    for ; n < l; n++ {
                        if isSlash(path[n]) {
                            break
                        }
                    }
                    return n
                }
                break
            }
        }
    }
    return 0
}
func join(elem []string) string {
    for i, e := range elem {
        if e != "" {
            return joinNonEmpty(elem[i:])
        }
    }
    return ""
}

// joinNonEmpty is like join, but it assumes that the first element is non-empty.
func joinNonEmpty(elem []string) string {
    if len(elem[0]) == 2 && elem[0][1] == ':' {
        // First element is drive letter without terminating slash.
        // Keep path relative to current directory on that drive.
        // Skip empty elements.
        i := 1
        for ; i < len(elem); i++ {
            if elem[i] != "" {
                break
            }
        }
        return Clean(elem[0] + strings.Join(elem[i:], string(Separator)))
    }
    // The following logic prevents Join from inadvertently creating a
    // UNC path on Windows. Unless the first element is a UNC path, Join
    // shouldn't create a UNC path. See golang.org/issue/9167.
    p := Clean(strings.Join(elem, string(Separator)))
    if !isUNC(p) {
        return p
    }
    // p == UNC only allowed when the first element is a UNC path.
    head := Clean(elem[0])
    if isUNC(head) {
        return p
    }
    // head + tail == UNC, but joining two non-UNC paths should not result
    // in a UNC path. Undo creation of UNC path.
    tail := Clean(strings.Join(elem[1:], string(Separator)))
    if head[len(head)-1] == Separator {
        return head + tail
    }
    return head + string(Separator) + tail
}

// isUNC reports whether path is a UNC path.
func isUNC(path string) bool {
    return volumeNameLen(path) > 2
}

func sameWord(a, b string) bool {
    return strings.EqualFold(a, b)
}

在 Windows 系统下,filepath.Join 函数的实现要复杂得多,因为需要处理路径分隔符和 UNC 路径等特殊情况。
到这里就变得有趣了一些 filepath.Join 的输入不完全是用户控制的 Clean函数会把用户输入和固定路径一起处理
这个工具刚好出现了一个非常特殊的情况
文件服务器本来想要限制访问当前目录下的文件
filepath.Join("./",'已经处理后的用户输入')
如果输入的路径是./ abc/1.txt
Clean处理后会变成 abc/1.txt Clean去除了开头的设定的./
这个处理在linux系统下没有问题
但是在windows 系统下 如果我们构造路径组./ c:/1.txt
Clean处理后会变成 c:/1.txt
显然从Clean处理后把当前目录下的路径变为了c盘根目录
在这里,filepath.Clean 函数的处理并没有避免目录穿越问题,反而造成了一个安全漏洞。
最终在http server 上复现成功

提交给go 官方之后才发现这洞3个月前就被修复了. 我电脑上的go版本一直没更新 23333
漏洞issue
https://github.com/golang/go/issues/52476

  1. 使用 filepath.Clean/filepath.Join 处理路径
  2. 左侧被拼接路径为./
  3. 右侧路径可完全控制
  4. Go编译Windows二进制文件使用 Go 1.18 <1.18.3 Go 1.17 <1.17.11 (不在维护的版本应该不会修复)
  5. 目标二进制部署在windows 操作系统

标准库的漏洞会影响编译分发出的二进制文件

更新go到最新版本 重新编译发布二进制文件


文章来源: https://xz.aliyun.com/t/12347
如有侵权请联系:admin#unsafe.sh