偷窥扩展程序中未被污染的安全反模式

内容脚本不服从任何限制或CSP策略

谈及到后台页面的内容安全策略(CSP)要求时,人们也许会认为跨站点脚本(XSS)在Chrome扩展程序中已经不存在了。但事实并非如此,它只是被附加到了扩展程序的内容脚本端。内容脚本不必遵守扩展声明的CSP,它们也不必遵守它们正在执行的网页的CSP(除非他们将<script>注入到网页的DOM中)。因此,如果https://example.com 具有以下CSP:

script-src 'self'; object-src 'self'

这与在内容脚本上下文中执行的JavaScript完全无关。例如,尽管它被自己的源网页禁止,它也一样可以使用eval()执行他想要的命令。这通常会转化为开发人员通过内容脚本创建Chrome扩展,这些扩展实际上会将新的漏洞引入到热门的网站中,如果不这样做,那么这些漏洞就是安全的。RES XSS漏洞是之前提到的这种情况下的一个很好的例子。尽管Reddit(红迪网(新闻网站名))没有跨站点脚本(XSS)漏洞,但RES为网站安装了一个扩展程序,它获取用户可以控制的图像标题,并将其作为HTML注入到Reddit的网页DOM中,进而形成了XSS漏洞,所以,Reddit就容易受到所有安装有RES扩展程序的用户的zero-click XSS攻击。
在研究这个问题时,反模式就应运而生了。编写内容脚本的开发人员如果让用户使用自己控制的输入来执行不安全的DOM操作,则很有可能会自取灭亡(并祸及他们的用户)。所以扩展程序的开发人员应该万分谨慎,以确保用户不使用诸如innerHTML,html()或设置href (javascript:URIs)之类的调用来执行不安全的DOM操作。

无法信任的网页DOM

Chrome扩展程序开发人员会涉及的另一个常见反模式是,他们会信任外部网页提供的内容。这存在多种体现形式,例如信任DOM中的数据,从事件侦听器触发的事件,或从网页发送到内容脚本的消息。

就以DOM为例,攻击者可以将布局修改为他们想要的任何格式,以便利用内容脚本。还应值得注意的是,攻击者通过恶意网页访问或通过可信网页中的XSS漏洞来将敏感数据放入DOM中的情况。例如,Grammarly Chrome扩展程序就犯了这个错误,因为它们将敏感的身份验证令牌放入所有网页的DOM中,允许恶意网页只是简单地从DOM中提取令牌并使用来对其API进行身份验证。当扩展程序将某种UI放入网页中供用户查看时,这种情况就会更加常见。通常,在这些UI元素中,开发人员会将敏感信息放在Chrome扩展程序之外。更糟糕的是,这些元素通常会被内容脚本再次查询并用于执行可信操作。 这些模式允许攻击者在这些操作中进行攻击并修改DOM元素,从而使其包含非预期的输入。

必须验证JavaScript DOM事件

事件侦听器是内容脚本和网页DOM之间的主要通道之一,它也会受到攻击者的攻击。由于开发人员希望事件完全是由用户操作生成的,而不是由攻击者的一系列动作来创建的,所以这些很会掩人耳目。使用如下脚本:

// Create an element to inject
var injected_last_key_element = document.createElement( "div" );
injected_last_key_element.innerHTML = 'Last character typed: <div id="last_keypress_div"></div>';
// Inject this box into the page
document.body.appendChild(
    injected_last_key_element
);

// Listen for keydown event and show it's value in the previously-injected div
document.body.addEventListener( "keydown", function( keyevent ) {
    document.getElementById( "last_keypress_div" ).innerHTML = keyevent.code;
});

上面的代码实现了侦听keydown事件,并显示被注入文档正文中的<div>内的最后一个按键的代码值。因此,如果您按下键盘上的“a”键,字符串“KeyA”将出现在这个注入的<div>中。以下是关于KeyboardEvent的MDN文档的屏幕截图:

KeyboardEvent.code页面说明如下所示:

“ KeyboardEvent.code属性表示键盘上的物理键(与按键生成的字符相对应)。换句话说,这个属性返回一个不会随键盘布局或键的状态而改变的值。”

阅读这份文档,作为开发人员的您,可能会认为XSS在这里是不可能存在的。毕竟,这只会显示用户键盘按下的最后一个键码,文档说该属性是“只读”!怎么可能是被用来引起XSS的呢?即使您可以发送合成事件,它也只是预定义的密钥代码,并且<div>将在每个事件上进行重写,对吧?

但如果您这样想,那您就完全错了。实际上,这很容易被攻击者利用。以下便是导致XSS的代码:

// Generate a synthetic key event
function generate_fake_key_event(target_element, key_event_type, custom_properties) {
  var new_event = document.createEvent(
    "KeyboardEvent",
  );
  for (var property_keyname in custom_properties) 
    if (custom_properties.hasOwnProperty(property_keyname)) {
      // Chromium Hack
      Object.defineProperty(new_event, property_keyname, {
        get: function() {
          return custom_properties[property_keyname];
        }
      });
      new_event.initKeyboardEvent(
        key_event_type,
        true,
        true,
        document.defaultView,
        false,
        false,
        false,
        false,
        0,
        0
      );
      new_event[property_keyname] = custom_properties[property_keyname];
    }
  }
  target_element.dispatchEvent(
    new_event
  );
}
// Send a keydown with a code of <img src=x onerror=alert('XSS') />
generate_fake_key_event(
  document.body,
  "keydown",
  {
    "code": "<img src=x onerror=alert('XSS') />",
  }
)

上面的代码导致了XSS漏洞,并且会显示带有文本“XSS”的警报:

上述的XSS-警报的例子说明了信任任意事件,会引发的一些严重的缺陷:

这一切听起来都让人非常难受,但还算幸运,我们还可以进行简单的检查来验证事件是否真的是用户生成的。对于用户生成的事件,我们将它的isTrusted属性设置为true,反之,对于脚本生成的事件,isTrusted属性设置为false。这样我们就可以验证事件是由用户创建的,还是说仅仅是由攻击者合成的:

// Listen for keydown event and show its value in the previously-injected div
document.body.addEventListener( "keydown", function( keyevent ) {
    if( keyevent.isTrusted ) {
        document.getElementById( "last_keypress_div" ).innerHTML = keyevent.code;
    }
});

现在,攻击者已经无法向我们发送破坏性的合成事件了。我们最终处理的所有事件都必须是由用户生成的。虽然isTrusted属性并不常用,但在内容脚本处理网页事件时,它至关重要。

这个例子是经过精心设计的,因为如果攻击者可以在您的页面上创建的任意事件,那么您很可能已经在网页层次上拥有了这些事件,所以,由此导致的网页中的XSS是循环的。一个真实环境的例子是,后台页面使用内容脚本检索的事件数据来做一些不安全的事情,为了简单起见,我们只在常规网页中演示JavaScript事件欺骗。

不能信任网页发送的消息

Chrome扩展程序中的另一个常见模式是使用JavaScript的postMessage()将消息传递到后台页面中调用特定的Chrome扩展API。通常,内容脚本通过事件监听器从网页发出消息,然后将消息中传递到后台页面以调用某些专用的Chrome API。这创建了一个直接通道,任何网页都可以使用特定的Chrome扩展API,如果开发人员没有检查消息的来源,则会发生一些让人极其不愉快的事情。

通常,即使开发人员会检查收到的消息的来源,他们也会用类似于以下的代码来进行检查:

window.addEventListener( "message", function( received_message ) {
    // Check to make sure this a sub-domain of our site
    if( received_message.origin.indexOf( ".trusteddomain.com" ) !== -1 ) {
        process_message( received_message );
    }
}, false);

或者,如果他们是正则表达式的忠实使用者,他们将进行类似于下面的操作:

received_message.match(/https:\/\/www\.trusteddomain\.com/i)
...
received_message.match(/https?:\/\/www.trusteddomain.com$/i)
...

令人抱憾的是,所有这些检查都是可以绕过去的,下面是绕过每个检查的代码:

// Bypassed with https://a.trusteddomain.com.attacker.com
received_message.origin.indexOf( ".trusteddomain.com" ) !== -1

// Also bypassed with https://a.trusteddomain.com.attacker.com
received_message.match(/https?:\/\/www\.trusteddomain\.com/i)

// Bypassed with https://wwwatrusteddomain.com
received_message.match(/https?:\/\/www.trusteddomain.com$/i)

这几乎可以肯定是因为收到的消息的origin属性是站点的原始字符串,而不是已解析过的原始部分的对象。众所周知,解析URL很容易掉入陷阱,因此交给每个用户一个字符串并要求他们自己检查,自然会有助于解决这些问题。也许在以后的Web标准中,可以添加本机源验证功能,以便为开发人员提供更有保证的方法来验证这个行为。

为了安全地验证消息的来源,建议使用可信任的HTTPS源的静态列表。因为没有它,扩展程序可能很容易受到中间人攻击。以下是执行这个操作的安全代码示例:

var trusted_origins = [
    "https://trusteddomain.com",
    "https://www.trusteddomain.com",
    "https://extension.trusteddomain.com"
];

if( trusted_origins.includes( received_message.origin ) ) {
    // We can trust this message came from the right place
    process_message( received_message );
}

这样可以使外围区域较小,并在检查来源是否可信时只留下很小的错误空间。
当然,许多开发人员更愿意简单地将主域的所有子域列入白名单中,这样他们就不必更改源代码来添加新的主机名。虽然我建议最好别这样做(原因下面会讨论),但这样做的安全代码如下所示:

// Pull out the message origin
var message_origin = received_message.origin;

// Specify your trusted domain
var base_domain = "trusteddomain.com";

if( message_origin.startsWith( "https://" ) && ( message_origin.edsWith( "."[b] + base_domain ) || message_origin === "https://" + base_domain ) ) {
    // Message is HTTPS and a sub-domain, trust it.
    process_message( received_message );
}

上面的代码并不难,它只是确保源既是HTTPS又是可信基域的子域,或者就只是基域本身。
最后,记住需要检查的是消息事件,这同样也很重要。入站消息具有source属性,它是对发送它的窗口的引用。大多数情况下,内容脚本假设消息的发送者与内容脚本运行的窗口相同。因此,添加下面所示的简单的检查非常重要:

// Check to make sure the sender is the window we expect.
// If it’s not, return immediately.
if( received_message.source !== window ) {
    return;
}

上面的检查确保了发送消息的窗口与运行内容脚本的窗口是相同的。如果不相同,则脚本会立即返回而不会继续处理消息。开发人员常犯的一个错误是不检查消息的来源并根据收到的消息的内容来执行DOM操作。这意味着另一个页面有可能会向目标站点发送恶意的消息,以通过内容脚本强制执行不安全的DOM操作,从而导致XSS漏洞。由于存在边界情况,您不希望与内容脚本具有相同的源窗口,但绝大多数都应该要有这个简单的检查。

君子不立危墙

既然我已经向您展示了确保某些东西是您信任域的子域的安全代码,我想还是要花一点时间来讨论一下外围区域(surface area)。
通常情况下,您会看到只允许从Chrome扩展程序所有者拥有的域的子域中调用特定的Chrome扩展API的扩展程序,这种模式通常被当作是对开发人员有用的模式,因为他们可以更轻松地更改其网站的代码,以便实现无需更新扩展程序本身,以便对Chrome扩展程序的API进行不同的调用。 一旦您将这个想法付诸实践,通常下一步就是允许来自可信基域的任何子域的特定API的调用。

当使用external_connectable指令时,这个行为也很容易落入陷阱。这个指令指出了哪些来源可以将消息发送到扩展程序的后台页面。即使在官方文档中,提供的示例也如下所示,它允许基域的所有子域通过HTTP或HTTPS发送消息:

"externally_connectable": {
  "matches": ["*://*.example.com/*"]
}

从开发的角度来看,这些似乎都很合理。然而,开发人员会将安全屏障从强力锁定的后台页面高效地移到其域的任何子域。这意味着如果攻击者在可信域的任何子域上执行任意JavaScript,则攻击者可以完全劫持这些特权调用。这可能有多种方式:

在任何这些子域中哪怕是只弄错一次也会导致Chrome扩展程序变得非常危险。把这个和所有保护措施(例如CSP)和Chrome扩展背景页面的阻止网页导航相比较,自然就相形见绌了。

我在ZenMate VPN Chrome扩展程序中发现的一个重要的漏洞中可以看到这个问题,这是一个很好的例子(在撰写本文时,它大约有350万用户)。下面是他们(以前)易受攻击的manifest.json的摘录:

...trimmed for brevity...
{
  "js": [
    "scripts/page_api.js"
  ],
  "matches": [
    "*://*.zenmate.com/*",
    "*://*.zenmate.ae/*",
    "*://*.zenmate.ma/*",
    "*://*.zenmate.dk/*",
    "*://*.zenmate.at/*",
    "*://*.zenmate.ch/*",
    "*://*.zenmate.de/*",
    "*://*.zenmate.li/*",
    "*://*.zenmate.ca/*",
    "*://*.zenmate.co.uk/*",
    "*://*.zenmate.ie/*",
    "*://*.zenmate.co.nz/*",
    "*://*.zenmate.com.ar/*",
    "*://*.zenmate.cl/*",
    "*://*.zenmate.co/*",
    "*://*.zenmate.es/*",
    "*://*.zenmate.mx/*",
    "*://*.zenmate.com.pa/*",
    "*://*.zenmate.com.pe/*",
    "*://*.zenmate.com.ve/*",
    "*://*.zenmate.fi/*",
    "*://*.zenmate.fr/*",
    "*://*.zenmate.co.il/*",
    "*://*.zenmate.in/*",
    "*://*.zenmate.hu/*",
    "*://*.zenmate.co.id/*",
    "*://*.zenmate.is/*",
    "*://*.zenmate.it/*",
    "*://*.zenmate.jp/*",
    "*://*.zenmate.kr/*",
    "*://*.zenmate.lu/*",
    "*://*.zenmate.lt/*",
    "*://*.zenmate.lv/*",
    "*://*.zenmate.my/*",
    "*://*.zenmate.be/*",
    "*://*.zenmate.nl/*",
    "*://*.zenmate.pl/*",
    "*://*.zenmate.com.br/*",
    "*://*.zenmate.pt/*",
    "*://*.zenmate.ro/*",
    "*://*.zenmate.com.ru/*",
    "*://*.zenmate.se/*",
    "*://*.zenmate.sg/*",
    "*://*.zenmate.com.ph/*",
    "*://*.zenmate.com.tr/*",
    "*://*.zenmate.pk/*",
    "*://*.zenmate.vn/*",
    "*://*.zenmate.hk/*"
  ],
  "run_at": "document_start"
}
...trimmed for brevity...

内容脚本page_api.js允许对扩展程序的特权API进行调用,以便执行诸如检索用户信息、白名单站点,从而使它们不被代理、以及切换用户是否需要连接到VPN之类的操作。在以上给定几十个域的列表中,有很多外围区域可能会被利用。为了劫持这个扩展程序,我们需要在任意的子域上,在这几十个域中的任何一个域上使用XSS。

然而,事实证明,我们甚至不需要这样做。其中一个域名ZeMat.Li已经过期并对外开放注册。在购买它并为其建立网站之后,提取用户信息所需的全部工作就是在这个白名单域上运行以下Payload:

// Make call to Content Script to get all user data
__zm.getData(function(results) {
    console.log(
        results
    );
});

// Turn off VPN
__zm.toggle(false);

使用这个Payload,我们可以检索所有用户的信息(他们的身份验证令牌,电子邮件地址等),并且可以有效地对它们进行去匿名化,从而绕过扩展程序的所有保护机制。虽然供应商以迅雷不及掩耳之势做出了响应并且解决了这个问题,但这可以准确地说明当您将扩展程序控制向外移动到多个网站并扩展外围区域时可能发生的严重问题。如需详细了解详情,请参阅此文章

正确的URL解析

一些情况下,开发人员可能会发现自己正在解析给定的URL,需要查看它是否来自可信任的来源。这可以通过使用诸如chrome.tabs.get()之类的API来实现,该API在查询的选项卡上返回充满元数据的Tab对象。开发人员通常会尝试通过正则表达式解析这个对象的url属性,以查看它是否是他们信任的站点。正如我们之前看到的,解析URL是一份棘手的工作,很难做到正确无误。这个问题的核心在于,您编写的用于提取URL源代码的代码可能与Chrome内部执行的代码不同,从而致使代码被绕过。

从给定的URL中巧妙地拉出来源的一种简洁方法是使用URL()构造函数:

// Input URL
var input_url = "https://trusted-domain.com/example-url/?red=blue#yellow";

// Safely check to see if a URL matches a target origin
function check_origin_match( input_url, target_origin ) {
    var parsed_url = new URL(
        input_url
    );
    return ( parsed_url.origin === target_origin );
}

if( check_origin_match( input_url, "https://trusted-domain.com" ) ) {
    // URL is from a trusted origin!
} else {
    // Not from a trusted origin.
}

上面的代码很特殊,因为它将工作转移到了Chrome的URL解析器以从给定的URL中检索来源。这可确保您的代码和Chrome代码就给定网址的实际来源达成一致。您可以自己将URL传递给URL构造函数,并使用上面提供的一些代码来检查origin属性,而不是使用上面的check_origin_match()函数。由于URL尚未被解析出来,故而您应该安全地检查它的来源,最终的URL对象还包含了用于散列的有用的解析字段,主机名,URL路径,参数以及其他信息。

点击劫持以及web_accessible_resources的使用注意事项

web_accessible_resources指令是指哪些资源,如扩展页面,图像和JavaScript可以通过任意网站被嵌入。前面已经提到,默认情况下,任意网页都不能在iframes中嵌入扩展页面,也不能通过脚本或样式表标签来发送它们。这条指令的示例用法如下所示:

{
  ...trimmed for brevity...
  "web_accessible_resources": [
    "images/*.png",
    "style/double-rainbow.css",
    "script/double-rainbow.js",
    "script/main.js",
    "templates/*"
  ],
  ...trimmed for brevity...
}

从上面的示例中不难看出,您不仅可以指定特定的资源,还可以使用通配符资源文件夹。但是,有时开发人员会遇到Chrome阻止他嵌入扩展资源的问题,并且会执行以下操作:

{
  ...trimmed for brevity...
  "web_accessible_resources": [
    "*",
  ],
  ...trimmed for brevity...
}

这是一项相当有效的策略,这意味着本质上所有的Chrome扩展资源现在都可以嵌入到第三方网站。问题是当您的扩展程序包含有执行特权操作的页面并且也属于web_accessible_resources策略的页面时,这会变成一个点击劫持(clickjacking)漏洞。由点击劫持引起的扩展程序漏洞的一个很好的例子可以参阅有关Steam Inventory Helper中的UXSS的这篇文章。

使用tarnish自动化审核流程

由于Chrome扩展程序的独特结构,我决定写一个服务工具来帮助开发人员和安全研究人员审核Chrome扩展程序中的安全漏洞。我把这个工具叫做tarnish,它具有以下功能:

所有这些功能都是为了将我在审核各种Chrome扩展程序时必须采取的烦人的机械式重复性操作自动化而创建的。如果您对该工具的任何功能有建议或者存在bug,请随时与我联系,我会尽快排查。

点击此处试用Chrome扩展程序分析仪tarnish

突破限制—一份安全编写和审计Chrome扩展程序的指南(上)

源链接

Hacking more

...