Bilibili-Evolved 功能拆解 ① - 沙箱

Bilibili-Evolved 功能拆解 ① - 沙箱

·

3 min read

背景

Bilibili Evolved 是一个模块化的 B 站 UserScript,我对这项目感兴趣的地方有两个点,一是沙箱的实现,二是插件系统的实现,本文主要是研究他的沙箱方案。

沙箱的实现

首先定位到沙箱的代码 load-feature-code.ts

export class LoadFeatureCodeError extends Error {}

/**
 * feature 代码运行沙箱
 */
type CodeSandbox = {
  /**
   * 在沙箱中执行代码
   *
   * @remarks
   * 代码执行时的相关注意事项见 {@link loadFeatureCode}
   *
   * @returns 一个二元组:`[导出值, 返回值]`
   * @throws {@link LoadFeatureCodeError}
   * 代码包含语法错误或代码执行时产生了异常
   */
  run(code: string): [unknown, unknown]
}

/**
 * 创建 feature 代码的运行沙箱
 *
 * @returns 一个 `CodeSandbox`。
 */
const createCodeSandbox = (): CodeSandbox => {
  // 需要被注入到 `sandbox` 中的键值对
  const injection = new Map([
    // 加固,防止逃逸
    [Symbol.unscopables, undefined],
    ['unsafeWindow', unsafeWindow],
  ] as [keyof any, unknown][])
  // 目标代码执行时的全局对象代理
  const sandbox = new Proxy(Object.create(null), {
    has: () => true,
    get: (_, p) => (injection.has(p) ? injection.get(p) : window[p as string]),
    set: (_, p, v) => !injection.has(p) && (window[p as string] = v),
  })
  const codeKey = 'BILIBILI_EVOLVED_LOAD_FEATURE_CODE_CODE_KEY_3B63D912__'
  // eslint-disable-next-line no-new-func
  const fn = Function(
    'window',
    `with (window) {
       return eval(${codeKey}) 
     }`,
  ).bind(undefined, sandbox)
  return {
    run(code) {
      injection.set('exports', {})
      injection.set(codeKey, code)
      let returned
      try {
        returned = fn()
      } catch (e) {
        throw new LoadFeatureCodeError(undefined, { cause: e })
      }
      const exportsValues = Object.values(injection.get('exports'))
      const exported = exportsValues.length > 0 ? exportsValues[0] : undefined
      return [exported, returned]
    },
  }
}

let staticCodeSandbox: CodeSandbox | undefined
/**
 * 执行 feature (component, plugin, style) 的代码,并尝试获取其导出元数据
 *
 * @remarks
 * feature 代码支持两种导出方式:
 * 1. 以 UMD 方式打包的库
 * 2. 若代码整体为一个表达式,则导出表达式的返回值
 *
 * 该函数线程不安全
 *
 * 代码默认以非严格模式执行,启用需自行添加 `use strict`。(从本项目中打包的 feature 自带严格模式)
 *
 * 代码执行时的全局对象为脚本管理器提供的 `window`。代码中支持访问 `unsafeWindow`。
 *
 * @param code - 被执行的代码
 * @returns 导出的元数据(不检测是否为正确的 feature)
 * @throws {@link LoadFeatureCodeError}
 * 代码包含语法错误或代码执行时产生了异常
 */
export const loadFeatureCode = (code: string): unknown => {
  staticCodeSandbox || (staticCodeSandbox = createCodeSandbox())
  const [exported, returned] = staticCodeSandbox.run(code)
  return exported || returned
}

内容并不多,首先在每次创建沙箱时,都会新建一个 Map 作为全局对象

const injection = new Map([
  // 加固,防止逃逸
  [Symbol.unscopables, undefined],
  ['unsafeWindow', unsafeWindow],
] as [keyof any, unknown][])

Map 中的项就是需要注入到沙箱的内容,unsafeWindow 好说,在 user script 中能通过这个变量来绕开油猴插件的安全模型,直接访问到实际页面的 window

Symbol.unscopables 我查了一下,首先 with 关键字会创建一个新的作用域链将指定对象添加到作用域链的顶部

Symbol.unscopables 的值是个对象的时候,对象值为 true 表示该键不在该作用域中可见,但这里的 Symbol.unscopablesundefined 所以似乎没有起到作用?也有可能是规范化各种浏览器的行为。

看下来这段的作用就是把 unsafeWindow 给注入到沙箱中。


接下来通过 Proxyinjection 套了一层代理:

const sandbox = new Proxy(Object.create(null), {
  has: () => true,
  get: (_, p) => (injection.has(p) ? injection.get(p) : window[p as string]),
  set: (_, p, v) => !injection.has(p) && (window[p as string] = v),
})

首先这段代码是在油猴 user script 中运行的,所以此时的 window 实际上是油猴的安全模型

has 限制了通过 in 来判断属性是否在对象中的操作,比如 'abc' in sandbox 总是会返回 true

get 优先会在 injection 上进行检索,其次才是 window,而 set 也一样,但新的值只会挂在 window 上,不会影响 injection

sandbox 才是沙箱本身,对于沙箱的写入,只影响 window,不影响 injectioninjection 只负责优先提供公共对象值的读取


const codeKey = 'BILIBILI_EVOLVED_LOAD_FEATURE_CODE_CODE_KEY_3B63D912__'  
// eslint-disable-next-line no-new-func  
const fn = Function(  
  'window',  
  `with (window) { return eval(${codeKey}) }`,  
).bind(undefined, sandbox)

首先看 fn,通过动态的方式构建出了一个函数,接受 window 作为参数,实参是 sandbox,将 sandbox 的实参提到作用域的顶部,然后执行 codeKey

指定这个函数执行时的上下文是 undefined,这一步估计也是防止逃逸的。

codeKeysandbox 中会作为全局对象的一个属性,他对应的内容就是沙箱要执行的代码:

return {
  run(code) {
    injection.set('exports', {})
    injection.set(codeKey, code)
    let returned
    try {
      returned = fn()
    } catch (e) {
      throw new LoadFeatureCodeError(undefined, { cause: e })
    }
    const exportsValues = Object.values(injection.get('exports'))
    const exported = exportsValues.length > 0 ? exportsValues[0] : undefined
    return [exported, returned]
  },
}

这段代码会被注入到 injection 中,同时会给 injection 设置一个 exports 属性,然后执行 fn 函数,这里的 fn 的内容就会变成:

with (sandbox) {
  return eval(动态代码)
}

得到返回值 returned,然后如果代码是 umd 打包的,那么最终的成品的导出是 commonjs 形式的,会挂载到预先准备的 injectionexports 对象上,所以 exportedreturned 就是沙箱的执行结果。