一天,我看完了番剧后,闲着无聊审计了一下我用来做内网共享的小工具——"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 标准库中的函数实现。
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 函数就不太一样了,这个函数在 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
filepath.Clean/filepath.Join
处理路径./
标准库的漏洞会影响编译分发出的二进制文件
更新go到最新版本 重新编译发布二进制文件