在之前一段时间,我需要借助Chrome扩展来完成一个需求,当时还在使用油猴脚本与浏览器扩展之间调研了一波,而此时恰好我又有一些做的还可以的油猴脚本 TKScript,相对会比较熟悉脚本管理器的能力,预估是不太能完成需求的,所以趁着这个机会,我又学习了一波浏览器扩展的能力。那么在后来需求的开发过程中,因为有些能力是类似于脚本管理器提供的基础环境,致使我越来越好奇脚本管理器是怎么实现的,而实际上脚本管理器实际上还是一个浏览器扩展,浏览器也并没有给脚本管理器开后门来实现相关能力,而让我疑惑的三个问题是:
JS运行。window对象。因此,之后调研了一波浏览器扩展能力的开发之后,总结了脚本管理器的核心能力实现,同样也是解答了让我疑惑的这三个问题。
不知道大家是否有用过油猴脚本,因为实际上浏览器级别的扩展整体架构非常复杂,尽管当前有统一规范但不同浏览器的具体实现不尽相同,并且成为开发者并上架Chrome应用商店需要支付5$的注册费,如果我们只是希望在Web页面中进行一些轻量级的脚本编写,使用浏览器扩展级别的能力会显得成本略高,所以在没有特殊需求的情况,在浏览器中实现级别的轻量级脚本是很不错的选择。
那么在简单了解了浏览器扩展的开发之后,我们回到开头提出的那三个问题,实际上这三个问题并没有那么独立,而是相辅相成的,为了清晰我们还是将其拆开来看,所以我们在看每个问题的时候都需要假设另一方面的实现。比如在解答第三个为什么能够跨域请求的问题时,我们就需要假设脚本实际是运行在Inject环境中的,因为如果脚本是运行在Background中的话,那么讨论跨域就没什么意义了。
在油猴脚本管理器中有一个非常重要的实现是@run-at: document-start/document-end/document-idle,特别是document-start,试想一下如果我们能够在页面实际加载的时候就运行我们想执行的JS代码的话,岂不是可以对当前的页面“为所欲为了”。虽然我们不能够Hook自面量的创建,但是我们总得调用浏览器提供的API,只要用API的调用,我们就可以想办法来劫持掉函数的调用,从而拿到我们想要的数据,例如可以劫持Function.prototype.call函数的调用,而这个函数能够完成很大程度上就需要依赖我这个劫持函数在整个页面是要最先支持的,否则这个函数已经被调用过去了,那么再劫持就没有什么意义了。
那么可能我们大家会想这个代码的实现意义在何处,举一个简单的实践,在某某文库中所有的文字都是通过canvas渲染的,因为没有DOM那么如果我们想获得文档的整篇内容是没有办法直接复制的,所以一个可行的方案是劫持document.createElement函数,当创建的元素是canvas时我们就可以提前拿到画布的对象,从而拿到ctx,而又因为实际绘制文字总归还是要调用context2DPrototype.fillText方法的,所以再劫持到这个方法,我们就能将绘制的文字拿出来,紧接着就可以自行创建DOM画在别处,想复制就可以复制了。
那么我们回到这个问题的实现上,如果能够保证脚本是最先执行的,那么我们几乎可以做到在语言层面上的任何事情,例如修改window对象、Hook函数定义、修改原型链、阻止事件等等等等。其本身的能力也是源自于浏览器拓展,而如何将浏览器扩展的这个能力暴露给Web页面就是脚本管理器需要考量的问题了。那么我们在这里假设用户脚本是运行在浏览器页面的Inject Script而不是Content Script,基于这个假设,首先我们大概率会写过动态/异步加载JS脚本的实现,类似于下面这种方式:
那么现在就有一个明显的问题,我们如果在body标签构建完成也就是大概在DOMContentLoaded时机再加载脚本肯定是达不到document-start的目标的,即使是在head标签完成之后处理也不行,很多网站都会在head内编写部分JS资源,在这里加载同样时机已经不合适了,实际上最大的问题还是整个过程是异步的,在整个外部脚本加载完成之前已经有很多JS代码在执行了,做不到我们想要的“最先执行”。
那么下载我们就来探究具体的实现,首先是V2的扩展,对于整个页面来说,最先加载的必定是html这个标签,那么很明显我们只要将脚本在html标签级别插入就好了,配合浏览器扩展中background的chrome.tabs.executeScript动态执行代码以及Content Script的"run_at": "document_start"建立消息通信确认注入的tab,这个方法是不是看起来很简单,但就是这么简单的问题让我思索了很久是如何做到的。
这个看起来其实已经还不错了,能够基本做到document-start,但既然都说了是基本,说明还有些情况会出问题,我们仔细看这个代码的实现,在这里有一个通信也就是Content Script --> Background,既然是通信那么就是异步处理的,既然是异步处理就会消耗时间,一旦消耗时间那么用户页面就可能已经执行了大量的代码了,所以这个实现会偶现无法做到document-start的情况,也就是实际上是会出现脚本失效的情况。
那么有什么办法解决这个问题呢,在V2中我们能够明确知道的是Content Script是完全可控的document-start,但是Content Script并不是Inject Script,没有办法访问到页面的window对象,也就没有办法实际劫持页面的函数,那么这个问题看起来很复杂,实际上想明白之后解决起来也很简单,我们在原本的Content Script的基础上,再引入一个Content Script,而这个Content Script的代码是完全等同于原本的Inject Script,只不过会挂在window上,我们可以借助打包工具来完成这件事。
这段代码表示了我们在同样的Content Script的window对象上挂了一个随机生成的key,而内容就是我们实际想要注入到页面的脚本,但是现在虽然我们能够拿到这个函数了,怎么能够让其在用户页面上执行呢,这里实际上是用到了同样的document.documentElement.appendChild创建脚本方法,但是在这里的实现非常非常巧妙,我们通过两个Content Script配合toString的方式拿到了字符串,并且将其作为代码直接注入到了页面,从而做到了真正的document-start。
此外我们可能纳闷,为什么脚本管理器框架和用户脚本都是采用这种方式注入的,而在浏览器控制台的Sources控制面板下只能看到一个userscript.html?name=xxxxxx.user.js却看不到脚本管理器的代码注入,实际上这是因为脚本管理器会在用户脚本的最后部分注入一个类似于//# sourceURL=chrome.runtime.getURL(xxx.user.js)的注释,其中这个sourceURL会将注释中指定的URL作为脚本的源URL,并在Sources面板中以该URL标识和显示该脚本,这对于在调试和追踪代码时非常有用,特别是在加载动态生成的或内联脚本时。
由于实际上Chrome浏览器不再允许V2的扩展程序提交,所以我们只能提交V3的代码,但是V3的代码有着非常严格的CSP内容安全策略的限制,可以简单的认为不允许动态地执行代码,所以我们上述的方式就都失效了,于是我们只能写出类似下面的代码。
虽然看起来我们也是在Content Script中立即创建了Script标签并且执行代码,而他能够达到我们的document-start目标吗,很遗憾答案是不能,在首次打开页面的时候是可以的,但是在之后因为这个脚本实际上是相当于拿到了一个外部的脚本,因此Chrome会将这个脚本和页面上其他的页面同样处于一个排队的状态,而其他的脚本会有强缓存在,所以实际表现上是不一定谁会先执行,但是这种不稳定的情况我们是不能够接受的,肯定做不到document-start目标。实际上光从这点来看V3并不成熟,很多能力的支持都不到位,所以在后来官方也是做出了一些方案来处理这个问题,但是因为我们并没有什么办法决定用户客户端的浏览器版本,所以很多兼容方法还是需要处理的。
在Chrome V109之后支持了chrome.scripting.registerContentScripts,Chrome 111支持了直接在Manifest中声明world: 'MAIN'的脚本,但是这其中的兼容性还是需要开发者来做,特别是如果原来的浏览器不支持world: 'MAIN',那么这个脚本是会被当作Content Script处理的,关于这点我觉得还是有点难以处理。
在高版本浏览器尚未普及的情况下,我们还是需要根据用户的页面实际情况,在worker中判断究竟是采用注册的方式还是直接执行的方式。但是在worker中执行就意味着我们必须要申请到scripting以及host permissions,这就意味着我们的扩展会有更高的权限,通常这样就要在扩展市场触发额外的审核环节。
此外,在Firefox如果用户页面存在CSP策略的话,这里就会出现额外的问题。网站的CSP策略通常会限制script-src以及unsafe-inline,因此我们上述注入到Inject Script的策略就会失效。而直接使用tabs.executeScript注入的环境是Content Script,因此我们也不能直接通过这个方法注入,顺便提一下上述的scripting.executeScript的World: MAIN的Chrome兼容性也不是特别好。
scripting.executeScript这个方法在Firefox虽然也可用,但是要FF 128才支持World: MAIN,那么在这种情况下我们只能通过一些策略来尝试绕过CSP的限制,或者采用兜底方案保证部分功能的正常运行。
webRequest.onHeadersReceived修改响应头,阻止CSP策略的加载。
CSP不支持多个nonce声明, 但可以配置多个sha-hash,因此我们可以在响应头中加入sha256-${CSP-HASH},在编译时计算并替换资源。但是处理不好就会导致问题,例如最初'self'是没问题的,但是'self'+'hash'就会导致宽松到严格结构问题。CSP的扩展同时运行时,浏览器会更倾向于更加严格的模式,实际同样会导致无法正常注入。CSP策略完全移除,暴力猴则会尝试在Firefox的情况中读取nonce来允许执行脚本。webRequest.onResponseStarted中读取响应头,读取CSP策略加载脚本。
script-src都会允许部分脚本的执行,例如我们上面提到的nonce。那么我们既然不能修改响应头,倒不如仅读取而不阻塞,然后读取到类似nonce、blob的值,基于这些特征创建脚本标签,然后插入到页面中。Content Script中执行了。在Content Script中不会受到CSP策略的限制,并且共享DOM事件样式等,可以保证部分功能的正常运行。Content Script中通过wrappedJSObject检查并兜底执行策略。
wrappedJSObject是Firefox中的一个特性,可以让我们访问到页面的Inject Script中的Window对象。那么在上述的脚本执行没有成功时,在window上不会挂载我们的幂等执行的锁,那么我们就可以得知脚本没有成功执行。Blob对象以及URL.createObjectURL方法,构造blob:https://xxx的同域URL,然后通过script.src的方式加载脚本。但是这里的问题与上边的问题类似,这会是异步的而不能完全保证document_start。onload后再次检查都无法成功执行后,我们同样只能兜底通过Content Script的方式执行脚本,以此保证DOM事件与样式部分功能的正常执行,此外在Content Script中通过exportFunction导出函数到页面也是可选的方案。这个问题也是一个非常有意思的点,关于这个问题我还在群里提问过但是当时并没有得到一个答案,那么在这里我们就研究一下,首先我们要明确的是在脚本中是存在两个window的,也就是window以及unsafeWindow两个对象,window对象是一个隔离的安全window环境,而unsafeWindow就是用户页面中的window对象。
曾经我很长一段时间都认为这些插件中可以访问的window对象实际上是浏览器拓展的Content Scripts提供的window对象,而unsafeWindow是用户页面中的window,以至于我用了比较长的时间在探寻如何直接在浏览器拓展中的Content Scripts直接获取用户页面的window对象,当然最终还是以失败告终,这其中比较有意思的是一个逃逸浏览器拓展的实现,因为在Content Scripts与Inject Scripts是共用DOM的,所以可以通过DOM来实现逃逸,当然这个方案早已失效。
此外在FireFox中还提供了一个wrappedJSObject来帮助我们从Content Scripts中访问页面的的window对象,但是这个特性也有可能因为不安全在未来的版本中被移除。实际上就拿上边这个逃逸方法而言,浏览器也是正在围追堵截这种行为,也就是说我们可以明确的是在Content Scripts中是拿不到浏览器页面的window对象的。
那么最终我如何确定这两个window对象实际上是同一个浏览器环境的window呢,主要是之前做到了需要动态渲染React组件的需求,突然又意识到了这个问题,所以除了看开源的脚本管理器源码之外我们也可以通过以下的代码来验证脚本在浏览器的效果,可以看出我们对于window的修改实际上是会同步到unsafeWindow上,证明实际上是同一个引用。
实际上在这里还利用了new Function的逃逸,所以才能够发现这俩实际上是同一个引用,那么问题又来了,既然都是同一个window对象,脚本管理器是如何提供的干净的window对象的,在这里我们就得聊一个小故事了。试想一下如果我们完全信任用户当前页面的window,那么我们可能会直接将API挂载到window对象上,听起来似乎没有什么问题,但是设想这么一个场景,假如用户访问了一个恶意页面,然后这个网页又恰好被类似https://*/*规则匹配到了,那么这个页面就可以获得访问我们的脚本管理器的相关API,这相当于是浏览器扩展级别的权限,例如直接获取用户磁盘中的文件内容,并且可以直接将内容跨域发送到恶意服务器,这样的话我们的脚本管理器就会成为一个安全隐患,再比如当前页面已经被XSS攻击了,攻击者便可以借助脚本管理器GM.cookie.get来获取HTTP Only的Cookie,并且即使不开启CORS也可以轻松将请求发送到服务端。那么显然我们本身是准备使用脚本管理器来Hook浏览器的Web页面,此时反而却被越权访问了更高级的函数,这显然是不合理的,所以GreaseMonkey实现了XPCNativeWrappers机制,也可以理解为针对于window对象的沙箱环境。
实际上在@grant none的情况下,脚本管理器会认为当前的环境是安全的,同样也不存在越权访问的问题了,所以此时访问的window就是页面原本的window对象。此外,上边我们也提到了验证代码最后两行我们突破了这些扩展的沙盒限制,从而可以在未@grant unsafeWindow情况下能够直接访问unsafeWindow,当然这并不是什么大问题,因为脚本管理器本身也是提供unsafeWindow访问的,而且如果在页面未启用unsafe-eval的CSP情况下这个例子就失效了。只不过我们也可以想一下其他的方案,是不是直接禁用Function函数以及eval的执行就可以了,但是很明显即使我们直接禁用了Function对象的访问,也同样可以通过构造函数的方式即(function(){}).constructor来访问Function对象,所以针对于window沙箱环境也是需要不断进行攻防的,例如小程序不允许使用Function、eval、setTimeout、setInterval来动态执行代码,那么社区就开始有了手写解释器的实现,对于我们这个场景来说,我们甚至可以直接使用iframe创建一个about:blank的window对象作为隔离环境。
那么我们紧接着可以简单地讨论下如何实现沙箱环境隔离,其实在上边的例子中也可以看到直接打印window输出的是一个Proxy对象,那么在这里我们同样使用Proxy来实现简单的沙箱环境,我们需要实现的是对于window对象的代理,在这里我们简单一些,我们希望的是所有的操作都在新的对象上,不会操作原本的对象,在取值的时候可以做到首先从我们新的对象取,取不到再去window对象上取,写值的时候只会在我们新的对象上操作,在这里我们还用到了with操作符,主要是为了将代码的作用域设置到一个特定的对象中,在这里就是我们创建的的context,在最终结果中我们可以看到我们对于window对象的读操作是正确的,并且写操作都只作用在沙箱环境中。
此外即使我们完成了沙箱环境的构建,但是如何将这个对象传递给用户脚本,我们不能将这些变量暴露给网站本身,但是又需要将相关的变量传递给脚本,而脚本本身就是运行在用户页面上的,否则我们没有办法访问用户页面的window对象,所以接下来我们就来讨论如何保证我们的高级方法安全地传递到用户脚本的问题。实际上在上边的source-map我们也可以明显地看出来,我们可以直接借助闭包以及with访问变量即可,并且在这里还需要注意this的问题,所以在调用该函数的时候通过如下方式调用即可将当前作用域的变量作为传递给脚本执行。
那么现在到目前为止我们使用Proxy实现了window对象隔离的沙箱环境,总结起来我们的目标是实现一个干净的window沙箱环境,也就是说我们希望网站本身执行的任何不会影响到我们的window对象,比如网站本体在window上挂载了$$对象,我们本身不希望其能直接在开发者的脚本中访问到这个对象,我们的沙箱环境是完全隔离的,而用户脚本管理器的目标则是不同的,比如用户需要在window上挂载事件,那么我们就应该将这个事件处理函数挂载到原本的window对象上,那么我们就需要区分读或者写的属性是原本window上的还是Web页面新写入的属性,显然如果想解决这个问题就要在用户脚本执行之前将原本window对象上的key记录副本,相当于以白名单的形式操作沙箱。同样的相辅相成的,如果想要做到window沙箱那么就必须保证扩展的Inject Script是最先执行的也就是document-start,否则就很难保证用户原本是不是在window对象上挂载了内容,导致污染了沙箱环境。
接着我们来聊最后一个问题,脚本管理器是如何做到的可以跨域请求,实际上因为在前边我们明确了用户脚本是在浏览器当前的页面执行的,那么理所当然的就会存在同源策略的问题,然后在脚本管理器中只要声明了链接的域名,就可以逃脱这个限制,这又是一件很神奇的事情。
那么解决这个问题的方式也比较简单,很明显在这里发起的通信并不是直接从页面的window发起的,而是从浏览器扩展发出去的,所以在这里我们就需要讨论如何做到在用户页面与浏览器扩展之间进行通信的问题。在Content Script中的DOM和事件流是与Inject Script共享的,那么实际上我们就可以有两种方式实现通信,首先我们常用的方法是window.addEventListener + window.postMessage,只不过这种方式很明显的一个问题是在Web页面中也可以收到我们的消息,即使我们可以生成一些随机的token来验证消息的来源,但是这个方式毕竟能够非常简单地被页面本身截获不够安全,所以在这里通常是用的另一种方式,即document.addEventListener + document.dispatchEvent + CustomEvent自定义事件的方式,在这里我们需要注意的是事件名要随机,通过在注入框架时于background生成唯一的随机事件名,之后在Content Script与Inject Script都使用该事件名通信,就可以防止用户截获方法调用时产生的消息了。
只不过因为这边涉及到的通信会比较多,通过Content Script中转来将消息发送到Background/Worker来最终实现的请求,实际控制起来还是有些麻烦的,需要比较好的设计来处理各个模块的通信与事件触发。
最终在这里我们可能已经明确了浏览器扩展的一些非常Hack能力的实现,同时可能也会发现浏览器扩展的权限是真的非常高,在V2版本中甚至连HTTP Only的Cookie都可以拿到,在V3中限制就多了起来,但是整体的权限还是非常高的,所以在选择浏览器扩展时还是需要谨慎,要不就选择用户比较多的,要不就选择开源的。不过之前类似EDGE扩展的事件还是不容易避免,简单来说就是EDGE开放了扩展,然后有些不怀好意的人将开源的扩展封装了广告代码然后提交到扩展市场,最终使用拓展的我们还是要关注一下这些问题的,最后提供一些脚本管理器可以参考学习。
Firefox提供扩展能力,采用MIT license协议。2.9版本前)采用GPL-3.0 license协议。MIT license协议。 GPL-3.0 license协议。