基于 Node.js Addon 和 v8 字节码的 Electron 代码保护解决方案

背景

咱们有一个项目使用了 Electron 开发桌面应用,使其可以在 Windows / Mac 两端上跨平台运行,所以核心逻辑都是经过 JavaScript 编写的,黑客很是容易对咱们的应用进行解包、修改逻辑破解商业化限制、从新打包,去再分发破解版。html

虽然咱们已经对应用作了数字签名,可是这还远远不够。要想真正解决问题,除了把全部商业化逻辑作到服务端,咱们还须要对代码进行加固,避免解包、篡改、二次打包、二次分发。前端

方案对比

主流方案

  • Uglify / Obfuscator
    • 介绍:经过对 JS 代码进行丑化和混淆,尽量下降其可读性。
    • 特征:容易解包容易阅读容易篡改容易二次打包
    • 优点:接入简单。
    • 劣势:代码格式化工具和混淆反解工具都能对代码进行必定程度的复原。丑化经过修改变量名,可能会引发代码没法运行。混淆经过调整代码结构,对代码性能有较大的影响,也可能引发代码没法执行。
  • Native 加解密
    • 介绍:将 Webpack 的构建产物 Bundle 经过 XOR 或者 AES 等方案进行加密,封装进 Node Addon,而后在运行时经过 JS 进行解密。
    • 特征:解包有成本容易阅读容易篡改容易二次打包
    • 优点:有必定的保护做用,能够阻拦小白。
    • 劣势:对于熟悉 Node 和 Electron 的黑客来讲,解包很是容易。可是若是应用支持 DevTools,则能够直接经过 DevTools 看到源代码而后再分发。若是应用不支持 DevTools,只要把 Node Addon 拷贝到一个支持 DevTools 的 Electron 下执行,仍是能看到源代码。
  • ASAR 加密
    • 介绍:将 Electron ASAR 文件进行加密,并修改 Electron 源代码,在读取 ASAR 文件以前对其解密后再运行。
    • 特征:难以解包容易阅读容易篡改容易二次打包
    • 优点:有较强的保护做用,能够阻拦很多黑客。
    • 劣势:须要从新构建 Electron,初期成本高昂。可是黑客能够经过强制开启 Inspect 端口或者应用内 DevTools 读取到源代码、或者经过 Dump 内存等方式解析出源代码,而且将源代码从新打包分发。
  • v8 字节码
    • 介绍:经过 Node 标准库里的 vm 模块,能够从 Script 对象中生成其缓存数据(参考)。该缓存数据能够理解为 v8 的字节码,该方案经过分发字节码的形式来达到源代码保护的目的。
    • 特征:容易解包难以阅读难以篡改容易二次打包
    • 优点:生成的字节码,不只几乎不可读,并且难以篡改。且不保存源代码。
    • 劣势:对构建流程具备较大侵入性,没有便捷的解决方案。字节码里仍是能够读到字符串等数据,能够进行篡改。

方案介绍

关于 v8 字节码

官方的几句话介绍:v8.dev/blog/code-c…node

扩展阅读:git

咱们能够理解,v8 字节码是 v8 引擎在解析和编译 JavaScript 后产物的序列化形式,它一般用于浏览器内的性能优化。因此若是咱们经过 v8 字节码运行代码,不只可以起到代码保护做用,还对性能有必定的提高。github

咱们在此不对 v8 字节码做为过多的阐述,能够经过阅读上述两篇文章去了解经过 v8 字节码进行代码保护的技术背景和实现方案。web

v8 字节码的局限性

在代码保护上的局限

v8 字节码不保护字符串,若是咱们在 JS 代码中写死了一些数据库的密钥等信息,只要将 v8 字节码做为字符串阅读,仍是能直接看到这些字符串内容的。固然,简单一点的方法就是使用 Binary 形式的非字符串密钥。算法

另外,若是直接将上面技术方案中生成的二进制文件进行略微修改,仍是能够很是容易地再分发。好比把 isVip 对应的值写死为 true,或者是把自动更新 URL 改为一个虚假的地址来禁用自动更新。为了不这些状况,咱们但愿在这一层之上作更多的保护,让破解成本更加高。数据库

对构建的影响

v8 字节码格式的和 v8 版本和环境有关,不一样版本或者不一样环境的 v8,其字节码产物不同。Electron 存在两种进程,Browser 进程和 Renderer 进程。两种进程虽然 v8 版本同样,可是因为注入的方法不一样,运行环境不一样,所以字节码产物也有区别。在 Browser 进程中生成的 v8 字节码不能在 Renderer 进程中运行,反之也不行。固然,在 Node.js 中生成的字节码也是没法在 Electron 上运行的。所以,咱们须要在 Browser 进程中构建用于 Browser 进程的代码,在 Renderer 进程中构建用于 Renderer 进程的代码。api

对调试的影响以及支持 Sourcemap

因为咱们将构造 vm.Script 所使用的代码都替换成了 dummyCode 进行占位,因此对 sourcemap 会有影响,而且 filename 也再也不起做用。因此对调试时定位代码存在必定影响。浏览器

对代码大小的影响

对于只有几行的 JS 代码来讲,编译为字节码会大大增长文件体积。若是项目中存在大量小体积的 JavaScript 文件,项目体积会有很是大幅度的增加。固然对于几 M 的 JS Bundle 来讲,其体积的增量基本能够忽略不计。

更进一步 - 经过 Node Addon 进行(解)混淆和运行

基于上述的局限性,咱们将 v8 字节码嵌入到一个 Node.js 能够运行的 Node Addon 之中。而且在这个 Node Addon 里面对嵌入的 v8 字节码进行解混淆、运行。如此一来,不只保护了 v8 字节码上的各类常量信息,还将整套字节码方案隐藏在了一个 Node Addon 以内。

使用 N-API

为了不 rebuild,咱们须要使用 N-API 做为 Node Addon 的方案,具体优点能够查阅:Node-API | Node.js v15.14.0 Documentation

使用 Rust 与 Neon Bindings

使用 Rust 语言为单纯的技术选型偏好,Rust 相较于 C++ 具备 相对的内存安全构建工具链便于使用跨平台能力强大 等特色,因此咱们选择了 Rust 做为 Node Addon 的实现方案。

同时,Rust 具有了 include_bytes! 宏,可以直接在编译时,将二进制文件嵌入至构建产生的动态连接库中,相比 C++ 须要实现 codegen 的方案更为简单。

固然,Rust 并不能直接用于编写 Node Addon,而是须要借助 Neon Bindings 进行开发。Neon Bindings 是一个对 Node API 进行 Rust 层封装的库,它把 Node API 隐藏于底层实现中,并向 Rust 开发者暴露简单易用的 Rust API。(Rust Bindings 在以前并不支持 Node API,Node API 的支持进度参考 Quest: N-API Support · Issue #444 · neon-bindings/neon

具体实现

实现上主要是对构建工具流的改造,具体构建流程能够参考该图示:

编译字节码

在大多数 Electron 应用的场景下,不管是使用 Webpack 仍是其余 Bundler 工具,都会产生两个以上的 Bundle 文件,分别用于主进程和单/多个渲染进程,咱们对构建产物的名称进行假定,具体须要结合实际使用场景。咱们经过 Bundler 构建出了两个及以上的 Bundle 文件,假设名称分别为:main.jsrenderer.js

完成 Bundle 构建以后,须要对两个 Bundle 编译成字节码。因为咱们须要在 Electron 环境下运行这两个 Bundle,所以咱们须要在 Electron 环境下完成字节码的生成。对于用于主进程的 Bundle,能够直接在主进程中生成字节码,而对于用于渲染进程的 Bundle,咱们须要新起一个浏览器窗口并在其中生成字节码。咱们分别建立两个 js 文件:

electron-main.js

// 这个文件能够直接用 electron 命令运行。

const fs = require('fs');
const path = require('path');
const rimraf = require('rimraf');
const { BrowserWindow, app } = require('electron');
const { compile } = require('./bytecode');

async function main() {
  // 输入目录,用于存放待编译的 js bundle
  const inputPath = path.resolve(__dirname, 'input');
  // 输出目录,用于存放编译产物,也就是字节码,文件名对应关系:main.js -> main.bin
  const outputPath = path.resolve(__dirname, 'output');

  // 清理并从新建立输出目录
  rimraf.sync(outputPath);
  fs.mkdirSync(outputPath);

  // 读取原始 js 并生成字节码
  const code = fs.readFileSync(path.resolve(inputPath, 'main.js'));
  fs.writeFileSync(path.resolve(outputPath, 'main.bin'), compile(code));

  // 启动一个浏览器窗口用于渲染进程字节码的编译
  await launchRenderer();
}


async function launchRenderer() {
  await app.whenReady();

  const win = new BrowserWindow({
    webPreferences: {
      // 咱们经过 preload 在 renderer 执行 js,这样就不须要一个 html 文件了。
      preload: path.resolve(__dirname, './electron-renderer.js'),
      enableRemoteModule: true,
      nodeIntegration: true,
    }
  });
  win.loadURL('about:blank');
  win.show();
}

main();
复制代码

electron-renderer.js

// 这个文件是在 electorn-main.js 建立的浏览器窗口中运行的。

const fs = require('fs')
const path = require('path')
const { remote } = require('electron')
const { compile } = require('./bytecode');

async function main() {
  const inputPath = path.resolve(__dirname, 'input')
  const outputPath = path.resolve(__dirname, 'output')

  const code = fs.readFileSync(path.resolve(inputPath, 'renderer.js'))
  fs.writeFileSync(path.resolve(outputPath, `renderer.bin`), compile(code));
}

// 执行完成后须要关闭浏览器窗口,以便通知主进程编译已完成
main().then(() => remote.getCurrentWindow().close())
复制代码

接着咱们须要实现 bytecode.js,也就是编译字节码的逻辑:

bytecode.js

const vm = require('vm');
const v8 = require('v8');

// 这两个参数很是重要,保证字节码可以被运行。
v8.setFlagsFromString('--no-lazy');
v8.setFlagsFromString('--no-flush-bytecode');

function encode(buf) {
  // 这里能够作一些混淆逻辑,好比异或。
  return buf.map(b => b ^ 12345);
}

exports.compile = function compile(code) {
  const script = new vm.Script(code);
  const raw = script.createCachedData();
  return encode(raw);
};
复制代码

关于混淆:为了避免影响应用的启动速度,不建议使用 AES 等过于复杂的加密算法。由于即使是使用了 AES,字节码构建产物仍是能够经过各类方式(内存 Dump、Hook 等)获取。这里对字节码进行混淆,是为了提到破解成本,以免破解者直接从 Node Addon Binary 的二进制数据中提取各类常量。

有上述几个文件以后,咱们就能够直接经过 electron ./electron-main.js 命令,对 input 文件夹里面的 main.jsrenderer.js 进行字节码编译。产物将会生成在 output 文件夹下。

编译时会建立一个可见的 BrowserWindow,若是不但愿它可见,在建立 BrowserWindow 的参数中设置为 hide: true 便可。

封装 Native Addon

咱们使用了 Rust 去开发 Node Addon。

后续存在很多直接在 Rust 中执行 JS 逻辑的操做,其中所涉及了一些引用 Node 模块、构造对象等操做,能够参考 Neon Bindings 文档:Introduction | Neon

引用 Node 模块

咱们知道在 Node 中引用模块须要依赖 require 方法,可是 require 方法并不存在于 Global 对象中,而是存在于模块代码执行的做用域之中,咱们须要了解 Node CommonJS 的实现机制:

(function (exports, require, module, __filename, __dirname) {
  /* 模块文件代码 */
});
复制代码

每一个文件都会被包裹在上面的匿名函数中,咱们能够看到,modulerequireexports__filename__dirname 所有都是以局部变量暴露给模块的,所以 Global 对象是不会持有这些内容的。

所以咱们没法直接在 Node Addon 中获取 require 等方法,因此 JS 侧在执行 Node Addon 时,必须将 module 对象透传至 Node Addon 中,Rust 侧才能经过调用 Module 的 require 方法去引用其余模块:

require("./loader.node").load({
  type: "main",
  module // 透传当前模块的 Module 对象
})
复制代码

上面这段代码会直接替换 main.js 中原来的内容,而在 Rust 中,须要实现这么一个方法去方便 Require 操做的进行:

fn node_require(&mut self, id: &str) -> NeonResult<Handle<'a, JsObject>> { let require_fn: Handle<JsFunction> = self.js_get(self.module, "require")?; let require_args = vec![self.cx.string(id)]; let result = require_fn.call(&mut self.cx, self.module, require_args)?.downcast_or_throw(&mut self.cx)?; Ok(result) } 复制代码

字节码的嵌入和获取

咱们在字节码编译完成以后,经过 JS 生成了下面的 Rust 代码,以让 Rust 可以将编译出来的字节码嵌入至动态连接库中,而且可以直接读取:

pub fn get_module_main() -> &'static [u8] { include_bytes!("[...]/output/main.bin") } pub fn get_module_renderer() -> &'static [u8] {
    include_bytes!("[...]/output/renderer.bin")
}
复制代码

而 Rust 内读取字节码,只须要根据  JS 对 Node Addon 中的函数调用时传入的 type 字段,作一个 match pattern 判断,再调用对应的二进制数据获取方法便可:

enum LoaderProcessType {
    Main,
    Renderer
}
let process_type = match process_type_str.value(&mut cx).as_str() {
    "main" => LoaderProcessType::Main,
    "renderer" => LoaderProcessType::Renderer,
    _ => panic!("ERROR")
};
match process_type {
    LoaderProcessType::Main => gen_main::get_module(),
    LoaderProcessType::Renderer => gen_renderer::get_module()
};
复制代码

Fix Code 生成和替换

在初始化时,咱们首先须要生成 Fix Code。Fix Code 是 4 个字节的二进制数据,其实是 v8 Flags Hash,v8 在运行字节码前会进行校验,若是不一致会致使 cachedDataRejected。为了让字节码可以在当前环境中正常运行,咱们须要获取当前环境的 v8 Flags Hash。

咱们经过 Rust 调用 vm 模块执行一段无心义的代码,取得 Fix Code:

fn init_fix_code(&mut self) -> NeonResult<()> {
    let vm = self.node_require("vm")?;
    let vm_script: Handle<JsFunction> = self.js_get(vm, "Script")?;
    let code = self.cx.string("\"\"");
    let script = vm_script.construct(&mut self.cx, vec![code])?;
    let cache: Handle<JsBuffer> = self.js_invoke(script, "createCachedData",  Vec::<Handle<JsValue>>::new())?;
    let buf: Vec::<u8> = self.buf_to_vec(cache)?;
    self.fix_code = Some(buf);
    Ok(())
}
复制代码

接着将待运行的字节码的 12~16 字节替换成刚刚获取的 4 字节 Fix Code:

data[12..16].clone_from_slice(&fix_code[12..16]);
复制代码

假源码生成

接着须要在 Rust 中解析字节码的 8~12 位,获得 Source Hash 并算出代码长度。接着生成一个等长的任意字符串,做为假源码,以欺骗过 v8 的源代码长度校验。

let mut len = 0usize;
for (i, b) in (&data[8..12]).iter().enumerate() {
    len += *b as usize * 256usize.pow(i as u32)
};
self.eval(&format!(r#"'"' + "\u200b".repeat({}) + '"'"#, len - 2))?;
复制代码

此处之因此直接调用 Eval 去生成二进制数据,是由于 Rust 的字符串转换为 JsString 存在不小的开销,因此仍是直接在 JS 中生成会比较高效。Eval 的实现本质上仍是调用 vm 模块的 runInThisContext 方法。

解混淆

在运行字节码以前,咱们须要经过异或运算去解混淆:

buf.into_iter().enumerate().map(|(_, b)| b ^ 12345).collect()
复制代码

运行字节码

接着,就要运行字节码了。

首先,为了可以正常运行以前生成的字节码,还须要对 v8 的一些参数进行设置,对齐编译环境的配置:

fn configure_v8(&mut self) -> NeonResult<()> {
    let v8 = self.node_require( "v8")?;
    let set_flag: Handle<JsFunction> = self.js_get(v8, "setFlagsFromString")?;
    let args1 = vec![self.cx.string("--no-lazy")];
    set_flag.call(&mut self.cx, v8, args1)?;
    let args2 = vec![self.cx.string("--no-flush-bytecode")];
    set_flag.call(&mut self.cx, v8, args2)?;
    Ok(())
}
复制代码

接着咱们仍是须要在 Rust 中调用 vm 模块去运行字节码,即便用 Rust 执行下面的一段 JS 逻辑(原 Rust 代码过长就不贴了):

const vm = require('vm');

const script = vm.Script(dummyCode, {
  cachedData, // 这个就是字节码
  filename,
  lineOffset: 0,
  displayErrors: true
});
script.runInThisContext({
  filename,
  lineOffset: 0,
  columnOffset: 0,
  displayErrors: true
});
复制代码

运行原理

最后,咱们的构建产物的目录结构以下:

dist
├─ loader.node - Node Addon,里面包含了混淆过的全部字节码数据,基本不可读。
├─ main.js - 主进程代码入口,只有一行加载代码
├─ renderer.js - 渲染进程代码入口,只有一行加载代码
└─ index.html - HTML 文件,用于加载 renderer.js
复制代码

运行应用时,以 main.js 为入口,完整的运行流程以下:

其中,loader.node 里存储了全部的字节码数据,而且包含了加载字节码的逻辑。main.js 和 renderer.js 都会直接去引用 loader.node,而且传入 type 参数去指定须要加载的字节码。

常见疑问

  • 对构建流程有何影响?
    • 对构建流程的影响,主要是在 Bundle 构建以后、Electron Builder 打包以前,插入了一层字节码编译和 Node Addon 编译。
  • 对构建性能的影响?
    • 启动 Electron 进程和 BrowserWindow 用于字节码的编译,须要消耗 2s 左右。编译字节码时,对于 10M 左右的 Bundle,得益于 v8 超高的 JavaScript 解析效率,字节码生成的时间在 150ms 左右。最后将字节码封装进 Node Addon,因为 Rust 的构建比较慢,可能须要 5s~10s。
    • 总体来讲,这套方案对构建时间会有 10s~20s 的延长。若是是在 CI/CD 上进行构建,因为失去了 cargo 缓存,额外算上 cargo 下载依赖的额外耗时,时间可能会延长到 1 分钟左右。
  • 对代码组织和编写的影响?
    • 目前发现字节码方案对代码的惟一影响,是 Function.prototype.toString() 方法没法正常使用,缘由是源代码并不跟随字节码分发,所以取不到函数的源代码。
  • 对程序性能是否有影响?
    • 对于代码的执行性能没有影响。对于初始化耗时,有 30% 左右的提高(在咱们的应用中,Bundle 大小为 10M 左右,初始化时间从 550ms 左右下降到了 370ms)。
  • 对程序体积的影响?
    • 对于只有几百 KB 的 Bundle 来讲,字节码体积会有比较明显的膨胀,可是对于 2M+ 的 Bundle 来讲,字节码体积没有太大的区别。
  • 代码保护强度如何?
    • 目前来讲,尚未现成的工具可以对 v8 字节码进行反编译,所以该方案仍是仍是比较可靠且安全的。可是受限于字节码自己的原理,开发反编译工具的难度并不高,在未知的未来,字节码加固的方案普及以后,v8 字节码应该会像 Java/C# 那样可以被工具反编译,到时候咱们就应该继续探索其余代码保护方法。
    • 所以,咱们额外地经过 Node Addon 层对字节码进行了混淆,可以在字节码保护的基础上隐藏代码运行逻辑,不只增大了解包难度,还增大了代码篡改、二次分发的难度。

招聘硬广

咱们是字节跳动的互娱音乐前端团队,涉猎跨端、中后台、桌面端等主流前端技术领域,是一个技术氛围很是浓厚的前端团队,欢迎各路大佬加入:job.toutiao.com/s/eB5sw3x

相关文章
相关标签/搜索