Skip to main content

Command Palette

Search for a command to run...

Bilibili-Evolved 功能拆解 ① - 沙箱

Published
3 min read
Bilibili-Evolved 功能拆解 ① - 沙箱

背景

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 就是沙箱的执行结果。

More from this blog

12 月装机行动记录

(Banner 图文无关) 这几天买了新的装备回来升级配置,除了显卡和散热器,其他都更新了,这两个不更新的原因是太贵了。 由于我不太懂选配置,所以还是让朋友给推荐,我说我的预算在 5000 左右,他就给我转了一个整机,配置大概是这样的: CPU:AMD 9700X 主板:微星 B650M GAMING PLUS WIFI 或者 微星 B650M GAMING WIFI 显卡:木有 内存:英睿达/宇瞻 DDR5 6000 32G 硬盘:1T NVMe PCIe4 SSD 读速 3500M...

Dec 7, 20241 min read
12 月装机行动记录

Homekit + cozylife 插座连接 HA

近期在淘宝上买了个 Homekit + cozylife 的插座,就这种: 一开始只通过 iOS 访问,就是只连接 Homekit,长按开关重置插座,iOS 一扫码就连上了,后来我嫌在外面访问不了,又不想掏钱买苹果的 HomePod,于是就装上了 Home Assistant,打算让设备们都连上 HA,这样就不用交苹果税了。 连接方式还是通过 Homekit,一般来说支持 Homekit 设备都能这样连接,先连上 iOS,然后在 Home App 中移除设备,这时候就能在 HA 中找到设备了:...

Nov 30, 20241 min read
Homekit + cozylife 插座连接 HA

找到了一台祖传的 Ccd 相机

开个玩笑,这台相机其实是我们家在 05 年的时候买的,发票都还在呢,当时花了 4000 块钱!搁现在我都受不了,更别说当年了,看到价格我都震惊了。 相机的型号是索尼的 Cybershot DSC-N1,属于小红书时尚单品 CCD 相机,由于一直放在包装盒里,现在还有 99 新呢。 机子还是正常的,能开机,能拍照,其中一个问题是日期,这款没有 WiFi 功能,所以时间只能保存在本地,不知道是不是 BUG,每次开机都让我重新设置,默认就定在 2005 年 1 月 1 日。 第二个问题是电池,电池应该...

Nov 28, 20241 min read
找到了一台祖传的 Ccd 相机

记录和 ffmpeg 与 LLM 搏斗的两天

要做的 最近在写一个制作视频的功能,就是把 N 个视频合并,然后把对应的 N 张图片,在视频开始的前 5 秒叠加显示出来。 第一口 - diffusion studio 本来我用的是 diffusion studio,这是一个 JS 库,但这玩意性能太差了,因为他要把视频每一帧都读到 canvas 里,数据一多页面就卡住了(为啥要折腾 DOM 呢?) 而且他的 API 十分不好用,作为浏览器脚本你无法读本地数据也就算了,你起码给一个接受纯数据的参数吧,比如 HTML 类型接受源代码,Image ...

Nov 16, 20242 min read
记录和 ffmpeg 与 LLM 搏斗的两天
V

void mian

39 posts